Add onboarding wizard for teaching the record key (#301)

* Add first-run onboarding wizard for recording mechanics

Introduce a minimal, skippable first-run wizard that teaches the record
key. On first authenticated launch it renders as a full-screen takeover
(gated above PusherProvider) so the stream/compose keyboard handlers are
not mounted to compete for the same keys.

Steps: welcome, hold ` to record, tap V to switch audio/video, tap ` to
toggle recording, mic/camera permission priming, and a recap. The lessons
detect the real gestures and never block — Esc or the Skip control
dismisses at any point.

Reuses existing pieces: VideoAudioToggle, useMediaDevices().requestLabels
for permission priming, KeyHint, and isTypingTarget. Extracts the shared
HOLD_THRESHOLD_MS into lib/constants and reuses it in the compose overlay.

Resolves #259

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9AHySdLb1zfZAEm6SuaQP

* nits

* light mode support for onboarding

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit was merged in pull request #301.
This commit is contained in:
Arjun Patel
2026-06-21 11:45:14 -07:00
committed by GitHub
parent d14cec5885
commit fec7f5d6d8
13 changed files with 658 additions and 3 deletions
+10
View File
@@ -23,12 +23,18 @@ import {
import { SoundEffectsProvider } from '@/lib/sound-effects/sound-effects-provider';
import { platform } from '@/lib/platform';
import { InAppAutoplayCard } from '@/components/in-app-autoplay-card';
import { useOnboardingStore } from '@/stores/onboarding-store';
import { OnboardingOverlay } from '@/features/onboarding/onboarding-overlay';
const queryClient = createQueryClient();
const App = () => {
const status = useAuthStore((s) => s.status);
const restoreSession = useAuthStore((s) => s.restoreSession);
const hasCompletedOnboarding = useOnboardingStore(
(s) => s.hasCompletedOnboarding,
);
const markOnboardingComplete = useOnboardingStore((s) => s.markComplete);
useEffect(() => {
restoreSession();
@@ -46,6 +52,10 @@ const App = () => {
return <LoginPage />;
}
if (!hasCompletedOnboarding) {
return <OnboardingOverlay onComplete={markOnboardingComplete} />;
}
return (
<PusherProvider>
<AuthenticatedApp />
@@ -29,7 +29,11 @@ import { useMediaDevices } from '@/hooks/use-media-devices';
import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id';
import { useFileInput } from '@/hooks/use-file-input';
import { createImageThumbnail } from '@/lib/image-thumbnail';
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from '@/lib/constants';
import {
HOLD_THRESHOLD_MS,
MAX_ATTACHMENT_SIZE_BYTES,
MAX_ATTACHMENTS,
} from '@/lib/constants';
import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
@@ -59,8 +63,6 @@ interface ComposeOverlayProps {
onParticleCreated?: (particleId: string) => void;
}
const HOLD_THRESHOLD_MS = 250;
/**
* Self-contained compose overlay. Each consumer renders its own instance
* with props that determine the mode (new stream vs. reply).
@@ -0,0 +1,52 @@
import { useEffect } from 'react';
import { Check } from 'lucide-react';
import { OnboardingKeyboard } from './onboarding-keyboard';
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
import { useHoldGesture } from './use-hold-gesture';
/**
* Visible hold duration for the lesson. Deliberately longer than the real
* HOLD_THRESHOLD_MS (which is imperceptible) so the gesture is teachable.
*/
const ONBOARDING_HOLD_MS = 600;
export function HoldStep({ onAdvance }: { onAdvance: () => void }) {
const { state, progress } = useHoldGesture({
targetKey: '`',
targetCode: 'Backquote',
holdMs: ONBOARDING_HOLD_MS,
});
const done = state === 'success';
useEffect(() => {
if (!done) return;
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
return () => clearTimeout(t);
}, [done, onAdvance]);
return (
<OnboardingStep
title={done ? 'That is push-to-talk' : 'Hold to record'}
subtitle={
done
? 'Hold ` whenever you want to speak, release when you are done.'
: 'Press and hold the ` key, like a walkie-talkie.'
}
status={
done ? (
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
<Check className="size-4" /> Nice.
</span>
) : state === 'holding' ? (
'Keep holding…'
) : null
}
>
<OnboardingKeyboard
highlightKey="`"
state={done ? 'success' : state === 'holding' ? 'active' : 'idle'}
progress={progress}
/>
</OnboardingStep>
);
}
@@ -0,0 +1,79 @@
import { cn } from '@/lib/utils';
type KeyState = 'idle' | 'active' | 'success';
interface OnboardingKeyboardProps {
/** The key to emphasise. Defaults to the record key. */
highlightKey?: string;
state: KeyState;
/** Hold progress (01), fills the emphasised key from the bottom. */
progress?: number;
}
interface KeyCapProps {
label: string;
hero?: boolean;
state?: KeyState;
progress?: number;
className?: string;
}
function KeyCap({
label,
hero,
state = 'idle',
progress = 0,
className,
}: KeyCapProps) {
return (
<div
className={cn(
'relative flex select-none items-center justify-center overflow-hidden rounded-lg border text-sm font-medium',
hero ? 'h-14 w-14 text-xl' : 'h-14',
!hero && 'border-border bg-muted text-muted-foreground/70',
hero &&
state === 'idle' &&
'border-primary/40 bg-primary/10 text-foreground motion-safe:animate-pulse',
hero &&
state === 'active' &&
'border-primary/60 bg-primary/20 text-foreground',
hero &&
state === 'success' &&
'border-emerald-500/50 bg-emerald-500/15 text-emerald-700 dark:text-emerald-200',
className,
)}
>
{hero && state !== 'success' && progress > 0 && (
<span
className="pointer-events-none absolute inset-x-0 bottom-0 bg-primary/25"
style={{ height: `${progress * 100}%` }}
/>
)}
<span className="relative z-10">{label}</span>
</div>
);
}
/**
* Draws the record key in its physical context: Esc above, 1 to its right, and
* Tab / Q on the row below — so the key is recognisable on a real keyboard.
*/
export function OnboardingKeyboard({
highlightKey = '`',
state,
progress = 0,
}: OnboardingKeyboardProps) {
return (
<div className="flex flex-col items-start gap-1.5">
<KeyCap label="esc" className="w-14 text-xs" />
<div className="flex gap-1.5">
<KeyCap label={highlightKey} hero state={state} progress={progress} />
<KeyCap label="1" className="w-14" />
</div>
<div className="flex gap-1.5">
<KeyCap label="tab" className="w-20 text-xs" />
<KeyCap label="Q" className="w-12" />
</div>
</div>
);
}
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { WindowControls } from '@/components/window-controls';
import { KeyHint } from '@/components/key-hint';
import { cn } from '@/lib/utils';
import { OnboardingKeyboard } from './onboarding-keyboard';
import { OnboardingStep } from './onboarding-step';
import { HoldStep } from './hold-step';
import { ToggleStep } from './toggle-step';
import { TapStep } from './tap-step';
import { PermissionStep } from './permission-step';
const STEPS = ['welcome', 'hold', 'tap', 'toggle', 'permission'] as const;
type Step = (typeof STEPS)[number];
/**
* First-run wizard that teaches the record key. Rendered as a full-screen
* takeover (above PusherProvider) so none of the stream/compose keyboard
* handlers are mounted to compete for the same keys.
*/
export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) {
const [step, setStep] = useState<Step>('welcome');
const panelRef = useRef<HTMLDivElement>(null);
const index = STEPS.indexOf(step);
const goNext = useCallback(() => {
setStep((cur) => STEPS[Math.min(STEPS.indexOf(cur) + 1, STEPS.length - 1)]);
}, []);
const goBack = useCallback(() => {
setStep((cur) => STEPS[Math.max(0, STEPS.indexOf(cur) - 1)]);
}, []);
// Focus the panel on the gesture steps so window-level key handling has a
// home; the static steps autofocus their primary button instead, which lets
// Enter activate it natively (no duplicate window handler).
useEffect(() => {
if (step === 'hold' || step === 'toggle' || step === 'tap') {
panelRef.current?.focus();
}
}, [step]);
// Esc skips the wizard from anywhere; ← (and Backspace) steps back.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onComplete();
} else if (e.key === 'Enter' && step === 'welcome') {
e.preventDefault();
goNext();
} else if (
e.key === 'ArrowLeft' ||
e.key === 'Backspace' ||
e.key === 'Delete'
) {
e.preventDefault();
goBack();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onComplete, goNext, step, goBack]);
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<div className="drag-region flex items-center justify-between border-b border-border px-3 py-1">
<WindowControls />
<KeyHint
keys="Esc"
onClick={onComplete}
title="Skip onboarding (or press Esc)"
className="no-drag text-xs text-muted-foreground"
>
to skip
</KeyHint>
</div>
<div className="flex flex-1 items-center justify-center p-6">
<div
ref={panelRef}
tabIndex={-1}
role="dialog"
aria-label="Welcome to llink"
className="flex w-full max-w-md flex-col items-center gap-8 rounded-2xl border border-border bg-card p-10 outline-none"
>
{step === 'welcome' && (
<OnboardingStep
title="Welcome to llink"
subtitle="To use llink, you must learn the keyboard shortcuts. Don't worry, once you learn them, you'll feel like your flowing."
>
<OnboardingKeyboard highlightKey="`" state="idle" />
</OnboardingStep>
)}
{step === 'hold' && <HoldStep onAdvance={goNext} />}
{step === 'toggle' && <ToggleStep onAdvance={goNext} />}
{step === 'tap' && <TapStep onAdvance={goNext} />}
{step === 'permission' && <PermissionStep onAdvance={onComplete} />}
<div className="flex items-center justify-center gap-1.5">
{STEPS.map((s, i) => (
<span
key={s}
className={cn(
'h-1.5 rounded-full transition-all',
i === index
? 'w-6 bg-foreground'
: i < index
? 'w-1.5 bg-foreground/40'
: 'w-1.5 bg-foreground/15',
)}
/>
))}
</div>
<div className="flex min-h-5 items-center justify-center gap-4 text-xs text-muted-foreground">
{index > 0 && (
<KeyHint
keys="←"
onClick={goBack}
title="Back (or press ←)"
aria-label="Previous step"
>
back
</KeyHint>
)}
{step === 'welcome' && (
<KeyHint
keys="Enter"
onClick={goNext}
title="Continue (or press Enter)"
>
continue
</KeyHint>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react';
/** How long a success state lingers before the wizard auto-advances, in ms. */
export const SUCCESS_DWELL_MS = 2000;
interface OnboardingStepProps {
title: ReactNode;
subtitle?: ReactNode;
/** The interactive body (keyboard diagram, toggle, etc.). */
children?: ReactNode;
/** Live feedback line, announced to screen readers. */
status?: ReactNode;
/** Actions and hints below the body. */
footer?: ReactNode;
}
/** Shared layout so every onboarding step keeps the same vertical rhythm. */
export function OnboardingStep({
title,
subtitle,
children,
status,
footer,
}: OnboardingStepProps) {
return (
<div className="flex flex-col items-center gap-6 text-center">
<div className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight text-foreground">
{title}
</h2>
{subtitle && (
<p className="mx-auto max-w-xs text-sm text-muted-foreground">
{subtitle}
</p>
)}
</div>
{children && (
<div className="flex flex-col items-center gap-4 py-2">{children}</div>
)}
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{status}
</p>
{footer && (
<div className="flex flex-col items-center gap-3">{footer}</div>
)}
</div>
);
}
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
import { useMediaDevices } from '@/hooks/use-media-devices';
export function PermissionStep({ onAdvance }: { onAdvance: () => void }) {
const { permissionState, requestLabels } = useMediaDevices();
const [requesting, setRequesting] = useState(false);
const granted = permissionState === 'granted';
const denied = permissionState === 'denied';
useEffect(() => {
if (!granted) return;
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
return () => clearTimeout(t);
}, [granted, onAdvance]);
const handleAllow = async () => {
setRequesting(true);
await requestLabels();
setRequesting(false);
};
return (
<OnboardingStep
title="Allow mic and camera"
subtitle="llink records audio and video. Grant access once so recording just works."
status={
granted ? (
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
<Check className="size-4" /> Access granted.
</span>
) : denied ? (
<span className="text-muted-foreground">
No access yet. You can enable it later in System Settings.
</span>
) : null
}
footer={
granted ? null : denied ? (
<Button variant="secondary" onClick={onAdvance} autoFocus>
Continue anyway
</Button>
) : (
<Button onClick={handleAllow} disabled={requesting} autoFocus>
{requesting ? 'Requesting…' : 'Allow access'}
</Button>
)
}
/>
);
}
@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { Check } from 'lucide-react';
import { OnboardingKeyboard } from './onboarding-keyboard';
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
import { useTapGesture } from './use-tap-gesture';
export function TapStep({ onAdvance }: { onAdvance: () => void }) {
const { taps, satisfied } = useTapGesture({
targetKey: '`',
targetCode: 'Backquote',
taps: 2,
});
const recording = taps === 1;
useEffect(() => {
if (!satisfied) return;
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
return () => clearTimeout(t);
}, [satisfied, onAdvance]);
return (
<OnboardingStep
title={
satisfied
? 'That is tap to toggle'
: recording
? 'Recording…'
: 'Tap to start and stop'
}
subtitle={
satisfied
? 'A quick tap starts recording, another tap finishes it.'
: recording
? 'Now tap ` again to stop.'
: 'Prefer not to hold? Tap ` once to start recording.'
}
status={
satisfied ? (
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
<Check className="size-4" /> Done.
</span>
) : recording ? (
<span className="inline-flex items-center gap-2 text-muted-foreground">
<span className="size-2.5 rounded-full bg-red-500 motion-safe:animate-pulse" />
Recording
</span>
) : null
}
>
<OnboardingKeyboard
highlightKey="`"
state={satisfied ? 'success' : recording ? 'active' : 'idle'}
/>
</OnboardingStep>
);
}
@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { Check, Mic, Video } from 'lucide-react';
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
import { useTapGesture } from './use-tap-gesture';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { useMediaSettingsStore } from '@/stores/media-settings-store';
export function ToggleStep({ onAdvance }: { onAdvance: () => void }) {
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
const { satisfied } = useTapGesture({
targetKey: 'v',
onTap: () => {
const current = useMediaSettingsStore.getState().recordingMode;
setRecordingMode(current === 'video' ? 'audio' : 'video');
},
});
useEffect(() => {
if (!satisfied) return;
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
return () => clearTimeout(t);
}, [satisfied, onAdvance]);
// Teaching the toggle shouldn't permanently change the user's default mode.
useEffect(() => {
const original = useMediaSettingsStore.getState().recordingMode;
return () => setRecordingMode(original);
}, [setRecordingMode]);
const isVideo = recordingMode === 'video';
return (
<OnboardingStep
title={satisfied ? 'Audio or video, your call' : 'Switch audio and video'}
subtitle={
satisfied
? 'Tap V before recording to choose how you show up.'
: 'Tap the V key to switch between video and audio.'
}
status={
satisfied ? (
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
<Check className="size-4" />{' '}
{isVideo ? 'Back to video.' : 'Audio it is.'}
</span>
) : null
}
>
<div className="flex size-32 flex-col items-center justify-center gap-2 rounded-2xl border border-border bg-muted text-foreground">
{isVideo ? <Video className="size-9" /> : <Mic className="size-9" />}
<span className="text-sm text-muted-foreground">
{isVideo ? 'Video' : 'Audio'}
</span>
</div>
<VideoAudioToggle />
</OnboardingStep>
);
}
@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
import { isTypingTarget } from '@/lib/keyboard';
export type HoldState = 'idle' | 'holding' | 'success';
interface UseHoldGestureOptions {
/** Detected against KeyboardEvent.key. */
targetKey: string;
/** Optional KeyboardEvent.code fallback (e.g. 'Backquote'). */
targetCode?: string;
/** How long the key must be held before it counts, in ms. */
holdMs: number;
}
/**
* Detects a deliberate press-and-hold of a single key. Success is driven by a
* timer started on keydown rather than by keyup, so it still resolves if the OS
* swallows the keyup (e.g. macOS press-and-hold accent popover). Releasing early
* simply resets — a tutorial never fails the user.
*/
export function useHoldGesture({
targetKey,
targetCode,
holdMs,
}: UseHoldGestureOptions) {
const [state, setState] = useState<HoldState>('idle');
const [progress, setProgress] = useState(0);
useEffect(() => {
let rafId = 0;
let startedAt = 0;
let done = false;
const matches = (e: KeyboardEvent) =>
e.key === targetKey || (targetCode != null && e.code === targetCode);
const tick = () => {
const next = Math.min((Date.now() - startedAt) / holdMs, 1);
setProgress(next);
if (next >= 1) {
done = true;
setState('success');
return;
}
rafId = requestAnimationFrame(tick);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (done || e.repeat || isTypingTarget(e) || !matches(e)) return;
e.preventDefault();
startedAt = Date.now();
setState('holding');
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(tick);
};
const handleKeyUp = (e: KeyboardEvent) => {
if (done || !matches(e)) return;
cancelAnimationFrame(rafId);
startedAt = 0;
setProgress(0);
setState('idle');
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [targetKey, targetCode, holdMs]);
return { state, progress };
}
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from 'react';
import { isTypingTarget } from '@/lib/keyboard';
interface UseTapGestureOptions {
/** Detected against KeyboardEvent.key. */
targetKey: string;
/** Optional KeyboardEvent.code fallback (e.g. 'Backquote'). */
targetCode?: string;
/** Taps required before `satisfied` flips true. Defaults to 1. */
taps?: number;
/** Fires on each counted tap, with the new running count. */
onTap?: (count: number) => void;
}
/**
* Counts discrete taps of a single key, ignoring auto-repeat. Used both for the
* one-tap audio/video toggle and the two-tap start/stop demonstration.
*/
export function useTapGesture({
targetKey,
targetCode,
taps = 1,
onTap,
}: UseTapGestureOptions) {
const [count, setCount] = useState(0);
const onTapRef = useRef(onTap);
useEffect(() => {
onTapRef.current = onTap;
});
useEffect(() => {
const matches = (e: KeyboardEvent) =>
e.key === targetKey || (targetCode != null && e.code === targetCode);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.repeat || isTypingTarget(e) || !matches(e)) return;
e.preventDefault();
setCount((prev) => {
if (prev >= taps) return prev;
const next = prev + 1;
onTapRef.current?.(next);
return next;
});
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [targetKey, targetCode, taps]);
return { taps: count, satisfied: count >= taps };
}
+6
View File
@@ -7,6 +7,12 @@ export const MAX_ATTACHMENTS = 10;
/** Maximum recommended duration for a media (audio/video) recording, in seconds. */
export const RECORDING_MAX_DURATION_SECONDS = 60;
/**
* Minimum time the record key must be held to count as hold-to-record (vs. a
* quick tap that toggles), in milliseconds.
*/
export const HOLD_THRESHOLD_MS = 250;
export const SUPPORT_EMAIL = 'team@flowylabs.ai';
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
+22
View File
@@ -0,0 +1,22 @@
import { create } from 'zustand';
const KEY = 'llink:onboarding-completed';
interface OnboardingState {
hasCompletedOnboarding: boolean;
markComplete: () => void;
/** Clears the flag so the wizard runs again (handy for QA). */
reset: () => void;
}
export const useOnboardingStore = create<OnboardingState>((set) => ({
hasCompletedOnboarding: localStorage.getItem(KEY) === 'true',
markComplete: () => {
localStorage.setItem(KEY, 'true');
set({ hasCompletedOnboarding: true });
},
reset: () => {
localStorage.removeItem(KEY);
set({ hasCompletedOnboarding: false });
},
}));