@celestia-island/hikari 0.55.19 → 0.55.21

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.19",
3
+ "version": "0.55.21",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -1334,6 +1334,11 @@ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
1334
1334
  * carved out of the ink spatter (R3 F2: without this the password
1335
1335
  * would render INVISIBLE on a real canvas, silently). */
1336
1336
  compositeOps: string[];
1337
+ /** Gradient stop colors, in order — pins the halo's THEME color
1338
+ * (white over a dark ground, black over a light one). */
1339
+ gradientStops: string[];
1340
+ /** Font string assignments — pins the BOLD glyph aperture. */
1341
+ fonts: string[];
1337
1342
  fillStyles: string[];
1338
1343
  clips: Array<{ x: number; y: number; w: number; h: number }>;
1339
1344
  translates: number[];
@@ -1354,6 +1359,8 @@ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
1354
1359
  gradients: 0,
1355
1360
  gradientFills: 0,
1356
1361
  compositeOps: [],
1362
+ gradientStops: [],
1363
+ fonts: [],
1357
1364
  fillStyles: [],
1358
1365
  clips: [],
1359
1366
  translates: [],
@@ -1364,6 +1371,7 @@ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
1364
1371
  let fillStyleBox: string | CanvasGradient | CanvasPattern = "";
1365
1372
  let gradientArmed = false;
1366
1373
  let compositeOpBox: GlobalCompositeOperation = "source-over";
1374
+ let fontBox = "";
1367
1375
  const stub: Record<string, unknown> = {
1368
1376
  canvas: this,
1369
1377
  clearRect: () => {},
@@ -1389,7 +1397,10 @@ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
1389
1397
  },
1390
1398
  createLinearGradient: () => {
1391
1399
  r.gradients++;
1392
- return { __recGradient: true, addColorStop: () => {} } as unknown as CanvasGradient;
1400
+ return {
1401
+ __recGradient: true,
1402
+ addColorStop: (_o: number, c: string) => r.gradientStops.push(String(c)),
1403
+ } as unknown as CanvasGradient;
1393
1404
  },
1394
1405
  createImageData: (w: number, h: number) => ({
1395
1406
  data: new Uint8ClampedArray(w * h * 4),
@@ -1406,7 +1417,13 @@ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
1406
1417
  compositeOpBox = v;
1407
1418
  r.compositeOps.push(String(v));
1408
1419
  },
1409
- font: "",
1420
+ get font() {
1421
+ return fontBox;
1422
+ },
1423
+ set font(v: string) {
1424
+ fontBox = v;
1425
+ r.fonts.push(String(v));
1426
+ },
1410
1427
  get fillStyle() {
1411
1428
  return fillStyleBox;
1412
1429
  },
@@ -1564,14 +1581,15 @@ describe("HkInput password reveal strategies", () => {
1564
1581
  });
1565
1582
 
1566
1583
  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).
1584
+ // The default reveal: STATIC BOLD glyph apertures filled with one
1585
+ // GRAYSCALE spatter texture, over a statistically matched spatter
1586
+ // field drifting the opposite way on the theme-anchored ground;
1587
+ // the glyphs shifted by a small pedestal toward the theme's
1588
+ // visibility direction with a halo band around the row. Glyph
1589
+ // geometry must NEVER reach the visible canvas (mask → source-in
1590
+ // stamp only) — that is the screenshot contract. Math.random is
1591
+ // pinned at 0.5 so the dot lightness equals the exact layer base
1592
+ // (deterministic pedestal comparison).
1575
1593
  const rand = vi.spyOn(Math, "random").mockReturnValue(0.5);
1576
1594
  const rec = stubRecordingContexts();
1577
1595
  try {
@@ -1604,14 +1622,25 @@ describe("HkInput password reveal strategies", () => {
1604
1622
  expect(vis.gradientFills, "halo band drawn").toBeGreaterThan(0);
1605
1623
  expect(vis.drawImages).toBeGreaterThan(0);
1606
1624
  // 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).
1625
+ // draw order), each with hundreds of solid-color dot fills.
1626
+ // happy-dom resolves no computed color, so the ink stays at the
1627
+ // pre-sync fallback (a DARK ink) — the effective theme here is
1628
+ // LIGHT: near-white ground, dark-gray speckle, and the pedestal
1629
+ // shifts the glyph layer DARKER (visibility direction).
1611
1630
  const tiles = allRecs.filter(
1612
1631
  (r) => r.fillStyles.filter((s) => s.startsWith("rgb(")).length > 100,
1613
1632
  );
1614
1633
  expect(tiles.length, "exactly two spatter tiles").toBe(2);
1634
+
1635
+ // Backing-store tripwire (R3 F1): a shrunken tile silently
1636
+ // drops ~75% of its dots and desyncs the pattern period from
1637
+ // wrapDrift's modulus (the noise painter's documented P1 class).
1638
+ expect(
1639
+ tiles.every(
1640
+ (t) => t.canvas.width === NOISE_TILE_W && t.canvas.height === NOISE_TILE_H,
1641
+ ),
1642
+ "spatter tiles sized to the noise tile backing store",
1643
+ ).toBe(true);
1615
1644
  const meanOf = (r: (typeof tiles)[number]) => {
1616
1645
  const samples = r.fillStyles.filter((s) => s.startsWith("rgb("));
1617
1646
  let sum = 0;
@@ -1624,9 +1653,38 @@ describe("HkInput password reveal strategies", () => {
1624
1653
  const bgMean = meanOf(tiles[0]!);
1625
1654
  const inkMean = meanOf(tiles[1]!);
1626
1655
  expect(
1627
- inkMean - bgMean,
1628
- `glyph layer carries the lightness pedestal (bg ${bgMean.toFixed(1)} vs ink ${inkMean.toFixed(1)})`,
1656
+ bgMean - inkMean,
1657
+ `light theme: glyph pedestal shifts DARKER (bg ${bgMean.toFixed(1)} vs ink ${inkMean.toFixed(1)})`,
1629
1658
  ).toBeGreaterThan(8);
1659
+ // Strict grayscale: no theme hue may survive into the spatter —
1660
+ // every solid fill is a NEUTRAL gray (r === g === b).
1661
+ for (const tile of tiles) {
1662
+ for (const fill of tile.fillStyles.filter((s) => s.startsWith("rgb("))) {
1663
+ const [r8, g8, b8] = fill.slice(4, -1).split(",").map(Number);
1664
+ expect(
1665
+ r8 === g8 && g8 === b8,
1666
+ `spatter must be grayscale, got ${fill}`,
1667
+ ).toBe(true);
1668
+ }
1669
+ }
1670
+ // Theme anchor: the LIGHT theme's ground is near-white (first
1671
+ // fill on the bg tile is the ground wash; L 93 → rgb ≈ 237).
1672
+ const [gr, gg, gb] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
1673
+ expect(gr!, "light-theme ground is near-white").toBeGreaterThan(230);
1674
+ expect(gr!).toBeLessThan(245);
1675
+ expect(gg!).toBe(gr);
1676
+ expect(gb!).toBe(gr);
1677
+ // Halo color follows the theme: BLACK over a light ground —
1678
+ // peak AND endpoints share the theme channel (R2 F1).
1679
+ expect(vis.gradientStops, "light theme paints a BLACK halo").toContain("rgba(0,0,0,0.1)");
1680
+ expect(vis.gradientStops).not.toContain("rgba(255,255,255,0.1)");
1681
+ expect(vis.gradientStops, "light halo endpoints fade on BLACK").toContain("rgba(0,0,0,0)");
1682
+ expect(vis.gradientStops).not.toContain("rgba(255,255,255,0)");
1683
+ // The glyph apertures rasterize BOLD.
1684
+ expect(
1685
+ mask!.fonts.some((f) => f.startsWith("bold ")),
1686
+ "glyph apertures rasterize bold",
1687
+ ).toBe(true);
1630
1688
  // The DOM input still never flips.
1631
1689
  expect(input.type).toBe("password");
1632
1690
  // Bus frames advance BOTH layer drifts (fresh pattern phases on
@@ -1661,6 +1719,226 @@ describe("HkInput password reveal strategies", () => {
1661
1719
  }
1662
1720
  });
1663
1721
 
1722
+ /**
1723
+ * Force the computed `.color` (the reveal's ink + theme proxy) while
1724
+ * forwarding every other computed-style query to the real engine.
1725
+ * Returns a restore function.
1726
+ */
1727
+ function forceInkColor(color: string): () => void {
1728
+ const original = window.getComputedStyle;
1729
+ window.getComputedStyle = ((el: Element, ...rest: unknown[]) => {
1730
+ const real = original.call(window, el, ...(rest as []));
1731
+ return new Proxy(real, {
1732
+ get(target, prop) {
1733
+ if (prop === "color") return color;
1734
+ const v = Reflect.get(target, prop, target);
1735
+ return typeof v === "function"
1736
+ ? (v as (...a: unknown[]) => unknown).bind(target)
1737
+ : v;
1738
+ },
1739
+ });
1740
+ }) as typeof window.getComputedStyle;
1741
+ return () => {
1742
+ window.getComputedStyle = original;
1743
+ };
1744
+ }
1745
+
1746
+ it("filter inverts its grayscale anchors for a dark theme (light field ink)", async () => {
1747
+ // Light field ink = a dark theme: near-black ground, light-gray
1748
+ // speckle, a BRIGHTER glyph pedestal and a WHITE halo — every
1749
+ // anchor flips with the theme while the mechanism stays identical.
1750
+ // The theme proxy is the computed field-ink lightness (>= 50 =
1751
+ // dark theme), so feed a light ink through getComputedStyle.
1752
+ const restoreInk = forceInkColor("rgb(148, 233, 211)");
1753
+ const rec = stubRecordingContexts();
1754
+ try {
1755
+ const { container } = mountPasswordInput("abc");
1756
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1757
+ eye.dispatchEvent(
1758
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1759
+ );
1760
+ await nextTick();
1761
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1762
+ const vis = rec.byCanvas.get(visible)!;
1763
+ const allRecs = Array.from(rec.byCanvas.values());
1764
+ const tiles = allRecs.filter(
1765
+ (r) => r.fillStyles.filter((s) => s.startsWith("rgb(")).length > 100,
1766
+ );
1767
+ expect(tiles.length).toBe(2);
1768
+ const meanOf = (r: (typeof tiles)[number]) => {
1769
+ const samples = r.fillStyles.filter((s) => s.startsWith("rgb("));
1770
+ let sum = 0;
1771
+ for (const s of samples) {
1772
+ const [r8, g8, b8] = s.slice(4, -1).split(",").map(Number);
1773
+ sum += (r8! + g8! + b8!) / 3;
1774
+ }
1775
+ return sum / samples.length;
1776
+ };
1777
+ const bgMean = meanOf(tiles[0]!);
1778
+ const inkMean = meanOf(tiles[1]!);
1779
+ expect(
1780
+ inkMean - bgMean,
1781
+ `dark theme: glyph pedestal shifts BRIGHTER (bg ${bgMean.toFixed(1)} vs ink ${inkMean.toFixed(1)})`,
1782
+ ).toBeGreaterThan(8);
1783
+ // Strict grayscale on the dark side too (R2 F4).
1784
+ for (const tile of tiles) {
1785
+ for (const fill of tile.fillStyles.filter((f) => f.startsWith("rgb("))) {
1786
+ const [r8, g8, b8] = fill.slice(4, -1).split(",").map(Number);
1787
+ expect(r8 === g8 && g8 === b8, `dark spatter must be grayscale, got ${fill}`).toBe(true);
1788
+ }
1789
+ }
1790
+ // Matched texture statistics (R2 F2): the two layers must carry
1791
+ // the SAME dot count and (pedestal aside) the same lightness
1792
+ // RANGE — a diverging spread/density is the single-frame
1793
+ // segmentation leak the docblock promises cannot happen.
1794
+ const dotStats = (r: (typeof tiles)[number]) => {
1795
+ const dots = r.fillStyles.slice(1).filter((f) => f.startsWith("rgb("));
1796
+ let min = 255, max = 0;
1797
+ for (const f of dots) {
1798
+ const v = Number(f.slice(4, -1).split(",")[0]);
1799
+ if (v < min) min = v;
1800
+ if (v > max) max = v;
1801
+ }
1802
+ return { count: dots.length, range: max - min };
1803
+ };
1804
+ const bgStats = dotStats(tiles[0]!);
1805
+ const inkStats = dotStats(tiles[1]!);
1806
+ expect(inkStats.count, "both layers carry the same dot density").toBe(bgStats.count);
1807
+ expect(
1808
+ Math.abs(inkStats.range - bgStats.range),
1809
+ `matched lightness spread (bg ${bgStats.range} vs ink ${inkStats.range})`,
1810
+ ).toBeLessThan(6);
1811
+ // The pedestal lifts ground AND dots together: the two grounds
1812
+ // sit exactly one pedestal apart (~10 L ≈ 25 rgb), so an ink
1813
+ // ground that loses or doubles its shift goes red (R2 C1b).
1814
+ const groundOf = (r: (typeof tiles)[number]) =>
1815
+ Number(r.fillStyles[0]!.slice(4, -1).split(",")[0]);
1816
+ const groundDelta = Math.abs(groundOf(tiles[1]!) - groundOf(tiles[0]!));
1817
+ expect(
1818
+ groundDelta >= 20 && groundDelta <= 30,
1819
+ `grounds one pedestal apart (delta ${groundDelta})`,
1820
+ ).toBe(true);
1821
+ // Near-black ground: L 10 → rgb ≈ 26.
1822
+ const [gr, gg, gb] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
1823
+ expect(gr!, "dark-theme ground is near-black").toBeGreaterThan(18);
1824
+ expect(gr!).toBeLessThan(34);
1825
+ expect(gg!).toBe(gr);
1826
+ expect(gb!).toBe(gr);
1827
+ // White halo over the dark ground — peak AND endpoints share
1828
+ // the theme channel (an endpoint desync must go red, R2 F1).
1829
+ expect(vis.gradientStops, "dark theme paints a WHITE halo").toContain("rgba(255,255,255,0.1)");
1830
+ expect(vis.gradientStops).not.toContain("rgba(0,0,0,0.1)");
1831
+ expect(vis.gradientStops, "dark halo endpoints fade on WHITE").toContain("rgba(255,255,255,0)");
1832
+ expect(vis.gradientStops).not.toContain("rgba(0,0,0,0)");
1833
+ // Same screenshot contract and bold apertures as the light theme.
1834
+ const mask = allRecs.find((r) => r.texts.length > 0)!;
1835
+ expect(mask.texts).toEqual(["a", "b", "c"]);
1836
+ expect(vis.texts).toEqual([]);
1837
+ expect(mask.fonts.some((f) => f.startsWith("bold "))).toBe(true);
1838
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1839
+ await nextTick();
1840
+ } finally {
1841
+ rec.restore();
1842
+ restoreInk();
1843
+ }
1844
+ });
1845
+
1846
+ it("filter keeps the documented light-anchor degrade for modern color-function inks", async () => {
1847
+ // oklch()/lab()/color() inks cannot be parsed into a triple — the
1848
+ // documented degrade keeps the fallback ink (a DARK ink → light
1849
+ // anchors), never throwing and never guessing a theme. Locked as
1850
+ // intentional (R1 finding F1).
1851
+ const restoreInk = forceInkColor("oklch(70% 0.1 200)");
1852
+ const rec = stubRecordingContexts();
1853
+ try {
1854
+ const { container } = mountPasswordInput("abc");
1855
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1856
+ eye.dispatchEvent(
1857
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1858
+ );
1859
+ await nextTick();
1860
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1861
+ const vis = rec.byCanvas.get(visible)!;
1862
+ const allRecs = Array.from(rec.byCanvas.values());
1863
+ const tiles = allRecs.filter(
1864
+ (r) => r.fillStyles.filter((s) => s.startsWith("rgb(")).length > 100,
1865
+ );
1866
+ expect(tiles.length).toBe(2);
1867
+ const [gr] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
1868
+ expect(gr!, "fallback ink → LIGHT anchors (near-white ground)").toBeGreaterThan(230);
1869
+ expect(vis.gradientStops, "black halo in the degrade").toContain("rgba(0,0,0,0.1)");
1870
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1871
+ await nextTick();
1872
+ } finally {
1873
+ rec.restore();
1874
+ restoreInk();
1875
+ }
1876
+ });
1877
+
1878
+ it("filter degrades the same way for color()-function inks", async () => {
1879
+ // Locks the DEGRADE SEMANTICS for color() inks: the modern-
1880
+ // function rejection yields null → the fallback dark ink → LIGHT
1881
+ // anchors. Honest limit (verified during R2 remedies): spec-valid
1882
+ // color() values are 0–1 floats, so even a DELETED guard parses
1883
+ // them into sub-1 triples (L < 1 → light) — guard deletion is not
1884
+ // observable through the filter theme. The guard's real consumer
1885
+ // is the NOISE painter's colored tiles (it prevents deriving a
1886
+ // garbage hue from e.g. [0.5,0.2,0.8]); this pin keeps any future
1887
+ // "auto-interpret color()" change (×255 scaling → L 50 → dark)
1888
+ // from silently re-theming the filter.
1889
+ const restoreInk = forceInkColor("color(display-p3 1 0 0)");
1890
+ const rec = stubRecordingContexts();
1891
+ try {
1892
+ const { container } = mountPasswordInput("abc");
1893
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1894
+ eye.dispatchEvent(
1895
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1896
+ );
1897
+ await nextTick();
1898
+ const allRecs = Array.from(rec.byCanvas.values());
1899
+ const tiles = allRecs.filter(
1900
+ (r) => r.fillStyles.filter((f) => f.startsWith("rgb(")).length > 100,
1901
+ );
1902
+ expect(tiles.length).toBe(2);
1903
+ const [gr] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
1904
+ expect(gr!, "color() ink degrades to the LIGHT anchors").toBeGreaterThan(230);
1905
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1906
+ await nextTick();
1907
+ } finally {
1908
+ rec.restore();
1909
+ restoreInk();
1910
+ }
1911
+ });
1912
+
1913
+ it("filter treats exactly-50 ink lightness as a dark theme (boundary)", async () => {
1914
+ // rgb(255,0,0): max+min halves to EXACTLY 0.5 (128/255 does not)
1915
+ // — L === 50 on the nose, so the >= side of the theme proxy wins
1916
+ // and the reveal anchors dark (R1 finding F2).
1917
+ const restoreInk = forceInkColor("rgb(255, 0, 0)");
1918
+ const rec = stubRecordingContexts();
1919
+ try {
1920
+ const { container } = mountPasswordInput("abc");
1921
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1922
+ eye.dispatchEvent(
1923
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1924
+ );
1925
+ await nextTick();
1926
+ const allRecs = Array.from(rec.byCanvas.values());
1927
+ const tiles = allRecs.filter(
1928
+ (r) => r.fillStyles.filter((s) => s.startsWith("rgb(")).length > 100,
1929
+ );
1930
+ expect(tiles.length).toBe(2);
1931
+ const [gr] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
1932
+ expect(gr!, "L === 50 anchors DARK (near-black ground)").toBeGreaterThan(18);
1933
+ expect(gr!).toBeLessThan(34);
1934
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1935
+ await nextTick();
1936
+ } finally {
1937
+ rec.restore();
1938
+ restoreInk();
1939
+ }
1940
+ });
1941
+
1664
1942
  it("filter regenerates both spatter tiles on every hold", async () => {
1665
1943
  // Fresh noise per hold is an anti-replay contract: two holds of the
1666
1944
  // same password must never replay the same frame sequence. Pin: the
@@ -77,13 +77,15 @@ export default defineComponent({
77
77
  /**
78
78
  * Reveal content for the password eye (`variant="password"`,
79
79
  * `passwordTrailing="eye"`):
80
- * - "filter" (default): dual counter-drifting spatter layers —
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.
80
+ * - "filter" (default): dual counter-drifting spatter layers in
81
+ * strict black/white/gray — static BOLD glyph apertures filled
82
+ * with one gray spatter texture over a statistically matched
83
+ * field drifting the opposite way on the theme's ground
84
+ * (near-black in a dark theme, near-white in a light one),
85
+ * plus a small visibility-direction lightness pedestal and a
86
+ * halo band around the row. The whole row stays readable in
87
+ * motion; a single-frame screenshot carries no glyph structure,
88
+ * only a weak luminance signal dissolved into the halo.
87
89
  * - "sweep": a readable window — ordinary high-contrast text inside
88
90
  * a narrow band sweeping across the row over the boiling-noise
89
91
  * field. Easy to read; a single screenshot leaks the characters
@@ -53,15 +53,17 @@ interface Ripple {
53
53
  * - "eye" (default): the reveal button. What the reveal SHOWS is chosen
54
54
  * by `revealStrategy`, how it is TRIGGERED by `revealTrigger`:
55
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.
56
+ * layers in strict BLACK/WHITE/GRAY — the glyph row is a set of
57
+ * STATIC bold apertures filled with a gray spatter texture
58
+ * drifting one way, over a statistically identical spatter field
59
+ * drifting the other way on the theme's ground (near-black in a
60
+ * dark theme, near-white in a light one), the glyphs shifted by a
61
+ * small lightness pedestal toward visibility with a soft halo
62
+ * band around the row (see revealKinematogram.ts). The whole row
63
+ * stays readable in motion while any single frame — a screenshot
64
+ * — carries no glyph structure, only a weak mean-luminance signal
65
+ * dissolved into the halo ramp. Reduced motion or a pattern-less
66
+ * engine degrades to the fully readable static plain text.
65
67
  * - strategy "sweep": a readable window — the password is drawn as
66
68
  * ordinary high-contrast text inside a narrow band that sweeps
67
69
  * across the row over the boiling-noise field. Reading is
@@ -118,11 +120,13 @@ export default defineComponent({
118
120
  },
119
121
  /**
120
122
  * What the eye reveal SHOWS:
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.
123
+ * - "filter" (default): dual counter-drifting spatter layers in
124
+ * strict black/white/gray — static BOLD glyph apertures over an
125
+ * oppositely drifting, statistically matched gray spatter field
126
+ * on the theme's ground (near-black dark theme / near-white
127
+ * light theme), plus a small visibility-direction lightness
128
+ * pedestal and halo. Readable in motion; a single screenshot
129
+ * carries no glyph structure, only a weak luminance signal.
126
130
  * - "sweep": a readable window — ordinary high-contrast text inside
127
131
  * a narrow band sweeping across the row. Easy to read; a single
128
132
  * screenshot leaks the band's characters in the clear.
@@ -313,7 +317,16 @@ export default defineComponent({
313
317
  // previous base instead (latent for consumer themes authored in
314
318
  // those functions; hikari's own themes use rgb triplets/hex).
315
319
  if (/^(oklch|oklab|lab|lch|color)\(/i.test(raw.trim())) return null;
316
- const ns = raw.split(/[\s,()rgba]+/).map(Number).filter((n) => !isNaN(n));
320
+ // Leading separators are REAL here: a computed `rgb(r, g, b)`
321
+ // string starts with "rgb(" and the split would yield a leading
322
+ // EMPTY token — Number("") === 0, which silently shifts the
323
+ // triple to [0, r, g] and skews every derived HSL channel (the
324
+ // filter theme proxy read a light ink as 45.7% lightness).
325
+ const ns = raw
326
+ .split(/[\s,()rgba]+/)
327
+ .filter((t) => t !== "")
328
+ .map(Number)
329
+ .filter((n) => !isNaN(n));
317
330
  return ns.length >= 3 ? [ns[0], ns[1], ns[2]] : null;
318
331
  }
319
332
 
@@ -789,7 +802,12 @@ export default defineComponent({
789
802
  sweepT = 0;
790
803
  if (!parked) {
791
804
  if (props.revealStrategy === "filter") {
792
- revealFilter.beginHold(textHsl, dpr);
805
+ // Grayscale spatter anchored to the effective theme: dark
806
+ // themes carry light field ink, so the ink lightness is
807
+ // the theme proxy — light ink (>= 50) means a dark theme
808
+ // (near-black ground), dark ink a light theme (near-white
809
+ // ground). No theme hue survives into the spatter.
810
+ revealFilter.beginHold(textHsl[2] >= 50 ? "dark" : "light", dpr);
793
811
  } else {
794
812
  revealNoise.beginHold(textHsl);
795
813
  }
@@ -248,3 +248,156 @@ describe("HkScrollContainer approachEnd", () => {
248
248
  expect(emissions.length).toBe(2);
249
249
  });
250
250
  });
251
+
252
+ // ── Back-to-top signal (scroll parameter every template can hand to a
253
+ // floating quick-action pad) — sensed on the container's own throttled
254
+ // pass; HkWaterfall forwards these thresholds and mirrors the emit. ──
255
+ describe("HkScrollContainer back-to-top signal", () => {
256
+ async function flushFrames(): Promise<void> {
257
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
258
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
259
+ }
260
+
261
+ it("emits update:backTopVisible with the SHOW/HIDE hysteresis band", async () => {
262
+ const container = document.createElement("div");
263
+ document.body.appendChild(container);
264
+ containers.push(container);
265
+
266
+ const emitted: boolean[] = [];
267
+ const instance = ref<{ backTopVisible?: boolean } | null>(null);
268
+ const Wrapper = defineComponent({
269
+ setup() {
270
+ return () =>
271
+ h(HkScrollContainer, {
272
+ ref: instance as never,
273
+ backTopShow: 360,
274
+ backTopHide: 240,
275
+ "onUpdate:backTopVisible": (v: boolean) => emitted.push(v),
276
+ }, { default: () => h("span", "content") });
277
+ },
278
+ });
279
+ const app = createApp(Wrapper);
280
+ mounts.push(app);
281
+ app.mount(container);
282
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport");
283
+ if (!viewport) throw new Error("no viewport");
284
+
285
+ const scrollTo = async (top: number) => {
286
+ stubGeometry(viewport, { scrollTop: top, scrollHeight: 5000, clientHeight: 400 });
287
+ viewport.dispatchEvent(new Event("scroll"));
288
+ await flushFrames();
289
+ };
290
+
291
+ await scrollTo(0);
292
+ expect(emitted).toEqual([]); // below: never emits
293
+ await scrollTo(300);
294
+ expect(emitted).toEqual([]); // inside the band, was hidden: stays hidden
295
+ await scrollTo(400);
296
+ expect(emitted).toEqual([true]); // past SHOW: emits true
297
+ emitted.length = 0;
298
+ await scrollTo(300);
299
+ expect(emitted).toEqual([]); // inside the band, was visible: stays visible
300
+ await scrollTo(200);
301
+ expect(emitted).toEqual([false]); // below HIDE: emits false
302
+ app.unmount();
303
+ });
304
+
305
+ it("exposes the sensed state as backTopVisible", async () => {
306
+ const container = document.createElement("div");
307
+ document.body.appendChild(container);
308
+ containers.push(container);
309
+ const instance = ref<{ backTopVisible?: boolean } | null>(null);
310
+ const Wrapper = defineComponent({
311
+ setup() {
312
+ return () =>
313
+ h(HkScrollContainer, {
314
+ ref: instance as never,
315
+ backTopShow: 100,
316
+ }, { default: () => h("span", "content") });
317
+ },
318
+ });
319
+ const app = createApp(Wrapper);
320
+ mounts.push(app);
321
+ app.mount(container);
322
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport");
323
+ if (!viewport) throw new Error("no viewport");
324
+
325
+ stubGeometry(viewport, { scrollTop: 500, scrollHeight: 5000, clientHeight: 400 });
326
+ viewport.dispatchEvent(new Event("scroll"));
327
+ await flushFrames();
328
+ expect(instance.value?.backTopVisible).toBe(true);
329
+
330
+ // Show-only mode hides at the SAME threshold it shows at: 50 < 100
331
+ // must read false (the hysteresis fallback `top > show` is what
332
+ // pins this — a mutant `top > 0` would keep it true here).
333
+ stubGeometry(viewport, { scrollTop: 50, scrollHeight: 5000, clientHeight: 400 });
334
+ viewport.dispatchEvent(new Event("scroll"));
335
+ await flushFrames();
336
+ expect(instance.value?.backTopVisible).toBe(false);
337
+
338
+ stubGeometry(viewport, { scrollTop: 0, scrollHeight: 5000, clientHeight: 400 });
339
+ viewport.dispatchEvent(new Event("scroll"));
340
+ await flushFrames();
341
+ expect(instance.value?.backTopVisible).toBe(false);
342
+ app.unmount();
343
+ });
344
+
345
+ it("re-senses immediately when the thresholds change at runtime", async () => {
346
+ // Live-read contract (same as approachDistance): toggling backTopShow
347
+ // after mount must emit without waiting for the next scroll event —
348
+ // a consumer that swaps thresholds while idle gets a truthful signal.
349
+ const container = document.createElement("div");
350
+ document.body.appendChild(container);
351
+ containers.push(container);
352
+ const emitted: boolean[] = [];
353
+ const props = reactive({ backTopShow: undefined as number | undefined });
354
+ const Wrapper = defineComponent({
355
+ setup() {
356
+ return () =>
357
+ h(HkScrollContainer, {
358
+ backTopShow: props.backTopShow,
359
+ "onUpdate:backTopVisible": (v: boolean) => emitted.push(v),
360
+ }, { default: () => h("span", "content") });
361
+ },
362
+ });
363
+ const app = createApp(Wrapper);
364
+ mounts.push(app);
365
+ app.mount(container);
366
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport");
367
+ if (!viewport) throw new Error("no viewport");
368
+ stubGeometry(viewport, { scrollTop: 500, scrollHeight: 5000, clientHeight: 400 });
369
+ await flushFrames();
370
+ expect(emitted).toEqual([]); // inert while unset, geometry already past
371
+
372
+ props.backTopShow = 100;
373
+ await nextTick();
374
+ await flushFrames();
375
+ expect(emitted).toEqual([true]); // re-sensed without any scroll event
376
+ app.unmount();
377
+ });
378
+
379
+ it("never emits while backTopShow is unset", async () => {
380
+ const container = document.createElement("div");
381
+ document.body.appendChild(container);
382
+ containers.push(container);
383
+ const emitted: boolean[] = [];
384
+ const Wrapper = defineComponent({
385
+ setup() {
386
+ return () =>
387
+ h(HkScrollContainer, {
388
+ "onUpdate:backTopVisible": (v: boolean) => emitted.push(v),
389
+ }, { default: () => h("span", "content") });
390
+ },
391
+ });
392
+ const app = createApp(Wrapper);
393
+ mounts.push(app);
394
+ app.mount(container);
395
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport");
396
+ if (!viewport) throw new Error("no viewport");
397
+ stubGeometry(viewport, { scrollTop: 5000, scrollHeight: 5000, clientHeight: 400 });
398
+ viewport.dispatchEvent(new Event("scroll"));
399
+ await flushFrames();
400
+ expect(emitted).toEqual([]);
401
+ app.unmount();
402
+ });
403
+ });
@@ -85,8 +85,23 @@ export default defineComponent({
85
85
  * scroll content dissolving at the dock's edge reads as
86
86
  * intentional instead of a hard clip. */
87
87
  dockFade: { type: Boolean, default: true },
88
+ /** Back-to-top signal (scroll parameter every scrollable template
89
+ * can hand to a floating quick-action pad): while set, the
90
+ * container derives a visibility signal from its own throttled
91
+ * scroll pass — visible once scrollTop passes SHOW px, hidden
92
+ * again below HIDE px. The HIDE < SHOW hysteresis band stops the
93
+ * signal from flickering when the user idles at one threshold.
94
+ * Emits `update:backTopVisible` on change; inert (never emitted)
95
+ * while unset. */
96
+ backTopShow: { type: Number, default: undefined },
97
+ /** Hide threshold of the hysteresis; only read when `backTopShow`
98
+ * is also set. Without it the signal falls back to the single
99
+ * `top > backTopShow` test. A misconfigured `backTopHide >=
100
+ * backTopShow` degrades the same way (the show test wins first),
101
+ * so the band only widens, never inverts. */
102
+ backTopHide: { type: Number, default: undefined },
88
103
  },
89
- emits: { approachEnd: () => true },
104
+ emits: { approachEnd: () => true, "update:backTopVisible": (_v: boolean) => true },
90
105
  setup(props, { slots, expose, emit }) {
91
106
  const { t } = useI18n();
92
107
  const viewportRef = ref<HTMLElement>();
@@ -112,6 +127,12 @@ export default defineComponent({
112
127
 
113
128
  const pinned = ref(true);
114
129
  const FOLLOW_THRESHOLD = 24;
130
+
131
+ // Back-to-top visibility (see the backTopShow prop doc). Starts
132
+ // false and only ever changes through the sensed hysteresis below —
133
+ // the same semantics HkWaterfall shipped before the signal moved
134
+ // down to the shared scroll host.
135
+ const backTopVisible = ref(false);
115
136
  const autoFollowContent = shallowRef<HTMLElement | null>(null);
116
137
  let followRO: ResizeObserver | null = null;
117
138
  const showAutoTag = computed(() => props.autoFollow && props.scrollbar && pinned.value);
@@ -186,6 +207,13 @@ export default defineComponent({
186
207
  scheduleUpdate();
187
208
  }, { flush: "post" });
188
209
 
210
+ // Runtime threshold changes re-sense immediately (same live-read
211
+ // contract as approachDistance): without this, a backTopShow toggle
212
+ // after mount would emit nothing until the next scroll/resize.
213
+ watch(() => [props.backTopShow, props.backTopHide] as const, () => {
214
+ scheduleUpdate();
215
+ });
216
+
189
217
  /** Build the overlay tracks for the enabled axes (scrollbar on). */
190
218
  function mountScrollbars() {
191
219
  const vp = viewportRef.value;
@@ -261,6 +289,30 @@ export default defineComponent({
261
289
  const vp = viewportRef.value;
262
290
  if (!vp) return;
263
291
  senseOverflow(vp);
292
+ senseBackTop(vp);
293
+ }
294
+
295
+ /** Back-to-top hysteresis, derived on the same throttled pass as the
296
+ * overflow sensing (scroll / resize / content resize / refresh) so
297
+ * every scrollable template gets the signal for free — no consumer
298
+ * installs a second scroll listener. */
299
+ function senseBackTop(vp: HTMLElement) {
300
+ if (props.backTopShow === undefined) return;
301
+ const top = vp.scrollTop;
302
+ const show = props.backTopShow;
303
+ const hide = props.backTopHide;
304
+ const next =
305
+ hide !== undefined
306
+ ? top > show
307
+ ? true
308
+ : top < hide
309
+ ? false
310
+ : backTopVisible.value
311
+ : top > show;
312
+ if (next !== backTopVisible.value) {
313
+ backTopVisible.value = next;
314
+ emit("update:backTopVisible", next);
315
+ }
264
316
  }
265
317
 
266
318
  /** Mirror the live scroll geometry onto the root element as
@@ -455,7 +507,7 @@ export default defineComponent({
455
507
  approachHandle.value?.recheck();
456
508
  }
457
509
 
458
- expose({ scrollTo, scrollToElement, getScrollElement, getScrollTop, refresh, getOverflow, isNearEnd, recheck });
510
+ expose({ scrollTo, scrollToElement, getScrollElement, getScrollTop, refresh, getOverflow, isNearEnd, recheck, backTopVisible });
459
511
 
460
512
  return () => {
461
513
  const Tag = props.as as "div" | "section" | "nav" | "main" | "aside";
@@ -252,25 +252,11 @@ export default defineComponent({
252
252
  function recomputeActive() {
253
253
  scrollRaf = null;
254
254
  if (!scrollEl) return;
255
- const top = scrollEl.scrollTop;
256
-
257
- if (props.backTopShow !== undefined) {
258
- const show = props.backTopShow;
259
- const hide = props.backTopHide;
260
- const next =
261
- hide !== undefined
262
- ? top > show
263
- ? true
264
- : top < hide
265
- ? false
266
- : backTopVisible.value
267
- : top > show;
268
- if (next !== backTopVisible.value) {
269
- backTopVisible.value = next;
270
- emit("update:backTopVisible", next);
271
- }
272
- }
273
-
255
+ // Back-to-top visibility is no longer derived here: the shared
256
+ // scroll host (HkScrollContainer) senses it on its own throttled
257
+ // pass and this view mirrors the host's signal (see the render
258
+ // wiring below) — one scroll pass, one source of truth, and every
259
+ // other scrollable template gets the same signal for free.
274
260
  const viewportTop = scrollEl.getBoundingClientRect().top;
275
261
  const attr = sectionAttribute.value;
276
262
  const sections = scrollEl.querySelectorAll<HTMLElement>(`[${attr}]`);
@@ -409,6 +395,18 @@ export default defineComponent({
409
395
  class="hk-waterfall-scroll"
410
396
  mode="windowed"
411
397
  overscanScreens={props.overscanScreens}
398
+ // Back-to-top sensing lives on the scroll host now; this view
399
+ // forwards the thresholds and mirrors the emitted signal so its
400
+ // own public contract (update:backTopVisible + exposed
401
+ // backTopVisible) is unchanged for consumers.
402
+ backTopShow={props.backTopShow}
403
+ backTopHide={props.backTopHide}
404
+ onUpdate:backTopVisible={(v: boolean) => {
405
+ if (v !== backTopVisible.value) {
406
+ backTopVisible.value = v;
407
+ emit("update:backTopVisible", v);
408
+ }
409
+ }}
412
410
  >
413
411
  {{
414
412
  default: () =>
@@ -139,7 +139,7 @@ describe("RevealFilterPainter", () => {
139
139
  const p = new RevealFilterPainter();
140
140
  const rand = vi.spyOn(Math, "random").mockReturnValue(0.25);
141
141
  try {
142
- p.beginHold([220, 10, 15]);
142
+ p.beginHold("dark");
143
143
  // Drift-sign draw, then one phase draw per layer before retile;
144
144
  // happy-dom has no 2d context, so the tile builds bail before
145
145
  // consuming any more randomness.
@@ -158,7 +158,7 @@ describe("RevealFilterPainter", () => {
158
158
  const p = new RevealFilterPainter();
159
159
  const rand = vi.spyOn(Math, "random").mockReturnValue(0.25); // driftSign = +1
160
160
  try {
161
- p.beginHold([220, 10, 15]);
161
+ p.beginHold("dark");
162
162
  const before = p.peekDrift();
163
163
  p.advance(0.25, 2);
164
164
  const after = p.peekDrift();
@@ -174,7 +174,7 @@ describe("RevealFilterPainter", () => {
174
174
  const p = new RevealFilterPainter();
175
175
  const rand = vi.spyOn(Math, "random").mockReturnValue(0.75); // driftSign = -1
176
176
  try {
177
- p.beginHold([220, 10, 15]);
177
+ p.beginHold("dark");
178
178
  const before = p.peekDrift();
179
179
  p.advance(0.25, 2);
180
180
  const after = p.peekDrift();
@@ -188,7 +188,7 @@ describe("RevealFilterPainter", () => {
188
188
 
189
189
  it("never throws without a 2d context and stays unusable (fallback path)", () => {
190
190
  const p = new RevealFilterPainter();
191
- expect(() => p.beginHold([220, 10, 15])).not.toThrow();
191
+ expect(() => p.beginHold("dark")).not.toThrow();
192
192
  expect(p.available).toBe(false);
193
193
  });
194
194
  });
@@ -395,16 +395,25 @@ export class RevealNoisePainter {
395
395
  * area carries ONE spatter texture drifting in one direction; the
396
396
  * password glyphs are STATIC apertures carrying a SECOND spatter
397
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.
398
+ * lightness distribution) but drifting the OPPOSITE way and shifted by
399
+ * a small lightness pedestal toward the theme's visibility direction,
400
+ * with a soft halo band around the glyph row. A human segments the two
401
+ * layers effortlessly (motion transparency at a 180° direction
402
+ * difference is the strongest segregation cue the visual system has)
403
+ * and reads the row aided by the pedestal + halo, while any SINGLE
404
+ * frame carries no motion at all: the glyph boundary survives only as
405
+ * a small mean-luminance step inside a smooth halo ramp — nothing for
406
+ * a global or adaptive threshold to plateau on, and (matched
407
+ * statistics) nothing for a texture classifier either.
408
+ *
409
+ * The palette is strictly GRAYSCALE — black, white and grays only, no
410
+ * theme hue survives into the spatter (the ink-colored variant read as
411
+ * an uncomfortable cyan). The two anchors follow the EFFECTIVE theme:
412
+ * a dark theme (light field ink) gets a near-black ground with light
413
+ * gray speckle, a white halo and a BRIGHTER glyph pedestal; a light
414
+ * theme (dark ink) inverts every one of those — near-white ground,
415
+ * dark gray speckle, black halo, DARKER glyph pedestal. Visibility,
416
+ * not absolute brightness, is the invariant.
408
417
  *
409
418
  * Why these parameters (the failure modes of video CAPTCHAs say what
410
419
  * to avoid — NuCAPTCHA & animated-GIF schemes died to per-frame OCR +
@@ -415,6 +424,10 @@ export class RevealNoisePainter {
415
424
  * and motion signal both peak near the letters' diagnostic spatial
416
425
  * band, so MODERATE noise contrast suffices (the old design's fine
417
426
  * grain + fast ±40px/s drift is exactly what made it unreadable).
427
+ * - The glyph apertures rasterize in BOLD: a heavier stroke exposes
428
+ * more of the counter-drifting texture inside each glyph (stronger
429
+ * signal under interference); the shared layout's 1.2× letter
430
+ * spacing absorbs the wider advances.
418
431
  * - Drift ±FILTER_DRIFT_PX_S: slow enough to track coherently at field
419
432
  * sizes, fast enough that a single frame carries no usable motion
420
433
  * energy (form-from-motion needs ~100–200ms of integration).
@@ -436,20 +449,29 @@ export class RevealNoisePainter {
436
449
  *
437
450
  * Invariants the tests pin: glyph geometry NEVER touches the visible
438
451
  * 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). */
452
+ * painter); every spatter fill is a NEUTRAL gray (r === g === b); the
453
+ * ground anchor follows the theme (near-black vs near-white); the two
454
+ * tiles differ in mean luminance along the theme's pedestal direction;
455
+ * the mask rasterizes in bold; the halo gradient is drawn every frame
456
+ * in the theme's halo color; a pattern-less or gradient-less engine
457
+ * degrades to the static plain text (filter exists FOR readability —
458
+ * never to frozen noise). */
459
+
460
+ /** Effective theme the filter anchors its grayscale palette to. */
461
+ export type RevealFilterTheme = "dark" | "light";
443
462
 
444
463
  /** Counter-drift speed of each layer in CSS px/s (opposite signs). */
445
464
  export const FILTER_DRIFT_PX_S = 84;
446
465
 
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. */
466
+ /** Lightness pedestal of the glyph layer, in HSL lightness points
467
+ * (clamped into [L_MIN, L_MAX] like every sample). The DIRECTION
468
+ * follows the theme: brighter glyphs over a dark theme's ground,
469
+ * darker glyphs over a light theme's. Small on purpose — see the
470
+ * strategy docblock. */
450
471
  export const FILTER_PEDESTAL_L = 10;
451
472
 
452
- /** Peak alpha of the halo band (white, at the glyph-row midline). */
473
+ /** Peak alpha of the halo band (theme-colored, at the glyph midline):
474
+ * white over a dark ground, black over a light ground. */
453
475
  export const FILTER_HALO_ALPHA = 0.1;
454
476
 
455
477
  /** Halo half-height as a multiple of the glyph font size (device px):
@@ -472,10 +494,17 @@ const FILTER_SPATTER_PX_PER_DOT = 45;
472
494
  /** Dot lightness spread around the layer base (both layers share it). */
473
495
  const FILTER_SPATTER_L_SPREAD = 22;
474
496
 
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;
497
+ /** Dark theme anchors: near-black ground, mid-light gray speckle. */
498
+ const FILTER_DARK_BASE_L = 64;
499
+ const FILTER_DARK_GROUND_L = 10;
500
+
501
+ /** Light theme anchors: near-white ground, mid-dark gray speckle. */
502
+ const FILTER_LIGHT_BASE_L = 38;
503
+ const FILTER_LIGHT_GROUND_L = 93;
504
+
505
+ /** Glyph aperture weight: bold strokes expose more of the counter-
506
+ * drifting texture inside each glyph. */
507
+ const FILTER_APERTURE_WEIGHT = "bold";
479
508
 
480
509
  export class RevealFilterPainter {
481
510
  private bgTile: HTMLCanvasElement | null = null;
@@ -485,6 +514,7 @@ export class RevealFilterPainter {
485
514
  private offsetBackground = 0;
486
515
  private offsetInk = 0;
487
516
  private driftSign = 1;
517
+ private theme: RevealFilterTheme = "dark";
488
518
  private dpr = 1;
489
519
  private ok = true;
490
520
 
@@ -500,17 +530,20 @@ export class RevealFilterPainter {
500
530
  return { background: this.offsetBackground, ink: this.offsetInk };
501
531
  }
502
532
 
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
533
+ /** Start a hold: two FRESH grayscale spatter tiles anchored to the
534
+ * theme (near-black/near-white ground), the glyph layer shifted by
535
+ * the pedestal along the theme's visibility direction, randomized
536
+ * phases and a randomized drift direction, so replays are never
506
537
  * pixel-identical and automation cannot precompute the motion. */
507
- beginHold(base: Hsl, dpr = 1): void {
538
+ beginHold(theme: RevealFilterTheme = "dark", dpr = 1): void {
508
539
  this.dpr = dpr;
540
+ this.theme = theme;
509
541
  this.driftSign = Math.random() < 0.5 ? 1 : -1;
510
542
  this.offsetBackground = Math.random() * NOISE_TILE_W;
511
543
  this.offsetInk = Math.random() * NOISE_TILE_W;
512
544
  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");
545
+ const pedestal = (theme === "dark" ? 1 : -1) * FILTER_PEDESTAL_L;
546
+ this.ok = this.retile(0, "bg") && this.retile(pedestal, "ink");
514
547
  }
515
548
 
516
549
  advance(dt: number, dpr: number): void {
@@ -519,11 +552,12 @@ export class RevealFilterPainter {
519
552
  this.offsetInk -= this.driftSign * step;
520
553
  }
521
554
 
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.
555
+ /** (Re)build one spatter tile: the theme's ground (near-black over a
556
+ * dark theme, near-white over a light one), then a fixed-density
557
+ * scatter of NEUTRAL-GRAY dots around the theme's base lightness.
524
558
  * Draw order is deterministic (bg tile first, then ink) so tests can
525
559
  * attribute the per-canvas recordings. */
526
- private retile(base: Hsl, pedestalL: number, which: "bg" | "ink"): boolean {
560
+ private retile(pedestalL: number, which: "bg" | "ink"): boolean {
527
561
  try {
528
562
  if (typeof document === "undefined") return false;
529
563
  if (which === "bg") {
@@ -536,17 +570,20 @@ export class RevealFilterPainter {
536
570
  tile.height = NOISE_TILE_H;
537
571
  const tctx = tile.getContext("2d");
538
572
  if (!tctx) return false;
539
- const [h, s, l] = base;
573
+ const dark = this.theme === "dark";
574
+ const baseL = dark ? FILTER_DARK_BASE_L : FILTER_LIGHT_BASE_L;
575
+ const groundL = dark ? FILTER_DARK_GROUND_L : FILTER_LIGHT_GROUND_L;
540
576
  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));
577
+ // Saturation is ZERO by construction — black/white/gray only.
578
+ const [gr, gg, gb] = hslToRgb(0, 0, lift(groundL + pedestalL));
542
579
  tctx.fillStyle = `rgb(${gr},${gg},${gb})`;
543
580
  tctx.fillRect(0, 0, NOISE_TILE_W, NOISE_TILE_H);
544
581
  const count = Math.round((NOISE_TILE_W * NOISE_TILE_H) / FILTER_SPATTER_PX_PER_DOT);
545
582
  const rMin = FILTER_SPATTER_R_MIN_CSS * this.dpr;
546
583
  const rMax = FILTER_SPATTER_R_MAX_CSS * this.dpr;
547
584
  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);
585
+ const dl = lift(baseL + pedestalL + (Math.random() * 2 - 1) * FILTER_SPATTER_L_SPREAD);
586
+ const [r, g, b] = hslToRgb(0, 0, dl);
550
587
  tctx.fillStyle = `rgb(${r},${g},${b})`;
551
588
  const rad = rMin + Math.random() * (rMax - rMin);
552
589
  tctx.beginPath();
@@ -588,14 +625,19 @@ export class RevealFilterPainter {
588
625
  }
589
626
  // Create the halo gradient up front as well: creation draws
590
627
  // nothing, so a bail here still leaves the visible canvas
591
- // untouched (no partial frame — see the pre-check above).
628
+ // untouched (no partial frame — see the pre-check above). The
629
+ // halo color follows the theme: white over a dark ground, black
630
+ // over a light one.
592
631
  const bandH = Math.min(H / 2, FILTER_HALO_FONT_SCALE * layout.fontPx);
593
632
  const midY = H / 2;
633
+ // Endpoints derive from the SAME theme channel string as the
634
+ // peak — no literal compare that could silently desync them.
635
+ const haloRGB = this.theme === "dark" ? "255,255,255" : "0,0,0";
594
636
  const grad = ctx.createLinearGradient(0, midY - bandH, 0, midY + bandH);
595
637
  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)");
638
+ grad.addColorStop(0, `rgba(${haloRGB},0)`);
639
+ grad.addColorStop(0.5, `rgba(${haloRGB},${FILTER_HALO_ALPHA})`);
640
+ grad.addColorStop(1, `rgba(${haloRGB},0)`);
599
641
  this.mask ??= document.createElement("canvas");
600
642
  if (this.mask.width !== W || this.mask.height !== H) {
601
643
  this.mask.width = W;
@@ -611,7 +653,7 @@ export class RevealFilterPainter {
611
653
  mctx.fillStyle = "#fff";
612
654
  mctx.textAlign = "left";
613
655
  mctx.textBaseline = "middle";
614
- mctx.font = `${layout.fontPx.toFixed(2)}px ${monoFont}`;
656
+ mctx.font = `${FILTER_APERTURE_WEIGHT} ${layout.fontPx.toFixed(2)}px ${monoFont}`;
615
657
  for (const g of layout.glyphs) {
616
658
  mctx.fillText(g.ch, g.x, H / 2);
617
659
  }