@celestia-island/hikari 0.55.20 → 0.55.22

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.20",
3
+ "version": "0.55.22",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -1334,8 +1334,8 @@ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
1334
1334
  * carved out of the ink spatter (R3 F2: without this the password
1335
1335
  * would render INVISIBLE on a real canvas, silently). */
1336
1336
  compositeOps: string[];
1337
- /** Gradient stop colors, in order — pins the halo's THEME color
1338
- * (white over a dark ground, black over a light one). */
1337
+ /** Gradient stop colors, in order — pins the halo's color (the
1338
+ * filter halo is ALWAYS white). */
1339
1339
  gradientStops: string[];
1340
1340
  /** Font string assignments — pins the BOLD glyph aperture. */
1341
1341
  fonts: string[];
@@ -1583,9 +1583,9 @@ describe("HkInput password reveal strategies", () => {
1583
1583
  it("filter (default) keeps glyphs off the visible canvas: counter-drifting spatter, pedestal, halo", async () => {
1584
1584
  // The default reveal: STATIC BOLD glyph apertures filled with one
1585
1585
  // GRAYSCALE spatter texture, over a statistically matched spatter
1586
- // field drifting the opposite way on the theme-anchored ground;
1587
- // the glyphs shifted by a small pedestal toward the theme's
1588
- // visibility direction with a halo band around the row. Glyph
1586
+ // field streaming the opposite way on the uniform near-black
1587
+ // ground; the glyphs lifted by a small brightening pedestal with a
1588
+ // white halo band around the row. Glyph
1589
1589
  // geometry must NEVER reach the visible canvas (mask → source-in
1590
1590
  // stamp only) — that is the screenshot contract. Math.random is
1591
1591
  // pinned at 0.5 so the dot lightness equals the exact layer base
@@ -1622,15 +1622,20 @@ describe("HkInput password reveal strategies", () => {
1622
1622
  expect(vis.gradientFills, "halo band drawn").toBeGreaterThan(0);
1623
1623
  expect(vis.drawImages).toBeGreaterThan(0);
1624
1624
  // The two spatter tiles: bg first, ink second (deterministic
1625
- // draw order), each with hundreds of solid-color dot fills.
1626
- // happy-dom resolves no computed color, so the ink stays at the
1627
- // pre-sync fallback (a DARK ink) — the effective theme here is
1628
- // LIGHT: near-white ground, dark-gray speckle, and the pedestal
1629
- // shifts the glyph layer DARKER (visibility direction).
1625
+ // draw order), each with hundreds of solid-color dot fills. The
1626
+ // anchors are UNIFORM (white-on-black reading in every theme):
1627
+ // near-black ground, light-gray speckle, and the pedestal lifts
1628
+ // the glyph layer BRIGHTER. happy-dom resolves no computed
1629
+ // color, so the ink stays at the pre-sync fallback (a DARK ink)
1630
+ // — irrelevant now, the anchors follow nothing.
1630
1631
  const tiles = allRecs.filter(
1631
1632
  (r) => r.fillStyles.filter((s) => s.startsWith("rgb(")).length > 100,
1632
1633
  );
1633
1634
  expect(tiles.length, "exactly two spatter tiles").toBe(2);
1635
+ expect(
1636
+ tiles[0]!.fillStyles.length,
1637
+ "both layers carry the same dot density",
1638
+ ).toBe(tiles[1]!.fillStyles.length);
1634
1639
 
1635
1640
  // Backing-store tripwire (R3 F1): a shrunken tile silently
1636
1641
  // drops ~75% of its dots and desyncs the pattern period from
@@ -1653,8 +1658,8 @@ describe("HkInput password reveal strategies", () => {
1653
1658
  const bgMean = meanOf(tiles[0]!);
1654
1659
  const inkMean = meanOf(tiles[1]!);
1655
1660
  expect(
1656
- bgMean - inkMean,
1657
- `light theme: glyph pedestal shifts DARKER (bg ${bgMean.toFixed(1)} vs ink ${inkMean.toFixed(1)})`,
1661
+ inkMean - bgMean,
1662
+ `glyph pedestal lifts BRIGHTER (bg ${bgMean.toFixed(1)} vs ink ${inkMean.toFixed(1)})`,
1658
1663
  ).toBeGreaterThan(8);
1659
1664
  // Strict grayscale: no theme hue may survive into the spatter —
1660
1665
  // every solid fill is a NEUTRAL gray (r === g === b).
@@ -1667,19 +1672,18 @@ describe("HkInput password reveal strategies", () => {
1667
1672
  ).toBe(true);
1668
1673
  }
1669
1674
  }
1670
- // Theme anchor: the LIGHT theme's ground is near-white (first
1671
- // fill on the bg tile is the ground wash; L 93 → rgb ≈ 237).
1675
+ // Ground anchor: ALWAYS near-black (first fill on the bg tile
1676
+ // is the ground wash; L 10 → rgb ≈ 26).
1672
1677
  const [gr, gg, gb] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
1673
- expect(gr!, "light-theme ground is near-white").toBeGreaterThan(230);
1674
- expect(gr!).toBeLessThan(245);
1678
+ expect(gr!, "ground is ALWAYS near-black").toBeGreaterThan(18);
1679
+ expect(gr!).toBeLessThan(34);
1675
1680
  expect(gg!).toBe(gr);
1676
1681
  expect(gb!).toBe(gr);
1677
- // Halo color follows the theme: BLACK over a light ground —
1678
- // peak AND endpoints share the theme channel (R2 F1).
1679
- expect(vis.gradientStops, "light theme paints a BLACK halo").toContain("rgba(0,0,0,0.1)");
1680
- expect(vis.gradientStops).not.toContain("rgba(255,255,255,0.1)");
1681
- expect(vis.gradientStops, "light halo endpoints fade on BLACK").toContain("rgba(0,0,0,0)");
1682
- expect(vis.gradientStops).not.toContain("rgba(255,255,255,0)");
1682
+ // Halo is ALWAYS white — peak AND endpoints (R2 F1).
1683
+ expect(vis.gradientStops, "halo is always WHITE").toContain("rgba(255,255,255,0.1)");
1684
+ expect(vis.gradientStops).not.toContain("rgba(0,0,0,0.1)");
1685
+ expect(vis.gradientStops, "halo endpoints fade on WHITE").toContain("rgba(255,255,255,0)");
1686
+ expect(vis.gradientStops).not.toContain("rgba(0,0,0,0)");
1683
1687
  // The glyph apertures rasterize BOLD.
1684
1688
  expect(
1685
1689
  mask!.fonts.some((f) => f.startsWith("bold ")),
@@ -1720,8 +1724,8 @@ describe("HkInput password reveal strategies", () => {
1720
1724
  });
1721
1725
 
1722
1726
  /**
1723
- * Force the computed `.color` (the reveal's ink + theme proxy) while
1724
- * forwarding every other computed-style query to the real engine.
1727
+ * Force the computed `.color` (the reveal's ink) while forwarding
1728
+ * every other computed-style query to the real engine.
1725
1729
  * Returns a restore function.
1726
1730
  */
1727
1731
  function forceInkColor(color: string): () => void {
@@ -1743,12 +1747,11 @@ describe("HkInput password reveal strategies", () => {
1743
1747
  };
1744
1748
  }
1745
1749
 
1746
- it("filter inverts its grayscale anchors for a dark theme (light field ink)", async () => {
1747
- // Light field ink = a dark theme: near-black ground, light-gray
1748
- // speckle, a BRIGHTER glyph pedestal and a WHITE halo — every
1749
- // anchor flips with the theme while the mechanism stays identical.
1750
- // The theme proxy is the computed field-ink lightness (>= 50 =
1751
- // dark theme), so feed a light ink through getComputedStyle.
1750
+ it("filter anchors white-on-black regardless of the field ink (light ink)", async () => {
1751
+ // UNIFORMITY pin: the anchors follow NOTHING — feed a LIGHT field
1752
+ // ink through getComputedStyle and the reveal must still paint the
1753
+ // same near-black ground, light-gray speckle, brighter pedestal
1754
+ // and white halo as the default (dark-ink) mount.
1752
1755
  const restoreInk = forceInkColor("rgb(148, 233, 211)");
1753
1756
  const rec = stubRecordingContexts();
1754
1757
  try {
@@ -1778,7 +1781,7 @@ describe("HkInput password reveal strategies", () => {
1778
1781
  const inkMean = meanOf(tiles[1]!);
1779
1782
  expect(
1780
1783
  inkMean - bgMean,
1781
- `dark theme: glyph pedestal shifts BRIGHTER (bg ${bgMean.toFixed(1)} vs ink ${inkMean.toFixed(1)})`,
1784
+ `pedestal lifts BRIGHTER under a light ink too (bg ${bgMean.toFixed(1)} vs ink ${inkMean.toFixed(1)})`,
1782
1785
  ).toBeGreaterThan(8);
1783
1786
  // Strict grayscale on the dark side too (R2 F4).
1784
1787
  for (const tile of tiles) {
@@ -1818,17 +1821,16 @@ describe("HkInput password reveal strategies", () => {
1818
1821
  groundDelta >= 20 && groundDelta <= 30,
1819
1822
  `grounds one pedestal apart (delta ${groundDelta})`,
1820
1823
  ).toBe(true);
1821
- // Near-black ground: L 10 → rgb ≈ 26.
1824
+ // Near-black ground regardless of the ink: L 10 → rgb ≈ 26.
1822
1825
  const [gr, gg, gb] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
1823
- expect(gr!, "dark-theme ground is near-black").toBeGreaterThan(18);
1826
+ expect(gr!, "ground near-black under a light ink").toBeGreaterThan(18);
1824
1827
  expect(gr!).toBeLessThan(34);
1825
1828
  expect(gg!).toBe(gr);
1826
1829
  expect(gb!).toBe(gr);
1827
- // White halo over the dark ground — peak AND endpoints share
1828
- // the theme channel (an endpoint desync must go red, R2 F1).
1829
- expect(vis.gradientStops, "dark theme paints a WHITE halo").toContain("rgba(255,255,255,0.1)");
1830
+ // White halo under a light ink too — peak AND endpoints (R2 F1).
1831
+ expect(vis.gradientStops, "halo stays WHITE under a light ink").toContain("rgba(255,255,255,0.1)");
1830
1832
  expect(vis.gradientStops).not.toContain("rgba(0,0,0,0.1)");
1831
- expect(vis.gradientStops, "dark halo endpoints fade on WHITE").toContain("rgba(255,255,255,0)");
1833
+ expect(vis.gradientStops, "halo endpoints fade on WHITE").toContain("rgba(255,255,255,0)");
1832
1834
  expect(vis.gradientStops).not.toContain("rgba(0,0,0,0)");
1833
1835
  // Same screenshot contract and bold apertures as the light theme.
1834
1836
  const mask = allRecs.find((r) => r.texts.length > 0)!;
@@ -1843,11 +1845,11 @@ describe("HkInput password reveal strategies", () => {
1843
1845
  }
1844
1846
  });
1845
1847
 
1846
- it("filter keeps the documented light-anchor degrade for modern color-function inks", async () => {
1848
+ it("filter degrades without throwing for modern color-function inks", async () => {
1847
1849
  // oklch()/lab()/color() inks cannot be parsed into a triple — the
1848
- // documented degrade keeps the fallback ink (a DARK ink → light
1849
- // anchors), never throwing and never guessing a theme. Locked as
1850
- // intentional (R1 finding F1).
1850
+ // documented degrade keeps the fallback ink and NEVER throws or
1851
+ // guesses; the anchors are uniform anyway, so the reveal keeps
1852
+ // painting the same near-black ground + white halo.
1851
1853
  const restoreInk = forceInkColor("oklch(70% 0.1 200)");
1852
1854
  const rec = stubRecordingContexts();
1853
1855
  try {
@@ -1865,8 +1867,9 @@ describe("HkInput password reveal strategies", () => {
1865
1867
  );
1866
1868
  expect(tiles.length).toBe(2);
1867
1869
  const [gr] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
1868
- expect(gr!, "fallback ink → LIGHT anchors (near-white ground)").toBeGreaterThan(230);
1869
- expect(vis.gradientStops, "black halo in the degrade").toContain("rgba(0,0,0,0.1)");
1870
+ expect(gr!, "degrade keeps the uniform near-black ground").toBeGreaterThan(18);
1871
+ expect(gr!).toBeLessThan(34);
1872
+ expect(vis.gradientStops, "white halo in the degrade").toContain("rgba(255,255,255,0.1)");
1870
1873
  document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1871
1874
  await nextTick();
1872
1875
  } finally {
@@ -1877,15 +1880,10 @@ describe("HkInput password reveal strategies", () => {
1877
1880
 
1878
1881
  it("filter degrades the same way for color()-function inks", async () => {
1879
1882
  // Locks the DEGRADE SEMANTICS for color() inks: the modern-
1880
- // function rejection yields null → the fallback dark ink → LIGHT
1881
- // anchors. Honest limit (verified during R2 remedies): spec-valid
1882
- // color() values are 0–1 floats, so even a DELETED guard parses
1883
- // them into sub-1 triples (L < 1 → light) — guard deletion is not
1884
- // observable through the filter theme. The guard's real consumer
1885
- // is the NOISE painter's colored tiles (it prevents deriving a
1886
- // garbage hue from e.g. [0.5,0.2,0.8]); this pin keeps any future
1887
- // "auto-interpret color()" change (×255 scaling → L 50 → dark)
1888
- // from silently re-theming the filter.
1883
+ // function rejection yields null → the fallback ink → the same
1884
+ // uniform anchors, never a thrown parse. (The guard also protects
1885
+ // the NOISE painter's colored tiles from garbage hues — that side
1886
+ // is pinned by the parseColorTriple unit tests.)
1889
1887
  const restoreInk = forceInkColor("color(display-p3 1 0 0)");
1890
1888
  const rec = stubRecordingContexts();
1891
1889
  try {
@@ -1901,35 +1899,7 @@ describe("HkInput password reveal strategies", () => {
1901
1899
  );
1902
1900
  expect(tiles.length).toBe(2);
1903
1901
  const [gr] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
1904
- expect(gr!, "color() ink degrades to the LIGHT anchors").toBeGreaterThan(230);
1905
- document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1906
- await nextTick();
1907
- } finally {
1908
- rec.restore();
1909
- restoreInk();
1910
- }
1911
- });
1912
-
1913
- it("filter treats exactly-50 ink lightness as a dark theme (boundary)", async () => {
1914
- // rgb(255,0,0): max+min halves to EXACTLY 0.5 (128/255 does not)
1915
- // — L === 50 on the nose, so the >= side of the theme proxy wins
1916
- // and the reveal anchors dark (R1 finding F2).
1917
- const restoreInk = forceInkColor("rgb(255, 0, 0)");
1918
- const rec = stubRecordingContexts();
1919
- try {
1920
- const { container } = mountPasswordInput("abc");
1921
- const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1922
- eye.dispatchEvent(
1923
- new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1924
- );
1925
- await nextTick();
1926
- const allRecs = Array.from(rec.byCanvas.values());
1927
- const tiles = allRecs.filter(
1928
- (r) => r.fillStyles.filter((s) => s.startsWith("rgb(")).length > 100,
1929
- );
1930
- expect(tiles.length).toBe(2);
1931
- const [gr] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
1932
- expect(gr!, "L === 50 anchors DARK (near-black ground)").toBeGreaterThan(18);
1902
+ expect(gr!, "color() ink degrades to the uniform near-black ground").toBeGreaterThan(18);
1933
1903
  expect(gr!).toBeLessThan(34);
1934
1904
  document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1935
1905
  await nextTick();
@@ -77,15 +77,14 @@ export default defineComponent({
77
77
  /**
78
78
  * Reveal content for the password eye (`variant="password"`,
79
79
  * `passwordTrailing="eye"`):
80
- * - "filter" (default): dual counter-drifting spatter layers in
81
- * strict black/white/gray — static BOLD glyph apertures filled
82
- * with one gray spatter texture over a statistically matched
83
- * field drifting the opposite way on the theme's ground
84
- * (near-black in a dark theme, near-white in a light one),
85
- * plus a small visibility-direction lightness pedestal and a
86
- * halo band around the row. The whole row stays readable in
87
- * motion; a single-frame screenshot carries no glyph structure,
88
- * only a weak luminance signal dissolved into the halo.
80
+ * - "filter" (default): dual FAST counter-drifting spatter layers
81
+ * in strict black/white/gray — static BOLD glyph apertures
82
+ * filled with one gray spatter texture over a statistically
83
+ * matched field streaming the opposite way on a UNIFORM
84
+ * near-black ground (white-on-black in every theme), plus a
85
+ * small brightening pedestal and a white halo; the speed lets
86
+ * persistence of vision average the glyphs readable. A
87
+ * single-frame screenshot carries no glyph structure.
89
88
  * - "sweep": a readable window — ordinary high-contrast text inside
90
89
  * a narrow band sweeping across the row over the boiling-noise
91
90
  * field. Easy to read; a single screenshot leaks the characters
@@ -0,0 +1,246 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkNodeCanvas from "./HkNodeCanvas";
5
+ import {
6
+ computeLod,
7
+ edgeMidpoint,
8
+ edgePath,
9
+ LOD_DEFAULTS,
10
+ } from "./nodeCanvasTypes";
11
+
12
+ /** Mount a component into a detached div and return the root element. */
13
+ function mountToDom(component: ReturnType<typeof defineComponent>): HTMLElement {
14
+ const el = document.createElement("div");
15
+ document.body.appendChild(el);
16
+ const app = createApp(component);
17
+ app.mount(el);
18
+ return el;
19
+ }
20
+
21
+ const CanvasHost = defineComponent({
22
+ props: {
23
+ edges: { type: Array, default: () => [] },
24
+ printMode: { type: Boolean, default: false },
25
+ printPaper: { type: String as () => "a4-landscape" | "a3-landscape" | "auto", default: "auto" },
26
+ minimap: { type: Boolean, default: false },
27
+ },
28
+ setup(props) {
29
+ return () =>
30
+ h(HkNodeCanvas, {
31
+ edges: props.edges as never[],
32
+ printMode: props.printMode,
33
+ printPaper: props.printPaper,
34
+ minimap: props.minimap,
35
+ }, { default: () => h("div", "content") });
36
+ },
37
+ });
38
+
39
+ /**
40
+ * HkNodeCanvas base upgrade tests — the new rendering capabilities:
41
+ * edge rendering, LOD, print mode, painter registration, and export.
42
+ */
43
+ describe("HkNodeCanvas rendering base upgrades", () => {
44
+ describe("nodeCanvasTypes", () => {
45
+ it("computes LOD from zoom factor and thresholds", () => {
46
+ expect(computeLod(1.0, LOD_DEFAULTS)).toBe("high");
47
+ expect(computeLod(0.5, LOD_DEFAULTS)).toBe("high");
48
+ expect(computeLod(0.45, LOD_DEFAULTS)).toBe("high");
49
+ expect(computeLod(0.44, LOD_DEFAULTS)).toBe("medium");
50
+ expect(computeLod(0.18, LOD_DEFAULTS)).toBe("medium");
51
+ expect(computeLod(0.17, LOD_DEFAULTS)).toBe("low");
52
+ expect(computeLod(0.05, LOD_DEFAULTS)).toBe("low");
53
+ });
54
+
55
+ it("supports custom LOD thresholds", () => {
56
+ const custom = { medium: 0.8, low: 0.3 };
57
+ expect(computeLod(0.9, custom)).toBe("high");
58
+ expect(computeLod(0.75, custom)).toBe("medium");
59
+ expect(computeLod(0.2, custom)).toBe("low");
60
+ });
61
+
62
+ it("generates a direct edge path", () => {
63
+ const path = edgePath(
64
+ { from: { x: 0, y: 0 }, to: { x: 100, y: 50 } },
65
+ "direct",
66
+ );
67
+ expect(path).toBe("M 0 0 L 100 50");
68
+ });
69
+
70
+ it("generates a bezier edge path with correct control points", () => {
71
+ const path = edgePath(
72
+ { from: { x: 0, y: 0 }, to: { x: 200, y: 100 } },
73
+ "bezier",
74
+ );
75
+ // cp = max(|dx| * 0.4, 40) = max(80, 40) = 80
76
+ // C (from.x+80) from.y, (to.x-80) to.y, to.x to.y
77
+ expect(path).toBe("M 0 0 C 80 0, 120 100, 200 100");
78
+ });
79
+
80
+ it("uses minimum control point offset for short edges", () => {
81
+ // dx = 50 → cp = max(20, 40) = 40 (minimum kicks in)
82
+ const path = edgePath(
83
+ { from: { x: 0, y: 0 }, to: { x: 50, y: 0 } },
84
+ "bezier",
85
+ );
86
+ expect(path).toBe("M 0 0 C 40 0, 10 0, 50 0");
87
+ });
88
+
89
+ it("generates an orthogonal (manhattan) edge path", () => {
90
+ const path = edgePath(
91
+ { from: { x: 0, y: 0 }, to: { x: 100, y: 100 } },
92
+ "orthogonal",
93
+ );
94
+ expect(path).toBe("M 0 0 L 50 0 L 50 100 L 100 100");
95
+ });
96
+
97
+ it("computes edge midpoints", () => {
98
+ const mid = edgeMidpoint(
99
+ { from: { x: 0, y: 0 }, to: { x: 100, y: 100 } },
100
+ "direct",
101
+ );
102
+ expect(mid).toEqual({ x: 50, y: 50 });
103
+ });
104
+ });
105
+
106
+ describe("HkNodeCanvas component", () => {
107
+ it("mounts without edges (backward compatible)", () => {
108
+ const el = mountToDom(CanvasHost);
109
+ const canvas = el.querySelector(".hk-node-canvas");
110
+ expect(canvas).toBeTruthy();
111
+ const layer = el.querySelector(".hk-node-canvas-layer");
112
+ expect(layer?.textContent).toBe("content");
113
+ // No edge layer when edges is empty.
114
+ expect(el.querySelector(".hk-node-canvas-edges")).toBeNull();
115
+ el.remove();
116
+ });
117
+
118
+ it("renders edges as SVG paths", async () => {
119
+ const el = mountToDom(defineComponent({
120
+ setup() {
121
+ return () =>
122
+ h(HkNodeCanvas, {
123
+ minimap: false,
124
+ edges: [
125
+ { id: "e1", from: { x: 0, y: 0 }, to: { x: 100, y: 50 }, label: "proxy" },
126
+ { id: "e2", from: { x: 50, y: 50 }, to: { x: 150, y: 150 }, routing: "direct" as const },
127
+ ],
128
+ }, { default: () => h("div") });
129
+ },
130
+ }));
131
+ await nextTick();
132
+
133
+ const paths = el.querySelectorAll(".hk-node-canvas-edge path");
134
+ expect(paths.length).toBe(2);
135
+ // First edge uses bezier by default.
136
+ expect(paths[0].getAttribute("d")).toMatch(/^M 0 0 C /);
137
+ // Second edge uses direct routing (override).
138
+ expect(paths[1].getAttribute("d")).toBe("M 50 50 L 150 150");
139
+ // Edge label is rendered.
140
+ const label = el.querySelector(".hk-node-canvas-edge text");
141
+ expect(label?.textContent).toBe("proxy");
142
+ el.remove();
143
+ });
144
+
145
+ it("renders dashed edges with stroke-dasharray", async () => {
146
+ const el = mountToDom(defineComponent({
147
+ setup() {
148
+ return () =>
149
+ h(HkNodeCanvas, {
150
+ minimap: false,
151
+ edges: [
152
+ { id: "e1", from: { x: 0, y: 0 }, to: { x: 100, y: 0 }, dashed: true },
153
+ ],
154
+ }, { default: () => h("div") });
155
+ },
156
+ }));
157
+ await nextTick();
158
+ const path = el.querySelector(".hk-node-canvas-edge path");
159
+ expect(path?.getAttribute("stroke-dasharray")).toBe("6 4");
160
+ el.remove();
161
+ });
162
+
163
+ it("renders a canvas layer element", () => {
164
+ const el = mountToDom(CanvasHost);
165
+ const canvas = el.querySelector("canvas.hk-node-canvas-canvas");
166
+ expect(canvas).toBeTruthy();
167
+ el.remove();
168
+ });
169
+
170
+ it("exposes levelOfDetail, registerPainter, exportSVG", () => {
171
+ const exposed: Record<string, unknown> = {};
172
+ const Host = defineComponent({
173
+ setup() {
174
+ const canvasRef = ref<unknown>(null);
175
+ const check = () => {
176
+ const vm = canvasRef.value as Record<string, unknown> | null;
177
+ if (vm) {
178
+ Object.assign(exposed, {
179
+ levelOfDetail: vm.levelOfDetail,
180
+ registerPainter: vm.registerPainter,
181
+ unregisterPainter: vm.unregisterPainter,
182
+ exportSVG: vm.exportSVG,
183
+ exportPNG: vm.exportPNG,
184
+ });
185
+ }
186
+ };
187
+ return () =>
188
+ h(HkNodeCanvas, {
189
+ ref: canvasRef,
190
+ minimap: false,
191
+ camera: { k: 1, x: 0, y: 0 },
192
+ onVnodeMounted: check,
193
+ }, { default: () => h("div") });
194
+ },
195
+ });
196
+ const el = mountToDom(Host);
197
+ expect(exposed.levelOfDetail).toBeDefined();
198
+ expect(typeof exposed.registerPainter).toBe("function");
199
+ expect(typeof exposed.unregisterPainter).toBe("function");
200
+ expect(typeof exposed.exportSVG).toBe("function");
201
+ expect(typeof exposed.exportPNG).toBe("function");
202
+ // LOD at zoom=1 should be "high". Vue expose auto-unwraps refs,
203
+ // so this may be a string or a ref depending on the consumer pattern.
204
+ const lod = exposed.levelOfDetail;
205
+ const lodValue = typeof lod === "string" ? lod : (lod as { value: string }).value;
206
+ expect(lodValue).toBe("high");
207
+ el.remove();
208
+ });
209
+
210
+ it("printMode disables minimap and gestures", async () => {
211
+ const el = mountToDom(defineComponent({
212
+ setup() {
213
+ return () =>
214
+ h(HkNodeCanvas, {
215
+ minimap: true,
216
+ printMode: true,
217
+ }, { default: () => h("div") });
218
+ },
219
+ }));
220
+ await nextTick();
221
+ const root = el.querySelector(".hk-node-canvas");
222
+ expect(root).toBeTruthy();
223
+ expect(root!.classList.contains("hk-node-canvas--print")).toBe(true);
224
+ expect(el.querySelector(".hk-node-canvas-minimap")).toBeNull();
225
+ expect(root!.getAttribute("data-pannable")).toBeNull();
226
+ el.remove();
227
+ });
228
+
229
+ it("printMode with paper size sets fixed dimensions", async () => {
230
+ const el = mountToDom(defineComponent({
231
+ setup() {
232
+ return () =>
233
+ h(HkNodeCanvas, {
234
+ printMode: true,
235
+ printPaper: "a4-landscape" as const,
236
+ }, { default: () => h("div") });
237
+ },
238
+ }));
239
+ await nextTick();
240
+ const root = el.querySelector(".hk-node-canvas") as HTMLElement;
241
+ expect(root.style.width).toBe("1122.5px");
242
+ expect(root.style.height).toBe("793.7px");
243
+ el.remove();
244
+ });
245
+ });
246
+ });