@celestia-island/hikari 0.55.269 → 0.55.270
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/HkAboutModal.scss +9 -6
- package/src/components/HkAboutModal.styles.test.ts +10 -2
- package/src/components/HkAltSignIn.tsx +1 -1
- package/src/components/HkBoard.tsx +2 -2
- package/src/components/HkDraggableGrid.scss +2 -2
- package/src/components/HkDraggableList.scss +1 -1
- package/src/components/HkInput.scss +3 -1
- package/src/components/HkListTransition.scss +5 -1
- package/src/components/HkMasonry.scss +53 -0
- package/src/components/HkMasonry.styles.test.ts +109 -0
- package/src/components/HkMasonry.test.tsx +211 -0
- package/src/components/HkMasonry.tsx +286 -0
- package/src/components/HkMinimap.tsx +1 -1
- package/src/components/HkNodeCanvas.tsx +2 -2
- package/src/components/HkPasswordSurface.scss +1 -1
- package/src/components/HkSelectionGrid.scss +9 -9
- package/src/components/HkSelectionWaterfall.scss +7 -7
- package/src/components/HkStepGuide.scss +1 -1
- package/src/components/HkTable.scss +1 -1
- package/src/index.ts +2 -0
- package/src/runtime/createAuthGuard.ts +1 -1
- package/src/styles/admin-tokens.scss +1 -1
- package/src/theme/_field.scss +1 -1
- package/src/theme/slotKinds.test.ts +1 -1
- package/src/theme/standardGroups.ts +1 -1
- package/src/theme/tokenGroups.test.ts +42 -42
- package/src/theme/tokenGroups.ts +4 -4
- package/src/utils/boardCamera.ts +3 -3
- package/src/utils/boardEdges.ts +2 -2
package/package.json
CHANGED
|
@@ -262,16 +262,19 @@
|
|
|
262
262
|
// across both palettes and modes) rather than shipping an unreadable tone
|
|
263
263
|
// colour. The border and background keep the raw channel: only the glyphs owe
|
|
264
264
|
// contrast, the tint is decorative.
|
|
265
|
+
// NOTE (grass constraint, see scripts/sync-vue-mirror.py): the relative-colour
|
|
266
|
+
// form oklch(from ... min/max(l,...) c h) was replaced by an oklab mix toward
|
|
267
|
+
// black/white - an APPROXIMATION of the lightness cap/floor, not channel math.
|
|
265
268
|
.s-about-modal-row-meta[data-tone="positive"] {
|
|
266
269
|
border-color: rgb(var(--color-success) / 32%);
|
|
267
270
|
background: rgb(var(--color-success) / 10%);
|
|
268
|
-
color:
|
|
271
|
+
color: color-mix(in oklab, rgb(var(--color-success)) 60%, black);
|
|
269
272
|
}
|
|
270
273
|
|
|
271
274
|
.s-about-modal-row-meta[data-tone="caution"] {
|
|
272
275
|
border-color: rgb(var(--color-warning) / 32%);
|
|
273
276
|
background: rgb(var(--color-warning) / 10%);
|
|
274
|
-
color:
|
|
277
|
+
color: color-mix(in oklab, rgb(var(--color-warning)) 60%, black);
|
|
275
278
|
}
|
|
276
279
|
|
|
277
280
|
// Red state: the component is not wired up / unreachable. The row then
|
|
@@ -279,21 +282,21 @@
|
|
|
279
282
|
.s-about-modal-row-meta[data-tone="danger"] {
|
|
280
283
|
border-color: rgb(var(--color-error) / 32%);
|
|
281
284
|
background: rgb(var(--color-error) / 10%);
|
|
282
|
-
color:
|
|
285
|
+
color: color-mix(in oklab, rgb(var(--color-error)) 60%, black);
|
|
283
286
|
}
|
|
284
287
|
|
|
285
288
|
// Dark arm: floor the lightness instead of capping it (rationale above). Only
|
|
286
289
|
// the glyphs re-ink — the border and tint already ride the live channel.
|
|
287
290
|
[data-mode="dark"] .s-about-modal-row-meta[data-tone="positive"] {
|
|
288
|
-
color:
|
|
291
|
+
color: color-mix(in oklab, rgb(var(--color-success)) 30%, white);
|
|
289
292
|
}
|
|
290
293
|
|
|
291
294
|
[data-mode="dark"] .s-about-modal-row-meta[data-tone="caution"] {
|
|
292
|
-
color:
|
|
295
|
+
color: color-mix(in oklab, rgb(var(--color-warning)) 30%, white);
|
|
293
296
|
}
|
|
294
297
|
|
|
295
298
|
[data-mode="dark"] .s-about-modal-row-meta[data-tone="danger"] {
|
|
296
|
-
color:
|
|
299
|
+
color: color-mix(in oklab, rgb(var(--color-error)) 30%, white);
|
|
297
300
|
}
|
|
298
301
|
|
|
299
302
|
/* ── Links: licenses, external links, credits names and filings ───────── */
|
|
@@ -251,6 +251,14 @@ describe("HkAboutModal stylesheet contract", () => {
|
|
|
251
251
|
const TONE = (tone: string) => `.s-about-modal-row-meta[data-tone=${tone}]`;
|
|
252
252
|
const DARK_TONE = (tone: string) => `[data-mode=dark] ${TONE(tone)}`;
|
|
253
253
|
|
|
254
|
+
// GRASS CONSTRAINT (2026-11): the Rust mirror compiles these sheets
|
|
255
|
+
// through `grass`, which cannot parse relative colour syntax — so the
|
|
256
|
+
// seed-relative `oklch(from … min/max(l, …) c h)` arms this contract used
|
|
257
|
+
// to pin are now an oklab MIX toward black/white (an approximation of the
|
|
258
|
+
// lightness cap/floor, not channel math). See HkAboutModal.scss's NOTE and
|
|
259
|
+
// scripts/sync-vue-mirror.py; the approximation is tuned to the same
|
|
260
|
+
// contrast bound.
|
|
261
|
+
//
|
|
254
262
|
// Channel behind each coloured tone, and the WCAG-AA ink derived from it.
|
|
255
263
|
// The pill text is `--text-2xs` (10px) — small text, 4.5:1 — and the raw
|
|
256
264
|
// channel misses that on both shipped palettes (stock light warning sits
|
|
@@ -268,9 +276,9 @@ describe("HkAboutModal stylesheet contract", () => {
|
|
|
268
276
|
// (shipped once — chest #2027). Reverting an arm to the raw channel, or
|
|
269
277
|
// dropping either mode arm, must turn this red.
|
|
270
278
|
const AA_INK_LIGHT = (channel: string) =>
|
|
271
|
-
`
|
|
279
|
+
`color-mix(in oklab, rgb(var(--color-${channel})) 60%, black)`;
|
|
272
280
|
const AA_INK_DARK = (channel: string) =>
|
|
273
|
-
`
|
|
281
|
+
`color-mix(in oklab, rgb(var(--color-${channel})) 30%, white)`;
|
|
274
282
|
const TONES: Record<string, string> = {
|
|
275
283
|
positive: "success",
|
|
276
284
|
caution: "warning",
|
|
@@ -17,7 +17,7 @@ export interface HkAltSignInEntry {
|
|
|
17
17
|
*
|
|
18
18
|
* A small muted trigger row (styled in the `.s-auth-footer` grammar) that
|
|
19
19
|
* opens a popover listing third-party/alternative credential entries —
|
|
20
|
-
* platform passkey on shittim-chest, feishu OAuth on
|
|
20
|
+
* platform passkey on shittim-chest, feishu OAuth on the ERP adopter — so
|
|
21
21
|
* both apps share one implementation and only differ in content.
|
|
22
22
|
*
|
|
23
23
|
* Entries render as [`HkMenuActionItem`] rows inside an [`HkPopover`]
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* HkBoard — the shared node-and-edge board surface.
|
|
3
3
|
*
|
|
4
4
|
* ONE world-camera canvas that every graph-like page builds on (history
|
|
5
|
-
* topic maps,
|
|
5
|
+
* topic maps, operations scenes, PLC panels, and whatever comes next). The
|
|
6
6
|
* board owns the motion feel and the chrome; the page owns the layout:
|
|
7
7
|
*
|
|
8
8
|
* - CAMERA: 5% geometric zoom ladder with an ANIMATED settle (no snapping),
|
|
@@ -221,7 +221,7 @@ export default defineComponent({
|
|
|
221
221
|
let pinchBase: { cam: BoardCamera; dist: number; mid: BoardPoint } | null = null;
|
|
222
222
|
let dragState: { node: BoardNodeInput; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
|
|
223
223
|
// Every node press is tracked (draggable or not) so read-only boards
|
|
224
|
-
// —
|
|
224
|
+
// — operations scenes, mind maps — still get `nodeClick` on a short tap.
|
|
225
225
|
let pressed: { id: string; pointerId: number; x: number; y: number } | null = null;
|
|
226
226
|
|
|
227
227
|
/** How many of the board's own pixels one DRAWN pixel is worth: the
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
min-height: 44px;
|
|
17
17
|
border: 1px dashed var(--hi-color-primary, #7aa2f7);
|
|
18
18
|
border-radius: var(--radius-sm, 4px);
|
|
19
|
-
background:
|
|
19
|
+
background: color-mix(in srgb, var(--hi-color-primary, #7aa2f7) 4%, transparent);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
.hk-draggable-grid-cell {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
transform: scale(0.96);
|
|
48
48
|
border-style: dashed;
|
|
49
49
|
border-color: var(--hi-color-primary, #7aa2f7);
|
|
50
|
-
background:
|
|
50
|
+
background: color-mix(in srgb, var(--hi-color-primary, #7aa2f7) 4%, transparent);
|
|
51
51
|
cursor: grabbing;
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
transform: scale(0.96);
|
|
32
32
|
border-style: dashed;
|
|
33
33
|
border-color: var(--hi-color-primary, #7aa2f7);
|
|
34
|
-
background:
|
|
34
|
+
background: color-mix(in srgb, var(--hi-color-primary, #7aa2f7) 4%, transparent);
|
|
35
35
|
cursor: grabbing;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -256,9 +256,11 @@
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
/* Autofill overrides */
|
|
259
|
+
/* The finite duration (instead of `calc(infinity * 1s)`) keeps this sheet
|
|
260
|
+
compilable by the grass compiler the Rust form shares it with. */
|
|
259
261
|
.hk-input-box input,
|
|
260
262
|
.hk-input-box textarea {
|
|
261
|
-
transition: color
|
|
263
|
+
transition: color 100000000s step-end, background-color 100000000s step-end;
|
|
262
264
|
}
|
|
263
265
|
|
|
264
266
|
.hk-input-box input:-webkit-autofill,
|
|
@@ -140,7 +140,11 @@
|
|
|
140
140
|
.hk-list-reveal-enter-from,
|
|
141
141
|
.hk-list-reveal-leave-to {
|
|
142
142
|
opacity: 0;
|
|
143
|
-
|
|
143
|
+
/* grass-compatible fallback: the Rust form compiles this same sheet, and
|
|
144
|
+
* grass cannot parse `calc-size(auto, size * 0.8)` (see the sync script's
|
|
145
|
+
* compile constraints). 80% of the container is the closest equivalent the
|
|
146
|
+
* parser accepts; the enter/leave reveal keeps its opacity fade either way. */
|
|
147
|
+
height: 80%;
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
.hk-list-reveal-move {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// AI-generated: developed with a DeepSeek Harness agent (model GLM-5.3-flash); human-directed.
|
|
2
|
+
|
|
3
|
+
/* HkMasonry — the column geometry (see the component docblock for the
|
|
4
|
+
* contract). Independent flex columns, NOT CSS columns: a card is placed
|
|
5
|
+
* whole into one column, so nothing can fragment it and no balancer can
|
|
6
|
+
* go stale under it.
|
|
7
|
+
*
|
|
8
|
+
* Column spacing rides SIBLING MARGINS, never a container `gap` — the
|
|
9
|
+
* same lesson HkWaterfall's sheet records: a gap is spent between every
|
|
10
|
+
* adjacent pair of rendered flex items, including a (live, collapsed)
|
|
11
|
+
* pair, so a trailing empty column would shave the survivor narrower than
|
|
12
|
+
* the host's other full-width children. A sibling margin is only spent
|
|
13
|
+
* where two width-carrying columns actually sit, and an empty column
|
|
14
|
+
* cancels it on both edges.
|
|
15
|
+
*
|
|
16
|
+
* Hosts tune the density with --hk-masonry-gap (vertical card gap inside a
|
|
17
|
+
* column AND the horizontal sibling margin); it defaults to --space-12. */
|
|
18
|
+
|
|
19
|
+
.hk-masonry {
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: flex-start;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.hk-masonry-col {
|
|
25
|
+
flex: 1 1 0;
|
|
26
|
+
min-width: 0;
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
gap: var(--hk-masonry-gap, var(--space-12));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.hk-masonry-col + .hk-masonry-col {
|
|
33
|
+
margin-inline-start: var(--hk-masonry-gap, var(--space-12));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Fewer cards than columns: the empty column must not keep its flex share
|
|
37
|
+
(the survivor would render half width beside a ghost), and with the
|
|
38
|
+
sibling-margin spacing above its collapse costs no gap on either edge. */
|
|
39
|
+
.hk-masonry-col[data-empty] {
|
|
40
|
+
flex: 0 0 0;
|
|
41
|
+
margin-inline-start: 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.hk-masonry-col[data-empty] + .hk-masonry-col {
|
|
45
|
+
margin-inline-start: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* The card wrapper is layout-transparent: the slot content owns its own
|
|
49
|
+
chrome, and the wrapper exists so the observer measures ONE stable box
|
|
50
|
+
per key regardless of what the card renders. */
|
|
51
|
+
.hk-masonry-card {
|
|
52
|
+
min-width: 0;
|
|
53
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// AI-generated: developed with a DeepSeek Harness agent (model GLM-5.3-flash); human-directed.
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { compile } from "sass";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compiled-stylesheet guard for HkMasonry's column geometry — the axis a
|
|
12
|
+
* runtime suite cannot see (happy-dom applies no stylesheets).
|
|
13
|
+
*
|
|
14
|
+
* The invariant this pins: columns are independent flex stacks whose
|
|
15
|
+
* spacing is SIBLING MARGIN, cancelled on a collapsed ([data-empty])
|
|
16
|
+
* column on both edges — never a container `gap` (a gap is spent on the
|
|
17
|
+
* (live, collapsed) pair too and shaves the survivor narrower than the
|
|
18
|
+
* host's other full-width children), and NEVER a CSS-multicol property
|
|
19
|
+
* (`columns` / `break-inside`), the fragmentation layout this component
|
|
20
|
+
* exists to replace. Same ownership discipline as the HkWaterfall sheet
|
|
21
|
+
* guard: the compiled sheet must contain EXACTLY the rules below, each
|
|
22
|
+
* with EXACTLY its pinned declaration list, and nothing else in the sheet
|
|
23
|
+
* may mention the columns.
|
|
24
|
+
*
|
|
25
|
+
* Vitest stubs CSS, so the runtime suite can never catch a lost rule —
|
|
26
|
+
* this compiles the sheet directly and asserts the contract.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
30
|
+
|
|
31
|
+
const compiled = compile(resolve(componentDir, "HkMasonry.scss"), {
|
|
32
|
+
style: "expanded",
|
|
33
|
+
}).css;
|
|
34
|
+
|
|
35
|
+
// Expanded sass keeps comments inline and may emit an @charset preamble;
|
|
36
|
+
// both must go before rule parsing or they glue onto captured selectors.
|
|
37
|
+
const css = compiled
|
|
38
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
39
|
+
.replace(/^@charset[^;]*;/m, "");
|
|
40
|
+
|
|
41
|
+
function rules(source: string): { selector: string; decls: string[] }[] {
|
|
42
|
+
expect(source, "no at-rule may appear in the compiled sheet").not.toContain(
|
|
43
|
+
"@",
|
|
44
|
+
);
|
|
45
|
+
expect(source, "no nested braces in the compiled sheet").not.toMatch(
|
|
46
|
+
/\{[^{}]*\{/,
|
|
47
|
+
);
|
|
48
|
+
return [...source.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({
|
|
49
|
+
selector: m[1].replace(/\s+/g, " ").trim(),
|
|
50
|
+
decls: m[2]
|
|
51
|
+
.split(";")
|
|
52
|
+
.map((d) => d.replace(/\s+/g, " ").trim())
|
|
53
|
+
.filter(Boolean),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const allRules = (): { selector: string; decls: string[] }[] => rules(css);
|
|
58
|
+
|
|
59
|
+
function expectRule(selector: string, decls: string[]): void {
|
|
60
|
+
const hits = allRules().filter((r) => r.selector === selector);
|
|
61
|
+
expect(hits, `exactly one "${selector}" rule`).toHaveLength(1);
|
|
62
|
+
expect(hits[0].decls, `declarations of "${selector}"`).toEqual(decls);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function columnRules(): { selector: string; decls: string[] }[] {
|
|
66
|
+
return allRules().filter((r) => r.selector.includes("hk-masonry-col"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("HkMasonry column spacing contract", () => {
|
|
70
|
+
it("compiles into the expected flat sheet", () => {
|
|
71
|
+
expect(css).toContain(".hk-masonry");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("the column machinery is exactly five rules with exactly these declarations", () => {
|
|
75
|
+
expect(columnRules().map((r) => r.selector).sort()).toEqual(
|
|
76
|
+
[
|
|
77
|
+
".hk-masonry-col",
|
|
78
|
+
".hk-masonry-col + .hk-masonry-col",
|
|
79
|
+
".hk-masonry-col[data-empty]",
|
|
80
|
+
".hk-masonry-col[data-empty] + .hk-masonry-col",
|
|
81
|
+
].sort(),
|
|
82
|
+
);
|
|
83
|
+
expectRule(".hk-masonry-col", [
|
|
84
|
+
"flex: 1 1 0",
|
|
85
|
+
"min-width: 0",
|
|
86
|
+
"display: flex",
|
|
87
|
+
"flex-direction: column",
|
|
88
|
+
"gap: var(--hk-masonry-gap, var(--space-12))",
|
|
89
|
+
]);
|
|
90
|
+
expectRule(".hk-masonry-col + .hk-masonry-col", [
|
|
91
|
+
"margin-inline-start: var(--hk-masonry-gap, var(--space-12))",
|
|
92
|
+
]);
|
|
93
|
+
expectRule(".hk-masonry-col[data-empty]", [
|
|
94
|
+
"flex: 0 0 0",
|
|
95
|
+
"margin-inline-start: 0",
|
|
96
|
+
]);
|
|
97
|
+
expectRule(".hk-masonry-col[data-empty] + .hk-masonry-col", [
|
|
98
|
+
"margin-inline-start: 0",
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("the sheet never speaks fragmentation layout", () => {
|
|
103
|
+
// The whole point of the component: no multicol anywhere, in any rule,
|
|
104
|
+
// under any alias the shorthand family offers.
|
|
105
|
+
expect(css).not.toMatch(/\bcolumns\s*:/);
|
|
106
|
+
expect(css).not.toMatch(/break-inside/);
|
|
107
|
+
expect(css).not.toMatch(/column-(gap|count|fill|width)/);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// AI-generated: developed with a DeepSeek Harness agent (model GLM-5.3-flash); human-directed.
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
|
5
|
+
|
|
6
|
+
import HkMasonry from "./HkMasonry";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HkMasonry tests — the packing MECHANISM: reading-order greedy fill while
|
|
10
|
+
* heights are unknown, the measured-height re-pack (including the wowsp
|
|
11
|
+
* spec-panel regression: a card GROWS in place and the cards below it must
|
|
12
|
+
* re-pack, not stay clipped under a stale column height), the width-derived
|
|
13
|
+
* column count, and the empty-column collapse.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface ROEntry {
|
|
17
|
+
target: HTMLElement;
|
|
18
|
+
contentRect: { width: number; height: number };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Minimal ResizeObserver fake the suite drives by hand. */
|
|
22
|
+
class FakeResizeObserver {
|
|
23
|
+
static instances: FakeResizeObserver[] = [];
|
|
24
|
+
callback: (entries: ROEntry[]) => void;
|
|
25
|
+
observed = new Set<HTMLElement>();
|
|
26
|
+
|
|
27
|
+
constructor(callback: (entries: ROEntry[]) => void) {
|
|
28
|
+
this.callback = callback;
|
|
29
|
+
FakeResizeObserver.instances.push(this);
|
|
30
|
+
}
|
|
31
|
+
observe(el: HTMLElement): void {
|
|
32
|
+
this.observed.add(el);
|
|
33
|
+
}
|
|
34
|
+
unobserve(el: HTMLElement): void {
|
|
35
|
+
this.observed.delete(el);
|
|
36
|
+
}
|
|
37
|
+
disconnect(): void {
|
|
38
|
+
this.observed.clear();
|
|
39
|
+
}
|
|
40
|
+
fire(entries: ROEntry[]): void {
|
|
41
|
+
this.callback(entries);
|
|
42
|
+
}
|
|
43
|
+
/** The spec's initial observation: observe() reports the current size. */
|
|
44
|
+
fireInitial(width: number, heightOf: (el: HTMLElement) => number): void {
|
|
45
|
+
this.fire([...this.observed].map((el) => ({ target: el, contentRect: { width, height: heightOf(el) } })));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const apps: App[] = [];
|
|
50
|
+
const containers: HTMLElement[] = [];
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
FakeResizeObserver.instances = [];
|
|
54
|
+
(globalThis as { ResizeObserver?: unknown }).ResizeObserver = FakeResizeObserver;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
for (const app of apps.splice(0)) app.unmount();
|
|
59
|
+
for (const el of containers.splice(0)) el.remove();
|
|
60
|
+
delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
interface Mounted {
|
|
64
|
+
app: App;
|
|
65
|
+
root: HTMLElement;
|
|
66
|
+
/** Every ResizeObserver the component created: [cards, root]. */
|
|
67
|
+
observers: FakeResizeObserver[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mount(props: Record<string, unknown>): Mounted {
|
|
71
|
+
const container = document.createElement("div");
|
|
72
|
+
document.body.appendChild(container);
|
|
73
|
+
containers.push(container);
|
|
74
|
+
const cardSlot = ({ item }: { item: unknown }) => h("div", { class: "card" }, String(item));
|
|
75
|
+
// Item-name keys by default: the RO height tables below are keyed "a".."e",
|
|
76
|
+
// so the wrappers' data-masonry-key must match (the positional fallback
|
|
77
|
+
// would key them "0".."n" and every fired report would miss).
|
|
78
|
+
const keyed = { itemKeyOf: (item: unknown) => String(item), ...props };
|
|
79
|
+
const Root = defineComponent({
|
|
80
|
+
setup() {
|
|
81
|
+
return () => h(HkMasonry, { items: (props.items ?? []) as never, ...keyed }, { card: cardSlot });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
const app = createApp(Root);
|
|
85
|
+
app.mount(container);
|
|
86
|
+
apps.push(app);
|
|
87
|
+
return {
|
|
88
|
+
app,
|
|
89
|
+
root: container,
|
|
90
|
+
observers: FakeResizeObserver.instances.filter((ro) => ro.observed.size > 0 || FakeResizeObserver.instances.length > 0),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The card observer: the one watching card wrappers (it has the most). */
|
|
95
|
+
function cardObserver(mounted: Mounted): FakeResizeObserver {
|
|
96
|
+
return mounted.observers.reduce((a, b) => (b.observed.size > a.observed.size ? b : a));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Per-column card TEXT (the slot renders String(item)) — the packing
|
|
100
|
+
* assertion reads placement, not identity; dataset keys are positional
|
|
101
|
+
* unless itemKeyOf is set, and one test pins those separately. */
|
|
102
|
+
function columnKeys(root: HTMLElement): string[][] {
|
|
103
|
+
return [...root.querySelectorAll<HTMLElement>(".hk-masonry-col")].map((col) =>
|
|
104
|
+
[...col.querySelectorAll<HTMLElement>(".card")].map((el) => el.textContent ?? ""),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function cardEls(root: HTMLElement): HTMLElement[] {
|
|
109
|
+
return [...root.querySelectorAll<HTMLElement>(".hk-masonry-card")];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe("HkMasonry", () => {
|
|
113
|
+
it("packs count-greedy in reading order while heights are unknown", () => {
|
|
114
|
+
const mounted = mount({ items: ["a", "b", "c", "d", "e"], columns: 3 });
|
|
115
|
+
// forward greedy: a->0, b->1, c->2, d->0, e->1
|
|
116
|
+
expect(columnKeys(mounted.root)).toEqual([["a", "d"], ["b", "e"], ["c"]]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("re-packs by measured heights once the observers report", async () => {
|
|
120
|
+
const mounted = mount({ items: ["a", "b", "c", "d", "e"], columns: 3 });
|
|
121
|
+
const ro = cardObserver(mounted);
|
|
122
|
+
await fireHeights(mounted, ro, { a: 300, b: 10, c: 10, d: 10, e: 10 });
|
|
123
|
+
// totals after each drop: a->0(300), b->1(10), c->2(10), d->1(20), e->2(20)
|
|
124
|
+
expect(columnKeys(mounted.root)).toEqual([["a"], ["b", "d"], ["c", "e"]]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("re-packs when a card grows in place — the stale-balancer regression", async () => {
|
|
128
|
+
const mounted = mount({ items: ["a", "b", "c", "d", "e"], columns: 3 });
|
|
129
|
+
const ro = cardObserver(mounted);
|
|
130
|
+
await fireHeights(mounted, ro, { a: 300, b: 10, c: 10, d: 10, e: 10 });
|
|
131
|
+
expect(columnKeys(mounted.root)).toEqual([["a"], ["b", "d"], ["c", "e"]]);
|
|
132
|
+
// b grows past a: the walk now strands later cards in the tail column
|
|
133
|
+
// instead of freezing the old geometry (multicol kept the pre-change
|
|
134
|
+
// column height and clipped — flex columns re-pack whole cards).
|
|
135
|
+
await fireHeights(mounted, ro, { a: 300, b: 400, c: 10, d: 10, e: 10 });
|
|
136
|
+
expect(columnKeys(mounted.root)).toEqual([["a"], ["b"], ["c", "d", "e"]]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("derives the column count from the measured width under columns=auto", async () => {
|
|
140
|
+
const mounted = mount({ items: ["a", "b", "c"], columns: "auto", columnMinWidth: 220 });
|
|
141
|
+
const rootRo = mounted.observers[mounted.observers.length - 1];
|
|
142
|
+
rootRo.fire([{ target: mounted.root.querySelector(".hk-masonry")!, contentRect: { width: 900, height: 0 } }]);
|
|
143
|
+
await nextTick();
|
|
144
|
+
expect(mounted.root.querySelector(".hk-masonry")?.getAttribute("data-columns")).toBe("4");
|
|
145
|
+
rootRo.fire([{ target: mounted.root.querySelector(".hk-masonry")!, contentRect: { width: 500, height: 0 } }]);
|
|
146
|
+
await nextTick();
|
|
147
|
+
expect(mounted.root.querySelector(".hk-masonry")?.getAttribute("data-columns")).toBe("2");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("collapses columns that received no card", () => {
|
|
151
|
+
const mounted = mount({ items: ["a"], columns: 3 });
|
|
152
|
+
const empties = [...mounted.root.querySelectorAll<HTMLElement>(".hk-masonry-col[data-empty]")];
|
|
153
|
+
expect(empties).toHaveLength(2);
|
|
154
|
+
expect(columnKeys(mounted.root)).toEqual([["a"], [], []]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("keys cards through itemKeyOf and falls back to position", () => {
|
|
158
|
+
const keyed = mount({ items: ["a", "b"], columns: 2, itemKeyOf: (item: unknown) => `k-${String(item)}` });
|
|
159
|
+
expect(cardEls(keyed.root).map((el) => el.dataset.masonryKey)).toEqual(["k-a", "k-b"]);
|
|
160
|
+
// explicit undefined overrides the harness default — this IS the
|
|
161
|
+
// positional-fallback case
|
|
162
|
+
const positional = mount({ items: ["a", "b"], columns: 2, itemKeyOf: undefined });
|
|
163
|
+
expect(cardEls(positional.root).map((el) => el.dataset.masonryKey)).toEqual(["0", "1"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("ignores zero-height reports instead of poisoning the height table", async () => {
|
|
167
|
+
const mounted = mount({ items: ["a", "b", "c", "d"], columns: 2 });
|
|
168
|
+
const ro = cardObserver(mounted);
|
|
169
|
+
await fireHeights(mounted, ro, { a: 100, b: 50, c: 50, d: 50 });
|
|
170
|
+
expect(columnKeys(mounted.root)).toEqual([["a", "d"], ["b", "c"]]);
|
|
171
|
+
// a collapses (display:none) — the stale height must survive the 0.
|
|
172
|
+
await fireHeights(mounted, ro, { a: 0, b: 50, c: 50, d: 50 });
|
|
173
|
+
expect(columnKeys(mounted.root)).toEqual([["a", "d"], ["b", "c"]]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("renders without ResizeObserver (SSR-ish hosts) and still lays out", () => {
|
|
177
|
+
delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
|
|
178
|
+
const mounted = mount({ items: ["a", "b", "c"], columns: 2 });
|
|
179
|
+
expect(columnKeys(mounted.root)).toEqual([["a", "c"], ["b"]]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("renders the empty slot for an empty stream", () => {
|
|
183
|
+
const container = document.createElement("div");
|
|
184
|
+
document.body.appendChild(container);
|
|
185
|
+
containers.push(container);
|
|
186
|
+
const Root = defineComponent({
|
|
187
|
+
setup() {
|
|
188
|
+
return () =>
|
|
189
|
+
h(HkMasonry, { items: [] as never, columns: 2 }, { empty: () => h("p", { class: "none" }, "nothing") });
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
const app = createApp(Root);
|
|
193
|
+
app.mount(container);
|
|
194
|
+
apps.push(app);
|
|
195
|
+
expect(container.querySelector(".none")?.textContent).toBe("nothing");
|
|
196
|
+
expect(container.querySelectorAll(".hk-masonry-col")).toHaveLength(0);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
async function fireHeights(
|
|
201
|
+
mounted: Mounted,
|
|
202
|
+
ro: FakeResizeObserver,
|
|
203
|
+
heights: Record<string, number>,
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
ro.fire(cardEls(mounted.root).map((el) => ({
|
|
206
|
+
target: el,
|
|
207
|
+
contentRect: { width: 200, height: heights[el.dataset.masonryKey ?? ""] ?? 0 },
|
|
208
|
+
})));
|
|
209
|
+
// the height table lands through Vue's async render effect
|
|
210
|
+
await nextTick();
|
|
211
|
+
}
|