@celestia-island/hikari 0.55.11 → 0.55.13

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.
@@ -2,7 +2,9 @@ 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 { NOISE_TILE_H, NOISE_TILE_W } from "./revealKinematogram";
5
6
  import { passwordLevel } from "../utils/password";
7
+ import { setReducedMotion } from "../runtime/animationBus";
6
8
 
7
9
  /**
8
10
  * HkInput variant="password" contract tests (the unified password field —
@@ -634,11 +636,12 @@ describe("HkInput password hold-to-reveal eye", () => {
634
636
  });
635
637
 
636
638
  it("reads the mono font stack once per reveal, never per frame", async () => {
637
- // The reveal pass re-randomizes glyphs EVERY frame; reading
638
- // computed styles at that rate is layout thrash. The cache warms
639
- // at reveal start (or lazily on the first draw) and every
640
- // subsequent frame must hit it — a wholesale cache removal would
641
- // silently pass the suite without this count.
639
+ // The reveal pass rebuilds the glyph mask / layout per hold and
640
+ // drifts the noise EVERY frame; reading computed styles at that
641
+ // rate is layout thrash. The cache warms at reveal start (or
642
+ // lazily on the first draw) and every subsequent frame must hit it
643
+ // — a wholesale cache removal would silently pass the suite
644
+ // without this count.
642
645
  const ctxStub = {
643
646
  canvas: {},
644
647
  clearRect: () => {},
@@ -646,6 +649,15 @@ describe("HkInput password hold-to-reveal eye", () => {
646
649
  beginPath: () => {}, arc: () => {}, fill: () => {},
647
650
  measureText: () => ({ width: 10 }),
648
651
  fillText: () => {},
652
+ fillRect: () => {},
653
+ drawImage: () => {},
654
+ createPattern: () => ({}) as CanvasPattern,
655
+ createImageData: (w: number, h: number) => ({
656
+ data: new Uint8ClampedArray(w * h * 4),
657
+ }),
658
+ putImageData: () => {},
659
+ imageSmoothingEnabled: false,
660
+ globalCompositeOperation: "source-over",
649
661
  font: "", fillStyle: "", textAlign: "", textBaseline: "",
650
662
  };
651
663
  const originalGetContext = HTMLCanvasElement.prototype.getContext;
@@ -693,86 +705,488 @@ describe("HkInput password hold-to-reveal eye", () => {
693
705
  }
694
706
  });
695
707
 
696
- it("draws the password on the canvas with per-frame jitter (anti-OCR)", async () => {
697
- // Recording canvas context: happy-dom's getContext is null, so the
698
- // drawing path never runs there — stub it and drive the jitter
699
- // deterministically by pinning Math.random per frame.
700
- const calls: Array<{ font: string; fillStyle: string; text: string }> = [];
701
- const ctxStub = {
702
- canvas: {},
703
- clearRect: () => {},
704
- save: () => {},
705
- restore: () => {},
706
- translate: () => {},
707
- rotate: () => {},
708
- beginPath: () => {},
709
- arc: () => {},
710
- fill: () => {},
711
- measureText: () => ({ width: 10 }),
712
- fillText: (text: string) => {
713
- calls.push({
714
- font: String(ctxStub.font),
715
- fillStyle: String(ctxStub.fillStyle),
716
- text,
717
- });
718
- },
719
- font: "",
720
- fillStyle: "",
721
- textAlign: "",
722
- textBaseline: "",
723
- };
708
+ it("reveals via counter-drifting noise — no glyph ever lands on the visible canvas", async () => {
709
+ // Recording canvas contexts, one per canvas element: happy-dom's
710
+ // getContext is null, so the drawing path never runs there. The
711
+ // per-canvas recorder can tell the VISIBLE dot canvas apart from
712
+ // the painter's offscreen tile/mask canvases, which is exactly
713
+ // what the screenshot-safety contract needs: the visible canvas
714
+ // may only ever receive noise fills and a noise-composited stamp,
715
+ // never glyph geometry.
716
+ interface CanvasRec {
717
+ canvas: HTMLCanvasElement;
718
+ texts: string[];
719
+ translateXs: number[];
720
+ patternFills: number;
721
+ drawImages: number;
722
+ }
723
+ const byCanvas = new Map<HTMLCanvasElement, CanvasRec>();
724
724
  const originalGetContext = HTMLCanvasElement.prototype.getContext;
725
- HTMLCanvasElement.prototype.getContext = (() =>
726
- ctxStub) as unknown as typeof HTMLCanvasElement.prototype.getContext;
727
- const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
725
+ HTMLCanvasElement.prototype.getContext = (function (
726
+ this: HTMLCanvasElement,
727
+ ): CanvasRenderingContext2D {
728
+ let rec = byCanvas.get(this);
729
+ if (!rec) {
730
+ rec = { canvas: this, texts: [], translateXs: [], patternFills: 0, drawImages: 0 };
731
+ byCanvas.set(this, rec);
732
+ }
733
+ const r = rec;
734
+ return {
735
+ canvas: this,
736
+ clearRect: () => {},
737
+ save: () => {},
738
+ restore: () => {},
739
+ translate: (x: number) => r.translateXs.push(x),
740
+ rotate: () => {},
741
+ beginPath: () => {},
742
+ arc: () => {},
743
+ fill: () => {},
744
+ measureText: () => ({ width: 10 }),
745
+ fillText: (text: string) => r.texts.push(String(text)),
746
+ fillRect: () => {},
747
+ drawImage: () => r.drawImages++,
748
+ createPattern: () => {
749
+ r.patternFills++;
750
+ return {} as CanvasPattern;
751
+ },
752
+ createImageData: (w: number, h: number) => ({
753
+ data: new Uint8ClampedArray(w * h * 4),
754
+ }),
755
+ putImageData: () => {},
756
+ imageSmoothingEnabled: false,
757
+ globalCompositeOperation: "source-over",
758
+ font: "",
759
+ fillStyle: "",
760
+ textAlign: "",
761
+ textBaseline: "",
762
+ } as unknown as CanvasRenderingContext2D;
763
+ }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
728
764
  try {
729
765
  const { container, input } = mountPasswordInput("abc");
730
766
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
731
767
 
732
- // Frame 1 (hold): every glyph drawn exactly once, mid-jitter.
733
768
  eye.dispatchEvent(
734
769
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
735
770
  );
736
771
  await nextTick();
737
- expect(calls.map((c) => c.text)).toEqual(["a", "b", "c"]);
772
+ // Let the animation bus render a few real frames while held. The
773
+ // bus's "normal" tier fires on a 33ms budget, so mix in real
774
+ // timeouts — rAF alone can fire back-to-back with no elapsed
775
+ // time, which would never cross the tier budget.
776
+ for (let i = 0; i < 12; i++) {
777
+ await new Promise((r) => setTimeout(r, 15));
778
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
779
+ }
780
+
781
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
782
+ const vis = byCanvas.get(visible);
783
+ expect(vis).toBeTruthy();
784
+
785
+ // ── Screenshot safety (the point of the change) ──
786
+ // Any single frame of the visible canvas is pure noise: no glyph
787
+ // text may ever be drawn on it. A regression back to the jitter
788
+ // reveal (or any direct fillText on the visible surface) fails
789
+ // here — this is the "break it and it goes red" guard.
790
+ expect(vis!.texts).toEqual([]);
791
+ // The visible surface only carries noise pattern fills and the
792
+ // noise-through-mask stamp.
793
+ expect(vis!.patternFills).toBeGreaterThan(0);
794
+ expect(vis!.drawImages).toBeGreaterThan(0);
795
+
796
+ // ── Motion ──
797
+ // The noise translation must differ across frames: a frozen
798
+ // offset would leave the field as unreadable pure noise forever.
799
+ expect(new Set(vis!.translateXs).size).toBeGreaterThan(1);
800
+
801
+ // ── Glyphs live only OFFSCREEN ──
802
+ // The password raster exists only on a mask canvas that is never
803
+ // the visible element, and is fully re-composited with noise.
804
+ const maskRecs = [...byCanvas.values()].filter((r) => r.texts.length > 0);
805
+ expect(maskRecs.length).toBeGreaterThan(0);
806
+ expect(maskRecs.every((r) => r.canvas !== visible)).toBe(true);
807
+ expect(maskRecs.some((r) => r.texts.join("") === "abc")).toBe(true);
808
+
809
+ // The noise tile backing store must be the full tile size: a
810
+ // fresh canvas defaults to 300×150 and would silently clip the
811
+ // 512×128 tile (real period 300, transparent rows below 128,
812
+ // drift wrap desynced) — the R3 P1, pinned structurally here.
813
+ const tileCanvas = [...byCanvas.keys()].find(
814
+ (c) => c !== visible && c.width === NOISE_TILE_W && c.height === NOISE_TILE_H,
815
+ );
816
+ expect(tileCanvas, "an offscreen canvas sized to the noise tile exists").toBeTruthy();
817
+
818
+ // The DOM value still never flips to a text input.
738
819
  expect(input.type).toBe("password");
739
- const frame1Fonts = calls.map((c) => c.font);
740
- const frame1Colors = calls.map((c) => c.fillStyle);
741
- const frame1 = frame1Fonts.map((f, i) => `${f}|${frame1Colors[i]}`);
742
-
743
- // Frame 2 (a second hold with a different random stream): the
744
- // SAME value must render with DIFFERENT font sizes AND colors —
745
- // each dimension is asserted SEPARATELY so a regression that
746
- // freezes only one of them (e.g. size→constant, color still
747
- // jittering) still fails this test.
820
+
821
+ // Release: the reveal pass stops — no further glyph draws.
822
+ const textsBefore = [...byCanvas.values()].reduce((s, r) => s + r.texts.length, 0);
823
+ const patternsBefore = vis!.patternFills;
748
824
  document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
749
825
  await nextTick();
750
- calls.length = 0;
751
- randomSpy.mockReturnValue(0.9);
826
+ for (let i = 0; i < 3; i++) {
827
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
828
+ }
829
+ const textsAfter = [...byCanvas.values()].reduce((s, r) => s + r.texts.length, 0);
830
+ expect(textsAfter).toBe(textsBefore);
831
+ expect(vis!.patternFills).toBe(patternsBefore);
832
+ } finally {
833
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
834
+ }
835
+ });
836
+
837
+ it("degrades to static jittered glyphs when the animation bus is parked (reduced motion)", async () => {
838
+ // Parked bus (reduced motion): the kinematogram would freeze into
839
+ // unreadable noise, so a bare-timer watchdog must flip the reveal
840
+ // to the legacy static per-glyph jitter — the feature stays usable
841
+ // and motion-sensitive users keep their preference.
842
+ setReducedMotion(true);
843
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
844
+ const textsByCanvas = new Map<HTMLCanvasElement, string[]>();
845
+ HTMLCanvasElement.prototype.getContext = (function (
846
+ this: HTMLCanvasElement,
847
+ ): CanvasRenderingContext2D {
848
+ let texts = textsByCanvas.get(this);
849
+ if (!texts) {
850
+ texts = [];
851
+ textsByCanvas.set(this, texts);
852
+ }
853
+ const t = texts;
854
+ return {
855
+ canvas: this,
856
+ clearRect: () => {},
857
+ save: () => {},
858
+ restore: () => {},
859
+ translate: () => {},
860
+ rotate: () => {},
861
+ beginPath: () => {},
862
+ arc: () => {},
863
+ fill: () => {},
864
+ measureText: () => ({ width: 10 }),
865
+ fillText: (text: string) => t.push(String(text)),
866
+ fillRect: () => {},
867
+ drawImage: () => {},
868
+ createPattern: () => ({}) as CanvasPattern,
869
+ createImageData: (w: number, h: number) => ({
870
+ data: new Uint8ClampedArray(w * h * 4),
871
+ }),
872
+ putImageData: () => {},
873
+ imageSmoothingEnabled: false,
874
+ globalCompositeOperation: "source-over",
875
+ font: "",
876
+ fillStyle: "",
877
+ textAlign: "",
878
+ textBaseline: "",
879
+ } as unknown as CanvasRenderingContext2D;
880
+ }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
881
+ try {
882
+ const { container } = mountPasswordInput("abc");
883
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
752
884
  eye.dispatchEvent(
753
885
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
754
886
  );
755
887
  await nextTick();
756
- const frame2 = calls.map((c) => `${c.font}|${c.fillStyle}`);
757
- expect(calls.map((c) => c.text)).toEqual(["a", "b", "c"]);
758
- expect(frame1.join(";")).not.toBe(frame2.join(";"));
759
- expect(
760
- calls.map((c) => c.font),
761
- "glyph SIZE jitter must vary across frames",
762
- ).not.toEqual(frame1Fonts);
763
- expect(
764
- calls.map((c) => c.fillStyle),
765
- "glyph COLOR jitter must vary across frames",
766
- ).not.toEqual(frame1Colors);
888
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
889
+ // Parked bus ⇒ the degrade is IMMEDIATE (isAnimationParked, no
890
+ // 160ms of unreadable noise first): the static jitter renders the
891
+ // glyphs on the very first synchronous frame.
892
+ expect(textsByCanvas.get(visible)).toEqual(["a", "b", "c"]);
893
+ // And it stays stable: no bus frames, and the watchdog must not
894
+ // re-draw over the fallback.
895
+ await new Promise((r) => setTimeout(r, 260));
896
+ expect(textsByCanvas.get(visible)).toEqual(["a", "b", "c"]);
897
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
898
+ await nextTick();
899
+ } finally {
900
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
901
+ setReducedMotion(false);
902
+ }
903
+ });
767
904
 
768
- // Release: the reveal pass must stop — no more glyph draws.
769
- calls.length = 0;
905
+ it("falls back through the watchdog when no bus frames arrive although the bus is not parked", async () => {
906
+ // Hidden document / extreme jank: rAF never fires, so the animation
907
+ // bus delivers no frames, but the bus is NOT parked — exactly the
908
+ // scenario the recurring cronBus watchdog covers (the instant
909
+ // isAnimationParked branch cannot). Neutering the watchdog must
910
+ // leave this test red, or the guard has no teeth.
911
+ const textsByCanvas = new Map<HTMLCanvasElement, string[]>();
912
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
913
+ HTMLCanvasElement.prototype.getContext = (function (
914
+ this: HTMLCanvasElement,
915
+ ): CanvasRenderingContext2D {
916
+ let texts = textsByCanvas.get(this);
917
+ if (!texts) {
918
+ texts = [];
919
+ textsByCanvas.set(this, texts);
920
+ }
921
+ const t = texts;
922
+ return {
923
+ canvas: this,
924
+ clearRect: () => {},
925
+ save: () => {},
926
+ restore: () => {},
927
+ translate: () => {},
928
+ rotate: () => {},
929
+ beginPath: () => {},
930
+ arc: () => {},
931
+ fill: () => {},
932
+ fillRect: () => {},
933
+ measureText: () => ({ width: 10 }),
934
+ fillText: (text: string) => t.push(String(text)),
935
+ drawImage: () => {},
936
+ createPattern: () => ({}) as CanvasPattern,
937
+ createImageData: (w: number, h: number) => ({
938
+ data: new Uint8ClampedArray(w * h * 4),
939
+ }),
940
+ putImageData: () => {},
941
+ imageSmoothingEnabled: false,
942
+ globalCompositeOperation: "source-over",
943
+ font: "",
944
+ fillStyle: "",
945
+ textAlign: "",
946
+ textBaseline: "",
947
+ } as unknown as CanvasRenderingContext2D;
948
+ }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
949
+ // Freeze the frame bus without parking it: rAF callbacks never run.
950
+ vi.stubGlobal("requestAnimationFrame", () => 0);
951
+ vi.stubGlobal("cancelAnimationFrame", () => {});
952
+ try {
953
+ const { container } = mountPasswordInput("abc");
954
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
955
+ eye.dispatchEvent(
956
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
957
+ );
958
+ await nextTick();
959
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
960
+ // Bus not parked ⇒ the first synchronous frame is the noise pass.
961
+ expect(textsByCanvas.get(visible)).toEqual([]);
962
+ // The recurring watchdog flips to the static fallback on a bare
963
+ // timer even though no bus frame ever arrived.
964
+ await new Promise((r) => setTimeout(r, 260));
965
+ expect(textsByCanvas.get(visible)).toEqual(["a", "b", "c"]);
966
+ // Latched: later ticks must not re-draw over the fallback.
967
+ await new Promise((r) => setTimeout(r, 220));
968
+ expect(textsByCanvas.get(visible)).toEqual(["a", "b", "c"]);
969
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
970
+ await nextTick();
971
+ } finally {
972
+ vi.unstubAllGlobals();
973
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
974
+ }
975
+ });
976
+
977
+ it("degrades to the legacy jitter when canvas patterns are unavailable", async () => {
978
+ // Engines where createPattern yields null: the painter must hand
979
+ // the frame back (paint() → false) so the surface falls to the
980
+ // legacy jitter on the VISIBLE canvas. Pinning the guard matters:
981
+ // without it, paint() would keep "succeeding" and stamp the mask
982
+ // with whatever fillStyle was left on it (#fff from the raster) —
983
+ // clean white glyphs, i.e. exactly the leak this PR exists to kill.
984
+ const seen: Array<{ canvas: HTMLCanvasElement; texts: string[]; drawImages: number }> = [];
985
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
986
+ HTMLCanvasElement.prototype.getContext = (function (
987
+ this: HTMLCanvasElement,
988
+ ): CanvasRenderingContext2D {
989
+ let rec = seen.find((r) => r.canvas === this);
990
+ if (!rec) {
991
+ rec = { canvas: this, texts: [], drawImages: 0 };
992
+ seen.push(rec);
993
+ }
994
+ const r = rec;
995
+ return {
996
+ canvas: this,
997
+ clearRect: () => {},
998
+ save: () => {},
999
+ restore: () => {},
1000
+ translate: () => {},
1001
+ rotate: () => {},
1002
+ beginPath: () => {},
1003
+ arc: () => {},
1004
+ fill: () => {},
1005
+ fillRect: () => {},
1006
+ measureText: () => ({ width: 10 }),
1007
+ fillText: (text: string) => r.texts.push(String(text)),
1008
+ drawImage: () => r.drawImages++,
1009
+ // ASYMMETRIC pattern failure: only the OFFSCREEN mask canvas
1010
+ // (and the never-patterned tile) sees a null pattern; the
1011
+ // VISIBLE canvas gets a working one. The glyph-pattern guard
1012
+ // in paint() is then the only thing standing between the null
1013
+ // and a white-stamped mask — deleting that guard (and only
1014
+ // it) must send this test red, which a both-null mock cannot
1015
+ // express (the background guard would absorb the mutation).
1016
+ createPattern: () =>
1017
+ r.canvas === document.querySelector(".hk-pwd-dots")
1018
+ ? ({} as CanvasPattern)
1019
+ : null,
1020
+ createImageData: (w: number, h: number) => ({
1021
+ data: new Uint8ClampedArray(w * h * 4),
1022
+ }),
1023
+ putImageData: () => {},
1024
+ imageSmoothingEnabled: false,
1025
+ globalCompositeOperation: "source-over",
1026
+ font: "",
1027
+ fillStyle: "",
1028
+ textAlign: "",
1029
+ textBaseline: "",
1030
+ } as unknown as CanvasRenderingContext2D;
1031
+ }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
1032
+ try {
1033
+ const { container, input } = mountPasswordInput("abc");
1034
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1035
+ eye.dispatchEvent(
1036
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1037
+ );
1038
+ await nextTick();
1039
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1040
+ const vis = seen.find((r) => r.canvas === visible)!;
1041
+ // The fallback engaged: glyphs (deliberately) on the visible
1042
+ // canvas, and the noise-composite stamp never happened.
1043
+ expect(vis.texts).toEqual(["a", "b", "c"]);
1044
+ expect(vis.drawImages).toBe(0);
1045
+ expect(input.type).toBe("password");
1046
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1047
+ await nextTick();
1048
+ } finally {
1049
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
1050
+ }
1051
+ });
1052
+
1053
+ it("degrades to the legacy jitter when only the visible canvas lacks patterns", async () => {
1054
+ // The mirrored asymmetry: the VISIBLE canvas's createPattern yields
1055
+ // null while the offscreen mask's works. Now the BACKGROUND guard
1056
+ // in paint() is the only decision point — deleting it alone must
1057
+ // redden this test (the frame would keep "succeeding" with the
1058
+ // visible fill falling back to a stale fillStyle, stamping
1059
+ // noise-through-glyphs over a flat wash — structure a screenshot
1060
+ // could pick up). Together with the offscreen-null test, each
1061
+ // pattern guard is now pinned alone.
1062
+ const seen: Array<{ canvas: HTMLCanvasElement; texts: string[]; drawImages: number }> = [];
1063
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
1064
+ HTMLCanvasElement.prototype.getContext = (function (
1065
+ this: HTMLCanvasElement,
1066
+ ): CanvasRenderingContext2D {
1067
+ let rec = seen.find((r) => r.canvas === this);
1068
+ if (!rec) {
1069
+ rec = { canvas: this, texts: [], drawImages: 0 };
1070
+ seen.push(rec);
1071
+ }
1072
+ const r = rec;
1073
+ return {
1074
+ canvas: this,
1075
+ clearRect: () => {},
1076
+ save: () => {},
1077
+ restore: () => {},
1078
+ translate: () => {},
1079
+ rotate: () => {},
1080
+ beginPath: () => {},
1081
+ arc: () => {},
1082
+ fill: () => {},
1083
+ fillRect: () => {},
1084
+ measureText: () => ({ width: 10 }),
1085
+ fillText: (text: string) => r.texts.push(String(text)),
1086
+ drawImage: () => r.drawImages++,
1087
+ createPattern: () =>
1088
+ r.canvas === document.querySelector(".hk-pwd-dots")
1089
+ ? null
1090
+ : ({} as CanvasPattern),
1091
+ createImageData: (w: number, h: number) => ({
1092
+ data: new Uint8ClampedArray(w * h * 4),
1093
+ }),
1094
+ putImageData: () => {},
1095
+ imageSmoothingEnabled: false,
1096
+ globalCompositeOperation: "source-over",
1097
+ font: "",
1098
+ fillStyle: "",
1099
+ textAlign: "",
1100
+ textBaseline: "",
1101
+ } as unknown as CanvasRenderingContext2D;
1102
+ }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
1103
+ try {
1104
+ const { container } = mountPasswordInput("abc");
1105
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1106
+ eye.dispatchEvent(
1107
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1108
+ );
1109
+ await nextTick();
1110
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1111
+ const vis = seen.find((r) => r.canvas === visible)!;
1112
+ expect(vis.texts).toEqual(["a", "b", "c"]);
1113
+ expect(vis.drawImages).toBe(0);
1114
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1115
+ await nextTick();
1116
+ } finally {
1117
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
1118
+ }
1119
+ });
1120
+
1121
+ it("degrades within the hold when reduced motion is switched on mid-hold", async () => {
1122
+ // Frames already arrived (bus live), then the host parks the bus:
1123
+ // the recurring watchdog must flip the hold to the static fallback
1124
+ // even though revealFrames > 0 at the 160ms tick — the mid-hold
1125
+ // branch of isAnimationParked() coverage.
1126
+ const textsByCanvas = new Map<HTMLCanvasElement, string[]>();
1127
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
1128
+ HTMLCanvasElement.prototype.getContext = (function (
1129
+ this: HTMLCanvasElement,
1130
+ ): CanvasRenderingContext2D {
1131
+ let texts = textsByCanvas.get(this);
1132
+ if (!texts) {
1133
+ texts = [];
1134
+ textsByCanvas.set(this, texts);
1135
+ }
1136
+ const t = texts;
1137
+ return {
1138
+ canvas: this,
1139
+ clearRect: () => {},
1140
+ save: () => {},
1141
+ restore: () => {},
1142
+ translate: () => {},
1143
+ rotate: () => {},
1144
+ beginPath: () => {},
1145
+ arc: () => {},
1146
+ fill: () => {},
1147
+ fillRect: () => {},
1148
+ measureText: () => ({ width: 10 }),
1149
+ fillText: (text: string) => t.push(String(text)),
1150
+ drawImage: () => {},
1151
+ createPattern: () => ({}) as CanvasPattern,
1152
+ createImageData: (w: number, h: number) => ({
1153
+ data: new Uint8ClampedArray(w * h * 4),
1154
+ }),
1155
+ putImageData: () => {},
1156
+ imageSmoothingEnabled: false,
1157
+ globalCompositeOperation: "source-over",
1158
+ font: "",
1159
+ fillStyle: "",
1160
+ textAlign: "",
1161
+ textBaseline: "",
1162
+ } as unknown as CanvasRenderingContext2D;
1163
+ }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
1164
+ try {
1165
+ const { container } = mountPasswordInput("abc");
1166
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1167
+ eye.dispatchEvent(
1168
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1169
+ );
1170
+ await nextTick();
1171
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1172
+ // Let real bus frames flow first: past the 160ms tick the hold
1173
+ // must STILL be on the noise path (frames arrived ⇒ no flip).
1174
+ // This is what separates the mid-hold branch from the
1175
+ // no-frames branch the other test pins.
1176
+ await new Promise((r) => setTimeout(r, 260));
1177
+ expect(textsByCanvas.get(visible)).toEqual([]);
1178
+ // Park the bus MID-HOLD (this is the branch no other test hits).
1179
+ setReducedMotion(true);
1180
+ await new Promise((r) => setTimeout(r, 600));
1181
+ expect(textsByCanvas.get(visible)).toEqual(["a", "b", "c"]);
1182
+ // Latched: no re-draws on later ticks.
1183
+ await new Promise((r) => setTimeout(r, 220));
1184
+ expect(textsByCanvas.get(visible)).toEqual(["a", "b", "c"]);
770
1185
  document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
771
1186
  await nextTick();
772
- expect(calls.length).toBe(0);
773
1187
  } finally {
774
- randomSpy.mockRestore();
775
1188
  HTMLCanvasElement.prototype.getContext = originalGetContext;
1189
+ setReducedMotion(false);
776
1190
  }
777
1191
  });
778
1192
  });
@@ -58,9 +58,12 @@ export default defineComponent({
58
58
  /**
59
59
  * Right-edge affordance for `variant="password"`:
60
60
  * - "eye" (default): hold-to-reveal button. While held, the canvas
61
- * draws the password text with a per-frame random perturbation of
62
- * glyph size, baseline, rotation and color (anti-OCR) instead of
63
- * the dot matrix; release restores the dots.
61
+ * renders a counter-drifting noise kinematogram instead of the
62
+ * dot matrix: the noise through the password glyphs drifts one
63
+ * way, the background noise the opposite way — readable by
64
+ * motion to a human, pure noise (nothing to OCR) in any single
65
+ * screenshot. Reduced motion falls back to the legacy static
66
+ * per-glyph jitter; release restores the dots.
64
67
  * - "strength": the traffic-light dot (weak / fair / strong via the
65
68
  * shared `passwordLevel` classifier, overridable through
66
69
  * `strengthEvaluator`) with a localized tooltip on hover and on
@@ -250,8 +250,9 @@
250
250
  }
251
251
 
252
252
  /* Hold-to-reveal eye button — the right-edge affordance of the default
253
- * passwordTrailing="eye" mode. Press-and-hold draws the jittered canvas
254
- * text; release restores the dot matrix. */
253
+ * passwordTrailing="eye" mode. Press-and-hold renders the counter-
254
+ * drifting noise kinematogram (screenshot-safe reveal); release
255
+ * restores the dot matrix. */
255
256
  .hk-pwd-eye {
256
257
  position: absolute;
257
258
  inset-inline-end: calc(var(--space-12) - 0.25rem);