@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 +1 -1
- package/src/DemoApp.tsx +2 -1
- package/src/components/HkInput.password.test.tsx +165 -11
- package/src/components/HkInput.tsx +17 -10
- package/src/components/HkPasswordSurface.scss +4 -3
- package/src/components/HkPasswordSurface.tsx +115 -23
- package/src/components/_menu-item.contract.test.ts +24 -0
- package/src/components/revealKinematogram.test.ts +41 -0
- package/src/components/revealKinematogram.ts +35 -3
- package/src/styles/admin-tokens.scss +25 -4
package/package.json
CHANGED
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 (
|
|
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): "
|
|
18
|
-
* (screenshot-safe
|
|
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 = {
|
|
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` (
|
|
62
|
-
*
|
|
63
|
-
* `revealTrigger` (press-and-hold by
|
|
64
|
-
* opt-in). The DOM value stays inside
|
|
65
|
-
* at all times — every strategy draws
|
|
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
|
-
* - "
|
|
80
|
-
* a
|
|
81
|
-
*
|
|
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: "
|
|
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
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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 "
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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:
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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: "
|
|
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
|
|
438
|
-
*
|
|
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
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
/*
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|