@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 +1 -1
- package/src/components/HkInput.password.test.tsx +294 -16
- package/src/components/HkInput.tsx +9 -7
- package/src/components/HkPasswordSurface.tsx +34 -16
- package/src/components/HkScrollContainer.test.ts +153 -0
- package/src/components/HkScrollContainer.tsx +54 -2
- package/src/components/HkWaterfall.tsx +17 -19
- package/src/components/revealKinematogram.test.ts +4 -4
- package/src/components/revealKinematogram.ts +81 -39
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
|
1569
|
-
// drifting the opposite way
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
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.
|
|
1608
|
-
//
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
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
|
-
|
|
1628
|
-
`
|
|
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
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
399
|
-
* a small lightness pedestal
|
|
400
|
-
*
|
|
401
|
-
* effortlessly (motion transparency at a 180° direction
|
|
402
|
-
* the strongest segregation cue the visual system has)
|
|
403
|
-
* row aided by the pedestal + halo, while any SINGLE
|
|
404
|
-
* motion at all: the glyph boundary survives only as
|
|
405
|
-
* luminance step inside a smooth halo ramp — nothing for
|
|
406
|
-
* adaptive threshold to plateau on, and (matched
|
|
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);
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
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
|
|
448
|
-
*
|
|
449
|
-
*
|
|
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 (
|
|
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
|
-
/**
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
|
504
|
-
*
|
|
505
|
-
*
|
|
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(
|
|
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
|
-
|
|
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:
|
|
523
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
549
|
-
const [r, g, b] = hslToRgb(
|
|
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,
|
|
597
|
-
grad.addColorStop(0.5, `rgba(
|
|
598
|
-
grad.addColorStop(1,
|
|
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
|
}
|