@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.
@@ -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 draws the password TEXT instead —
48
- * each glyph gets a per-frame random perturbation of size, baseline,
49
- * rotation and color, so a screenshot (or an automated scraper)
50
- * never sees a stable, OCR-friendly rendering.
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
- * Anti-OCR reveal pass: the password is drawn as text on the same
324
- * canvas that usually carries the dot matrix, with every glyph
325
- * re-randomized per frame — size, baseline, rotation and color all
326
- * wobble, so no two frames (and no two screenshots) render the same
327
- * pixel grid and automated recognition never gets a stable target.
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 drawRevealText(ctx: CanvasRenderingContext2D, W: number, H: number) {
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
- drawRevealText(ctx, W, H);
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 drives the per-frame anti-OCR re-jitter.
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
- // Parked animation bus (reduced motion): the loop never fires, so
480
- // paint one synchronous frame — a static jitter is still better
481
- // for OCR than nothing, and motion-sensitive users keep their
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
- data-cols={props.columns}
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
+ });