@celestia-island/hikari 0.55.21 → 0.55.23
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/HkInput.password.test.tsx +114 -88
- package/src/components/HkInput.tsx +5 -7
- package/src/components/HkNodeCanvas.base.test.tsx +246 -0
- package/src/components/HkNodeCanvas.gestures.test.tsx +158 -0
- package/src/components/HkNodeCanvas.tsx +468 -13
- package/src/components/HkPasswordSurface.tsx +20 -41
- package/src/components/nodeCanvasTypes.ts +170 -0
- package/src/components/revealKinematogram.test.ts +55 -11
- package/src/components/revealKinematogram.ts +100 -75
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import { Comment, createApp, createVNode, h, nextTick, ref, type Slot } from "vu
|
|
|
3
3
|
|
|
4
4
|
import HkInput from "./HkInput";
|
|
5
5
|
import HkPasswordSurface from "./HkPasswordSurface";
|
|
6
|
-
import { NOISE_TILE_H, NOISE_TILE_W } from "./revealKinematogram";
|
|
6
|
+
import { FILTER_PEDESTAL_L, NOISE_TILE_H, NOISE_TILE_W } from "./revealKinematogram";
|
|
7
7
|
import { passwordLevel } from "../utils/password";
|
|
8
8
|
import { setReducedMotion } from "../runtime/animationBus";
|
|
9
9
|
|
|
@@ -1334,11 +1334,13 @@ 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
|
|
1338
|
-
*
|
|
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[];
|
|
1342
|
+
/** arc() radii, in call order — pins the spatter dot SIZE band. */
|
|
1343
|
+
arcRadii: number[];
|
|
1342
1344
|
fillStyles: string[];
|
|
1343
1345
|
clips: Array<{ x: number; y: number; w: number; h: number }>;
|
|
1344
1346
|
translates: number[];
|
|
@@ -1361,6 +1363,7 @@ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
|
|
|
1361
1363
|
compositeOps: [],
|
|
1362
1364
|
gradientStops: [],
|
|
1363
1365
|
fonts: [],
|
|
1366
|
+
arcRadii: [],
|
|
1364
1367
|
fillStyles: [],
|
|
1365
1368
|
clips: [],
|
|
1366
1369
|
translates: [],
|
|
@@ -1380,7 +1383,7 @@ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
|
|
|
1380
1383
|
translate: (x: number) => r.translates.push(x),
|
|
1381
1384
|
rotate: () => {},
|
|
1382
1385
|
beginPath: () => {},
|
|
1383
|
-
arc: () =>
|
|
1386
|
+
arc: (_x: number, _y: number, radius: number) => r.arcRadii.push(radius),
|
|
1384
1387
|
fill: () => {},
|
|
1385
1388
|
fillRect: () => {
|
|
1386
1389
|
if (gradientArmed) {
|
|
@@ -1582,10 +1585,10 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1582
1585
|
|
|
1583
1586
|
it("filter (default) keeps glyphs off the visible canvas: counter-drifting spatter, pedestal, halo", async () => {
|
|
1584
1587
|
// The default reveal: STATIC BOLD glyph apertures filled with one
|
|
1585
|
-
// GRAYSCALE spatter texture, over a statistically matched
|
|
1586
|
-
// field drifting the opposite way on the
|
|
1587
|
-
// the glyphs
|
|
1588
|
-
//
|
|
1588
|
+
// BRIGHT GRAYSCALE spatter texture, over a statistically matched
|
|
1589
|
+
// DIM spatter field drifting the opposite way on the uniform
|
|
1590
|
+
// near-black ground; the glyphs lifted by a LARGE brightening
|
|
1591
|
+
// pedestal with a white halo band around the row. Glyph
|
|
1589
1592
|
// geometry must NEVER reach the visible canvas (mask → source-in
|
|
1590
1593
|
// stamp only) — that is the screenshot contract. Math.random is
|
|
1591
1594
|
// pinned at 0.5 so the dot lightness equals the exact layer base
|
|
@@ -1622,15 +1625,20 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1622
1625
|
expect(vis.gradientFills, "halo band drawn").toBeGreaterThan(0);
|
|
1623
1626
|
expect(vis.drawImages).toBeGreaterThan(0);
|
|
1624
1627
|
// The two spatter tiles: bg first, ink second (deterministic
|
|
1625
|
-
// draw order), each with hundreds of solid-color dot fills.
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
1629
|
-
//
|
|
1628
|
+
// draw order), each with hundreds of solid-color dot fills. The
|
|
1629
|
+
// anchors are UNIFORM (white-on-black reading in every theme):
|
|
1630
|
+
// near-black ground, dim-gray speckle, and the pedestal lifts
|
|
1631
|
+
// the glyph layer BRIGHTER. happy-dom resolves no computed
|
|
1632
|
+
// color, so the ink stays at the pre-sync fallback (a DARK ink)
|
|
1633
|
+
// — irrelevant now, the anchors follow nothing.
|
|
1630
1634
|
const tiles = allRecs.filter(
|
|
1631
1635
|
(r) => r.fillStyles.filter((s) => s.startsWith("rgb(")).length > 100,
|
|
1632
1636
|
);
|
|
1633
1637
|
expect(tiles.length, "exactly two spatter tiles").toBe(2);
|
|
1638
|
+
expect(
|
|
1639
|
+
tiles[0]!.fillStyles.length,
|
|
1640
|
+
"both layers carry the same dot density",
|
|
1641
|
+
).toBe(tiles[1]!.fillStyles.length);
|
|
1634
1642
|
|
|
1635
1643
|
// Backing-store tripwire (R3 F1): a shrunken tile silently
|
|
1636
1644
|
// drops ~75% of its dots and desyncs the pattern period from
|
|
@@ -1653,9 +1661,16 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1653
1661
|
const bgMean = meanOf(tiles[0]!);
|
|
1654
1662
|
const inkMean = meanOf(tiles[1]!);
|
|
1655
1663
|
expect(
|
|
1656
|
-
|
|
1657
|
-
`
|
|
1664
|
+
inkMean - bgMean,
|
|
1665
|
+
`glyph pedestal lifts BRIGHTER (bg ${bgMean.toFixed(1)} vs ink ${inkMean.toFixed(1)})`,
|
|
1658
1666
|
).toBeGreaterThan(8);
|
|
1667
|
+
// Magnitude pin (R1 F4): the pedestal is LARGE by design (26 L ≈
|
|
1668
|
+
// 66 rgb here) — a revert to the old small 10 L pedestal (~26
|
|
1669
|
+
// rgb) must go red, not just stay above the sign threshold.
|
|
1670
|
+
expect(
|
|
1671
|
+
inkMean - bgMean,
|
|
1672
|
+
"the bright-text pedestal is LARGE, not merely positive",
|
|
1673
|
+
).toBeGreaterThan(36);
|
|
1659
1674
|
// Strict grayscale: no theme hue may survive into the spatter —
|
|
1660
1675
|
// every solid fill is a NEUTRAL gray (r === g === b).
|
|
1661
1676
|
for (const tile of tiles) {
|
|
@@ -1667,24 +1682,38 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1667
1682
|
).toBe(true);
|
|
1668
1683
|
}
|
|
1669
1684
|
}
|
|
1670
|
-
//
|
|
1671
|
-
//
|
|
1685
|
+
// Ground anchor: ALWAYS near-black (first fill on the bg tile
|
|
1686
|
+
// is the ground wash; L 10 → rgb ≈ 26).
|
|
1672
1687
|
const [gr, gg, gb] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
|
|
1673
|
-
expect(gr!, "
|
|
1674
|
-
expect(gr!).toBeLessThan(
|
|
1688
|
+
expect(gr!, "ground is ALWAYS near-black").toBeGreaterThan(18);
|
|
1689
|
+
expect(gr!).toBeLessThan(34);
|
|
1675
1690
|
expect(gg!).toBe(gr);
|
|
1676
1691
|
expect(gb!).toBe(gr);
|
|
1677
|
-
// Halo
|
|
1678
|
-
|
|
1679
|
-
expect(vis.gradientStops
|
|
1680
|
-
expect(vis.gradientStops).
|
|
1681
|
-
expect(vis.gradientStops
|
|
1682
|
-
expect(vis.gradientStops).not.toContain("rgba(255,255,255,0)");
|
|
1692
|
+
// Halo is ALWAYS white — peak AND endpoints (R2 F1).
|
|
1693
|
+
expect(vis.gradientStops, "halo is always WHITE").toContain("rgba(255,255,255,0.1)");
|
|
1694
|
+
expect(vis.gradientStops).not.toContain("rgba(0,0,0,0.1)");
|
|
1695
|
+
expect(vis.gradientStops, "halo endpoints fade on WHITE").toContain("rgba(255,255,255,0)");
|
|
1696
|
+
expect(vis.gradientStops).not.toContain("rgba(0,0,0,0)");
|
|
1683
1697
|
// The glyph apertures rasterize BOLD.
|
|
1684
1698
|
expect(
|
|
1685
1699
|
mask!.fonts.some((f) => f.startsWith("bold ")),
|
|
1686
1700
|
"glyph apertures rasterize bold",
|
|
1687
1701
|
).toBe(true);
|
|
1702
|
+
// Dot size band (R1 F6): ~0.6–1.4× the stroke width — dpr is 1
|
|
1703
|
+
// in this environment (guarded below), so every spatter radius
|
|
1704
|
+
// sits in the CSS band [1.2, 2.8]. NOTE: with random pinned at
|
|
1705
|
+
// 0.5 every radius is the band MIDPOINT — the min-side teeth
|
|
1706
|
+
// live in the light-ink test's unmocked band check (R2 F1).
|
|
1707
|
+
expect(window.devicePixelRatio || 1).toBe(1);
|
|
1708
|
+
for (const tile of tiles) {
|
|
1709
|
+
expect(tile.arcRadii.length, "spatter dots drawn as arcs").toBeGreaterThan(1000);
|
|
1710
|
+
for (const rad of tile.arcRadii) {
|
|
1711
|
+
expect(
|
|
1712
|
+
rad >= 1.2 && rad <= 2.8,
|
|
1713
|
+
`dot radius inside the stroke band, got ${rad}`,
|
|
1714
|
+
).toBe(true);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1688
1717
|
// The DOM input still never flips.
|
|
1689
1718
|
expect(input.type).toBe("password");
|
|
1690
1719
|
// Bus frames advance BOTH layer drifts (fresh pattern phases on
|
|
@@ -1720,8 +1749,8 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1720
1749
|
});
|
|
1721
1750
|
|
|
1722
1751
|
/**
|
|
1723
|
-
* Force the computed `.color` (the reveal's ink
|
|
1724
|
-
*
|
|
1752
|
+
* Force the computed `.color` (the reveal's ink) while forwarding
|
|
1753
|
+
* every other computed-style query to the real engine.
|
|
1725
1754
|
* Returns a restore function.
|
|
1726
1755
|
*/
|
|
1727
1756
|
function forceInkColor(color: string): () => void {
|
|
@@ -1743,12 +1772,11 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1743
1772
|
};
|
|
1744
1773
|
}
|
|
1745
1774
|
|
|
1746
|
-
it("filter
|
|
1747
|
-
//
|
|
1748
|
-
//
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
1751
|
-
// dark theme), so feed a light ink through getComputedStyle.
|
|
1775
|
+
it("filter anchors white-on-black regardless of the field ink (light ink)", async () => {
|
|
1776
|
+
// UNIFORMITY pin: the anchors follow NOTHING — feed a LIGHT field
|
|
1777
|
+
// ink through getComputedStyle and the reveal must still paint the
|
|
1778
|
+
// same near-black ground, dim-gray speckle, brighter pedestal
|
|
1779
|
+
// and white halo as the default (dark-ink) mount.
|
|
1752
1780
|
const restoreInk = forceInkColor("rgb(148, 233, 211)");
|
|
1753
1781
|
const rec = stubRecordingContexts();
|
|
1754
1782
|
try {
|
|
@@ -1778,7 +1806,7 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1778
1806
|
const inkMean = meanOf(tiles[1]!);
|
|
1779
1807
|
expect(
|
|
1780
1808
|
inkMean - bgMean,
|
|
1781
|
-
`
|
|
1809
|
+
`pedestal lifts BRIGHTER under a light ink too (bg ${bgMean.toFixed(1)} vs ink ${inkMean.toFixed(1)})`,
|
|
1782
1810
|
).toBeGreaterThan(8);
|
|
1783
1811
|
// Strict grayscale on the dark side too (R2 F4).
|
|
1784
1812
|
for (const tile of tiles) {
|
|
@@ -1787,6 +1815,33 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1787
1815
|
expect(r8 === g8 && g8 === b8, `dark spatter must be grayscale, got ${fill}`).toBe(true);
|
|
1788
1816
|
}
|
|
1789
1817
|
}
|
|
1818
|
+
// Dot size band with REAL randomness (R2 F1): the default test's
|
|
1819
|
+
// pinned 0.5 random freezes every radius at the band MIDPOINT,
|
|
1820
|
+
// which is blind on the min side — here thousands of uniform
|
|
1821
|
+
// samples must ALL sit in [1.2, 2.8] AND the empirical extremes
|
|
1822
|
+
// must touch both edges, so a shrunken rMin (sub-pixel speckle)
|
|
1823
|
+
// or a blown rMax both go red. dpr is 1 in this environment —
|
|
1824
|
+
// the guard makes that assumption explicit (R2 F2).
|
|
1825
|
+
expect(window.devicePixelRatio || 1).toBe(1);
|
|
1826
|
+
{
|
|
1827
|
+
let min = Infinity;
|
|
1828
|
+
let max = -Infinity;
|
|
1829
|
+
let count = 0;
|
|
1830
|
+
for (const tile of tiles) {
|
|
1831
|
+
for (const rad of tile.arcRadii) {
|
|
1832
|
+
if (rad < min) min = rad;
|
|
1833
|
+
if (rad > max) max = rad;
|
|
1834
|
+
count++;
|
|
1835
|
+
expect(
|
|
1836
|
+
rad >= 1.2 && rad <= 2.8,
|
|
1837
|
+
`dot radius inside the stroke band, got ${rad}`,
|
|
1838
|
+
).toBe(true);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
expect(count, "thousands of spatter dots sampled").toBeGreaterThan(3000);
|
|
1842
|
+
expect(min, "the small edge of the band is actually used").toBeLessThan(1.5);
|
|
1843
|
+
expect(max, "the large edge of the band is actually used").toBeGreaterThan(2.5);
|
|
1844
|
+
}
|
|
1790
1845
|
// Matched texture statistics (R2 F2): the two layers must carry
|
|
1791
1846
|
// the SAME dot count and (pedestal aside) the same lightness
|
|
1792
1847
|
// RANGE — a diverging spread/density is the single-frame
|
|
@@ -1809,26 +1864,27 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1809
1864
|
`matched lightness spread (bg ${bgStats.range} vs ink ${inkStats.range})`,
|
|
1810
1865
|
).toBeLessThan(6);
|
|
1811
1866
|
// The pedestal lifts ground AND dots together: the two grounds
|
|
1812
|
-
// sit exactly one pedestal apart (
|
|
1867
|
+
// sit exactly one pedestal apart (26 L ≈ 66 rgb at the current
|
|
1868
|
+
// tuning; the threshold derives from the constant), so an ink
|
|
1813
1869
|
// ground that loses or doubles its shift goes red (R2 C1b).
|
|
1814
1870
|
const groundOf = (r: (typeof tiles)[number]) =>
|
|
1815
1871
|
Number(r.fillStyles[0]!.slice(4, -1).split(",")[0]);
|
|
1816
1872
|
const groundDelta = Math.abs(groundOf(tiles[1]!) - groundOf(tiles[0]!));
|
|
1873
|
+
const pedRgb = FILTER_PEDESTAL_L * 2.55;
|
|
1817
1874
|
expect(
|
|
1818
|
-
groundDelta >=
|
|
1819
|
-
`grounds one pedestal apart (delta ${groundDelta})`,
|
|
1875
|
+
groundDelta >= pedRgb - 6 && groundDelta <= pedRgb + 6,
|
|
1876
|
+
`grounds one pedestal apart (delta ${groundDelta}, want ~${pedRgb.toFixed(0)})`,
|
|
1820
1877
|
).toBe(true);
|
|
1821
|
-
// Near-black ground: L 10 → rgb ≈ 26.
|
|
1878
|
+
// Near-black ground regardless of the ink: L 10 → rgb ≈ 26.
|
|
1822
1879
|
const [gr, gg, gb] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
|
|
1823
|
-
expect(gr!, "
|
|
1880
|
+
expect(gr!, "ground near-black under a light ink").toBeGreaterThan(18);
|
|
1824
1881
|
expect(gr!).toBeLessThan(34);
|
|
1825
1882
|
expect(gg!).toBe(gr);
|
|
1826
1883
|
expect(gb!).toBe(gr);
|
|
1827
|
-
// White halo
|
|
1828
|
-
|
|
1829
|
-
expect(vis.gradientStops, "dark theme paints a WHITE halo").toContain("rgba(255,255,255,0.1)");
|
|
1884
|
+
// White halo under a light ink too — peak AND endpoints (R2 F1).
|
|
1885
|
+
expect(vis.gradientStops, "halo stays WHITE under a light ink").toContain("rgba(255,255,255,0.1)");
|
|
1830
1886
|
expect(vis.gradientStops).not.toContain("rgba(0,0,0,0.1)");
|
|
1831
|
-
expect(vis.gradientStops, "
|
|
1887
|
+
expect(vis.gradientStops, "halo endpoints fade on WHITE").toContain("rgba(255,255,255,0)");
|
|
1832
1888
|
expect(vis.gradientStops).not.toContain("rgba(0,0,0,0)");
|
|
1833
1889
|
// Same screenshot contract and bold apertures as the light theme.
|
|
1834
1890
|
const mask = allRecs.find((r) => r.texts.length > 0)!;
|
|
@@ -1843,11 +1899,11 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1843
1899
|
}
|
|
1844
1900
|
});
|
|
1845
1901
|
|
|
1846
|
-
it("filter
|
|
1902
|
+
it("filter degrades without throwing for modern color-function inks", async () => {
|
|
1847
1903
|
// oklch()/lab()/color() inks cannot be parsed into a triple — the
|
|
1848
|
-
// documented degrade keeps the fallback ink
|
|
1849
|
-
//
|
|
1850
|
-
//
|
|
1904
|
+
// documented degrade keeps the fallback ink and NEVER throws or
|
|
1905
|
+
// guesses; the anchors are uniform anyway, so the reveal keeps
|
|
1906
|
+
// painting the same near-black ground + white halo.
|
|
1851
1907
|
const restoreInk = forceInkColor("oklch(70% 0.1 200)");
|
|
1852
1908
|
const rec = stubRecordingContexts();
|
|
1853
1909
|
try {
|
|
@@ -1865,8 +1921,9 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1865
1921
|
);
|
|
1866
1922
|
expect(tiles.length).toBe(2);
|
|
1867
1923
|
const [gr] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
|
|
1868
|
-
expect(gr!, "
|
|
1869
|
-
expect(
|
|
1924
|
+
expect(gr!, "degrade keeps the uniform near-black ground").toBeGreaterThan(18);
|
|
1925
|
+
expect(gr!).toBeLessThan(34);
|
|
1926
|
+
expect(vis.gradientStops, "white halo in the degrade").toContain("rgba(255,255,255,0.1)");
|
|
1870
1927
|
document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
1871
1928
|
await nextTick();
|
|
1872
1929
|
} finally {
|
|
@@ -1877,15 +1934,10 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1877
1934
|
|
|
1878
1935
|
it("filter degrades the same way for color()-function inks", async () => {
|
|
1879
1936
|
// Locks the DEGRADE SEMANTICS for color() inks: the modern-
|
|
1880
|
-
// function rejection yields null → the fallback
|
|
1881
|
-
// anchors
|
|
1882
|
-
//
|
|
1883
|
-
//
|
|
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.
|
|
1937
|
+
// function rejection yields null → the fallback ink → the same
|
|
1938
|
+
// uniform anchors, never a thrown parse. (The guard also protects
|
|
1939
|
+
// the NOISE painter's colored tiles from garbage hues — that side
|
|
1940
|
+
// is pinned by the parseColorTriple unit tests.)
|
|
1889
1941
|
const restoreInk = forceInkColor("color(display-p3 1 0 0)");
|
|
1890
1942
|
const rec = stubRecordingContexts();
|
|
1891
1943
|
try {
|
|
@@ -1901,35 +1953,7 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1901
1953
|
);
|
|
1902
1954
|
expect(tiles.length).toBe(2);
|
|
1903
1955
|
const [gr] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
|
|
1904
|
-
expect(gr!, "color() ink degrades to the
|
|
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);
|
|
1956
|
+
expect(gr!, "color() ink degrades to the uniform near-black ground").toBeGreaterThan(18);
|
|
1933
1957
|
expect(gr!).toBeLessThan(34);
|
|
1934
1958
|
document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
1935
1959
|
await nextTick();
|
|
@@ -1961,7 +1985,9 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1961
1985
|
);
|
|
1962
1986
|
expect(tiles.length).toBe(2);
|
|
1963
1987
|
const counts1 = tiles.map((r) => r.fillStyles.length);
|
|
1964
|
-
|
|
1988
|
+
// Dense field (R1 F5): ~2341 dots per tile at 28 px²/dot — the
|
|
1989
|
+
// old sparse 45 px²/dot (1457 fills) must fail this floor.
|
|
1990
|
+
expect(counts1[0]!, "dense spatter field on hold 1").toBeGreaterThan(2000);
|
|
1965
1991
|
await hold();
|
|
1966
1992
|
const counts2 = tiles.map((r) => r.fillStyles.length);
|
|
1967
1993
|
expect(counts2[0]).toBe(counts1[0]! * 2);
|
|
@@ -79,13 +79,11 @@ export default defineComponent({
|
|
|
79
79
|
* `passwordTrailing="eye"`):
|
|
80
80
|
* - "filter" (default): dual counter-drifting spatter layers in
|
|
81
81
|
* strict black/white/gray — static BOLD glyph apertures filled
|
|
82
|
-
* with
|
|
83
|
-
* field drifting the opposite way on
|
|
84
|
-
* (
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* motion; a single-frame screenshot carries no glyph structure,
|
|
88
|
-
* only a weak luminance signal dissolved into the halo.
|
|
82
|
+
* with a BRIGHT dense gray spatter over a statistically matched
|
|
83
|
+
* DIM field drifting the opposite way on a UNIFORM near-black
|
|
84
|
+
* ground (white-on-black in every theme), plus a large
|
|
85
|
+
* brightening pedestal and a white halo. A single-frame
|
|
86
|
+
* screenshot carries no glyph structure.
|
|
89
87
|
* - "sweep": a readable window — ordinary high-contrast text inside
|
|
90
88
|
* a narrow band sweeping across the row over the boiling-noise
|
|
91
89
|
* 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
|
+
});
|