(() =>
+ Array.from({ length }, (_, index) => ({
+ id: index,
+ phase: 'active',
+ shape: chooseExpressivePinShape(),
+ })));
+ const activeLength = indicators.filter(({ phase }) => phase === 'active').length;
+ const removeIndicator = useCallback((id: number) => {
+ setIndicators((current) => current.filter((indicator) => indicator.id !== id));
+ }, []);
useEffect(() => {
- setShapes((current) => {
- if (length < current.length) return current.slice(0, length);
- if (length === current.length) return current;
+ if (length === activeLength) return;
+ if (length < activeLength) {
+ setIndicators((current) => {
+ let keptActive = 0;
+ return current.map((indicator) => {
+ if (indicator.phase === 'exiting') return indicator;
+ if (keptActive++ < length) return indicator;
+ return { ...indicator, phase: 'exiting' };
+ });
+ });
+ return;
+ }
+
+ setIndicators((current) => {
+ const missing = length - current.filter(({ phase }) => phase === 'active').length;
+ if (missing <= 0) return current;
return [
...current,
- ...Array.from({ length: length - current.length }, chooseExpressivePinShape),
+ ...Array.from({ length: missing }, () => ({
+ id: nextIndicatorId.current++,
+ phase: 'active' as const,
+ shape: chooseExpressivePinShape(),
+ })),
];
});
- }, [length]);
+ }, [activeLength, length]);
useEffect(() => {
- setShapes([]);
+ if (previousResetToken.current === resetToken) return;
+ previousResetToken.current = resetToken;
+ setIndicators((current) => current.map((indicator) => (
+ indicator.phase === 'exiting' ? indicator : { ...indicator, phase: 'exiting' }
+ )));
}, [resetToken]);
return (
- {shapes.map((shape, index) => )}
+ {indicators.map((item, index) => (
+
+ ))}
);
}
diff --git a/src/styles/lockscreen.css b/src/styles/lockscreen.css
index fec93bd..402831a 100644
--- a/src/styles/lockscreen.css
+++ b/src/styles/lockscreen.css
@@ -62,39 +62,74 @@ html[data-app-covered='true'] body > dialog {
}
.pin-indicators {
- display: flex;
+ position: relative;
+ width: 100%;
min-height: 32px;
- align-items: center;
- justify-content: center;
- gap: 8px;
}
.pin-indicator {
+ position: absolute;
+ top: 50%;
+ left: 50%;
display: grid;
width: 16px;
height: 16px;
+ margin: -8px 0 0 -8px;
flex: 0 0 16px;
color: var(--color-on-surface);
place-items: center;
transform-origin: center;
+ transition: transform var(--motion-short) var(--motion-emphasized);
}
-.pin-indicator svg {
- display: block;
+.pin-indicator__shape {
+ display: grid;
width: 16px;
height: 16px;
- overflow: visible;
- fill: currentColor;
+ place-items: center;
+ transform: scale(1);
+ transform-origin: center;
+}
+
+.pin-indicator__settle,
+.pin-indicator__grow {
+ display: grid;
+ width: 16px;
+ height: 16px;
+ place-items: center;
+ transform-origin: center;
+}
+
+.pin-indicator__settle {
+ animation: pin-dot-settle var(--pin-shape-morph) var(--motion-emphasized) var(--pin-shape-hold) both;
+}
+
+.pin-indicator__grow {
+ animation: pin-dot-grow 160ms var(--motion-emphasized) both;
+}
+
+.pin-indicator[data-indicator-state='exiting'] .pin-indicator__shape {
+ opacity: 0;
+ transform: scale(0);
+ transition: opacity 160ms linear, transform 160ms cubic-bezier(0.4, 0, 1, 1);
+}
+
+@keyframes pin-dot-grow {
+ from { opacity: 0; transform: scale(0); }
+ to { opacity: 1; transform: scale(1.5); }
}
-.pin-indicator--entering {
- animation: pin-dot-enter 380ms var(--motion-emphasized) both;
+@keyframes pin-dot-settle {
+ from { transform: scale(1); }
+ to { transform: scale(0.5); }
}
-@keyframes pin-dot-enter {
- 0% { opacity: 0; transform: scale(0); }
- 28% { opacity: 1; transform: scale(1.75); }
- 100% { opacity: 1; transform: scale(1); }
+.pin-indicator svg {
+ display: block;
+ width: 16px;
+ height: 16px;
+ overflow: visible;
+ fill: currentColor;
}
.pin-pad__message {
@@ -119,7 +154,7 @@ html[data-app-covered='true'] body > dialog {
}
.pin-keypad {
- --pin-key-size: clamp(58px, 18vw, 74px);
+ --pin-key-size: clamp(66px, 20.63vw, 85px);
display: grid;
grid-template-columns: repeat(3, var(--pin-key-size));
gap: clamp(9px, 2.8vw, 15px) clamp(14px, 4.2vw, 22px);
@@ -135,13 +170,13 @@ html[data-app-covered='true'] body > dialog {
min-height: var(--pin-key-size);
padding: 0;
overflow: hidden;
- border: 1px solid color-mix(in srgb, var(--color-outline-variant) 48%, transparent);
+ border: none;
border-radius: 50%;
background: color-mix(in srgb, var(--color-surface-bright) 72%, var(--color-primary-container));
- box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-surface-bright) 70%, transparent);
+ box-shadow: none;
color: var(--color-on-surface);
cursor: pointer;
- font-size: clamp(22px, 7vw, 29px);
+ font-size: clamp(25px, 7.5vw, 32px);
font-variation-settings: 'ROND' 100, 'wdth' 100;
font-weight: 560;
place-items: center;
@@ -388,7 +423,8 @@ html[data-app-covered='true'] body > dialog {
.pin-management-dialog {
display: flex;
- width: min(100%, 520px);
+ width: 339px;
+ max-width: 100%;
max-height: calc(100dvh - max(32px, env(safe-area-inset-top) + env(safe-area-inset-bottom)));
padding: 0 var(--space-5) var(--space-5);
overflow-y: auto;
@@ -442,7 +478,8 @@ html[data-app-covered='true'] body > dialog {
}
.pin-management-dialog__supporting {
- max-width: 390px;
+ width: 100%;
+ min-height: 57px;
margin: var(--space-4) auto 0;
color: var(--color-on-surface-variant);
font-size: 13px;
@@ -451,7 +488,7 @@ html[data-app-covered='true'] body > dialog {
}
.pin-management-dialog .pin-pad {
- margin-top: var(--space-5);
+ margin-top: var(--space-8);
}
@media (max-height: 700px) {
@@ -492,15 +529,21 @@ html[data-app-covered='true'] body > dialog {
}
.pin-management-dialog {
- width: 100%;
- max-height: 100dvh;
+ width: 100vw;
+ max-width: none;
+ height: 100dvh;
min-height: 100dvh;
+ max-height: none;
padding-bottom: max(var(--space-5), env(safe-area-inset-bottom));
border-radius: 0;
+ align-self: stretch;
+ justify-self: stretch;
}
}
@media (prefers-reduced-motion: reduce) {
+ .pin-indicator,
+ .pin-indicator__shape,
.pin-key,
.pin-key::after,
.settings-switch-row__track,
@@ -508,6 +551,12 @@ html[data-app-covered='true'] body > dialog {
transition: none;
}
+ .pin-indicator__settle,
+ .pin-indicator__grow {
+ animation: none;
+ transform: none;
+ }
+
.pin-key:active {
transform: none;
}
diff --git a/tests/visual/__screenshots__/chromium/1024-light-pin-confirmation.png b/tests/visual/__screenshots__/chromium/1024-light-pin-confirmation.png
new file mode 100644
index 0000000..2d38133
Binary files /dev/null and b/tests/visual/__screenshots__/chromium/1024-light-pin-confirmation.png differ
diff --git a/tests/visual/__screenshots__/chromium/1024-light-pin-setup.png b/tests/visual/__screenshots__/chromium/1024-light-pin-setup.png
new file mode 100644
index 0000000..fe476b2
Binary files /dev/null and b/tests/visual/__screenshots__/chromium/1024-light-pin-setup.png differ
diff --git a/tests/visual/__screenshots__/chromium/412-dark-pin-lockscreen.png b/tests/visual/__screenshots__/chromium/412-dark-pin-lockscreen.png
index f0fb8b1..87d20f2 100644
Binary files a/tests/visual/__screenshots__/chromium/412-dark-pin-lockscreen.png and b/tests/visual/__screenshots__/chromium/412-dark-pin-lockscreen.png differ
diff --git a/tests/visual/__screenshots__/chromium/412-light-pin-lockscreen.png b/tests/visual/__screenshots__/chromium/412-light-pin-lockscreen.png
index fceee96..f45a059 100644
Binary files a/tests/visual/__screenshots__/chromium/412-light-pin-lockscreen.png and b/tests/visual/__screenshots__/chromium/412-light-pin-lockscreen.png differ
diff --git a/tests/visual/__screenshots__/chromium/412-light-pin-setup.png b/tests/visual/__screenshots__/chromium/412-light-pin-setup.png
new file mode 100644
index 0000000..acc7086
Binary files /dev/null and b/tests/visual/__screenshots__/chromium/412-light-pin-setup.png differ
diff --git a/tests/visual/finance-ui.spec.ts b/tests/visual/finance-ui.spec.ts
index 3c544e8..ce12ff3 100644
--- a/tests/visual/finance-ui.spec.ts
+++ b/tests/visual/finance-ui.spec.ts
@@ -395,7 +395,16 @@ test('412 light PIN setup, expressive entry, reload lock, unlock, and disable',
await preparePage(page, context, 'connected', 'light', true);
await page.getByLabel('Einstellungen öffnen').click();
await page.getByRole('button', { name: 'PIN einrichten', exact: true }).click();
- await expect(page.getByRole('dialog', { name: 'PIN einrichten' })).toBeVisible();
+ const setupDialog = page.getByRole('dialog', { name: 'PIN einrichten' });
+ await expect(setupDialog).toBeVisible();
+ const mobileSurfaceBox = await setupDialog.locator('.pin-management-dialog').boundingBox();
+ expect(mobileSurfaceBox?.width).toBe(412);
+ expect(mobileSurfaceBox?.height).toBe(915);
+ const mobileKey = setupDialog.getByRole('button', { name: '1', exact: true });
+ await expect(mobileKey).toHaveCSS('border-style', 'none');
+ expect((await mobileKey.boundingBox())?.width).toBeCloseTo(85, 0);
+ expect(Number.parseFloat(await mobileKey.evaluate((element) => getComputedStyle(element).fontSize))).toBeGreaterThan(30);
+ await expect(setupDialog).toHaveScreenshot('412-light-pin-setup.png');
await enterPin(page, 'PIN einrichten', '123456');
await expect(page.getByRole('dialog', { name: 'Neue PIN bestätigen' })).toBeVisible();
await enterPin(page, 'Neue PIN bestätigen', '123456');
@@ -433,7 +442,12 @@ test('412 light PIN setup, expressive entry, reload lock, unlock, and disable',
const expressivePath = await activeShapePath.getAttribute('d');
await expect(activeShape).toHaveCount(1);
await expect(activeShape).toHaveAttribute('data-start-shape', /^(?!Circle$).+/);
- await expect(activeShape).toHaveCSS('animation-name', 'pin-dot-enter');
+ await expect(activeShape).toHaveAttribute('data-indicator-state', 'active');
+ expect(await activeShape.locator('.pin-indicator__grow').evaluate((element) => (
+ element.getAnimations().flatMap((animation) => (
+ (animation.effect as KeyframeEffect).getKeyframes().map((keyframe) => keyframe.transform)
+ ))
+ ))).toContain('scale(2)');
const centered = await activeShape.evaluate((element) => {
const indicator = element.getBoundingClientRect();
const group = element.parentElement!.getBoundingClientRect();
@@ -444,6 +458,38 @@ test('412 light PIN setup, expressive entry, reload lock, unlock, and disable',
await expect(activeShape).toHaveCSS('width', '16px');
await expect(activeShape).toHaveCSS('height', '16px');
expect(await activeShapePath.getAttribute('d')).not.toBe(expressivePath);
+
+ await lock.getByRole('button', { name: '2', exact: true }).click();
+ await expect(lock.locator('.pin-indicator')).toHaveCount(2);
+ await expect(lock.locator('.pin-indicator').nth(1)).toHaveAttribute('data-morph-progress', '1.000', { timeout: 1_000 });
+ const firstCenterBeforeDeletion = await lock.locator('.pin-indicator').first().evaluate((element) => {
+ const box = element.getBoundingClientRect();
+ return box.left + box.width / 2;
+ });
+ await lock.getByRole('button', { name: 'Letzte PIN-Stelle löschen' }).click();
+ const exitingIndicator = lock.locator('.pin-indicator[data-indicator-state="exiting"]');
+ await expect(exitingIndicator).toHaveCount(1);
+ await expect.poll(() => exitingIndicator.locator('.pin-indicator__shape').evaluate((element) => (
+ element.getBoundingClientRect().width
+ ))).toBeLessThan(16);
+ const firstCenterWhileDeleting = await lock.locator('.pin-indicator').first().evaluate((element) => {
+ const box = element.getBoundingClientRect();
+ return box.left + box.width / 2;
+ });
+ expect(firstCenterWhileDeleting).toBeCloseTo(firstCenterBeforeDeletion, 0);
+ await expect(lock.locator('.pin-indicator')).toHaveCount(1);
+ const distanceFromCenterAfterDeletion = await lock.locator('.pin-indicator').first().evaluate((element) => {
+ const indicator = element.getBoundingClientRect();
+ const group = element.parentElement!.getBoundingClientRect();
+ return Math.abs((indicator.left + indicator.width / 2) - (group.left + group.width / 2));
+ });
+ expect(distanceFromCenterAfterDeletion).toBeGreaterThan(1);
+ await expect.poll(() => lock.locator('.pin-indicator').first().evaluate((element) => {
+ const indicator = element.getBoundingClientRect();
+ const group = element.parentElement!.getBoundingClientRect();
+ return Math.abs((indicator.left + indicator.width / 2) - (group.left + group.width / 2));
+ })).toBeLessThan(1);
+
for (const digit of '23456') await lock.getByRole('button', { name: digit, exact: true }).click();
await lock.getByRole('button', { name: 'PIN bestätigen' }).click();
@@ -463,6 +509,32 @@ test('412 light PIN setup, expressive entry, reload lock, unlock, and disable',
expect(runtimeErrors).toEqual([]);
});
+test('desktop PIN setup and confirmation share stable modal geometry', async ({ page, context }) => {
+ await page.setViewportSize({ width: 1024, height: 900 });
+ await preparePage(page, context, 'connected', 'light', true);
+ await page.getByLabel('Einstellungen öffnen').click();
+ await page.getByRole('button', { name: 'PIN einrichten', exact: true }).click();
+
+ const setup = page.getByRole('dialog', { name: 'PIN einrichten', exact: true });
+ const setupSurface = setup.locator('.pin-management-dialog');
+ await expect(setupSurface).toBeVisible();
+ const setupBox = await setupSurface.boundingBox();
+ expect(setupBox?.width).toBe(339);
+ expect(setupBox?.height).toBeLessThan(900);
+ expect(setupBox?.height).toBeGreaterThan(0);
+ await expect(setup.getByRole('button', { name: '1', exact: true })).toHaveCSS('width', '85px');
+ await expect(setupSurface).toHaveScreenshot('1024-light-pin-setup.png');
+
+ await enterPin(page, 'PIN einrichten', '123456');
+ const confirmation = page.getByRole('dialog', { name: 'Neue PIN bestätigen', exact: true });
+ const confirmationSurface = confirmation.locator('.pin-management-dialog');
+ await expect(confirmationSurface).toBeVisible();
+ const confirmationBox = await confirmationSurface.boundingBox();
+ expect(confirmationBox?.width).toBe(setupBox?.width);
+ expect(confirmationBox?.height).toBe(setupBox?.height);
+ await expect(confirmationSurface).toHaveScreenshot('1024-light-pin-confirmation.png');
+});
+
test('PIN setup remains usable after an unexpected Web Crypto rejection', async ({ page, context }) => {
await preparePage(page, context, 'connected', 'light', true);
await page.evaluate(() => {