@celestia-island/hikari 0.55.275 → 0.55.277
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/HkButton.test.tsx +5 -2
- package/src/components/HkDraggableList.scss +26 -0
- package/src/components/HkDraggableList.styles.test.ts +63 -0
- package/src/components/HkDraggableList.test.tsx +190 -0
- package/src/components/HkDraggableList.tsx +38 -10
- package/src/components/HkJsonTree.contract.test.ts +3 -1
- package/src/components/HkModal.open-close-scale.test.ts +150 -0
- package/src/components/HkModal.scss +30 -48
- package/src/components/HkModal.tsx +1 -1
- package/src/components/HkSettingsDialog.test.tsx +5 -2
- package/src/components/HkSvgVarPresentation.test.tsx +18 -8
- package/src/components/HkWallpaperBackdrop.styles.contract.test.ts +4 -1
- package/src/runtime/index.ts +7 -0
- package/src/test-utils/vueJsxSsrRegisterHelper.ts +41 -0
- package/src/components/HkModal.close-collapse.test.ts +0 -79
package/package.json
CHANGED
|
@@ -201,7 +201,9 @@ describe("HkButton icon-only square contract", () => {
|
|
|
201
201
|
// over the square width. Textual assertions because happy-dom does not
|
|
202
202
|
// lay out.
|
|
203
203
|
describe("HkButton icon-only SCSS contract", () => {
|
|
204
|
-
|
|
204
|
+
// autocrlf checkouts write CRLF to disk; normalize so the LF-joined
|
|
205
|
+
// selector anchor below matches what git stores regardless of platform.
|
|
206
|
+
const scss = readFileSync(join(here, "HkButton.scss"), "utf-8").replace(/\r\n/g, "\n");
|
|
205
207
|
|
|
206
208
|
it("self-declares the collapsed square geometry", () => {
|
|
207
209
|
const rule = scss.match(/\.hk-btn-icon-only\s*{[^}]*}/)?.[0] ?? "";
|
|
@@ -310,7 +312,8 @@ describe("HkButton label prop + responsive collapse contract", () => {
|
|
|
310
312
|
// useBreakpoint px scale (sm 640 / md 768 / lg 1024 / xl 1280), and must
|
|
311
313
|
// land on the exact icon-only square width of its size.
|
|
312
314
|
describe("HkButton label-collapse SCSS contract", () => {
|
|
313
|
-
|
|
315
|
+
// Normalized to LF (autocrlf checkouts) — see the icon-only contract above.
|
|
316
|
+
const scss = readFileSync(join(here, "HkButton.scss"), "utf-8").replace(/\r\n/g, "\n");
|
|
314
317
|
|
|
315
318
|
const breakpoints: Array<[string, string]> = [
|
|
316
319
|
["sm", "639.98px"],
|
|
@@ -53,6 +53,32 @@
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/* The horizontal axis: the SAME list laid out as ONE never-wrapping row
|
|
57
|
+
* (wowsp's overlay-columns editor is the first host). Only geometry
|
|
58
|
+
* changes — the drop line moves to the leading/trailing edge (the
|
|
59
|
+
* horizontal twin of the top/bottom inset above; the descendant rules
|
|
60
|
+
* below out-specify the vertical box-shadow, so they REPLACE it rather
|
|
61
|
+
* than stack onto it), and min-width: 0 lets an over-full row shrink
|
|
62
|
+
* its items so the single line always holds (the content lane keeps its
|
|
63
|
+
* own min-width, so ellipsis chains keep working inside them). The
|
|
64
|
+
* reserved handle lane stays the row's leading column exactly as it is
|
|
65
|
+
* vertically, and the actions lane stays the row's trailing edge. */
|
|
66
|
+
.hk-draggable-list[data-axis="horizontal"] {
|
|
67
|
+
flex-direction: row;
|
|
68
|
+
flex-wrap: nowrap;
|
|
69
|
+
|
|
70
|
+
.hk-draggable-list-item {
|
|
71
|
+
min-width: 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.hk-draggable-list-item[data-placeholder="before"] {
|
|
75
|
+
box-shadow: inset 2px 0 0 0 var(--hi-color-primary, #7aa2f7);
|
|
76
|
+
}
|
|
77
|
+
.hk-draggable-list-item[data-placeholder="after"] {
|
|
78
|
+
box-shadow: inset -2px 0 0 0 var(--hi-color-primary, #7aa2f7);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
56
82
|
.hk-draggable-list-handle {
|
|
57
83
|
display: flex;
|
|
58
84
|
align-items: center;
|
|
@@ -108,3 +108,66 @@ describe("HkDraggableList actions-lane sheet", () => {
|
|
|
108
108
|
expect(content).toContain("flex: 1");
|
|
109
109
|
});
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
describe("HkDraggableList horizontal-axis sheet", () => {
|
|
113
|
+
/** axis="horizontal" (DOM twin: the horizontal-axis cases in
|
|
114
|
+
* HkDraggableList.test.tsx) lays the SAME list as one never-wrapping
|
|
115
|
+
* row. Only geometry changes: the drop line moves to the item's
|
|
116
|
+
* leading/trailing edge — the horizontal twin of the top/bottom inset
|
|
117
|
+
* — and the items become shrinkable so the single line always holds. */
|
|
118
|
+
const css = listCss();
|
|
119
|
+
|
|
120
|
+
// ruleBody selectors are written in the COMPILED form: sass expanded
|
|
121
|
+
// output drops attribute-value quotes ([data-axis=horizontal]).
|
|
122
|
+
|
|
123
|
+
it("lays the horizontal list out as one never-wrapping row", () => {
|
|
124
|
+
const body = ruleBody(css, ".hk-draggable-list[data-axis=horizontal]");
|
|
125
|
+
expect(body, "the horizontal-axis layout rule must exist").not.toBeNull();
|
|
126
|
+
expect(body).toContain("flex-direction: row");
|
|
127
|
+
expect(body).toContain("flex-wrap: nowrap");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("lets an over-full row shrink its items so the one line holds", () => {
|
|
131
|
+
const body = ruleBody(
|
|
132
|
+
css,
|
|
133
|
+
".hk-draggable-list[data-axis=horizontal] .hk-draggable-list-item",
|
|
134
|
+
);
|
|
135
|
+
expect(body, "the horizontal item rule must exist").not.toBeNull();
|
|
136
|
+
expect(body).toContain("min-width: 0");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("marks the drop edge on the leading/trailing side, not top/bottom", () => {
|
|
140
|
+
const before = ruleBody(
|
|
141
|
+
css,
|
|
142
|
+
".hk-draggable-list[data-axis=horizontal] .hk-draggable-list-item[data-placeholder=before]",
|
|
143
|
+
);
|
|
144
|
+
expect(before, "the horizontal before rule must exist").not.toBeNull();
|
|
145
|
+
expect(before).toContain("inset 2px 0 0 0");
|
|
146
|
+
expect(before).not.toContain("inset 0 2px");
|
|
147
|
+
expect(before).not.toContain("inset 0 -2px");
|
|
148
|
+
|
|
149
|
+
const after = ruleBody(
|
|
150
|
+
css,
|
|
151
|
+
".hk-draggable-list[data-axis=horizontal] .hk-draggable-list-item[data-placeholder=after]",
|
|
152
|
+
);
|
|
153
|
+
expect(after, "the horizontal after rule must exist").not.toBeNull();
|
|
154
|
+
expect(after).toContain("inset -2px 0 0 0");
|
|
155
|
+
expect(after).not.toContain("inset 0 2px");
|
|
156
|
+
expect(after).not.toContain("inset 0 -2px");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("keeps the vertical drop edges on top/bottom for the default axis", () => {
|
|
160
|
+
// First source match = the plain vertical rule (the horizontal twin
|
|
161
|
+
// comes later and under a descendant selector); its body must carry
|
|
162
|
+
// the top/bottom inset and never the horizontal one.
|
|
163
|
+
const before = ruleBody(css, ".hk-draggable-list-item[data-placeholder=before]");
|
|
164
|
+
expect(before, "the vertical before rule must exist").not.toBeNull();
|
|
165
|
+
expect(before).toContain("inset 0 2px 0 0");
|
|
166
|
+
expect(before).not.toContain("inset 2px 0 0 0");
|
|
167
|
+
|
|
168
|
+
const after = ruleBody(css, ".hk-draggable-list-item[data-placeholder=after]");
|
|
169
|
+
expect(after, "the vertical after rule must exist").not.toBeNull();
|
|
170
|
+
expect(after).toContain("inset 0 -2px 0 0");
|
|
171
|
+
expect(after).not.toContain("inset -2px 0 0 0");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -525,3 +525,193 @@ describe("HkDraggableList actions lane", () => {
|
|
|
525
525
|
fireWin("pointerup");
|
|
526
526
|
});
|
|
527
527
|
});
|
|
528
|
+
|
|
529
|
+
describe("HkDraggableList horizontal axis", () => {
|
|
530
|
+
/** axis="horizontal" lays the SAME list as one never-wrapping row
|
|
531
|
+
* (wowsp's overlay-columns editor). Only the pointer geometry
|
|
532
|
+
* changes: the arm reads |dx| alone, the ghost follows BOTH axes,
|
|
533
|
+
* and the placeholder targets slots by pointer.x against their
|
|
534
|
+
* horizontal midpoints (the plain single-line rule — the list never
|
|
535
|
+
* wraps). Roles, slots, the reserved handle lane and the
|
|
536
|
+
* commit-once-at-pointerup contract stay exactly as vertical. */
|
|
537
|
+
|
|
538
|
+
const PointerEventCtor: typeof MouseEvent =
|
|
539
|
+
(globalThis as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
|
|
540
|
+
|
|
541
|
+
const pointerInit: MouseEventInit = { bubbles: true, cancelable: true };
|
|
542
|
+
|
|
543
|
+
function fire(el: Element, type: string, x = 0, y = 0) {
|
|
544
|
+
el.dispatchEvent(new PointerEventCtor(type, {
|
|
545
|
+
...pointerInit,
|
|
546
|
+
button: 0,
|
|
547
|
+
clientX: x,
|
|
548
|
+
clientY: y,
|
|
549
|
+
}));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function fireWin(type: string, x = 0, y = 0) {
|
|
553
|
+
window.dispatchEvent(new PointerEventCtor(type, {
|
|
554
|
+
...pointerInit,
|
|
555
|
+
clientX: x,
|
|
556
|
+
clientY: y,
|
|
557
|
+
}));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const emitLog: Array<{ from: number; to: number }> = [];
|
|
561
|
+
|
|
562
|
+
function mountHorizontal(items: string[], opts?: { lockedKeys?: string[] }): HTMLElement {
|
|
563
|
+
emitLog.length = 0;
|
|
564
|
+
const container = document.createElement("div");
|
|
565
|
+
document.body.appendChild(container);
|
|
566
|
+
const app = createApp({
|
|
567
|
+
render: () =>
|
|
568
|
+
h(
|
|
569
|
+
HDraggableList,
|
|
570
|
+
{
|
|
571
|
+
items: items.map((key) => ({ key })),
|
|
572
|
+
axis: "horizontal",
|
|
573
|
+
lockedKeys: opts?.lockedKeys ?? [],
|
|
574
|
+
onReorder: (from: number, to: number) => emitLog.push({ from, to }),
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
item: ({ item }: { item: { key: string } }) =>
|
|
578
|
+
h("span", { class: "row-body" }, item.key),
|
|
579
|
+
},
|
|
580
|
+
),
|
|
581
|
+
});
|
|
582
|
+
app.mount(container);
|
|
583
|
+
mounts.push({ app, container });
|
|
584
|
+
return container.querySelector(".hk-draggable-list") as HTMLElement;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function rowsOf(root: HTMLElement): HTMLElement[] {
|
|
588
|
+
return Array.from(root.querySelectorAll(".hk-draggable-list-item")) as HTMLElement[];
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** happy-dom reports zero rects; pin the slots to a 100px-each single
|
|
592
|
+
* line [0..100][100..200][200..300] (40px tall) so the midpoint rule
|
|
593
|
+
* walks real geometry. */
|
|
594
|
+
function layLine(root: HTMLElement, width = 100, height = 40): HTMLElement[] {
|
|
595
|
+
const rows = rowsOf(root);
|
|
596
|
+
rows.forEach((row, i) => {
|
|
597
|
+
const left = i * width;
|
|
598
|
+
row.getBoundingClientRect = () =>
|
|
599
|
+
({
|
|
600
|
+
left,
|
|
601
|
+
top: 0,
|
|
602
|
+
right: left + width,
|
|
603
|
+
bottom: height,
|
|
604
|
+
width,
|
|
605
|
+
height,
|
|
606
|
+
x: left,
|
|
607
|
+
y: 0,
|
|
608
|
+
toJSON: () => ({}),
|
|
609
|
+
}) as DOMRect;
|
|
610
|
+
});
|
|
611
|
+
return rows;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
it("keeps list semantics and marks no axis on the vertical default", () => {
|
|
615
|
+
const verticalRoot = mountList(["a"], []);
|
|
616
|
+
expect(verticalRoot.hasAttribute("data-axis")).toBe(false);
|
|
617
|
+
|
|
618
|
+
const root = mountHorizontal(["a", "b"]);
|
|
619
|
+
expect(root.getAttribute("data-axis")).toBe("horizontal");
|
|
620
|
+
expect(root.getAttribute("role")).toBe("list");
|
|
621
|
+
const rows = rowsOf(root);
|
|
622
|
+
expect(rows).toHaveLength(2);
|
|
623
|
+
expect(rows.every((row) => row.getAttribute("role") === "listitem")).toBe(true);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("arms on |dx| alone — vertical travel never arms a horizontal drag", async () => {
|
|
627
|
+
const root = mountHorizontal(["a", "b"]);
|
|
628
|
+
layLine(root);
|
|
629
|
+
const row = rowsOf(root)[0]!;
|
|
630
|
+
|
|
631
|
+
fire(row, "pointerdown", 50, 20);
|
|
632
|
+
// 30px of pure-Y travel: under the |dx| arm, still a plain press.
|
|
633
|
+
fireWin("pointermove", 50, 50);
|
|
634
|
+
await nextTick();
|
|
635
|
+
expect(row.hasAttribute("data-dragging")).toBe(false);
|
|
636
|
+
|
|
637
|
+
// 6px of pure-X travel crosses ARM_PX: the drag publishes.
|
|
638
|
+
fireWin("pointermove", 56, 50);
|
|
639
|
+
await nextTick();
|
|
640
|
+
expect(row.getAttribute("data-dragging")).not.toBeNull();
|
|
641
|
+
|
|
642
|
+
fireWin("pointerup");
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("moves the ghost on BOTH axes", async () => {
|
|
646
|
+
const root = mountHorizontal(["a"]);
|
|
647
|
+
layLine(root); // slot [0..100]
|
|
648
|
+
// A stale ghost from an earlier failed run would shadow this assert.
|
|
649
|
+
document.querySelectorAll(".hk-draggable-list-ghost").forEach((n) => n.remove());
|
|
650
|
+
const row = rowsOf(root)[0]!;
|
|
651
|
+
|
|
652
|
+
fire(row, "pointerdown", 50, 20); // grabOffset (50, 20)
|
|
653
|
+
fireWin("pointermove", 106, 20); // arm past ARM_PX on |dx|
|
|
654
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
655
|
+
let ghost = document.querySelector(".hk-draggable-list-ghost") as HTMLElement | null;
|
|
656
|
+
expect(ghost, "the ghost must rise next frame").not.toBeNull();
|
|
657
|
+
|
|
658
|
+
fireWin("pointermove", 260, 130); // both axes travel: (260-50, 130-20)
|
|
659
|
+
ghost = document.querySelector(".hk-draggable-list-ghost");
|
|
660
|
+
expect(ghost!.style.transform).toBe("translate3d(210px, 110px, 0)");
|
|
661
|
+
|
|
662
|
+
fireWin("pointerup");
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("targets the placeholder by pointer.x against slot midpoints", async () => {
|
|
666
|
+
const root = mountHorizontal(["a", "b", "c"]);
|
|
667
|
+
const rows = layLine(root);
|
|
668
|
+
const row = rows[0]!;
|
|
669
|
+
|
|
670
|
+
fire(row, "pointerdown", 50, 20);
|
|
671
|
+
fireWin("pointermove", 60, 20); // arm; still left of b's midpoint (150) → before b
|
|
672
|
+
await nextTick();
|
|
673
|
+
expect(rows[1]!.getAttribute("data-placeholder")).toBe("after");
|
|
674
|
+
|
|
675
|
+
fireWin("pointermove", 180, 20); // past b's midpoint, under c's (250) → before c
|
|
676
|
+
await nextTick();
|
|
677
|
+
expect(rows[1]!.hasAttribute("data-placeholder")).toBe(false);
|
|
678
|
+
expect(rows[2]!.getAttribute("data-placeholder")).toBe("after");
|
|
679
|
+
|
|
680
|
+
fireWin("pointerup");
|
|
681
|
+
// Commit-once-at-pointerup: ONE reorder, at the placeholder slot.
|
|
682
|
+
expect(emitLog).toEqual([{ from: 0, to: 2 }]);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("marks the leading edge when the pointer retreats before earlier slots", async () => {
|
|
686
|
+
const root = mountHorizontal(["a", "b", "c"]);
|
|
687
|
+
const rows = layLine(root);
|
|
688
|
+
const row = rows[2]!;
|
|
689
|
+
|
|
690
|
+
fire(row, "pointerdown", 250, 20);
|
|
691
|
+
fireWin("pointermove", 240, 20); // arm
|
|
692
|
+
await nextTick();
|
|
693
|
+
|
|
694
|
+
fireWin("pointermove", 20, 20); // before a's midpoint (50) → lands before a
|
|
695
|
+
await nextTick();
|
|
696
|
+
expect(rows[0]!.getAttribute("data-placeholder")).toBe("before");
|
|
697
|
+
expect(rows[1]!.hasAttribute("data-placeholder")).toBe(false);
|
|
698
|
+
|
|
699
|
+
fireWin("pointerup");
|
|
700
|
+
expect(emitLog).toEqual([{ from: 2, to: 0 }]);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("skips locked slots when walking the row", async () => {
|
|
704
|
+
const root = mountHorizontal(["a", "b", "c"], { lockedKeys: ["b"] });
|
|
705
|
+
const rows = layLine(root);
|
|
706
|
+
const row = rows[0]!;
|
|
707
|
+
|
|
708
|
+
fire(row, "pointerdown", 50, 20);
|
|
709
|
+
fireWin("pointermove", 160, 20); // past b's midpoint — b is locked, c takes the drop
|
|
710
|
+
await nextTick();
|
|
711
|
+
expect(rows[1]!.hasAttribute("data-placeholder")).toBe(false);
|
|
712
|
+
expect(rows[2]!.getAttribute("data-placeholder")).toBe("after");
|
|
713
|
+
|
|
714
|
+
fireWin("pointerup");
|
|
715
|
+
expect(emitLog).toEqual([{ from: 0, to: 2 }]);
|
|
716
|
+
});
|
|
717
|
+
});
|
|
@@ -46,6 +46,16 @@ export default defineComponent({
|
|
|
46
46
|
* mirroring the menu/vocabulary a11y rulings (#736/#737): chrome
|
|
47
47
|
* announces structure, the host supplies the name. */
|
|
48
48
|
label: { type: String, default: undefined },
|
|
49
|
+
/** The direction the list flows: "vertical" (the default) stacks
|
|
50
|
+
* rows in one column; "horizontal" lays them in ONE never-wrapping
|
|
51
|
+
* row (wowsp's overlay-columns editor is the first host). Only the
|
|
52
|
+
* pointer geometry differs — the arm threshold reads |dx| instead
|
|
53
|
+
* of the hypot, the ghost follows BOTH axes, and the placeholder
|
|
54
|
+
* targets slots by pointer.x against their horizontal midpoints
|
|
55
|
+
* (the same adjacent-midpoint rule, walked along the row). Roles,
|
|
56
|
+
* slots, the reserved handle lane and the commit-once-at-pointerup
|
|
57
|
+
* contract are identical on both axes. */
|
|
58
|
+
axis: { type: String as PropType<"vertical" | "horizontal">, default: "vertical" },
|
|
49
59
|
},
|
|
50
60
|
emits: {
|
|
51
61
|
reorder: (_fromIndex: number, _toIndex: number) => true,
|
|
@@ -173,21 +183,27 @@ export default defineComponent({
|
|
|
173
183
|
}
|
|
174
184
|
}
|
|
175
185
|
|
|
176
|
-
function moveGhost(clientY: number) {
|
|
186
|
+
function moveGhost(clientX: number, clientY: number) {
|
|
177
187
|
const s = drag.value;
|
|
178
188
|
if (!s || !ghostNode) return;
|
|
179
189
|
// gx/gy are the ghost's visual top-left (client coords minus the
|
|
180
190
|
// visual grab offset); the teleported ghost paints local px scaled
|
|
181
|
-
// by the cumulative zoom — divide once at the write layer.
|
|
191
|
+
// by the cumulative zoom — divide once at the write layer. A
|
|
192
|
+
// vertical drag pins the ghost to the source row's x (rows only
|
|
193
|
+
// ever move along the column); a horizontal drag follows BOTH
|
|
194
|
+
// axes — a card drifts vertically as naturally as a row drifts.
|
|
182
195
|
const z = ancestorZoom(document.body);
|
|
183
|
-
const gx = (
|
|
196
|
+
const gx = (props.axis === "horizontal"
|
|
197
|
+
? clientX - s.grabOffsetX
|
|
198
|
+
: s.originX - s.grabOffsetX) / z;
|
|
184
199
|
const gy = (clientY - s.grabOffsetY) / z;
|
|
185
200
|
ghostNode.style.transform = `translate3d(${gx}px, ${gy}px, 0)`;
|
|
186
201
|
}
|
|
187
202
|
|
|
188
|
-
function recomputePlaceholder(clientY: number) {
|
|
203
|
+
function recomputePlaceholder(clientX: number, clientY: number) {
|
|
189
204
|
const s = drag.value;
|
|
190
205
|
if (!s) return;
|
|
206
|
+
const horizontal = props.axis === "horizontal";
|
|
191
207
|
const rows = rowEls.value;
|
|
192
208
|
let target = s.fromIndex;
|
|
193
209
|
let fallback = s.fromIndex;
|
|
@@ -200,10 +216,15 @@ export default defineComponent({
|
|
|
200
216
|
if (!el) continue;
|
|
201
217
|
|
|
202
218
|
const rect = el.getBoundingClientRect();
|
|
203
|
-
|
|
219
|
+
// The adjacent-midpoint rule along the list's ONE axis — the
|
|
220
|
+
// column walks by y, the row walks by x. The horizontal form is
|
|
221
|
+
// the plain rule over every slot: this list never wraps, so the
|
|
222
|
+
// pointer's line is always the whole row (no banding).
|
|
223
|
+
const mid = horizontal ? rect.left + rect.width / 2 : rect.top + rect.height / 2;
|
|
224
|
+
const pointer = horizontal ? clientX : clientY;
|
|
204
225
|
fallback = i;
|
|
205
226
|
|
|
206
|
-
if (
|
|
227
|
+
if (pointer < mid) {
|
|
207
228
|
target = i;
|
|
208
229
|
break;
|
|
209
230
|
}
|
|
@@ -227,8 +248,14 @@ export default defineComponent({
|
|
|
227
248
|
|
|
228
249
|
if (!p.armed) {
|
|
229
250
|
// Arm past the threshold: only now does the drag publish, the
|
|
230
|
-
// ghost rise, and the gesture claim its defaults.
|
|
231
|
-
|
|
251
|
+
// ghost rise, and the gesture claim its defaults. Vertical arms
|
|
252
|
+
// on the hypot (travel along either axis counts); horizontal
|
|
253
|
+
// arms on |dx| alone — a y-wiggle under a pressed card is
|
|
254
|
+
// jitter, never a drag intent.
|
|
255
|
+
const travelled = props.axis === "horizontal"
|
|
256
|
+
? Math.abs(e.clientX - p.state.originX)
|
|
257
|
+
: Math.hypot(e.clientX - p.state.originX, e.clientY - p.state.originY);
|
|
258
|
+
if (travelled <= ARM_PX) {
|
|
232
259
|
return;
|
|
233
260
|
}
|
|
234
261
|
p.armed = true;
|
|
@@ -243,8 +270,8 @@ export default defineComponent({
|
|
|
243
270
|
}
|
|
244
271
|
|
|
245
272
|
e.preventDefault();
|
|
246
|
-
moveGhost(e.clientY);
|
|
247
|
-
recomputePlaceholder(e.clientY);
|
|
273
|
+
moveGhost(e.clientX, e.clientY);
|
|
274
|
+
recomputePlaceholder(e.clientX, e.clientY);
|
|
248
275
|
}
|
|
249
276
|
|
|
250
277
|
function onPointerUp() {
|
|
@@ -291,6 +318,7 @@ export default defineComponent({
|
|
|
291
318
|
class="hk-draggable-list"
|
|
292
319
|
role="list"
|
|
293
320
|
aria-label={props.label || undefined}
|
|
321
|
+
data-axis={props.axis === "horizontal" ? "horizontal" : undefined}
|
|
294
322
|
data-dragging={s ? "" : undefined}
|
|
295
323
|
data-all-locked={allLocked || undefined}
|
|
296
324
|
>
|
|
@@ -13,7 +13,9 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
* the normalization fixed.
|
|
14
14
|
*/
|
|
15
15
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
|
|
16
|
+
// autocrlf checkouts write CRLF to disk; normalize so the LF-joined
|
|
17
|
+
// selector anchor below matches what git stores regardless of platform.
|
|
18
|
+
const scss = readFileSync(join(here, "HkJsonTree.scss"), "utf-8").replace(/\r\n/g, "\n");
|
|
17
19
|
|
|
18
20
|
describe("HkJsonTree spacing contract", () => {
|
|
19
21
|
it("keeps the toggle exactly one spacing unit from the row text", () => {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the DESKTOP modal open/close choreography (user
|
|
3
|
+
* direction 2026-11-04): the window grows by scaling the content itself
|
|
4
|
+
* about the frame's center — width pinned at 100% (scaleX 1), height
|
|
5
|
+
* scaling 90% → 100% — replacing the 2026-09-14 clip unveil (and the
|
|
6
|
+
* #508/#512/#524 lineage before it). The old "content rides rigidly,
|
|
7
|
+
* never squashed" invariant was DELIBERATELY dropped: the rigid ride is
|
|
8
|
+
* exactly what made the unveil read as dead weight on ordinary modals.
|
|
9
|
+
*
|
|
10
|
+
* Pinned on the stylesheet SOURCE (computed animation geometry is not
|
|
11
|
+
* observable in happy-dom), mirroring the sheetmorph / tokenHygiene
|
|
12
|
+
* guard pattern.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const src = readFileSync(join(here, "HkModal.scss"), "utf-8");
|
|
21
|
+
|
|
22
|
+
/** The desktop choreography block — the mobile sheet block (≤767px)
|
|
23
|
+
* carries its own enter/leave classes and must not leak in. */
|
|
24
|
+
const desktop = src.slice(
|
|
25
|
+
src.indexOf("@media (min-width: 768px)"),
|
|
26
|
+
src.indexOf("@media (max-width: 767px)"),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/** The mobile sheet block — sliced the same way, for the parity guard. */
|
|
30
|
+
const mobile = src.slice(src.indexOf("@media (max-width: 767px)"));
|
|
31
|
+
|
|
32
|
+
/** Body of one rule (selector possibly grouped / multi-line). Line
|
|
33
|
+
* comments are stripped so prose in the sheet cannot satisfy or break
|
|
34
|
+
* a structural assertion. */
|
|
35
|
+
function bodyOf(selector: string, scope: string = desktop): string {
|
|
36
|
+
const escaped = selector.replace(/\./g, "\\.");
|
|
37
|
+
const match = scope.match(new RegExp(`${escaped}[^{]*\\{([^}]*)\\}`));
|
|
38
|
+
expect(match, `rule ${selector} must exist in scope`).toBeTruthy();
|
|
39
|
+
return match![1]
|
|
40
|
+
.replace(/\/\/[^\n]*/g, "")
|
|
41
|
+
.replace(/\s+/g, " ")
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ENTER_LEAVE = [
|
|
46
|
+
".hk-modal-content-enter-active",
|
|
47
|
+
".hk-modal-content-leave-active",
|
|
48
|
+
".hk-modal-content-enter-from",
|
|
49
|
+
".hk-modal-content-enter-to",
|
|
50
|
+
".hk-modal-content-leave-from",
|
|
51
|
+
".hk-modal-content-leave-to",
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
/** The centering translate: every transform state keeps it as the
|
|
55
|
+
* leading term, so the frame never re-seats mid-flight (dragged windows
|
|
56
|
+
* keep their shift through the animation). */
|
|
57
|
+
const CENTERING =
|
|
58
|
+
/translate\(\s*calc\(-50% \+ var\(--hk-modal-shift-x, 0px\)\),\s*calc\(-50% \+ var\(--hk-modal-shift-y, 0px\)\)\s*\)/;
|
|
59
|
+
|
|
60
|
+
describe("HkModal desktop content-scale open/close contract (user direction 2026-11-04)", () => {
|
|
61
|
+
it("scales the content from 90% height about the center on enter", () => {
|
|
62
|
+
const from = bodyOf(".hk-modal-content-enter-from");
|
|
63
|
+
expect(from).toMatch(CENTERING);
|
|
64
|
+
expect(from).toMatch(/scale\(1, 0\.9\)/);
|
|
65
|
+
// scaleX stays 1 — the width is pinned at 100% for the whole ride.
|
|
66
|
+
expect(from).not.toMatch(/scale\(0\.9\)/);
|
|
67
|
+
expect(from).toMatch(/opacity:\s*0/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("collapses back to 90% height on leave (the reveal's true reverse)", () => {
|
|
71
|
+
const to = bodyOf(".hk-modal-content-leave-to");
|
|
72
|
+
expect(to).toMatch(CENTERING);
|
|
73
|
+
expect(to).toMatch(/scale\(1, 0\.9\)/);
|
|
74
|
+
expect(to).toMatch(/opacity:\s*0/);
|
|
75
|
+
const from = bodyOf(".hk-modal-content-leave-from");
|
|
76
|
+
expect(from).toMatch(CENTERING);
|
|
77
|
+
expect(from).toMatch(/opacity:\s*1/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rests at the identity transform (no scale, no offset) at both ends", () => {
|
|
81
|
+
const enterTo = bodyOf(".hk-modal-content-enter-to");
|
|
82
|
+
expect(enterTo).toMatch(CENTERING);
|
|
83
|
+
// The identity state carries NO trailing transform term — the
|
|
84
|
+
// scale-to-1 end of the interpolation is a bare translate, so the
|
|
85
|
+
// transform lists on both ends share one shape and interpolate
|
|
86
|
+
// continuously (the clip-era "pin the identity clip" workaround is
|
|
87
|
+
// structurally unnecessary now).
|
|
88
|
+
expect(enterTo).toMatch(new RegExp(`transform:\\s*${CENTERING.source};`));
|
|
89
|
+
expect(enterTo).not.toMatch(/scale\(|translate[XY]\(/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("anchors the scale at the frame's center for the whole ride", () => {
|
|
93
|
+
for (const active of [
|
|
94
|
+
".hk-modal-content-enter-active",
|
|
95
|
+
".hk-modal-content-leave-active",
|
|
96
|
+
]) {
|
|
97
|
+
expect(bodyOf(active)).toMatch(/transform-origin:\s*50% 50%/);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("retires the clip unveil: no clip-path and no Y drift in any open/close rule", () => {
|
|
102
|
+
// History: the 2026-09-14 unveil rode translateY(5%) + inset() clip
|
|
103
|
+
// (with the #508 identity-clip fix). The scale grammar replaces the
|
|
104
|
+
// whole mechanism — any reappearance is a regression to the retired
|
|
105
|
+
// choreography.
|
|
106
|
+
for (const rule of ENTER_LEAVE) {
|
|
107
|
+
const body = bodyOf(rule);
|
|
108
|
+
expect(body, `${rule} must not clip`).not.toMatch(/clip-path/);
|
|
109
|
+
expect(body, `${rule} must not drift`).not.toMatch(/translateY\(/);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("keeps the ride animated on the timing tokens (never a snap)", () => {
|
|
114
|
+
// The retired close-collapse contract pinned "transitions clip-path
|
|
115
|
+
// on leave (the collapse animates, not snaps)"; its replacement must
|
|
116
|
+
// not be weaker on that axis. Deleting the transition blocks (or
|
|
117
|
+
// hardcoding their durations away from the family tokens) must turn
|
|
118
|
+
// this red — computed animation is unobservable in happy-dom, so
|
|
119
|
+
// this source pin is the only CI guard for "it animates at all".
|
|
120
|
+
const enter = bodyOf(".hk-modal-content-enter-active");
|
|
121
|
+
expect(enter).toMatch(
|
|
122
|
+
/opacity var\(--hk-modal-duration, 0\.42s\) var\(--hk-modal-ease-out, cubic-bezier\(0\.33, 1, 0\.68, 1\)\)/,
|
|
123
|
+
);
|
|
124
|
+
expect(enter).toMatch(
|
|
125
|
+
/transform var\(--hk-modal-duration, 0\.42s\) var\(--hk-modal-ease-out, cubic-bezier\(0\.33, 1, 0\.68, 1\)\)/,
|
|
126
|
+
);
|
|
127
|
+
expect(enter).not.toMatch(/clip-path/);
|
|
128
|
+
|
|
129
|
+
const leave = bodyOf(".hk-modal-content-leave-active");
|
|
130
|
+
expect(leave).toMatch(
|
|
131
|
+
/opacity var\(--hk-modal-duration, 0\.32s\) var\(--hk-modal-ease-in, cubic-bezier\(0\.45, 0, 0\.55, 1\)\)/,
|
|
132
|
+
);
|
|
133
|
+
expect(leave).toMatch(
|
|
134
|
+
/transform var\(--hk-modal-duration, 0\.32s\) var\(--hk-modal-ease-in, cubic-bezier\(0\.45, 0, 0\.55, 1\)\)/,
|
|
135
|
+
);
|
|
136
|
+
expect(leave).not.toMatch(/clip-path/);
|
|
137
|
+
|
|
138
|
+
for (const active of [
|
|
139
|
+
".hk-modal-content-enter-active",
|
|
140
|
+
".hk-modal-content-leave-active",
|
|
141
|
+
]) {
|
|
142
|
+
expect(bodyOf(active)).toMatch(/will-change: transform, opacity/);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("keeps the mobile sheet's translateY choreography untouched", () => {
|
|
147
|
+
const from = bodyOf(".hk-modal-content-enter-from", mobile);
|
|
148
|
+
expect(from).toMatch(/translateY\(100%\)/);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -97,56 +97,46 @@
|
|
|
97
97
|
// ------
|
|
98
98
|
// Content enter/leave transitions (desktop ≥768px)
|
|
99
99
|
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// drops on content-heavy modals, chest history topic detail). Scoped to
|
|
115
|
-
// ≥768px; ≤767px keeps the sheet's translateY choreography below.
|
|
100
|
+
// Content-scale reveal (user direction 2026-11-04): the frame grows by
|
|
101
|
+
// SCALING the content itself about the frame's center — the width stays
|
|
102
|
+
// pinned at 100% the whole time while the height scales from 90% up to
|
|
103
|
+
// 100%. No clip-path and no Y offset ride the open/close any more: the
|
|
104
|
+
// visible box IS the frame at every t, so nothing is ever sliced off
|
|
105
|
+
// (the 2026-09-14 clip unveil — and the #508/#512/#524 lineage before
|
|
106
|
+
// it — is superseded; the "content rides rigidly" invariant was the
|
|
107
|
+
// very thing that made the old reveal read as dead weight on ordinary
|
|
108
|
+
// modals). Implemented with transform (translate + uniform-axis scale)
|
|
109
|
+
// + opacity: all compositor-level, zero layout per frame, and the
|
|
110
|
+
// centering translate stays the leading term of EVERY transform state
|
|
111
|
+
// so the frame never re-seats mid-flight (dragged windows keep their
|
|
112
|
+
// shift). Scoped to ≥768px; ≤767px keeps the sheet's translateY
|
|
113
|
+
// choreography below.
|
|
116
114
|
// ------
|
|
117
115
|
|
|
118
116
|
@media (min-width: 768px) {
|
|
119
117
|
.hk-modal-content-enter-active {
|
|
118
|
+
// The scale anchors at the frame's center: the window expands from
|
|
119
|
+
// the middle outward on the Y axis only (X is pinned by scaleX 1).
|
|
120
|
+
transform-origin: 50% 50%;
|
|
120
121
|
transition:
|
|
121
122
|
opacity var(--hk-modal-duration, 0.42s)
|
|
122
123
|
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
|
|
123
124
|
transform var(--hk-modal-duration, 0.42s)
|
|
124
|
-
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
|
|
125
|
-
clip-path var(--hk-modal-duration, 0.42s)
|
|
126
125
|
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
|
|
127
|
-
will-change: transform, opacity
|
|
126
|
+
will-change: transform, opacity;
|
|
128
127
|
}
|
|
129
128
|
|
|
130
|
-
// Close: the
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// (user report 2026-09-15: the fade+drift close read as the box
|
|
134
|
-
// shrinking straight down instead of folding back into its center).
|
|
135
|
-
// The earlier re-clip attempt (#508) broke because leave-from had no
|
|
136
|
-
// clip at all: interpolating `none → inset()` is DISCRETE, so the
|
|
137
|
-
// bottom band (the footer) vanished at the first frame. Pinning the
|
|
138
|
-
// identity clip in leave-from — byte-identical to enter-to — makes
|
|
139
|
-
// the same-shape insets interpolate continuously, which structurally
|
|
140
|
-
// rules that jump out while restoring the symmetric collapse.
|
|
129
|
+
// Close: the reveal's TRUE reverse — the frame scales back down to
|
|
130
|
+
// 90% height about its center (both vertical edges ease toward the
|
|
131
|
+
// center line) while fading out.
|
|
141
132
|
.hk-modal-content-leave-active {
|
|
133
|
+
transform-origin: 50% 50%;
|
|
142
134
|
transition:
|
|
143
135
|
opacity var(--hk-modal-duration, 0.32s)
|
|
144
136
|
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
|
|
145
137
|
transform var(--hk-modal-duration, 0.32s)
|
|
146
|
-
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
|
|
147
|
-
clip-path var(--hk-modal-duration, 0.32s)
|
|
148
138
|
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
|
|
149
|
-
will-change: transform, opacity
|
|
139
|
+
will-change: transform, opacity;
|
|
150
140
|
}
|
|
151
141
|
|
|
152
142
|
.hk-modal-content-enter-from {
|
|
@@ -155,9 +145,7 @@
|
|
|
155
145
|
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
156
146
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
157
147
|
)
|
|
158
|
-
|
|
159
|
-
clip-path: inset(0 0 10% 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
160
|
-
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
148
|
+
scale(1, 0.9);
|
|
161
149
|
}
|
|
162
150
|
|
|
163
151
|
.hk-modal-content-enter-to {
|
|
@@ -166,8 +154,6 @@
|
|
|
166
154
|
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
167
155
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
168
156
|
);
|
|
169
|
-
clip-path: inset(0 0 0 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
170
|
-
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
171
157
|
}
|
|
172
158
|
|
|
173
159
|
.hk-modal-content-leave-from {
|
|
@@ -176,24 +162,20 @@
|
|
|
176
162
|
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
177
163
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
178
164
|
);
|
|
179
|
-
// The pinned identity clip: identical to enter-to so the leave's
|
|
180
|
-
// clip interpolates (never jumps) — see the close note above.
|
|
181
|
-
clip-path: inset(0 0 0 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
182
|
-
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
183
165
|
}
|
|
184
166
|
|
|
185
167
|
.hk-modal-content-leave-to {
|
|
186
168
|
opacity: 0;
|
|
187
|
-
// Mirror of enter-from
|
|
188
|
-
//
|
|
189
|
-
//
|
|
169
|
+
// Mirror of enter-from. No identity pin is needed any more: CSS
|
|
170
|
+
// transform-list interpolation pads the shorter list with identity
|
|
171
|
+
// scale(1, 1), so translate()+scale(1, 0.9) ↔ bare translate()
|
|
172
|
+
// interpolates continuously (unlike `none → inset()` clips, which
|
|
173
|
+
// are DISCRETE — the #508 lesson does not apply to transforms).
|
|
190
174
|
transform: translate(
|
|
191
175
|
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
192
176
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
193
177
|
)
|
|
194
|
-
|
|
195
|
-
clip-path: inset(0 0 10% 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
196
|
-
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
178
|
+
scale(1, 0.9);
|
|
197
179
|
}
|
|
198
180
|
}
|
|
199
181
|
|
|
@@ -588,7 +588,7 @@ export default defineComponent({
|
|
|
588
588
|
// Flush the growth deferred through the unfold. One tick later
|
|
589
589
|
// the transition classes are off the frame, so the base height
|
|
590
590
|
// transition animates the pin change instead of the enter's
|
|
591
|
-
// transform
|
|
591
|
+
// opacity/transform-only transition list swallowing it as a snap.
|
|
592
592
|
void nextTick(() => {
|
|
593
593
|
if (machine.phase.value === "open") morph.remeasure();
|
|
594
594
|
});
|
|
@@ -260,7 +260,9 @@ describe("settings dialog SCSS host contract (source-level)", () => {
|
|
|
260
260
|
// depends on.
|
|
261
261
|
// vitest serves modules through its own scheme, so import.meta.url is
|
|
262
262
|
// not a file URL here; the suite's cwd is packages/vue.
|
|
263
|
-
|
|
263
|
+
// autocrlf checkouts write CRLF to disk; normalize so the LF-joined
|
|
264
|
+
// anchors below match what git stores regardless of platform.
|
|
265
|
+
const scss = readFileSync("src/components/HkSettingsDialog.scss", "utf8").replace(/\r\n/g, "\n");
|
|
264
266
|
|
|
265
267
|
it("self-check: the extractor sees a rule that exists", () => {
|
|
266
268
|
// The shell rules are BEM-nested in the source (&__rail-item inside
|
|
@@ -508,7 +510,8 @@ describe("phone shell (rail → top tab strip, 2026-10-28 user direction)", () =
|
|
|
508
510
|
// assertions read INSIDE the &[data-phone] block — several of these
|
|
509
511
|
// declarations also exist elsewhere in the sheet, and a bare
|
|
510
512
|
// toContain would keep passing after the phone rule dies (R2).
|
|
511
|
-
|
|
513
|
+
// Normalized to LF (autocrlf checkouts) — see the host contract above.
|
|
514
|
+
const scss = readFileSync("src/components/HkSettingsDialog.scss", "utf8").replace(/\r\n/g, "\n");
|
|
512
515
|
const phoneBlock = scss.slice(
|
|
513
516
|
scss.indexOf("&[data-phone] {"),
|
|
514
517
|
scss.indexOf("Dialog host"),
|
|
@@ -137,14 +137,24 @@ describe("static sweep — literal var() presentation attributes must ride an in
|
|
|
137
137
|
void violations;
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
140
|
+
// The sweep reads ~200 painter files before it can judge anything; on a
|
|
141
|
+
// starved runner (the CI worker contention that masked hikari#1058) those
|
|
142
|
+
// sequential reads once bled past the default 5s test timeout, which
|
|
143
|
+
// reads as a flake and proves nothing. The gate is about *what* the
|
|
144
|
+
// files contain, never about how fast they read. (Same reasoning as
|
|
145
|
+
// theme/wallpaperSsr.test.ts's IMPORT_TIMEOUT_MS.)
|
|
146
|
+
const SWEEP_TIMEOUT_MS = 30_000;
|
|
147
|
+
|
|
148
|
+
it(
|
|
149
|
+
"every painted component file is clean",
|
|
150
|
+
async () => {
|
|
151
|
+
const files = (await readdir(dir)).filter((f) => /^Hk.+\.tsx$/.test(f) && !f.includes(".test."));
|
|
152
|
+
// Parallel reads: one I/O round trip instead of ~200 awaited ones.
|
|
153
|
+
const offenders = (await Promise.all(files.map((f) => violations(f)))).flat();
|
|
154
|
+
expect(offenders).toEqual([]);
|
|
155
|
+
},
|
|
156
|
+
SWEEP_TIMEOUT_MS,
|
|
157
|
+
);
|
|
148
158
|
});
|
|
149
159
|
|
|
150
160
|
describe("index export surface smoke", () => {
|
|
@@ -32,7 +32,10 @@ import { describe, expect, it } from "vitest";
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
35
|
-
|
|
35
|
+
// autocrlf checkouts write CRLF to disk; normalize so the LF-joined
|
|
36
|
+
// selector anchor below matches what git stores regardless of platform.
|
|
37
|
+
const read = (rel: string): string =>
|
|
38
|
+
readFileSync(join(here, rel), "utf-8").replace(/\r\n/g, "\n");
|
|
36
39
|
|
|
37
40
|
const sheet = read("./HkWallpaperBackdrop.scss");
|
|
38
41
|
const tsx = read("./HkWallpaperBackdrop.tsx");
|
package/src/runtime/index.ts
CHANGED
|
@@ -51,6 +51,13 @@ export {
|
|
|
51
51
|
DESKTOP_GUTTER_PX,
|
|
52
52
|
GUTTER_MOBILE_MAX_WIDTH,
|
|
53
53
|
} from "./viewportGutter";
|
|
54
|
+
// The cumulative CSS-zoom factor between a body-teleported element and
|
|
55
|
+
// the viewport root (runtime/cssZoom.ts). Hosts that measure anchors
|
|
56
|
+
// with gBCR and write fixed px themselves — a horizontal HkDraggableList
|
|
57
|
+
// port especially — need the same ghost-math division the components run
|
|
58
|
+
// internally; until this re-export existed the factor had to be copied
|
|
59
|
+
// verbatim into consumers.
|
|
60
|
+
export { ancestorZoom } from "./cssZoom";
|
|
54
61
|
export {
|
|
55
62
|
displayWidthUnits,
|
|
56
63
|
glyphWidthUnits,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useSSRContext } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Real-file stand-in for @vitejs/plugin-vue-jsx's SSR virtual module.
|
|
5
|
+
*
|
|
6
|
+
* When the plugin SSR-transforms a component that registers JSX components,
|
|
7
|
+
* it appends `import { ssrRegisterHelper } from "/__vue-jsx-ssr-register-helper"`
|
|
8
|
+
* and serves that id as a VIRTUAL module. Vitest fetches the virtual module
|
|
9
|
+
* itself, but the module runner derives the module's `import.meta.url` from
|
|
10
|
+
* the id alone — a rootless `file:///__vue-jsx-ssr-register-helper` — and
|
|
11
|
+
* vitest's evaluator builds the module context with a plain Node
|
|
12
|
+
* `createRequire(url)`. Windows rejects a file URL without a drive letter
|
|
13
|
+
* (ERR_INVALID_ARG_VALUE), so every `@vitest-environment node` suite that
|
|
14
|
+
* imports a .tsx component died before running a single test (hikari#1058).
|
|
15
|
+
*
|
|
16
|
+
* The alias in vite.config.ts points that id HERE, so the runner sees a real
|
|
17
|
+
* file path and `createRequire` gets a normal, absolute file URL. The body
|
|
18
|
+
* mirrors the plugin's virtual module — it wraps `setup` so a server render
|
|
19
|
+
* records the component's module into `ssrContext.modules` — keep the two
|
|
20
|
+
* in sync when upgrading the plugin.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A component options object the plugin's SSR transform can rewire. */
|
|
24
|
+
interface SsrRegisterableComponent {
|
|
25
|
+
setup?: (props: unknown, ctx: unknown) => unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ssrRegisterHelper(
|
|
29
|
+
comp: SsrRegisterableComponent,
|
|
30
|
+
filename: string,
|
|
31
|
+
): void {
|
|
32
|
+
const setup = comp.setup;
|
|
33
|
+
comp.setup = (props, ctx) => {
|
|
34
|
+
const ssrContext = useSSRContext();
|
|
35
|
+
if (ssrContext) {
|
|
36
|
+
const modules = ssrContext.modules ?? (ssrContext.modules = new Set());
|
|
37
|
+
modules.add(filename);
|
|
38
|
+
}
|
|
39
|
+
if (setup) return setup(props, ctx);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Source contract for the DESKTOP modal close choreography (user report
|
|
3
|
-
* 2026-09-15): the unveil expands the visible box outward from the
|
|
4
|
-
* center (bottom-10% clip + +5% offset at t=0, full box at t=1), but the
|
|
5
|
-
* close had degenerated into a fade + downward drift — the box read as
|
|
6
|
-
* shrinking straight down instead of folding back into its center.
|
|
7
|
-
*
|
|
8
|
-
* The close now runs the unveil's TRUE reverse, which is only safe
|
|
9
|
-
* because the leave pins an identity clip: interpolating
|
|
10
|
-
* `none → inset()` is DISCRETE (the #508 regression — the footer band
|
|
11
|
-
* vanished at the first frame), while identical `inset → inset` shape
|
|
12
|
-
* functions interpolate continuously.
|
|
13
|
-
*
|
|
14
|
-
* Pinned on the stylesheet SOURCE (computed animation geometry is not
|
|
15
|
-
* observable in happy-dom), mirroring the themeToggleChrome /
|
|
16
|
-
* tokenHygiene guard pattern.
|
|
17
|
-
*/
|
|
18
|
-
import { describe, expect, it } from "vitest";
|
|
19
|
-
import { readFileSync } from "node:fs";
|
|
20
|
-
import { dirname, join } from "node:path";
|
|
21
|
-
import { fileURLToPath } from "node:url";
|
|
22
|
-
|
|
23
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
24
|
-
const src = readFileSync(join(here, "HkModal.scss"), "utf-8");
|
|
25
|
-
|
|
26
|
-
/** The desktop choreography block — the mobile sheet block (≤767px)
|
|
27
|
-
* carries its own enter/leave classes and must not leak in. */
|
|
28
|
-
const desktop = src.slice(
|
|
29
|
-
src.indexOf("@media (min-width: 768px)"),
|
|
30
|
-
src.indexOf("@media (max-width: 767px)"),
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
/** Body of one flat rule (no nested braces in this block). */
|
|
34
|
-
function bodyOf(selector: string): string {
|
|
35
|
-
const escaped = selector.replace(/\./g, "\\.");
|
|
36
|
-
const match = desktop.match(new RegExp(`${escaped}\\s*{([^}]*)}`));
|
|
37
|
-
expect(match, `rule ${selector} must exist in the desktop block`).toBeTruthy();
|
|
38
|
-
return match![1].replace(/\s+/g, " ").trim();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function clipOf(selector: string): string {
|
|
42
|
-
const body = bodyOf(selector);
|
|
43
|
-
const match = body.match(/clip-path:\s*([^;]+);?/);
|
|
44
|
-
expect(match, `${selector} must declare a clip-path`).toBeTruthy();
|
|
45
|
-
return match![1].replace(/\s+/g, " ").trim();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
describe("HkModal desktop close-collapse contract (user report 2026-09-15)", () => {
|
|
49
|
-
it("pins the identity clip in leave-from, identical to enter-to (no discrete clip jump)", () => {
|
|
50
|
-
// The #508 regression guard: leave-from WITHOUT a clip makes
|
|
51
|
-
// none → inset() interpolate discretely and the footer band vanish
|
|
52
|
-
// at the first frame of the close.
|
|
53
|
-
expect(clipOf(".hk-modal-content-leave-from")).toBe(
|
|
54
|
-
clipOf(".hk-modal-content-enter-to"),
|
|
55
|
-
);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("mirrors the unveil geometry: leave-to clips like enter-from", () => {
|
|
59
|
-
// Bottom-10% clip + same rounding on both ends of the mirror — the
|
|
60
|
-
// visible box collapses symmetrically back to 90% centered.
|
|
61
|
-
expect(clipOf(".hk-modal-content-leave-to")).toBe(
|
|
62
|
-
clipOf(".hk-modal-content-enter-from"),
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("transitions clip-path on leave (the collapse animates, not snaps)", () => {
|
|
67
|
-
const body = bodyOf(".hk-modal-content-leave-active");
|
|
68
|
-
expect(body).toMatch(/clip-path\s+var\(--hk-modal-duration/);
|
|
69
|
-
expect(body).toMatch(/will-change:\s*[^;]*clip-path/);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("keeps the close a rigid ride: no scaleY squash anywhere in the desktop block", () => {
|
|
73
|
-
// History: #512 collapsed via scaleY (content squashed), #524
|
|
74
|
-
// reverted to a bare drift. The clip choreography moves whole
|
|
75
|
-
// pixels of the visible window — the frame itself never distorts.
|
|
76
|
-
expect(desktop).not.toContain("scaleY");
|
|
77
|
-
expect(desktop).not.toContain("transform-origin");
|
|
78
|
-
});
|
|
79
|
-
});
|