@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.
- package/package.json +1 -1
- package/src/components/HkInput.password.test.tsx +114 -88
- package/src/components/HkInput.tsx +5 -7
- package/src/components/HkNodeCanvas.base.test.tsx +246 -0
- package/src/components/HkNodeCanvas.gestures.test.tsx +158 -0
- package/src/components/HkNodeCanvas.tsx +468 -13
- package/src/components/HkPasswordSurface.tsx +20 -41
- package/src/components/nodeCanvasTypes.ts +170 -0
- package/src/components/revealKinematogram.test.ts +55 -11
- package/src/components/revealKinematogram.ts +100 -75
|
@@ -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(
|
|
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(
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
410
|
-
* theme hue survives into the spatter (the
|
|
411
|
-
* an uncomfortable cyan)
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
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
|
|
432
|
-
* sizes, fast enough that a single frame
|
|
433
|
-
* energy (
|
|
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
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
* (
|
|
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
|
|
454
|
-
* tiles differ in mean luminance along the
|
|
455
|
-
* the mask rasterizes in bold; the halo gradient is drawn
|
|
456
|
-
*
|
|
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
|
-
/**
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
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 =
|
|
484
|
+
export const FILTER_PEDESTAL_L = 26;
|
|
472
485
|
|
|
473
|
-
/** Peak alpha of the halo band (
|
|
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
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
|
|
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
|
-
/**
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
beginHold(
|
|
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
|
-
|
|
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
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
|
630
|
-
//
|
|
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
|
-
|
|
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)`);
|