diff --git a/src/components/PinPad.tsx b/src/components/PinPad.tsx index 4e47547..0837576 100644 --- a/src/components/PinPad.tsx +++ b/src/components/PinPad.tsx @@ -1,6 +1,6 @@ import { useReducedMotion } from 'motion/react'; import { useMorph } from 'shape-morph/react'; -import { useEffect, useState, type KeyboardEvent, type RefObject } from 'react'; +import { useCallback, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type RefObject } from 'react'; import { PIN_LENGTH } from '../privacy/appProtectionStore'; import { chooseExpressivePinShape, @@ -8,13 +8,29 @@ import { } from '../privacy/expressivePinShapes'; import { Icon } from './Icon'; -const PIN_SHAPE_HOLD_MS = 80; -const PIN_SHAPE_MORPH_MS = 220; +const PIN_SHAPE_HOLD_MS = 260; +const PIN_SHAPE_MORPH_MS = 140; +const PIN_DOT_EXIT_MS = 160; +const PIN_DOT_STEP_PX = 24; -function MorphingPinDot({ shape }: { shape: ExpressivePinShapeName }) { +type PinIndicatorItem = { + id: number; + phase: 'active' | 'exiting'; + shape: ExpressivePinShapeName; +}; + +function MorphingPinDot({ + item, + onExitComplete, + position, +}: { + item: PinIndicatorItem; + onExitComplete: (id: number) => void; + position: number; +}) { const reducedMotion = Boolean(useReducedMotion()); const [morphToCircle, setMorphToCircle] = useState(reducedMotion); - const { pathD, progress } = useMorph(shape, 'Circle', { + const { pathD, progress } = useMorph(item.shape, 'Circle', { duration: PIN_SHAPE_MORPH_MS, progress: morphToCircle ? 1 : 0, size: 100, @@ -29,41 +45,106 @@ function MorphingPinDot({ shape }: { shape: ExpressivePinShapeName }) { return () => window.clearTimeout(timer); }, [reducedMotion]); + useEffect(() => { + if (item.phase !== 'exiting') return; + if (reducedMotion) { + onExitComplete(item.id); + return; + } + const fallback = window.setTimeout(() => onExitComplete(item.id), PIN_DOT_EXIT_MS + 40); + return () => window.clearTimeout(fallback); + }, [item.id, item.phase, onExitComplete, reducedMotion]); + return ( - - - + { + if (item.phase === 'exiting' && event.propertyName === 'transform') onExitComplete(item.id); + }} + style={{ + '--pin-shape-hold': `${PIN_SHAPE_HOLD_MS}ms`, + '--pin-shape-morph': `${PIN_SHAPE_MORPH_MS}ms`, + } as CSSProperties} + > + + + + + + + + ); } function PinIndicators({ length, resetToken }: { length: number; resetToken?: number }) { - const [shapes, setShapes] = useState(() => - Array.from({ length }, chooseExpressivePinShape)); + const nextIndicatorId = useRef(length); + const previousResetToken = useRef(resetToken); + const [indicators, setIndicators] = useState(() => + 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 ( ); } 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(() => {