@celestia-island/hikari 0.55.17 → 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 +3 -1
- package/src/components/HkInput.password.test.tsx +465 -19
- package/src/components/HkInput.tsx +21 -8
- package/src/components/HkPasswordSurface.scss +4 -3
- package/src/components/HkPasswordSurface.tsx +168 -24
- package/src/components/revealKinematogram.test.ts +102 -0
- package/src/components/revealKinematogram.ts +307 -3
package/package.json
CHANGED
package/src/DemoApp.tsx
CHANGED
|
@@ -366,7 +366,9 @@ 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)" />
|
|
371
|
+
<HInput variant="password" revealStrategy="noise" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (noise reveal — screenshot-safe)" />
|
|
370
372
|
<HInput variant="password" revealStrategy="plain" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (plain reveal)" />
|
|
371
373
|
<HInput variant="password" revealTrigger="toggle" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (click eye toggles)" />
|
|
372
374
|
<HInput variant="password" passwordTrailing="strength" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (strength light)" />
|
|
@@ -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,8 +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
|
-
* (
|
|
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
|
|
19
21
|
* - reveal trigger (revealTrigger): press-and-hold (default) vs
|
|
20
22
|
* click-to-toggle with auto-hide (revealAutoHideMs)
|
|
21
23
|
* - the strength dot classifies through the shared passwordLevel util
|
|
@@ -651,6 +653,7 @@ describe("HkInput password reveal eye", () => {
|
|
|
651
653
|
clearRect: () => {},
|
|
652
654
|
save: () => {}, restore: () => {}, translate: () => {}, rotate: () => {},
|
|
653
655
|
beginPath: () => {}, arc: () => {}, fill: () => {},
|
|
656
|
+
rect: () => {}, clip: () => {},
|
|
654
657
|
measureText: () => ({ width: 10 }),
|
|
655
658
|
fillText: () => {},
|
|
656
659
|
fillRect: () => {},
|
|
@@ -766,7 +769,9 @@ describe("HkInput password reveal eye", () => {
|
|
|
766
769
|
} as unknown as CanvasRenderingContext2D;
|
|
767
770
|
}) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
|
768
771
|
try {
|
|
769
|
-
const { container, input } = mountPasswordInput("abc"
|
|
772
|
+
const { container, input } = mountPasswordInput("abc", {
|
|
773
|
+
revealStrategy: "noise",
|
|
774
|
+
});
|
|
770
775
|
const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
|
|
771
776
|
|
|
772
777
|
eye.dispatchEvent(
|
|
@@ -883,7 +888,9 @@ describe("HkInput password reveal eye", () => {
|
|
|
883
888
|
} as unknown as CanvasRenderingContext2D;
|
|
884
889
|
}) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
|
885
890
|
try {
|
|
886
|
-
const { container } = mountPasswordInput("abc"
|
|
891
|
+
const { container } = mountPasswordInput("abc", {
|
|
892
|
+
revealStrategy: "noise",
|
|
893
|
+
});
|
|
887
894
|
const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
|
|
888
895
|
eye.dispatchEvent(
|
|
889
896
|
new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
|
|
@@ -954,7 +961,9 @@ describe("HkInput password reveal eye", () => {
|
|
|
954
961
|
vi.stubGlobal("requestAnimationFrame", () => 0);
|
|
955
962
|
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
956
963
|
try {
|
|
957
|
-
const { container } = mountPasswordInput("abc"
|
|
964
|
+
const { container } = mountPasswordInput("abc", {
|
|
965
|
+
revealStrategy: "noise",
|
|
966
|
+
});
|
|
958
967
|
const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
|
|
959
968
|
eye.dispatchEvent(
|
|
960
969
|
new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
|
|
@@ -1034,7 +1043,9 @@ describe("HkInput password reveal eye", () => {
|
|
|
1034
1043
|
} as unknown as CanvasRenderingContext2D;
|
|
1035
1044
|
}) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
|
1036
1045
|
try {
|
|
1037
|
-
const { container, input } = mountPasswordInput("abc"
|
|
1046
|
+
const { container, input } = mountPasswordInput("abc", {
|
|
1047
|
+
revealStrategy: "noise",
|
|
1048
|
+
});
|
|
1038
1049
|
const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
|
|
1039
1050
|
eye.dispatchEvent(
|
|
1040
1051
|
new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
|
|
@@ -1105,7 +1116,9 @@ describe("HkInput password reveal eye", () => {
|
|
|
1105
1116
|
} as unknown as CanvasRenderingContext2D;
|
|
1106
1117
|
}) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
|
1107
1118
|
try {
|
|
1108
|
-
const { container } = mountPasswordInput("abc"
|
|
1119
|
+
const { container } = mountPasswordInput("abc", {
|
|
1120
|
+
revealStrategy: "noise",
|
|
1121
|
+
});
|
|
1109
1122
|
const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
|
|
1110
1123
|
eye.dispatchEvent(
|
|
1111
1124
|
new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
|
|
@@ -1166,7 +1179,9 @@ describe("HkInput password reveal eye", () => {
|
|
|
1166
1179
|
} as unknown as CanvasRenderingContext2D;
|
|
1167
1180
|
}) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
|
1168
1181
|
try {
|
|
1169
|
-
const { container } = mountPasswordInput("abc"
|
|
1182
|
+
const { container } = mountPasswordInput("abc", {
|
|
1183
|
+
revealStrategy: "noise",
|
|
1184
|
+
});
|
|
1170
1185
|
const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
|
|
1171
1186
|
eye.dispatchEvent(
|
|
1172
1187
|
new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
|
|
@@ -1247,7 +1262,9 @@ describe("HkInput password reveal eye", () => {
|
|
|
1247
1262
|
} as unknown as CanvasRenderingContext2D;
|
|
1248
1263
|
}) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
|
1249
1264
|
try {
|
|
1250
|
-
const { container } = mountPasswordInput("abc"
|
|
1265
|
+
const { container } = mountPasswordInput("abc", {
|
|
1266
|
+
revealStrategy: "noise",
|
|
1267
|
+
});
|
|
1251
1268
|
const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
|
|
1252
1269
|
eye.dispatchEvent(
|
|
1253
1270
|
new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
|
|
@@ -1297,13 +1314,29 @@ describe("HkInput password reveal eye", () => {
|
|
|
1297
1314
|
/** Installs a per-canvas recording getContext stub (happy-dom has no
|
|
1298
1315
|
* real 2d): every canvas element gets its own recorder so a test can
|
|
1299
1316
|
* tell the visible dot canvas apart from offscreen painter canvases. */
|
|
1300
|
-
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;
|
|
1301
1322
|
interface CanvasRec {
|
|
1302
1323
|
canvas: HTMLCanvasElement;
|
|
1303
1324
|
texts: string[];
|
|
1304
1325
|
patternFills: number;
|
|
1305
1326
|
drawImages: number;
|
|
1306
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[];
|
|
1338
|
+
clips: Array<{ x: number; y: number; w: number; h: number }>;
|
|
1339
|
+
translates: number[];
|
|
1307
1340
|
}
|
|
1308
1341
|
const byCanvas = new Map<HTMLCanvasElement, CanvasRec>();
|
|
1309
1342
|
const original = HTMLCanvasElement.prototype.getContext;
|
|
@@ -1312,21 +1345,41 @@ function stubRecordingContexts() {
|
|
|
1312
1345
|
): CanvasRenderingContext2D {
|
|
1313
1346
|
let rec = byCanvas.get(this);
|
|
1314
1347
|
if (!rec) {
|
|
1315
|
-
rec = {
|
|
1348
|
+
rec = {
|
|
1349
|
+
canvas: this,
|
|
1350
|
+
texts: [],
|
|
1351
|
+
patternFills: 0,
|
|
1352
|
+
drawImages: 0,
|
|
1353
|
+
putImageDatas: 0,
|
|
1354
|
+
gradients: 0,
|
|
1355
|
+
gradientFills: 0,
|
|
1356
|
+
compositeOps: [],
|
|
1357
|
+
fillStyles: [],
|
|
1358
|
+
clips: [],
|
|
1359
|
+
translates: [],
|
|
1360
|
+
};
|
|
1316
1361
|
byCanvas.set(this, rec);
|
|
1317
1362
|
}
|
|
1318
1363
|
const r = rec;
|
|
1319
|
-
|
|
1364
|
+
let fillStyleBox: string | CanvasGradient | CanvasPattern = "";
|
|
1365
|
+
let gradientArmed = false;
|
|
1366
|
+
let compositeOpBox: GlobalCompositeOperation = "source-over";
|
|
1367
|
+
const stub: Record<string, unknown> = {
|
|
1320
1368
|
canvas: this,
|
|
1321
1369
|
clearRect: () => {},
|
|
1322
1370
|
save: () => {},
|
|
1323
1371
|
restore: () => {},
|
|
1324
|
-
translate: () =>
|
|
1372
|
+
translate: (x: number) => r.translates.push(x),
|
|
1325
1373
|
rotate: () => {},
|
|
1326
1374
|
beginPath: () => {},
|
|
1327
1375
|
arc: () => {},
|
|
1328
1376
|
fill: () => {},
|
|
1329
|
-
fillRect: () => {
|
|
1377
|
+
fillRect: () => {
|
|
1378
|
+
if (gradientArmed) {
|
|
1379
|
+
r.gradientFills++;
|
|
1380
|
+
gradientArmed = false;
|
|
1381
|
+
}
|
|
1382
|
+
},
|
|
1330
1383
|
measureText: () => ({ width: 10 }),
|
|
1331
1384
|
fillText: (text: string) => r.texts.push(String(text)),
|
|
1332
1385
|
drawImage: () => r.drawImages++,
|
|
@@ -1334,17 +1387,42 @@ function stubRecordingContexts() {
|
|
|
1334
1387
|
r.patternFills++;
|
|
1335
1388
|
return {} as CanvasPattern;
|
|
1336
1389
|
},
|
|
1390
|
+
createLinearGradient: () => {
|
|
1391
|
+
r.gradients++;
|
|
1392
|
+
return { __recGradient: true, addColorStop: () => {} } as unknown as CanvasGradient;
|
|
1393
|
+
},
|
|
1337
1394
|
createImageData: (w: number, h: number) => ({
|
|
1338
1395
|
data: new Uint8ClampedArray(w * h * 4),
|
|
1339
1396
|
}),
|
|
1340
1397
|
putImageData: () => r.putImageDatas++,
|
|
1398
|
+
rect: (x: number, y: number, w: number, h: number) =>
|
|
1399
|
+
r.clips.push({ x, y, w, h }),
|
|
1400
|
+
clip: () => {},
|
|
1341
1401
|
imageSmoothingEnabled: false,
|
|
1342
|
-
globalCompositeOperation
|
|
1402
|
+
get globalCompositeOperation() {
|
|
1403
|
+
return compositeOpBox;
|
|
1404
|
+
},
|
|
1405
|
+
set globalCompositeOperation(v: GlobalCompositeOperation) {
|
|
1406
|
+
compositeOpBox = v;
|
|
1407
|
+
r.compositeOps.push(String(v));
|
|
1408
|
+
},
|
|
1343
1409
|
font: "",
|
|
1344
|
-
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
|
+
},
|
|
1345
1421
|
textAlign: "",
|
|
1346
1422
|
textBaseline: "",
|
|
1347
|
-
}
|
|
1423
|
+
};
|
|
1424
|
+
if (!linearGradients) delete stub.createLinearGradient;
|
|
1425
|
+
return stub as unknown as CanvasRenderingContext2D;
|
|
1348
1426
|
}) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
|
1349
1427
|
return {
|
|
1350
1428
|
byCanvas,
|
|
@@ -1355,6 +1433,370 @@ function stubRecordingContexts() {
|
|
|
1355
1433
|
}
|
|
1356
1434
|
|
|
1357
1435
|
describe("HkInput password reveal strategies", () => {
|
|
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
|
|
1438
|
+
// that sweeps across the row (sweepWindow), on top of the boiling
|
|
1439
|
+
// noise field. A single frame leaks only the band's characters.
|
|
1440
|
+
const rec = stubRecordingContexts();
|
|
1441
|
+
try {
|
|
1442
|
+
const { container, input } = mountPasswordInput("abc", {
|
|
1443
|
+
revealStrategy: "sweep",
|
|
1444
|
+
});
|
|
1445
|
+
const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
|
|
1446
|
+
eye.dispatchEvent(
|
|
1447
|
+
new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
|
|
1448
|
+
);
|
|
1449
|
+
await nextTick();
|
|
1450
|
+
const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
|
|
1451
|
+
const vis = rec.byCanvas.get(visible)!;
|
|
1452
|
+
// The sync first frame already carries text + noise base.
|
|
1453
|
+
expect(vis.texts).toEqual(["a", "b", "c"]);
|
|
1454
|
+
expect(vis.patternFills).toBeGreaterThan(0);
|
|
1455
|
+
expect(vis.clips.length).toBeGreaterThanOrEqual(1);
|
|
1456
|
+
expect(vis.clips[0]!.w).toBeGreaterThan(0);
|
|
1457
|
+
// The DOM input still never flips.
|
|
1458
|
+
expect(input.type).toBe("password");
|
|
1459
|
+
// Frames move the window: clip x changes across the sweep.
|
|
1460
|
+
for (let i = 0; i < 3; i++) {
|
|
1461
|
+
await new Promise((r) => setTimeout(r, 45));
|
|
1462
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
1463
|
+
}
|
|
1464
|
+
expect(vis.clips.length).toBeGreaterThanOrEqual(2);
|
|
1465
|
+
const xs = new Set(vis.clips.map((c) => c.x));
|
|
1466
|
+
expect(xs.size, "the window travels across the row").toBeGreaterThan(1);
|
|
1467
|
+
// The band must traverse (essentially) the WHOLE row, not stop
|
|
1468
|
+
// mid-row: on the degenerate 0-width happy-dom canvas the layout
|
|
1469
|
+
// scale floors at 0.5, giving advance 6 and an 18px row for
|
|
1470
|
+
// "abc" — the clip centers must span ≥ 12px of that travel
|
|
1471
|
+
// (center = clip.x + w/2; the sweep spans first-glyph left edge
|
|
1472
|
+
// to the row's right edge).
|
|
1473
|
+
const centers = vis.clips.map((c) => c.x + c.w / 2);
|
|
1474
|
+
expect(
|
|
1475
|
+
Math.max(...centers) - Math.min(...centers),
|
|
1476
|
+
"the sweep reaches the row tail",
|
|
1477
|
+
).toBeGreaterThanOrEqual(12);
|
|
1478
|
+
document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
1479
|
+
await nextTick();
|
|
1480
|
+
} finally {
|
|
1481
|
+
rec.restore();
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
it("restarts the sweep from the row head on the second hold", async () => {
|
|
1486
|
+
// sweepT resets per reveal: hold-release-hold must show the window
|
|
1487
|
+
// at the row START again, not resume from wherever the last hold
|
|
1488
|
+
// left it (R2 mutation M23 — the sweepT reset had no pin).
|
|
1489
|
+
const rec = stubRecordingContexts();
|
|
1490
|
+
try {
|
|
1491
|
+
const { container } = mountPasswordInput("abc", {
|
|
1492
|
+
revealStrategy: "sweep",
|
|
1493
|
+
});
|
|
1494
|
+
const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
|
|
1495
|
+
eye.dispatchEvent(
|
|
1496
|
+
new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
|
|
1497
|
+
);
|
|
1498
|
+
await nextTick();
|
|
1499
|
+
const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
|
|
1500
|
+
const vis = rec.byCanvas.get(visible)!;
|
|
1501
|
+
const firstHoldFirstClip = vis.clips[0]!;
|
|
1502
|
+
// Drive real bus frames DURING the first hold so sweepT actually
|
|
1503
|
+
// advances past the row before release — without this, both
|
|
1504
|
+
// holds' first frames draw at sweepT = 0 and the reset pin below
|
|
1505
|
+
// passes vacuously. The self-check makes a frameless environment
|
|
1506
|
+
// fail loudly instead of slipping through.
|
|
1507
|
+
for (let i = 0; i < 3; i++) {
|
|
1508
|
+
await new Promise((r) => setTimeout(r, 45));
|
|
1509
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
1510
|
+
}
|
|
1511
|
+
expect(
|
|
1512
|
+
vis.clips.length,
|
|
1513
|
+
"bus frames advanced the sweep during hold 1",
|
|
1514
|
+
).toBeGreaterThanOrEqual(2);
|
|
1515
|
+
document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
1516
|
+
await nextTick();
|
|
1517
|
+
// Second hold: the very first frame's window must sit at the row
|
|
1518
|
+
// head again (same clip as the first hold's first frame).
|
|
1519
|
+
eye.dispatchEvent(
|
|
1520
|
+
new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
|
|
1521
|
+
);
|
|
1522
|
+
await nextTick();
|
|
1523
|
+
const secondHoldFirstClip = vis.clips[vis.clips.length - 1]!;
|
|
1524
|
+
expect(secondHoldFirstClip.x).toBe(firstHoldFirstClip.x);
|
|
1525
|
+
expect(secondHoldFirstClip.w).toBe(firstHoldFirstClip.w);
|
|
1526
|
+
document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
1527
|
+
await nextTick();
|
|
1528
|
+
} finally {
|
|
1529
|
+
rec.restore();
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
it("sweep degrades to static plain text when the animation bus is parked (reduced motion)", async () => {
|
|
1534
|
+
// A parked bus cannot move the window, so the sweep falls back to
|
|
1535
|
+
// the fully readable static plain text — never to frozen noise.
|
|
1536
|
+
setReducedMotion(true);
|
|
1537
|
+
const rec = stubRecordingContexts();
|
|
1538
|
+
try {
|
|
1539
|
+
const { container } = mountPasswordInput("abc", {
|
|
1540
|
+
revealStrategy: "sweep",
|
|
1541
|
+
});
|
|
1542
|
+
const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
|
|
1543
|
+
eye.dispatchEvent(
|
|
1544
|
+
new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
|
|
1545
|
+
);
|
|
1546
|
+
await nextTick();
|
|
1547
|
+
const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
|
|
1548
|
+
const vis = rec.byCanvas.get(visible)!;
|
|
1549
|
+
expect(vis.texts).toEqual(["a", "b", "c"]);
|
|
1550
|
+
// No noise machinery on the visible canvas in the degraded state.
|
|
1551
|
+
expect(vis.patternFills).toBe(0);
|
|
1552
|
+
// And the degrade target is PLAIN text, not the legacy jitter:
|
|
1553
|
+
// the plain pass never translates per glyph (the jitter does).
|
|
1554
|
+
expect(vis.translates.length, "static plain, not jitter").toBe(0);
|
|
1555
|
+
// And it stays put — the static frame is the whole reveal.
|
|
1556
|
+
await new Promise((r) => setTimeout(r, 260));
|
|
1557
|
+
expect(vis.texts).toEqual(["a", "b", "c"]);
|
|
1558
|
+
document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
1559
|
+
await nextTick();
|
|
1560
|
+
} finally {
|
|
1561
|
+
rec.restore();
|
|
1562
|
+
setReducedMotion(false);
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
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
|
+
|
|
1358
1800
|
it("plain strategy draws readable text on the VISIBLE canvas — and nothing else", async () => {
|
|
1359
1801
|
// The opt-in readable reveal (revealStrategy="plain"): glyphs ARE
|
|
1360
1802
|
// the visible frame — no noise fills, no mask stamp — while the DOM
|
|
@@ -1476,6 +1918,7 @@ describe("HkInput password reveal strategies", () => {
|
|
|
1476
1918
|
const rec = stubRecordingContexts();
|
|
1477
1919
|
try {
|
|
1478
1920
|
const { container, model } = mountPasswordInput("abc", {
|
|
1921
|
+
revealStrategy: "noise",
|
|
1479
1922
|
revealTrigger: "toggle",
|
|
1480
1923
|
});
|
|
1481
1924
|
const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
|
|
@@ -1734,10 +2177,13 @@ describe("HkInput password reveal trigger", () => {
|
|
|
1734
2177
|
// intervening release must not re-seed the painter mid-hold (fresh
|
|
1735
2178
|
// noise + phase reset would visibly flash the field). Observable
|
|
1736
2179
|
// pin: beginHold retiles the noise canvas — exactly ONE
|
|
1737
|
-
// putImageData per reveal
|
|
2180
|
+
// putImageData per reveal (the NOISE painter's signature; the
|
|
2181
|
+
// filter painter retiles with vector fills instead).
|
|
1738
2182
|
const rec = stubRecordingContexts();
|
|
1739
2183
|
try {
|
|
1740
|
-
const { container } = mountPasswordInput("abc"
|
|
2184
|
+
const { container } = mountPasswordInput("abc", {
|
|
2185
|
+
revealStrategy: "noise",
|
|
2186
|
+
});
|
|
1741
2187
|
const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
|
|
1742
2188
|
eye.dispatchEvent(
|
|
1743
2189
|
new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
|