@celestia-island/hikari 0.55.22 → 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 +68 -12
- package/src/components/HkInput.tsx +7 -8
- package/src/components/HkNodeCanvas.gestures.test.tsx +158 -0
- package/src/components/HkNodeCanvas.tsx +155 -0
- package/src/components/HkPasswordSurface.tsx +17 -17
- package/src/components/revealKinematogram.test.ts +22 -16
- package/src/components/revealKinematogram.ts +47 -34
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
|
|
|
@@ -1339,6 +1339,8 @@ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
|
|
|
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
|
|
1587
|
-
// ground; the glyphs lifted by a
|
|
1588
|
-
// white halo band around the row. Glyph
|
|
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
|
|
@@ -1624,7 +1627,7 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1624
1627
|
// The two spatter tiles: bg first, ink second (deterministic
|
|
1625
1628
|
// draw order), each with hundreds of solid-color dot fills. The
|
|
1626
1629
|
// anchors are UNIFORM (white-on-black reading in every theme):
|
|
1627
|
-
// near-black ground,
|
|
1630
|
+
// near-black ground, dim-gray speckle, and the pedestal lifts
|
|
1628
1631
|
// the glyph layer BRIGHTER. happy-dom resolves no computed
|
|
1629
1632
|
// color, so the ink stays at the pre-sync fallback (a DARK ink)
|
|
1630
1633
|
// — irrelevant now, the anchors follow nothing.
|
|
@@ -1661,6 +1664,13 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1661
1664
|
inkMean - bgMean,
|
|
1662
1665
|
`glyph pedestal lifts BRIGHTER (bg ${bgMean.toFixed(1)} vs ink ${inkMean.toFixed(1)})`,
|
|
1663
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);
|
|
1664
1674
|
// Strict grayscale: no theme hue may survive into the spatter —
|
|
1665
1675
|
// every solid fill is a NEUTRAL gray (r === g === b).
|
|
1666
1676
|
for (const tile of tiles) {
|
|
@@ -1689,6 +1699,21 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1689
1699
|
mask!.fonts.some((f) => f.startsWith("bold ")),
|
|
1690
1700
|
"glyph apertures rasterize bold",
|
|
1691
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
|
+
}
|
|
1692
1717
|
// The DOM input still never flips.
|
|
1693
1718
|
expect(input.type).toBe("password");
|
|
1694
1719
|
// Bus frames advance BOTH layer drifts (fresh pattern phases on
|
|
@@ -1750,7 +1775,7 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1750
1775
|
it("filter anchors white-on-black regardless of the field ink (light ink)", async () => {
|
|
1751
1776
|
// UNIFORMITY pin: the anchors follow NOTHING — feed a LIGHT field
|
|
1752
1777
|
// ink through getComputedStyle and the reveal must still paint the
|
|
1753
|
-
// same near-black ground,
|
|
1778
|
+
// same near-black ground, dim-gray speckle, brighter pedestal
|
|
1754
1779
|
// and white halo as the default (dark-ink) mount.
|
|
1755
1780
|
const restoreInk = forceInkColor("rgb(148, 233, 211)");
|
|
1756
1781
|
const rec = stubRecordingContexts();
|
|
@@ -1790,6 +1815,33 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1790
1815
|
expect(r8 === g8 && g8 === b8, `dark spatter must be grayscale, got ${fill}`).toBe(true);
|
|
1791
1816
|
}
|
|
1792
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
|
+
}
|
|
1793
1845
|
// Matched texture statistics (R2 F2): the two layers must carry
|
|
1794
1846
|
// the SAME dot count and (pedestal aside) the same lightness
|
|
1795
1847
|
// RANGE — a diverging spread/density is the single-frame
|
|
@@ -1812,14 +1864,16 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1812
1864
|
`matched lightness spread (bg ${bgStats.range} vs ink ${inkStats.range})`,
|
|
1813
1865
|
).toBeLessThan(6);
|
|
1814
1866
|
// The pedestal lifts ground AND dots together: the two grounds
|
|
1815
|
-
// 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
|
|
1816
1869
|
// ground that loses or doubles its shift goes red (R2 C1b).
|
|
1817
1870
|
const groundOf = (r: (typeof tiles)[number]) =>
|
|
1818
1871
|
Number(r.fillStyles[0]!.slice(4, -1).split(",")[0]);
|
|
1819
1872
|
const groundDelta = Math.abs(groundOf(tiles[1]!) - groundOf(tiles[0]!));
|
|
1873
|
+
const pedRgb = FILTER_PEDESTAL_L * 2.55;
|
|
1820
1874
|
expect(
|
|
1821
|
-
groundDelta >=
|
|
1822
|
-
`grounds one pedestal apart (delta ${groundDelta})`,
|
|
1875
|
+
groundDelta >= pedRgb - 6 && groundDelta <= pedRgb + 6,
|
|
1876
|
+
`grounds one pedestal apart (delta ${groundDelta}, want ~${pedRgb.toFixed(0)})`,
|
|
1823
1877
|
).toBe(true);
|
|
1824
1878
|
// Near-black ground regardless of the ink: L 10 → rgb ≈ 26.
|
|
1825
1879
|
const [gr, gg, gb] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
|
|
@@ -1931,7 +1985,9 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1931
1985
|
);
|
|
1932
1986
|
expect(tiles.length).toBe(2);
|
|
1933
1987
|
const counts1 = tiles.map((r) => r.fillStyles.length);
|
|
1934
|
-
|
|
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);
|
|
1935
1991
|
await hold();
|
|
1936
1992
|
const counts2 = tiles.map((r) => r.fillStyles.length);
|
|
1937
1993
|
expect(counts2[0]).toBe(counts1[0]! * 2);
|
|
@@ -77,14 +77,13 @@ export default defineComponent({
|
|
|
77
77
|
/**
|
|
78
78
|
* Reveal content for the password eye (`variant="password"`,
|
|
79
79
|
* `passwordTrailing="eye"`):
|
|
80
|
-
* - "filter" (default): dual
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* single-frame screenshot carries no glyph structure.
|
|
80
|
+
* - "filter" (default): dual counter-drifting spatter layers in
|
|
81
|
+
* strict black/white/gray — static BOLD glyph apertures filled
|
|
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.
|
|
88
87
|
* - "sweep": a readable window — ordinary high-contrast text inside
|
|
89
88
|
* a narrow band sweeping across the row over the boiling-noise
|
|
90
89
|
* field. Easy to read; a single screenshot leaks the characters
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkNodeCanvas from "./HkNodeCanvas";
|
|
5
|
+
|
|
6
|
+
/** Mount a component into a detached div and return the root element. */
|
|
7
|
+
function mountToDom(component: ReturnType<typeof defineComponent>): HTMLElement {
|
|
8
|
+
const el = document.createElement("div");
|
|
9
|
+
document.body.appendChild(el);
|
|
10
|
+
const app = createApp(component);
|
|
11
|
+
app.mount(el);
|
|
12
|
+
return el;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function pointerEvent(type: string, opts: Record<string, number | boolean> = {}): PointerEvent {
|
|
16
|
+
return new PointerEvent(type, {
|
|
17
|
+
pointerId: (opts.pointerId as number) ?? 1,
|
|
18
|
+
clientX: (opts.clientX as number) ?? 0,
|
|
19
|
+
clientY: (opts.clientY as number) ?? 0,
|
|
20
|
+
button: (opts.button as number) ?? 0,
|
|
21
|
+
bubbles: true,
|
|
22
|
+
cancelable: true,
|
|
23
|
+
...opts,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("HkNodeCanvas gesture & animation upgrades", () => {
|
|
28
|
+
describe("tweenCamera", () => {
|
|
29
|
+
it("exposes tweenCamera as a function", () => {
|
|
30
|
+
const exposed: Record<string, unknown> = {};
|
|
31
|
+
const Host = defineComponent({
|
|
32
|
+
setup() {
|
|
33
|
+
const canvasRef = ref<unknown>(null);
|
|
34
|
+
const check = () => {
|
|
35
|
+
const vm = canvasRef.value as Record<string, unknown> | null;
|
|
36
|
+
if (vm) Object.assign(exposed, { tweenCamera: vm.tweenCamera });
|
|
37
|
+
};
|
|
38
|
+
return () =>
|
|
39
|
+
h(HkNodeCanvas, { ref: canvasRef, minimap: false, onVnodeMounted: check }, { default: () => h("div") });
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const el = mountToDom(Host);
|
|
43
|
+
expect(typeof exposed.tweenCamera).toBe("function");
|
|
44
|
+
el.remove();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("tweenMs=0 (default) does not start an animation frame", () => {
|
|
48
|
+
let tweenFn: ((...a: unknown[]) => void) | null = null;
|
|
49
|
+
const Host = defineComponent({
|
|
50
|
+
setup() {
|
|
51
|
+
const canvasRef = ref<unknown>(null);
|
|
52
|
+
const check = () => {
|
|
53
|
+
const vm = canvasRef.value as Record<string, unknown> | null;
|
|
54
|
+
if (vm) tweenFn = vm.tweenCamera as (...a: unknown[]) => void;
|
|
55
|
+
};
|
|
56
|
+
return () =>
|
|
57
|
+
h(HkNodeCanvas, {
|
|
58
|
+
ref: canvasRef,
|
|
59
|
+
minimap: false,
|
|
60
|
+
fitOnLoad: false,
|
|
61
|
+
onVnodeMounted: check,
|
|
62
|
+
}, { default: () => h("div") });
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const el = mountToDom(Host);
|
|
66
|
+
expect(tweenFn).toBeTruthy();
|
|
67
|
+
// Calling with default tweenMs=0 should snap (no rAF started).
|
|
68
|
+
tweenFn!({ k: 2, x: 100, y: 50 });
|
|
69
|
+
el.remove();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("pinchable prop", () => {
|
|
74
|
+
it("defaults to true", () => {
|
|
75
|
+
const el = mountToDom(defineComponent({
|
|
76
|
+
setup() {
|
|
77
|
+
return () => h(HkNodeCanvas, { minimap: false }, { default: () => h("div") });
|
|
78
|
+
},
|
|
79
|
+
}));
|
|
80
|
+
expect(el.querySelector(".hk-node-canvas")).toBeTruthy();
|
|
81
|
+
el.remove();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("can be disabled", () => {
|
|
85
|
+
const el = mountToDom(defineComponent({
|
|
86
|
+
setup() {
|
|
87
|
+
return () => h(HkNodeCanvas, { minimap: false, pinchable: false }, { default: () => h("div") });
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
expect(el.querySelector(".hk-node-canvas")).toBeTruthy();
|
|
91
|
+
el.remove();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("reduced motion", () => {
|
|
96
|
+
it("respectReducedMotion defaults to true", () => {
|
|
97
|
+
const el = mountToDom(defineComponent({
|
|
98
|
+
setup() {
|
|
99
|
+
return () => h(HkNodeCanvas, { minimap: false }, { default: () => h("div") });
|
|
100
|
+
},
|
|
101
|
+
}));
|
|
102
|
+
expect(el.querySelector(".hk-node-canvas")).toBeTruthy();
|
|
103
|
+
el.remove();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("pointerScale prop", () => {
|
|
108
|
+
it("accepts a pointerScale function", () => {
|
|
109
|
+
const el = mountToDom(defineComponent({
|
|
110
|
+
setup() {
|
|
111
|
+
return () =>
|
|
112
|
+
h(HkNodeCanvas, {
|
|
113
|
+
minimap: false,
|
|
114
|
+
pointerScale: () => ({ x: 2, y: 2 }),
|
|
115
|
+
}, { default: () => h("div") });
|
|
116
|
+
},
|
|
117
|
+
}));
|
|
118
|
+
expect(el.querySelector(".hk-node-canvas")).toBeTruthy();
|
|
119
|
+
el.remove();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("pinch gesture (integration)", () => {
|
|
124
|
+
it("two simultaneous pointers do not crash", async () => {
|
|
125
|
+
const Host = defineComponent({
|
|
126
|
+
setup() {
|
|
127
|
+
return () =>
|
|
128
|
+
h(HkNodeCanvas, {
|
|
129
|
+
minimap: false,
|
|
130
|
+
pannable: true,
|
|
131
|
+
pinchable: true,
|
|
132
|
+
}, { default: () => h("div") });
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const el = mountToDom(Host);
|
|
136
|
+
const root = el.querySelector(".hk-node-canvas")!;
|
|
137
|
+
expect(root).toBeTruthy();
|
|
138
|
+
|
|
139
|
+
// Simulate two fingers down.
|
|
140
|
+
root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 10, clientX: 100, clientY: 100 }));
|
|
141
|
+
root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 20, clientX: 200, clientY: 100 }));
|
|
142
|
+
await nextTick();
|
|
143
|
+
|
|
144
|
+
// Simulate one finger moving (pinch in progress).
|
|
145
|
+
root.dispatchEvent(pointerEvent("pointermove", { pointerId: 20, clientX: 150, clientY: 100 }));
|
|
146
|
+
await nextTick();
|
|
147
|
+
|
|
148
|
+
// Release both fingers.
|
|
149
|
+
root.dispatchEvent(pointerEvent("pointerup", { pointerId: 10 }));
|
|
150
|
+
root.dispatchEvent(pointerEvent("pointerup", { pointerId: 20 }));
|
|
151
|
+
await nextTick();
|
|
152
|
+
|
|
153
|
+
// No crash — the component is still mounted.
|
|
154
|
+
expect(el.querySelector(".hk-node-canvas")).toBeTruthy();
|
|
155
|
+
el.remove();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -219,6 +219,26 @@ export default defineComponent({
|
|
|
219
219
|
type: String as PropType<PrintPaper>,
|
|
220
220
|
default: "auto" as PrintPaper,
|
|
221
221
|
},
|
|
222
|
+
|
|
223
|
+
// ── Gesture & animation upgrades ─────────────────────────────
|
|
224
|
+
|
|
225
|
+
/** Camera tween duration in ms (0 disables tweening). Only
|
|
226
|
+
* `tweenCamera()` animates over this duration with ease-out cubic.
|
|
227
|
+
* Snaps instantly when prefers-reduced-motion is active and
|
|
228
|
+
* `respectReducedMotion` is on (default). */
|
|
229
|
+
tweenMs: { type: Number, default: 0 },
|
|
230
|
+
/** Two-finger pinch-to-zoom on touch devices. */
|
|
231
|
+
pinchable: { type: Boolean, default: true },
|
|
232
|
+
/** Snap tweens when prefers-reduced-motion is active (default). */
|
|
233
|
+
respectReducedMotion: { type: Boolean, default: true },
|
|
234
|
+
/** DPI scale provider: maps pointer coordinates to drawn coordinates.
|
|
235
|
+
* Needed when the canvas is inside a CSS `zoom` or `transform: scale()`
|
|
236
|
+
* container — pointer events report unscaled page coordinates but the
|
|
237
|
+
* camera measures the canvas's own pixels. */
|
|
238
|
+
pointerScale: {
|
|
239
|
+
type: Function as PropType<(() => { x: number; y: number }) | undefined>,
|
|
240
|
+
default: undefined,
|
|
241
|
+
},
|
|
222
242
|
},
|
|
223
243
|
emits: ["update:camera"],
|
|
224
244
|
setup(props, { slots, emit, expose }) {
|
|
@@ -237,6 +257,27 @@ export default defineComponent({
|
|
|
237
257
|
/** Set by setCamera (gestures and imperative calls) — never by `fit`. */
|
|
238
258
|
let movedByHand = false;
|
|
239
259
|
|
|
260
|
+
// ── Pinch-to-zoom state ──────────────────────────────────────
|
|
261
|
+
/** Active pointers for pinch tracking (pointerId → screen position). */
|
|
262
|
+
const activePointers = new Map<number, { x: number; y: number }>();
|
|
263
|
+
/** Pinch gesture snapshot: start distance/zoom/midpoint for the ratio. */
|
|
264
|
+
let pinchSnapshot: {
|
|
265
|
+
startDist: number;
|
|
266
|
+
startZoom: number;
|
|
267
|
+
midX: number;
|
|
268
|
+
midY: number;
|
|
269
|
+
} | null = null;
|
|
270
|
+
|
|
271
|
+
// ── Camera tween state ───────────────────────────────────────
|
|
272
|
+
let tweenRaf: number | null = null;
|
|
273
|
+
|
|
274
|
+
// ── Motion constraints ───────────────────────────────────────
|
|
275
|
+
const reducedMotion = computed(() => {
|
|
276
|
+
if (!props.respectReducedMotion) return false;
|
|
277
|
+
return typeof window !== "undefined"
|
|
278
|
+
&& window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
279
|
+
});
|
|
280
|
+
|
|
240
281
|
// ── Canvas2D painter layer ───────────────────────────────────
|
|
241
282
|
const canvasEl = shallowRef<HTMLCanvasElement | null>(null);
|
|
242
283
|
/** Registered painters, sorted by z on each frame. */
|
|
@@ -495,6 +536,11 @@ export default defineComponent({
|
|
|
495
536
|
}
|
|
496
537
|
|
|
497
538
|
function onPointerDown(event: PointerEvent) {
|
|
539
|
+
// When a pinch is live, cancel any single-pointer pan.
|
|
540
|
+
if (pinchSnapshot !== null) {
|
|
541
|
+
panning = null;
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
498
544
|
if (!props.pannable || event.button !== 0) return;
|
|
499
545
|
// A host that drags its own nodes suppresses the pan by preventing the
|
|
500
546
|
// default on its card's pointerdown: the root listens in the bubble
|
|
@@ -517,6 +563,7 @@ export default defineComponent({
|
|
|
517
563
|
) {
|
|
518
564
|
return;
|
|
519
565
|
}
|
|
566
|
+
trackPointer(event);
|
|
520
567
|
panning = {
|
|
521
568
|
pointerId: event.pointerId,
|
|
522
569
|
x: event.clientX,
|
|
@@ -535,6 +582,14 @@ export default defineComponent({
|
|
|
535
582
|
}
|
|
536
583
|
|
|
537
584
|
function onPointerMove(event: PointerEvent) {
|
|
585
|
+
// Update pinch tracking when a tracked pointer moves.
|
|
586
|
+
if (activePointers.has(event.pointerId)) {
|
|
587
|
+
activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
588
|
+
if (pinchSnapshot !== null) {
|
|
589
|
+
updatePinch();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
538
593
|
if (!panning || event.pointerId !== panning.pointerId) return;
|
|
539
594
|
// The gesture is only captured once it passes the slop, so a release that
|
|
540
595
|
// happens outside the canvas never reaches this element. Without this the
|
|
@@ -567,6 +622,8 @@ export default defineComponent({
|
|
|
567
622
|
}
|
|
568
623
|
|
|
569
624
|
function endPan(event: PointerEvent) {
|
|
625
|
+
endPointerTracking(event);
|
|
626
|
+
if (pinchSnapshot !== null) return;
|
|
570
627
|
if (!panning || event.pointerId !== panning.pointerId) return;
|
|
571
628
|
const pointerId = panning.pointerId;
|
|
572
629
|
panning = null;
|
|
@@ -580,6 +637,11 @@ export default defineComponent({
|
|
|
580
637
|
// canvas clears focus), and ending the gesture there would kill the first
|
|
581
638
|
// pan after any click. Only the window's own blur ends it.
|
|
582
639
|
if (event.target && event.target !== window) return;
|
|
640
|
+
// Clear pinch state too: a blur during a two-finger gesture would
|
|
641
|
+
// otherwise leave a stale snapshot that permanently suppresses pan
|
|
642
|
+
// (every pointerdown would hit the pinch-active early return).
|
|
643
|
+
activePointers.clear();
|
|
644
|
+
pinchSnapshot = null;
|
|
583
645
|
if (!panning) return;
|
|
584
646
|
const pointerId = panning.pointerId;
|
|
585
647
|
panning = null;
|
|
@@ -668,6 +730,7 @@ export default defineComponent({
|
|
|
668
730
|
);
|
|
669
731
|
|
|
670
732
|
onBeforeUnmount(() => {
|
|
733
|
+
cancelTween();
|
|
671
734
|
stopFrameLoop();
|
|
672
735
|
ro?.disconnect();
|
|
673
736
|
ro = null;
|
|
@@ -820,6 +883,97 @@ export default defineComponent({
|
|
|
820
883
|
return copy;
|
|
821
884
|
}
|
|
822
885
|
|
|
886
|
+
// ── Pinch-to-zoom ────────────────────────────────────────────
|
|
887
|
+
|
|
888
|
+
/** Track active pointers for pinch detection. */
|
|
889
|
+
function trackPointer(event: PointerEvent): void {
|
|
890
|
+
activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
891
|
+
if (
|
|
892
|
+
props.pinchable
|
|
893
|
+
&& !props.printMode
|
|
894
|
+
&& activePointers.size === 2
|
|
895
|
+
&& pinchSnapshot === null
|
|
896
|
+
) {
|
|
897
|
+
const [a, b] = [...activePointers.values()];
|
|
898
|
+
const dx = b.x - a.x;
|
|
899
|
+
const dy = b.y - a.y;
|
|
900
|
+
pinchSnapshot = {
|
|
901
|
+
startDist: Math.hypot(dx, dy),
|
|
902
|
+
startZoom: camera.value.k,
|
|
903
|
+
midX: (a.x + b.x) / 2,
|
|
904
|
+
midY: (a.y + b.y) / 2,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Update pinch zoom from current pointer positions. */
|
|
910
|
+
function updatePinch(): void {
|
|
911
|
+
if (!pinchSnapshot || activePointers.size < 2) return;
|
|
912
|
+
const [a, b] = [...activePointers.values()];
|
|
913
|
+
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
|
914
|
+
if (pinchSnapshot.startDist < 1) return;
|
|
915
|
+
const ratio = dist / pinchSnapshot.startDist;
|
|
916
|
+
const targetK = normalizeZoom(pinchSnapshot.startZoom * ratio);
|
|
917
|
+
// Zoom anchored at the pinch midpoint.
|
|
918
|
+
const rect = rootEl.value?.getBoundingClientRect();
|
|
919
|
+
if (!rect) return;
|
|
920
|
+
const scale = props.pointerScale?.() ?? { x: 1, y: 1 };
|
|
921
|
+
const midX = ((pinchSnapshot.midX - rect.left) / scale.x);
|
|
922
|
+
const midY = ((pinchSnapshot.midY - rect.top) / scale.y);
|
|
923
|
+
const cam = camera.value;
|
|
924
|
+
const k = targetK;
|
|
925
|
+
const x = midX - ((midX - cam.x) * k) / cam.k;
|
|
926
|
+
const y = midY - ((midY - cam.y) * k) / cam.k;
|
|
927
|
+
setCamera({ k, x, y });
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** End pinch tracking when a pointer lifts. */
|
|
931
|
+
function endPointerTracking(event: PointerEvent): void {
|
|
932
|
+
activePointers.delete(event.pointerId);
|
|
933
|
+
if (activePointers.size < 2) {
|
|
934
|
+
pinchSnapshot = null;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// ── Camera tween ─────────────────────────────────────────────
|
|
939
|
+
|
|
940
|
+
/** Cancel any running tween. */
|
|
941
|
+
function cancelTween(): void {
|
|
942
|
+
if (tweenRaf !== null) {
|
|
943
|
+
cancelAnimationFrame(tweenRaf);
|
|
944
|
+
tweenRaf = null;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Animate the camera to a target over `tweenMs`, or snap if
|
|
949
|
+
* reduced-motion is active or tweenMs is 0. */
|
|
950
|
+
function tweenCamera(target: NodeCanvasCamera, durationMs?: number): void {
|
|
951
|
+
cancelTween();
|
|
952
|
+
const ms = durationMs ?? props.tweenMs;
|
|
953
|
+
if (ms <= 0 || reducedMotion.value) {
|
|
954
|
+
setCamera(target);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const start = { ...camera.value };
|
|
958
|
+
const startTime = performance.now();
|
|
959
|
+
|
|
960
|
+
const step = (now: number) => {
|
|
961
|
+
const t = Math.min((now - startTime) / ms, 1);
|
|
962
|
+
const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
|
|
963
|
+
setCamera({
|
|
964
|
+
k: start.k + (target.k - start.k) * eased,
|
|
965
|
+
x: start.x + (target.x - start.x) * eased,
|
|
966
|
+
y: start.y + (target.y - start.y) * eased,
|
|
967
|
+
});
|
|
968
|
+
if (t < 1) {
|
|
969
|
+
tweenRaf = requestAnimationFrame(step);
|
|
970
|
+
} else {
|
|
971
|
+
tweenRaf = null;
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
tweenRaf = requestAnimationFrame(step);
|
|
975
|
+
}
|
|
976
|
+
|
|
823
977
|
expose({
|
|
824
978
|
camera,
|
|
825
979
|
viewport,
|
|
@@ -829,6 +983,7 @@ export default defineComponent({
|
|
|
829
983
|
screenToWorld,
|
|
830
984
|
worldToScreen,
|
|
831
985
|
setCamera,
|
|
986
|
+
tweenCamera,
|
|
832
987
|
// ── New: rendering base capabilities ──
|
|
833
988
|
levelOfDetail,
|
|
834
989
|
registerPainter,
|
|
@@ -53,17 +53,17 @@ interface Ripple {
|
|
|
53
53
|
* Right-edge affordance (`passwordTrailing`):
|
|
54
54
|
* - "eye" (default): the reveal button. What the reveal SHOWS is chosen
|
|
55
55
|
* by `revealStrategy`, how it is TRIGGERED by `revealTrigger`:
|
|
56
|
-
* - strategy "filter" (default): dual
|
|
56
|
+
* - strategy "filter" (default): dual counter-drifting spatter
|
|
57
57
|
* layers in strict BLACK/WHITE/GRAY — the glyph row is a set of
|
|
58
|
-
* STATIC bold apertures filled with a gray spatter texture
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* (white-on-black reading in every theme
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
58
|
+
* STATIC bold apertures filled with a bright gray spatter texture
|
|
59
|
+
* drifting one way, over a statistically matched DIM spatter
|
|
60
|
+
* field drifting the other way, both on a UNIFORM near-black
|
|
61
|
+
* ground (white-on-black reading in every theme; legibility lives
|
|
62
|
+
* in the brightness channel — dense dots, dim surround, bright
|
|
63
|
+
* text layer). A single frame — a screenshot — carries no glyph
|
|
64
|
+
* structure, only the mean-luminance signal dissolved into the
|
|
65
|
+
* halo ramp. Reduced motion or a pattern-less engine degrades to
|
|
66
|
+
* the fully readable static plain text.
|
|
67
67
|
* - strategy "sweep": a readable window — the password is drawn as
|
|
68
68
|
* ordinary high-contrast text inside a narrow band that sweeps
|
|
69
69
|
* across the row over the boiling-noise field. Reading is
|
|
@@ -120,13 +120,13 @@ export default defineComponent({
|
|
|
120
120
|
},
|
|
121
121
|
/**
|
|
122
122
|
* What the eye reveal SHOWS:
|
|
123
|
-
* - "filter" (default): dual
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* theme), plus a
|
|
128
|
-
*
|
|
129
|
-
*
|
|
123
|
+
* - "filter" (default): dual counter-drifting spatter layers in
|
|
124
|
+
* strict black/white/gray — static BOLD glyph apertures filled
|
|
125
|
+
* with a BRIGHT dense gray spatter over an oppositely drifting,
|
|
126
|
+
* statistically matched DIM spatter field on a UNIFORM
|
|
127
|
+
* near-black ground (white-on-black in every theme), plus a
|
|
128
|
+
* large brightening pedestal and white halo. A single
|
|
129
|
+
* screenshot carries no glyph structure.
|
|
130
130
|
* - "sweep": a readable window — ordinary high-contrast text inside
|
|
131
131
|
* a narrow band sweeping across the row. Easy to read; a single
|
|
132
132
|
* screenshot leaks the band's characters in the clear.
|
|
@@ -135,15 +135,14 @@ describe("RevealNoisePainter", () => {
|
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
-
describe("FILTER_DRIFT_PX_S
|
|
139
|
-
it("keeps the
|
|
140
|
-
//
|
|
141
|
-
// persistence
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
expect(FILTER_DRIFT_PX_S).toBeGreaterThanOrEqual(150);
|
|
138
|
+
describe("FILTER_DRIFT_PX_S settled band", () => {
|
|
139
|
+
it("keeps the drift in the live-tuned moderate band", () => {
|
|
140
|
+
// 84 px/s is the live-tuned settled value: the 300px/s
|
|
141
|
+
// persistence experiment read WORSE live (no stable surface for
|
|
142
|
+
// the eye to bind) and was reverted — a silent re-raise must go
|
|
143
|
+
// red. The band allows small tuning without test edits (R1 F2).
|
|
144
|
+
expect(FILTER_DRIFT_PX_S).toBeGreaterThanOrEqual(60);
|
|
145
|
+
expect(FILTER_DRIFT_PX_S).toBeLessThanOrEqual(120);
|
|
147
146
|
});
|
|
148
147
|
});
|
|
149
148
|
|
|
@@ -192,17 +191,24 @@ describe("RevealFilterPainter", () => {
|
|
|
192
191
|
it("drifts the two layers in OPPOSITE directions at equal speed", () => {
|
|
193
192
|
// The segregation cue IS the 180° direction difference: a mutation
|
|
194
193
|
// sending both layers the same way (or at unequal speeds) must go
|
|
195
|
-
// red here.
|
|
194
|
+
// red here. TWO probe combos whose real steps differ (42 and 21)
|
|
195
|
+
// so no single hardcoded step constant passes both (R1 F3: the
|
|
196
|
+
// old 0.25/2-only probe was blind to `const step = 42`).
|
|
196
197
|
const p = new RevealFilterPainter();
|
|
197
198
|
const rand = vi.spyOn(Math, "random").mockReturnValue(0.25); // driftSign = +1
|
|
198
199
|
try {
|
|
199
200
|
p.beginHold();
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
201
|
+
for (const [dt, dpr] of [
|
|
202
|
+
[0.25, 2],
|
|
203
|
+
[0.125, 2],
|
|
204
|
+
] as const) {
|
|
205
|
+
const before = p.peekDrift();
|
|
206
|
+
p.advance(dt, dpr);
|
|
207
|
+
const after = p.peekDrift();
|
|
208
|
+
const step = FILTER_DRIFT_PX_S * dpr * dt;
|
|
209
|
+
expect(after.background - before.background).toBeCloseTo(step, 10);
|
|
210
|
+
expect(after.ink - before.ink).toBeCloseTo(-step, 10);
|
|
211
|
+
}
|
|
206
212
|
} finally {
|
|
207
213
|
rand.mockRestore();
|
|
208
214
|
}
|
|
@@ -396,21 +396,22 @@ export class RevealNoisePainter {
|
|
|
396
396
|
* password glyphs are STATIC apertures carrying a SECOND spatter
|
|
397
397
|
* texture — statistically identical (same generator, dot size and
|
|
398
398
|
* lightness distribution) but drifting the OPPOSITE way and shifted by
|
|
399
|
-
* a
|
|
399
|
+
* a large brightening lightness pedestal (live-tuned, white-on-black
|
|
400
|
+
* reading),
|
|
400
401
|
* with a soft halo band around the glyph row. A human segments the two
|
|
401
402
|
* layers effortlessly (motion transparency at a 180° direction
|
|
402
403
|
* difference is the strongest segregation cue the visual system has)
|
|
403
404
|
* and reads the row aided by the pedestal + halo, while any SINGLE
|
|
404
405
|
* frame carries no motion at all: the glyph boundary survives only as
|
|
405
|
-
* a
|
|
406
|
+
* a mean-luminance step inside a smooth halo ramp — nothing for
|
|
406
407
|
* a global or adaptive threshold to plateau on, and (matched
|
|
407
408
|
* statistics) nothing for a texture classifier either.
|
|
408
409
|
*
|
|
409
410
|
* The palette is strictly GRAYSCALE and strictly UNIFORM — black,
|
|
410
411
|
* white and grays only, no theme hue survives into the spatter (the
|
|
411
412
|
* ink-colored variant read as an uncomfortable cyan), and the anchors
|
|
412
|
-
* do NOT follow the theme: ALWAYS a near-black ground with
|
|
413
|
-
* speckle, a white halo and a brighter glyph pedestal (the theme-
|
|
413
|
+
* do NOT follow the theme: ALWAYS a near-black ground with dim gray
|
|
414
|
+
* speckle, a white halo and a much-brighter glyph pedestal (the theme-
|
|
414
415
|
* following light variant read far worse and was dropped — a dark
|
|
415
416
|
* reveal strip inside a light page is the accepted look).
|
|
416
417
|
*
|
|
@@ -423,30 +424,37 @@ export class RevealNoisePainter {
|
|
|
423
424
|
* and motion signal both peak near the letters' diagnostic spatial
|
|
424
425
|
* band, so MODERATE noise contrast suffices (the old design's fine
|
|
425
426
|
* grain + fast ±40px/s drift is exactly what made it unreadable).
|
|
427
|
+
* - Legibility lives in the BRIGHTNESS channel (live-tuned): the
|
|
428
|
+
* surround dots sit LOW (FILTER_BASE_L) on the near-black ground so
|
|
429
|
+
* the background reads dim, the glyph layer is lifted by a LARGE
|
|
430
|
+
* pedestal (FILTER_PEDESTAL_L) so the text reads bright, and the
|
|
431
|
+
* dots are DENSE (FILTER_SPATTER_PX_PER_DOT) so both fields read as
|
|
432
|
+
* textured surfaces rather than sparse speckle. The large pedestal
|
|
433
|
+
* is a bigger single-frame leak than the original small one — an
|
|
434
|
+
* accepted trade per the readability-first direction.
|
|
426
435
|
* - The glyph apertures rasterize in BOLD: a heavier stroke exposes
|
|
427
436
|
* more of the counter-drifting texture inside each glyph (stronger
|
|
428
437
|
* signal under interference); the shared layout's 1.2× letter
|
|
429
438
|
* spacing absorbs the wider advances.
|
|
430
|
-
* - Drift ±FILTER_DRIFT_PX_S
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* the
|
|
435
|
-
*
|
|
436
|
-
* all (form-from-motion needs the temporal integration that only
|
|
437
|
-
* the live eye provides).
|
|
439
|
+
* - Drift ±FILTER_DRIFT_PX_S: moderate — slow enough to track
|
|
440
|
+
* coherently at field font sizes, fast enough that a single frame
|
|
441
|
+
* carries no usable motion energy. (A 300px/s persistence-of-vision
|
|
442
|
+
* experiment read worse live: the eye never got a stable surface to
|
|
443
|
+
* bind, so the speed came back down and legibility moved into the
|
|
444
|
+
* brightness channel instead.)
|
|
438
445
|
* - The glyph APERTURES never move — only the texture inside them
|
|
439
446
|
* flows. Cross-frame registration of the glyph shapes (the attack
|
|
440
447
|
* that killed video CAPTCHAs) finds nothing to align.
|
|
441
|
-
* - The pedestal is deliberately
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
* (
|
|
448
|
+
* - The pedestal is deliberately LARGE (live-tuned readability
|
|
449
|
+
* first): it is a static first-order cue and therefore the one
|
|
450
|
+
* signal a single frame leaks, and the current magnitude leaks MORE
|
|
451
|
+
* of it than the original small one — the accepted trade for a dim
|
|
452
|
+
* surround and a bright text layer. The halo ramp still spreads the
|
|
453
|
+
* step so thresholding finds no plateau, and an attacker averaging
|
|
454
|
+
* MANY frames can in principle recover the pedestal's DC component
|
|
455
|
+
* (same cost class as the video attack on the noise strategy) —
|
|
456
|
+
* consumers that cannot accept this trade pick "noise" (zero static
|
|
457
|
+
* signal) or "plain" (full readability).
|
|
450
458
|
* - The drift sign is re-randomized per hold (and both tiles are
|
|
451
459
|
* freshly generated per hold) so two holds never replay the same
|
|
452
460
|
* frame sequence.
|
|
@@ -462,14 +470,18 @@ export class RevealNoisePainter {
|
|
|
462
470
|
* never to frozen noise). */
|
|
463
471
|
|
|
464
472
|
/** Counter-drift speed of each layer in CSS px/s (opposite signs).
|
|
465
|
-
*
|
|
466
|
-
|
|
473
|
+
* The 300px/s persistence experiment read WORSE live (the eye never
|
|
474
|
+
* got a stable surface to bind); 84 tracks coherently at field sizes
|
|
475
|
+
* and is the settled value. */
|
|
476
|
+
export const FILTER_DRIFT_PX_S = 84;
|
|
467
477
|
|
|
468
478
|
/** Lightness pedestal of the glyph layer (brighter — the text reads
|
|
469
479
|
* white-on-black), in HSL lightness points (clamped into
|
|
470
|
-
* [L_MIN, L_MAX] like every sample).
|
|
480
|
+
* [L_MIN, L_MAX] like every sample). Deliberately LARGE (live-tuned
|
|
481
|
+
* readability-first): a dim surround and a bright text layer. The
|
|
482
|
+
* static single-frame leak grows with it — accepted trade, see the
|
|
471
483
|
* strategy docblock. */
|
|
472
|
-
export const FILTER_PEDESTAL_L =
|
|
484
|
+
export const FILTER_PEDESTAL_L = 26;
|
|
473
485
|
|
|
474
486
|
/** Peak alpha of the halo band (white, at the glyph-row midline). */
|
|
475
487
|
export const FILTER_HALO_ALPHA = 0.1;
|
|
@@ -485,19 +497,20 @@ export const FILTER_HALO_FONT_SCALE = 1.2;
|
|
|
485
497
|
const FILTER_SPATTER_R_MIN_CSS = 1.2;
|
|
486
498
|
const FILTER_SPATTER_R_MAX_CSS = 2.8;
|
|
487
499
|
|
|
488
|
-
/** Spatter coverage: one dot per this many tile px
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
|
|
500
|
+
/** Spatter coverage: one dot per this many tile px² — DENSE
|
|
501
|
+
* (live-tuned) so both fields read as textured surfaces. BOTH layers
|
|
502
|
+
* share the density, size and lightness distributions — matched
|
|
503
|
+
* texture statistics are the single-frame defense; only the mean
|
|
504
|
+
* luminance (pedestal) and the drift direction differ. */
|
|
505
|
+
const FILTER_SPATTER_PX_PER_DOT = 28;
|
|
493
506
|
|
|
494
507
|
/** Dot lightness spread around the layer base (both layers share it). */
|
|
495
508
|
const FILTER_SPATTER_L_SPREAD = 22;
|
|
496
509
|
|
|
497
|
-
/** The one anchor
|
|
498
|
-
* white-on-black reading in EVERY theme
|
|
499
|
-
* worse and was dropped). */
|
|
500
|
-
const FILTER_BASE_L =
|
|
510
|
+
/** The one anchor set: near-black ground + DIM gray speckle for the
|
|
511
|
+
* surround — white-on-black reading in EVERY theme with the brightness
|
|
512
|
+
* doing the work (the light variant read far worse and was dropped). */
|
|
513
|
+
const FILTER_BASE_L = 48;
|
|
501
514
|
const FILTER_GROUND_L = 10;
|
|
502
515
|
|
|
503
516
|
/** Glyph aperture weight: bold strokes expose more of the counter-
|