From d24cc0c4080a205ea54a8b3f2c0a5b85e42633a6 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 8 Aug 2026 05:40:51 +0000 Subject: [PATCH] feat(desktop): unlock by sliding the vault doors open The locked app is one sealed surface with the dcrypt mark across the seam, which doubles as the loader while the key is derived; unlocking parts it to reveal the vault already rendered behind, and locking slides it shut. --- apps/desktop/__tests__/vault-doors.test.ts | 20 +++ apps/desktop/src/renderer/src/App.tsx | 139 ++++++++++------ .../src/renderer/src/components/Loader.tsx | 23 ++- .../renderer/src/components/VaultDoors.tsx | 133 +++++++++++++++ .../src/renderer/src/screens/UnlockScreen.tsx | 154 +++++++++--------- apps/desktop/src/renderer/src/styles.css | 23 +++ 6 files changed, 366 insertions(+), 126 deletions(-) create mode 100644 apps/desktop/__tests__/vault-doors.test.ts create mode 100644 apps/desktop/src/renderer/src/components/VaultDoors.tsx 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; + } +}