@celestia-island/hikari 0.55.21 → 0.55.22
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/HkInput.password.test.tsx +51 -81
- package/src/components/HkInput.tsx +8 -9
- package/src/components/HkNodeCanvas.base.test.tsx +246 -0
- package/src/components/HkNodeCanvas.tsx +313 -13
- package/src/components/HkPasswordSurface.tsx +19 -40
- package/src/components/nodeCanvasTypes.ts +170 -0
- package/src/components/revealKinematogram.test.ts +42 -4
- package/src/components/revealKinematogram.ts +72 -60
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
FILTER_DRIFT_PX_S,
|
|
6
6
|
layoutRevealGlyphs,
|
|
7
7
|
NOISE_TILE_W,
|
|
8
|
+
parseColorTriple,
|
|
8
9
|
RevealFilterPainter,
|
|
9
10
|
RevealNoisePainter,
|
|
10
11
|
sweepWindow,
|
|
@@ -134,12 +135,49 @@ describe("RevealNoisePainter", () => {
|
|
|
134
135
|
});
|
|
135
136
|
});
|
|
136
137
|
|
|
138
|
+
describe("FILTER_DRIFT_PX_S persistence floor", () => {
|
|
139
|
+
it("keeps the counter-drift fast enough for persistence reading", () => {
|
|
140
|
+
// The readability mechanism RELIES on speed: within one
|
|
141
|
+
// persistence-of-vision window (~100ms) the texture must keep
|
|
142
|
+
// crossing dot spacings (the mean spacing is ~7px — ~2 crossings
|
|
143
|
+
// at the 150 floor, several at the shipped 300) so the static
|
|
144
|
+
// aperture interiors time-average into near-solid glyphs (R1 F7,
|
|
145
|
+
// R3 wording note).
|
|
146
|
+
expect(FILTER_DRIFT_PX_S).toBeGreaterThanOrEqual(150);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("parseColorTriple", () => {
|
|
151
|
+
it("parses computed rgb() strings without the leading-empty-token shift", () => {
|
|
152
|
+
// The bug this pins: "rgb(148, 233, 211)" used to split into a
|
|
153
|
+
// leading EMPTY token that Number() turned into 0, shifting the
|
|
154
|
+
// triple to [0, 148, 233] and skewing every derived HSL channel.
|
|
155
|
+
expect(parseColorTriple("rgb(148, 233, 211)")).toEqual([148, 233, 211]);
|
|
156
|
+
expect(parseColorTriple("rgb(255, 0, 0)")).toEqual([255, 0, 0]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("parses bare and alpha-suffixed triples, ignoring extra channels", () => {
|
|
160
|
+
expect(parseColorTriple("148 233 211")).toEqual([148, 233, 211]);
|
|
161
|
+
expect(parseColorTriple("148,233,211")).toEqual([148, 233, 211]);
|
|
162
|
+
expect(parseColorTriple("rgba(148, 233, 211, 0.5)")).toEqual([148, 233, 211]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rejects modern color functions and non-triples", () => {
|
|
166
|
+
expect(parseColorTriple("oklch(70% 0.1 200)")).toBeNull();
|
|
167
|
+
expect(parseColorTriple("oklab(0.7 0.1 200)")).toBeNull();
|
|
168
|
+
expect(parseColorTriple("lab(52% 40 59)")).toBeNull();
|
|
169
|
+
expect(parseColorTriple("color(display-p3 1 0 0)")).toBeNull();
|
|
170
|
+
expect(parseColorTriple("teal")).toBeNull();
|
|
171
|
+
expect(parseColorTriple("")).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
137
175
|
describe("RevealFilterPainter", () => {
|
|
138
176
|
it("seeds both layer phases from randomness at each hold", () => {
|
|
139
177
|
const p = new RevealFilterPainter();
|
|
140
178
|
const rand = vi.spyOn(Math, "random").mockReturnValue(0.25);
|
|
141
179
|
try {
|
|
142
|
-
p.beginHold(
|
|
180
|
+
p.beginHold();
|
|
143
181
|
// Drift-sign draw, then one phase draw per layer before retile;
|
|
144
182
|
// happy-dom has no 2d context, so the tile builds bail before
|
|
145
183
|
// consuming any more randomness.
|
|
@@ -158,7 +196,7 @@ describe("RevealFilterPainter", () => {
|
|
|
158
196
|
const p = new RevealFilterPainter();
|
|
159
197
|
const rand = vi.spyOn(Math, "random").mockReturnValue(0.25); // driftSign = +1
|
|
160
198
|
try {
|
|
161
|
-
p.beginHold(
|
|
199
|
+
p.beginHold();
|
|
162
200
|
const before = p.peekDrift();
|
|
163
201
|
p.advance(0.25, 2);
|
|
164
202
|
const after = p.peekDrift();
|
|
@@ -174,7 +212,7 @@ describe("RevealFilterPainter", () => {
|
|
|
174
212
|
const p = new RevealFilterPainter();
|
|
175
213
|
const rand = vi.spyOn(Math, "random").mockReturnValue(0.75); // driftSign = -1
|
|
176
214
|
try {
|
|
177
|
-
p.beginHold(
|
|
215
|
+
p.beginHold();
|
|
178
216
|
const before = p.peekDrift();
|
|
179
217
|
p.advance(0.25, 2);
|
|
180
218
|
const after = p.peekDrift();
|
|
@@ -188,7 +226,7 @@ describe("RevealFilterPainter", () => {
|
|
|
188
226
|
|
|
189
227
|
it("never throws without a 2d context and stays unusable (fallback path)", () => {
|
|
190
228
|
const p = new RevealFilterPainter();
|
|
191
|
-
expect(() => p.beginHold(
|
|
229
|
+
expect(() => p.beginHold()).not.toThrow();
|
|
192
230
|
expect(p.available).toBe(false);
|
|
193
231
|
});
|
|
194
232
|
});
|
|
@@ -396,7 +396,7 @@ export class RevealNoisePainter {
|
|
|
396
396
|
* password glyphs are STATIC apertures carrying a SECOND spatter
|
|
397
397
|
* texture — statistically identical (same generator, dot size and
|
|
398
398
|
* lightness distribution) but drifting the OPPOSITE way and shifted by
|
|
399
|
-
* a small lightness pedestal
|
|
399
|
+
* a small brightening lightness pedestal (white-on-black reading),
|
|
400
400
|
* with a soft halo band around the glyph row. A human segments the two
|
|
401
401
|
* layers effortlessly (motion transparency at a 180° direction
|
|
402
402
|
* difference is the strongest segregation cue the visual system has)
|
|
@@ -406,14 +406,13 @@ export class RevealNoisePainter {
|
|
|
406
406
|
* a global or adaptive threshold to plateau on, and (matched
|
|
407
407
|
* statistics) nothing for a texture classifier either.
|
|
408
408
|
*
|
|
409
|
-
* The palette is strictly GRAYSCALE
|
|
410
|
-
* theme hue survives into the spatter (the
|
|
411
|
-
* an uncomfortable cyan)
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
* not absolute brightness, is the invariant.
|
|
409
|
+
* The palette is strictly GRAYSCALE and strictly UNIFORM — black,
|
|
410
|
+
* white and grays only, no theme hue survives into the spatter (the
|
|
411
|
+
* ink-colored variant read as an uncomfortable cyan), and the anchors
|
|
412
|
+
* do NOT follow the theme: ALWAYS a near-black ground with light gray
|
|
413
|
+
* speckle, a white halo and a brighter glyph pedestal (the theme-
|
|
414
|
+
* following light variant read far worse and was dropped — a dark
|
|
415
|
+
* reveal strip inside a light page is the accepted look).
|
|
417
416
|
*
|
|
418
417
|
* Why these parameters (the failure modes of video CAPTCHAs say what
|
|
419
418
|
* to avoid — NuCAPTCHA & animated-GIF schemes died to per-frame OCR +
|
|
@@ -428,9 +427,14 @@ export class RevealNoisePainter {
|
|
|
428
427
|
* more of the counter-drifting texture inside each glyph (stronger
|
|
429
428
|
* signal under interference); the shared layout's 1.2× letter
|
|
430
429
|
* spacing absorbs the wider advances.
|
|
431
|
-
* - Drift ±FILTER_DRIFT_PX_S
|
|
432
|
-
*
|
|
433
|
-
*
|
|
430
|
+
* - Drift ±FILTER_DRIFT_PX_S, deliberately VERY fast: within one
|
|
431
|
+
* persistence-of-vision window (~100ms) the texture travels ~30px —
|
|
432
|
+
* several dot spacings (the mean spacing is ~7px) — so the aperture
|
|
433
|
+
* interior time-averages into a clean pedestal + spatter mean and
|
|
434
|
+
* the glyphs read near-solid, while the counter-motion segregation
|
|
435
|
+
* only sharpens. A single frame still carries no motion energy at
|
|
436
|
+
* all (form-from-motion needs the temporal integration that only
|
|
437
|
+
* the live eye provides).
|
|
434
438
|
* - The glyph APERTURES never move — only the texture inside them
|
|
435
439
|
* flows. Cross-frame registration of the glyph shapes (the attack
|
|
436
440
|
* that killed video CAPTCHAs) finds nothing to align.
|
|
@@ -450,28 +454,24 @@ export class RevealNoisePainter {
|
|
|
450
454
|
* Invariants the tests pin: glyph geometry NEVER touches the visible
|
|
451
455
|
* canvas (mask → `source-in` noise stamp, exactly like the noise
|
|
452
456
|
* painter); every spatter fill is a NEUTRAL gray (r === g === b); the
|
|
453
|
-
* ground
|
|
454
|
-
* tiles differ in mean luminance along the
|
|
455
|
-
* the mask rasterizes in bold; the halo gradient is drawn
|
|
456
|
-
*
|
|
457
|
+
* ground is ALWAYS the near-black anchor regardless of the theme or
|
|
458
|
+
* field ink; the two tiles differ in mean luminance along the
|
|
459
|
+
* pedestal; the mask rasterizes in bold; the halo gradient is drawn
|
|
460
|
+
* every frame in white; a pattern-less or gradient-less engine
|
|
457
461
|
* degrades to the static plain text (filter exists FOR readability —
|
|
458
462
|
* never to frozen noise). */
|
|
459
463
|
|
|
460
|
-
/**
|
|
461
|
-
|
|
464
|
+
/** Counter-drift speed of each layer in CSS px/s (opposite signs).
|
|
465
|
+
* Deliberately very fast — see the persistence rationale above. */
|
|
466
|
+
export const FILTER_DRIFT_PX_S = 300;
|
|
462
467
|
|
|
463
|
-
/**
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
/** Lightness pedestal of the glyph layer, in HSL lightness points
|
|
467
|
-
* (clamped into [L_MIN, L_MAX] like every sample). The DIRECTION
|
|
468
|
-
* follows the theme: brighter glyphs over a dark theme's ground,
|
|
469
|
-
* darker glyphs over a light theme's. Small on purpose — see the
|
|
468
|
+
/** Lightness pedestal of the glyph layer (brighter — the text reads
|
|
469
|
+
* white-on-black), in HSL lightness points (clamped into
|
|
470
|
+
* [L_MIN, L_MAX] like every sample). Small on purpose — see the
|
|
470
471
|
* strategy docblock. */
|
|
471
472
|
export const FILTER_PEDESTAL_L = 10;
|
|
472
473
|
|
|
473
|
-
/** Peak alpha of the halo band (
|
|
474
|
-
* white over a dark ground, black over a light ground. */
|
|
474
|
+
/** Peak alpha of the halo band (white, at the glyph-row midline). */
|
|
475
475
|
export const FILTER_HALO_ALPHA = 0.1;
|
|
476
476
|
|
|
477
477
|
/** Halo half-height as a multiple of the glyph font size (device px):
|
|
@@ -494,18 +494,37 @@ const FILTER_SPATTER_PX_PER_DOT = 45;
|
|
|
494
494
|
/** Dot lightness spread around the layer base (both layers share it). */
|
|
495
495
|
const FILTER_SPATTER_L_SPREAD = 22;
|
|
496
496
|
|
|
497
|
-
/**
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const FILTER_LIGHT_BASE_L = 38;
|
|
503
|
-
const FILTER_LIGHT_GROUND_L = 93;
|
|
497
|
+
/** The one anchor pair: near-black ground, mid-light gray speckle —
|
|
498
|
+
* white-on-black reading in EVERY theme (the light variant read far
|
|
499
|
+
* worse and was dropped). */
|
|
500
|
+
const FILTER_BASE_L = 64;
|
|
501
|
+
const FILTER_GROUND_L = 10;
|
|
504
502
|
|
|
505
503
|
/** Glyph aperture weight: bold strokes expose more of the counter-
|
|
506
504
|
* drifting texture inside each glyph. */
|
|
507
505
|
const FILTER_APERTURE_WEIGHT = "bold";
|
|
508
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Parse an `rgb(r, g, b)` / `rgba(...)` / bare `r g b` triple out of a
|
|
509
|
+
* color string, rejecting the modern color functions (oklch/lab/
|
|
510
|
+
* color() would split into garbage numeric triples — the caller keeps
|
|
511
|
+
* its previous base instead). Leading separators are REAL here: a
|
|
512
|
+
* computed `rgb(...)` string starts with separators and the split
|
|
513
|
+
* would yield a leading EMPTY token — `Number("") === 0` silently
|
|
514
|
+
* shifts the triple to [0, r, g] and skews every derived HSL channel
|
|
515
|
+
* (the noise painter's ink base read a light ink as 45.7% lightness
|
|
516
|
+
* before this was fixed).
|
|
517
|
+
*/
|
|
518
|
+
export function parseColorTriple(raw: string): [number, number, number] | null {
|
|
519
|
+
if (/^(oklch|oklab|lab|lch|color)\(/i.test(raw.trim())) return null;
|
|
520
|
+
const ns = raw
|
|
521
|
+
.split(/[\s,()rgba]+/)
|
|
522
|
+
.filter((t) => t !== "")
|
|
523
|
+
.map(Number)
|
|
524
|
+
.filter((n) => !isNaN(n));
|
|
525
|
+
return ns.length >= 3 ? [ns[0], ns[1], ns[2]] : null;
|
|
526
|
+
}
|
|
527
|
+
|
|
509
528
|
export class RevealFilterPainter {
|
|
510
529
|
private bgTile: HTMLCanvasElement | null = null;
|
|
511
530
|
private inkTile: HTMLCanvasElement | null = null;
|
|
@@ -514,7 +533,6 @@ export class RevealFilterPainter {
|
|
|
514
533
|
private offsetBackground = 0;
|
|
515
534
|
private offsetInk = 0;
|
|
516
535
|
private driftSign = 1;
|
|
517
|
-
private theme: RevealFilterTheme = "dark";
|
|
518
536
|
private dpr = 1;
|
|
519
537
|
private ok = true;
|
|
520
538
|
|
|
@@ -530,20 +548,18 @@ export class RevealFilterPainter {
|
|
|
530
548
|
return { background: this.offsetBackground, ink: this.offsetInk };
|
|
531
549
|
}
|
|
532
550
|
|
|
533
|
-
/** Start a hold: two FRESH grayscale spatter tiles
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
beginHold(
|
|
551
|
+
/** Start a hold: two FRESH grayscale spatter tiles on the uniform
|
|
552
|
+
* near-black ground, the glyph layer lifted by the pedestal, with
|
|
553
|
+
* randomized phases and a randomized drift direction, so replays
|
|
554
|
+
* are never pixel-identical and automation cannot precompute the
|
|
555
|
+
* motion. */
|
|
556
|
+
beginHold(dpr = 1): void {
|
|
539
557
|
this.dpr = dpr;
|
|
540
|
-
this.theme = theme;
|
|
541
558
|
this.driftSign = Math.random() < 0.5 ? 1 : -1;
|
|
542
559
|
this.offsetBackground = Math.random() * NOISE_TILE_W;
|
|
543
560
|
this.offsetInk = Math.random() * NOISE_TILE_W;
|
|
544
561
|
this.maskKey = ""; // force a mask rebuild on the first paint
|
|
545
|
-
|
|
546
|
-
this.ok = this.retile(0, "bg") && this.retile(pedestal, "ink");
|
|
562
|
+
this.ok = this.retile(0, "bg") && this.retile(FILTER_PEDESTAL_L, "ink");
|
|
547
563
|
}
|
|
548
564
|
|
|
549
565
|
advance(dt: number, dpr: number): void {
|
|
@@ -552,11 +568,10 @@ export class RevealFilterPainter {
|
|
|
552
568
|
this.offsetInk -= this.driftSign * step;
|
|
553
569
|
}
|
|
554
570
|
|
|
555
|
-
/** (Re)build one spatter tile: the
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
* attribute the per-canvas recordings. */
|
|
571
|
+
/** (Re)build one spatter tile: the ONE near-black ground anchor,
|
|
572
|
+
* then a fixed-density scatter of NEUTRAL-GRAY dots around the one
|
|
573
|
+
* base lightness. Draw order is deterministic (bg tile first, then
|
|
574
|
+
* ink) so tests can attribute the per-canvas recordings. */
|
|
560
575
|
private retile(pedestalL: number, which: "bg" | "ink"): boolean {
|
|
561
576
|
try {
|
|
562
577
|
if (typeof document === "undefined") return false;
|
|
@@ -570,19 +585,17 @@ export class RevealFilterPainter {
|
|
|
570
585
|
tile.height = NOISE_TILE_H;
|
|
571
586
|
const tctx = tile.getContext("2d");
|
|
572
587
|
if (!tctx) return false;
|
|
573
|
-
const dark = this.theme === "dark";
|
|
574
|
-
const baseL = dark ? FILTER_DARK_BASE_L : FILTER_LIGHT_BASE_L;
|
|
575
|
-
const groundL = dark ? FILTER_DARK_GROUND_L : FILTER_LIGHT_GROUND_L;
|
|
576
588
|
const lift = (v: number) => Math.min(L_MAX, Math.max(L_MIN, v));
|
|
577
|
-
// Saturation is ZERO by construction — black/white/gray only
|
|
578
|
-
|
|
589
|
+
// Saturation is ZERO by construction — black/white/gray only,
|
|
590
|
+
// on the ONE near-black ground anchor.
|
|
591
|
+
const [gr, gg, gb] = hslToRgb(0, 0, lift(FILTER_GROUND_L + pedestalL));
|
|
579
592
|
tctx.fillStyle = `rgb(${gr},${gg},${gb})`;
|
|
580
593
|
tctx.fillRect(0, 0, NOISE_TILE_W, NOISE_TILE_H);
|
|
581
594
|
const count = Math.round((NOISE_TILE_W * NOISE_TILE_H) / FILTER_SPATTER_PX_PER_DOT);
|
|
582
595
|
const rMin = FILTER_SPATTER_R_MIN_CSS * this.dpr;
|
|
583
596
|
const rMax = FILTER_SPATTER_R_MAX_CSS * this.dpr;
|
|
584
597
|
for (let i = 0; i < count; i++) {
|
|
585
|
-
const dl = lift(
|
|
598
|
+
const dl = lift(FILTER_BASE_L + pedestalL + (Math.random() * 2 - 1) * FILTER_SPATTER_L_SPREAD);
|
|
586
599
|
const [r, g, b] = hslToRgb(0, 0, dl);
|
|
587
600
|
tctx.fillStyle = `rgb(${r},${g},${b})`;
|
|
588
601
|
const rad = rMin + Math.random() * (rMax - rMin);
|
|
@@ -626,13 +639,12 @@ export class RevealFilterPainter {
|
|
|
626
639
|
// Create the halo gradient up front as well: creation draws
|
|
627
640
|
// nothing, so a bail here still leaves the visible canvas
|
|
628
641
|
// untouched (no partial frame — see the pre-check above). The
|
|
629
|
-
// halo
|
|
630
|
-
//
|
|
642
|
+
// halo is ALWAYS white — the reveal reads white-on-black in
|
|
643
|
+
// every theme — and the endpoints derive from the same channel
|
|
644
|
+
// string as the peak (no literal-compare desync trap).
|
|
631
645
|
const bandH = Math.min(H / 2, FILTER_HALO_FONT_SCALE * layout.fontPx);
|
|
632
646
|
const midY = H / 2;
|
|
633
|
-
|
|
634
|
-
// peak — no literal compare that could silently desync them.
|
|
635
|
-
const haloRGB = this.theme === "dark" ? "255,255,255" : "0,0,0";
|
|
647
|
+
const haloRGB = "255,255,255";
|
|
636
648
|
const grad = ctx.createLinearGradient(0, midY - bandH, 0, midY + bandH);
|
|
637
649
|
if (!grad) return false;
|
|
638
650
|
grad.addColorStop(0, `rgba(${haloRGB},0)`);
|