@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.
- package/package.json +1 -1
- package/src/components/HkAdminHeader.tsx +4 -5
- package/src/components/HkButton.test.tsx +2 -2
- package/src/components/HkButton.tsx +45 -1
- package/src/components/HkInput.password.test.tsx +478 -64
- package/src/components/HkInput.tsx +6 -3
- package/src/components/HkPasswordSurface.scss +3 -2
- package/src/components/HkPasswordSurface.tsx +134 -18
- package/src/components/HkSelectionGrid.scss +13 -0
- package/src/components/HkSelectionGrid.test.tsx +49 -0
- package/src/components/HkSelectionGrid.tsx +25 -1
- package/src/components/revealKinematogram.test.ts +121 -0
- package/src/components/revealKinematogram.ts +293 -0
- package/src/runtime/animationBus.test.ts +18 -1
- package/src/runtime/animationBus.ts +9 -0
- package/src/styles/admin-tokens.scss +4 -22
|
@@ -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
|
-
/*
|
|
379
|
-
*
|
|
380
|
-
* the
|
|
381
|
-
|
|
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
|