@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.17",
3
+ "version": "0.55.19",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
package/src/DemoApp.tsx CHANGED
@@ -366,7 +366,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 (hold the eye)" />
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): "noise" boiling kinematogram
18
- * (screenshot-safe, default) vs "plain" 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
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 = { canvas: this, texts: [], patternFills: 0, drawImages: 0, putImageDatas: 0 };
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
- return {
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: "source-over",
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
- } as unknown as CanvasRenderingContext2D;
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 }),