Skip to content

Commit 55a618e

Browse files
authored
Merge pull request #19 from constructive-io/feat/vault-door-animation
feat(desktop): unlock by sliding the vault doors open
2 parents 3ea3008 + d24cc0c commit 55a618e

6 files changed

Lines changed: 366 additions & 126 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { DOOR_MOTION, doorDuration } from '../src/renderer/src/components/VaultDoors';
4+
5+
describe('doorDuration', () => {
6+
it('shuts faster than it opens, so locking feels like a latch', () => {
7+
expect(doorDuration('closing', false)).toBeLessThan(doorDuration('opening', false));
8+
expect(doorDuration('opening', false)).toBe(DOOR_MOTION.opening.ms);
9+
});
10+
11+
it('collapses to nothing when the user asked for less motion', () => {
12+
expect(doorDuration('opening', true)).toBe(0);
13+
expect(doorDuration('closing', true)).toBe(0);
14+
});
15+
16+
it('opens over the 450–650ms the design calls for', () => {
17+
expect(DOOR_MOTION.opening.ms).toBeGreaterThanOrEqual(450);
18+
expect(DOOR_MOTION.opening.ms).toBeLessThanOrEqual(650);
19+
});
20+
});

apps/desktop/src/renderer/src/App.tsx

Lines changed: 92 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import { Button } from '@constructive-io/ui/button';
22
import { Separator } from '@constructive-io/ui/separator';
33
import { Toaster } from '@constructive-io/ui/sonner';
4-
import { KeyRound, Lock, Settings, ShieldCheck, Timer, Wrench } from 'lucide-react';
4+
import {
5+
KeyRound,
6+
Lock,
7+
Settings,
8+
ShieldCheck,
9+
Timer,
10+
Wrench,
11+
} from 'lucide-react';
512
import { useCallback, useEffect, useState } from 'react';
613

14+
import { DoorState, VaultDoors } from './components/VaultDoors';
715
import { dcrypt } from './lib/ipc';
816
import { ThemeProvider, useThemeMode } from './lib/theme-context';
917
import { SettingsScreen } from './screens/SettingsScreen';
@@ -14,6 +22,9 @@ import { VaultScreen } from './screens/VaultScreen';
1422

1523
type Tab = 'vault' | 'codes' | 'tools' | 'settings';
1624

25+
/** `opening`/`closing` are the door transitions; the vault is mounted for all but `locked`. */
26+
type Phase = 'locked' | 'opening' | 'unlocked' | 'closing';
27+
1728
const NAV: { id: Tab; label: string; icon: typeof KeyRound }[] = [
1829
{ id: 'vault', label: 'Vault', icon: KeyRound },
1930
{ id: 'codes', label: 'Codes', icon: Timer },
@@ -23,65 +34,99 @@ const NAV: { id: Tab; label: string; icon: typeof KeyRound }[] = [
2334

2435
const AppContent = () => {
2536
const { dark } = useThemeMode();
26-
const [unlocked, setUnlocked] = useState(false);
37+
const [phase, setPhase] = useState<Phase>('locked');
38+
const [working, setWorking] = useState(false);
2739
const [tab, setTab] = useState<Tab>('vault');
2840

29-
useEffect(() => dcrypt.onLocked(() => setUnlocked(false)), []);
41+
// the main process locks on its own for menu actions and after a restore
42+
useEffect(
43+
() =>
44+
dcrypt.onLocked(() =>
45+
setPhase((current) => (current === 'unlocked' ? 'closing' : current))
46+
),
47+
[]
48+
);
3049

3150
useEffect(() => {
32-
void dcrypt.vault.status().then((s) => setUnlocked(s.unlocked));
51+
void dcrypt.vault
52+
.status()
53+
.then((s) => setPhase(s.unlocked ? 'unlocked' : 'locked'));
3354
}, []);
3455

35-
// switch to the unlock screen first: the flush behind `vault.lock()` is fast
36-
// but not instant, and waiting on it makes the click feel stuck
56+
// close the doors and lock at the same time: the flush behind `vault.lock()`
57+
// is fast, so the animation covers it entirely
3758
const lock = useCallback(() => {
38-
setUnlocked(false);
59+
setPhase('closing');
3960
void dcrypt.vault.lock();
4061
}, []);
4162

42-
if (!unlocked) {
43-
return (
44-
<>
45-
<UnlockScreen onUnlocked={() => setUnlocked(true)} />
46-
<Toaster theme={dark ? 'dark' : 'light'} position="bottom-right" />
47-
</>
48-
);
49-
}
63+
const doorState: DoorState =
64+
phase === 'opening'
65+
? 'opening'
66+
: phase === 'closing'
67+
? 'closing'
68+
: 'closed';
69+
const settled = useCallback(() => {
70+
setWorking(false);
71+
setPhase((current) => (current === 'opening' ? 'unlocked' : 'locked'));
72+
}, []);
5073

5174
return (
52-
<div className="flex h-screen">
53-
<aside className="flex w-52 flex-col border-r bg-muted/40 p-3">
54-
<div className="mb-4 flex items-center gap-2 px-2 pt-1">
55-
<ShieldCheck className="size-5 text-primary" />
56-
<span className="text-lg font-semibold">dcrypt</span>
75+
<div className="relative h-screen overflow-hidden">
76+
{phase !== 'locked' && (
77+
<div
78+
className={`flex h-full ${phase === 'opening' ? 'dcrypt-vault-settle' : ''}`}
79+
>
80+
<aside className="flex w-52 flex-col border-r bg-muted/40 p-3">
81+
<div className="mb-4 flex items-center gap-2 px-2 pt-1">
82+
<ShieldCheck className="size-5 text-primary" />
83+
<span className="text-lg font-semibold">dcrypt</span>
84+
</div>
85+
<nav className="flex flex-col gap-1">
86+
{NAV.map(({ id, label, icon: Icon }) => (
87+
<Button
88+
key={id}
89+
variant={tab === id ? 'secondary' : 'ghost'}
90+
className="justify-start gap-2"
91+
onClick={() => setTab(id)}
92+
>
93+
<Icon className="size-4" />
94+
{label}
95+
</Button>
96+
))}
97+
</nav>
98+
<div className="mt-auto">
99+
<Separator className="my-3" />
100+
<Button
101+
variant="outline"
102+
className="w-full justify-start gap-2"
103+
onClick={lock}
104+
>
105+
<Lock className="size-4" />
106+
Lock vault
107+
</Button>
108+
</div>
109+
</aside>
110+
<main className="min-w-0 flex-1 overflow-hidden">
111+
{tab === 'vault' && <VaultScreen />}
112+
{tab === 'codes' && <TotpScreen />}
113+
{tab === 'tools' && <ToolsScreen />}
114+
{tab === 'settings' && (
115+
<SettingsScreen onLocked={() => setPhase('closing')} />
116+
)}
117+
</main>
57118
</div>
58-
<nav className="flex flex-col gap-1">
59-
{NAV.map(({ id, label, icon: Icon }) => (
60-
<Button
61-
key={id}
62-
variant={tab === id ? 'secondary' : 'ghost'}
63-
className="justify-start gap-2"
64-
onClick={() => setTab(id)}
65-
>
66-
<Icon className="size-4" />
67-
{label}
68-
</Button>
69-
))}
70-
</nav>
71-
<div className="mt-auto">
72-
<Separator className="my-3" />
73-
<Button variant="outline" className="w-full justify-start gap-2" onClick={lock}>
74-
<Lock className="size-4" />
75-
Lock vault
76-
</Button>
77-
</div>
78-
</aside>
79-
<main className="min-w-0 flex-1 overflow-hidden">
80-
{tab === 'vault' && <VaultScreen />}
81-
{tab === 'codes' && <TotpScreen />}
82-
{tab === 'tools' && <ToolsScreen />}
83-
{tab === 'settings' && <SettingsScreen onLocked={() => setUnlocked(false)} />}
84-
</main>
119+
)}
120+
121+
{phase !== 'unlocked' && (
122+
<VaultDoors state={doorState} working={working} onRest={settled}>
123+
<UnlockScreen
124+
onUnlocked={() => setPhase('opening')}
125+
onWorkingChange={setWorking}
126+
/>
127+
</VaultDoors>
128+
)}
129+
85130
<Toaster theme={dark ? 'dark' : 'light'} position="bottom-right" />
86131
</div>
87132
);

apps/desktop/src/renderer/src/components/Loader.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,27 @@ const CUBES: [string, string, string][] = [
5151
],
5252
];
5353

54-
/** The animated dcrypt cube-stack loader. */
55-
export const Loader = ({ className }: { className?: string }) => (
56-
<svg viewBox="-125 -140 460 700" fill="none" className={className} role="status" aria-label="Loading">
54+
/**
55+
* The dcrypt cube-stack mark. It assembles itself while `animate` is set — the
56+
* loading state — and rests as the finished stack otherwise.
57+
*/
58+
export const Loader = ({
59+
className,
60+
animate = true,
61+
}: {
62+
className?: string;
63+
animate?: boolean;
64+
}) => (
65+
<svg
66+
viewBox="-125 -140 460 700"
67+
fill="none"
68+
className={className}
69+
role={animate ? 'status' : 'img'}
70+
aria-label={animate ? 'Loading' : 'dcrypt'}
71+
>
5772
<style>{KEYFRAMES}</style>
5873
{CUBES.map((paths, i) => (
59-
<g key={i} className={`dcrypt-cube-${i}`}>
74+
<g key={i} className={animate ? `dcrypt-cube-${i}` : undefined}>
6075
{paths.map((d, j) => (
6176
<path
6277
key={j}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { ReactNode, useEffect, useState } from 'react';
2+
3+
import { Loader } from './Loader';
4+
5+
export type DoorState = 'closed' | 'opening' | 'closing';
6+
7+
/**
8+
* The doors move decisively and settle without bouncing on the way open; they
9+
* shut faster, with a hint of overshoot so it reads as a latch.
10+
*/
11+
export const DOOR_MOTION = {
12+
opening: { ms: 560, ease: 'cubic-bezier(.2,.8,.2,1)' },
13+
closing: { ms: 380, ease: 'cubic-bezier(.35,1.3,.6,1)' },
14+
} as const;
15+
16+
/** How long the panels take to reach the state the caller asked for. */
17+
export const doorDuration = (
18+
state: DoorState,
19+
reducedMotion: boolean
20+
): number => {
21+
if (reducedMotion) return 0;
22+
return state === 'closing' ? DOOR_MOTION.closing.ms : DOOR_MOTION.opening.ms;
23+
};
24+
25+
const prefersReducedMotion = (): boolean =>
26+
typeof window !== 'undefined' &&
27+
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
28+
29+
/**
30+
* A pair of vault doors over the app. Locked, the app is one sealed surface with
31+
* the dcrypt mark across the seam; unlocking parts it down the middle to reveal
32+
* the vault already rendered underneath, and locking slides it shut again.
33+
*
34+
* Each panel clips a viewport-wide copy of the mark, so the halves line up as
35+
* one image while closed and tear apart as the panels travel.
36+
*/
37+
export const VaultDoors = ({
38+
state,
39+
working = false,
40+
onRest,
41+
children,
42+
}: {
43+
state: DoorState;
44+
/** Animates the mark while the key is being derived. */
45+
working?: boolean;
46+
/** Fired once the panels have reached their destination. */
47+
onRest?: () => void;
48+
/** The unlock controls, which leave with the doors. */
49+
children?: ReactNode;
50+
}) => {
51+
const reduced = prefersReducedMotion();
52+
// start where the previous state left the panels, then move on the next frame
53+
const [parted, setParted] = useState(state === 'closing');
54+
55+
useEffect(() => {
56+
if (state === 'closed') {
57+
setParted(false);
58+
return;
59+
}
60+
const frame = requestAnimationFrame(() => setParted(state === 'opening'));
61+
return () => cancelAnimationFrame(frame);
62+
}, [state]);
63+
64+
useEffect(() => {
65+
if (state === 'closed' || !onRest) return;
66+
const timer = setTimeout(onRest, doorDuration(state, reduced) + 40);
67+
return () => clearTimeout(timer);
68+
}, [state, reduced, onRest]);
69+
70+
const motion =
71+
state === 'closing' ? DOOR_MOTION.closing : DOOR_MOTION.opening;
72+
const sealed = state === 'closed';
73+
74+
return (
75+
<div className="absolute inset-0 z-40" aria-hidden={!sealed}>
76+
{(['left', 'right'] as const).map((side) => (
77+
<div
78+
key={side}
79+
data-testid={`vault-door-${side}`}
80+
className={`absolute inset-y-0 w-1/2 overflow-hidden bg-background ${
81+
side === 'left' ? 'left-0' : 'right-0'
82+
}`}
83+
style={{
84+
transition: reduced
85+
? 'opacity 120ms linear'
86+
: `transform ${motion.ms}ms ${motion.ease}`,
87+
transform: parted
88+
? `translateX(${side === 'left' ? '-100%' : '100%'})`
89+
: 'translateX(0)',
90+
// while moving, the inner edges cast onto the vault, so the panels
91+
// read as sitting above it; sealed, the surface is unbroken
92+
boxShadow: sealed
93+
? undefined
94+
: side === 'left'
95+
? '10px 0 28px -6px rgb(0 0 0 / 0.4)'
96+
: '-10px 0 28px -6px rgb(0 0 0 / 0.4)',
97+
opacity: reduced && parted ? 0 : 1,
98+
}}
99+
>
100+
{/* a viewport-wide face, offset so the two halves compose one mark */}
101+
<div
102+
className="absolute inset-y-0 flex w-screen items-center justify-center"
103+
style={{ left: side === 'left' ? 0 : '-50vw' }}
104+
>
105+
<Loader
106+
className="h-[44vh] max-h-96 opacity-90"
107+
animate={working}
108+
/>
109+
</div>
110+
</div>
111+
))}
112+
113+
<div
114+
className="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-primary/25"
115+
style={{ opacity: sealed ? 1 : 0, transition: 'opacity 200ms linear' }}
116+
/>
117+
118+
{/* the controls ride on the doors: they shrink away as the panels part */}
119+
<div
120+
className={`absolute inset-x-0 bottom-[8vh] flex justify-center ${sealed ? '' : 'pointer-events-none'}`}
121+
style={{
122+
opacity: sealed ? 1 : 0,
123+
transform: sealed ? 'scale(1)' : 'scale(0.94)',
124+
transition: reduced
125+
? 'opacity 120ms linear'
126+
: 'opacity 220ms linear, transform 320ms cubic-bezier(.2,.8,.2,1)',
127+
}}
128+
>
129+
{children}
130+
</div>
131+
</div>
132+
);
133+
};

0 commit comments

Comments
 (0)