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