@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.269",
3
+ "version": "0.55.270",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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: oklch(from rgb(var(--color-success)) min(l, 0.5) c h);
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: oklch(from rgb(var(--color-warning)) min(l, 0.5) c h);
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: oklch(from rgb(var(--color-error)) min(l, 0.5) c h);
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: oklch(from rgb(var(--color-success)) max(l, 0.72) c h);
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: oklch(from rgb(var(--color-warning)) max(l, 0.72) c h);
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: oklch(from rgb(var(--color-error)) max(l, 0.72) c h);
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
- `oklch(from rgb(var(--color-${channel})) min(l, 0.5) c h)`;
279
+ `color-mix(in oklab, rgb(var(--color-${channel})) 60%, black)`;
272
280
  const AA_INK_DARK = (channel: string) =>
273
- `oklch(from rgb(var(--color-${channel})) max(l, 0.72) c h)`;
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 easy-hydro-erp — so
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, SCADA scenes, PLC panels, and whatever comes next). The
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
- // — SCADA scenes, mind maps — still get `nodeClick` on a short tap.
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: rgb(from var(--hi-color-primary, #7aa2f7) r g b / 0.04);
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: rgb(from var(--hi-color-primary, #7aa2f7) r g b / 0.04);
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: rgb(from var(--hi-color-primary, #7aa2f7) r g b / 0.04);
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 calc(infinity * 1s) step-end, background-color calc(infinity * 1s) step-end;
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
- height: calc-size(auto, size * 0.8);
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
+ }