@celestia-island/hikari 0.55.268 → 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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkAboutModal.scss +9 -6
  3. package/src/components/HkAboutModal.styles.test.ts +10 -2
  4. package/src/components/HkAltSignIn.tsx +1 -1
  5. package/src/components/HkAuthMethodList.test.tsx +8 -5
  6. package/src/components/HkBoard.tsx +2 -2
  7. package/src/components/HkColorSchemeDialog.test.ts +3 -3
  8. package/src/components/HkColorSchemeEditor.test.ts +2 -2
  9. package/src/components/HkCopyBadge.test.tsx +16 -14
  10. package/src/components/HkDraggableGrid.scss +2 -2
  11. package/src/components/HkDraggableList.scss +1 -1
  12. package/src/components/HkInput.password.test.tsx +33 -18
  13. package/src/components/HkInput.scss +3 -1
  14. package/src/components/HkListTransition.scss +5 -1
  15. package/src/components/HkMasonry.scss +53 -0
  16. package/src/components/HkMasonry.styles.test.ts +109 -0
  17. package/src/components/HkMasonry.test.tsx +211 -0
  18. package/src/components/HkMasonry.tsx +286 -0
  19. package/src/components/HkMinimap.tsx +1 -1
  20. package/src/components/HkModalBreadcrumb.test.tsx +1 -1
  21. package/src/components/HkNodeCanvas.tsx +2 -2
  22. package/src/components/HkPasswordSurface.scss +1 -1
  23. package/src/components/HkSelectionGrid.scss +9 -9
  24. package/src/components/HkSelectionWaterfall.scss +7 -7
  25. package/src/components/HkStepGuide.scss +1 -1
  26. package/src/components/HkTable.scss +1 -1
  27. package/src/components/HkTooltip.test.tsx +60 -60
  28. package/src/components/HkTooltip.tsx +36 -37
  29. package/src/composables/useRailIconFlight.test.ts +13 -5
  30. package/src/index.ts +7 -0
  31. package/src/runtime/createAuthGuard.ts +1 -1
  32. package/src/runtime/index.ts +10 -0
  33. package/src/runtime/tooltipBridge.test.ts +18 -1
  34. package/src/runtime/tooltipBridge.ts +10 -1
  35. package/src/runtime/tooltipPosition.test.ts +128 -0
  36. package/src/runtime/tooltipPosition.ts +55 -0
  37. package/src/runtime/touchHint.scss +73 -0
  38. package/src/runtime/touchHint.test.ts +407 -0
  39. package/src/runtime/touchHint.ts +576 -0
  40. package/src/styles/admin-tokens.scss +1 -1
  41. package/src/theme/_field.scss +1 -1
  42. package/src/theme/slotKinds.test.ts +1 -1
  43. package/src/theme/standardGroups.ts +1 -1
  44. package/src/theme/tokenGroups.test.ts +49 -49
  45. package/src/theme/tokenGroups.ts +4 -4
  46. package/src/utils/boardCamera.ts +3 -3
  47. package/src/utils/boardEdges.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.268",
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`]
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, h, nextTick } from "vue";
3
3
 
4
4
  import HkAuthMethodList from "./HkAuthMethodList";
@@ -15,6 +15,7 @@ function mount(node: ReturnType<typeof h>) {
15
15
  }
16
16
 
17
17
  afterEach(() => {
18
+ vi.useRealTimers();
18
19
  for (const { app, container } of mounts.splice(0)) {
19
20
  app.unmount();
20
21
  container.remove();
@@ -61,17 +62,19 @@ describe("HkAuthMethodList", () => {
61
62
  });
62
63
 
63
64
  it("wraps every tile in a tooltip carrying the provider label", async () => {
65
+ vi.useFakeTimers();
64
66
  const c = mount(
65
67
  h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })),
66
68
  );
67
69
  const wrappers = c.querySelectorAll(".hk-tooltip-wrapper");
68
70
  expect(wrappers.length).toBe(2);
69
- // Popups mount lazily on first engage — a touch tap mounts each one
70
- // immediately — and then carry the label text.
71
+ // Popups mount lazily on first engage — hover each tile through the
72
+ // 300ms show delay (a touch press no longer mounts the popup: the
73
+ // touch half is the long-press hint bar, never the anchored popup).
71
74
  for (const w of wrappers) {
72
- w.dispatchEvent(new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }));
75
+ w.dispatchEvent(new MouseEvent("mouseenter"));
73
76
  }
74
- await Promise.resolve();
77
+ await vi.advanceTimersByTimeAsync(300);
75
78
  const popups = [...document.querySelectorAll(".hk-tooltip-popup .hk-tooltip-content")]
76
79
  .map((el) => el.textContent);
77
80
  expect(popups).toContain("GitHub");
@@ -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
@@ -52,8 +52,8 @@ const SECTIONED_GROUP: TokenGroupDefinition = {
52
52
  label: { en: "Process media", "zh-Hans": "工艺介质" },
53
53
  slots: [
54
54
  {
55
- key: "h2",
56
- label: { en: "Hydrogen", "zh-Hans": "氢气" },
55
+ key: "buffer",
56
+ label: { en: "Buffer", "zh-Hans": "缓冲" },
57
57
  defaults: { dark: { r: 20, g: 184, b: 166 }, light: { r: 15, g: 118, b: 110 } },
58
58
  },
59
59
  ],
@@ -151,7 +151,7 @@ describe("HkColorSchemeDialog extension groups", () => {
151
151
  );
152
152
  expect(labels).toContain("L1 相(黄)");
153
153
  expect(labels).toContain("L2 相(绿)");
154
- expect(labels).toContain("氢气");
154
+ expect(labels).toContain("缓冲");
155
155
 
156
156
  await setLocale("en");
157
157
  });
@@ -50,8 +50,8 @@ const SECTIONED_GROUP: TokenGroupDefinition = {
50
50
  label: { en: "Process media", "zh-Hans": "工艺介质" },
51
51
  slots: [
52
52
  {
53
- key: "h2",
54
- label: { en: "Hydrogen", "zh-Hans": "氢气" },
53
+ key: "buffer",
54
+ label: { en: "Buffer", "zh-Hans": "缓冲" },
55
55
  defaults: { dark: { r: 20, g: 184, b: 166 }, light: { r: 15, g: 118, b: 110 } },
56
56
  },
57
57
  ],
@@ -21,6 +21,7 @@ function mount(renderNode: () => ReturnType<typeof h>) {
21
21
  }
22
22
 
23
23
  afterEach(() => {
24
+ vi.useRealTimers();
24
25
  for (const { app, container } of mounts.splice(0)) {
25
26
  app.unmount();
26
27
  container.remove();
@@ -128,33 +129,36 @@ describe("HkCopyBadge", () => {
128
129
  expect(writeText).not.toHaveBeenCalled();
129
130
  });
130
131
 
131
- /** Engage the lazy tooltip without hovering: a touch tap mounts the
132
- * popup immediately (showNow), no fake timers needed. */
133
- function engageTooltip(c: HTMLElement) {
134
- c.querySelector(".hk-tooltip-wrapper")!.dispatchEvent(
135
- new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }),
136
- );
132
+ /** Engage the lazy tooltip the way a mouse user does: hover the
133
+ * wrapper through the 300ms show delay. (A touch press no longer
134
+ * mounts the popup — the touch half is the long-press hint bar, so
135
+ * the tap vehicle this helper replaced is gone for good.) */
136
+ async function engageTooltip(c: HTMLElement) {
137
+ c.querySelector(".hk-tooltip-wrapper")!.dispatchEvent(new MouseEvent("mouseenter"));
138
+ await vi.advanceTimersByTimeAsync(300);
139
+ await nextTick();
137
140
  }
138
141
 
139
142
  it("renders the custom tooltip text", async () => {
143
+ vi.useFakeTimers();
140
144
  const c = mount(() => h(HkCopyBadge, { tooltip: "Open details" }, () => "#d.132"));
141
145
  // The popup mounts lazily on first engage (dense-list contract).
142
146
  expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
143
- engageTooltip(c);
144
- await nextTick();
147
+ await engageTooltip(c);
145
148
  const content = document.body.querySelector(".hk-tooltip-popup .hk-tooltip-content");
146
149
  expect(content?.textContent).toBe("Open details");
147
150
  });
148
151
 
149
152
  it("shows the localized click-to-copy tooltip by default", async () => {
153
+ vi.useFakeTimers();
150
154
  const c = mount(() => h(HkCopyBadge, null, () => "#d.132"));
151
- engageTooltip(c);
152
- await nextTick();
155
+ await engageTooltip(c);
153
156
  const content = document.body.querySelector(".hk-tooltip-popup .hk-tooltip-content");
154
157
  expect(content?.textContent).toBe("Click to copy");
155
158
  });
156
159
 
157
160
  it("links the badge to the popup via aria-describedby across the whole lifecycle", async () => {
161
+ vi.useFakeTimers();
158
162
  const c = mount(() => h(HkCopyBadge, { tooltip: "Open details" }, () => "#d.132"));
159
163
  const badge = c.querySelector(".hk-badge")!;
160
164
 
@@ -163,8 +167,7 @@ describe("HkCopyBadge", () => {
163
167
  expect(document.body.querySelector(".hk-tooltip-popup")).toBeNull();
164
168
 
165
169
  // Engage: describedby == the popup id.
166
- engageTooltip(c);
167
- await nextTick();
170
+ await engageTooltip(c);
168
171
  const popup = document.body.querySelector(".hk-tooltip-popup")!;
169
172
  expect(badge.getAttribute("aria-describedby")).toBe(popup.getAttribute("id"));
170
173
  expect(popup.querySelector(".hk-tooltip-content")!.textContent).toBe("Open details");
@@ -175,8 +178,7 @@ describe("HkCopyBadge", () => {
175
178
  expect(badge.getAttribute("aria-describedby")).toBeNull();
176
179
 
177
180
  // Re-engage: returns, reusing the same popup element.
178
- engageTooltip(c);
179
- await nextTick();
181
+ await engageTooltip(c);
180
182
  expect(badge.getAttribute("aria-describedby")).toBe(
181
183
  document.body.querySelector(".hk-tooltip-popup")!.getAttribute("id"),
182
184
  );
@@ -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
 
@@ -34,7 +34,8 @@ const fakeCreateImageData = fakeFn<CanvasRenderingContext2D["createImageData"]>(
34
34
  * click-to-toggle with auto-hide (revealAutoHideMs)
35
35
  * - the strength dot classifies through the shared passwordLevel util
36
36
  * unless strengthEvaluator overrides it, and carries a localized
37
- * tooltip that opens on hover AND on touch tap
37
+ * tooltip that opens on hover (desktop) / via the touch long-press
38
+ * hint bar (the touch half is the top bar, never the anchored popup)
38
39
  * - the native input stays type="password" at all times — the reveal
39
40
  * pass draws on the canvas, so the value is never DOM text
40
41
  */
@@ -108,10 +109,14 @@ function fireBeforeinput(input: HTMLInputElement, inputType: string) {
108
109
  }
109
110
 
110
111
  afterEach(() => {
112
+ vi.useRealTimers();
111
113
  for (const { app, container } of mounts.splice(0)) {
112
114
  app.unmount();
113
115
  container.remove();
114
116
  }
117
+ // The touch hint bar is presenter-owned module state (runtime/
118
+ // touchHint.ts) — tear the node out so a case remounts it fresh.
119
+ document.querySelectorAll(".hk-touch-hint-bar").forEach((el) => el.remove());
115
120
  });
116
121
 
117
122
  describe("HkInput password surface identity", () => {
@@ -566,32 +571,42 @@ describe("HkInput password strength traffic light", () => {
566
571
  ).toBe(false);
567
572
  });
568
573
 
569
- it("opens the tooltip immediately on a touch tap and closes on an outside tap", async () => {
574
+ it("hints the strength on touch through the long-press bar, never the anchored popup", async () => {
575
+ vi.useFakeTimers();
570
576
  const { container } = mountPasswordInput("Password1", {
571
577
  passwordTrailing: "strength",
572
578
  });
573
579
  const wrapper = container.querySelector<HTMLElement>(".hk-tooltip-wrapper")!;
574
580
 
581
+ // A quick tap shows NOTHING: neither the anchored popup (hover/focus
582
+ // only) nor the top hint bar (the hold never elapsed).
575
583
  wrapper.dispatchEvent(
576
- new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }),
584
+ new PointerEvent("pointerdown", { pointerType: "touch", pointerId: 1, bubbles: true }),
577
585
  );
578
- await nextTick();
579
-
580
- let popup = document.querySelector<HTMLElement>(".hk-tooltip-popup");
581
- expect(popup?.classList.contains("hk-tooltip-visible")).toBe(true);
582
- expect(popup?.textContent).toContain("Fair");
583
-
584
- // Tap somewhere else: the capture-phase document listener hides it.
585
- const outside = document.createElement("div");
586
- document.body.appendChild(outside);
587
- outside.dispatchEvent(
588
- new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }),
586
+ await vi.advanceTimersByTimeAsync(100);
587
+ document.dispatchEvent(
588
+ new PointerEvent("pointerup", { pointerType: "touch", pointerId: 1, bubbles: true }),
589
589
  );
590
- await nextTick();
590
+ await vi.advanceTimersByTimeAsync(1000);
591
+ expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
592
+ expect(document.querySelector(".hk-touch-hint-bar")).toBeNull();
591
593
 
592
- popup = document.querySelector<HTMLElement>(".hk-tooltip-popup");
593
- expect(popup?.classList.contains("hk-tooltip-visible")).toBe(false);
594
- outside.remove();
594
+ // The long-press fires the top-docked bar with the strength text…
595
+ wrapper.dispatchEvent(
596
+ new PointerEvent("pointerdown", { pointerType: "touch", pointerId: 1, bubbles: true }),
597
+ );
598
+ await vi.advanceTimersByTimeAsync(450);
599
+ const bar = document.querySelector<HTMLElement>(".hk-touch-hint-bar");
600
+ expect(bar?.classList.contains("is-on")).toBe(true);
601
+ expect(bar?.textContent).toContain("Fair");
602
+ expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
603
+
604
+ // …the lift starts the linger window, and the bar retires after it.
605
+ document.dispatchEvent(
606
+ new PointerEvent("pointerup", { pointerType: "touch", pointerId: 1, bubbles: true }),
607
+ );
608
+ await vi.advanceTimersByTimeAsync(2200);
609
+ expect(bar?.classList.contains("is-on")).toBe(false);
595
610
  });
596
611
  });
597
612
 
@@ -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
+ });