@celestia-island/hikari 0.55.21 → 0.55.23

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.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * nodeCanvasTypes.ts — Shared types for the HkNodeCanvas rendering base.
3
+ *
4
+ * These types are consumed by views (network topology, ladder diagram,
5
+ * mind map) that build on HkNodeCanvas. They are framework-agnostic
6
+ * (no Vue imports) so they can be used in pure computation modules too.
7
+ */
8
+
9
+ // ── Camera & Bounds (re-exported from the main component) ────────────────
10
+
11
+ export interface NodeCanvasCamera {
12
+ /** Zoom factor (1 = 1:1). */
13
+ k: number;
14
+ /** Translation of the content origin, in screen pixels. */
15
+ x: number;
16
+ /** Translation of the content origin, in screen pixels. */
17
+ y: number;
18
+ }
19
+
20
+ export interface NodeCanvasBounds {
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ }
26
+
27
+ export interface NodeCanvasSlotProps {
28
+ camera: NodeCanvasCamera;
29
+ viewport: { width: number; height: number };
30
+ contentBounds: NodeCanvasBounds | null;
31
+ }
32
+
33
+ // ── Level of Detail ──────────────────────────────────────────────────────
34
+
35
+ /** Level of detail, derived from the camera zoom factor. */
36
+ export type LodLevel = "high" | "medium" | "low";
37
+
38
+ /** Zoom thresholds: at or above `medium` shows full detail, at or above
39
+ * `low` shows outlines + labels, below `low` shows outlines only. */
40
+ export interface LodThresholds {
41
+ medium: number;
42
+ low: number;
43
+ }
44
+
45
+ export const LOD_DEFAULTS: LodThresholds = {
46
+ medium: 0.45,
47
+ low: 0.18,
48
+ };
49
+
50
+ /** Compute the LOD level from a zoom factor and thresholds. */
51
+ export function computeLod(k: number, t: LodThresholds): LodLevel {
52
+ if (k >= t.medium) return "high";
53
+ if (k >= t.low) return "medium";
54
+ return "low";
55
+ }
56
+
57
+ // ── Canvas Painter ───────────────────────────────────────────────────────
58
+
59
+ /** Context passed to canvas painters and the onFrame callback each frame. */
60
+ export interface NodeCanvasFrameContext {
61
+ camera: NodeCanvasCamera;
62
+ viewport: { width: number; height: number };
63
+ /** Milliseconds since the last frame (for animations). */
64
+ dt: number;
65
+ /** Current LOD level. */
66
+ lod: LodLevel;
67
+ /** The Canvas2D context, already transformed to world coordinates. */
68
+ ctx: CanvasRenderingContext2D;
69
+ }
70
+
71
+ /** A canvas painter: called every frame while mounted. Painters draw
72
+ * strokes, grids, pipes, edges — anything that benefits from Canvas2D
73
+ * performance. They draw in WORLD coordinates (the camera transform
74
+ * is already applied to the context). */
75
+ export interface NodeCanvasPainter {
76
+ /** Unique identifier for registration/removal. */
77
+ id: string;
78
+ /** Called every frame. Draw in world coordinates. */
79
+ draw: (frame: NodeCanvasFrameContext) => void;
80
+ /** Painters with smaller z are drawn first (default 0). */
81
+ z?: number;
82
+ }
83
+
84
+ // ── Edges ────────────────────────────────────────────────────────────────
85
+
86
+ /** How an edge is routed from source to target. */
87
+ export type EdgeRouting = "bezier" | "orthogonal" | "direct";
88
+
89
+ /** An edge between two points (or two nodes, resolved by the host). */
90
+ export interface NodeCanvasEdge {
91
+ id: string;
92
+ /** Source point in world coordinates. */
93
+ from: { x: number; y: number };
94
+ /** Target point in world coordinates. */
95
+ to: { x: number; y: number };
96
+ /** Semantic type — views can use this for styling. */
97
+ type?: string;
98
+ /** Label rendered at the edge midpoint. */
99
+ label?: string;
100
+ /** Override the global routing strategy for this edge. */
101
+ routing?: EdgeRouting;
102
+ /** Stroke color (CSS color value or CSS variable name). */
103
+ color?: string;
104
+ /** Stroke width in world units. */
105
+ width?: number;
106
+ /** Dashed line. */
107
+ dashed?: boolean;
108
+ }
109
+
110
+ // ── Edge Geometry (pure functions) ───────────────────────────────────────
111
+
112
+ /** Compute an SVG path string for an edge using the given routing. */
113
+ export function edgePath(
114
+ edge: Pick<NodeCanvasEdge, "from" | "to">,
115
+ routing: EdgeRouting,
116
+ ): string {
117
+ const { from, to } = edge;
118
+ switch (routing) {
119
+ case "direct":
120
+ return `M ${from.x} ${from.y} L ${to.x} ${to.y}`;
121
+ case "orthogonal":
122
+ return orthogonalPath(from, to);
123
+ case "bezier":
124
+ default:
125
+ return bezierPath(from, to);
126
+ }
127
+ }
128
+
129
+ /** Cubic bezier: control points at 40% horizontal offset. */
130
+ function bezierPath(
131
+ from: { x: number; y: number },
132
+ to: { x: number; y: number },
133
+ ): string {
134
+ const dx = Math.abs(to.x - from.x);
135
+ const cp = Math.max(dx * 0.4, 40);
136
+ return `M ${from.x} ${from.y} C ${from.x + cp} ${from.y}, ${to.x - cp} ${to.y}, ${to.x} ${to.y}`;
137
+ }
138
+
139
+ /** Manhattan routing: horizontal → vertical → horizontal. */
140
+ function orthogonalPath(
141
+ from: { x: number; y: number },
142
+ to: { x: number; y: number },
143
+ ): string {
144
+ const midX = (from.x + to.x) / 2;
145
+ return `M ${from.x} ${from.y} L ${midX} ${from.y} L ${midX} ${to.y} L ${to.x} ${to.y}`;
146
+ }
147
+
148
+ /** Compute the midpoint of an edge (for label placement). */
149
+ export function edgeMidpoint(
150
+ edge: Pick<NodeCanvasEdge, "from" | "to">,
151
+ routing: EdgeRouting,
152
+ ): { x: number; y: number } {
153
+ const { from, to } = edge;
154
+ if (routing === "orthogonal") {
155
+ const midX = (from.x + to.x) / 2;
156
+ return { x: midX, y: (from.y + to.y) / 2 };
157
+ }
158
+ return { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
159
+ }
160
+
161
+ // ── Print ────────────────────────────────────────────────────────────────
162
+
163
+ export type PrintPaper = "a4-landscape" | "a3-landscape" | "auto";
164
+
165
+ /** Paper dimensions in CSS pixels at 96 DPI. */
166
+ export const PAPER_SIZES: Record<PrintPaper, { width: number; height: number } | null> = {
167
+ "a4-landscape": { width: 1122.5, height: 793.7 },
168
+ "a3-landscape": { width: 1587.4, height: 1122.5 },
169
+ auto: null,
170
+ };
@@ -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,48 @@ describe("RevealNoisePainter", () => {
134
135
  });
135
136
  });
136
137
 
138
+ describe("FILTER_DRIFT_PX_S settled band", () => {
139
+ it("keeps the drift in the live-tuned moderate band", () => {
140
+ // 84 px/s is the live-tuned settled value: the 300px/s
141
+ // persistence experiment read WORSE live (no stable surface for
142
+ // the eye to bind) and was reverted — a silent re-raise must go
143
+ // red. The band allows small tuning without test edits (R1 F2).
144
+ expect(FILTER_DRIFT_PX_S).toBeGreaterThanOrEqual(60);
145
+ expect(FILTER_DRIFT_PX_S).toBeLessThanOrEqual(120);
146
+ });
147
+ });
148
+
149
+ describe("parseColorTriple", () => {
150
+ it("parses computed rgb() strings without the leading-empty-token shift", () => {
151
+ // The bug this pins: "rgb(148, 233, 211)" used to split into a
152
+ // leading EMPTY token that Number() turned into 0, shifting the
153
+ // triple to [0, 148, 233] and skewing every derived HSL channel.
154
+ expect(parseColorTriple("rgb(148, 233, 211)")).toEqual([148, 233, 211]);
155
+ expect(parseColorTriple("rgb(255, 0, 0)")).toEqual([255, 0, 0]);
156
+ });
157
+
158
+ it("parses bare and alpha-suffixed triples, ignoring extra channels", () => {
159
+ expect(parseColorTriple("148 233 211")).toEqual([148, 233, 211]);
160
+ expect(parseColorTriple("148,233,211")).toEqual([148, 233, 211]);
161
+ expect(parseColorTriple("rgba(148, 233, 211, 0.5)")).toEqual([148, 233, 211]);
162
+ });
163
+
164
+ it("rejects modern color functions and non-triples", () => {
165
+ expect(parseColorTriple("oklch(70% 0.1 200)")).toBeNull();
166
+ expect(parseColorTriple("oklab(0.7 0.1 200)")).toBeNull();
167
+ expect(parseColorTriple("lab(52% 40 59)")).toBeNull();
168
+ expect(parseColorTriple("color(display-p3 1 0 0)")).toBeNull();
169
+ expect(parseColorTriple("teal")).toBeNull();
170
+ expect(parseColorTriple("")).toBeNull();
171
+ });
172
+ });
173
+
137
174
  describe("RevealFilterPainter", () => {
138
175
  it("seeds both layer phases from randomness at each hold", () => {
139
176
  const p = new RevealFilterPainter();
140
177
  const rand = vi.spyOn(Math, "random").mockReturnValue(0.25);
141
178
  try {
142
- p.beginHold("dark");
179
+ p.beginHold();
143
180
  // Drift-sign draw, then one phase draw per layer before retile;
144
181
  // happy-dom has no 2d context, so the tile builds bail before
145
182
  // consuming any more randomness.
@@ -154,17 +191,24 @@ describe("RevealFilterPainter", () => {
154
191
  it("drifts the two layers in OPPOSITE directions at equal speed", () => {
155
192
  // The segregation cue IS the 180° direction difference: a mutation
156
193
  // sending both layers the same way (or at unequal speeds) must go
157
- // red here.
194
+ // red here. TWO probe combos whose real steps differ (42 and 21)
195
+ // so no single hardcoded step constant passes both (R1 F3: the
196
+ // old 0.25/2-only probe was blind to `const step = 42`).
158
197
  const p = new RevealFilterPainter();
159
198
  const rand = vi.spyOn(Math, "random").mockReturnValue(0.25); // driftSign = +1
160
199
  try {
161
- p.beginHold("dark");
162
- const before = p.peekDrift();
163
- p.advance(0.25, 2);
164
- const after = p.peekDrift();
165
- const step = FILTER_DRIFT_PX_S * 2 * 0.25;
166
- expect(after.background - before.background).toBeCloseTo(step, 10);
167
- expect(after.ink - before.ink).toBeCloseTo(-step, 10);
200
+ p.beginHold();
201
+ for (const [dt, dpr] of [
202
+ [0.25, 2],
203
+ [0.125, 2],
204
+ ] as const) {
205
+ const before = p.peekDrift();
206
+ p.advance(dt, dpr);
207
+ const after = p.peekDrift();
208
+ const step = FILTER_DRIFT_PX_S * dpr * dt;
209
+ expect(after.background - before.background).toBeCloseTo(step, 10);
210
+ expect(after.ink - before.ink).toBeCloseTo(-step, 10);
211
+ }
168
212
  } finally {
169
213
  rand.mockRestore();
170
214
  }
@@ -174,7 +218,7 @@ describe("RevealFilterPainter", () => {
174
218
  const p = new RevealFilterPainter();
175
219
  const rand = vi.spyOn(Math, "random").mockReturnValue(0.75); // driftSign = -1
176
220
  try {
177
- p.beginHold("dark");
221
+ p.beginHold();
178
222
  const before = p.peekDrift();
179
223
  p.advance(0.25, 2);
180
224
  const after = p.peekDrift();
@@ -188,7 +232,7 @@ describe("RevealFilterPainter", () => {
188
232
 
189
233
  it("never throws without a 2d context and stays unusable (fallback path)", () => {
190
234
  const p = new RevealFilterPainter();
191
- expect(() => p.beginHold("dark")).not.toThrow();
235
+ expect(() => p.beginHold()).not.toThrow();
192
236
  expect(p.available).toBe(false);
193
237
  });
194
238
  });
@@ -396,24 +396,24 @@ 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 large brightening lightness pedestal (live-tuned, white-on-black
400
+ * reading),
400
401
  * with a soft halo band around the glyph row. A human segments the two
401
402
  * layers effortlessly (motion transparency at a 180° direction
402
403
  * difference is the strongest segregation cue the visual system has)
403
404
  * and reads the row aided by the pedestal + halo, while any SINGLE
404
405
  * frame carries no motion at all: the glyph boundary survives only as
405
- * a small mean-luminance step inside a smooth halo ramp — nothing for
406
+ * a mean-luminance step inside a smooth halo ramp — nothing for
406
407
  * a global or adaptive threshold to plateau on, and (matched
407
408
  * statistics) nothing for a texture classifier either.
408
409
  *
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.
410
+ * The palette is strictly GRAYSCALE and strictly UNIFORM — black,
411
+ * white and grays only, no theme hue survives into the spatter (the
412
+ * ink-colored variant read as an uncomfortable cyan), and the anchors
413
+ * do NOT follow the theme: ALWAYS a near-black ground with dim gray
414
+ * speckle, a white halo and a much-brighter glyph pedestal (the theme-
415
+ * following light variant read far worse and was dropped — a dark
416
+ * reveal strip inside a light page is the accepted look).
417
417
  *
418
418
  * Why these parameters (the failure modes of video CAPTCHAs say what
419
419
  * to avoid — NuCAPTCHA & animated-GIF schemes died to per-frame OCR +
@@ -424,25 +424,37 @@ export class RevealNoisePainter {
424
424
  * and motion signal both peak near the letters' diagnostic spatial
425
425
  * band, so MODERATE noise contrast suffices (the old design's fine
426
426
  * grain + fast ±40px/s drift is exactly what made it unreadable).
427
+ * - Legibility lives in the BRIGHTNESS channel (live-tuned): the
428
+ * surround dots sit LOW (FILTER_BASE_L) on the near-black ground so
429
+ * the background reads dim, the glyph layer is lifted by a LARGE
430
+ * pedestal (FILTER_PEDESTAL_L) so the text reads bright, and the
431
+ * dots are DENSE (FILTER_SPATTER_PX_PER_DOT) so both fields read as
432
+ * textured surfaces rather than sparse speckle. The large pedestal
433
+ * is a bigger single-frame leak than the original small one — an
434
+ * accepted trade per the readability-first direction.
427
435
  * - The glyph apertures rasterize in BOLD: a heavier stroke exposes
428
436
  * more of the counter-drifting texture inside each glyph (stronger
429
437
  * signal under interference); the shared layout's 1.2× letter
430
438
  * 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).
439
+ * - Drift ±FILTER_DRIFT_PX_S: moderate — slow enough to track
440
+ * coherently at field font sizes, fast enough that a single frame
441
+ * carries no usable motion energy. (A 300px/s persistence-of-vision
442
+ * experiment read worse live: the eye never got a stable surface to
443
+ * bind, so the speed came back down and legibility moved into the
444
+ * brightness channel instead.)
434
445
  * - The glyph APERTURES never move — only the texture inside them
435
446
  * flows. Cross-frame registration of the glyph shapes (the attack
436
447
  * that killed video CAPTCHAs) finds nothing to align.
437
- * - The pedestal is deliberately SMALL: it is a static first-order cue
438
- * and therefore the one signal a single frame leaks. Kept at
439
- * FILTER_PEDESTAL_L lightness points and spread by the halo ramp, it
440
- * aids human pop-out without giving thresholding a plateau. An
441
- * attacker averaging MANY frames can in principle recover the
442
- * pedestal's DC component (same cost class as the video attack on
443
- * the noise strategy) — accepted risk, documented; consumers that
444
- * cannot accept it pick "noise" (zero static signal) or "plain"
445
- * (full readability).
448
+ * - The pedestal is deliberately LARGE (live-tuned readability
449
+ * first): it is a static first-order cue and therefore the one
450
+ * signal a single frame leaks, and the current magnitude leaks MORE
451
+ * of it than the original small one — the accepted trade for a dim
452
+ * surround and a bright text layer. The halo ramp still spreads the
453
+ * step so thresholding finds no plateau, and an attacker averaging
454
+ * MANY frames can in principle recover the pedestal's DC component
455
+ * (same cost class as the video attack on the noise strategy) —
456
+ * consumers that cannot accept this trade pick "noise" (zero static
457
+ * signal) or "plain" (full readability).
446
458
  * - The drift sign is re-randomized per hold (and both tiles are
447
459
  * freshly generated per hold) so two holds never replay the same
448
460
  * frame sequence.
@@ -450,28 +462,28 @@ export class RevealNoisePainter {
450
462
  * Invariants the tests pin: glyph geometry NEVER touches the visible
451
463
  * canvas (mask → `source-in` noise stamp, exactly like the noise
452
464
  * 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
465
+ * ground is ALWAYS the near-black anchor regardless of the theme or
466
+ * field ink; the two tiles differ in mean luminance along the
467
+ * pedestal; the mask rasterizes in bold; the halo gradient is drawn
468
+ * every frame in white; a pattern-less or gradient-less engine
457
469
  * degrades to the static plain text (filter exists FOR readability —
458
470
  * never to frozen noise). */
459
471
 
460
- /** Effective theme the filter anchors its grayscale palette to. */
461
- export type RevealFilterTheme = "dark" | "light";
462
-
463
- /** Counter-drift speed of each layer in CSS px/s (opposite signs). */
472
+ /** Counter-drift speed of each layer in CSS px/s (opposite signs).
473
+ * The 300px/s persistence experiment read WORSE live (the eye never
474
+ * got a stable surface to bind); 84 tracks coherently at field sizes
475
+ * and is the settled value. */
464
476
  export const FILTER_DRIFT_PX_S = 84;
465
477
 
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
478
+ /** Lightness pedestal of the glyph layer (brighter — the text reads
479
+ * white-on-black), in HSL lightness points (clamped into
480
+ * [L_MIN, L_MAX] like every sample). Deliberately LARGE (live-tuned
481
+ * readability-first): a dim surround and a bright text layer. The
482
+ * static single-frame leak grows with it — accepted trade, see the
470
483
  * strategy docblock. */
471
- export const FILTER_PEDESTAL_L = 10;
484
+ export const FILTER_PEDESTAL_L = 26;
472
485
 
473
- /** Peak alpha of the halo band (theme-colored, at the glyph midline):
474
- * white over a dark ground, black over a light ground. */
486
+ /** Peak alpha of the halo band (white, at the glyph-row midline). */
475
487
  export const FILTER_HALO_ALPHA = 0.1;
476
488
 
477
489
  /** Halo half-height as a multiple of the glyph font size (device px):
@@ -485,27 +497,47 @@ export const FILTER_HALO_FONT_SCALE = 1.2;
485
497
  const FILTER_SPATTER_R_MIN_CSS = 1.2;
486
498
  const FILTER_SPATTER_R_MAX_CSS = 2.8;
487
499
 
488
- /** Spatter coverage: one dot per this many tile px². BOTH layers share
489
- * the density, size and lightness distributions — matched texture
490
- * statistics are the single-frame defense; only the mean luminance
491
- * (pedestal) and the drift direction differ. */
492
- const FILTER_SPATTER_PX_PER_DOT = 45;
500
+ /** Spatter coverage: one dot per this many tile px² — DENSE
501
+ * (live-tuned) so both fields read as textured surfaces. BOTH layers
502
+ * share the density, size and lightness distributions — matched
503
+ * texture statistics are the single-frame defense; only the mean
504
+ * luminance (pedestal) and the drift direction differ. */
505
+ const FILTER_SPATTER_PX_PER_DOT = 28;
493
506
 
494
507
  /** Dot lightness spread around the layer base (both layers share it). */
495
508
  const FILTER_SPATTER_L_SPREAD = 22;
496
509
 
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;
510
+ /** The one anchor set: near-black ground + DIM gray speckle for the
511
+ * surround — white-on-black reading in EVERY theme with the brightness
512
+ * doing the work (the light variant read far worse and was dropped). */
513
+ const FILTER_BASE_L = 48;
514
+ const FILTER_GROUND_L = 10;
504
515
 
505
516
  /** Glyph aperture weight: bold strokes expose more of the counter-
506
517
  * drifting texture inside each glyph. */
507
518
  const FILTER_APERTURE_WEIGHT = "bold";
508
519
 
520
+ /**
521
+ * Parse an `rgb(r, g, b)` / `rgba(...)` / bare `r g b` triple out of a
522
+ * color string, rejecting the modern color functions (oklch/lab/
523
+ * color() would split into garbage numeric triples — the caller keeps
524
+ * its previous base instead). Leading separators are REAL here: a
525
+ * computed `rgb(...)` string starts with separators and the split
526
+ * would yield a leading EMPTY token — `Number("") === 0` silently
527
+ * shifts the triple to [0, r, g] and skews every derived HSL channel
528
+ * (the noise painter's ink base read a light ink as 45.7% lightness
529
+ * before this was fixed).
530
+ */
531
+ export function parseColorTriple(raw: string): [number, number, number] | null {
532
+ if (/^(oklch|oklab|lab|lch|color)\(/i.test(raw.trim())) return null;
533
+ const ns = raw
534
+ .split(/[\s,()rgba]+/)
535
+ .filter((t) => t !== "")
536
+ .map(Number)
537
+ .filter((n) => !isNaN(n));
538
+ return ns.length >= 3 ? [ns[0], ns[1], ns[2]] : null;
539
+ }
540
+
509
541
  export class RevealFilterPainter {
510
542
  private bgTile: HTMLCanvasElement | null = null;
511
543
  private inkTile: HTMLCanvasElement | null = null;
@@ -514,7 +546,6 @@ export class RevealFilterPainter {
514
546
  private offsetBackground = 0;
515
547
  private offsetInk = 0;
516
548
  private driftSign = 1;
517
- private theme: RevealFilterTheme = "dark";
518
549
  private dpr = 1;
519
550
  private ok = true;
520
551
 
@@ -530,20 +561,18 @@ export class RevealFilterPainter {
530
561
  return { background: this.offsetBackground, ink: this.offsetInk };
531
562
  }
532
563
 
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 {
564
+ /** Start a hold: two FRESH grayscale spatter tiles on the uniform
565
+ * near-black ground, the glyph layer lifted by the pedestal, with
566
+ * randomized phases and a randomized drift direction, so replays
567
+ * are never pixel-identical and automation cannot precompute the
568
+ * motion. */
569
+ beginHold(dpr = 1): void {
539
570
  this.dpr = dpr;
540
- this.theme = theme;
541
571
  this.driftSign = Math.random() < 0.5 ? 1 : -1;
542
572
  this.offsetBackground = Math.random() * NOISE_TILE_W;
543
573
  this.offsetInk = Math.random() * NOISE_TILE_W;
544
574
  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");
575
+ this.ok = this.retile(0, "bg") && this.retile(FILTER_PEDESTAL_L, "ink");
547
576
  }
548
577
 
549
578
  advance(dt: number, dpr: number): void {
@@ -552,11 +581,10 @@ export class RevealFilterPainter {
552
581
  this.offsetInk -= this.driftSign * step;
553
582
  }
554
583
 
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. */
584
+ /** (Re)build one spatter tile: the ONE near-black ground anchor,
585
+ * then a fixed-density scatter of NEUTRAL-GRAY dots around the one
586
+ * base lightness. Draw order is deterministic (bg tile first, then
587
+ * ink) so tests can attribute the per-canvas recordings. */
560
588
  private retile(pedestalL: number, which: "bg" | "ink"): boolean {
561
589
  try {
562
590
  if (typeof document === "undefined") return false;
@@ -570,19 +598,17 @@ export class RevealFilterPainter {
570
598
  tile.height = NOISE_TILE_H;
571
599
  const tctx = tile.getContext("2d");
572
600
  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
601
  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));
602
+ // Saturation is ZERO by construction — black/white/gray only,
603
+ // on the ONE near-black ground anchor.
604
+ const [gr, gg, gb] = hslToRgb(0, 0, lift(FILTER_GROUND_L + pedestalL));
579
605
  tctx.fillStyle = `rgb(${gr},${gg},${gb})`;
580
606
  tctx.fillRect(0, 0, NOISE_TILE_W, NOISE_TILE_H);
581
607
  const count = Math.round((NOISE_TILE_W * NOISE_TILE_H) / FILTER_SPATTER_PX_PER_DOT);
582
608
  const rMin = FILTER_SPATTER_R_MIN_CSS * this.dpr;
583
609
  const rMax = FILTER_SPATTER_R_MAX_CSS * this.dpr;
584
610
  for (let i = 0; i < count; i++) {
585
- const dl = lift(baseL + pedestalL + (Math.random() * 2 - 1) * FILTER_SPATTER_L_SPREAD);
611
+ const dl = lift(FILTER_BASE_L + pedestalL + (Math.random() * 2 - 1) * FILTER_SPATTER_L_SPREAD);
586
612
  const [r, g, b] = hslToRgb(0, 0, dl);
587
613
  tctx.fillStyle = `rgb(${r},${g},${b})`;
588
614
  const rad = rMin + Math.random() * (rMax - rMin);
@@ -626,13 +652,12 @@ export class RevealFilterPainter {
626
652
  // Create the halo gradient up front as well: creation draws
627
653
  // nothing, so a bail here still leaves the visible canvas
628
654
  // 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.
655
+ // halo is ALWAYS white — the reveal reads white-on-black in
656
+ // every theme — and the endpoints derive from the same channel
657
+ // string as the peak (no literal-compare desync trap).
631
658
  const bandH = Math.min(H / 2, FILTER_HALO_FONT_SCALE * layout.fontPx);
632
659
  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";
660
+ const haloRGB = "255,255,255";
636
661
  const grad = ctx.createLinearGradient(0, midY - bandH, 0, midY + bandH);
637
662
  if (!grad) return false;
638
663
  grad.addColorStop(0, `rgba(${haloRGB},0)`);