c1ac77bc6a
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
- NewGame: all three forms (quick / friends / hotseat) now use the Profile sub-view's natural-flow .page (no forced height, no pinned-CTA .grow/.fg scroll), so the screen's single .content scroll + --vvh handle overflow and a focused name input scrolls into view cleanly — fixes the keyboard blank-space / deep-scroll regression. One layout rule, one place. - PinPad: pause 250ms after the 4th digit before the verdict, so the fill (and the shake on a wrong PIN) reads as a deliberate response. - e2e: typePin waits for empty dots before typing (robust to the pause).
269 lines
7.5 KiB
Svelte
269 lines
7.5 KiB
Svelte
<script lang="ts">
|
|
// Apple-lock-screen-style 4-digit PIN pad for offline pass-and-play (hotseat) games.
|
|
// Modes:
|
|
// set — enter a PIN, then repeat it to confirm; emits { kind: 'set', lock }.
|
|
// verify — enter a PIN; runs `verify(pin)` on the 4th digit; emits { kind: 'verified' }
|
|
// or shakes + clears on a wrong PIN.
|
|
// change — run `verify` on the current PIN, then offer "set a new PIN" (the set flow)
|
|
// or, when allowRemove, "remove PIN" ({ kind: 'removed' }).
|
|
// Verification is delegated to the caller's `verify` callback: at creation time it checks a
|
|
// UI-held lock (lib/pin verifyPin); in-game it delegates to the local source (which holds the
|
|
// stored lock). The pad never shows an OK button: entering the 4th digit is the action. PIN
|
|
// storage is a social lock, not cryptography — see lib/pin.ts.
|
|
import Modal from './Modal.svelte';
|
|
import { t } from '../lib/i18n/index.svelte';
|
|
import { newLock, type PinLock } from '../lib/pin';
|
|
|
|
/** PinResult is the outcome handed to onresult when the pad completes. */
|
|
export type PinResult =
|
|
| { kind: 'set'; lock: PinLock }
|
|
| { kind: 'verified' }
|
|
| { kind: 'removed' };
|
|
|
|
let {
|
|
mode,
|
|
title = '',
|
|
verify,
|
|
allowRemove = false,
|
|
onclose,
|
|
onresult,
|
|
}: {
|
|
mode: 'set' | 'verify' | 'change';
|
|
title?: string;
|
|
/** Verifies an entered PIN (required for 'verify' and 'change'). */
|
|
verify?: (pin: string) => Promise<boolean>;
|
|
allowRemove?: boolean;
|
|
onclose?: () => void;
|
|
onresult: (r: PinResult) => void;
|
|
} = $props();
|
|
|
|
const LEN = 4;
|
|
|
|
// 'old' verifies the existing PIN (change); 'menu' offers set-new / remove (change);
|
|
// 'enter' collects a PIN; 'confirm' re-enters it (set / change→new). `mode` is fixed
|
|
// for the pad's lifetime (callers mount a fresh pad per operation), so capturing its
|
|
// initial value here is intentional.
|
|
// svelte-ignore state_referenced_locally
|
|
let phase = $state<'old' | 'menu' | 'enter' | 'confirm'>(mode === 'change' ? 'old' : 'enter');
|
|
let buffer = $state('');
|
|
let first = $state(''); // the first entry, awaiting confirmation
|
|
let error = $state(false); // drives the error prompt + shake
|
|
let shakeSeq = $state(0); // bumped on each error to replay the shake animation
|
|
let busy = $state(false); // guards the async check between the 4th digit and the outcome
|
|
|
|
function promptText(): string {
|
|
if (error) return phase === 'confirm' ? t('pin.mismatch') : t('pin.wrong');
|
|
switch (phase) {
|
|
case 'old':
|
|
return t('pin.enterCurrent');
|
|
case 'confirm':
|
|
return t('pin.repeat');
|
|
default:
|
|
return mode === 'verify' ? t('pin.enter') : t('pin.create');
|
|
}
|
|
}
|
|
|
|
function fail(): void {
|
|
buffer = '';
|
|
error = true;
|
|
shakeSeq += 1;
|
|
}
|
|
|
|
async function commit(): Promise<void> {
|
|
busy = true;
|
|
try {
|
|
// Let the 4th dot register before the verdict: a beat so the fill (and, on failure, the shake)
|
|
// reads as a deliberate response rather than an instant flicker.
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
if (phase === 'old') {
|
|
if (verify && (await verify(buffer))) {
|
|
buffer = '';
|
|
error = false;
|
|
phase = 'menu';
|
|
} else fail();
|
|
} else if (phase === 'enter' && mode === 'verify') {
|
|
if (verify && (await verify(buffer))) onresult({ kind: 'verified' });
|
|
else fail();
|
|
} else if (phase === 'enter') {
|
|
first = buffer;
|
|
buffer = '';
|
|
error = false;
|
|
phase = 'confirm';
|
|
} else if (phase === 'confirm') {
|
|
if (buffer === first) onresult({ kind: 'set', lock: await newLock(buffer) });
|
|
else {
|
|
first = '';
|
|
phase = 'enter';
|
|
fail();
|
|
}
|
|
}
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
function press(d: string): void {
|
|
if (busy || phase === 'menu' || buffer.length >= LEN) return;
|
|
error = false;
|
|
buffer += d;
|
|
if (buffer.length === LEN) void commit();
|
|
}
|
|
|
|
function back(): void {
|
|
if (busy || phase === 'menu') return;
|
|
buffer = buffer.slice(0, -1);
|
|
}
|
|
|
|
function startNew(): void {
|
|
error = false;
|
|
first = '';
|
|
buffer = '';
|
|
phase = 'enter';
|
|
}
|
|
|
|
function onkey(e: KeyboardEvent): void {
|
|
if (e.key >= '0' && e.key <= '9') {
|
|
press(e.key);
|
|
e.preventDefault();
|
|
} else if (e.key === 'Backspace') {
|
|
back();
|
|
e.preventDefault();
|
|
} else if (e.key === 'Escape') {
|
|
onclose?.();
|
|
}
|
|
}
|
|
|
|
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
|
const slots = Array.from({ length: LEN }, (_, i) => i);
|
|
</script>
|
|
|
|
<svelte:window onkeydown={onkey} />
|
|
|
|
<Modal {title} {onclose} overlayKeyboard>
|
|
<div class="pad">
|
|
{#if phase === 'menu'}
|
|
<div class="menu">
|
|
<button class="opt" onclick={startNew}>{t('pin.setNew')}</button>
|
|
{#if allowRemove}
|
|
<button class="opt danger" onclick={() => onresult({ kind: 'removed' })}>
|
|
{t('pin.remove')}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<p class="prompt" class:err={error}>{promptText()}</p>
|
|
{#key shakeSeq}
|
|
<div class="dots" class:shake={error} aria-hidden="true">
|
|
{#each slots as i}
|
|
<span class="dot" class:on={i < buffer.length}></span>
|
|
{/each}
|
|
</div>
|
|
{/key}
|
|
<div class="grid">
|
|
{#each keys as k}
|
|
<button class="key" onclick={() => press(k)} aria-label={k}>{k}</button>
|
|
{/each}
|
|
<span class="key spacer"></span>
|
|
<button class="key" onclick={() => press('0')} aria-label="0">0</button>
|
|
<button class="key back" onclick={back} aria-label="⌫" disabled={buffer.length === 0}>⌫</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</Modal>
|
|
|
|
<style>
|
|
.pad {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding-top: 4px;
|
|
}
|
|
.prompt {
|
|
margin: 0;
|
|
font-size: 0.95rem;
|
|
color: var(--muted, var(--text));
|
|
min-height: 1.2em;
|
|
}
|
|
.prompt.err {
|
|
color: var(--danger);
|
|
}
|
|
.dots {
|
|
display: flex;
|
|
gap: 16px;
|
|
}
|
|
.dot {
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 50%;
|
|
border: 1.5px solid var(--text);
|
|
box-sizing: border-box;
|
|
}
|
|
.dot.on {
|
|
background: var(--text);
|
|
}
|
|
.shake {
|
|
animation: shake 0.32s ease;
|
|
}
|
|
@keyframes shake {
|
|
0%, 100% { transform: translateX(0); }
|
|
20% { transform: translateX(-7px); }
|
|
40% { transform: translateX(7px); }
|
|
60% { transform: translateX(-5px); }
|
|
80% { transform: translateX(5px); }
|
|
}
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 12px;
|
|
}
|
|
.key {
|
|
width: 62px;
|
|
height: 62px;
|
|
border-radius: 50%;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
font-size: 1.4rem;
|
|
font-weight: 500;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.key:active {
|
|
background: var(--accent);
|
|
color: var(--accent-text);
|
|
}
|
|
.key.back {
|
|
border-color: transparent;
|
|
background: transparent;
|
|
font-size: 1.2rem;
|
|
}
|
|
.key.back:disabled {
|
|
opacity: 0.3;
|
|
}
|
|
.key.spacer {
|
|
border: none;
|
|
background: none;
|
|
pointer-events: none;
|
|
}
|
|
.menu {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
width: 100%;
|
|
}
|
|
.opt {
|
|
padding: 14px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
border-radius: var(--radius);
|
|
font-weight: 600;
|
|
}
|
|
.opt.danger {
|
|
border-color: var(--danger);
|
|
color: var(--danger);
|
|
}
|
|
</style>
|