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..c92b4a7 --- /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..ef3764c --- /dev/null +++ b/js/desktop/src/features/onboarding/onboarding-keyboard.tsx @@ -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 (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..c9aee9a --- /dev/null +++ b/js/desktop/src/features/onboarding/onboarding-overlay.tsx @@ -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('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)]); + }, []); + + 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 ( +
+
+ + + to skip + +
+ +
+
+ {step === 'welcome' && ( + + + + )} + {step === 'hold' && } + {step === 'toggle' && } + {step === 'tap' && } + {step === 'permission' && } + +
+ {STEPS.map((s, i) => ( + + ))} +
+ +
+ {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 new file mode 100644 index 0000000..49598c7 --- /dev/null +++ b/js/desktop/src/features/onboarding/onboarding-step.tsx @@ -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 ( +
+
+

+ {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..9dfe2e1 --- /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..ab25268 --- /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..0b60727 --- /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 }); + }, +}));