@celestia-island/hikari 0.55.12 → 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.
- package/package.json +1 -1
- package/src/components/HkAdminHeader.tsx +4 -5
- package/src/components/HkButton.test.tsx +2 -2
- package/src/components/HkButton.tsx +45 -1
- package/src/components/HkInput.password.test.tsx +478 -64
- package/src/components/HkInput.tsx +6 -3
- package/src/components/HkPasswordSurface.scss +3 -2
- package/src/components/HkPasswordSurface.tsx +134 -18
- package/src/components/HkSelectionGrid.scss +13 -0
- package/src/components/HkSelectionGrid.test.tsx +49 -0
- package/src/components/HkSelectionGrid.tsx +25 -1
- package/src/components/revealKinematogram.test.ts +121 -0
- package/src/components/revealKinematogram.ts +293 -0
- package/src/runtime/animationBus.test.ts +18 -1
- package/src/runtime/animationBus.ts +9 -0
- package/src/styles/admin-tokens.scss +4 -22
|
@@ -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
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
// silently pass the suite
|
|
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("
|
|
697
|
-
// Recording canvas
|
|
698
|
-
// drawing path never runs there
|
|
699
|
-
//
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
727
|
-
|
|
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
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
const
|
|
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
|
-
|
|
751
|
-
|
|
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
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
769
|
-
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* the
|
|
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
|
|
254
|
-
*
|
|
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);
|