@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
|
@@ -14,14 +14,19 @@ import {
|
|
|
14
14
|
import { useI18n } from "../i18n/context";
|
|
15
15
|
import { credentialAutocomplete } from "../runtime/credentialAutofill";
|
|
16
16
|
|
|
17
|
-
import { onFrame, onceFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
18
|
-
import { scheduleCronAfter, type CronHandle } from "../runtime/cronBus";
|
|
17
|
+
import { onFrame, onceFrame, isAnimationParked, type AnimationHandle } from "../runtime/animationBus";
|
|
18
|
+
import { scheduleCron, scheduleCronAfter, type CronHandle } from "../runtime/cronBus";
|
|
19
19
|
import { scheduleInterval, type IntervalHandle } from "../runtime/intervalBus";
|
|
20
20
|
import { passwordLevel, type PasswordLevel, type PasswordStrengthEvaluator } from "../utils/password";
|
|
21
21
|
|
|
22
22
|
import HListTransition from "./HkListTransition";
|
|
23
23
|
import HkTooltip from "./HkTooltip";
|
|
24
24
|
import { HkPlaceholderMarquee, type PlaceholderVariant } from "./HkPlaceholderMarquee";
|
|
25
|
+
import {
|
|
26
|
+
layoutRevealGlyphs,
|
|
27
|
+
RevealNoisePainter,
|
|
28
|
+
type RevealLayout,
|
|
29
|
+
} from "./revealKinematogram";
|
|
25
30
|
import "./HkPasswordSurface.scss";
|
|
26
31
|
import { drawnScale } from "../composables/layoutGeometry";
|
|
27
32
|
|
|
@@ -44,10 +49,15 @@ interface Ripple {
|
|
|
44
49
|
*
|
|
45
50
|
* Right-edge affordance (`passwordTrailing`):
|
|
46
51
|
* - "eye" (default): a hold-to-reveal button. While held the canvas
|
|
47
|
-
* stops drawing the dot matrix and
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
52
|
+
* stops drawing the dot matrix and renders a counter-drifting noise
|
|
53
|
+
* kinematogram instead (see revealKinematogram.ts): one shared noise
|
|
54
|
+
* tile, with the noise sampled through the password glyphs drifting
|
|
55
|
+
* one way and the background noise the opposite way. A human reads
|
|
56
|
+
* the glyphs off the motion, while any single frame — a screenshot —
|
|
57
|
+
* is pure noise with no glyph structure for OCR to lock onto. When
|
|
58
|
+
* the animation bus is parked (reduced motion) or the engine cannot
|
|
59
|
+
* run the pattern path, the reveal degrades to the legacy static
|
|
60
|
+
* per-glyph jitter drawing.
|
|
51
61
|
* - "strength": the traffic-light dot (weak / fair / strong) with a
|
|
52
62
|
* localized tooltip on hover AND on touch tap.
|
|
53
63
|
* - "none": no affordance at all.
|
|
@@ -105,6 +115,22 @@ export default defineComponent({
|
|
|
105
115
|
const composing = ref(false);
|
|
106
116
|
const preComposeValue = ref("");
|
|
107
117
|
const revealing = ref(false);
|
|
118
|
+
// Hold-to-reveal rendering state (see revealKinematogram.ts): the
|
|
119
|
+
// painter owns the noise tile + offscreen glyph mask; revealFrames
|
|
120
|
+
// counts bus-driven frames so the watchdog can tell a parked
|
|
121
|
+
// animation bus (reduced motion) from a live one; the layout memo
|
|
122
|
+
// keeps measureText off the per-frame path (inputs compared as
|
|
123
|
+
// fields, the string key built only on an actual rebuild).
|
|
124
|
+
const revealNoise = new RevealNoisePainter();
|
|
125
|
+
let revealFrames = 0;
|
|
126
|
+
let revealLayoutValue = "";
|
|
127
|
+
let revealLayoutW = -1;
|
|
128
|
+
let revealLayoutH = -1;
|
|
129
|
+
let revealLayoutMono = "";
|
|
130
|
+
let revealStaticFallback = false;
|
|
131
|
+
let revealWatchdog: CronHandle | null = null;
|
|
132
|
+
let revealLayout: RevealLayout | null = null;
|
|
133
|
+
let revealLayoutKey = "";
|
|
108
134
|
const pendingClear = ref(false);
|
|
109
135
|
// Flipped by the opt-in marquee overlay when the placeholder actually
|
|
110
136
|
// overflows — the static text below is then hidden so the scrolling
|
|
@@ -320,13 +346,73 @@ export default defineComponent({
|
|
|
320
346
|
}
|
|
321
347
|
|
|
322
348
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
349
|
+
* Resolve (and memoize by value + canvas size + mono stack) the
|
|
350
|
+
* glyph layout for the noise reveal. Measuring text is a per-hold
|
|
351
|
+
* cost, not a per-frame one: the mask must stay perfectly still
|
|
352
|
+
* while only the noise sampled through it drifts. The mono stack is
|
|
353
|
+
* part of the key so a theme/font change BETWEEN holds (same value,
|
|
354
|
+
* same size) rebuilds the layout and the mask instead of reusing a
|
|
355
|
+
* stale raster in the old font.
|
|
356
|
+
*/
|
|
357
|
+
function revealLayoutFor(ctx: CanvasRenderingContext2D, W: number, H: number): RevealLayout | null {
|
|
358
|
+
const value = props.modelValue;
|
|
359
|
+
if (!value) return null;
|
|
360
|
+
const mono = cachedMonoFont || syncMonoFont();
|
|
361
|
+
if (
|
|
362
|
+
!revealLayout ||
|
|
363
|
+
revealLayoutValue !== value ||
|
|
364
|
+
revealLayoutW !== W ||
|
|
365
|
+
revealLayoutH !== H ||
|
|
366
|
+
revealLayoutMono !== mono
|
|
367
|
+
) {
|
|
368
|
+
revealLayout = layoutRevealGlyphs(
|
|
369
|
+
Array.from(value),
|
|
370
|
+
(ch, fontPx) => {
|
|
371
|
+
ctx.font = `${fontPx}px ${mono}`;
|
|
372
|
+
return ctx.measureText(ch).width;
|
|
373
|
+
},
|
|
374
|
+
W / dpr,
|
|
375
|
+
H / dpr,
|
|
376
|
+
dpr,
|
|
377
|
+
);
|
|
378
|
+
revealLayoutValue = value;
|
|
379
|
+
revealLayoutW = W;
|
|
380
|
+
revealLayoutH = H;
|
|
381
|
+
revealLayoutMono = mono;
|
|
382
|
+
// JSON encoding: delimiter-unambiguous even for adversarial
|
|
383
|
+
// font stacks, and only rebuilt when the layout actually is.
|
|
384
|
+
revealLayoutKey = JSON.stringify([value, W, H, mono]);
|
|
385
|
+
}
|
|
386
|
+
return revealLayout;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Motion reveal pass (the default): one frame of the counter-
|
|
391
|
+
* drifting noise kinematogram — background noise translated by the
|
|
392
|
+
* accumulated background drift, then the (offscreen) glyph mask
|
|
393
|
+
* re-composited with noise translated the opposite way. The glyph
|
|
394
|
+
* geometry itself never touches the visible canvas, so a single
|
|
395
|
+
* frame is pure noise. Returns false when the pattern path is
|
|
396
|
+
* unavailable, handing the frame to the legacy jitter fallback.
|
|
397
|
+
*/
|
|
398
|
+
function drawRevealNoise(ctx: CanvasRenderingContext2D, W: number, H: number, dt: number): boolean {
|
|
399
|
+
const layout = revealLayoutFor(ctx, W, H);
|
|
400
|
+
if (!layout) return true; // empty value: nothing to reveal at all
|
|
401
|
+
revealNoise.advance(dt, dpr);
|
|
402
|
+
return revealNoise.paint(ctx, W, H, layout, cachedMonoFont || syncMonoFont(), revealLayoutKey);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Legacy anti-OCR fallback, used only when frames cannot drive the
|
|
407
|
+
* kinematogram (parked animation bus — reduced motion — or an
|
|
408
|
+
* engine without canvas patterns): the password is drawn as text
|
|
409
|
+
* with every glyph re-randomized per frame — size, baseline,
|
|
410
|
+
* rotation and color all wobble — so automated recognition never
|
|
411
|
+
* gets a stable target. A parked bus renders exactly ONE such
|
|
412
|
+
* frame per hold; with patterns unavailable it degrades to the old
|
|
413
|
+
* per-frame behavior.
|
|
328
414
|
*/
|
|
329
|
-
function
|
|
415
|
+
function drawRevealJitterText(ctx: CanvasRenderingContext2D, W: number, H: number) {
|
|
330
416
|
const value = props.modelValue;
|
|
331
417
|
if (!value) return;
|
|
332
418
|
const aW = W / dpr;
|
|
@@ -382,7 +468,8 @@ export default defineComponent({
|
|
|
382
468
|
ctx.clearRect(0, 0, W, H);
|
|
383
469
|
|
|
384
470
|
if (revealing.value) {
|
|
385
|
-
|
|
471
|
+
if (!revealStaticFallback && drawRevealNoise(ctx, W, H, dt)) return;
|
|
472
|
+
drawRevealJitterText(ctx, W, H);
|
|
386
473
|
return;
|
|
387
474
|
}
|
|
388
475
|
|
|
@@ -452,8 +539,10 @@ export default defineComponent({
|
|
|
452
539
|
// priority so the reduced-motion switch parks it like every other
|
|
453
540
|
// JS-driven animation. The draw callback clamps the delta exactly
|
|
454
541
|
// like the old self-scheduling rAF loop did. While revealing, the
|
|
455
|
-
// same loop
|
|
542
|
+
// same loop advances the noise kinematogram (and counts its
|
|
543
|
+
// frames for the parked-bus watchdog in startReveal).
|
|
456
544
|
loopHandle = onFrame((ctx) => {
|
|
545
|
+
if (revealing.value) revealFrames++;
|
|
457
546
|
draw(ctx.delta); // the bus already clamps per-entry delta to MAX_DELTA
|
|
458
547
|
}, "normal");
|
|
459
548
|
}
|
|
@@ -475,12 +564,37 @@ export default defineComponent({
|
|
|
475
564
|
if (!props.modelValue || props.disabled) return;
|
|
476
565
|
syncTextHsl();
|
|
477
566
|
syncMonoFont();
|
|
567
|
+
revealStaticFallback = false;
|
|
568
|
+
revealFrames = 0;
|
|
569
|
+
revealNoise.beginHold(textHsl);
|
|
478
570
|
revealing.value = true;
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
// preference
|
|
571
|
+
// A parked bus (reduced motion) will never deliver a frame, so
|
|
572
|
+
// the kinematogram would freeze into unreadable pure noise —
|
|
573
|
+
// degrade immediately to the legacy static jitter drawing.
|
|
574
|
+
// Motion-sensitive users keep their preference and the reveal
|
|
575
|
+
// stays usable.
|
|
576
|
+
if (isAnimationParked()) {
|
|
577
|
+
revealStaticFallback = true;
|
|
578
|
+
}
|
|
579
|
+
// Paint one synchronous frame so the reveal appears instantly;
|
|
580
|
+
// the bus takes over from the next tick.
|
|
483
581
|
draw(0);
|
|
582
|
+
// Recurring belt-and-suspenders watchdog (cronBus on purpose:
|
|
583
|
+
// bare timers fire even when the rAF-driven bus is parked or
|
|
584
|
+
// throttled). While held it degrades to the static fallback as
|
|
585
|
+
// soon as the hold is undrivable: the bus parked — possibly
|
|
586
|
+
// MID-hold, reduced motion flipped on after frames already
|
|
587
|
+
// arrived — or no bus frame ever arrived at all (hidden
|
|
588
|
+
// document, extreme jank). The fallback latches for the rest of
|
|
589
|
+
// the hold; the next hold re-probes from scratch.
|
|
590
|
+
revealWatchdog?.disconnect();
|
|
591
|
+
revealWatchdog = scheduleCron(() => {
|
|
592
|
+
if (!revealing.value || revealStaticFallback) return;
|
|
593
|
+
if (isAnimationParked() || revealFrames === 0) {
|
|
594
|
+
revealStaticFallback = true;
|
|
595
|
+
draw(0);
|
|
596
|
+
}
|
|
597
|
+
}, 160);
|
|
484
598
|
document.addEventListener("pointerup", endReveal, { once: true });
|
|
485
599
|
document.addEventListener("pointercancel", endReveal, { once: true });
|
|
486
600
|
}
|
|
@@ -488,6 +602,8 @@ export default defineComponent({
|
|
|
488
602
|
function endReveal() {
|
|
489
603
|
if (!revealing.value) return;
|
|
490
604
|
revealing.value = false;
|
|
605
|
+
revealWatchdog?.disconnect();
|
|
606
|
+
revealWatchdog = null;
|
|
491
607
|
draw(0);
|
|
492
608
|
document.removeEventListener("pointerup", endReveal);
|
|
493
609
|
document.removeEventListener("pointercancel", endReveal);
|
|
@@ -28,6 +28,19 @@
|
|
|
28
28
|
&[data-cols="3"] { grid-template-columns: repeat(3, 1fr); }
|
|
29
29
|
&[data-cols="4"] { grid-template-columns: repeat(4, 1fr); }
|
|
30
30
|
|
|
31
|
+
/* Adaptive floor mode (minItemWidth prop): the column count derives from
|
|
32
|
+
* the available width — every track is at least the floor, and hosts
|
|
33
|
+
* wide enough for one more column simply get it. The min(..., 100%)
|
|
34
|
+
* guard keeps a lone track capped at the container width, so nothing
|
|
35
|
+
* overflows below one floor of space. Fluid grids emit no data-cols, so
|
|
36
|
+
* the narrow-viewport overrides further down never fight this rule. */
|
|
37
|
+
&[data-fluid] {
|
|
38
|
+
grid-template-columns: repeat(
|
|
39
|
+
auto-fill,
|
|
40
|
+
minmax(min(var(--hk-selection-grid-item-min, 240px), 100%), 1fr)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
&[data-dense] {
|
|
32
45
|
gap: var(--space-6, 0.375rem);
|
|
33
46
|
|
|
@@ -12,6 +12,10 @@ import HkSelectionGrid, { type SelectionGridItem } from "./HkSelectionGrid";
|
|
|
12
12
|
* - `select` fires for already-selected cards too — the consumer owns the
|
|
13
13
|
* toggle, the grid only reports the click
|
|
14
14
|
* - the optional `hint` renders under the grid and is absent when unset
|
|
15
|
+
* - `minItemWidth` flips the grid into adaptive floor mode: `data-fluid` +
|
|
16
|
+
* the floor as a CSS var, and NO `data-cols` hook (the narrow-viewport
|
|
17
|
+
* overrides key off data-cols and must not fight the fluid template)
|
|
18
|
+
* - without `minItemWidth` the fixed `columns` contract is unchanged
|
|
15
19
|
*
|
|
16
20
|
* House style: raw createApp mounts on a shared container list torn down
|
|
17
21
|
* after each case; DOM assertions via document queries.
|
|
@@ -117,3 +121,48 @@ describe("HkSelectionGrid hint", () => {
|
|
|
117
121
|
expect(container.querySelector(".hk-selection-grid-hint")).toBeNull();
|
|
118
122
|
});
|
|
119
123
|
});
|
|
124
|
+
|
|
125
|
+
describe("HkSelectionGrid adaptive floor mode (minItemWidth)", () => {
|
|
126
|
+
function gridEl(container: HTMLElement) {
|
|
127
|
+
return container.querySelector<HTMLElement>(".hk-selection-grid-grid")!;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
it("switches the grid to fluid mode and publishes the floor as a CSS var", () => {
|
|
131
|
+
const container = mount(h(HkSelectionGrid, { items: ITEMS, minItemWidth: 240 }));
|
|
132
|
+
const grid = gridEl(container);
|
|
133
|
+
expect(grid.hasAttribute("data-fluid")).toBe(true);
|
|
134
|
+
expect(grid.style.getPropertyValue("--hk-selection-grid-item-min")).toBe("240px");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("owns the track template: no data-cols hook the narrow overrides could flip", () => {
|
|
138
|
+
const container = mount(
|
|
139
|
+
h(HkSelectionGrid, { items: ITEMS, minItemWidth: 240, columns: 4 }),
|
|
140
|
+
);
|
|
141
|
+
const grid = gridEl(container);
|
|
142
|
+
expect(grid.hasAttribute("data-cols")).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("stays in fixed-columns mode when minItemWidth is unset", () => {
|
|
146
|
+
const container = mount(h(HkSelectionGrid, { items: ITEMS, columns: 3 }));
|
|
147
|
+
const grid = gridEl(container);
|
|
148
|
+
expect(grid.hasAttribute("data-fluid")).toBe(false);
|
|
149
|
+
expect(grid.getAttribute("data-cols")).toBe("3");
|
|
150
|
+
expect(grid.style.getPropertyValue("--hk-selection-grid-item-min")).toBe("");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("renders every item in fluid mode like in fixed mode", () => {
|
|
154
|
+
const container = mount(h(HkSelectionGrid, { items: ITEMS, minItemWidth: 240 }));
|
|
155
|
+
expect(cards(container).length).toBe(ITEMS.length);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("ignores floors that cannot form a track minimum (0 / negative / NaN)", () => {
|
|
159
|
+
for (const bad of [0, -240, Number.NaN]) {
|
|
160
|
+
const container = mount(
|
|
161
|
+
h(HkSelectionGrid, { items: ITEMS, minItemWidth: bad, columns: 3 }),
|
|
162
|
+
);
|
|
163
|
+
const grid = gridEl(container);
|
|
164
|
+
expect(grid.hasAttribute("data-fluid")).toBe(false);
|
|
165
|
+
expect(grid.getAttribute("data-cols")).toBe("3");
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -25,6 +25,15 @@ export default defineComponent({
|
|
|
25
25
|
},
|
|
26
26
|
multi: { type: Boolean, default: false },
|
|
27
27
|
columns: { type: Number as PropType<SelectionGridCols>, default: 2 },
|
|
28
|
+
/** Adaptive floor mode: when set (px), the column count is DERIVED from
|
|
29
|
+
* the available width instead of the fixed `columns` number — every
|
|
30
|
+
* track is at least this wide, so a wide host (e.g. an 896px modal)
|
|
31
|
+
* lays three 240px options per row while narrow hosts step down to
|
|
32
|
+
* two, then one, without a horizontal overflow at any width. Takes
|
|
33
|
+
* precedence over `columns` (which stays the contract for hosts that
|
|
34
|
+
* want an exact count). Values that cannot form a floor (non-finite
|
|
35
|
+
* or ≤ 0) are ignored and fall back to fixed `columns`. */
|
|
36
|
+
minItemWidth: { type: Number, default: undefined },
|
|
28
37
|
groupTitle: { type: String, default: undefined },
|
|
29
38
|
hint: { type: String, default: undefined },
|
|
30
39
|
dense: { type: Boolean, default: false },
|
|
@@ -36,6 +45,14 @@ export default defineComponent({
|
|
|
36
45
|
return () => {
|
|
37
46
|
if (!props.items.length) return null;
|
|
38
47
|
|
|
48
|
+
// A floor must be a usable track minimum: non-finite or non-positive
|
|
49
|
+
// values fall back to the fixed `columns` contract instead of
|
|
50
|
+
// producing a degenerate (0px → dozens of tracks) or invalid grid.
|
|
51
|
+
const fluid =
|
|
52
|
+
props.minItemWidth != null &&
|
|
53
|
+
Number.isFinite(props.minItemWidth) &&
|
|
54
|
+
props.minItemWidth > 0;
|
|
55
|
+
|
|
39
56
|
return (
|
|
40
57
|
<div class="hk-selection-grid">
|
|
41
58
|
{props.groupTitle && (
|
|
@@ -43,8 +60,15 @@ export default defineComponent({
|
|
|
43
60
|
)}
|
|
44
61
|
<div
|
|
45
62
|
class="hk-selection-grid-grid"
|
|
46
|
-
|
|
63
|
+
// Fluid mode owns the track template (auto-fill with the floor),
|
|
64
|
+
// so the data-cols hooks — and their narrow-viewport overrides —
|
|
65
|
+
// stay out of its way entirely.
|
|
66
|
+
data-cols={fluid ? undefined : props.columns}
|
|
67
|
+
data-fluid={fluid || undefined}
|
|
47
68
|
data-dense={props.dense || undefined}
|
|
69
|
+
style={fluid
|
|
70
|
+
? ({ "--hk-selection-grid-item-min": `${props.minItemWidth}px` })
|
|
71
|
+
: undefined}
|
|
48
72
|
>
|
|
49
73
|
{props.items.map((item) => {
|
|
50
74
|
const isSelected = props.multi
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BACKGROUND_DRIFT_PX_S,
|
|
5
|
+
GLYPH_DRIFT_PX_S,
|
|
6
|
+
layoutRevealGlyphs,
|
|
7
|
+
NOISE_TILE_W,
|
|
8
|
+
RevealNoisePainter,
|
|
9
|
+
wrapDrift,
|
|
10
|
+
} from "./revealKinematogram";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pure-contract tests for the noise kinematogram behind the password
|
|
14
|
+
* hold-to-reveal pass: drift wrapping/accumulation (the motion that
|
|
15
|
+
* makes glyphs readable) and the glyph layout math (the mask that must
|
|
16
|
+
* stay still). The screenshot-safety invariant itself — no glyph ever
|
|
17
|
+
* drawn on the visible canvas — is pinned in HkInput.password.test.tsx.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
describe("wrapDrift", () => {
|
|
21
|
+
it("folds any accumulator into [0, tile) and stays periodic", () => {
|
|
22
|
+
for (const px of [0, 5, 511.5, 512, 1024.25, -1, -13, -40.75, -2048]) {
|
|
23
|
+
const w = wrapDrift(px, NOISE_TILE_W);
|
|
24
|
+
expect(w).toBeGreaterThanOrEqual(0);
|
|
25
|
+
expect(w).toBeLessThan(NOISE_TILE_W);
|
|
26
|
+
expect(w).toBeCloseTo(((px % NOISE_TILE_W) + NOISE_TILE_W) % NOISE_TILE_W, 10);
|
|
27
|
+
}
|
|
28
|
+
expect(wrapDrift(13, NOISE_TILE_W)).toBe(wrapDrift(13 + NOISE_TILE_W * 7, NOISE_TILE_W));
|
|
29
|
+
// The background drifts towards -∞: its wrap must stay positive.
|
|
30
|
+
expect(wrapDrift(-13, NOISE_TILE_W)).toBe(NOISE_TILE_W - 13);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("layoutRevealGlyphs", () => {
|
|
35
|
+
const measure10 = (_ch: string, _px: number) => 10;
|
|
36
|
+
|
|
37
|
+
it("keeps surrogate pairs as single glyphs", () => {
|
|
38
|
+
const l = layoutRevealGlyphs(Array.from("😀ab"), measure10, 300, 40, 2);
|
|
39
|
+
expect(l.glyphs.map((g) => g.ch)).toEqual(["😀", "a", "b"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("centers a fitting row without scaling (device-px coordinates)", () => {
|
|
43
|
+
// basePx clamps to 18 at a 40px-tall box; raw 30 < avail 272 → no
|
|
44
|
+
// shrink; the row is centered and advances are dpr-scaled.
|
|
45
|
+
const l = layoutRevealGlyphs(["a", "b", "c"], measure10, 300, 40, 2);
|
|
46
|
+
expect(l.fontPx).toBe(36);
|
|
47
|
+
expect(l.glyphs.map((g) => g.x)).toEqual([270, 290, 310]);
|
|
48
|
+
expect(l.glyphs.every((g) => g.advance === 20)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("scales an overflowing row down with a 0.4 floor", () => {
|
|
52
|
+
const narrow = layoutRevealGlyphs(
|
|
53
|
+
Array.from("x".repeat(40)),
|
|
54
|
+
measure10,
|
|
55
|
+
300,
|
|
56
|
+
40,
|
|
57
|
+
1,
|
|
58
|
+
);
|
|
59
|
+
// raw 400 > avail 272 → scale 0.68.
|
|
60
|
+
expect(narrow.fontPx).toBeCloseTo(18 * 0.68, 10);
|
|
61
|
+
const extreme = layoutRevealGlyphs(
|
|
62
|
+
Array.from("x".repeat(200)),
|
|
63
|
+
measure10,
|
|
64
|
+
300,
|
|
65
|
+
40,
|
|
66
|
+
1,
|
|
67
|
+
);
|
|
68
|
+
expect(extreme.fontPx).toBeCloseTo(18 * 0.4, 10);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps the visual size band regardless of box height", () => {
|
|
72
|
+
const tiny = layoutRevealGlyphs(["a"], measure10, 300, 8, 1);
|
|
73
|
+
const huge = layoutRevealGlyphs(["a"], measure10, 300, 400, 1);
|
|
74
|
+
expect(tiny.fontPx).toBe(12);
|
|
75
|
+
expect(huge.fontPx).toBe(18);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns an empty layout for an empty row", () => {
|
|
79
|
+
const l = layoutRevealGlyphs([], measure10, 300, 40, 2);
|
|
80
|
+
expect(l.glyphs).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("RevealNoisePainter", () => {
|
|
85
|
+
it("seeds both drift phases from randomness at each hold", () => {
|
|
86
|
+
const p = new RevealNoisePainter();
|
|
87
|
+
const rand = vi.spyOn(Math, "random").mockReturnValue(0.25);
|
|
88
|
+
try {
|
|
89
|
+
p.beginHold([220, 10, 15]);
|
|
90
|
+
// Two phase draws before retile; happy-dom has no 2d context, so
|
|
91
|
+
// the tile build bails before consuming any more randomness.
|
|
92
|
+
expect(p.peekDrift().glyph).toBeCloseTo(0.25 * NOISE_TILE_W, 10);
|
|
93
|
+
expect(p.peekDrift().background).toBeCloseTo(0.25 * NOISE_TILE_W, 10);
|
|
94
|
+
} finally {
|
|
95
|
+
rand.mockRestore();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("accumulates the two counter-drifts in device px", () => {
|
|
100
|
+
const p = new RevealNoisePainter();
|
|
101
|
+
p.beginHold([220, 10, 15]);
|
|
102
|
+
const before = p.peekDrift();
|
|
103
|
+
p.advance(0.25, 2);
|
|
104
|
+
const mid = p.peekDrift();
|
|
105
|
+
expect(mid.glyph - before.glyph).toBeCloseTo(GLYPH_DRIFT_PX_S * 2 * 0.25, 10);
|
|
106
|
+
expect(mid.background - before.background).toBeCloseTo(
|
|
107
|
+
BACKGROUND_DRIFT_PX_S * 2 * 0.25,
|
|
108
|
+
10,
|
|
109
|
+
);
|
|
110
|
+
p.advance(0.25, 2);
|
|
111
|
+
const after = p.peekDrift();
|
|
112
|
+
expect(after.glyph - mid.glyph).toBeCloseTo(GLYPH_DRIFT_PX_S * 0.5, 10);
|
|
113
|
+
expect(after.background - mid.background).toBeCloseTo(BACKGROUND_DRIFT_PX_S * 0.5, 10);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("never throws without a 2d context and stays unusable (fallback path)", () => {
|
|
117
|
+
const p = new RevealNoisePainter();
|
|
118
|
+
expect(() => p.beginHold([220, 10, 15])).not.toThrow();
|
|
119
|
+
expect(p.available).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|