@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.
@@ -0,0 +1,293 @@
1
+ /**
2
+ * revealKinematogram — the screenshot-safe rendering behind the
3
+ * HkPasswordSurface hold-to-reveal pass (a random-dot kinematogram).
4
+ *
5
+ * Principle: the whole reveal area is filled with ONE shared noise tile,
6
+ * and the only difference between the password glyphs and the background
7
+ * is the direction their slice of that noise drifts — the noise sampled
8
+ * through the glyph mask translates one way, the background noise the
9
+ * opposite way. Human vision segments the glyphs by motion coherence;
10
+ * any SINGLE frame is statistically identical noise inside and outside
11
+ * the glyphs, so a screenshot carries no shape, edge or contrast signal
12
+ * for OCR to lock onto.
13
+ *
14
+ * The glyph mask never reaches the visible canvas as geometry: glyphs
15
+ * are rasterized into an offscreen mask whose pixels are immediately
16
+ * re-composited with noise (`source-in` on the mask itself), so the
17
+ * visible canvas only ever receives ONE background noise fill and a
18
+ * final `drawImage` of noise-on-noise. This is the invariant the
19
+ * component tests pin: no `fillText` with password content may ever
20
+ * target the visible canvas.
21
+ *
22
+ * Threat model & limits: this defeats single-frame capture (screenshots,
23
+ * scrapers, bystander photos). Motion must exist for the human to read,
24
+ * so it also exists for software: an attacker recording video and
25
+ * correlating frames can in principle recover the drift boundary. The
26
+ * reveal is hold-to-reveal and user-initiated; that residual risk is
27
+ * the accepted trade-off.
28
+ *
29
+ * Reduced motion: the shared animation bus parks, so the field would
30
+ * freeze into unreadable pure noise. HkPasswordSurface therefore checks
31
+ * the park state directly at hold start and re-checks it for the whole
32
+ * hold on a recurring bare-timer watchdog, degrading to the legacy
33
+ * static per-glyph jitter drawing instead.
34
+ */
35
+
36
+ /** One glyph's placement on the offscreen mask, in DEVICE pixels. */
37
+ export interface RevealGlyph {
38
+ ch: string;
39
+ /** Left edge of the glyph's advance box. */
40
+ x: number;
41
+ /** Advance width (the box the glyph is centered inside). */
42
+ advance: number;
43
+ }
44
+
45
+ /** A fully resolved glyph row for one password value at one canvas size. */
46
+ export interface RevealLayout {
47
+ glyphs: RevealGlyph[];
48
+ /** Font size in DEVICE px (CSS size × dpr) for a crisp mask. */
49
+ fontPx: number;
50
+ }
51
+
52
+ /** Noise tile size in device px. Wide enough that the horizontal wrap
53
+ * period stays well above a typical field width; random content makes
54
+ * the wrap seam invisible, and the tile carries no glyph information. */
55
+ export const NOISE_TILE_W = 512;
56
+ export const NOISE_TILE_H = 128;
57
+
58
+ /** Counter-drift speeds in CSS px/s (glyph noise →, background noise ←).
59
+ * Opposite directions maximize motion segregation at a relative speed
60
+ * that reads clearly without becoming dizzying at ~30fps. */
61
+ export const GLYPH_DRIFT_PX_S = 40;
62
+ export const BACKGROUND_DRIFT_PX_S = -40;
63
+
64
+ /** Lightness spread of the noise around the field's ink color. Both
65
+ * regions sample the SAME tile, so any spread is shared and a single
66
+ * frame stays statistically uniform across the glyph boundary. */
67
+ const L_SPREAD = 34;
68
+ const L_MIN = 4;
69
+ const L_MAX = 96;
70
+
71
+ /** Side margins (CSS px) kept clear of glyphs so the drifting field
72
+ * does not hide text under the lock / eye affordances. */
73
+ const SIDE_MARGIN_CSS = 14;
74
+
75
+ /** Fold an accumulated drift (device px) into the tile period: a value
76
+ * in [0, tile) usable as a pattern translation, correct for negative
77
+ * accumulators (background drifts towards −∞) and periodic in t. */
78
+ export function wrapDrift(px: number, tile: number): number {
79
+ const m = px % tile;
80
+ return m < 0 ? m + tile : m;
81
+ }
82
+
83
+ /**
84
+ * Lay the password glyphs out centered in the reveal area, scaling the
85
+ * row down (floor 0.4×) when it overflows — the same fitting contract
86
+ * the legacy jitter pass used, but WITHOUT per-frame randomness: the
87
+ * mask must stay still while only the noise through it moves.
88
+ * `measure` receives CSS-px font sizes and returns CSS-px widths.
89
+ */
90
+ export function layoutRevealGlyphs(
91
+ chars: string[],
92
+ measure: (ch: string, fontPx: number) => number,
93
+ aW: number,
94
+ aH: number,
95
+ dpr: number,
96
+ ): RevealLayout {
97
+ // Visual size matches the legacy pass: ~58% of the box height, pinned
98
+ // to a readable band regardless of box size.
99
+ const basePx = Math.min(18, Math.max(12, aH * 0.58));
100
+ const widths = chars.map((ch) => measure(ch, basePx));
101
+ const raw = widths.reduce((s, w) => s + w, 0);
102
+ const avail = Math.max(16, aW - SIDE_MARGIN_CSS * 2);
103
+ const scale = raw > avail ? Math.max(0.4, avail / raw) : 1;
104
+ const fontPx = basePx * scale * dpr;
105
+ const advanceScale = scale * dpr;
106
+ let x = (aW / 2 - (raw * scale) / 2) * dpr;
107
+ const glyphs: RevealGlyph[] = [];
108
+ for (let i = 0; i < chars.length; i++) {
109
+ const advance = widths[i]! * advanceScale;
110
+ glyphs.push({ ch: chars[i]!, x, advance });
111
+ x += advance;
112
+ }
113
+ return { glyphs, fontPx };
114
+ }
115
+
116
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
117
+ const c = (1 - Math.abs((2 * l) / 100 - 1)) * (s / 100);
118
+ const hp = (((h % 360) + 360) % 360) / 60;
119
+ const x = c * (1 - Math.abs((hp % 2) - 1));
120
+ let r = 0;
121
+ let g = 0;
122
+ let b = 0;
123
+ if (hp < 1) {
124
+ r = c;
125
+ g = x;
126
+ } else if (hp < 2) {
127
+ r = x;
128
+ g = c;
129
+ } else if (hp < 3) {
130
+ g = c;
131
+ b = x;
132
+ } else if (hp < 4) {
133
+ g = x;
134
+ b = c;
135
+ } else if (hp < 5) {
136
+ r = x;
137
+ b = c;
138
+ } else {
139
+ r = c;
140
+ b = x;
141
+ }
142
+ const m = l / 100 - c / 2;
143
+ return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
144
+ }
145
+
146
+ type Hsl = readonly [number, number, number];
147
+
148
+ /**
149
+ * Stateful painter for one password surface instance: owns the noise
150
+ * tile and the offscreen glyph mask, accumulates the two drift offsets,
151
+ * and composites one frame per animation-bus tick. All state resets per
152
+ * hold (`beginHold`): fresh noise so two holds of the same password
153
+ * never replay the same frame sequence, plus randomized drift phases.
154
+ */
155
+ export class RevealNoisePainter {
156
+ private tile: HTMLCanvasElement | null = null;
157
+ private mask: HTMLCanvasElement | null = null;
158
+ private maskKey = "";
159
+ private offsetGlyph = 0;
160
+ private offsetBackground = 0;
161
+ private ok = true;
162
+
163
+ /** False once canvas 2D is unusable (no DOM / no 2d context / no
164
+ * pattern support): the caller degrades to the legacy static jitter. */
165
+ get available(): boolean {
166
+ return this.ok;
167
+ }
168
+
169
+ /** Current accumulated drifts in device px (test/debug window). */
170
+ peekDrift(): { glyph: number; background: number } {
171
+ return { glyph: this.offsetGlyph, background: this.offsetBackground };
172
+ }
173
+
174
+ /** Start a hold: fresh noise tile around the field's ink color and
175
+ * randomized phases so replays are never pixel-identical. */
176
+ beginHold(base: Hsl): void {
177
+ this.offsetGlyph = Math.random() * NOISE_TILE_W;
178
+ this.offsetBackground = Math.random() * NOISE_TILE_W;
179
+ this.maskKey = ""; // force a mask rebuild on the first paint
180
+ this.ok = this.retile(base);
181
+ }
182
+
183
+ advance(dt: number, dpr: number): void {
184
+ this.offsetGlyph += GLYPH_DRIFT_PX_S * dpr * dt;
185
+ this.offsetBackground += BACKGROUND_DRIFT_PX_S * dpr * dt;
186
+ }
187
+
188
+ private retile(base: Hsl): boolean {
189
+ try {
190
+ if (typeof document === "undefined") return false;
191
+ this.tile ??= document.createElement("canvas");
192
+ // Size the backing store to the noise tile BEFORE putImageData:
193
+ // a fresh canvas defaults to 300×150, which would silently clip
194
+ // the 512×128 ImageData (real pattern period 300 instead of 512,
195
+ // transparent rows below 128) and desync wrapDrift's modulus
196
+ // from the actual pattern period.
197
+ this.tile.width = NOISE_TILE_W;
198
+ this.tile.height = NOISE_TILE_H;
199
+ const tctx = this.tile.getContext("2d");
200
+ if (!tctx || typeof tctx.createImageData !== "function") return false;
201
+ const img = tctx.createImageData(NOISE_TILE_W, NOISE_TILE_H);
202
+ if (!img) return false;
203
+ const data = img.data;
204
+ const [h, s, l] = base;
205
+ for (let i = 0; i < data.length; i += 4) {
206
+ const nl = Math.min(L_MAX, Math.max(L_MIN, l + (Math.random() * 2 - 1) * L_SPREAD));
207
+ const [r, g, b] = hslToRgb(h, s, nl);
208
+ data[i] = r;
209
+ data[i + 1] = g;
210
+ data[i + 2] = b;
211
+ data[i + 3] = 255;
212
+ }
213
+ tctx.putImageData(img, 0, 0);
214
+ return true;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Composite one frame: background noise translated by the background
222
+ * drift, then the glyph mask — rebuilt only when `maskKey` (value +
223
+ * canvas size) changes — re-filled with noise translated by the glyph
224
+ * drift and stamped over it. Returns false (WITHOUT latching
225
+ * `available` off — pattern-null engines retry the pattern path every
226
+ * frame by design, so a later working pattern resumes the
227
+ * kinematogram; only a thrown error latches the painter off) when the
228
+ * engine cannot support the pattern path this frame.
229
+ */
230
+ paint(
231
+ ctx: CanvasRenderingContext2D,
232
+ W: number,
233
+ H: number,
234
+ layout: RevealLayout,
235
+ monoFont: string,
236
+ maskKey: string,
237
+ ): boolean {
238
+ if (!this.tile || !this.ok) return false;
239
+ try {
240
+ if (typeof document === "undefined") return false;
241
+ this.mask ??= document.createElement("canvas");
242
+ if (this.mask.width !== W || this.mask.height !== H) {
243
+ // Assigning width/height wipes the canvas: force a rebuild.
244
+ this.mask.width = W;
245
+ this.mask.height = H;
246
+ this.maskKey = "";
247
+ }
248
+ const mctx = this.mask.getContext("2d");
249
+ if (!mctx) return false;
250
+
251
+ if (this.maskKey !== maskKey) {
252
+ mctx.globalCompositeOperation = "source-over";
253
+ mctx.clearRect(0, 0, W, H);
254
+ // Opaque white: only the ALPHA of this raster matters — it is
255
+ // fully replaced by noise on the very next step, every frame.
256
+ mctx.fillStyle = "#fff";
257
+ mctx.textAlign = "left";
258
+ mctx.textBaseline = "middle";
259
+ mctx.font = `${layout.fontPx.toFixed(2)}px ${monoFont}`;
260
+ for (const g of layout.glyphs) {
261
+ mctx.fillText(g.ch, g.x, H / 2);
262
+ }
263
+ this.maskKey = maskKey;
264
+ }
265
+
266
+ const patGlyph = mctx.createPattern(this.tile, "repeat");
267
+ if (!patGlyph) return false;
268
+ mctx.save();
269
+ mctx.globalCompositeOperation = "source-in";
270
+ mctx.imageSmoothingEnabled = false;
271
+ const og = Math.round(wrapDrift(this.offsetGlyph, NOISE_TILE_W));
272
+ mctx.translate(-og, 0);
273
+ mctx.fillStyle = patGlyph;
274
+ mctx.fillRect(og, 0, W + 2, H);
275
+ mctx.restore();
276
+
277
+ const patBg = ctx.createPattern(this.tile, "repeat");
278
+ if (!patBg) return false;
279
+ ctx.save();
280
+ ctx.imageSmoothingEnabled = false;
281
+ const ob = Math.round(wrapDrift(this.offsetBackground, NOISE_TILE_W));
282
+ ctx.translate(-ob, 0);
283
+ ctx.fillStyle = patBg;
284
+ ctx.fillRect(ob, 0, W + 2, H);
285
+ ctx.restore();
286
+ ctx.drawImage(this.mask, 0, 0);
287
+ return true;
288
+ } catch {
289
+ this.ok = false;
290
+ return false;
291
+ }
292
+ }
293
+ }
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- import { onFrame, type AnimationHandle } from "./animationBus";
3
+ import { onFrame, isAnimationParked, setReducedMotion, type AnimationHandle } from "./animationBus";
4
4
 
5
5
  // ── Fake-RAF harness ──────────────────────────────────────────────────
6
6
  // The bus arms the next frame by calling requestAnimationFrame(tick); the
@@ -110,3 +110,20 @@ describe("animationBus per-entry delta", () => {
110
110
  expect(deltas).toEqual([0.1, 0.1]);
111
111
  });
112
112
  });
113
+
114
+ describe("isAnimationParked", () => {
115
+ it("tracks the reduced-motion switch for callers that cannot wait for a frame", () => {
116
+ // Callers like the password surface's motion reveal branch on this
117
+ // BEFORE holding anything: a parked bus never delivers a frame, so
118
+ // waiting to find out would render unreadable noise in the meantime.
119
+ const original = isAnimationParked();
120
+ try {
121
+ setReducedMotion(true);
122
+ expect(isAnimationParked()).toBe(true);
123
+ setReducedMotion(false);
124
+ expect(isAnimationParked()).toBe(false);
125
+ } finally {
126
+ setReducedMotion(original);
127
+ }
128
+ });
129
+ });
@@ -246,6 +246,15 @@ export function setReducedMotion(flag: boolean) {
246
246
  ensureRuntimeReport().pulse({ paused });
247
247
  }
248
248
 
249
+ /** Whether the bus is currently parked by `setReducedMotion(true)` —
250
+ * i.e. NO frame callback will fire until motion is re-enabled. Callers
251
+ * that must render something usable when frames cannot arrive (the
252
+ * password surface's motion-based reveal) branch on this instead of
253
+ * guessing from frame-arrival timeouts. */
254
+ export function isAnimationParked(): boolean {
255
+ return paused;
256
+ }
257
+
249
258
  export function notifyScrollStart() {
250
259
  scrolling = true;
251
260
  if (scrollTimer) clearTimeout(scrollTimer);
@@ -375,28 +375,10 @@
375
375
  object-fit: cover;
376
376
  }
377
377
 
378
- /* Hover veil: the "change avatar" affordance over the trigger. The Uno
379
- * form was `group-hover:opacity-100` scoped by a `group` marker class on
380
- * the button; this descendant selector expresses the same scope. */
381
- .s-admin-header-avatar-veil {
382
- position: absolute;
383
- inset: 0;
384
- display: flex;
385
- align-items: center;
386
- justify-content: center;
387
- border-radius: var(--radius-full);
388
- background: rgb(0 0 0 / 30%);
389
- opacity: 0;
390
- transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
391
- }
392
-
393
- .s-admin-header-avatar:hover .s-admin-header-avatar-veil {
394
- opacity: 1;
395
- }
396
-
397
- .s-admin-header-veil-icon {
398
- color: #fff;
399
- }
378
+ /* The old hover camera veil ("change avatar" affordance) is gone: the
379
+ * avatar trigger opens the account MENU — the avatar edit is the Camera
380
+ * item inside it — so the veil promised an action the click never
381
+ * delivered (user report 2026-09-16). */
400
382
 
401
383
  /* The open view's title. The 1.5 line-height is load-bearing: the default
402
384
  * text-sm box is exactly the em advance, so descenders (g, y, p) fall