@celestia-island/hikari 0.55.16 → 0.55.18

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.16",
3
+ "version": "0.55.18",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
package/src/DemoApp.tsx CHANGED
@@ -366,7 +366,8 @@ export default defineComponent({
366
366
  <HInput modelValue={form.value.text} onUpdate:modelValue={(v: string) => (form.value.text = v)} placeholder="Disabled" disabled />
367
367
  <HSearchInput modelValue={form.value.search} onUpdate:modelValue={(v: string | number) => (form.value.search = String(v))} placeholder="Search..." />
368
368
  <HNumberInput modelValue={form.value.number} onUpdate:modelValue={(v: number) => (form.value.number = v)} placeholder="Number" />
369
- <HInput variant="password" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (hold the eye)" />
369
+ <HInput variant="password" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (sweep reveal — default)" />
370
+ <HInput variant="password" revealStrategy="noise" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (noise reveal — screenshot-safe)" />
370
371
  <HInput variant="password" revealStrategy="plain" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (plain reveal)" />
371
372
  <HInput variant="password" revealTrigger="toggle" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (click eye toggles)" />
372
373
  <HInput variant="password" passwordTrailing="strength" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (strength light)" />
@@ -14,8 +14,9 @@ import { setReducedMotion } from "../runtime/animationBus";
14
14
  * hint, caps-lock / full-width hints, pending-clear refocus semantics
15
15
  * - right-edge affordance (passwordTrailing): eye reveal (default) /
16
16
  * strength traffic light / none
17
- * - reveal strategy (revealStrategy): "noise" boiling kinematogram
18
- * (screenshot-safe, default) vs "plain" readable text
17
+ * - reveal strategy (revealStrategy): "sweep" readable window (default)
18
+ * vs "noise" boiling kinematogram (screenshot-safe) vs "plain"
19
+ * readable text
19
20
  * - reveal trigger (revealTrigger): press-and-hold (default) vs
20
21
  * click-to-toggle with auto-hide (revealAutoHideMs)
21
22
  * - the strength dot classifies through the shared passwordLevel util
@@ -651,6 +652,7 @@ describe("HkInput password reveal eye", () => {
651
652
  clearRect: () => {},
652
653
  save: () => {}, restore: () => {}, translate: () => {}, rotate: () => {},
653
654
  beginPath: () => {}, arc: () => {}, fill: () => {},
655
+ rect: () => {}, clip: () => {},
654
656
  measureText: () => ({ width: 10 }),
655
657
  fillText: () => {},
656
658
  fillRect: () => {},
@@ -766,7 +768,9 @@ describe("HkInput password reveal eye", () => {
766
768
  } as unknown as CanvasRenderingContext2D;
767
769
  }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
768
770
  try {
769
- const { container, input } = mountPasswordInput("abc");
771
+ const { container, input } = mountPasswordInput("abc", {
772
+ revealStrategy: "noise",
773
+ });
770
774
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
771
775
 
772
776
  eye.dispatchEvent(
@@ -883,7 +887,9 @@ describe("HkInput password reveal eye", () => {
883
887
  } as unknown as CanvasRenderingContext2D;
884
888
  }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
885
889
  try {
886
- const { container } = mountPasswordInput("abc");
890
+ const { container } = mountPasswordInput("abc", {
891
+ revealStrategy: "noise",
892
+ });
887
893
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
888
894
  eye.dispatchEvent(
889
895
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
@@ -954,7 +960,9 @@ describe("HkInput password reveal eye", () => {
954
960
  vi.stubGlobal("requestAnimationFrame", () => 0);
955
961
  vi.stubGlobal("cancelAnimationFrame", () => {});
956
962
  try {
957
- const { container } = mountPasswordInput("abc");
963
+ const { container } = mountPasswordInput("abc", {
964
+ revealStrategy: "noise",
965
+ });
958
966
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
959
967
  eye.dispatchEvent(
960
968
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
@@ -1034,7 +1042,9 @@ describe("HkInput password reveal eye", () => {
1034
1042
  } as unknown as CanvasRenderingContext2D;
1035
1043
  }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
1036
1044
  try {
1037
- const { container, input } = mountPasswordInput("abc");
1045
+ const { container, input } = mountPasswordInput("abc", {
1046
+ revealStrategy: "noise",
1047
+ });
1038
1048
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1039
1049
  eye.dispatchEvent(
1040
1050
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
@@ -1105,7 +1115,9 @@ describe("HkInput password reveal eye", () => {
1105
1115
  } as unknown as CanvasRenderingContext2D;
1106
1116
  }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
1107
1117
  try {
1108
- const { container } = mountPasswordInput("abc");
1118
+ const { container } = mountPasswordInput("abc", {
1119
+ revealStrategy: "noise",
1120
+ });
1109
1121
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1110
1122
  eye.dispatchEvent(
1111
1123
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
@@ -1166,7 +1178,9 @@ describe("HkInput password reveal eye", () => {
1166
1178
  } as unknown as CanvasRenderingContext2D;
1167
1179
  }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
1168
1180
  try {
1169
- const { container } = mountPasswordInput("abc");
1181
+ const { container } = mountPasswordInput("abc", {
1182
+ revealStrategy: "noise",
1183
+ });
1170
1184
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1171
1185
  eye.dispatchEvent(
1172
1186
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
@@ -1247,7 +1261,9 @@ describe("HkInput password reveal eye", () => {
1247
1261
  } as unknown as CanvasRenderingContext2D;
1248
1262
  }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
1249
1263
  try {
1250
- const { container } = mountPasswordInput("abc");
1264
+ const { container } = mountPasswordInput("abc", {
1265
+ revealStrategy: "noise",
1266
+ });
1251
1267
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1252
1268
  eye.dispatchEvent(
1253
1269
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
@@ -1304,6 +1320,8 @@ function stubRecordingContexts() {
1304
1320
  patternFills: number;
1305
1321
  drawImages: number;
1306
1322
  putImageDatas: number;
1323
+ clips: Array<{ x: number; y: number; w: number; h: number }>;
1324
+ translates: number[];
1307
1325
  }
1308
1326
  const byCanvas = new Map<HTMLCanvasElement, CanvasRec>();
1309
1327
  const original = HTMLCanvasElement.prototype.getContext;
@@ -1312,7 +1330,15 @@ function stubRecordingContexts() {
1312
1330
  ): CanvasRenderingContext2D {
1313
1331
  let rec = byCanvas.get(this);
1314
1332
  if (!rec) {
1315
- rec = { canvas: this, texts: [], patternFills: 0, drawImages: 0, putImageDatas: 0 };
1333
+ rec = {
1334
+ canvas: this,
1335
+ texts: [],
1336
+ patternFills: 0,
1337
+ drawImages: 0,
1338
+ putImageDatas: 0,
1339
+ clips: [],
1340
+ translates: [],
1341
+ };
1316
1342
  byCanvas.set(this, rec);
1317
1343
  }
1318
1344
  const r = rec;
@@ -1321,7 +1347,7 @@ function stubRecordingContexts() {
1321
1347
  clearRect: () => {},
1322
1348
  save: () => {},
1323
1349
  restore: () => {},
1324
- translate: () => {},
1350
+ translate: (x: number) => r.translates.push(x),
1325
1351
  rotate: () => {},
1326
1352
  beginPath: () => {},
1327
1353
  arc: () => {},
@@ -1338,6 +1364,9 @@ function stubRecordingContexts() {
1338
1364
  data: new Uint8ClampedArray(w * h * 4),
1339
1365
  }),
1340
1366
  putImageData: () => r.putImageDatas++,
1367
+ rect: (x: number, y: number, w: number, h: number) =>
1368
+ r.clips.push({ x, y, w, h }),
1369
+ clip: () => {},
1341
1370
  imageSmoothingEnabled: false,
1342
1371
  globalCompositeOperation: "source-over",
1343
1372
  font: "",
@@ -1355,6 +1384,130 @@ function stubRecordingContexts() {
1355
1384
  }
1356
1385
 
1357
1386
  describe("HkInput password reveal strategies", () => {
1387
+ it("sweep (default) draws readable text inside a moving window over the noise field", async () => {
1388
+ // The default reveal: real high-contrast text clipped to a band
1389
+ // that sweeps across the row (sweepWindow), on top of the boiling
1390
+ // noise field. A single frame leaks only the band's characters.
1391
+ const rec = stubRecordingContexts();
1392
+ try {
1393
+ const { container, input } = mountPasswordInput("abc");
1394
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1395
+ eye.dispatchEvent(
1396
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1397
+ );
1398
+ await nextTick();
1399
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1400
+ const vis = rec.byCanvas.get(visible)!;
1401
+ // The sync first frame already carries text + noise base.
1402
+ expect(vis.texts).toEqual(["a", "b", "c"]);
1403
+ expect(vis.patternFills).toBeGreaterThan(0);
1404
+ expect(vis.clips.length).toBeGreaterThanOrEqual(1);
1405
+ expect(vis.clips[0]!.w).toBeGreaterThan(0);
1406
+ // The DOM input still never flips.
1407
+ expect(input.type).toBe("password");
1408
+ // Frames move the window: clip x changes across the sweep.
1409
+ for (let i = 0; i < 3; i++) {
1410
+ await new Promise((r) => setTimeout(r, 45));
1411
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
1412
+ }
1413
+ expect(vis.clips.length).toBeGreaterThanOrEqual(2);
1414
+ const xs = new Set(vis.clips.map((c) => c.x));
1415
+ expect(xs.size, "the window travels across the row").toBeGreaterThan(1);
1416
+ // The band must traverse (essentially) the WHOLE row, not stop
1417
+ // mid-row: on the degenerate 0-width happy-dom canvas the layout
1418
+ // scale floors at 0.5, giving advance 6 and an 18px row for
1419
+ // "abc" — the clip centers must span ≥ 12px of that travel
1420
+ // (center = clip.x + w/2; the sweep spans first-glyph left edge
1421
+ // to the row's right edge).
1422
+ const centers = vis.clips.map((c) => c.x + c.w / 2);
1423
+ expect(
1424
+ Math.max(...centers) - Math.min(...centers),
1425
+ "the sweep reaches the row tail",
1426
+ ).toBeGreaterThanOrEqual(12);
1427
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1428
+ await nextTick();
1429
+ } finally {
1430
+ rec.restore();
1431
+ }
1432
+ });
1433
+
1434
+ it("restarts the sweep from the row head on the second hold", async () => {
1435
+ // sweepT resets per reveal: hold-release-hold must show the window
1436
+ // at the row START again, not resume from wherever the last hold
1437
+ // left it (R2 mutation M23 — the sweepT reset had no pin).
1438
+ const rec = stubRecordingContexts();
1439
+ try {
1440
+ const { container } = mountPasswordInput("abc");
1441
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1442
+ eye.dispatchEvent(
1443
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1444
+ );
1445
+ await nextTick();
1446
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1447
+ const vis = rec.byCanvas.get(visible)!;
1448
+ const firstHoldFirstClip = vis.clips[0]!;
1449
+ // Drive real bus frames DURING the first hold so sweepT actually
1450
+ // advances past the row before release — without this, both
1451
+ // holds' first frames draw at sweepT = 0 and the reset pin below
1452
+ // passes vacuously. The self-check makes a frameless environment
1453
+ // fail loudly instead of slipping through.
1454
+ for (let i = 0; i < 3; i++) {
1455
+ await new Promise((r) => setTimeout(r, 45));
1456
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
1457
+ }
1458
+ expect(
1459
+ vis.clips.length,
1460
+ "bus frames advanced the sweep during hold 1",
1461
+ ).toBeGreaterThanOrEqual(2);
1462
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1463
+ await nextTick();
1464
+ // Second hold: the very first frame's window must sit at the row
1465
+ // head again (same clip as the first hold's first frame).
1466
+ eye.dispatchEvent(
1467
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1468
+ );
1469
+ await nextTick();
1470
+ const secondHoldFirstClip = vis.clips[vis.clips.length - 1]!;
1471
+ expect(secondHoldFirstClip.x).toBe(firstHoldFirstClip.x);
1472
+ expect(secondHoldFirstClip.w).toBe(firstHoldFirstClip.w);
1473
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1474
+ await nextTick();
1475
+ } finally {
1476
+ rec.restore();
1477
+ }
1478
+ });
1479
+
1480
+ it("sweep degrades to static plain text when the animation bus is parked (reduced motion)", async () => {
1481
+ // A parked bus cannot move the window, so the sweep falls back to
1482
+ // the fully readable static plain text — never to frozen noise.
1483
+ setReducedMotion(true);
1484
+ const rec = stubRecordingContexts();
1485
+ try {
1486
+ const { container } = mountPasswordInput("abc");
1487
+ const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
1488
+ eye.dispatchEvent(
1489
+ new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
1490
+ );
1491
+ await nextTick();
1492
+ const visible = container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
1493
+ const vis = rec.byCanvas.get(visible)!;
1494
+ expect(vis.texts).toEqual(["a", "b", "c"]);
1495
+ // No noise machinery on the visible canvas in the degraded state.
1496
+ expect(vis.patternFills).toBe(0);
1497
+ // And the degrade target is PLAIN text, not the legacy jitter:
1498
+ // the plain pass never translates per glyph (the jitter does).
1499
+ expect(vis.translates.length, "static plain, not jitter").toBe(0);
1500
+ // And it stays put — the static frame is the whole reveal.
1501
+ await new Promise((r) => setTimeout(r, 260));
1502
+ expect(vis.texts).toEqual(["a", "b", "c"]);
1503
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
1504
+ await nextTick();
1505
+ } finally {
1506
+ rec.restore();
1507
+ setReducedMotion(false);
1508
+ }
1509
+ });
1510
+
1358
1511
  it("plain strategy draws readable text on the VISIBLE canvas — and nothing else", async () => {
1359
1512
  // The opt-in readable reveal (revealStrategy="plain"): glyphs ARE
1360
1513
  // the visible frame — no noise fills, no mask stamp — while the DOM
@@ -1476,6 +1629,7 @@ describe("HkInput password reveal strategies", () => {
1476
1629
  const rec = stubRecordingContexts();
1477
1630
  try {
1478
1631
  const { container, model } = mountPasswordInput("abc", {
1632
+ revealStrategy: "noise",
1479
1633
  revealTrigger: "toggle",
1480
1634
  });
1481
1635
  const eye = container.querySelector<HTMLElement>("button.hk-pwd-eye")!;
@@ -58,11 +58,12 @@ export default defineComponent({
58
58
  /**
59
59
  * Right-edge affordance for `variant="password"`:
60
60
  * - "eye" (default): the reveal button. While revealed, the canvas
61
- * renders the reveal chosen by `revealStrategy` (boiling-noise
62
- * kinematogram by default, plain text on opt-in); the trigger is
63
- * `revealTrigger` (press-and-hold by default, click-toggle on
64
- * opt-in). The DOM value stays inside the type="password" input
65
- * at all times — every strategy draws on the canvas.
61
+ * renders the reveal chosen by `revealStrategy` (the sweep
62
+ * readable-window by default; boiling-noise and plain text on
63
+ * opt-in); the trigger is `revealTrigger` (press-and-hold by
64
+ * default, click-toggle on opt-in). The DOM value stays inside
65
+ * the type="password" input at all times — every strategy draws
66
+ * on the canvas.
66
67
  * - "strength": the traffic-light dot (weak / fair / strong via the
67
68
  * shared `passwordLevel` classifier, overridable through
68
69
  * `strengthEvaluator`) with a localized tooltip on hover and on
@@ -76,17 +77,23 @@ export default defineComponent({
76
77
  /**
77
78
  * Reveal content for the password eye (`variant="password"`,
78
79
  * `passwordTrailing="eye"`):
79
- * - "noise" (default): the boiling-noise kinematogram — readable by
80
- * a watching human, statistically pure noise in any single-frame
81
- * screenshot (nothing for OCR to lock onto).
80
+ * - "sweep" (default): a readable window — ordinary high-contrast
81
+ * text inside a narrow band sweeping across the row over the
82
+ * boiling-noise field. Easy to read; a single screenshot leaks
83
+ * only the characters under the band (partial capture
84
+ * resistance).
85
+ * - "noise": the boiling-noise kinematogram — statistically pure
86
+ * noise in any single-frame screenshot (nothing for OCR to lock
87
+ * onto), but the hardest to read. Opt-in for high-exposure
88
+ * surfaces.
82
89
  * - "plain": ordinary readable text while revealed (the industry-
83
90
  * standard reveal) — readable by everyone including
84
91
  * reduced-motion users, but fully visible to screenshots and
85
92
  * shoulder surfers. Pick per threat model.
86
93
  */
87
94
  revealStrategy: {
88
- type: String as () => "noise" | "plain",
89
- default: "noise",
95
+ type: String as () => "sweep" | "noise" | "plain",
96
+ default: "sweep",
90
97
  },
91
98
  /**
92
99
  * Reveal interaction for the password eye: "hold" (default) reveals
@@ -251,9 +251,10 @@
251
251
 
252
252
  /* Reveal eye button — the right-edge affordance of the default
253
253
  * passwordTrailing="eye" mode. Revealing renders the chosen
254
- * revealStrategy (the screenshot-safe boiling-noise kinematogram by
255
- * default, plain text on opt-in); the trigger is press-and-hold by
256
- * default, click-toggle on opt-in; hiding restores the dot matrix. */
254
+ * revealStrategy (the sweep readable window by default; the
255
+ * screenshot-safe boiling-noise kinematogram and plain text on
256
+ * opt-in); the trigger is press-and-hold by default, click-toggle on
257
+ * opt-in; hiding restores the dot matrix. */
257
258
  .hk-pwd-eye {
258
259
  position: absolute;
259
260
  inset-inline-end: calc(var(--space-12) - 0.25rem);
@@ -25,6 +25,7 @@ import { HkPlaceholderMarquee, type PlaceholderVariant } from "./HkPlaceholderMa
25
25
  import {
26
26
  layoutRevealGlyphs,
27
27
  RevealNoisePainter,
28
+ sweepWindow,
28
29
  type RevealLayout,
29
30
  } from "./revealKinematogram";
30
31
  import "./HkPasswordSurface.scss";
@@ -50,16 +51,24 @@ interface Ripple {
50
51
  * Right-edge affordance (`passwordTrailing`):
51
52
  * - "eye" (default): the reveal button. What the reveal SHOWS is chosen
52
53
  * by `revealStrategy`, how it is TRIGGERED by `revealTrigger`:
53
- * - strategy "noise" (default): the canvas stops drawing the dot
54
- * matrix and renders the boiling-noise kinematogram instead (see
55
- * revealKinematogram.ts): one shared noise tile, the background
56
- * drifting sideways while the noise sampled through the password
57
- * glyphs is re-sampled at a random phase every frame. A human reads
58
- * the glyph silhouettes off the flicker-vs-drift contrast, while
59
- * any single frame — a screenshot — is pure noise with no glyph
60
- * structure for OCR to lock onto. When the animation bus is parked
61
- * (reduced motion) or the engine cannot run the pattern path, the
62
- * reveal degrades to the legacy static per-glyph jitter drawing.
54
+ * - strategy "sweep" (default): a readable window — the password is
55
+ * drawn as ordinary high-contrast text inside a narrow band that
56
+ * sweeps across the row over the boiling-noise field (see
57
+ * revealKinematogram.ts). Reading is effortless; a single frame —
58
+ * a screenshot — shows only the characters under the band, the rest
59
+ * stays noise (partial capture resistance). Reduced motion or a
60
+ * pattern-less engine degrades to the fully readable static plain
61
+ * text.
62
+ * - strategy "noise": the screenshot-safe boiling-noise kinematogram:
63
+ * one shared noise tile, the background drifting sideways while the
64
+ * noise sampled through the password glyphs is re-sampled at a
65
+ * random phase every frame. A human reads the glyph silhouettes off
66
+ * the flicker-vs-drift contrast, while any single frame — a
67
+ * screenshot — is pure noise with no glyph structure for OCR to
68
+ * lock onto. The hardest to read; opt-in for high-exposure
69
+ * surfaces. When the animation bus is parked (reduced motion) or
70
+ * the engine cannot run the pattern path, the reveal degrades to
71
+ * the legacy static per-glyph jitter drawing.
63
72
  * - strategy "plain": the industry-standard readable reveal — the
64
73
  * password is drawn as ordinary text on the canvas while revealed
65
74
  * (the DOM input stays type="password"). Readable by everyone,
@@ -102,13 +111,21 @@ export default defineComponent({
102
111
  default: "eye",
103
112
  },
104
113
  /**
105
- * What the eye reveal SHOWS: "noise" (default) = the screenshot-
106
- * safe boiling-noise kinematogram; "plain" = ordinary readable text
107
- * drawn on the canvas (screenshot-visible — pick per threat model).
114
+ * What the eye reveal SHOWS:
115
+ * - "sweep" (default): a readable window — the password is drawn as
116
+ * ordinary high-contrast text inside a narrow band that sweeps
117
+ * across the row over the boiling-noise field. Genuinely easy to
118
+ * read; a single screenshot leaks only the characters under the
119
+ * band (partial capture resistance).
120
+ * - "noise": the screenshot-safe boiling-noise kinematogram —
121
+ * statistically pure noise in any single frame, nothing for OCR,
122
+ * but the hardest to read (opt-in for high-exposure surfaces).
123
+ * - "plain": ordinary readable text drawn on the canvas (fully
124
+ * screenshot-visible — pick per threat model).
108
125
  */
109
126
  revealStrategy: {
110
- type: String as () => "noise" | "plain",
111
- default: "noise",
127
+ type: String as () => "sweep" | "noise" | "plain",
128
+ default: "sweep",
112
129
  },
113
130
  /**
114
131
  * How the eye reveal is TRIGGERED: "hold" (default) = press-and-
@@ -156,7 +173,9 @@ export default defineComponent({
156
173
  // counts bus-driven frames so the watchdog can tell a parked
157
174
  // animation bus (reduced motion) from a live one; the layout memo
158
175
  // keeps measureText off the per-frame path (inputs compared as
159
- // fields, the string key built only on an actual rebuild).
176
+ // fields, the string key built only on an actual rebuild). sweepT
177
+ // drives the sweep strategy's window position (seconds since the
178
+ // reveal started).
160
179
  const revealNoise = new RevealNoisePainter();
161
180
  let revealFrames = 0;
162
181
  let revealLayoutValue = "";
@@ -168,6 +187,7 @@ export default defineComponent({
168
187
  let revealAutoHide: CronHandle | null = null;
169
188
  let revealLayout: RevealLayout | null = null;
170
189
  let revealLayoutKey = "";
190
+ let sweepT = 0;
171
191
  // Raw computed `color` of the box, cached at reveal start — the
172
192
  // plain strategy's fillStyle (textHsl is the same read parsed for
173
193
  // the noise tile base).
@@ -434,8 +454,8 @@ export default defineComponent({
434
454
  }
435
455
 
436
456
  /**
437
- * Motion reveal pass (the "noise" strategy, default): one frame of
438
- * the boiling-noise kinematogram — background noise translated by
457
+ * Motion reveal pass (the "noise" strategy): one frame of the
458
+ * boiling-noise kinematogram — background noise translated by
439
459
  * the accumulated drift, then the (offscreen) glyph mask
440
460
  * re-composited with noise sampled at a fresh random phase. The
441
461
  * glyph geometry itself never touches the visible canvas, so a
@@ -473,6 +493,60 @@ export default defineComponent({
473
493
  ctx.restore();
474
494
  }
475
495
 
496
+ /**
497
+ * Sweep reveal pass (`revealStrategy="sweep"`, the default): the
498
+ * boiling-noise field stays as the base layer, and the password is
499
+ * drawn as ordinary high-contrast text ONLY inside a narrow window
500
+ * that sweeps across the row (sweepWindow kinematics: constant
501
+ * pace, dwell at the end, loop while held). Reading is effortless —
502
+ * real text under the window — while a single frame leaks only the
503
+ * window band's characters. The window geometry needs the text
504
+ * extent, so it derives from the memoized layout: span = first
505
+ * glyph's left edge to the last advance's right edge, window width
506
+ * = 6.5 advances (clamped into the field).
507
+ *
508
+ * Accepted risk: a row NARROWER than the window floor (≤ ~4 very
509
+ * short characters) fits entirely inside the band, so one frame
510
+ * leaks the whole password — for tiny secrets the sweep is
511
+ * effectively the plain strategy. Documented trade-off, not a bug.
512
+ *
513
+ * If the noise pattern path is unavailable this latches the static
514
+ * fallback (plain text, like the reduced-motion degrade) instead of
515
+ * the legacy jitter: sweep exists FOR readability.
516
+ */
517
+ function drawRevealSweepFrame(ctx: CanvasRenderingContext2D, W: number, H: number, dt: number) {
518
+ const layout = revealLayoutFor(ctx, W, H);
519
+ if (!layout || layout.glyphs.length === 0) return;
520
+ sweepT += dt;
521
+ revealNoise.advance(dt, dpr);
522
+ if (!revealNoise.paint(ctx, W, H, layout, cachedMonoFont || syncMonoFont(), revealLayoutKey)) {
523
+ revealStaticFallback = true;
524
+ drawRevealPlainText(ctx, W, H);
525
+ return;
526
+ }
527
+ const first = layout.glyphs[0]!;
528
+ const last = layout.glyphs[layout.glyphs.length - 1]!;
529
+ const rowStart = first.x;
530
+ const rowEnd = last.x + last.advance;
531
+ const advance = last.advance;
532
+ // Window width: 6.5 advances, never wider than half the field,
533
+ // but never below a readable floor (a degenerate canvas width
534
+ // must not collapse the band to zero — and a row shorter than
535
+ // the floor fits the band entirely: accepted risk, see the
536
+ // docblock above).
537
+ const winW = Math.max(56 * dpr, Math.min(W * 0.5, 6.5 * advance));
538
+ // Window-center span: from the first glyph's left edge to the
539
+ // row's right edge (so the band starts showing the head and ends
540
+ // showing the tail).
541
+ const { x, w } = sweepWindow(rowStart, rowEnd, winW, sweepT, dpr);
542
+ ctx.save();
543
+ ctx.beginPath();
544
+ ctx.rect(x - w / 2, 0, w, H);
545
+ ctx.clip();
546
+ drawRevealPlainText(ctx, W, H);
547
+ ctx.restore();
548
+ }
549
+
476
550
  /**
477
551
  * Legacy anti-OCR fallback, used only when frames cannot drive the
478
552
  * kinematogram (parked animation bus — reduced motion — or an
@@ -543,6 +617,17 @@ export default defineComponent({
543
617
  drawRevealPlainText(ctx, W, H);
544
618
  return;
545
619
  }
620
+ if (props.revealStrategy === "sweep") {
621
+ // Static fallback (reduced motion / pattern-less engine):
622
+ // sweep exists for readability, so its degrade target is the
623
+ // fully readable plain text, not the legacy jitter.
624
+ if (revealStaticFallback) {
625
+ drawRevealPlainText(ctx, W, H);
626
+ return;
627
+ }
628
+ drawRevealSweepFrame(ctx, W, H, dt);
629
+ return;
630
+ }
546
631
  if (!revealStaticFallback && drawRevealNoise(ctx, W, H, dt)) return;
547
632
  drawRevealJitterText(ctx, W, H);
548
633
  return;
@@ -639,19 +724,26 @@ export default defineComponent({
639
724
  if (!props.modelValue || props.disabled || revealing.value) return;
640
725
  syncTextHsl();
641
726
  syncMonoFont();
727
+ // The plain strategy is static — no painter, no watchdog. Both
728
+ // motion strategies (sweep default, noise opt-in) drive the noise
729
+ // painter per frame and degrade to a STATIC fallback when frames
730
+ // cannot drive them: sweep falls back to plain text (its whole
731
+ // point is readability), noise to the legacy jitter.
642
732
  const isPlain = props.revealStrategy === "plain";
643
733
  if (!isPlain) {
644
734
  revealStaticFallback = false;
645
735
  revealFrames = 0;
736
+ sweepT = 0;
646
737
  revealNoise.beginHold(textHsl);
647
738
  }
648
739
  revealing.value = true;
649
740
  // A parked bus (reduced motion) will never deliver a frame, so
650
- // the kinematogram would freeze into unreadable pure noise —
651
- // degrade immediately to the legacy static jitter drawing.
652
- // Motion-sensitive users keep their preference and the reveal
653
- // stays usable. The plain strategy needs no degrade: its static
654
- // text is already motion-free.
741
+ // the motion reveals would freeze — the sweep into unreadable
742
+ // mid-state noise, the noise into pure noise — degrade immediately
743
+ // to the strategy's static fallback (plain text for sweep, legacy
744
+ // jitter for noise). Motion-sensitive users keep their preference
745
+ // and the reveal stays usable. The plain strategy needs no
746
+ // degrade: its static text is already motion-free.
655
747
  if (!isPlain && isAnimationParked()) {
656
748
  revealStaticFallback = true;
657
749
  }
@@ -87,4 +87,28 @@ describe("menu-item grammar contract", () => {
87
87
  expect(adminTokens).toMatch(/\.hk-menu-divider \{[^}]*--hk-menu-item-pad-y, 6px/);
88
88
  expect(adminTokens).toMatch(/\.hk-menu-divider \{[^}]*--hk-menu-item-pad-x, 12px/);
89
89
  });
90
+
91
+ it("lands the identity header on the rows' content edge inside HkMenu surfaces", () => {
92
+ // The account identity block (`.s-user-header` — the admin header's
93
+ // dropdown header slot and every consumer's HkMenu header slot) shares
94
+ // its container with the menu rows, so its inline padding must BE the
95
+ // rows' content edge: their 1px transparent border plus pad-x, in the
96
+ // grammar's own px units. A fixed rem value read 1px off at the
97
+ // default root, 4.5px at a 20px root, and 3px the other way on sheets
98
+ // (measured 2026-09-17): pin both scoped rules and their branch values.
99
+ expect(adminTokens).toMatch(
100
+ /\.hk-select-popout-host \.s-user-header \{[^}]*padding-inline: calc\(1px \+ var\(--hk-menu-item-pad-x, 12px\)\)/,
101
+ );
102
+ expect(adminTokens).toMatch(
103
+ /\.hk-select-sheet-panel \.s-user-header \{[^}]*padding-inline: calc\(1px \+ var\(--hk-menu-item-pad-x, 16px\)\)/,
104
+ );
105
+ // The sheet branch value must follow the sheet tokens, not the popout's.
106
+ expect(adminTokens).not.toMatch(
107
+ /\.hk-select-sheet-panel \.s-user-header \{[^}]*--hk-menu-item-pad-x, 12px/,
108
+ );
109
+ // Bare surfaces (no rows beside the block) keep the block's own value.
110
+ expect(adminTokens).toMatch(
111
+ /\.s-user-header \{[^}]*padding: var\(--space-10\) var\(--space-14\) var\(--space-12\)/,
112
+ );
113
+ });
90
114
  });
@@ -5,6 +5,9 @@ import {
5
5
  layoutRevealGlyphs,
6
6
  NOISE_TILE_W,
7
7
  RevealNoisePainter,
8
+ sweepWindow,
9
+ SWEEP_END_PAUSE_S,
10
+ SWEEP_SPEED_CSS_PX_S,
8
11
  wrapDrift,
9
12
  } from "./revealKinematogram";
10
13
 
@@ -128,3 +131,41 @@ describe("RevealNoisePainter", () => {
128
131
  expect(p.available).toBe(false);
129
132
  });
130
133
  });
134
+
135
+ describe("sweepWindow", () => {
136
+ const dpr = 1;
137
+ // A 100px travel at 220 CSS px/s ≈ 0.4545s of movement per cycle.
138
+ const start = 100;
139
+ const end = 200;
140
+ const winW = 60;
141
+
142
+ it("starts the window center at the span start and moves right", () => {
143
+ expect(sweepWindow(start, end, winW, 0, dpr).x).toBe(start);
144
+ const mid = sweepWindow(start, end, winW, 0.2, dpr);
145
+ expect(mid.x).toBeGreaterThan(start);
146
+ expect(mid.x).toBeLessThan(end);
147
+ });
148
+
149
+ it("reaches the span end and dwells there for the pause", () => {
150
+ const moveT = (end - start) / SWEEP_SPEED_CSS_PX_S;
151
+ expect(sweepWindow(start, end, winW, moveT, dpr).x).toBe(end);
152
+ // Mid-dwell: still at the end, not yet looping.
153
+ expect(sweepWindow(start, end, winW, moveT + SWEEP_END_PAUSE_S / 2, dpr).x).toBe(end);
154
+ });
155
+
156
+ it("loops with a period of travel + pause, phase-aligned", () => {
157
+ const cycle = (end - start) / SWEEP_SPEED_CSS_PX_S + SWEEP_END_PAUSE_S;
158
+ const a = sweepWindow(start, end, winW, 0.1, dpr);
159
+ const b = sweepWindow(start, end, winW, 0.1 + cycle, dpr);
160
+ expect(b.x).toBeCloseTo(a.x, 10);
161
+ // Negative t folds into the same cycle (defensive modulo).
162
+ expect(sweepWindow(start, end, winW, 0.1 - cycle, dpr).x).toBeCloseTo(a.x, 10);
163
+ });
164
+
165
+ it("carries the window width through and survives a degenerate span", () => {
166
+ expect(sweepWindow(start, end, winW, 0.1, dpr).w).toBe(winW);
167
+ // start == end: travel clamps to >=1, never divides by zero.
168
+ expect(() => sweepWindow(50, 50, winW, 0.1, dpr)).not.toThrow();
169
+ expect(sweepWindow(50, 50, winW, 0.1, dpr).x).toBeGreaterThanOrEqual(50);
170
+ });
171
+ });
@@ -1,7 +1,10 @@
1
1
  /**
2
- * revealKinematogram — the screenshot-safe rendering behind the
3
- * HkPasswordSurface noise reveal (hold or toggle; a random-dot
4
- * kinematogram).
2
+ * revealKinematogram — the canvas rendering behind the HkPasswordSurface
3
+ * reveal strategies: the boiling-noise kinematogram (the "noise"
4
+ * strategy — screenshot-safe in any single frame) and the sweep-window
5
+ * kinematics for the "sweep" strategy (a readable band over the noise;
6
+ * deliberately NOT single-frame safe). Both serve hold and toggle
7
+ * triggers; the "plain" strategy bypasses this module entirely.
5
8
  *
6
9
  * Principle: the whole reveal area is filled with ONE shared noise tile.
7
10
  * The glyph mask stays perfectly still while the BACKGROUND noise drifts
@@ -121,6 +124,35 @@ export function wrapDrift(px: number, tile: number): number {
121
124
  return m < 0 ? m + tile : m;
122
125
  }
123
126
 
127
+ /** Sweep-window kinematics for the "sweep" reveal strategy (all values
128
+ * in DEVICE px): the readable window's CENTER travels from `spanStart`
129
+ * to `spanEnd` at a constant pace, dwells at the end for
130
+ * SWEEP_END_PAUSE_S so the tail characters get read, then loops for as
131
+ * long as the reveal is held. Pure math — unit-pinned.
132
+ *
133
+ * Readability contract: inside the window the password is drawn as
134
+ * ordinary high-contrast text (the eye reads it effortlessly); outside
135
+ * it, the boiling-noise field continues. A single screenshot therefore
136
+ * leaks only the characters under the window — weaker than the "noise"
137
+ * strategy's full single-frame safety, but far more readable. */
138
+ export const SWEEP_SPEED_CSS_PX_S = 220;
139
+ export const SWEEP_END_PAUSE_S = 0.45;
140
+
141
+ export function sweepWindow(
142
+ spanStart: number,
143
+ spanEnd: number,
144
+ winW: number,
145
+ t: number,
146
+ dpr: number,
147
+ ): { x: number; w: number } {
148
+ const travel = Math.max(1, spanEnd - spanStart);
149
+ const moveT = travel / (SWEEP_SPEED_CSS_PX_S * dpr);
150
+ const cycle = moveT + SWEEP_END_PAUSE_S;
151
+ const phase = ((t % cycle) + cycle) % cycle;
152
+ const x = phase >= moveT ? spanEnd : spanStart + travel * (phase / moveT);
153
+ return { x, w: winW };
154
+ }
155
+
124
156
  /**
125
157
  * Lay the password glyphs out centered in the reveal area, scaling the
126
158
  * row down (floor 0.5×) when it overflows. The mask must stay perfectly
@@ -550,14 +550,35 @@
550
550
  flex-direction: column;
551
551
  gap: 2px;
552
552
  min-width: 0;
553
- /* In menus this aligns with .s-popup-menu-item's own space-14 (menu
554
- padding applies equally to both); bare surfaces (the popover
555
- wrapper) rely on it entirely. The bottom margin restores the
556
- separation the removed section divider used to provide. */
553
+ /* The 14px inline is the BARE-surface value (a wrapper with no menu rows
554
+ beside it). Inside HkMenu's surfaces the scoped rules below take over:
555
+ the unified menu-item grammar moved rows to a 12px pad plus a 1px
556
+ transparent border (sheets 16px), so a fixed 14px aligned with nothing
557
+ — 1px off at the default root, 4.5px at a 20px root, and 3px the other
558
+ way on sheets (measured 2026-09-17, chest account menus). The bottom
559
+ margin restores the separation the removed section divider used to
560
+ provide. */
557
561
  padding: var(--space-10) var(--space-14) var(--space-12);
558
562
  margin-bottom: var(--space-4);
559
563
  }
560
564
 
565
+ /* Inside HkMenu's popout/sheet surfaces the block shares its container with
566
+ the menu rows, so its inline padding IS the rows' content edge — their
567
+ 1px transparent border plus pad-x — expressed in the grammar's own units
568
+ (px formulas: the row paddings are px literals declared on the rows, so
569
+ a rem here drifts at any other root font-size; the fallback literals must
570
+ follow _menu-item.scss if the row padding ever moves). Scoped to the two
571
+ surfaces that host the block, so bare surfaces keep the 14px and plain
572
+ HkSelect / HkPopupSelect panels are untouched. Consumers that re-anchor
573
+ the block (a drawer footer) keep their own context overrides. */
574
+ .hk-select-popout-host .s-user-header {
575
+ padding-inline: calc(1px + var(--hk-menu-item-pad-x, 12px));
576
+ }
577
+
578
+ .hk-select-sheet-panel .s-user-header {
579
+ padding-inline: calc(1px + var(--hk-menu-item-pad-x, 16px));
580
+ }
581
+
561
582
  /* Profile variant — avatar chip beside the identity column, the ONE
562
583
  identity grammar the chat frontend's user menu and the admin
563
584
  header's dropdown share (moved up from chest's ChatLayout so both