@celestia-island/hikari 0.55.51 → 0.55.53
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/HkModal.stepflowswap.test.tsx +17 -16
- package/src/components/HkSettingsDialog.scss +169 -0
- package/src/components/HkSettingsDialog.test.tsx +249 -0
- package/src/components/HkSettingsDialog.tsx +237 -0
- package/src/composables/useSizeMorph.test.ts +133 -2
- package/src/composables/useSizeMorph.ts +59 -4
- package/src/index.ts +9 -0
package/package.json
CHANGED
|
@@ -338,21 +338,14 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
338
338
|
stubClip();
|
|
339
339
|
stubBox(500, Number.POSITIVE_INFINITY);
|
|
340
340
|
|
|
341
|
-
|
|
341
|
+
// The rider models the stepflow's ENTERING body: registered
|
|
342
|
+
// MID-CYCLE (never at arm time), so it stays on the per-swap
|
|
343
|
+
// promotion path — resident promotion is the chrome's alone.
|
|
344
|
+
let lateRegistry: ReturnType<typeof useSheetRide> = null;
|
|
342
345
|
const Rider = defineComponent({
|
|
343
346
|
setup() {
|
|
344
|
-
|
|
345
|
-
return () =>
|
|
346
|
-
h("div", {
|
|
347
|
-
class: "rider-el",
|
|
348
|
-
ref: (el: unknown) => {
|
|
349
|
-
const node = el as HTMLElement | null;
|
|
350
|
-
if (node && registry && !registered) {
|
|
351
|
-
registered = true;
|
|
352
|
-
registry.register({ el: node, counter: true });
|
|
353
|
-
}
|
|
354
|
-
},
|
|
355
|
-
}, "rider");
|
|
347
|
+
lateRegistry = useSheetRide();
|
|
348
|
+
return () => h("div", { class: "rider-el" }, "rider");
|
|
356
349
|
},
|
|
357
350
|
});
|
|
358
351
|
|
|
@@ -384,7 +377,9 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
384
377
|
const header = () => document.querySelector<HTMLElement>(".hk-modal-header")!;
|
|
385
378
|
const bodyBlock = () => document.querySelector<HTMLElement>(".hk-modal-body")!;
|
|
386
379
|
const riderEl = () => document.querySelector<HTMLElement>(".rider-el")!;
|
|
387
|
-
expect(
|
|
380
|
+
expect(lateRegistry).not.toBeNull();
|
|
381
|
+
// Register only AFTER the surface armed — the per-swap timing.
|
|
382
|
+
lateRegistry!.register({ el: riderEl(), counter: true });
|
|
388
383
|
|
|
389
384
|
// The sweep needs real frames from here on.
|
|
390
385
|
const frames: FrameRequestCallback[] = [];
|
|
@@ -422,13 +417,19 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
422
417
|
expect(riderEl().style.transform).toBe("translateY(0px)");
|
|
423
418
|
expect(header().style.transition).toMatch(/^transform 0\.3s /);
|
|
424
419
|
|
|
425
|
-
// The landing releases the rides with the frame
|
|
420
|
+
// The landing releases the rides with the frame — but the CHROME
|
|
421
|
+
// keeps its resident promotion (round 15: demoting at the settle
|
|
422
|
+
// put the phone-GPU raster race back at every landing = black
|
|
423
|
+
// blocks). Only the per-swap registration (the Rider div, not in
|
|
424
|
+
// the frame at arm time) releases its layer.
|
|
426
425
|
const ev = new Event("transitionend");
|
|
427
426
|
Object.defineProperty(ev, "propertyName", { value: "clip-path" });
|
|
428
427
|
frame().dispatchEvent(ev);
|
|
429
428
|
expect(header().style.transform).toBe("");
|
|
430
429
|
expect(bodyBlock().style.transform).toBe("");
|
|
431
430
|
expect(riderEl().style.transform).toBe("");
|
|
432
|
-
expect(header().style.willChange).toBe("");
|
|
431
|
+
expect(header().style.willChange).toBe("transform");
|
|
432
|
+
expect(bodyBlock().style.willChange).toBe("transform");
|
|
433
|
+
expect(riderEl().style.willChange).toBe("");
|
|
433
434
|
});
|
|
434
435
|
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// HkSettingsDialog — the sectioned settings shell (rail + pane) and its
|
|
2
|
+
// content vocabulary (group / sub / hint). Grammar extracted from the
|
|
3
|
+
// wowsp settings window (the pattern this component upstreams): values
|
|
4
|
+
// are the L2 scale tokens wowsp already used, so existing surfaces
|
|
5
|
+
// migrate without repainting.
|
|
6
|
+
//
|
|
7
|
+
// Scroll ownership: inside the dialog (host class `hk-settings-host`)
|
|
8
|
+
// the shell gets a STABLE height sized under the modal body scroller's
|
|
9
|
+
// inner padding, so HkModal's floating overlay scrollbar stays idle and
|
|
10
|
+
// the pane's own edge bar is the only scrollbar. The negative-margin
|
|
11
|
+
// trick puts that edge — and its scrollbar — flush with the modal
|
|
12
|
+
// window's right edge; the padding re-applies the content inset between
|
|
13
|
+
// the scrollbar and the cards. A page-embedded body (no host class)
|
|
14
|
+
// skips both: plain scroll container, host-managed layout.
|
|
15
|
+
|
|
16
|
+
.hk-settings {
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: stretch;
|
|
19
|
+
gap: var(--space-20);
|
|
20
|
+
|
|
21
|
+
/* ── section rail (mirrors the main sidebar nav look) ── */
|
|
22
|
+
&__rail {
|
|
23
|
+
flex: none;
|
|
24
|
+
width: 10.5rem;
|
|
25
|
+
display: flex;
|
|
26
|
+
flex-direction: column;
|
|
27
|
+
gap: var(--space-2);
|
|
28
|
+
padding: var(--space-4) var(--space-12) var(--space-4) 0;
|
|
29
|
+
border-right: 1px solid rgb(var(--color-border) / 15%);
|
|
30
|
+
overflow-y: auto;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
&__rail-item {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
gap: var(--space-10);
|
|
37
|
+
padding: var(--space-8) var(--space-12);
|
|
38
|
+
border: 0;
|
|
39
|
+
border-radius: var(--radius-sm);
|
|
40
|
+
background: transparent;
|
|
41
|
+
text-align: left;
|
|
42
|
+
color: rgb(var(--color-muted));
|
|
43
|
+
font-size: var(--text-sm);
|
|
44
|
+
font-family: inherit;
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
transition:
|
|
47
|
+
color var(--duration-short) var(--ease-standard),
|
|
48
|
+
background var(--duration-short) var(--ease-standard);
|
|
49
|
+
|
|
50
|
+
&:hover:not(:disabled) {
|
|
51
|
+
color: rgb(var(--color-text));
|
|
52
|
+
background: rgb(var(--color-primary) / 8%);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
&:focus-visible {
|
|
56
|
+
outline: 2px solid rgb(var(--color-primary));
|
|
57
|
+
outline-offset: 1px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
&:disabled {
|
|
61
|
+
opacity: 0.45;
|
|
62
|
+
cursor: not-allowed;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
&.is-active {
|
|
66
|
+
color: rgb(var(--color-primary));
|
|
67
|
+
background: rgb(var(--color-primary) / 12%);
|
|
68
|
+
font-weight: 500;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
&__rail-icon {
|
|
73
|
+
flex-shrink: 0;
|
|
74
|
+
display: flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
justify-content: center;
|
|
77
|
+
width: var(--space-20);
|
|
78
|
+
height: var(--space-20);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
&__rail-label {
|
|
82
|
+
min-width: 0;
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
text-overflow: ellipsis;
|
|
85
|
+
white-space: nowrap;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ── content pane: one section at a time, this is the scroller ── */
|
|
89
|
+
&__pane {
|
|
90
|
+
flex: 1;
|
|
91
|
+
min-width: 0;
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: column;
|
|
94
|
+
gap: var(--space-16);
|
|
95
|
+
overflow-y: auto;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ── Dialog host: stable frame + flush scrollbar edge ────────────────── */
|
|
100
|
+
|
|
101
|
+
/* Give the body inner a definite height so the shell's fit is computed
|
|
102
|
+
against the real scroll area (wowsp's host rule, verbatim). */
|
|
103
|
+
.hk-settings-host .hk-modal-body-inner {
|
|
104
|
+
height: 100%;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.hk-settings-host .hk-settings {
|
|
108
|
+
height: var(--hk-settings-height, calc(70vh - 6.5rem));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Cancel the modal body's fixed right padding so the pane's scrollbar
|
|
112
|
+
sits flush at the window edge; re-apply the inset between the bar and
|
|
113
|
+
the cards. HkModal retunes the body padding to 1rem under 768px —
|
|
114
|
+
follow it or the pane overshoots the edge. */
|
|
115
|
+
.hk-settings-host .hk-settings__pane {
|
|
116
|
+
margin-right: calc(-1 * var(--hk-modal-padding-body, 1.5rem));
|
|
117
|
+
padding-right: var(--space-28);
|
|
118
|
+
|
|
119
|
+
@media (max-width: 767px) {
|
|
120
|
+
margin-right: -1rem;
|
|
121
|
+
padding-right: var(--space-20);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ── Content vocabulary ───────────────────────────────────────────────── */
|
|
126
|
+
|
|
127
|
+
/* One titled card per settings area. Slightly translucent so the cards
|
|
128
|
+
sit quietly on image wallpapers. */
|
|
129
|
+
.hk-settings-group {
|
|
130
|
+
display: flex;
|
|
131
|
+
flex-direction: column;
|
|
132
|
+
gap: var(--space-12);
|
|
133
|
+
padding: var(--space-16) var(--space-20);
|
|
134
|
+
background: rgb(var(--color-surface) / 55%);
|
|
135
|
+
border: 1px solid rgb(var(--color-border) / 15%);
|
|
136
|
+
border-radius: var(--radius-md);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.hk-settings-group-title {
|
|
140
|
+
margin: 0;
|
|
141
|
+
font-size: var(--text-sm);
|
|
142
|
+
font-weight: 600;
|
|
143
|
+
color: rgb(var(--color-text));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* Titled sub-block inside a group. */
|
|
147
|
+
.hk-settings-sub {
|
|
148
|
+
display: flex;
|
|
149
|
+
flex-direction: column;
|
|
150
|
+
gap: var(--space-8);
|
|
151
|
+
margin-top: var(--space-4);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.hk-settings-sub-title {
|
|
155
|
+
margin: 0;
|
|
156
|
+
font-size: var(--text-xs);
|
|
157
|
+
font-weight: 600;
|
|
158
|
+
color: rgb(var(--color-muted));
|
|
159
|
+
text-transform: uppercase;
|
|
160
|
+
letter-spacing: 0.04em;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* The small muted explanation line under a control. */
|
|
164
|
+
.hk-settings-hint {
|
|
165
|
+
margin: 0;
|
|
166
|
+
font-size: var(--text-xs);
|
|
167
|
+
line-height: 1.5;
|
|
168
|
+
color: rgb(var(--color-muted));
|
|
169
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
4
|
+
import { Languages, Globe } from "lucide-vue-next";
|
|
5
|
+
import {
|
|
6
|
+
HkSettingsBody as HSettingsBody,
|
|
7
|
+
HkSettingsDialog as HSettingsDialog,
|
|
8
|
+
HkSettingsGroup as HSettingsGroup,
|
|
9
|
+
HkSettingsHint as HSettingsHint,
|
|
10
|
+
HkSettingsSub as HSettingsSub,
|
|
11
|
+
} from "./HkSettingsDialog";
|
|
12
|
+
|
|
13
|
+
function mount(component: ReturnType<typeof defineComponent>) {
|
|
14
|
+
const container = document.createElement("div");
|
|
15
|
+
document.body.appendChild(container);
|
|
16
|
+
const app = createApp(component);
|
|
17
|
+
app.mount(container);
|
|
18
|
+
return { container, unmount: () => { app.unmount(); container.remove(); } };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SECTIONS = [
|
|
22
|
+
{ key: "language", label: "Language", icon: Languages },
|
|
23
|
+
{ key: "network", label: "Network", icon: Globe },
|
|
24
|
+
{ key: "locked", label: "Locked", disabled: true },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const Host = defineComponent({
|
|
28
|
+
setup() {
|
|
29
|
+
const section = ref("language");
|
|
30
|
+
return () => (
|
|
31
|
+
<HSettingsBody sections={SECTIONS} v-model:section={section.value}
|
|
32
|
+
onUpdate:section={(v: string) => (section.value = v)}>
|
|
33
|
+
{{
|
|
34
|
+
language: () => <div data-test="pane-language">lang pane</div>,
|
|
35
|
+
network: () => <div data-test="pane-network">net pane</div>,
|
|
36
|
+
locked: () => <div data-test="pane-locked">locked pane</div>,
|
|
37
|
+
}}
|
|
38
|
+
</HSettingsBody>
|
|
39
|
+
);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("HSettingsBody", () => {
|
|
44
|
+
it("renders every rail entry and the first section's pane", () => {
|
|
45
|
+
const { container, unmount } = mount(Host);
|
|
46
|
+
const items = container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
|
|
47
|
+
expect(items).toHaveLength(3);
|
|
48
|
+
expect(items[0].textContent).toContain("Language");
|
|
49
|
+
expect(items[0].querySelector(".hk-settings__rail-icon svg")).toBeTruthy();
|
|
50
|
+
// First section active by default (internal state, unbound prop).
|
|
51
|
+
expect(items[0].classList.contains("is-active")).toBe(true);
|
|
52
|
+
expect(container.querySelector('[data-test="pane-language"]')).toBeTruthy();
|
|
53
|
+
expect(container.querySelector('[data-test="pane-network"]')).toBeNull();
|
|
54
|
+
unmount();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("switches the pane on rail click and reports aria-current", async () => {
|
|
58
|
+
const { container, unmount } = mount(Host);
|
|
59
|
+
const items = container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
|
|
60
|
+
items[1].click();
|
|
61
|
+
await nextTick();
|
|
62
|
+
expect(container.querySelector('[data-test="pane-network"]')).toBeTruthy();
|
|
63
|
+
expect(items[1].getAttribute("aria-current")).toBe("true");
|
|
64
|
+
expect(items[0].getAttribute("aria-current")).toBeNull();
|
|
65
|
+
unmount();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("never activates a disabled entry", async () => {
|
|
69
|
+
const { container, unmount } = mount(Host);
|
|
70
|
+
const items = container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
|
|
71
|
+
expect(items[2].disabled).toBe(true);
|
|
72
|
+
items[2].click();
|
|
73
|
+
await nextTick();
|
|
74
|
+
// Still on the first section.
|
|
75
|
+
expect(container.querySelector('[data-test="pane-language"]')).toBeTruthy();
|
|
76
|
+
expect(items[2].classList.contains("is-active")).toBe(false);
|
|
77
|
+
unmount();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("restarts the pane scroll on section switch", async () => {
|
|
81
|
+
const scrolled = ref(false);
|
|
82
|
+
const Probe = defineComponent({
|
|
83
|
+
setup() {
|
|
84
|
+
const section = ref("language");
|
|
85
|
+
return () => (
|
|
86
|
+
<HSettingsBody
|
|
87
|
+
sections={SECTIONS.slice(0, 2)}
|
|
88
|
+
section={section.value}
|
|
89
|
+
onUpdate:section={(v: string) => (section.value = v)}
|
|
90
|
+
>
|
|
91
|
+
{{
|
|
92
|
+
language: () => <div style="height: 2000px">lang</div>,
|
|
93
|
+
network: () => <div style="height: 2000px">net</div>,
|
|
94
|
+
}}
|
|
95
|
+
</HSettingsBody>
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const { container, unmount } = mount(Probe);
|
|
100
|
+
const pane = container.querySelector<HTMLElement>(".hk-settings__pane")!;
|
|
101
|
+
pane.scrollTop = 500;
|
|
102
|
+
const items = container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
|
|
103
|
+
items[1].click();
|
|
104
|
+
await nextTick();
|
|
105
|
+
await nextTick();
|
|
106
|
+
scrolled.value = pane.scrollTop === 0;
|
|
107
|
+
expect(scrolled.value).toBe(true);
|
|
108
|
+
unmount();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("honors an externally driven section change (deep-link landing)", async () => {
|
|
112
|
+
const section = ref("language");
|
|
113
|
+
const Deep = defineComponent({
|
|
114
|
+
setup() {
|
|
115
|
+
return () => (
|
|
116
|
+
<HSettingsBody sections={SECTIONS.slice(0, 2)} section={section.value}>
|
|
117
|
+
{{
|
|
118
|
+
language: () => <div data-test="pane-language" />,
|
|
119
|
+
network: () => <div data-test="pane-network" />,
|
|
120
|
+
}}
|
|
121
|
+
</HSettingsBody>
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const { container, unmount } = mount(Deep);
|
|
126
|
+
section.value = "network";
|
|
127
|
+
await nextTick();
|
|
128
|
+
expect(container.querySelector('[data-test="pane-network"]')).toBeTruthy();
|
|
129
|
+
unmount();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("HSettingsDialog", () => {
|
|
134
|
+
it("hosts the shell inside an open HModal and lands on the bound section", async () => {
|
|
135
|
+
const section = ref("network");
|
|
136
|
+
const Dialog = defineComponent({
|
|
137
|
+
setup() {
|
|
138
|
+
return () => (
|
|
139
|
+
<HSettingsDialog
|
|
140
|
+
modelValue
|
|
141
|
+
title="Settings"
|
|
142
|
+
sections={SECTIONS.slice(0, 2)}
|
|
143
|
+
section={section.value}
|
|
144
|
+
onUpdate:section={(v: string) => (section.value = v)}
|
|
145
|
+
>
|
|
146
|
+
{{
|
|
147
|
+
language: () => <div data-test="pane-language" />,
|
|
148
|
+
network: () => <div data-test="pane-network" />,
|
|
149
|
+
}}
|
|
150
|
+
</HSettingsDialog>
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
const { unmount } = mount(Dialog);
|
|
155
|
+
await nextTick();
|
|
156
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
157
|
+
// HkModal teleports to document.body — the shell lives there, not in
|
|
158
|
+
// the mount container.
|
|
159
|
+
const host = document.body.querySelector(".hk-settings-host");
|
|
160
|
+
expect(host).toBeTruthy();
|
|
161
|
+
expect(host!.querySelector(".hk-settings__rail")).toBeTruthy();
|
|
162
|
+
// The bound section won over the default-first.
|
|
163
|
+
expect(host!.querySelector('[data-test="pane-network"]')).toBeTruthy();
|
|
164
|
+
unmount();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("settings content vocabulary", () => {
|
|
169
|
+
it("renders group / sub / hint with their titles", () => {
|
|
170
|
+
const { container, unmount } = mount(
|
|
171
|
+
defineComponent({
|
|
172
|
+
setup() {
|
|
173
|
+
return () => (
|
|
174
|
+
<HSettingsGroup title="Network">
|
|
175
|
+
<HSettingsSub title="Proxy">
|
|
176
|
+
<HSettingsHint>Applies to every request.</HSettingsHint>
|
|
177
|
+
</HSettingsSub>
|
|
178
|
+
</HSettingsGroup>
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
expect(container.querySelector(".hk-settings-group-title")!.textContent).toBe("Network");
|
|
184
|
+
expect(container.querySelector(".hk-settings-sub-title")!.textContent).toBe("Proxy");
|
|
185
|
+
expect(container.querySelector(".hk-settings-hint")!.textContent)
|
|
186
|
+
.toContain("Applies to every request");
|
|
187
|
+
unmount();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("renders a group without a title (untitled card)", () => {
|
|
191
|
+
const { container, unmount } = mount(
|
|
192
|
+
defineComponent({
|
|
193
|
+
setup() {
|
|
194
|
+
return () => h(HSettingsGroup, null, { default: () => "body" });
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
expect(container.querySelector(".hk-settings-group-title")).toBeNull();
|
|
199
|
+
unmount();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("settings dialog SCSS host contract (source-level)", () => {
|
|
204
|
+
// The stable-frame contract is three rules in the SCSS that happy-dom's
|
|
205
|
+
// cascade-free environment cannot exercise behaviorally — pin them at
|
|
206
|
+
// the source so a silent deletion turns this red (the R1 mutation-d
|
|
207
|
+
// gap). Known-positive control: the base rail rule every variant
|
|
208
|
+
// depends on.
|
|
209
|
+
// vitest serves modules through its own scheme, so import.meta.url is
|
|
210
|
+
// not a file URL here; the suite's cwd is packages/vue.
|
|
211
|
+
const scss = readFileSync("src/components/HkSettingsDialog.scss", "utf8");
|
|
212
|
+
|
|
213
|
+
it("self-check: the extractor sees a rule that exists", () => {
|
|
214
|
+
// The shell rules are BEM-nested in the source (&__rail-item inside
|
|
215
|
+
// .hk-settings) — the positive control matches the nested form.
|
|
216
|
+
expect(scss).toContain("&__rail-item {");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("pins the stable-frame host rules (idle modal scrollbar, flush edge)", () => {
|
|
220
|
+
expect(scss).toContain(".hk-settings-host .hk-modal-body-inner {");
|
|
221
|
+
expect(scss).toContain("height: var(--hk-settings-height, calc(70vh - 6.5rem));");
|
|
222
|
+
expect(scss).toContain("margin-right: calc(-1 * var(--hk-modal-padding-body, 1.5rem));");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("never activates a disabled section through a deep-linked prop", async () => {
|
|
226
|
+
const Deep = defineComponent({
|
|
227
|
+
setup() {
|
|
228
|
+
return () => (
|
|
229
|
+
<HSettingsBody sections={SECTIONS} section="locked">
|
|
230
|
+
{{
|
|
231
|
+
language: () => <div data-test="pane-language" />,
|
|
232
|
+
network: () => <div data-test="pane-network" />,
|
|
233
|
+
locked: () => <div data-test="pane-locked" />,
|
|
234
|
+
}}
|
|
235
|
+
</HSettingsBody>
|
|
236
|
+
);
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
const { container, unmount } = mount(Deep);
|
|
240
|
+
await nextTick();
|
|
241
|
+
// The disabled key did not win: first usable section shows, the
|
|
242
|
+
// locked pane never renders, its rail entry stays grey.
|
|
243
|
+
expect(container.querySelector('[data-test="pane-language"]')).toBeTruthy();
|
|
244
|
+
expect(container.querySelector('[data-test="pane-locked"]')).toBeNull();
|
|
245
|
+
const items = container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
|
|
246
|
+
expect(items[2].classList.contains("is-active")).toBe(false);
|
|
247
|
+
unmount();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineComponent,
|
|
3
|
+
h,
|
|
4
|
+
nextTick,
|
|
5
|
+
ref,
|
|
6
|
+
watch,
|
|
7
|
+
type Component,
|
|
8
|
+
type PropType,
|
|
9
|
+
} from "vue";
|
|
10
|
+
|
|
11
|
+
import HModal from "./HkModal";
|
|
12
|
+
import "./HkSettingsDialog.scss";
|
|
13
|
+
|
|
14
|
+
/** One rail entry of the settings dialog. */
|
|
15
|
+
export interface HkSettingsSection {
|
|
16
|
+
/** Identity of the section — also the name of the slot that renders its
|
|
17
|
+
* content pane (`{ [key]: () => … }`). */
|
|
18
|
+
key: string;
|
|
19
|
+
/** Rail label. */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Leading rail icon (a lucide-style component; rendered at 16px). */
|
|
22
|
+
icon?: Component;
|
|
23
|
+
/** A disabled entry renders but cannot be activated. */
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* HkSettingsBody — the sectioned settings shell: a left rail of icon+label
|
|
29
|
+
* nav entries and a right scrollable pane rendering the active section's
|
|
30
|
+
* slot. Extracted from the pattern wowsp, the flasher and the admin
|
|
31
|
+
* consoles each hand-rolled (the "classic settings window" anatomy).
|
|
32
|
+
*
|
|
33
|
+
* Standalone by design: the BODY can be embedded in a page (the phone
|
|
34
|
+
* surface pattern — wowsp renders it inside its own /settings route) or
|
|
35
|
+
* inside {@link HSettingsDialog}, which wraps it in an HModal window.
|
|
36
|
+
* Section identity is a `v-model:section` so openers can land on a
|
|
37
|
+
* specific section; leaving it unbound keeps the state internal
|
|
38
|
+
* (first section active).
|
|
39
|
+
*
|
|
40
|
+
* Scroll ownership: the pane is the only scroller — switching sections
|
|
41
|
+
* restarts it from the top (arriving on a half-scrolled pane reads as a
|
|
42
|
+
* broken page). Inside the dialog the modal body's own scroller is made
|
|
43
|
+
* mathematically idle by the host height rule (see the SCSS); on a page
|
|
44
|
+
* the host manages its own layout.
|
|
45
|
+
*/
|
|
46
|
+
export const HkSettingsBody = defineComponent({
|
|
47
|
+
name: "HkSettingsBody",
|
|
48
|
+
props: {
|
|
49
|
+
/** Rail entries, in rail order. */
|
|
50
|
+
sections: { type: Array as PropType<HkSettingsSection[]>, required: true },
|
|
51
|
+
/** Active section key (v-model:section). Unbound = internal state. */
|
|
52
|
+
section: { type: String, default: undefined },
|
|
53
|
+
/** Accessible name for the rail navigation. */
|
|
54
|
+
navLabel: { type: String, default: undefined },
|
|
55
|
+
},
|
|
56
|
+
emits: {
|
|
57
|
+
"update:section": (key: string) => typeof key === "string",
|
|
58
|
+
},
|
|
59
|
+
setup(props, { emit, slots }) {
|
|
60
|
+
const internal = ref(props.sections[0]?.key ?? "");
|
|
61
|
+
// Controlled while a section prop is bound: external changes flow in,
|
|
62
|
+
// clicks flow out. Unbound: the internal ref alone decides. Immediate
|
|
63
|
+
// so an opener that mounts ALREADY on a deep-linked section
|
|
64
|
+
// (section="network" from the first render) wins over the first-entry
|
|
65
|
+
// default — a non-immediate watch would never fire for it.
|
|
66
|
+
watch(
|
|
67
|
+
() => props.section,
|
|
68
|
+
(key) => {
|
|
69
|
+
if (key == null) return;
|
|
70
|
+
// A disabled (or unknown) key never activates — a host that
|
|
71
|
+
// deep-links to one keeps the first usable section instead
|
|
72
|
+
// (R1 gap: the locked pane used to render with its rail entry
|
|
73
|
+
// greyed out, contradicting "renders but cannot be activated").
|
|
74
|
+
if (props.sections.some((sec) => sec.key === key && !sec.disabled)) {
|
|
75
|
+
internal.value = key;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
{ immediate: true },
|
|
79
|
+
);
|
|
80
|
+
const active = () =>
|
|
81
|
+
props.sections.some((s) => s.key === internal.value)
|
|
82
|
+
? internal.value
|
|
83
|
+
: (props.sections[0]?.key ?? "");
|
|
84
|
+
|
|
85
|
+
const paneRef = ref<HTMLElement | null>(null);
|
|
86
|
+
// Section switches restart the pane from the top.
|
|
87
|
+
watch(active, () => {
|
|
88
|
+
void nextTick(() => {
|
|
89
|
+
if (paneRef.value) paneRef.value.scrollTop = 0;
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function pick(key: string) {
|
|
94
|
+
const entry = props.sections.find((s) => s.key === key);
|
|
95
|
+
if (!entry || entry.disabled) return;
|
|
96
|
+
internal.value = key;
|
|
97
|
+
emit("update:section", key);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return () => (
|
|
101
|
+
<div class="hk-settings">
|
|
102
|
+
<nav class="hk-settings__rail" aria-label={props.navLabel}>
|
|
103
|
+
{props.sections.map((s) => {
|
|
104
|
+
const Icon = s.icon;
|
|
105
|
+
const on = active() === s.key && !s.disabled;
|
|
106
|
+
return (
|
|
107
|
+
<button
|
|
108
|
+
key={s.key}
|
|
109
|
+
type="button"
|
|
110
|
+
class={["hk-settings__rail-item", on ? "is-active" : ""]}
|
|
111
|
+
aria-current={on ? "true" : undefined}
|
|
112
|
+
aria-disabled={s.disabled || undefined}
|
|
113
|
+
disabled={s.disabled}
|
|
114
|
+
onClick={() => pick(s.key)}
|
|
115
|
+
>
|
|
116
|
+
{typeof Icon === "function" ? (
|
|
117
|
+
<span class="hk-settings__rail-icon" aria-hidden="true">
|
|
118
|
+
<Icon size={16} />
|
|
119
|
+
</span>
|
|
120
|
+
) : null}
|
|
121
|
+
<span class="hk-settings__rail-label">{s.label}</span>
|
|
122
|
+
</button>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</nav>
|
|
126
|
+
<div class="hk-settings__pane" ref={paneRef}>
|
|
127
|
+
{slots[active()]?.()}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* HkSettingsDialog — the settings window: an HModal carrying the
|
|
136
|
+
* {@link HSettingsBody} shell with a stable frame height, so the modal
|
|
137
|
+
* body's floating scrollbar stays idle and the pane's edge bar is the
|
|
138
|
+
* only scrollbar (the wowsp-proven contract). Width defaults to the
|
|
139
|
+
* settings-standard 58rem; `height` styles the shell (the 70vh default
|
|
140
|
+
* fits a modal with title and paddings).
|
|
141
|
+
*
|
|
142
|
+
* Like every HkModal composite it bottom-sheets under 768px; hosts that
|
|
143
|
+
* want a phone page instead render HSettingsBody in their own route and
|
|
144
|
+
* simply never mount this dialog there.
|
|
145
|
+
*/
|
|
146
|
+
export const HkSettingsDialog = defineComponent({
|
|
147
|
+
name: "HkSettingsDialog",
|
|
148
|
+
props: {
|
|
149
|
+
modelValue: { type: Boolean, default: false },
|
|
150
|
+
title: { type: String, default: "" },
|
|
151
|
+
/** Modal max-width (CSS value or HkModal preset). */
|
|
152
|
+
width: { type: String, default: "58rem" },
|
|
153
|
+
/** Rail entries — passed through to the body. */
|
|
154
|
+
sections: { type: Array as PropType<HkSettingsSection[]>, required: true },
|
|
155
|
+
/** Active section key (v-model:section). */
|
|
156
|
+
section: { type: String, default: undefined },
|
|
157
|
+
/** Accessible name for the rail navigation. */
|
|
158
|
+
navLabel: { type: String, default: undefined },
|
|
159
|
+
/** Shell height — the stable frame the pane scrolls inside. */
|
|
160
|
+
height: { type: String, default: "calc(70vh - 6.5rem)" },
|
|
161
|
+
},
|
|
162
|
+
emits: {
|
|
163
|
+
"update:modelValue": (v: boolean) => typeof v === "boolean",
|
|
164
|
+
"update:section": (key: string) => typeof key === "string",
|
|
165
|
+
},
|
|
166
|
+
setup(props, { emit, slots }) {
|
|
167
|
+
return () => (
|
|
168
|
+
<HModal
|
|
169
|
+
modelValue={props.modelValue}
|
|
170
|
+
onUpdate:modelValue={(v: boolean) => emit("update:modelValue", v)}
|
|
171
|
+
title={props.title}
|
|
172
|
+
width={props.width}
|
|
173
|
+
contentClass="hk-settings-host"
|
|
174
|
+
>
|
|
175
|
+
{/* The per-section slots flow through h()'s third argument — the
|
|
176
|
+
canonical slots channel. JSX children would wrap the slots
|
|
177
|
+
object into a vnode child and the keys would never reach the
|
|
178
|
+
body (caught by the dialog mount test). */}
|
|
179
|
+
{h(
|
|
180
|
+
HkSettingsBody,
|
|
181
|
+
{
|
|
182
|
+
sections: props.sections,
|
|
183
|
+
section: props.section,
|
|
184
|
+
navLabel: props.navLabel,
|
|
185
|
+
"onUpdate:section": (key: string) => emit("update:section", key),
|
|
186
|
+
style: { "--hk-settings-height": props.height },
|
|
187
|
+
},
|
|
188
|
+
slots,
|
|
189
|
+
)}
|
|
190
|
+
</HModal>
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* HSettingsGroup — one titled card in the content pane: the standard
|
|
197
|
+
* container of a section's controls. Slightly translucent so cards sit
|
|
198
|
+
* quietly on image wallpapers.
|
|
199
|
+
*/
|
|
200
|
+
export const HkSettingsGroup = defineComponent({
|
|
201
|
+
name: "HkSettingsGroup",
|
|
202
|
+
props: {
|
|
203
|
+
title: { type: String, default: undefined },
|
|
204
|
+
},
|
|
205
|
+
setup(props, { slots }) {
|
|
206
|
+
return () => (
|
|
207
|
+
<section class="hk-settings-group">
|
|
208
|
+
{props.title ? <h3 class="hk-settings-group-title">{props.title}</h3> : null}
|
|
209
|
+
{slots.default?.()}
|
|
210
|
+
</section>
|
|
211
|
+
);
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/** HSettingsSub — a titled sub-block inside a group. */
|
|
216
|
+
export const HkSettingsSub = defineComponent({
|
|
217
|
+
name: "HkSettingsSub",
|
|
218
|
+
props: {
|
|
219
|
+
title: { type: String, default: undefined },
|
|
220
|
+
},
|
|
221
|
+
setup(props, { slots }) {
|
|
222
|
+
return () => (
|
|
223
|
+
<div class="hk-settings-sub">
|
|
224
|
+
{props.title ? <h4 class="hk-settings-sub-title">{props.title}</h4> : null}
|
|
225
|
+
{slots.default?.()}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/** HSettingsHint — the small muted explanation line under a control. */
|
|
232
|
+
export const HkSettingsHint = defineComponent({
|
|
233
|
+
name: "HkSettingsHint",
|
|
234
|
+
setup(_props, { slots }) {
|
|
235
|
+
return () => <p class="hk-settings-hint">{slots.default?.()}</p>;
|
|
236
|
+
},
|
|
237
|
+
});
|
|
@@ -200,6 +200,35 @@ describe("useSizeMorph", () => {
|
|
|
200
200
|
expect(h.frame.style.transition).toBe("");
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
it("keeps the resident rider layers through a contamination-guard release", async () => {
|
|
204
|
+
// Round 15 rig finding: the guard's mid-flight release() used to
|
|
205
|
+
// call clearResidentWill(), tearing the chrome promotions down at
|
|
206
|
+
// the swap frame on capped sheets — every later sweep of the same
|
|
207
|
+
// open cycle then promoted at STAGE again (the black-block race).
|
|
208
|
+
// The pin drops (measurement hygiene) but the cycle's layers stay.
|
|
209
|
+
const rider = document.createElement("div");
|
|
210
|
+
const h = mountHarness(600, 560, {
|
|
211
|
+
collectRide: () => [{ el: rider }],
|
|
212
|
+
});
|
|
213
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
214
|
+
h.content.appendChild(rider);
|
|
215
|
+
h.start();
|
|
216
|
+
expect(rider.style.willChange).toBe("transform");
|
|
217
|
+
|
|
218
|
+
h.setNatural(1728);
|
|
219
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
220
|
+
await settle();
|
|
221
|
+
// The pin dropped…
|
|
222
|
+
expect(h.frame.style.height).toBe("");
|
|
223
|
+
// …but the resident promotion survived the guard, and only the full
|
|
224
|
+
// stop unwinds it.
|
|
225
|
+
expect(rider.style.willChange).toBe("transform");
|
|
226
|
+
expect(h.frame.style.willChange).toBe("clip-path");
|
|
227
|
+
h.stop();
|
|
228
|
+
expect(rider.style.willChange).toBe("");
|
|
229
|
+
expect(h.frame.style.willChange).toBe("");
|
|
230
|
+
});
|
|
231
|
+
|
|
203
232
|
it("recovers and pins again once a clean measurement returns", async () => {
|
|
204
233
|
const h = mountHarness(600, 560);
|
|
205
234
|
h.start();
|
|
@@ -874,14 +903,15 @@ describe("useSizeMorph fold riders", () => {
|
|
|
874
903
|
h.setNatural(360);
|
|
875
904
|
h.remeasure();
|
|
876
905
|
await busFrames(2);
|
|
877
|
-
|
|
906
|
+
// One collection at ARM (resident promotion) + one at the stage.
|
|
907
|
+
expect(collected).toBe(2);
|
|
878
908
|
// A second growth mid-sweep: the old ride is cleared (atomically
|
|
879
909
|
// with the interrupted sweep's landing) and re-collected for the new
|
|
880
910
|
// dance's own stage.
|
|
881
911
|
h.setNatural(420);
|
|
882
912
|
h.remeasure();
|
|
883
913
|
expect(rider.style.transform).toBe("translateY(60px)");
|
|
884
|
-
expect(collected).toBe(
|
|
914
|
+
expect(collected).toBe(3);
|
|
885
915
|
// The successor sweep runs and lands on its own terms.
|
|
886
916
|
await busFrames(2);
|
|
887
917
|
expect(rider.style.transform).toBe("translateY(0px)");
|
|
@@ -1017,3 +1047,104 @@ describe("useSizeMorph fold rider settle handoff", () => {
|
|
|
1017
1047
|
h.stop();
|
|
1018
1048
|
});
|
|
1019
1049
|
});
|
|
1050
|
+
|
|
1051
|
+
// ── Resident rider promotion (round 15, 2026-09-23 black-block report) ──
|
|
1052
|
+
// The riders' will-change initially landed at STAGE time: three fresh
|
|
1053
|
+
// layer promotions inside the frame's re-raster task, with only the
|
|
1054
|
+
// two-frame warmup to absorb them — on the phone GPU the body block
|
|
1055
|
+
// composited as black tiles through the early sweep (the same raster
|
|
1056
|
+
// race the frame's resident promotion had fixed). The host chrome now
|
|
1057
|
+
// promotes ONCE at arm time and keeps its layers for the whole cycle.
|
|
1058
|
+
|
|
1059
|
+
describe("useSizeMorph resident rider promotion", () => {
|
|
1060
|
+
it("promotes in-frame riders at arm time and keeps them through settles", async () => {
|
|
1061
|
+
const rider = document.createElement("div");
|
|
1062
|
+
const h = mountHarness(300, 300, {
|
|
1063
|
+
collectRide: () => [{ el: rider }],
|
|
1064
|
+
});
|
|
1065
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
1066
|
+
h.content.appendChild(rider);
|
|
1067
|
+
// Before arming there is no promotion.
|
|
1068
|
+
expect(rider.style.willChange).toBe("");
|
|
1069
|
+
h.start();
|
|
1070
|
+
// Armed: the rider (inside the frame subtree) is promoted once,
|
|
1071
|
+
// together with the frame itself.
|
|
1072
|
+
expect(rider.style.willChange).toBe("transform");
|
|
1073
|
+
expect(h.frame.style.willChange).toBe("clip-path");
|
|
1074
|
+
|
|
1075
|
+
// A full sweep: stage → flip → settle. The rider rides, and the
|
|
1076
|
+
// settle must NOT demote it — the landing crossing a layer boundary
|
|
1077
|
+
// is the raster race that read as black blocks on the phone.
|
|
1078
|
+
h.setNatural(360);
|
|
1079
|
+
h.remeasure();
|
|
1080
|
+
expect(rider.style.transform).toBe("translateY(60px)");
|
|
1081
|
+
await busFrames(2);
|
|
1082
|
+
expect(rider.style.transform).toBe("translateY(0px)");
|
|
1083
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
1084
|
+
expect(rider.style.transform).toBe("");
|
|
1085
|
+
expect(rider.style.willChange).toBe("transform");
|
|
1086
|
+
|
|
1087
|
+
// The whole open cycle's layerization unwinds at stop/hold.
|
|
1088
|
+
h.stop();
|
|
1089
|
+
expect(rider.style.willChange).toBe("");
|
|
1090
|
+
expect(h.frame.style.willChange).toBe("");
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it("leaves detached (per-swap) riders on the stage-time promotion path", async () => {
|
|
1094
|
+
// A rider NOT in the frame subtree at arm time — the stepflow's
|
|
1095
|
+
// entering body, registered mid-swap — never becomes resident: its
|
|
1096
|
+
// promotion still lands at stage and still releases at the settle.
|
|
1097
|
+
const resident = document.createElement("div");
|
|
1098
|
+
let perSwap: HTMLElement | null = null;
|
|
1099
|
+
const h = mountHarness(300, 300, {
|
|
1100
|
+
collectRide: () => {
|
|
1101
|
+
const out: Array<{ el: HTMLElement }> = [{ el: resident }];
|
|
1102
|
+
if (perSwap) out.push({ el: perSwap });
|
|
1103
|
+
return out;
|
|
1104
|
+
},
|
|
1105
|
+
});
|
|
1106
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
1107
|
+
h.content.appendChild(resident);
|
|
1108
|
+
h.start();
|
|
1109
|
+
expect(resident.style.willChange).toBe("transform");
|
|
1110
|
+
|
|
1111
|
+
perSwap = document.createElement("div"); // registered mid-flight, detached
|
|
1112
|
+
h.setNatural(360);
|
|
1113
|
+
h.remeasure();
|
|
1114
|
+
expect(perSwap.style.willChange).toBe("transform");
|
|
1115
|
+
await busFrames(2);
|
|
1116
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
1117
|
+
expect(perSwap.style.willChange).toBe("");
|
|
1118
|
+
expect(resident.style.willChange).toBe("transform");
|
|
1119
|
+
h.stop();
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("unwinds the resident rider layers on unmount", async () => {
|
|
1123
|
+
// The unmount teardown owns its own clearResidentWill (it must not
|
|
1124
|
+
// rely on hold() — an open surface torn down mid-cycle leaves no
|
|
1125
|
+
// other path). Witness: the promoted layer flag outlives the app.
|
|
1126
|
+
const rider = document.createElement("div");
|
|
1127
|
+
const h = mountHarness(300, 300, {
|
|
1128
|
+
collectRide: () => [{ el: rider }],
|
|
1129
|
+
});
|
|
1130
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
1131
|
+
h.content.appendChild(rider);
|
|
1132
|
+
h.start();
|
|
1133
|
+
expect(rider.style.willChange).toBe("transform");
|
|
1134
|
+
const mount = mounts.splice(-1)[0]!;
|
|
1135
|
+
mount.app.unmount();
|
|
1136
|
+
expect(rider.style.willChange).toBe("");
|
|
1137
|
+
expect(h.frame.style.willChange).toBe("");
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
it("does not promote riders when the surface is not clip-mode", () => {
|
|
1141
|
+
const rider = document.createElement("div");
|
|
1142
|
+
const h = mountHarness(300, 300, {
|
|
1143
|
+
collectRide: () => [{ el: rider }],
|
|
1144
|
+
});
|
|
1145
|
+
h.content.appendChild(rider);
|
|
1146
|
+
h.start();
|
|
1147
|
+
expect(rider.style.willChange).toBe("");
|
|
1148
|
+
h.stop();
|
|
1149
|
+
});
|
|
1150
|
+
});
|
|
@@ -235,6 +235,22 @@ export function useSizeMorph(
|
|
|
235
235
|
* at open and KEEPING it means step morphs never cross a layer
|
|
236
236
|
* boundary at all. */
|
|
237
237
|
let residentWill = false;
|
|
238
|
+
/** Resident RIDER promotions (2026-09-23 round 15, the black-block
|
|
239
|
+
* regression report): the fold riders' own will-change initially
|
|
240
|
+
* landed at STAGE time — three fresh layer promotions (header,
|
|
241
|
+
* subheader, body block) inside the same task as the frame's
|
|
242
|
+
* re-raster, with only the two-frame warmup to absorb them. On the
|
|
243
|
+
* phone GPU the promoted-but-unrastered body block composited as
|
|
244
|
+
* black tiles through the early sweep — the same race the frame's
|
|
245
|
+
* resident promotion had fixed, re-entered from the side. The host
|
|
246
|
+
* chrome riders (whatever collectRide returns at ARM time, when no
|
|
247
|
+
* swap is registered) now promote ONCE at open and keep their layers
|
|
248
|
+
* for the whole cycle; a sweep's settle no longer demotes them, so
|
|
249
|
+
* stage and landing cross no layer boundaries at all. Per-swap
|
|
250
|
+
* riders (the stepflow's entering body — registered mid-flight,
|
|
251
|
+
* small, and opacity-composited by its own fade anyway) keep the
|
|
252
|
+
* stage-time promotion. */
|
|
253
|
+
let residentRideEls: HTMLElement[] = [];
|
|
238
254
|
/** Identity of the sweep currently staged/folding, echoed to consumers. */
|
|
239
255
|
let sweepSeq = 0;
|
|
240
256
|
let activeSweep = 0;
|
|
@@ -336,7 +352,12 @@ export function useSizeMorph(
|
|
|
336
352
|
// instant; opacity (still listed with its own clock) keeps running.
|
|
337
353
|
r.el.style.transition = r.ownTransition || "none";
|
|
338
354
|
r.el.style.transform = "";
|
|
339
|
-
|
|
355
|
+
// Resident-promoted riders (the host chrome, promoted at arm time)
|
|
356
|
+
// KEEP their layers through the settle — demoting here would put
|
|
357
|
+
// the raster race back at every landing (round 15 black blocks).
|
|
358
|
+
if (!residentRideEls.includes(r.el)) {
|
|
359
|
+
r.el.style.willChange = "";
|
|
360
|
+
}
|
|
340
361
|
}
|
|
341
362
|
void frame.value?.offsetHeight;
|
|
342
363
|
for (const r of rideEls) {
|
|
@@ -423,8 +444,15 @@ export function useSizeMorph(
|
|
|
423
444
|
}
|
|
424
445
|
}
|
|
425
446
|
|
|
426
|
-
/** Drop the resident layer promotion (hold / release paths)
|
|
447
|
+
/** Drop the resident layer promotion (hold / release paths) — the
|
|
448
|
+
* frame's own and the resident riders' together: the whole open
|
|
449
|
+
* cycle's layerization unwinds at once, inside the close
|
|
450
|
+
* choreography's own leave window. */
|
|
427
451
|
function clearResidentWill(): void {
|
|
452
|
+
for (const el of residentRideEls) {
|
|
453
|
+
el.style.willChange = "";
|
|
454
|
+
}
|
|
455
|
+
residentRideEls = [];
|
|
428
456
|
if (!residentWill) return;
|
|
429
457
|
residentWill = false;
|
|
430
458
|
const f = frame.value;
|
|
@@ -551,7 +579,15 @@ export function useSizeMorph(
|
|
|
551
579
|
const f = frame.value;
|
|
552
580
|
if (f) f.style.height = "";
|
|
553
581
|
stopReveal();
|
|
554
|
-
clearResidentWill()
|
|
582
|
+
// Deliberately NOT clearResidentWill(): release() runs both on the
|
|
583
|
+
// full-stop path AND mid-flight from the contamination guard, and
|
|
584
|
+
// the guard's pin drop is a measurement-hygiene event, not a cycle
|
|
585
|
+
// end — dropping the resident layers there re-armed the stage-time
|
|
586
|
+
// raster race for every later sweep of the same open cycle (round
|
|
587
|
+
// 15 rig finding, confirmed by the adversarial round: the capped
|
|
588
|
+
// case tore header/body/frame promotions down at the swap frame).
|
|
589
|
+
// The armed stop path clears them via hold(); a never-armed stop
|
|
590
|
+
// has nothing to clear; unmount owns its own teardown.
|
|
555
591
|
pinned = 0;
|
|
556
592
|
}
|
|
557
593
|
|
|
@@ -799,11 +835,30 @@ export function useSizeMorph(
|
|
|
799
835
|
// Resident promotion on clip-mode surfaces (see residentWill): the
|
|
800
836
|
// layer crosses no boundary during later step morphs. Applied here,
|
|
801
837
|
// at the open edge — one promotion per open cycle instead of one
|
|
802
|
-
// per resize.
|
|
838
|
+
// per resize. The HOST CHROME RIDERS promote here too (see
|
|
839
|
+
// residentRideEls): collectRide at arm time returns exactly the
|
|
840
|
+
// chrome (no swap is registered mid-open), and their layers then
|
|
841
|
+
// live for the whole cycle — a sweep's stage promotes nothing but
|
|
842
|
+
// the small per-swap riders, so the phone GPU never rasterizes a
|
|
843
|
+
// big layer inside the morph window it cannot finish in time.
|
|
803
844
|
const f0 = frame.value;
|
|
804
845
|
if (f0 && clipMode(f0)) {
|
|
805
846
|
f0.style.willChange = "clip-path";
|
|
806
847
|
residentWill = true;
|
|
848
|
+
if (options.collectRide) {
|
|
849
|
+
residentRideEls = options.collectRide()
|
|
850
|
+
.map((entry) => entry.el)
|
|
851
|
+
.filter((el) => {
|
|
852
|
+
try {
|
|
853
|
+
return frame.value?.contains(el) ?? false;
|
|
854
|
+
} catch {
|
|
855
|
+
return false;
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
for (const el of residentRideEls) {
|
|
859
|
+
el.style.willChange = "transform";
|
|
860
|
+
}
|
|
861
|
+
}
|
|
807
862
|
}
|
|
808
863
|
if (typeof ResizeObserver === "undefined" || !content.value) {
|
|
809
864
|
remeasure();
|
package/src/index.ts
CHANGED
|
@@ -435,6 +435,15 @@ export {
|
|
|
435
435
|
type HAboutLinkIcon,
|
|
436
436
|
} from "./components/HkAboutModal";
|
|
437
437
|
export { HkLogWindow as HLogWindow, type HLogTab } from "./components/HkLogWindow";
|
|
438
|
+
export {
|
|
439
|
+
HkSettingsBody as HSettingsBody,
|
|
440
|
+
HkSettingsDialog as HSettingsDialog,
|
|
441
|
+
HkSettingsGroup as HSettingsGroup,
|
|
442
|
+
HkSettingsSub as HSettingsSub,
|
|
443
|
+
HkSettingsHint as HSettingsHint,
|
|
444
|
+
type HkSettingsSection,
|
|
445
|
+
} from "./components/HkSettingsDialog";
|
|
446
|
+
|
|
438
447
|
export { HkCookieConsent as HCookieConsent } from "./components/HkCookieConsent";
|
|
439
448
|
export {
|
|
440
449
|
HkAttachmentModal as HAttachmentModal,
|