mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:01:31 +00:00
FIX - modernize lock screen passcode flow
Rebuild device unlock around the current iOS-style wallpaper, outlined keypad, telephone letters, status bar, and keyboard support. Require the passcode only for the first unlock of each open device session so a soft lock from the status bar returns directly after the session is authenticated.
This commit is contained in:
@@ -321,6 +321,7 @@ const passcodeError = ref('')
|
||||
const passcodeResetKey = ref(0)
|
||||
const passcodeRetrySeconds = ref(0)
|
||||
const passcodeVisible = ref(false)
|
||||
const passcodeRequired = ref(false)
|
||||
const setupPreviewDismissed = ref(false)
|
||||
const pendingUnlockRoute = ref<string | null>(null)
|
||||
const unlockedServicesLoaded = ref(false)
|
||||
@@ -521,6 +522,7 @@ function completePhoneSetup(): void {
|
||||
isLocked.value = false
|
||||
isUnlocking.value = false
|
||||
passcodeVisible.value = false
|
||||
passcodeRequired.value = false
|
||||
controlCenterOpened.value = false
|
||||
void router.replace('/')
|
||||
loadUnlockedPhoneData()
|
||||
@@ -1116,6 +1118,7 @@ function finishUnlock(): void {
|
||||
isUnlocking.value = true
|
||||
isLocked.value = false
|
||||
passcodeVisible.value = false
|
||||
passcodeRequired.value = false
|
||||
passcodeError.value = ''
|
||||
|
||||
unlockTimer = window.setTimeout(() => {
|
||||
@@ -1132,7 +1135,7 @@ function finishUnlock(): void {
|
||||
|
||||
function unlockPhone(): void {
|
||||
if (!isLocked.value) return
|
||||
if (phone.security.enabled) {
|
||||
if (phone.security.enabled && passcodeRequired.value) {
|
||||
passcodeError.value = ''
|
||||
passcodeVisible.value = true
|
||||
return
|
||||
@@ -1219,6 +1222,7 @@ function lockPhone(): void {
|
||||
passcodeVisible.value = false
|
||||
passcodeBusy.value = false
|
||||
passcodeError.value = ''
|
||||
passcodeRequired.value = false
|
||||
pendingUnlockRoute.value = null
|
||||
isLocked.value = true
|
||||
}
|
||||
@@ -1336,6 +1340,7 @@ watch(
|
||||
passcodeVisible.value = false
|
||||
passcodeBusy.value = false
|
||||
passcodeError.value = ''
|
||||
passcodeRequired.value = false
|
||||
pendingUnlockRoute.value = null
|
||||
unlockedServicesLoaded.value = false
|
||||
if (passcodeLockTimer !== undefined) {
|
||||
@@ -1347,6 +1352,7 @@ watch(
|
||||
isLocked.value = setupRequired.value
|
||||
? false
|
||||
: !isDevelopment || developmentLockScreenPreview
|
||||
passcodeRequired.value = isLocked.value && phone.security.enabled
|
||||
unlockedServicesLoaded.value = false
|
||||
controlCenterOpened.value = false
|
||||
weather.start()
|
||||
@@ -1519,6 +1525,7 @@ onBeforeUnmount(() => {
|
||||
:disabled="passcodeRetrySeconds > 0"
|
||||
:error="passcodeError"
|
||||
:length="phone.security.length ?? 6"
|
||||
lock-screen
|
||||
:reset-key="passcodeResetKey"
|
||||
:subtitle="
|
||||
passcodeRetrySeconds > 0
|
||||
|
||||
@@ -22,6 +22,19 @@ describe('browser development preview contract', () => {
|
||||
expect(source).toContain('else loadUnlockedPhoneData()')
|
||||
})
|
||||
|
||||
it('requires the passcode only for the first unlock of a device session', () => {
|
||||
expect(source).toContain('const passcodeRequired = ref(false)')
|
||||
expect(source).toContain(
|
||||
'if (phone.security.enabled && passcodeRequired.value)',
|
||||
)
|
||||
expect(source).toContain(
|
||||
'passcodeRequired.value = isLocked.value && phone.security.enabled',
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/function lockPhone\(\): void \{[\s\S]*?passcodeRequired\.value = false[\s\S]*?isLocked\.value = true/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps hairlines at one rendered device pixel through phone zoom', () => {
|
||||
expect(source).toContain(
|
||||
'...getHairlinePixelStyle(phoneZoom.value, browserDevicePixelRatio.value)',
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./PhonePasscode.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('PhonePasscode iOS presentation contract', () => {
|
||||
it('uses the Lock Screen wallpaper and status bar for device unlocks', () => {
|
||||
expect(source).toContain('lockScreen?: boolean')
|
||||
expect(source).toContain('settings.lockWallpaperImageUrl')
|
||||
expect(source).toContain(
|
||||
'wallpaper--${phone.preferences.settings.lockWallpaper}',
|
||||
)
|
||||
expect(source).toContain('<PhoneStatusBar v-if="lockScreen"')
|
||||
})
|
||||
|
||||
it('renders the telephone letters and accessible native-size controls', () => {
|
||||
expect(source).toContain("{ digit: 2, letters: 'ABC' }")
|
||||
expect(source).toContain("{ digit: 7, letters: 'PQRS' }")
|
||||
expect(source).toContain('min-height: var(--sky-touch-target)')
|
||||
expect(source).toContain('passcode-screen__footer')
|
||||
expect(source).toContain('--passcode-key-background: transparent')
|
||||
expect(source).toContain('@keydown="handleKeydown"')
|
||||
})
|
||||
|
||||
it('keeps light, dark and reduced-motion treatments explicit', () => {
|
||||
expect(source).toContain(':global(.phone-app--light)')
|
||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
expect(source).toContain('passcode-screen__backdrop')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Delete } from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch, type CSSProperties } from 'vue'
|
||||
|
||||
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -11,6 +12,7 @@ const props = withDefaults(
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
length: 4 | 6
|
||||
lockScreen?: boolean
|
||||
resetKey?: number
|
||||
subtitle?: string
|
||||
title: string
|
||||
@@ -20,6 +22,7 @@ const props = withDefaults(
|
||||
cancelable: true,
|
||||
disabled: false,
|
||||
error: '',
|
||||
lockScreen: false,
|
||||
resetKey: 0,
|
||||
subtitle: '',
|
||||
},
|
||||
@@ -32,8 +35,40 @@ const emit = defineEmits<{
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const digits = ref('')
|
||||
const keypad = [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
const screen = ref<HTMLElement | null>(null)
|
||||
const keypad = [
|
||||
{ digit: 1, letters: '' },
|
||||
{ digit: 2, letters: 'ABC' },
|
||||
{ digit: 3, letters: 'DEF' },
|
||||
{ digit: 4, letters: 'GHI' },
|
||||
{ digit: 5, letters: 'JKL' },
|
||||
{ digit: 6, letters: 'MNO' },
|
||||
{ digit: 7, letters: 'PQRS' },
|
||||
{ digit: 8, letters: 'TUV' },
|
||||
{ digit: 9, letters: 'WXYZ' },
|
||||
] as const
|
||||
const inputDisabled = computed(() => props.busy || props.disabled)
|
||||
const screenClass = computed(() =>
|
||||
props.lockScreen
|
||||
? [
|
||||
`passcode-screen--lock`,
|
||||
`wallpaper--${phone.preferences.settings.lockWallpaper}`,
|
||||
]
|
||||
: ['passcode-screen--surface'],
|
||||
)
|
||||
const screenStyle = computed<CSSProperties>(() => {
|
||||
const imageUrl = phone.preferences.settings.lockWallpaperImageUrl
|
||||
if (
|
||||
!props.lockScreen ||
|
||||
phone.preferences.settings.lockWallpaper !== 'custom' ||
|
||||
!imageUrl
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'--phone-wallpaper-image': `url(${JSON.stringify(imageUrl)})`,
|
||||
}
|
||||
})
|
||||
|
||||
function enterDigit(digit: number): void {
|
||||
if (inputDisabled.value || digits.value.length >= props.length) return
|
||||
@@ -46,170 +81,338 @@ function removeDigit(): void {
|
||||
digits.value = digits.value.slice(0, -1)
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (/^[0-9]$/.test(event.key)) {
|
||||
event.preventDefault()
|
||||
enterDigit(Number(event.key))
|
||||
return
|
||||
}
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
removeDigit()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape' && props.cancelable && !props.busy) {
|
||||
event.preventDefault()
|
||||
emit('cancel')
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
() => {
|
||||
digits.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => screen.value?.focus())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="passcode-screen" :aria-label="title">
|
||||
<header class="passcode-screen__header">
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="subtitle">{{ subtitle }}</p>
|
||||
<div class="passcode-screen__dots" aria-hidden="true">
|
||||
<span
|
||||
v-for="index in length"
|
||||
:key="index"
|
||||
:class="{ 'passcode-screen__dot--filled': digits.length >= index }"
|
||||
></span>
|
||||
</div>
|
||||
<p
|
||||
v-if="error"
|
||||
class="passcode-screen__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</header>
|
||||
<section
|
||||
ref="screen"
|
||||
class="passcode-screen"
|
||||
:class="screenClass"
|
||||
:style="screenStyle"
|
||||
:aria-label="title"
|
||||
:aria-busy="busy"
|
||||
tabindex="-1"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<PhoneStatusBar v-if="lockScreen" :interactive="false" />
|
||||
<div class="passcode-screen__backdrop" aria-hidden="true"></div>
|
||||
|
||||
<div class="passcode-screen__keypad">
|
||||
<button
|
||||
v-for="digit in keypad"
|
||||
:key="digit"
|
||||
type="button"
|
||||
:disabled="inputDisabled"
|
||||
@click="enterDigit(digit)"
|
||||
>
|
||||
{{ digit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="passcode-screen__action"
|
||||
:disabled="!cancelable || busy"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ cancelable ? phone.t('LockScreen.passcode.cancel') : '' }}
|
||||
</button>
|
||||
<button type="button" :disabled="inputDisabled" @click="enterDigit(0)">
|
||||
0
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="passcode-screen__action"
|
||||
:aria-label="phone.t('LockScreen.passcode.delete')"
|
||||
:disabled="inputDisabled || digits.length === 0"
|
||||
@click="removeDigit"
|
||||
>
|
||||
<Delete :size="25" :stroke-width="1.7" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="passcode-screen__layout">
|
||||
<header class="passcode-screen__header">
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="subtitle">{{ subtitle }}</p>
|
||||
<div class="passcode-screen__dots" aria-hidden="true">
|
||||
<span
|
||||
v-for="index in length"
|
||||
:key="index"
|
||||
:class="{ 'passcode-screen__dot--filled': digits.length >= index }"
|
||||
></span>
|
||||
</div>
|
||||
<p v-if="error" class="passcode-screen__error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="passcode-screen__keypad">
|
||||
<button
|
||||
v-for="key in keypad"
|
||||
:key="key.digit"
|
||||
type="button"
|
||||
class="passcode-screen__key"
|
||||
:class="{ 'passcode-screen__key--single': !key.letters }"
|
||||
:aria-label="String(key.digit)"
|
||||
:disabled="inputDisabled"
|
||||
@click="enterDigit(key.digit)"
|
||||
>
|
||||
<span>{{ key.digit }}</span>
|
||||
<small v-if="key.letters">{{ key.letters }}</small>
|
||||
</button>
|
||||
<span aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="passcode-screen__key passcode-screen__key--single"
|
||||
aria-label="0"
|
||||
:disabled="inputDisabled"
|
||||
@click="enterDigit(0)"
|
||||
>
|
||||
<span>0</span>
|
||||
</button>
|
||||
<span aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<footer class="passcode-screen__footer">
|
||||
<span aria-hidden="true"></span>
|
||||
<button
|
||||
v-if="digits.length > 0"
|
||||
type="button"
|
||||
class="passcode-screen__action"
|
||||
:aria-label="phone.t('LockScreen.passcode.delete')"
|
||||
:disabled="inputDisabled || digits.length === 0"
|
||||
@click="removeDigit"
|
||||
>
|
||||
<Delete :size="24" :stroke-width="1.7" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="passcode-screen__action"
|
||||
:disabled="!cancelable || busy"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ cancelable ? phone.t('LockScreen.passcode.cancel') : '' }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.passcode-screen {
|
||||
--passcode-text: #fff;
|
||||
--passcode-muted: rgb(255 255 255 / 72%);
|
||||
--passcode-key-background: transparent;
|
||||
--passcode-key-border: rgb(255 255 255 / 58%);
|
||||
--passcode-key-pressed: rgb(255 255 255 / 24%);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 76px 28px 30px;
|
||||
color: white;
|
||||
background:
|
||||
radial-gradient(circle at 50% 16%, rgb(76 92 132 / 46%), transparent 35%),
|
||||
linear-gradient(160deg, #182139, #080b12 72%);
|
||||
overflow: hidden;
|
||||
color: var(--passcode-text);
|
||||
background-color: #080b12;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
outline: none;
|
||||
font-family: var(--sky-font-family);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.passcode-screen--surface {
|
||||
background-image:
|
||||
radial-gradient(circle at 50% 16%, rgb(76 92 132 / 46%), transparent 35%),
|
||||
linear-gradient(160deg, #182139, #080b12 72%);
|
||||
}
|
||||
|
||||
.passcode-screen--lock {
|
||||
background-color: #090a0d;
|
||||
}
|
||||
|
||||
.passcode-screen__backdrop {
|
||||
position: absolute;
|
||||
inset: -16px;
|
||||
z-index: 0;
|
||||
background: linear-gradient(180deg, rgb(0 0 0 / 32%), rgb(0 0 0 / 48%));
|
||||
backdrop-filter: blur(18px) saturate(0.76);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(0.76);
|
||||
}
|
||||
|
||||
.passcode-screen--surface .passcode-screen__backdrop {
|
||||
background: rgb(0 0 0 / 8%);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.passcode-screen :deep(.phone-status-bar) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.passcode-screen__layout {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 116px 28px 34px;
|
||||
}
|
||||
|
||||
.passcode-screen__header {
|
||||
min-height: 170px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.passcode-screen__header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 19px;
|
||||
font-weight: 430;
|
||||
letter-spacing: -0.015em;
|
||||
text-shadow: 0 1px 8px rgb(0 0 0 / 36%);
|
||||
}
|
||||
|
||||
.passcode-screen__header p {
|
||||
max-width: 270px;
|
||||
margin: 8px auto 0;
|
||||
color: rgb(255 255 255 / 72%);
|
||||
margin: 7px auto 0;
|
||||
color: var(--passcode-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
line-height: 1.4;
|
||||
text-shadow: 0 1px 6px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.passcode-screen--lock
|
||||
.passcode-screen__header
|
||||
> p:not(.passcode-screen__error) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.passcode-screen__dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
margin-top: 28px;
|
||||
gap: 22px;
|
||||
margin-top: 29px;
|
||||
}
|
||||
|
||||
.passcode-screen__dots span {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid rgb(255 255 255 / 78%);
|
||||
border: 1.25px solid currentColor;
|
||||
border-radius: 50%;
|
||||
transition: background-color 120ms ease, transform 120ms ease;
|
||||
background: transparent;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 25%);
|
||||
transition:
|
||||
background-color var(--sky-transition-fast) ease,
|
||||
transform var(--sky-transition-fast) ease;
|
||||
}
|
||||
|
||||
.passcode-screen__dots .passcode-screen__dot--filled {
|
||||
background: white;
|
||||
transform: scale(1.06);
|
||||
background: currentColor;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.passcode-screen__header .passcode-screen__error {
|
||||
color: #ff9b93;
|
||||
font-weight: 500;
|
||||
min-height: 18px;
|
||||
color: #ffb4ae;
|
||||
font-weight: 570;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 72px);
|
||||
gap: 15px 20px;
|
||||
grid-template-columns: repeat(3, 80px);
|
||||
gap: 14px 24px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
width: max-content;
|
||||
margin: 48px auto 0;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad button:not(.passcode-screen__action) {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 0;
|
||||
.passcode-screen__key {
|
||||
display: flex;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1.25px solid var(--passcode-key-border);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
background: rgb(255 255 255 / 16%);
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
backdrop-filter: blur(18px);
|
||||
transition: background-color 100ms ease, transform 100ms ease;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: var(--passcode-key-background);
|
||||
box-shadow: 0 1px 5px rgb(0 0 0 / 12%);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--sky-transition-fast) ease,
|
||||
transform var(--sky-transition-fast) ease;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad button:not(.passcode-screen__action):active {
|
||||
background: rgb(255 255 255 / 34%);
|
||||
.passcode-screen__key span {
|
||||
font-size: 32px;
|
||||
font-weight: 320;
|
||||
line-height: 32px;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.passcode-screen__key small {
|
||||
min-height: 11px;
|
||||
margin-top: 3px;
|
||||
font-size: 9px;
|
||||
font-weight: 650;
|
||||
line-height: 9px;
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
|
||||
.passcode-screen__key--single span {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.passcode-screen__key:active {
|
||||
background: var(--passcode-key-pressed);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.passcode-screen__keypad button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad .passcode-screen__action {
|
||||
.passcode-screen__footer {
|
||||
position: absolute;
|
||||
right: 28px;
|
||||
bottom: 35px;
|
||||
left: 28px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.passcode-screen__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 72px;
|
||||
min-height: 48px;
|
||||
justify-self: end;
|
||||
min-width: 96px;
|
||||
min-height: var(--sky-touch-target);
|
||||
border: 0;
|
||||
color: white;
|
||||
border-radius: var(--sky-radius-pill);
|
||||
padding: 0 8px;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 430;
|
||||
cursor: pointer;
|
||||
text-shadow: 0 1px 5px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.passcode-screen__action:active:not(:disabled) {
|
||||
background: rgb(255 255 255 / 12%);
|
||||
}
|
||||
|
||||
:global(.phone-app--light) .passcode-screen--surface {
|
||||
--passcode-text: #111;
|
||||
--passcode-muted: rgb(0 0 0 / 55%);
|
||||
--passcode-key-background: transparent;
|
||||
--passcode-key-border: rgb(0 0 0 / 32%);
|
||||
--passcode-key-pressed: rgb(0 0 0 / 12%);
|
||||
background-color: #f2f2f7;
|
||||
background-image:
|
||||
radial-gradient(circle at 50% 18%, rgb(188 211 255 / 72%), transparent 38%),
|
||||
linear-gradient(160deg, #f8f8fb, #e8e8ee 72%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.passcode-screen__dots span,
|
||||
.passcode-screen__key {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user