Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/desktop/__tests__/vault-doors.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
139 changes: 92 additions & 47 deletions apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 },
Expand All @@ -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<Phase>('locked');
const [working, setWorking] = useState(false);
const [tab, setTab] = useState<Tab>('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 (
<>
<UnlockScreen onUnlocked={() => setUnlocked(true)} />
<Toaster theme={dark ? 'dark' : 'light'} position="bottom-right" />
</>
);
}
const doorState: DoorState =
phase === 'opening'
? 'opening'
: phase === 'closing'
? 'closing'
: 'closed';
const settled = useCallback(() => {
setWorking(false);
setPhase((current) => (current === 'opening' ? 'unlocked' : 'locked'));
}, []);

return (
<div className="flex h-screen">
<aside className="flex w-52 flex-col border-r bg-muted/40 p-3">
<div className="mb-4 flex items-center gap-2 px-2 pt-1">
<ShieldCheck className="size-5 text-primary" />
<span className="text-lg font-semibold">dcrypt</span>
<div className="relative h-screen overflow-hidden">
{phase !== 'locked' && (
<div
className={`flex h-full ${phase === 'opening' ? 'dcrypt-vault-settle' : ''}`}
>
<aside className="flex w-52 flex-col border-r bg-muted/40 p-3">
<div className="mb-4 flex items-center gap-2 px-2 pt-1">
<ShieldCheck className="size-5 text-primary" />
<span className="text-lg font-semibold">dcrypt</span>
</div>
<nav className="flex flex-col gap-1">
{NAV.map(({ id, label, icon: Icon }) => (
<Button
key={id}
variant={tab === id ? 'secondary' : 'ghost'}
className="justify-start gap-2"
onClick={() => setTab(id)}
>
<Icon className="size-4" />
{label}
</Button>
))}
</nav>
<div className="mt-auto">
<Separator className="my-3" />
<Button
variant="outline"
className="w-full justify-start gap-2"
onClick={lock}
>
<Lock className="size-4" />
Lock vault
</Button>
</div>
</aside>
<main className="min-w-0 flex-1 overflow-hidden">
{tab === 'vault' && <VaultScreen />}
{tab === 'codes' && <TotpScreen />}
{tab === 'tools' && <ToolsScreen />}
{tab === 'settings' && (
<SettingsScreen onLocked={() => setPhase('closing')} />
)}
</main>
</div>
<nav className="flex flex-col gap-1">
{NAV.map(({ id, label, icon: Icon }) => (
<Button
key={id}
variant={tab === id ? 'secondary' : 'ghost'}
className="justify-start gap-2"
onClick={() => setTab(id)}
>
<Icon className="size-4" />
{label}
</Button>
))}
</nav>
<div className="mt-auto">
<Separator className="my-3" />
<Button variant="outline" className="w-full justify-start gap-2" onClick={lock}>
<Lock className="size-4" />
Lock vault
</Button>
</div>
</aside>
<main className="min-w-0 flex-1 overflow-hidden">
{tab === 'vault' && <VaultScreen />}
{tab === 'codes' && <TotpScreen />}
{tab === 'tools' && <ToolsScreen />}
{tab === 'settings' && <SettingsScreen onLocked={() => setUnlocked(false)} />}
</main>
)}

{phase !== 'unlocked' && (
<VaultDoors state={doorState} working={working} onRest={settled}>
<UnlockScreen
onUnlocked={() => setPhase('opening')}
onWorkingChange={setWorking}
/>
</VaultDoors>
)}

<Toaster theme={dark ? 'dark' : 'light'} position="bottom-right" />
</div>
);
Expand Down
23 changes: 19 additions & 4 deletions apps/desktop/src/renderer/src/components/Loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,27 @@ const CUBES: [string, string, string][] = [
],
];

/** The animated dcrypt cube-stack loader. */
export const Loader = ({ className }: { className?: string }) => (
<svg viewBox="-125 -140 460 700" fill="none" className={className} role="status" aria-label="Loading">
/**
* 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;
}) => (
<svg
viewBox="-125 -140 460 700"
fill="none"
className={className}
role={animate ? 'status' : 'img'}
aria-label={animate ? 'Loading' : 'dcrypt'}
>
<style>{KEYFRAMES}</style>
{CUBES.map((paths, i) => (
<g key={i} className={`dcrypt-cube-${i}`}>
<g key={i} className={animate ? `dcrypt-cube-${i}` : undefined}>
{paths.map((d, j) => (
<path
key={j}
Expand Down
133 changes: 133 additions & 0 deletions apps/desktop/src/renderer/src/components/VaultDoors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { ReactNode, useEffect, useState } from 'react';

import { Loader } from './Loader';

export type DoorState = 'closed' | 'opening' | 'closing';

/**
* The doors move decisively and settle without bouncing on the way open; they
* shut faster, with a hint of overshoot so it reads as a latch.
*/
export const DOOR_MOTION = {
opening: { ms: 560, ease: 'cubic-bezier(.2,.8,.2,1)' },
closing: { ms: 380, ease: 'cubic-bezier(.35,1.3,.6,1)' },
} as const;

/** How long the panels take to reach the state the caller asked for. */
export const doorDuration = (
state: DoorState,
reducedMotion: boolean
): number => {
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 (
<div className="absolute inset-0 z-40" aria-hidden={!sealed}>
{(['left', 'right'] as const).map((side) => (
<div
key={side}
data-testid={`vault-door-${side}`}
className={`absolute inset-y-0 w-1/2 overflow-hidden bg-background ${
side === 'left' ? 'left-0' : 'right-0'
}`}
style={{
transition: reduced
? 'opacity 120ms linear'
: `transform ${motion.ms}ms ${motion.ease}`,
transform: parted
? `translateX(${side === 'left' ? '-100%' : '100%'})`
: 'translateX(0)',
// while moving, the inner edges cast onto the vault, so the panels
// read as sitting above it; sealed, the surface is unbroken
boxShadow: sealed
? undefined
: side === 'left'
? '10px 0 28px -6px rgb(0 0 0 / 0.4)'
: '-10px 0 28px -6px rgb(0 0 0 / 0.4)',
opacity: reduced && parted ? 0 : 1,
}}
>
{/* a viewport-wide face, offset so the two halves compose one mark */}
<div
className="absolute inset-y-0 flex w-screen items-center justify-center"
style={{ left: side === 'left' ? 0 : '-50vw' }}
>
<Loader
className="h-[44vh] max-h-96 opacity-90"
animate={working}
/>
</div>
</div>
))}

<div
className="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-primary/25"
style={{ opacity: sealed ? 1 : 0, transition: 'opacity 200ms linear' }}
/>

{/* the controls ride on the doors: they shrink away as the panels part */}
<div
className={`absolute inset-x-0 bottom-[8vh] flex justify-center ${sealed ? '' : 'pointer-events-none'}`}
style={{
opacity: sealed ? 1 : 0,
transform: sealed ? 'scale(1)' : 'scale(0.94)',
transition: reduced
? 'opacity 120ms linear'
: 'opacity 220ms linear, transform 320ms cubic-bezier(.2,.8,.2,1)',
}}
>
{children}
</div>
</div>
);
};
Loading
Loading