@celestia-island/hikari 0.55.15 → 0.55.16

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.15",
3
+ "version": "0.55.16",
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
@@ -367,6 +367,8 @@ export default defineComponent({
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
369
  <HInput variant="password" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (hold the eye)" />
370
+ <HInput variant="password" revealStrategy="plain" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (plain reveal)" />
371
+ <HInput variant="password" revealTrigger="toggle" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (click eye toggles)" />
370
372
  <HInput variant="password" passwordTrailing="strength" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (strength light)" />
371
373
  <HInput variant="password" passwordTrailing="none" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (no affordance)" />
372
374
  <HTextarea modelValue={form.value.textarea} onUpdate:modelValue={(v: string) => (form.value.textarea = v)} placeholder="Textarea" rows={3} />
@@ -12,8 +12,12 @@ import { setReducedMotion } from "../runtime/animationBus";
12
12
  * - the hikari password visual identity survives: canvas dot matrix,
13
13
  * centered breathing placeholder with focus states, blur "entered"
14
14
  * hint, caps-lock / full-width hints, pending-clear refocus semantics
15
- * - right-edge affordance (passwordTrailing): eye hold-to-reveal
16
- * (default) / strength traffic light / none
15
+ * - right-edge affordance (passwordTrailing): eye reveal (default) /
16
+ * strength traffic light / none
17
+ * - reveal strategy (revealStrategy): "noise" boiling kinematogram
18
+ * (screenshot-safe, default) vs "plain" readable text
19
+ * - reveal trigger (revealTrigger): press-and-hold (default) vs
20
+ * click-to-toggle with auto-hide (revealAutoHideMs)
17
21
  * - the strength dot classifies through the shared passwordLevel util
18
22
  * unless strengthEvaluator overrides it, and carries a localized
19
23
  * tooltip that opens on hover AND on touch tap
@@ -576,7 +580,7 @@ describe("HkInput password strength traffic light", () => {
576
580
  });
577
581
  });
578
582
 
579
- describe("HkInput password hold-to-reveal eye", () => {
583
+ describe("HkInput password reveal eye", () => {
580
584
  it("renders the eye by default and hides it for none", () => {
581
585
  const def = mountPasswordInput("");
582
586
  expect(def.container.querySelector("button.hk-pwd-eye")).toBeTruthy();
@@ -705,7 +709,7 @@ describe("HkInput password hold-to-reveal eye", () => {
705
709
  }
706
710
  });
707
711
 
708
- it("reveals via counter-drifting noise — no glyph ever lands on the visible canvas", async () => {
712
+ it("reveals via the boiling-noise kinematogram — no glyph ever lands on the visible canvas", async () => {
709
713
  // Recording canvas contexts, one per canvas element: happy-dom's
710
714
  // getContext is null, so the drawing path never runs there. The
711
715
  // per-canvas recorder can tell the VISIBLE dot canvas apart from
@@ -1189,6 +1193,571 @@ describe("HkInput password hold-to-reveal eye", () => {
1189
1193
  setReducedMotion(false);
1190
1194
  }
1191
1195
  });
1196
+
1197
+ it("boils the glyph noise: a fresh random tile phase per frame, not an accumulating drift", async () => {
1198
+ // Boiling is the readability mechanism: the glyph region must
1199
+ // RE-SAMPLE the noise tile at a random phase every frame while the
1200
+ // background slides smoothly. A regression back to an accumulated
1201
+ // glyph drift (the old counter-drift design nobody could read) must
1202
+ // go red here. The painter consumes exactly ONE Math.random per
1203
+ // frame (the glyph phase), so a cycling mock makes the expected
1204
+ // mask translations fully deterministic — a drift accumulator would
1205
+ // ignore the mock entirely and produce monotone offsets instead.
1206
+ interface CanvasRec {
1207
+ canvas: HTMLCanvasElement;
1208
+ texts: string[];
1209
+ translateXs: number[];
1210
+ }
1211
+ const byCanvas = new Map<HTMLCanvasElement, CanvasRec>();
1212
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
1213
+ HTMLCanvasElement.prototype.getContext = (function (
1214
+ this: HTMLCanvasElement,
1215
+ ): CanvasRenderingContext2D {
1216
+ let rec = byCanvas.get(this);
1217
+ if (!rec) {
1218
+ rec = { canvas: this, texts: [], translateXs: [] };
1219
+ byCanvas.set(this, rec);
1220
+ }
1221
+ const r = rec;
1222
+ return {
1223
+ canvas: this,
1224
+ clearRect: () => {},
1225
+ save: () => {},
1226
+ restore: () => {},
1227
+ translate: (x: number) => r.translateXs.push(x),
1228
+ rotate: () => {},
1229
+ beginPath: () => {},
1230
+ arc: () => {},
1231
+ fill: () => {},
1232
+ fillRect: () => {},
1233
+ measureText: () => ({ width: 10 }),
1234
+ fillText: (text: string) => r.texts.push(String(text)),
1235
+ drawImage: () => {},
1236
+ createPattern: () => ({}) as CanvasPattern,
1237
+ createImageData: (w: number, h: number) => ({
1238
+ data: new Uint8ClampedArray(w * h * 4),
1239
+ }),
1240
+ putImageData: () => {},
1241
+ imageSmoothingEnabled: false,
1242
+ globalCompositeOperation: "source-over",
1243
+ font: "",
1244
+ fillStyle: "",
1245
+ textAlign: "",
1246
+ textBaseline: "",
1247
+ } as unknown as CanvasRenderingContext2D;
1248
+ }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
1249
+ try {
1250
+ const { container } = mountPasswordInput("abc");
1251
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1252
+ eye.dispatchEvent(
1253
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1254
+ );
1255
+ await nextTick();
1256
+ const maskRec = [...byCanvas.values()].find((r) => r.texts.length > 0);
1257
+ expect(maskRec, "the offscreen glyph mask exists").toBeTruthy();
1258
+ // Install the cycling mock AFTER the synchronous first frame: that
1259
+ // frame consumed its glyph phase from real randomness, and
1260
+ // beginHold's tile build is done consuming randomness too. From
1261
+ // here each painted frame draws exactly one mock value.
1262
+ const cycle = [0.5, 0.25, 0.75];
1263
+ let calls = 0;
1264
+ const rand = vi
1265
+ .spyOn(Math, "random")
1266
+ .mockImplementation(() => cycle[calls++ % cycle.length]!);
1267
+ const before = maskRec!.translateXs.length;
1268
+ try {
1269
+ // A few bus frames (the "normal" tier needs ~33ms between
1270
+ // deliveries — mix real timeouts with rAF like the other
1271
+ // reveal tests).
1272
+ for (let i = 0; i < 3; i++) {
1273
+ await new Promise((r) => setTimeout(r, 45));
1274
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
1275
+ }
1276
+ } finally {
1277
+ rand.mockRestore();
1278
+ }
1279
+ const fresh = maskRec!.translateXs.slice(before);
1280
+ expect(fresh.length, "frames painted while the mock was live").toBeGreaterThanOrEqual(2);
1281
+ // Frame i consumes cycle[i % 3]: the expected mask translation
1282
+ // sequence is the cycle repeated, truncated to the frame count.
1283
+ const mapped = cycle.map((v) => -Math.floor(v * NOISE_TILE_W));
1284
+ const expected = fresh.map((_, i) => mapped[i % mapped.length]!);
1285
+ expect(
1286
+ fresh,
1287
+ "each frame re-samples the glyph phase from the mock cycle",
1288
+ ).toEqual(expected);
1289
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1290
+ await nextTick();
1291
+ } finally {
1292
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
1293
+ }
1294
+ });
1295
+ });
1296
+
1297
+ /** Installs a per-canvas recording getContext stub (happy-dom has no
1298
+ * real 2d): every canvas element gets its own recorder so a test can
1299
+ * tell the visible dot canvas apart from offscreen painter canvases. */
1300
+ function stubRecordingContexts() {
1301
+ interface CanvasRec {
1302
+ canvas: HTMLCanvasElement;
1303
+ texts: string[];
1304
+ patternFills: number;
1305
+ drawImages: number;
1306
+ putImageDatas: number;
1307
+ }
1308
+ const byCanvas = new Map<HTMLCanvasElement, CanvasRec>();
1309
+ const original = HTMLCanvasElement.prototype.getContext;
1310
+ HTMLCanvasElement.prototype.getContext = (function (
1311
+ this: HTMLCanvasElement,
1312
+ ): CanvasRenderingContext2D {
1313
+ let rec = byCanvas.get(this);
1314
+ if (!rec) {
1315
+ rec = { canvas: this, texts: [], patternFills: 0, drawImages: 0, putImageDatas: 0 };
1316
+ byCanvas.set(this, rec);
1317
+ }
1318
+ const r = rec;
1319
+ return {
1320
+ canvas: this,
1321
+ clearRect: () => {},
1322
+ save: () => {},
1323
+ restore: () => {},
1324
+ translate: () => {},
1325
+ rotate: () => {},
1326
+ beginPath: () => {},
1327
+ arc: () => {},
1328
+ fill: () => {},
1329
+ fillRect: () => {},
1330
+ measureText: () => ({ width: 10 }),
1331
+ fillText: (text: string) => r.texts.push(String(text)),
1332
+ drawImage: () => r.drawImages++,
1333
+ createPattern: () => {
1334
+ r.patternFills++;
1335
+ return {} as CanvasPattern;
1336
+ },
1337
+ createImageData: (w: number, h: number) => ({
1338
+ data: new Uint8ClampedArray(w * h * 4),
1339
+ }),
1340
+ putImageData: () => r.putImageDatas++,
1341
+ imageSmoothingEnabled: false,
1342
+ globalCompositeOperation: "source-over",
1343
+ font: "",
1344
+ fillStyle: "",
1345
+ textAlign: "",
1346
+ textBaseline: "",
1347
+ } as unknown as CanvasRenderingContext2D;
1348
+ }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
1349
+ return {
1350
+ byCanvas,
1351
+ restore() {
1352
+ HTMLCanvasElement.prototype.getContext = original;
1353
+ },
1354
+ };
1355
+ }
1356
+
1357
+ describe("HkInput password reveal strategies", () => {
1358
+ it("plain strategy draws readable text on the VISIBLE canvas — and nothing else", async () => {
1359
+ // The opt-in readable reveal (revealStrategy="plain"): glyphs ARE
1360
+ // the visible frame — no noise fills, no mask stamp — while the DOM
1361
+ // input still never flips to type="text".
1362
+ const rec = stubRecordingContexts();
1363
+ try {
1364
+ const { container, input } = mountPasswordInput("abc", {
1365
+ revealStrategy: "plain",
1366
+ });
1367
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1368
+ eye.dispatchEvent(
1369
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1370
+ );
1371
+ await nextTick();
1372
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1373
+ const vis = rec.byCanvas.get(visible)!;
1374
+ expect(vis, "visible canvas recorder").toBeTruthy();
1375
+ expect(vis.texts).toEqual(["a", "b", "c"]);
1376
+ expect(vis.patternFills).toBe(0);
1377
+ expect(vis.drawImages).toBe(0);
1378
+ // No offscreen mask/tile canvases at all in plain mode.
1379
+ expect(rec.byCanvas.size).toBe(1);
1380
+ expect(input.type).toBe("password");
1381
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1382
+ await nextTick();
1383
+ // After release the reveal pass stops: no further text draws.
1384
+ const drawn = vis.texts.length;
1385
+ for (let i = 0; i < 2; i++) {
1386
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
1387
+ }
1388
+ expect(vis.texts.length).toBe(drawn);
1389
+ } finally {
1390
+ rec.restore();
1391
+ }
1392
+ });
1393
+
1394
+ it("plain strategy stays readable under reduced motion (no motion to lose)", async () => {
1395
+ // The a11y win of the plain strategy: a parked animation bus cannot
1396
+ // freeze it into noise — the static text IS the reveal.
1397
+ setReducedMotion(true);
1398
+ const rec = stubRecordingContexts();
1399
+ try {
1400
+ const { container } = mountPasswordInput("abc", {
1401
+ revealStrategy: "plain",
1402
+ });
1403
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1404
+ eye.dispatchEvent(
1405
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1406
+ );
1407
+ await nextTick();
1408
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1409
+ expect(rec.byCanvas.get(visible)!.texts).toEqual(["a", "b", "c"]);
1410
+ // And it stays put — no watchdog flips, no redraws.
1411
+ await new Promise((r) => setTimeout(r, 260));
1412
+ expect(rec.byCanvas.get(visible)!.texts).toEqual(["a", "b", "c"]);
1413
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1414
+ await nextTick();
1415
+ } finally {
1416
+ rec.restore();
1417
+ setReducedMotion(false);
1418
+ }
1419
+ });
1420
+
1421
+ it("plain strategy repaints when the value changes mid-reveal", async () => {
1422
+ setReducedMotion(true); // parked bus: only explicit repaints draw
1423
+ const rec = stubRecordingContexts();
1424
+ try {
1425
+ const { container, model } = mountPasswordInput("abc", {
1426
+ revealStrategy: "plain",
1427
+ revealTrigger: "toggle",
1428
+ });
1429
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1430
+ eye.dispatchEvent(
1431
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1432
+ );
1433
+ await nextTick();
1434
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1435
+ expect(rec.byCanvas.get(visible)!.texts).toEqual(["a", "b", "c"]);
1436
+ // Typing while revealed: the static frame must follow the value
1437
+ // even though no bus frame will ever arrive.
1438
+ model.value = "abcz";
1439
+ await nextTick();
1440
+ expect(rec.byCanvas.get(visible)!.texts).toEqual(["a", "b", "c", "a", "b", "c", "z"]);
1441
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1442
+ await nextTick();
1443
+ } finally {
1444
+ rec.restore();
1445
+ setReducedMotion(false);
1446
+ }
1447
+ });
1448
+
1449
+ it("clearing the value mid-reveal ends the reveal", async () => {
1450
+ const rec = stubRecordingContexts();
1451
+ try {
1452
+ const { container, model } = mountPasswordInput("abc", {
1453
+ revealStrategy: "plain",
1454
+ revealTrigger: "toggle",
1455
+ });
1456
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1457
+ eye.dispatchEvent(
1458
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1459
+ );
1460
+ await nextTick();
1461
+ expect(eye.hasAttribute("data-revealing")).toBe(true);
1462
+ model.value = "";
1463
+ await nextTick();
1464
+ expect(eye.hasAttribute("data-revealing")).toBe(false);
1465
+ } finally {
1466
+ rec.restore();
1467
+ }
1468
+ });
1469
+
1470
+ it("repaints the latched jitter fallback when the value changes mid-reveal (parked bus)", async () => {
1471
+ // Reduced motion + noise strategy: the reveal is a STATIC jitter
1472
+ // frame that no bus frame will ever refresh — a mid-reveal value
1473
+ // change must repaint it, or the field would keep showing the OLD
1474
+ // password until release.
1475
+ setReducedMotion(true);
1476
+ const rec = stubRecordingContexts();
1477
+ try {
1478
+ const { container, model } = mountPasswordInput("abc", {
1479
+ revealTrigger: "toggle",
1480
+ });
1481
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1482
+ eye.dispatchEvent(
1483
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1484
+ );
1485
+ await nextTick();
1486
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1487
+ expect(rec.byCanvas.get(visible)!.texts).toEqual(["a", "b", "c"]);
1488
+ model.value = "abcz";
1489
+ await nextTick();
1490
+ expect(rec.byCanvas.get(visible)!.texts).toEqual(["a", "b", "c", "a", "b", "c", "z"]);
1491
+ } finally {
1492
+ rec.restore();
1493
+ setReducedMotion(false);
1494
+ }
1495
+ });
1496
+
1497
+ it("ends the reveal when the field is disabled mid-reveal", async () => {
1498
+ // showEye gates on disabled, so the eye unmounts — but the reveal
1499
+ // must not linger on a disabled field (above all a toggle with the
1500
+ // auto-hide timer off).
1501
+ const rec = stubRecordingContexts();
1502
+ const dis = ref(false);
1503
+ const model = ref("secret");
1504
+ const container = document.createElement("div");
1505
+ document.body.appendChild(container);
1506
+ const app = createApp({
1507
+ render() {
1508
+ return h(HkInput, {
1509
+ variant: "password",
1510
+ revealStrategy: "plain",
1511
+ revealTrigger: "toggle",
1512
+ revealAutoHideMs: 0,
1513
+ disabled: dis.value,
1514
+ modelValue: model.value,
1515
+ "onUpdate:modelValue": (v: string) => {
1516
+ model.value = v;
1517
+ },
1518
+ });
1519
+ },
1520
+ });
1521
+ app.mount(container);
1522
+ try {
1523
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1524
+ eye.dispatchEvent(
1525
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1526
+ );
1527
+ await nextTick();
1528
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1529
+ const vis = rec.byCanvas.get(visible)!;
1530
+ expect(vis.texts).toEqual(["s", "e", "c", "r", "e", "t"]);
1531
+ dis.value = true;
1532
+ await nextTick();
1533
+ // The eye is gone and the reveal pass ended: the dot-matrix
1534
+ // repaint after endReveal adds no further text draws.
1535
+ expect(container.querySelector("button.hk-pwd-eye")).toBeNull();
1536
+ for (let i = 0; i < 2; i++) {
1537
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
1538
+ }
1539
+ expect(vis.texts).toEqual(["s", "e", "c", "r", "e", "t"]);
1540
+ } finally {
1541
+ app.unmount();
1542
+ container.remove();
1543
+ rec.restore();
1544
+ }
1545
+ });
1546
+
1547
+ it("ends the reveal when the reveal props flip mid-reveal", async () => {
1548
+ // strategy/trigger are read once at reveal start (painter setup,
1549
+ // watchdog, trigger listeners all branch on them) — a mid-reveal
1550
+ // flip reconciles by ending the reveal, never by stranding a
1551
+ // half-old/half-new hold.
1552
+ const rec = stubRecordingContexts();
1553
+ const strategy = ref<"noise" | "plain">("plain");
1554
+ const model = ref("secret");
1555
+ const container = document.createElement("div");
1556
+ document.body.appendChild(container);
1557
+ const app = createApp({
1558
+ render() {
1559
+ return h(HkInput, {
1560
+ variant: "password",
1561
+ revealStrategy: strategy.value,
1562
+ revealTrigger: "toggle",
1563
+ modelValue: model.value,
1564
+ "onUpdate:modelValue": (v: string) => {
1565
+ model.value = v;
1566
+ },
1567
+ });
1568
+ },
1569
+ });
1570
+ app.mount(container);
1571
+ try {
1572
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1573
+ eye.dispatchEvent(
1574
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1575
+ );
1576
+ await nextTick();
1577
+ expect(eye.hasAttribute("data-revealing")).toBe(true);
1578
+ strategy.value = "noise";
1579
+ await nextTick();
1580
+ expect(eye.hasAttribute("data-revealing")).toBe(false);
1581
+ // And the noise strategy works fine on the NEXT reveal.
1582
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1583
+ const textsBeforeNoise = rec.byCanvas.get(visible)!.texts.length;
1584
+ eye.dispatchEvent(
1585
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1586
+ );
1587
+ await nextTick();
1588
+ expect(eye.hasAttribute("data-revealing")).toBe(true);
1589
+ for (let i = 0; i < 2; i++) {
1590
+ await new Promise((r) => setTimeout(r, 45));
1591
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
1592
+ }
1593
+ // Noise mode: glyphs stay off the visible canvas — the plain
1594
+ // reveal's earlier text draws are the last ones it ever saw.
1595
+ expect(rec.byCanvas.get(visible)!.texts.length).toBe(textsBeforeNoise);
1596
+ eye.dispatchEvent(
1597
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1598
+ );
1599
+ await nextTick();
1600
+ } finally {
1601
+ app.unmount();
1602
+ container.remove();
1603
+ rec.restore();
1604
+ }
1605
+ });
1606
+ });
1607
+
1608
+ describe("HkInput password reveal trigger", () => {
1609
+ it("toggle mode reveals on click, survives release, hides on the next click", async () => {
1610
+ const { container } = mountPasswordInput("secret", {
1611
+ revealTrigger: "toggle",
1612
+ });
1613
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1614
+
1615
+ eye.dispatchEvent(
1616
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1617
+ );
1618
+ await nextTick();
1619
+ expect(eye.hasAttribute("data-revealing")).toBe(true);
1620
+
1621
+ // Releasing the pointer must NOT end a toggled reveal.
1622
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1623
+ await nextTick();
1624
+ expect(eye.hasAttribute("data-revealing")).toBe(true);
1625
+
1626
+ eye.dispatchEvent(
1627
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1628
+ );
1629
+ await nextTick();
1630
+ expect(eye.hasAttribute("data-revealing")).toBe(false);
1631
+ });
1632
+
1633
+ it("toggle mode hides automatically after revealAutoHideMs", async () => {
1634
+ const { container } = mountPasswordInput("secret", {
1635
+ revealTrigger: "toggle",
1636
+ revealAutoHideMs: 120,
1637
+ });
1638
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1639
+ eye.dispatchEvent(
1640
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1641
+ );
1642
+ await nextTick();
1643
+ expect(eye.hasAttribute("data-revealing")).toBe(true);
1644
+ await new Promise((r) => setTimeout(r, 260));
1645
+ expect(eye.hasAttribute("data-revealing")).toBe(false);
1646
+ });
1647
+
1648
+ it("revealAutoHideMs=0 disables the toggle auto-hide", async () => {
1649
+ const { container } = mountPasswordInput("secret", {
1650
+ revealTrigger: "toggle",
1651
+ revealAutoHideMs: 0,
1652
+ });
1653
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1654
+ eye.dispatchEvent(
1655
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1656
+ );
1657
+ await nextTick();
1658
+ await new Promise((r) => setTimeout(r, 260));
1659
+ expect(eye.hasAttribute("data-revealing")).toBe(true);
1660
+ eye.dispatchEvent(
1661
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1662
+ );
1663
+ await nextTick();
1664
+ expect(eye.hasAttribute("data-revealing")).toBe(false);
1665
+ });
1666
+
1667
+ it("toggle mode keyboard: Space toggles, keyup does not hide, auto-repeat does not flap", async () => {
1668
+ const { container } = mountPasswordInput("secret", {
1669
+ revealTrigger: "toggle",
1670
+ });
1671
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1672
+
1673
+ eye.dispatchEvent(
1674
+ new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }),
1675
+ );
1676
+ await nextTick();
1677
+ expect(eye.hasAttribute("data-revealing")).toBe(true);
1678
+
1679
+ // keyup is a no-op in toggle mode (hold semantics would hide here).
1680
+ eye.dispatchEvent(
1681
+ new KeyboardEvent("keyup", { key: " ", bubbles: true, cancelable: true }),
1682
+ );
1683
+ await nextTick();
1684
+ expect(eye.hasAttribute("data-revealing")).toBe(true);
1685
+
1686
+ // Held-key auto-repeat must not flap the toggle.
1687
+ eye.dispatchEvent(
1688
+ new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true, repeat: true }),
1689
+ );
1690
+ await nextTick();
1691
+ expect(eye.hasAttribute("data-revealing")).toBe(true);
1692
+
1693
+ eye.dispatchEvent(
1694
+ new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }),
1695
+ );
1696
+ await nextTick();
1697
+ expect(eye.hasAttribute("data-revealing")).toBe(false);
1698
+ });
1699
+
1700
+ it("switches the eye aria-label between show and hide in toggle mode", async () => {
1701
+ const { container } = mountPasswordInput("secret", {
1702
+ revealTrigger: "toggle",
1703
+ });
1704
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1705
+ expect(eye.getAttribute("aria-label")).toBe("Show password");
1706
+ eye.dispatchEvent(
1707
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1708
+ );
1709
+ await nextTick();
1710
+ expect(eye.getAttribute("aria-label")).toBe("Hide password");
1711
+ eye.dispatchEvent(
1712
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1713
+ );
1714
+ await nextTick();
1715
+ expect(eye.getAttribute("aria-label")).toBe("Show password");
1716
+ });
1717
+
1718
+ it("hold mode keeps the hold-to-reveal aria-label and semantics", async () => {
1719
+ const { container } = mountPasswordInput("secret");
1720
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1721
+ expect(eye.getAttribute("aria-label")).toBe("Hold to show password");
1722
+ eye.dispatchEvent(
1723
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1724
+ );
1725
+ await nextTick();
1726
+ expect(eye.hasAttribute("data-revealing")).toBe(true);
1727
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1728
+ await nextTick();
1729
+ expect(eye.hasAttribute("data-revealing")).toBe(false);
1730
+ });
1731
+
1732
+ it("ignores a second press without release (one painter seed per reveal)", async () => {
1733
+ // The startReveal double-start guard: two pointerdowns without an
1734
+ // intervening release must not re-seed the painter mid-hold (fresh
1735
+ // noise + phase reset would visibly flash the field). Observable
1736
+ // pin: beginHold retiles the noise canvas — exactly ONE
1737
+ // putImageData per reveal.
1738
+ const rec = stubRecordingContexts();
1739
+ try {
1740
+ const { container } = mountPasswordInput("abc");
1741
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1742
+ eye.dispatchEvent(
1743
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1744
+ );
1745
+ await nextTick();
1746
+ eye.dispatchEvent(
1747
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1748
+ );
1749
+ await nextTick();
1750
+ const tilePuts = [...rec.byCanvas.values()].reduce(
1751
+ (s, r) => s + r.putImageDatas,
1752
+ 0,
1753
+ );
1754
+ expect(tilePuts).toBe(1);
1755
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1756
+ await nextTick();
1757
+ } finally {
1758
+ rec.restore();
1759
+ }
1760
+ });
1192
1761
  });
1193
1762
 
1194
1763
  /** happy-dom's own ResizeObserver never fires (no layout engine): this one