@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.
@@ -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("dark");
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("dark");
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("dark");
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("dark")).not.toThrow();
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 toward the theme's visibility direction,
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 — black, white and grays only, no
410
- * theme hue survives into the spatter (the ink-colored variant read as
411
- * an uncomfortable cyan). The two anchors follow the EFFECTIVE theme:
412
- * a dark theme (light field ink) gets a near-black ground with light
413
- * gray speckle, a white halo and a BRIGHTER glyph pedestal; a light
414
- * theme (dark ink) inverts every one of those — near-white ground,
415
- * dark gray speckle, black halo, DARKER glyph pedestal. Visibility,
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: slow enough to track coherently at field
432
- * sizes, fast enough that a single frame carries no usable motion
433
- * energy (form-from-motion needs ~100–200ms of integration).
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 anchor follows the theme (near-black vs near-white); the two
454
- * tiles differ in mean luminance along the theme's pedestal direction;
455
- * the mask rasterizes in bold; the halo gradient is drawn every frame
456
- * in the theme's halo color; a pattern-less or gradient-less engine
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
- /** Effective theme the filter anchors its grayscale palette to. */
461
- export type RevealFilterTheme = "dark" | "light";
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
- /** Counter-drift speed of each layer in CSS px/s (opposite signs). */
464
- export const FILTER_DRIFT_PX_S = 84;
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 (theme-colored, at the glyph midline):
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
- /** Dark theme anchors: near-black ground, mid-light gray speckle. */
498
- const FILTER_DARK_BASE_L = 64;
499
- const FILTER_DARK_GROUND_L = 10;
500
-
501
- /** Light theme anchors: near-white ground, mid-dark gray speckle. */
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 anchored to the
534
- * theme (near-black/near-white ground), the glyph layer shifted by
535
- * the pedestal along the theme's visibility direction, randomized
536
- * phases and a randomized drift direction, so replays are never
537
- * pixel-identical and automation cannot precompute the motion. */
538
- beginHold(theme: RevealFilterTheme = "dark", dpr = 1): void {
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
- const pedestal = (theme === "dark" ? 1 : -1) * FILTER_PEDESTAL_L;
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 theme's ground (near-black over a
556
- * dark theme, near-white over a light one), then a fixed-density
557
- * scatter of NEUTRAL-GRAY dots around the theme's base lightness.
558
- * Draw order is deterministic (bg tile first, then ink) so tests can
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
- const [gr, gg, gb] = hslToRgb(0, 0, lift(groundL + pedestalL));
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(baseL + pedestalL + (Math.random() * 2 - 1) * FILTER_SPATTER_L_SPREAD);
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 color follows the theme: white over a dark ground, black
630
- // over a light one.
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
- // Endpoints derive from the SAME theme channel string as the
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)`);