From af06a250fea7e077b583d2ad273296b2060d5442 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 16:47:12 +0000 Subject: [PATCH 1/3] Add first-run onboarding wizard for recording mechanics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01A9AHySdLb1zfZAEm6SuaQP --- js/desktop/src/App.tsx | 10 ++ .../src/features/compose/compose-overlay.tsx | 8 +- .../src/features/onboarding/hold-step.tsx | 52 ++++++ .../onboarding/onboarding-keyboard.tsx | 77 +++++++++ .../onboarding/onboarding-overlay.tsx | 148 ++++++++++++++++++ .../features/onboarding/onboarding-step.tsx | 46 ++++++ .../features/onboarding/permission-step.tsx | 53 +++++++ .../src/features/onboarding/tap-step.tsx | 56 +++++++ .../src/features/onboarding/toggle-step.tsx | 60 +++++++ .../features/onboarding/use-hold-gesture.ts | 75 +++++++++ .../features/onboarding/use-tap-gesture.ts | 52 ++++++ js/desktop/src/lib/constants.ts | 6 + js/desktop/src/stores/onboarding-store.ts | 22 +++ 13 files changed, 662 insertions(+), 3 deletions(-) create mode 100644 js/desktop/src/features/onboarding/hold-step.tsx create mode 100644 js/desktop/src/features/onboarding/onboarding-keyboard.tsx create mode 100644 js/desktop/src/features/onboarding/onboarding-overlay.tsx create mode 100644 js/desktop/src/features/onboarding/onboarding-step.tsx create mode 100644 js/desktop/src/features/onboarding/permission-step.tsx create mode 100644 js/desktop/src/features/onboarding/tap-step.tsx create mode 100644 js/desktop/src/features/onboarding/toggle-step.tsx create mode 100644 js/desktop/src/features/onboarding/use-hold-gesture.ts create mode 100644 js/desktop/src/features/onboarding/use-tap-gesture.ts create mode 100644 js/desktop/src/stores/onboarding-store.ts diff --git a/js/desktop/src/App.tsx b/js/desktop/src/App.tsx index 2858f50..51bd394 100644 --- a/js/desktop/src/App.tsx +++ b/js/desktop/src/App.tsx @@ -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 ; } + if (!hasCompletedOnboarding) { + return ; + } + return ( diff --git a/js/desktop/src/features/compose/compose-overlay.tsx b/js/desktop/src/features/compose/compose-overlay.tsx index d361a8d..e35323f 100644 --- a/js/desktop/src/features/compose/compose-overlay.tsx +++ b/js/desktop/src/features/compose/compose-overlay.tsx @@ -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). diff --git a/js/desktop/src/features/onboarding/hold-step.tsx b/js/desktop/src/features/onboarding/hold-step.tsx new file mode 100644 index 0000000..a915372 --- /dev/null +++ b/js/desktop/src/features/onboarding/hold-step.tsx @@ -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 ( + + Nice. + + ) : state === 'holding' ? ( + 'Keep holding…' + ) : null + } + > + + + ); +} diff --git a/js/desktop/src/features/onboarding/onboarding-keyboard.tsx b/js/desktop/src/features/onboarding/onboarding-keyboard.tsx new file mode 100644 index 0000000..8307229 --- /dev/null +++ b/js/desktop/src/features/onboarding/onboarding-keyboard.tsx @@ -0,0 +1,77 @@ +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 (0–1), 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 ( +
+ {hero && state !== 'success' && progress > 0 && ( + + )} + {label} +
+ ); +} + +/** + * 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 ( +
+ +
+ + +
+
+ + +
+
+ ); +} diff --git a/js/desktop/src/features/onboarding/onboarding-overlay.tsx b/js/desktop/src/features/onboarding/onboarding-overlay.tsx new file mode 100644 index 0000000..a0d0cf1 --- /dev/null +++ b/js/desktop/src/features/onboarding/onboarding-overlay.tsx @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { WindowControls } from '@/components/window-controls'; +import { KeyHint } from '@/components/key-hint'; +import { Button } from '@/components/ui/button'; +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', + 'toggle', + 'tap', + 'permission', + 'done', +] 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('welcome'); + const panelRef = useRef(null); + const index = STEPS.indexOf(step); + + const goNext = useCallback(() => { + setStep((cur) => STEPS[Math.min(STEPS.indexOf(cur) + 1, STEPS.length - 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. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onComplete(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onComplete]); + + return ( +
+
+ + + to skip + +
+ +
+
+ {step === 'welcome' && ( + + + + to begin + + + } + > + + + )} + {step === 'hold' && } + {step === 'toggle' && } + {step === 'tap' && } + {step === 'permission' && } + {step === 'done' && ( + + + + for all shortcuts + + + } + > +
+ + to talk + + switch audio / video + + to start and stop + +
+
+ )} + +
+ {STEPS.map((s, i) => ( + + ))} +
+
+
+
+ ); +} diff --git a/js/desktop/src/features/onboarding/onboarding-step.tsx b/js/desktop/src/features/onboarding/onboarding-step.tsx new file mode 100644 index 0000000..2f9b9e2 --- /dev/null +++ b/js/desktop/src/features/onboarding/onboarding-step.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from 'react'; + +/** How long a success state lingers before the wizard auto-advances, in ms. */ +export const SUCCESS_DWELL_MS = 800; + +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 ( +
+
+

+ {title} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ {children && ( +
{children}
+ )} +

+ {status} +

+ {footer && ( +
{footer}
+ )} +
+ ); +} diff --git a/js/desktop/src/features/onboarding/permission-step.tsx b/js/desktop/src/features/onboarding/permission-step.tsx new file mode 100644 index 0000000..a3cbd1f --- /dev/null +++ b/js/desktop/src/features/onboarding/permission-step.tsx @@ -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 ( + + Access granted. + + ) : denied ? ( + + No access yet. You can enable it later in System Settings. + + ) : null + } + footer={ + granted ? null : denied ? ( + + ) : ( + + ) + } + /> + ); +} diff --git a/js/desktop/src/features/onboarding/tap-step.tsx b/js/desktop/src/features/onboarding/tap-step.tsx new file mode 100644 index 0000000..9122364 --- /dev/null +++ b/js/desktop/src/features/onboarding/tap-step.tsx @@ -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 ( + + Done. + + ) : recording ? ( + + + Recording + + ) : null + } + > + + + ); +} diff --git a/js/desktop/src/features/onboarding/toggle-step.tsx b/js/desktop/src/features/onboarding/toggle-step.tsx new file mode 100644 index 0000000..5ca3684 --- /dev/null +++ b/js/desktop/src/features/onboarding/toggle-step.tsx @@ -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 ( + + {' '} + {isVideo ? 'Back to video.' : 'Audio it is.'} + + ) : null + } + > +
+ {isVideo ?
+ +
+ ); +} diff --git a/js/desktop/src/features/onboarding/use-hold-gesture.ts b/js/desktop/src/features/onboarding/use-hold-gesture.ts new file mode 100644 index 0000000..d9984ce --- /dev/null +++ b/js/desktop/src/features/onboarding/use-hold-gesture.ts @@ -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('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 }; +} diff --git a/js/desktop/src/features/onboarding/use-tap-gesture.ts b/js/desktop/src/features/onboarding/use-tap-gesture.ts new file mode 100644 index 0000000..f6aa8ca --- /dev/null +++ b/js/desktop/src/features/onboarding/use-tap-gesture.ts @@ -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 }; +} diff --git a/js/desktop/src/lib/constants.ts b/js/desktop/src/lib/constants.ts index 8df2a15..fb6bbed 100644 --- a/js/desktop/src/lib/constants.ts +++ b/js/desktop/src/lib/constants.ts @@ -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'; diff --git a/js/desktop/src/stores/onboarding-store.ts b/js/desktop/src/stores/onboarding-store.ts new file mode 100644 index 0000000..df0b399 --- /dev/null +++ b/js/desktop/src/stores/onboarding-store.ts @@ -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((set) => ({ + hasCompletedOnboarding: localStorage.getItem(KEY) === 'true', + markComplete: () => { + localStorage.setItem(KEY, 'true'); + set({ hasCompletedOnboarding: true }); + }, + reset: () => { + localStorage.removeItem(KEY); + set({ hasCompletedOnboarding: false }); + }, +})); -- 2.54.0 From d62d35f25a1fafcd857516181f2bb60470bf7f26 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sun, 21 Jun 2026 11:34:49 -0700 Subject: [PATCH 2/3] nits --- .../onboarding/onboarding-keyboard.tsx | 8 +- .../onboarding/onboarding-overlay.tsx | 90 +++++++++---------- .../features/onboarding/onboarding-step.tsx | 2 +- 3 files changed, 46 insertions(+), 54 deletions(-) diff --git a/js/desktop/src/features/onboarding/onboarding-keyboard.tsx b/js/desktop/src/features/onboarding/onboarding-keyboard.tsx index 8307229..8d31893 100644 --- a/js/desktop/src/features/onboarding/onboarding-keyboard.tsx +++ b/js/desktop/src/features/onboarding/onboarding-keyboard.tsx @@ -29,7 +29,7 @@ function KeyCap({
- +
- +
- +
diff --git a/js/desktop/src/features/onboarding/onboarding-overlay.tsx b/js/desktop/src/features/onboarding/onboarding-overlay.tsx index a0d0cf1..cee5ffd 100644 --- a/js/desktop/src/features/onboarding/onboarding-overlay.tsx +++ b/js/desktop/src/features/onboarding/onboarding-overlay.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { WindowControls } from '@/components/window-controls'; import { KeyHint } from '@/components/key-hint'; -import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { OnboardingKeyboard } from './onboarding-keyboard'; import { OnboardingStep } from './onboarding-step'; @@ -10,14 +9,7 @@ import { ToggleStep } from './toggle-step'; import { TapStep } from './tap-step'; import { PermissionStep } from './permission-step'; -const STEPS = [ - 'welcome', - 'hold', - 'toggle', - 'tap', - 'permission', - 'done', -] as const; +const STEPS = ['welcome', 'hold', 'tap', 'toggle', 'permission'] as const; type Step = (typeof STEPS)[number]; /** @@ -34,6 +26,10 @@ export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) { 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). @@ -43,17 +39,27 @@ export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) { } }, [step]); - // Esc skips the wizard from anywhere. + // 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]); + }, [onComplete, goNext, step, goBack]); return (
@@ -80,17 +86,7 @@ export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) { {step === 'welcome' && ( - - - to begin - - - } + subtitle="To use llink, you must learn the keyboard shortcuts. Don't worry, once you learn them, you'll feel like your flowing." > @@ -98,33 +94,7 @@ export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) { {step === 'hold' && } {step === 'toggle' && } {step === 'tap' && } - {step === 'permission' && } - {step === 'done' && ( - - - - for all shortcuts - - - } - > -
- - to talk - - switch audio / video - - to start and stop - -
-
- )} + {step === 'permission' && }
{STEPS.map((s, i) => ( @@ -141,6 +111,28 @@ export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) { /> ))}
+ +
+ {index > 0 && ( + + back + + )} + {step === 'welcome' && ( + + continue + + )} +
diff --git a/js/desktop/src/features/onboarding/onboarding-step.tsx b/js/desktop/src/features/onboarding/onboarding-step.tsx index 2f9b9e2..33447ca 100644 --- a/js/desktop/src/features/onboarding/onboarding-step.tsx +++ b/js/desktop/src/features/onboarding/onboarding-step.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; /** How long a success state lingers before the wizard auto-advances, in ms. */ -export const SUCCESS_DWELL_MS = 800; +export const SUCCESS_DWELL_MS = 2000; interface OnboardingStepProps { title: ReactNode; -- 2.54.0 From e6bf0247295ecd33650c1340a680028b2404ad79 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sun, 21 Jun 2026 11:42:11 -0700 Subject: [PATCH 3/3] light mode support for onboarding --- js/desktop/src/features/onboarding/hold-step.tsx | 2 +- .../features/onboarding/onboarding-keyboard.tsx | 12 +++++++----- .../features/onboarding/onboarding-overlay.tsx | 16 ++++++++-------- .../src/features/onboarding/onboarding-step.tsx | 8 +++++--- .../src/features/onboarding/permission-step.tsx | 4 ++-- js/desktop/src/features/onboarding/tap-step.tsx | 4 ++-- .../src/features/onboarding/toggle-step.tsx | 6 +++--- 7 files changed, 28 insertions(+), 24 deletions(-) diff --git a/js/desktop/src/features/onboarding/hold-step.tsx b/js/desktop/src/features/onboarding/hold-step.tsx index a915372..c92b4a7 100644 --- a/js/desktop/src/features/onboarding/hold-step.tsx +++ b/js/desktop/src/features/onboarding/hold-step.tsx @@ -34,7 +34,7 @@ export function HoldStep({ onAdvance }: { onAdvance: () => void }) { } status={ done ? ( - + Nice. ) : state === 'holding' ? ( diff --git a/js/desktop/src/features/onboarding/onboarding-keyboard.tsx b/js/desktop/src/features/onboarding/onboarding-keyboard.tsx index 8d31893..ef3764c 100644 --- a/js/desktop/src/features/onboarding/onboarding-keyboard.tsx +++ b/js/desktop/src/features/onboarding/onboarding-keyboard.tsx @@ -30,20 +30,22 @@ function KeyCap({ 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-white/10 bg-white/5 text-white/30', + !hero && 'border-border bg-muted text-muted-foreground/70', hero && state === 'idle' && - 'border-white/25 bg-white/10 text-white motion-safe:animate-pulse', - hero && state === 'active' && 'border-white/50 bg-white/20 text-white', + '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-400/50 bg-emerald-500/20 text-emerald-200', + 'border-emerald-500/50 bg-emerald-500/15 text-emerald-700 dark:text-emerald-200', className, )} > {hero && state !== 'success' && progress > 0 && ( )} diff --git a/js/desktop/src/features/onboarding/onboarding-overlay.tsx b/js/desktop/src/features/onboarding/onboarding-overlay.tsx index cee5ffd..c9aee9a 100644 --- a/js/desktop/src/features/onboarding/onboarding-overlay.tsx +++ b/js/desktop/src/features/onboarding/onboarding-overlay.tsx @@ -62,14 +62,14 @@ export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) { }, [onComplete, goNext, step, goBack]); return ( -
-
+
+
to skip @@ -81,7 +81,7 @@ export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) { 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-white/10 bg-white/5 p-10 outline-none" + 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' && ( void }) { className={cn( 'h-1.5 rounded-full transition-all', i === index - ? 'w-6 bg-white/80' + ? 'w-6 bg-foreground' : i < index - ? 'w-1.5 bg-white/40' - : 'w-1.5 bg-white/15', + ? 'w-1.5 bg-foreground/40' + : 'w-1.5 bg-foreground/15', )} /> ))}
-
+
{index > 0 && (
-

+

{title}

{subtitle && ( -

{subtitle}

+

+ {subtitle} +

)}
{children && (
{children}
)} -

+

{status}

{footer && ( diff --git a/js/desktop/src/features/onboarding/permission-step.tsx b/js/desktop/src/features/onboarding/permission-step.tsx index a3cbd1f..9dfe2e1 100644 --- a/js/desktop/src/features/onboarding/permission-step.tsx +++ b/js/desktop/src/features/onboarding/permission-step.tsx @@ -28,11 +28,11 @@ export function PermissionStep({ onAdvance }: { onAdvance: () => void }) { subtitle="llink records audio and video. Grant access once so recording just works." status={ granted ? ( - + Access granted. ) : denied ? ( - + No access yet. You can enable it later in System Settings. ) : null diff --git a/js/desktop/src/features/onboarding/tap-step.tsx b/js/desktop/src/features/onboarding/tap-step.tsx index 9122364..ab25268 100644 --- a/js/desktop/src/features/onboarding/tap-step.tsx +++ b/js/desktop/src/features/onboarding/tap-step.tsx @@ -36,11 +36,11 @@ export function TapStep({ onAdvance }: { onAdvance: () => void }) { } status={ satisfied ? ( - + Done. ) : recording ? ( - + Recording diff --git a/js/desktop/src/features/onboarding/toggle-step.tsx b/js/desktop/src/features/onboarding/toggle-step.tsx index 5ca3684..0b60727 100644 --- a/js/desktop/src/features/onboarding/toggle-step.tsx +++ b/js/desktop/src/features/onboarding/toggle-step.tsx @@ -41,16 +41,16 @@ export function ToggleStep({ onAdvance }: { onAdvance: () => void }) { } status={ satisfied ? ( - + {' '} {isVideo ? 'Back to video.' : 'Audio it is.'} ) : null } > -
+
{isVideo ?
-- 2.54.0