@celestia-island/hikari 0.55.18 → 0.55.19

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.18",
3
+ "version": "0.55.19",
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",
package/src/DemoApp.tsx CHANGED
@@ -366,7 +366,8 @@ export default defineComponent({
366
366
  <HInput modelValue={form.value.text} onUpdate:modelValue={(v: string) => (form.value.text = v)} placeholder="Disabled" disabled />
367
367
  <HSearchInput modelValue={form.value.search} onUpdate:modelValue={(v: string | number) => (form.value.search = String(v))} placeholder="Search..." />
368
368
  <HNumberInput modelValue={form.value.number} onUpdate:modelValue={(v: number) => (form.value.number = v)} placeholder="Number" />
369
- <HInput variant="password" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (sweep reveal — default)" />
369
+ <HInput variant="password" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (filter reveal — default)" />
370
+ <HInput variant="password" revealStrategy="sweep" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (sweep reveal — readable band)" />
370
371
  <HInput variant="password" revealStrategy="noise" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (noise reveal — screenshot-safe)" />
371
372
  <HInput variant="password" revealStrategy="plain" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (plain reveal)" />
372
373
  <HInput variant="password" revealTrigger="toggle" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (click eye toggles)" />
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { Comment, createApp, createVNode, h, nextTick, ref, type Slot } from "vue";
3
3
 
4
4
  import HkInput from "./HkInput";
5
+ import HkPasswordSurface from "./HkPasswordSurface";
5
6
  import { NOISE_TILE_H, NOISE_TILE_W } from "./revealKinematogram";
6
7
  import { passwordLevel } from "../utils/password";
7
8
  import { setReducedMotion } from "../runtime/animationBus";
@@ -14,9 +15,9 @@ import { setReducedMotion } from "../runtime/animationBus";
14
15
  * hint, caps-lock / full-width hints, pending-clear refocus semantics
15
16
  * - right-edge affordance (passwordTrailing): eye reveal (default) /
16
17
  * strength traffic light / none
17
- * - reveal strategy (revealStrategy): "sweep" readable window (default)
18
- * vs "noise" boiling kinematogram (screenshot-safe) vs "plain"
19
- * readable text
18
+ * - reveal strategy (revealStrategy): "filter" dual counter-drifting
19
+ * spatter layers (default) vs "sweep" readable window vs "noise"
20
+ * boiling kinematogram (screenshot-safe) vs "plain" readable text
20
21
  * - reveal trigger (revealTrigger): press-and-hold (default) vs
21
22
  * click-to-toggle with auto-hide (revealAutoHideMs)
22
23
  * - the strength dot classifies through the shared passwordLevel util
@@ -1313,13 +1314,27 @@ describe("HkInput password reveal eye", () => {
1313
1314
  /** Installs a per-canvas recording getContext stub (happy-dom has no
1314
1315
  * real 2d): every canvas element gets its own recorder so a test can
1315
1316
  * tell the visible dot canvas apart from offscreen painter canvases. */
1316
- function stubRecordingContexts() {
1317
+ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
1318
+ // linearGradients: false simulates a pattern-ful but gradient-less
1319
+ // engine (the filter painter must hand the frame back BEFORE any
1320
+ // visible drawing so the component can latch the plain fallback).
1321
+ const { linearGradients = true } = opts;
1317
1322
  interface CanvasRec {
1318
1323
  canvas: HTMLCanvasElement;
1319
1324
  texts: string[];
1320
1325
  patternFills: number;
1321
1326
  drawImages: number;
1322
1327
  putImageDatas: number;
1328
+ gradients: number;
1329
+ /** fillRect calls that actually ran with a gradient as fillStyle —
1330
+ * the halo DRAWN pin (creation alone proves nothing, R2 M3). */
1331
+ gradientFills: number;
1332
+ /** globalCompositeOperation assignments, in order — the mask MUST
1333
+ * see "source-in" before its pattern fill or the glyphs never get
1334
+ * carved out of the ink spatter (R3 F2: without this the password
1335
+ * would render INVISIBLE on a real canvas, silently). */
1336
+ compositeOps: string[];
1337
+ fillStyles: string[];
1323
1338
  clips: Array<{ x: number; y: number; w: number; h: number }>;
1324
1339
  translates: number[];
1325
1340
  }
@@ -1336,13 +1351,20 @@ function stubRecordingContexts() {
1336
1351
  patternFills: 0,
1337
1352
  drawImages: 0,
1338
1353
  putImageDatas: 0,
1354
+ gradients: 0,
1355
+ gradientFills: 0,
1356
+ compositeOps: [],
1357
+ fillStyles: [],
1339
1358
  clips: [],
1340
1359
  translates: [],
1341
1360
  };
1342
1361
  byCanvas.set(this, rec);
1343
1362
  }
1344
1363
  const r = rec;
1345
- return {
1364
+ let fillStyleBox: string | CanvasGradient | CanvasPattern = "";
1365
+ let gradientArmed = false;
1366
+ let compositeOpBox: GlobalCompositeOperation = "source-over";
1367
+ const stub: Record<string, unknown> = {
1346
1368
  canvas: this,
1347
1369
  clearRect: () => {},
1348
1370
  save: () => {},
@@ -1352,7 +1374,12 @@ function stubRecordingContexts() {
1352
1374
  beginPath: () => {},
1353
1375
  arc: () => {},
1354
1376
  fill: () => {},
1355
- fillRect: () => {},
1377
+ fillRect: () => {
1378
+ if (gradientArmed) {
1379
+ r.gradientFills++;
1380
+ gradientArmed = false;
1381
+ }
1382
+ },
1356
1383
  measureText: () => ({ width: 10 }),
1357
1384
  fillText: (text: string) => r.texts.push(String(text)),
1358
1385
  drawImage: () => r.drawImages++,
@@ -1360,6 +1387,10 @@ function stubRecordingContexts() {
1360
1387
  r.patternFills++;
1361
1388
  return {} as CanvasPattern;
1362
1389
  },
1390
+ createLinearGradient: () => {
1391
+ r.gradients++;
1392
+ return { __recGradient: true, addColorStop: () => {} } as unknown as CanvasGradient;
1393
+ },
1363
1394
  createImageData: (w: number, h: number) => ({
1364
1395
  data: new Uint8ClampedArray(w * h * 4),
1365
1396
  }),
@@ -1368,12 +1399,30 @@ function stubRecordingContexts() {
1368
1399
  r.clips.push({ x, y, w, h }),
1369
1400
  clip: () => {},
1370
1401
  imageSmoothingEnabled: false,
1371
- globalCompositeOperation: "source-over",
1402
+ get globalCompositeOperation() {
1403
+ return compositeOpBox;
1404
+ },
1405
+ set globalCompositeOperation(v: GlobalCompositeOperation) {
1406
+ compositeOpBox = v;
1407
+ r.compositeOps.push(String(v));
1408
+ },
1372
1409
  font: "",
1373
- fillStyle: "",
1410
+ get fillStyle() {
1411
+ return fillStyleBox;
1412
+ },
1413
+ set fillStyle(v: string | CanvasGradient | CanvasPattern) {
1414
+ fillStyleBox = v;
1415
+ gradientArmed =
1416
+ typeof v === "object" &&
1417
+ v !== null &&
1418
+ (v as { __recGradient?: boolean }).__recGradient === true;
1419
+ if (typeof v === "string") r.fillStyles.push(v);
1420
+ },
1374
1421
  textAlign: "",
1375
1422
  textBaseline: "",
1376
- } as unknown as CanvasRenderingContext2D;
1423
+ };
1424
+ if (!linearGradients) delete stub.createLinearGradient;
1425
+ return stub as unknown as CanvasRenderingContext2D;
1377
1426
  }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
1378
1427
  return {
1379
1428
  byCanvas,
@@ -1384,13 +1433,15 @@ function stubRecordingContexts() {
1384
1433
  }
1385
1434
 
1386
1435
  describe("HkInput password reveal strategies", () => {
1387
- it("sweep (default) draws readable text inside a moving window over the noise field", async () => {
1388
- // The default reveal: real high-contrast text clipped to a band
1436
+ it("sweep draws readable text inside a moving window over the noise field", async () => {
1437
+ // The opt-in readable band: real high-contrast text clipped to a band
1389
1438
  // that sweeps across the row (sweepWindow), on top of the boiling
1390
1439
  // noise field. A single frame leaks only the band's characters.
1391
1440
  const rec = stubRecordingContexts();
1392
1441
  try {
1393
- const { container, input } = mountPasswordInput("abc");
1442
+ const { container, input } = mountPasswordInput("abc", {
1443
+ revealStrategy: "sweep",
1444
+ });
1394
1445
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1395
1446
  eye.dispatchEvent(
1396
1447
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
@@ -1437,7 +1488,9 @@ describe("HkInput password reveal strategies", () => {
1437
1488
  // left it (R2 mutation M23 — the sweepT reset had no pin).
1438
1489
  const rec = stubRecordingContexts();
1439
1490
  try {
1440
- const { container } = mountPasswordInput("abc");
1491
+ const { container } = mountPasswordInput("abc", {
1492
+ revealStrategy: "sweep",
1493
+ });
1441
1494
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1442
1495
  eye.dispatchEvent(
1443
1496
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
@@ -1483,7 +1536,9 @@ describe("HkInput password reveal strategies", () => {
1483
1536
  setReducedMotion(true);
1484
1537
  const rec = stubRecordingContexts();
1485
1538
  try {
1486
- const { container } = mountPasswordInput("abc");
1539
+ const { container } = mountPasswordInput("abc", {
1540
+ revealStrategy: "sweep",
1541
+ });
1487
1542
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1488
1543
  eye.dispatchEvent(
1489
1544
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
@@ -1508,6 +1563,240 @@ describe("HkInput password reveal strategies", () => {
1508
1563
  }
1509
1564
  });
1510
1565
 
1566
+ it("filter (default) keeps glyphs off the visible canvas: counter-drifting spatter, pedestal, halo", async () => {
1567
+ // The default reveal: STATIC glyph apertures filled with one
1568
+ // spatter texture, over a statistically matched spatter field
1569
+ // drifting the opposite way; the glyphs lifted by a small lightness
1570
+ // pedestal with a halo band around the row. Glyph geometry must
1571
+ // NEVER reach the visible canvas (mask → source-in stamp only) —
1572
+ // that is the screenshot contract. Math.random is pinned at 0.5 so
1573
+ // the dot lightness equals the exact layer base (deterministic
1574
+ // pedestal comparison).
1575
+ const rand = vi.spyOn(Math, "random").mockReturnValue(0.5);
1576
+ const rec = stubRecordingContexts();
1577
+ try {
1578
+ const { container, input } = mountPasswordInput("abc");
1579
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1580
+ eye.dispatchEvent(
1581
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1582
+ );
1583
+ await nextTick();
1584
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1585
+ const vis = rec.byCanvas.get(visible)!;
1586
+ // No glyph on the visible canvas — the screenshot sees spatter,
1587
+ // halo and a noise-composited stamp, never letterforms…
1588
+ expect(vis.texts).toEqual([]);
1589
+ // …while the offscreen mask rasterized the row exactly once.
1590
+ const allRecs = Array.from(rec.byCanvas.values());
1591
+ const mask = allRecs.find((r) => r.texts.length > 0);
1592
+ expect(mask, "offscreen glyph mask").toBeTruthy();
1593
+ expect(mask!.texts).toEqual(["a", "b", "c"]);
1594
+ // The mask MUST carve the glyphs out of the ink spatter via
1595
+ // source-in compositing — losing it renders the password
1596
+ // INVISIBLE on a real canvas (R3 F2, a silent total-loss
1597
+ // regression the suite previously could not see).
1598
+ expect(mask!.compositeOps, "mask carves glyphs via source-in").toContain("source-in");
1599
+ // The first frame already carries background spatter (pattern),
1600
+ // the halo ramp (gradient CREATED and then actually FILLED) and
1601
+ // the mask stamp (drawImage).
1602
+ expect(vis.patternFills).toBeGreaterThan(0);
1603
+ expect(vis.gradients, "halo gradient created").toBeGreaterThan(0);
1604
+ expect(vis.gradientFills, "halo band drawn").toBeGreaterThan(0);
1605
+ expect(vis.drawImages).toBeGreaterThan(0);
1606
+ // The two spatter tiles: bg first, ink second (deterministic
1607
+ // draw order), each with hundreds of solid-color dot fills. The
1608
+ // ink tile's mean color must sit ABOVE the bg tile's — the
1609
+ // lightness pedestal the human pop-out cue (and the only signal
1610
+ // a single frame leaks).
1611
+ const tiles = allRecs.filter(
1612
+ (r) => r.fillStyles.filter((s) => s.startsWith("rgb(")).length > 100,
1613
+ );
1614
+ expect(tiles.length, "exactly two spatter tiles").toBe(2);
1615
+ const meanOf = (r: (typeof tiles)[number]) => {
1616
+ const samples = r.fillStyles.filter((s) => s.startsWith("rgb("));
1617
+ let sum = 0;
1618
+ for (const s of samples) {
1619
+ const [r8, g8, b8] = s.slice(4, -1).split(",").map(Number);
1620
+ sum += (r8! + g8! + b8!) / 3;
1621
+ }
1622
+ return sum / samples.length;
1623
+ };
1624
+ const bgMean = meanOf(tiles[0]!);
1625
+ const inkMean = meanOf(tiles[1]!);
1626
+ expect(
1627
+ inkMean - bgMean,
1628
+ `glyph layer carries the lightness pedestal (bg ${bgMean.toFixed(1)} vs ink ${inkMean.toFixed(1)})`,
1629
+ ).toBeGreaterThan(8);
1630
+ // The DOM input still never flips.
1631
+ expect(input.type).toBe("password");
1632
+ // Bus frames advance BOTH layer drifts (fresh pattern phases on
1633
+ // the visible ctx for the background, on the mask ctx for the
1634
+ // glyph layer).
1635
+ const visTranslates = vis.translates.length;
1636
+ const maskTranslates = mask!.translates.length;
1637
+ const visPatterns = vis.patternFills;
1638
+ const visGradientFills = vis.gradientFills;
1639
+ // Distinct drift PHASES, not just repeat calls — a frozen
1640
+ // accumulator re-pushes the same wrapped offset every frame and
1641
+ // count-only pins cannot tell (R3 F1).
1642
+ const visDistinct = new Set(vis.translates).size;
1643
+ const maskDistinct = new Set(mask!.translates).size;
1644
+ for (let i = 0; i < 3; i++) {
1645
+ await new Promise((r) => setTimeout(r, 45));
1646
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
1647
+ }
1648
+ expect(vis.patternFills, "background layer keeps drifting").toBeGreaterThan(visPatterns);
1649
+ expect(vis.translates.length, "background pattern phase advances").toBeGreaterThan(visTranslates);
1650
+ expect(mask!.translates.length, "glyph layer counter-drifts").toBeGreaterThan(maskTranslates);
1651
+ expect(new Set(vis.translates).size, "background drift phases CHANGE").toBeGreaterThan(visDistinct);
1652
+ expect(new Set(mask!.translates).size, "glyph drift phases CHANGE").toBeGreaterThan(maskDistinct);
1653
+ expect(vis.gradientFills, "halo redrawn every frame").toBeGreaterThan(visGradientFills);
1654
+ // Still no glyph on the visible canvas after all those frames.
1655
+ expect(vis.texts).toEqual([]);
1656
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1657
+ await nextTick();
1658
+ } finally {
1659
+ rec.restore();
1660
+ rand.mockRestore();
1661
+ }
1662
+ });
1663
+
1664
+ it("filter regenerates both spatter tiles on every hold", async () => {
1665
+ // Fresh noise per hold is an anti-replay contract: two holds of the
1666
+ // same password must never replay the same frame sequence. Pin: the
1667
+ // tile canvases receive a full redraw (ground + every dot) per hold.
1668
+ const rec = stubRecordingContexts();
1669
+ try {
1670
+ const { container } = mountPasswordInput("abc");
1671
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1672
+ const hold = async () => {
1673
+ eye.dispatchEvent(
1674
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1675
+ );
1676
+ await nextTick();
1677
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1678
+ await nextTick();
1679
+ };
1680
+ await hold();
1681
+ const tiles = Array.from(rec.byCanvas.values()).filter(
1682
+ (r) => r.fillStyles.filter((s) => s.startsWith("rgb(")).length > 100,
1683
+ );
1684
+ expect(tiles.length).toBe(2);
1685
+ const counts1 = tiles.map((r) => r.fillStyles.length);
1686
+ expect(counts1[0]!, "ground + dots on hold 1").toBeGreaterThan(1000);
1687
+ await hold();
1688
+ const counts2 = tiles.map((r) => r.fillStyles.length);
1689
+ expect(counts2[0]).toBe(counts1[0]! * 2);
1690
+ expect(counts2[1]).toBe(counts1[1]! * 2);
1691
+ } finally {
1692
+ rec.restore();
1693
+ }
1694
+ });
1695
+
1696
+ it("filter degrades to static plain text when the animation bus is parked (reduced motion)", async () => {
1697
+ // Filter's static frame is camouflaged noise BY DESIGN — exactly
1698
+ // what a reduced-motion user cannot trade on. The degrade target is
1699
+ // therefore the fully readable plain text, like the sweep's.
1700
+ setReducedMotion(true);
1701
+ const rec = stubRecordingContexts();
1702
+ try {
1703
+ const { container } = mountPasswordInput("abc");
1704
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1705
+ eye.dispatchEvent(
1706
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1707
+ );
1708
+ await nextTick();
1709
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1710
+ const vis = rec.byCanvas.get(visible)!;
1711
+ expect(vis.texts).toEqual(["a", "b", "c"]);
1712
+ // No filter machinery on the visible canvas in the degraded state…
1713
+ expect(vis.patternFills).toBe(0);
1714
+ expect(vis.gradients).toBe(0);
1715
+ // …and the degrade target is PLAIN text, not the legacy jitter
1716
+ // (the jitter translates per glyph, plain never does).
1717
+ expect(vis.translates.length, "static plain, not jitter").toBe(0);
1718
+ await new Promise((r) => setTimeout(r, 260));
1719
+ expect(vis.texts).toEqual(["a", "b", "c"]);
1720
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1721
+ await nextTick();
1722
+ } finally {
1723
+ rec.restore();
1724
+ setReducedMotion(false);
1725
+ }
1726
+ });
1727
+
1728
+ it("filter hands the frame back untouched on a gradient-less engine, then latches the plain fallback", async () => {
1729
+ // A pattern-ful but gradient-less engine: the painter's pre-check
1730
+ // must bail BEFORE any visible drawing (no partial spatter frame),
1731
+ // and the component then shows the readable plain fallback — the
1732
+ // pin for the capability guard (R1 weak pin C2).
1733
+ const rec = stubRecordingContexts({ linearGradients: false });
1734
+ try {
1735
+ const { container } = mountPasswordInput("abc");
1736
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1737
+ eye.dispatchEvent(
1738
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1739
+ );
1740
+ await nextTick();
1741
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1742
+ const vis = rec.byCanvas.get(visible)!;
1743
+ // The painter handed the frame back WITHOUT drawing anything…
1744
+ expect(vis.patternFills, "no partial spatter frame").toBe(0);
1745
+ expect(vis.drawImages).toBe(0);
1746
+ // …and the fallback that engaged is the readable plain text.
1747
+ expect(vis.texts).toEqual(["a", "b", "c"]);
1748
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1749
+ await nextTick();
1750
+ } finally {
1751
+ rec.restore();
1752
+ }
1753
+ });
1754
+
1755
+ it("filter is the surface-level default too (direct HkPasswordSurface mount)", async () => {
1756
+ // HkInput declares and forwards its own default, which is where the
1757
+ // user-facing pin lives — but the surface ALSO declares a default,
1758
+ // and the two must not silently drift apart (R1 weak pin M7). The
1759
+ // surface is internal (never exported), so mount it directly.
1760
+ const rec = stubRecordingContexts();
1761
+ const model = ref("abc");
1762
+ const container = document.createElement("div");
1763
+ document.body.appendChild(container);
1764
+ const app = createApp({
1765
+ render() {
1766
+ return h(HkPasswordSurface, {
1767
+ modelValue: model.value,
1768
+ "onUpdate:modelValue": (v: string) => {
1769
+ model.value = v;
1770
+ },
1771
+ });
1772
+ },
1773
+ });
1774
+ app.mount(container);
1775
+ try {
1776
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1777
+ eye.dispatchEvent(
1778
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1779
+ );
1780
+ await nextTick();
1781
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1782
+ const vis = rec.byCanvas.get(visible)!;
1783
+ // Filter signature: glyphs only on the offscreen mask, two
1784
+ // spatter tiles, halo gradient — with NO revealStrategy prop.
1785
+ expect(vis.texts).toEqual([]);
1786
+ const tiles = Array.from(rec.byCanvas.values()).filter(
1787
+ (r) => r.fillStyles.filter((s) => s.startsWith("rgb(")).length > 100,
1788
+ );
1789
+ expect(tiles.length, "two spatter tiles = filter, not sweep/noise").toBe(2);
1790
+ expect(vis.gradientFills, "halo band drawn").toBeGreaterThan(0);
1791
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1792
+ await nextTick();
1793
+ } finally {
1794
+ app.unmount();
1795
+ container.remove();
1796
+ rec.restore();
1797
+ }
1798
+ });
1799
+
1511
1800
  it("plain strategy draws readable text on the VISIBLE canvas — and nothing else", async () => {
1512
1801
  // The opt-in readable reveal (revealStrategy="plain"): glyphs ARE
1513
1802
  // the visible frame — no noise fills, no mask stamp — while the DOM
@@ -1888,10 +2177,13 @@ describe("HkInput password reveal trigger", () => {
1888
2177
  // intervening release must not re-seed the painter mid-hold (fresh
1889
2178
  // noise + phase reset would visibly flash the field). Observable
1890
2179
  // pin: beginHold retiles the noise canvas — exactly ONE
1891
- // putImageData per reveal.
2180
+ // putImageData per reveal (the NOISE painter's signature; the
2181
+ // filter painter retiles with vector fills instead).
1892
2182
  const rec = stubRecordingContexts();
1893
2183
  try {
1894
- const { container } = mountPasswordInput("abc");
2184
+ const { container } = mountPasswordInput("abc", {
2185
+ revealStrategy: "noise",
2186
+ });
1895
2187
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1896
2188
  eye.dispatchEvent(
1897
2189
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
@@ -58,12 +58,12 @@ export default defineComponent({
58
58
  /**
59
59
  * Right-edge affordance for `variant="password"`:
60
60
  * - "eye" (default): the reveal button. While revealed, the canvas
61
- * renders the reveal chosen by `revealStrategy` (the sweep
62
- * readable-window by default; boiling-noise and plain text on
63
- * opt-in); the trigger is `revealTrigger` (press-and-hold by
64
- * default, click-toggle on opt-in). The DOM value stays inside
65
- * the type="password" input at all times — every strategy draws
66
- * on the canvas.
61
+ * renders the reveal chosen by `revealStrategy` (the dual-layer
62
+ * counter-drifting spatter "filter" by default; sweep readable-
63
+ * window, boiling-noise and plain text on opt-in); the trigger
64
+ * is `revealTrigger` (press-and-hold by default, click-toggle on
65
+ * opt-in). The DOM value stays inside the type="password" input
66
+ * at all times — every strategy draws on the canvas.
67
67
  * - "strength": the traffic-light dot (weak / fair / strong via the
68
68
  * shared `passwordLevel` classifier, overridable through
69
69
  * `strengthEvaluator`) with a localized tooltip on hover and on
@@ -77,11 +77,17 @@ export default defineComponent({
77
77
  /**
78
78
  * Reveal content for the password eye (`variant="password"`,
79
79
  * `passwordTrailing="eye"`):
80
- * - "sweep" (default): a readable window — ordinary high-contrast
81
- * text inside a narrow band sweeping across the row over the
82
- * boiling-noise field. Easy to read; a single screenshot leaks
83
- * only the characters under the band (partial capture
84
- * resistance).
80
+ * - "filter" (default): dual counter-drifting spatter layers —
81
+ * static glyph apertures filled with one spatter texture over a
82
+ * statistically matched field drifting the opposite way, plus a
83
+ * small lightness pedestal and a halo band around the row. The
84
+ * whole row stays readable in motion; a single-frame screenshot
85
+ * carries no glyph structure, only a weak luminance signal
86
+ * dissolved into the halo.
87
+ * - "sweep": a readable window — ordinary high-contrast text inside
88
+ * a narrow band sweeping across the row over the boiling-noise
89
+ * field. Easy to read; a single screenshot leaks the characters
90
+ * under the band in the clear (partial capture resistance).
85
91
  * - "noise": the boiling-noise kinematogram — statistically pure
86
92
  * noise in any single-frame screenshot (nothing for OCR to lock
87
93
  * onto), but the hardest to read. Opt-in for high-exposure
@@ -92,8 +98,8 @@ export default defineComponent({
92
98
  * shoulder surfers. Pick per threat model.
93
99
  */
94
100
  revealStrategy: {
95
- type: String as () => "sweep" | "noise" | "plain",
96
- default: "sweep",
101
+ type: String as () => "filter" | "sweep" | "noise" | "plain",
102
+ default: "filter",
97
103
  },
98
104
  /**
99
105
  * Reveal interaction for the password eye: "hold" (default) reveals
@@ -24,6 +24,7 @@ import HkTooltip from "./HkTooltip";
24
24
  import { HkPlaceholderMarquee, type PlaceholderVariant } from "./HkPlaceholderMarquee";
25
25
  import {
26
26
  layoutRevealGlyphs,
27
+ RevealFilterPainter,
27
28
  RevealNoisePainter,
28
29
  sweepWindow,
29
30
  type RevealLayout,
@@ -51,24 +52,29 @@ interface Ripple {
51
52
  * Right-edge affordance (`passwordTrailing`):
52
53
  * - "eye" (default): the reveal button. What the reveal SHOWS is chosen
53
54
  * by `revealStrategy`, how it is TRIGGERED by `revealTrigger`:
54
- * - strategy "sweep" (default): a readable window — the password is
55
- * drawn as ordinary high-contrast text inside a narrow band that
56
- * sweeps across the row over the boiling-noise field (see
57
- * revealKinematogram.ts). Reading is effortless; a single frame —
58
- * a screenshot — shows only the characters under the band, the rest
59
- * stays noise (partial capture resistance). Reduced motion or a
60
- * pattern-less engine degrades to the fully readable static plain
61
- * text.
55
+ * - strategy "filter" (default): dual counter-drifting spatter
56
+ * layers — the glyph row is a set of STATIC apertures filled with a
57
+ * spatter texture drifting one way, over a statistically identical
58
+ * spatter field drifting the other way, the glyphs lifted by a
59
+ * small lightness pedestal with a soft halo band around the row
60
+ * (see revealKinematogram.ts). The whole row stays readable in
61
+ * motion while any single frame — a screenshot — carries no glyph
62
+ * structure, only a weak mean-luminance signal dissolved into the
63
+ * halo ramp. Reduced motion or a pattern-less engine degrades to
64
+ * the fully readable static plain text.
65
+ * - strategy "sweep": a readable window — the password is drawn as
66
+ * ordinary high-contrast text inside a narrow band that sweeps
67
+ * across the row over the boiling-noise field. Reading is
68
+ * effortless; a single frame shows the characters under the band
69
+ * in the clear (partial capture resistance only).
62
70
  * - strategy "noise": the screenshot-safe boiling-noise kinematogram:
63
71
  * one shared noise tile, the background drifting sideways while the
64
72
  * noise sampled through the password glyphs is re-sampled at a
65
- * random phase every frame. A human reads the glyph silhouettes off
66
- * the flicker-vs-drift contrast, while any single frame — a
67
- * screenshot — is pure noise with no glyph structure for OCR to
68
- * lock onto. The hardest to read; opt-in for high-exposure
69
- * surfaces. When the animation bus is parked (reduced motion) or
70
- * the engine cannot run the pattern path, the reveal degrades to
71
- * the legacy static per-glyph jitter drawing.
73
+ * random phase every frame. Statistically pure noise in ANY single
74
+ * frame (nothing for OCR), but the hardest to read; opt-in for
75
+ * high-exposure surfaces. When the animation bus is parked
76
+ * (reduced motion) or the engine cannot run the pattern path, the
77
+ * reveal degrades to the legacy static per-glyph jitter drawing.
72
78
  * - strategy "plain": the industry-standard readable reveal — the
73
79
  * password is drawn as ordinary text on the canvas while revealed
74
80
  * (the DOM input stays type="password"). Readable by everyone,
@@ -112,11 +118,14 @@ export default defineComponent({
112
118
  },
113
119
  /**
114
120
  * What the eye reveal SHOWS:
115
- * - "sweep" (default): a readable window — the password is drawn as
116
- * ordinary high-contrast text inside a narrow band that sweeps
117
- * across the row over the boiling-noise field. Genuinely easy to
118
- * read; a single screenshot leaks only the characters under the
119
- * band (partial capture resistance).
121
+ * - "filter" (default): dual counter-drifting spatter layers —
122
+ * static glyph apertures over an oppositely drifting, statisti-
123
+ * cally matched spatter field, plus a small lightness pedestal
124
+ * and halo. Readable in motion; a single screenshot carries no
125
+ * glyph structure, only a weak luminance signal.
126
+ * - "sweep": a readable window — ordinary high-contrast text inside
127
+ * a narrow band sweeping across the row. Easy to read; a single
128
+ * screenshot leaks the band's characters in the clear.
120
129
  * - "noise": the screenshot-safe boiling-noise kinematogram —
121
130
  * statistically pure noise in any single frame, nothing for OCR,
122
131
  * but the hardest to read (opt-in for high-exposure surfaces).
@@ -124,8 +133,8 @@ export default defineComponent({
124
133
  * screenshot-visible — pick per threat model).
125
134
  */
126
135
  revealStrategy: {
127
- type: String as () => "sweep" | "noise" | "plain",
128
- default: "sweep",
136
+ type: String as () => "filter" | "sweep" | "noise" | "plain",
137
+ default: "filter",
129
138
  },
130
139
  /**
131
140
  * How the eye reveal is TRIGGERED: "hold" (default) = press-and-
@@ -177,6 +186,7 @@ export default defineComponent({
177
186
  // drives the sweep strategy's window position (seconds since the
178
187
  // reveal started).
179
188
  const revealNoise = new RevealNoisePainter();
189
+ const revealFilter = new RevealFilterPainter();
180
190
  let revealFrames = 0;
181
191
  let revealLayoutValue = "";
182
192
  let revealLayoutW = -1;
@@ -494,7 +504,7 @@ export default defineComponent({
494
504
  }
495
505
 
496
506
  /**
497
- * Sweep reveal pass (`revealStrategy="sweep"`, the default): the
507
+ * Sweep reveal pass (`revealStrategy="sweep"`): the
498
508
  * boiling-noise field stays as the base layer, and the password is
499
509
  * drawn as ordinary high-contrast text ONLY inside a narrow window
500
510
  * that sweeps across the row (sweepWindow kinematics: constant
@@ -547,6 +557,28 @@ export default defineComponent({
547
557
  ctx.restore();
548
558
  }
549
559
 
560
+ /**
561
+ * Filter reveal pass (`revealStrategy="filter"`, the default): the
562
+ * whole row stays on screen at all times — static glyph apertures
563
+ * filled with one spatter texture, over a statistically identical
564
+ * spatter field drifting the OPPOSITE way, plus a small lightness
565
+ * pedestal and a halo band. A human reads the row continuously off
566
+ * the counter-motion + pedestal; a single frame carries no glyph
567
+ * structure (matched texture statistics), only the weak pedestal
568
+ * signal dissolved into the halo ramp. Like the sweep, a pattern-
569
+ * less engine latches the static fallback to PLAIN text — filter
570
+ * exists for readability, never degrade to frozen noise.
571
+ */
572
+ function drawRevealFilterFrame(ctx: CanvasRenderingContext2D, W: number, H: number, dt: number) {
573
+ const layout = revealLayoutFor(ctx, W, H);
574
+ if (!layout || layout.glyphs.length === 0) return;
575
+ revealFilter.advance(dt, dpr);
576
+ if (!revealFilter.paint(ctx, W, H, layout, cachedMonoFont || syncMonoFont(), revealLayoutKey)) {
577
+ revealStaticFallback = true;
578
+ drawRevealPlainText(ctx, W, H);
579
+ }
580
+ }
581
+
550
582
  /**
551
583
  * Legacy anti-OCR fallback, used only when frames cannot drive the
552
584
  * kinematogram (parked animation bus — reduced motion — or an
@@ -628,6 +660,16 @@ export default defineComponent({
628
660
  drawRevealSweepFrame(ctx, W, H, dt);
629
661
  return;
630
662
  }
663
+ if (props.revealStrategy === "filter") {
664
+ // Same degrade contract as the sweep: readable in motion,
665
+ // plain text when motion cannot run.
666
+ if (revealStaticFallback) {
667
+ drawRevealPlainText(ctx, W, H);
668
+ return;
669
+ }
670
+ drawRevealFilterFrame(ctx, W, H, dt);
671
+ return;
672
+ }
631
673
  if (!revealStaticFallback && drawRevealNoise(ctx, W, H, dt)) return;
632
674
  drawRevealJitterText(ctx, W, H);
633
675
  return;
@@ -724,27 +766,37 @@ export default defineComponent({
724
766
  if (!props.modelValue || props.disabled || revealing.value) return;
725
767
  syncTextHsl();
726
768
  syncMonoFont();
727
- // The plain strategy is static — no painter, no watchdog. Both
728
- // motion strategies (sweep default, noise opt-in) drive the noise
769
+ // The plain strategy is static — no painter, no watchdog. The
770
+ // motion strategies (filter default, sweep, noise) drive their
729
771
  // painter per frame and degrade to a STATIC fallback when frames
730
- // cannot drive them: sweep falls back to plain text (its whole
731
- // point is readability), noise to the legacy jitter.
772
+ // cannot drive them: filter and sweep fall back to plain text
773
+ // (their whole point is readability), noise to the legacy jitter.
774
+ // A parked bus (reduced motion) will never deliver a frame, so
775
+ // the motion reveals would freeze — filter and sweep into an
776
+ // unreadable mid-state, noise into pure noise — degrade
777
+ // immediately to the strategy's static fallback (plain text for
778
+ // filter and sweep, legacy jitter for noise). Motion-sensitive
779
+ // users keep their preference and the reveal stays usable. The
780
+ // plain strategy needs no degrade: its static text is already
781
+ // motion-free. The parked check runs BEFORE beginHold so a
782
+ // reduced-motion hold never builds noise/spatter tiles it will
783
+ // never paint.
732
784
  const isPlain = props.revealStrategy === "plain";
785
+ const parked = !isPlain && isAnimationParked();
733
786
  if (!isPlain) {
734
787
  revealStaticFallback = false;
735
788
  revealFrames = 0;
736
789
  sweepT = 0;
737
- revealNoise.beginHold(textHsl);
790
+ if (!parked) {
791
+ if (props.revealStrategy === "filter") {
792
+ revealFilter.beginHold(textHsl, dpr);
793
+ } else {
794
+ revealNoise.beginHold(textHsl);
795
+ }
796
+ }
738
797
  }
739
798
  revealing.value = true;
740
- // A parked bus (reduced motion) will never deliver a frame, so
741
- // the motion reveals would freeze — the sweep into unreadable
742
- // mid-state noise, the noise into pure noise — degrade immediately
743
- // to the strategy's static fallback (plain text for sweep, legacy
744
- // jitter for noise). Motion-sensitive users keep their preference
745
- // and the reveal stays usable. The plain strategy needs no
746
- // degrade: its static text is already motion-free.
747
- if (!isPlain && isAnimationParked()) {
799
+ if (parked) {
748
800
  revealStaticFallback = true;
749
801
  }
750
802
  // Paint one synchronous frame so the reveal appears instantly;
@@ -2,8 +2,10 @@ import { describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import {
4
4
  BACKGROUND_DRIFT_PX_S,
5
+ FILTER_DRIFT_PX_S,
5
6
  layoutRevealGlyphs,
6
7
  NOISE_TILE_W,
8
+ RevealFilterPainter,
7
9
  RevealNoisePainter,
8
10
  sweepWindow,
9
11
  SWEEP_END_PAUSE_S,
@@ -132,6 +134,65 @@ describe("RevealNoisePainter", () => {
132
134
  });
133
135
  });
134
136
 
137
+ describe("RevealFilterPainter", () => {
138
+ it("seeds both layer phases from randomness at each hold", () => {
139
+ const p = new RevealFilterPainter();
140
+ const rand = vi.spyOn(Math, "random").mockReturnValue(0.25);
141
+ try {
142
+ p.beginHold([220, 10, 15]);
143
+ // Drift-sign draw, then one phase draw per layer before retile;
144
+ // happy-dom has no 2d context, so the tile builds bail before
145
+ // consuming any more randomness.
146
+ const d = p.peekDrift();
147
+ expect(d.background).toBeCloseTo(0.25 * NOISE_TILE_W, 10);
148
+ expect(d.ink).toBeCloseTo(0.25 * NOISE_TILE_W, 10);
149
+ } finally {
150
+ rand.mockRestore();
151
+ }
152
+ });
153
+
154
+ it("drifts the two layers in OPPOSITE directions at equal speed", () => {
155
+ // The segregation cue IS the 180° direction difference: a mutation
156
+ // sending both layers the same way (or at unequal speeds) must go
157
+ // red here.
158
+ const p = new RevealFilterPainter();
159
+ const rand = vi.spyOn(Math, "random").mockReturnValue(0.25); // driftSign = +1
160
+ try {
161
+ p.beginHold([220, 10, 15]);
162
+ const before = p.peekDrift();
163
+ p.advance(0.25, 2);
164
+ const after = p.peekDrift();
165
+ const step = FILTER_DRIFT_PX_S * 2 * 0.25;
166
+ expect(after.background - before.background).toBeCloseTo(step, 10);
167
+ expect(after.ink - before.ink).toBeCloseTo(-step, 10);
168
+ } finally {
169
+ rand.mockRestore();
170
+ }
171
+ });
172
+
173
+ it("flips both drift directions when the per-hold draw says so", () => {
174
+ const p = new RevealFilterPainter();
175
+ const rand = vi.spyOn(Math, "random").mockReturnValue(0.75); // driftSign = -1
176
+ try {
177
+ p.beginHold([220, 10, 15]);
178
+ const before = p.peekDrift();
179
+ p.advance(0.25, 2);
180
+ const after = p.peekDrift();
181
+ const step = FILTER_DRIFT_PX_S * 2 * 0.25;
182
+ expect(after.background - before.background).toBeCloseTo(-step, 10);
183
+ expect(after.ink - before.ink).toBeCloseTo(step, 10);
184
+ } finally {
185
+ rand.mockRestore();
186
+ }
187
+ });
188
+
189
+ it("never throws without a 2d context and stays unusable (fallback path)", () => {
190
+ const p = new RevealFilterPainter();
191
+ expect(() => p.beginHold([220, 10, 15])).not.toThrow();
192
+ expect(p.available).toBe(false);
193
+ });
194
+ });
195
+
135
196
  describe("sweepWindow", () => {
136
197
  const dpr = 1;
137
198
  // A 100px travel at 220 CSS px/s ≈ 0.4545s of movement per cycle.
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * revealKinematogram — the canvas rendering behind the HkPasswordSurface
3
3
  * reveal strategies: the boiling-noise kinematogram (the "noise"
4
- * strategy — screenshot-safe in any single frame) and the sweep-window
4
+ * strategy — screenshot-safe in any single frame), the sweep-window
5
5
  * kinematics for the "sweep" strategy (a readable band over the noise;
6
- * deliberately NOT single-frame safe). Both serve hold and toggle
7
- * triggers; the "plain" strategy bypasses this module entirely.
6
+ * deliberately NOT single-frame safe), and the dual counter-drifting
7
+ * spatter layers of the "filter" strategy (readable in motion, only a
8
+ * weak luminance signal in any single frame). All serve hold and
9
+ * toggle triggers; the "plain" strategy bypasses this module entirely.
8
10
  *
9
11
  * Principle: the whole reveal area is filled with ONE shared noise tile.
10
12
  * The glyph mask stays perfectly still while the BACKGROUND noise drifts
@@ -385,3 +387,273 @@ export class RevealNoisePainter {
385
387
  }
386
388
  }
387
389
  }
390
+
391
+ /** ── Filter strategy: dual counter-drifting spatter layers ──────────
392
+ *
393
+ * Design (literature-grounded, chosen over the sweep when screenshot
394
+ * resistance matters more than instant legibility): the whole reveal
395
+ * area carries ONE spatter texture drifting in one direction; the
396
+ * password glyphs are STATIC apertures carrying a SECOND spatter
397
+ * texture — statistically identical (same generator, dot size and
398
+ * lightness distribution) but drifting the OPPOSITE way and lifted by
399
+ * a small lightness pedestal, with a soft halo band brightening the
400
+ * surround of the glyph row. A human segments the two layers
401
+ * effortlessly (motion transparency at a 180° direction difference is
402
+ * the strongest segregation cue the visual system has) and reads the
403
+ * row aided by the pedestal + halo, while any SINGLE frame carries no
404
+ * motion at all: the glyph boundary survives only as a small mean-
405
+ * luminance step inside a smooth halo ramp — nothing for a global or
406
+ * adaptive threshold to plateau on, and (matched statistics) nothing
407
+ * for a texture classifier either.
408
+ *
409
+ * Why these parameters (the failure modes of video CAPTCHAs say what
410
+ * to avoid — NuCAPTCHA & animated-GIF schemes died to per-frame OCR +
411
+ * cross-frame registration; the kinematogram above replaced an earlier
412
+ * counter-DRIFT variant of this module that was illegible at field
413
+ * font sizes):
414
+ * - Spatter dots ~0.6–1.4× the stroke width, not 1px grain: masking
415
+ * and motion signal both peak near the letters' diagnostic spatial
416
+ * band, so MODERATE noise contrast suffices (the old design's fine
417
+ * grain + fast ±40px/s drift is exactly what made it unreadable).
418
+ * - Drift ±FILTER_DRIFT_PX_S: slow enough to track coherently at field
419
+ * sizes, fast enough that a single frame carries no usable motion
420
+ * energy (form-from-motion needs ~100–200ms of integration).
421
+ * - The glyph APERTURES never move — only the texture inside them
422
+ * flows. Cross-frame registration of the glyph shapes (the attack
423
+ * that killed video CAPTCHAs) finds nothing to align.
424
+ * - The pedestal is deliberately SMALL: it is a static first-order cue
425
+ * and therefore the one signal a single frame leaks. Kept at
426
+ * FILTER_PEDESTAL_L lightness points and spread by the halo ramp, it
427
+ * aids human pop-out without giving thresholding a plateau. An
428
+ * attacker averaging MANY frames can in principle recover the
429
+ * pedestal's DC component (same cost class as the video attack on
430
+ * the noise strategy) — accepted risk, documented; consumers that
431
+ * cannot accept it pick "noise" (zero static signal) or "plain"
432
+ * (full readability).
433
+ * - The drift sign is re-randomized per hold (and both tiles are
434
+ * freshly generated per hold) so two holds never replay the same
435
+ * frame sequence.
436
+ *
437
+ * Invariants the tests pin: glyph geometry NEVER touches the visible
438
+ * canvas (mask → `source-in` noise stamp, exactly like the noise
439
+ * painter); the two tiles differ in mean luminance (the pedestal);
440
+ * the halo gradient is drawn every frame; a pattern-less or
441
+ * gradient-less engine degrades to the static plain text (filter
442
+ * exists FOR readability — never to frozen noise). */
443
+
444
+ /** Counter-drift speed of each layer in CSS px/s (opposite signs). */
445
+ export const FILTER_DRIFT_PX_S = 84;
446
+
447
+ /** Lightness pedestal of the glyph layer over the background layer,
448
+ * in HSL lightness points (clamped into [L_MIN, L_MAX] like every
449
+ * sample). Small on purpose — see the strategy docblock. */
450
+ export const FILTER_PEDESTAL_L = 10;
451
+
452
+ /** Peak alpha of the halo band (white, at the glyph-row midline). */
453
+ export const FILTER_HALO_ALPHA = 0.1;
454
+
455
+ /** Halo half-height as a multiple of the glyph font size (device px):
456
+ * the ramp spans ±this × fontPx around the row midline, so the
457
+ * pedestal step dissolves into a gradient ~1–2 letter heights wide. */
458
+ export const FILTER_HALO_FONT_SCALE = 1.2;
459
+
460
+ /** Spatter dot radius band in CSS px (scaled by dpr into the device-px
461
+ * tile): ≈0.6–1.4× the stroke width of the 13–22px reveal band, the
462
+ * spatial scale where masking is most efficient per unit contrast. */
463
+ const FILTER_SPATTER_R_MIN_CSS = 1.2;
464
+ const FILTER_SPATTER_R_MAX_CSS = 2.8;
465
+
466
+ /** Spatter coverage: one dot per this many tile px². BOTH layers share
467
+ * the density, size and lightness distributions — matched texture
468
+ * statistics are the single-frame defense; only the mean luminance
469
+ * (pedestal) and the drift direction differ. */
470
+ const FILTER_SPATTER_PX_PER_DOT = 45;
471
+
472
+ /** Dot lightness spread around the layer base (both layers share it). */
473
+ const FILTER_SPATTER_L_SPREAD = 22;
474
+
475
+ /** Tile ground sits this far below the layer base so the dots read as
476
+ * speckles on a darker field; the pedestal lifts ground AND dots
477
+ * together so the whole glyph aperture carries the +pedestal mean. */
478
+ const FILTER_SPATTER_GROUND_L = 30;
479
+
480
+ export class RevealFilterPainter {
481
+ private bgTile: HTMLCanvasElement | null = null;
482
+ private inkTile: HTMLCanvasElement | null = null;
483
+ private mask: HTMLCanvasElement | null = null;
484
+ private maskKey = "";
485
+ private offsetBackground = 0;
486
+ private offsetInk = 0;
487
+ private driftSign = 1;
488
+ private dpr = 1;
489
+ private ok = true;
490
+
491
+ /** False once canvas 2D is unusable: the caller degrades to the
492
+ * static plain text (filter exists for readability). */
493
+ get available(): boolean {
494
+ return this.ok;
495
+ }
496
+
497
+ /** Accumulated pre-wrap drift of both layers in device px (test
498
+ * window: the two must move in OPPOSITE directions at equal speed). */
499
+ peekDrift(): { background: number; ink: number } {
500
+ return { background: this.offsetBackground, ink: this.offsetInk };
501
+ }
502
+
503
+ /** Start a hold: two FRESH spatter tiles (background at the base
504
+ * lightness, glyph layer lifted by the pedestal), randomized phases
505
+ * and a randomized drift direction, so replays are never
506
+ * pixel-identical and automation cannot precompute the motion. */
507
+ beginHold(base: Hsl, dpr = 1): void {
508
+ this.dpr = dpr;
509
+ this.driftSign = Math.random() < 0.5 ? 1 : -1;
510
+ this.offsetBackground = Math.random() * NOISE_TILE_W;
511
+ this.offsetInk = Math.random() * NOISE_TILE_W;
512
+ this.maskKey = ""; // force a mask rebuild on the first paint
513
+ this.ok = this.retile(base, 0, "bg") && this.retile(base, FILTER_PEDESTAL_L, "ink");
514
+ }
515
+
516
+ advance(dt: number, dpr: number): void {
517
+ const step = FILTER_DRIFT_PX_S * dpr * dt;
518
+ this.offsetBackground += this.driftSign * step;
519
+ this.offsetInk -= this.driftSign * step;
520
+ }
521
+
522
+ /** (Re)build one spatter tile: a darkened ground of the ink color,
523
+ * then a fixed-density scatter of soft dots at the layer lightness.
524
+ * Draw order is deterministic (bg tile first, then ink) so tests can
525
+ * attribute the per-canvas recordings. */
526
+ private retile(base: Hsl, pedestalL: number, which: "bg" | "ink"): boolean {
527
+ try {
528
+ if (typeof document === "undefined") return false;
529
+ if (which === "bg") {
530
+ this.bgTile ??= document.createElement("canvas");
531
+ } else {
532
+ this.inkTile ??= document.createElement("canvas");
533
+ }
534
+ const tile = (which === "bg" ? this.bgTile : this.inkTile)!;
535
+ tile.width = NOISE_TILE_W;
536
+ tile.height = NOISE_TILE_H;
537
+ const tctx = tile.getContext("2d");
538
+ if (!tctx) return false;
539
+ const [h, s, l] = base;
540
+ const lift = (v: number) => Math.min(L_MAX, Math.max(L_MIN, v));
541
+ const [gr, gg, gb] = hslToRgb(h, s, lift(l + pedestalL - FILTER_SPATTER_GROUND_L));
542
+ tctx.fillStyle = `rgb(${gr},${gg},${gb})`;
543
+ tctx.fillRect(0, 0, NOISE_TILE_W, NOISE_TILE_H);
544
+ const count = Math.round((NOISE_TILE_W * NOISE_TILE_H) / FILTER_SPATTER_PX_PER_DOT);
545
+ const rMin = FILTER_SPATTER_R_MIN_CSS * this.dpr;
546
+ const rMax = FILTER_SPATTER_R_MAX_CSS * this.dpr;
547
+ for (let i = 0; i < count; i++) {
548
+ const dl = lift(l + pedestalL + (Math.random() * 2 - 1) * FILTER_SPATTER_L_SPREAD);
549
+ const [r, g, b] = hslToRgb(h, s, dl);
550
+ tctx.fillStyle = `rgb(${r},${g},${b})`;
551
+ const rad = rMin + Math.random() * (rMax - rMin);
552
+ tctx.beginPath();
553
+ tctx.arc(Math.random() * NOISE_TILE_W, Math.random() * NOISE_TILE_H, rad, 0, Math.PI * 2);
554
+ tctx.fill();
555
+ }
556
+ return true;
557
+ } catch {
558
+ return false;
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Composite one frame: background spatter translated by its
564
+ * accumulated drift, the halo ramp centered on the glyph row, then
565
+ * the glyph mask — rebuilt only when `maskKey` changes — re-filled
566
+ * with the ink spatter at ITS accumulated counter-drift and stamped
567
+ * on top. Returns false (latching ONLY on a thrown error, like the
568
+ * noise painter) when the pattern/gradient path is unavailable.
569
+ */
570
+ paint(
571
+ ctx: CanvasRenderingContext2D,
572
+ W: number,
573
+ H: number,
574
+ layout: RevealLayout,
575
+ monoFont: string,
576
+ maskKey: string,
577
+ ): boolean {
578
+ if (!this.bgTile || !this.inkTile || !this.ok) return false;
579
+ try {
580
+ if (typeof document === "undefined") return false;
581
+ // Capability pre-check BEFORE anything is drawn: a partial frame
582
+ // (spatter without halo or text) must never flash on screen.
583
+ if (
584
+ typeof ctx.createPattern !== "function" ||
585
+ typeof ctx.createLinearGradient !== "function"
586
+ ) {
587
+ return false;
588
+ }
589
+ // Create the halo gradient up front as well: creation draws
590
+ // nothing, so a bail here still leaves the visible canvas
591
+ // untouched (no partial frame — see the pre-check above).
592
+ const bandH = Math.min(H / 2, FILTER_HALO_FONT_SCALE * layout.fontPx);
593
+ const midY = H / 2;
594
+ const grad = ctx.createLinearGradient(0, midY - bandH, 0, midY + bandH);
595
+ if (!grad) return false;
596
+ grad.addColorStop(0, "rgba(255,255,255,0)");
597
+ grad.addColorStop(0.5, `rgba(255,255,255,${FILTER_HALO_ALPHA})`);
598
+ grad.addColorStop(1, "rgba(255,255,255,0)");
599
+ this.mask ??= document.createElement("canvas");
600
+ if (this.mask.width !== W || this.mask.height !== H) {
601
+ this.mask.width = W;
602
+ this.mask.height = H;
603
+ this.maskKey = "";
604
+ }
605
+ const mctx = this.mask.getContext("2d");
606
+ if (!mctx) return false;
607
+
608
+ if (this.maskKey !== maskKey) {
609
+ mctx.globalCompositeOperation = "source-over";
610
+ mctx.clearRect(0, 0, W, H);
611
+ mctx.fillStyle = "#fff";
612
+ mctx.textAlign = "left";
613
+ mctx.textBaseline = "middle";
614
+ mctx.font = `${layout.fontPx.toFixed(2)}px ${monoFont}`;
615
+ for (const g of layout.glyphs) {
616
+ mctx.fillText(g.ch, g.x, H / 2);
617
+ }
618
+ this.maskKey = maskKey;
619
+ }
620
+
621
+ const patInk = mctx.createPattern(this.inkTile, "repeat");
622
+ if (!patInk) return false;
623
+ mctx.save();
624
+ mctx.globalCompositeOperation = "source-in";
625
+ mctx.imageSmoothingEnabled = false;
626
+ const oi = Math.round(wrapDrift(this.offsetInk, NOISE_TILE_W));
627
+ mctx.translate(-oi, 0);
628
+ mctx.fillStyle = patInk;
629
+ mctx.fillRect(oi, 0, W + 2, H);
630
+ mctx.restore();
631
+
632
+ const patBg = ctx.createPattern(this.bgTile, "repeat");
633
+ if (!patBg) return false;
634
+ ctx.save();
635
+ ctx.imageSmoothingEnabled = false;
636
+ const ob = Math.round(wrapDrift(this.offsetBackground, NOISE_TILE_W));
637
+ ctx.translate(-ob, 0);
638
+ ctx.fillStyle = patBg;
639
+ ctx.fillRect(ob, 0, W + 2, H);
640
+ ctx.restore();
641
+
642
+ // Halo: a smooth vertical ramp centered on the glyph row — the
643
+ // surround of the text sits slightly brighter, aiding pop-out
644
+ // while dissolving the pedestal step into a gradient with no
645
+ // plateau for thresholding to lock onto (gradient created up
646
+ // front, before any visible drawing).
647
+ ctx.save();
648
+ ctx.fillStyle = grad;
649
+ ctx.fillRect(0, midY - bandH, W, bandH * 2);
650
+ ctx.restore();
651
+
652
+ ctx.drawImage(this.mask, 0, 0);
653
+ return true;
654
+ } catch {
655
+ this.ok = false;
656
+ return false;
657
+ }
658
+ }
659
+ }