diff --git a/apps/desktop/__tests__/vault-doors.test.ts b/apps/desktop/__tests__/vault-doors.test.ts new file mode 100644 index 0000000..bf37cc8 --- /dev/null +++ b/apps/desktop/__tests__/vault-doors.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { DOOR_MOTION, doorDuration } from '../src/renderer/src/components/VaultDoors'; + +describe('doorDuration', () => { + it('shuts faster than it opens, so locking feels like a latch', () => { + expect(doorDuration('closing', false)).toBeLessThan(doorDuration('opening', false)); + expect(doorDuration('opening', false)).toBe(DOOR_MOTION.opening.ms); + }); + + it('collapses to nothing when the user asked for less motion', () => { + expect(doorDuration('opening', true)).toBe(0); + expect(doorDuration('closing', true)).toBe(0); + }); + + it('opens over the 450–650ms the design calls for', () => { + expect(DOOR_MOTION.opening.ms).toBeGreaterThanOrEqual(450); + expect(DOOR_MOTION.opening.ms).toBeLessThanOrEqual(650); + }); +}); diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 97d1e49..e41875a 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,9 +1,17 @@ import { Button } from '@constructive-io/ui/button'; import { Separator } from '@constructive-io/ui/separator'; import { Toaster } from '@constructive-io/ui/sonner'; -import { KeyRound, Lock, Settings, ShieldCheck, Timer, Wrench } from 'lucide-react'; +import { + KeyRound, + Lock, + Settings, + ShieldCheck, + Timer, + Wrench, +} from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; +import { DoorState, VaultDoors } from './components/VaultDoors'; import { dcrypt } from './lib/ipc'; import { ThemeProvider, useThemeMode } from './lib/theme-context'; import { SettingsScreen } from './screens/SettingsScreen'; @@ -14,6 +22,9 @@ import { VaultScreen } from './screens/VaultScreen'; type Tab = 'vault' | 'codes' | 'tools' | 'settings'; +/** `opening`/`closing` are the door transitions; the vault is mounted for all but `locked`. */ +type Phase = 'locked' | 'opening' | 'unlocked' | 'closing'; + const NAV: { id: Tab; label: string; icon: typeof KeyRound }[] = [ { id: 'vault', label: 'Vault', icon: KeyRound }, { id: 'codes', label: 'Codes', icon: Timer }, @@ -23,65 +34,99 @@ const NAV: { id: Tab; label: string; icon: typeof KeyRound }[] = [ const AppContent = () => { const { dark } = useThemeMode(); - const [unlocked, setUnlocked] = useState(false); + const [phase, setPhase] = useState('locked'); + const [working, setWorking] = useState(false); const [tab, setTab] = useState('vault'); - useEffect(() => dcrypt.onLocked(() => setUnlocked(false)), []); + // the main process locks on its own for menu actions and after a restore + useEffect( + () => + dcrypt.onLocked(() => + setPhase((current) => (current === 'unlocked' ? 'closing' : current)) + ), + [] + ); useEffect(() => { - void dcrypt.vault.status().then((s) => setUnlocked(s.unlocked)); + void dcrypt.vault + .status() + .then((s) => setPhase(s.unlocked ? 'unlocked' : 'locked')); }, []); - // switch to the unlock screen first: the flush behind `vault.lock()` is fast - // but not instant, and waiting on it makes the click feel stuck + // close the doors and lock at the same time: the flush behind `vault.lock()` + // is fast, so the animation covers it entirely const lock = useCallback(() => { - setUnlocked(false); + setPhase('closing'); void dcrypt.vault.lock(); }, []); - if (!unlocked) { - return ( - <> - setUnlocked(true)} /> - - - ); - } + const doorState: DoorState = + phase === 'opening' + ? 'opening' + : phase === 'closing' + ? 'closing' + : 'closed'; + const settled = useCallback(() => { + setWorking(false); + setPhase((current) => (current === 'opening' ? 'unlocked' : 'locked')); + }, []); return ( -
- -
- {tab === 'vault' && } - {tab === 'codes' && } - {tab === 'tools' && } - {tab === 'settings' && setUnlocked(false)} />} -
+ )} + + {phase !== 'unlocked' && ( + + setPhase('opening')} + onWorkingChange={setWorking} + /> + + )} +
); diff --git a/apps/desktop/src/renderer/src/components/Loader.tsx b/apps/desktop/src/renderer/src/components/Loader.tsx index 45ca075..cf958d6 100644 --- a/apps/desktop/src/renderer/src/components/Loader.tsx +++ b/apps/desktop/src/renderer/src/components/Loader.tsx @@ -51,12 +51,27 @@ const CUBES: [string, string, string][] = [ ], ]; -/** The animated dcrypt cube-stack loader. */ -export const Loader = ({ className }: { className?: string }) => ( - +/** + * The dcrypt cube-stack mark. It assembles itself while `animate` is set — the + * loading state — and rests as the finished stack otherwise. + */ +export const Loader = ({ + className, + animate = true, +}: { + className?: string; + animate?: boolean; +}) => ( + {CUBES.map((paths, i) => ( - + {paths.map((d, j) => ( { + if (reducedMotion) return 0; + return state === 'closing' ? DOOR_MOTION.closing.ms : DOOR_MOTION.opening.ms; +}; + +const prefersReducedMotion = (): boolean => + typeof window !== 'undefined' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +/** + * A pair of vault doors over the app. Locked, the app is one sealed surface with + * the dcrypt mark across the seam; unlocking parts it down the middle to reveal + * the vault already rendered underneath, and locking slides it shut again. + * + * Each panel clips a viewport-wide copy of the mark, so the halves line up as + * one image while closed and tear apart as the panels travel. + */ +export const VaultDoors = ({ + state, + working = false, + onRest, + children, +}: { + state: DoorState; + /** Animates the mark while the key is being derived. */ + working?: boolean; + /** Fired once the panels have reached their destination. */ + onRest?: () => void; + /** The unlock controls, which leave with the doors. */ + children?: ReactNode; +}) => { + const reduced = prefersReducedMotion(); + // start where the previous state left the panels, then move on the next frame + const [parted, setParted] = useState(state === 'closing'); + + useEffect(() => { + if (state === 'closed') { + setParted(false); + return; + } + const frame = requestAnimationFrame(() => setParted(state === 'opening')); + return () => cancelAnimationFrame(frame); + }, [state]); + + useEffect(() => { + if (state === 'closed' || !onRest) return; + const timer = setTimeout(onRest, doorDuration(state, reduced) + 40); + return () => clearTimeout(timer); + }, [state, reduced, onRest]); + + const motion = + state === 'closing' ? DOOR_MOTION.closing : DOOR_MOTION.opening; + const sealed = state === 'closed'; + + return ( +
+ {(['left', 'right'] as const).map((side) => ( +
+ {/* a viewport-wide face, offset so the two halves compose one mark */} +
+ +
+
+ ))} + +
+ + {/* the controls ride on the doors: they shrink away as the panels part */} +
+ {children} +
+
+ ); +}; diff --git a/apps/desktop/src/renderer/src/screens/UnlockScreen.tsx b/apps/desktop/src/renderer/src/screens/UnlockScreen.tsx index bf6ee24..d320599 100644 --- a/apps/desktop/src/renderer/src/screens/UnlockScreen.tsx +++ b/apps/desktop/src/renderer/src/screens/UnlockScreen.tsx @@ -1,21 +1,23 @@ import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert'; import { Button } from '@constructive-io/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@constructive-io/ui/card'; import { Input } from '@constructive-io/ui/input'; import { Label } from '@constructive-io/ui/label'; -import { ShieldCheck } from 'lucide-react'; import { FormEvent, useEffect, useState } from 'react'; -import { Loader } from '../components/Loader'; import { dcrypt } from '../lib/ipc'; -export const UnlockScreen = ({ onUnlocked }: { onUnlocked: () => void }) => { +/** + * The unlock controls that sit on the vault doors. The dcrypt mark behind them + * belongs to the doors, and doubles as the loader while the key is derived. + */ +export const UnlockScreen = ({ + onUnlocked, + onWorkingChange, +}: { + onUnlocked: () => void; + /** Lets the doors animate their mark while we are busy. */ + onWorkingChange?: (working: boolean) => void; +}) => { const [passphrase, setPassphrase] = useState(''); const [confirm, setConfirm] = useState(''); const [exists, setExists] = useState(null); @@ -26,11 +28,15 @@ export const UnlockScreen = ({ onUnlocked }: { onUnlocked: () => void }) => { void dcrypt.vault.status().then((status) => setExists(status.exists)); }, []); + useEffect(() => onWorkingChange?.(busy), [busy, onWorkingChange]); + + const creating = exists === false; + const submit = async (event: FormEvent) => { event.preventDefault(); setError(''); if (!passphrase) return; - if (exists === false) { + if (creating) { if (passphrase.length < 8) { setError('Choose a master password of at least 8 characters.'); return; @@ -52,80 +58,78 @@ export const UnlockScreen = ({ onUnlocked }: { onUnlocked: () => void }) => { ? 'Wrong master password.' : `Could not unlock: ${err instanceof Error ? err.message : String(err)}` ); - } finally { setBusy(false); } }; - const creating = exists === false; - if (busy) { return ( -
- -
-

- {creating ? 'Setting up your encrypted database…' : 'Unlocking your vault…'} -

-

- {creating - ? 'Deploying the local database and sealing it under your master password. This first run takes a little longer.' - : 'Deriving your key and loading the encrypted database.'} -

-
+
+

+ {creating + ? 'Setting up your encrypted database…' + : 'Unlocking your vault…'} +

+

+ {creating + ? 'Deploying the local database and sealing it under your master password. This first run takes a little longer.' + : 'Deriving your key and loading the encrypted database.'} +

); } return ( -
- - - - {creating ? 'Create your vault' : 'Unlock dcrypt'} - - {creating - ? 'Pick a master password. It never leaves this device and cannot be recovered.' - : 'Everything stays on this device, sealed under your master password.'} - - - -
-
- - setPassphrase(e.target.value)} - disabled={busy || exists === null} - /> -
- {creating && ( -
- - setConfirm(e.target.value)} - disabled={busy} - /> -
- )} - {error && ( - - {creating ? 'Cannot create vault' : 'Cannot unlock'} - {error} - - )} - -
-
-
-
+
+
+

+ {creating ? 'Create your vault' : 'Unlock dcrypt'} +

+

+ {creating + ? 'Pick a master password. It never leaves this device and cannot be recovered.' + : 'Everything stays on this device, sealed under your master password.'} +

+
+
+ + setPassphrase(e.target.value)} + disabled={exists === null} + /> +
+ {creating && ( +
+ + setConfirm(e.target.value)} + /> +
+ )} + {error && ( + + + {creating ? 'Cannot create vault' : 'Cannot unlock'} + + {error} + + )} + +
); }; diff --git a/apps/desktop/src/renderer/src/styles.css b/apps/desktop/src/renderer/src/styles.css index 8d77da3..6d1e4c3 100644 --- a/apps/desktop/src/renderer/src/styles.css +++ b/apps/desktop/src/renderer/src/styles.css @@ -87,3 +87,26 @@ @apply bg-background text-foreground; } } + +/* The vault gently settles into place as the doors reveal it. */ +@keyframes dcrypt-vault-settle { + from { + transform: scale(0.985); + opacity: 0.85; + } + to { + transform: scale(1); + opacity: 1; + } +} + +.dcrypt-vault-settle { + animation: dcrypt-vault-settle 560ms cubic-bezier(0.2, 0.8, 0.2, 1); + transform-origin: center; +} + +@media (prefers-reduced-motion: reduce) { + .dcrypt-vault-settle { + animation: none; + } +}