From 4e1a22f86943191273195b06b6d06bf1de3c712d Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 13:35:06 -0800 Subject: [PATCH] feat(web): add onboarding walkthrough + ambient sounds for focus mode - OnboardingOverlay: 3-step walkthrough for first-time users (localStorage flag) - ambient-sounds.ts: Web Audio API noise generators (rain, white noise, brown noise, coffee shop) - FocusView: ambient sound picker with volume slider, auto-stops on session end --- web/src/components/Dashboard.tsx | 4 + web/src/components/FocusView.tsx | 62 ++++++- web/src/components/OnboardingOverlay.tsx | 167 ++++++++++++++++++ web/src/lib/ambient-sounds.ts | 214 +++++++++++++++++++++++ 4 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 web/src/components/OnboardingOverlay.tsx create mode 100644 web/src/lib/ambient-sounds.ts diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index de96245..8470e2d 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -17,6 +17,7 @@ import { BUILT_IN_CATEGORIES, matchesCategory } from '@/lib/categories'; import Link from 'next/link'; import { FeedbackButton } from './FeedbackButton'; import { InstallPrompt } from './InstallPrompt'; +import { OnboardingOverlay } from './OnboardingOverlay'; import { useTheme } from '@/lib/use-theme'; import { getSnoozeSuggestions } from '@/lib/adaptive-snooze'; import type { SnoozeSuggestion } from '@/lib/adaptive-snooze'; @@ -127,6 +128,9 @@ export function Dashboard() { Skip to content + {/* Onboarding walkthrough for first-time users */} + + {/* Alarm overlay for firing timers */} diff --git a/web/src/components/FocusView.tsx b/web/src/components/FocusView.tsx index c660cfa..ec17197 100644 --- a/web/src/components/FocusView.tsx +++ b/web/src/components/FocusView.tsx @@ -13,7 +13,11 @@ import { Clock, Shield, Zap, + Volume2, + VolumeX, } from 'lucide-react'; +import { AMBIENT_SOUNDS, startAmbientSound, stopAmbientSound, setAmbientVolume } from '@/lib/ambient-sounds'; +import type { AmbientSoundType } from '@/lib/ambient-sounds'; // ── Types ────────────────────────────────────────────────────── @@ -54,6 +58,10 @@ export function FocusView({ onExit }: FocusViewProps) { const rafRef = useRef(0); const nextTimer = useTimerStore((s) => s.getNextFiringTimer()); + // Ambient sound state + const [ambientType, setAmbientType] = useState(null); + const [ambientVolume, setAmbientVolumeState] = useState(0.3); + // Tick loop for countdown useEffect(() => { if (!session?.isActive) return; @@ -95,6 +103,8 @@ export function FocusView({ onExit }: FocusViewProps) { }, []); const endFocus = useCallback(() => { + stopAmbientSound(); + setAmbientType(null); setSession((prev) => { if (!prev) return prev; return { ...prev, isActive: false, isCompleted: true }; @@ -367,8 +377,58 @@ export function FocusView({ onExit }: FocusViewProps) { + {/* Ambient sound controls */} +
+
+ {ambientType ? : } + + {ambientType ? AMBIENT_SOUNDS.find(s => s.type === ambientType)?.label : 'No ambient sound'} + +
+
+ {AMBIENT_SOUNDS.map((sound) => ( + + ))} +
+ {ambientType && ( + { + const v = parseInt(e.target.value) / 100; + setAmbientVolumeState(v); + setAmbientVolume(v); + }} + className="w-40 accent-[var(--cm-accent-secondary)]" + title={`Volume: ${Math.round(ambientVolume * 100)}%`} + /> + )} +
+ {/* Elapsed time */} -
+
Focused for {formatDurationCompact(elapsed)}
diff --git a/web/src/components/OnboardingOverlay.tsx b/web/src/components/OnboardingOverlay.tsx new file mode 100644 index 0000000..27ee65e --- /dev/null +++ b/web/src/components/OnboardingOverlay.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Clock, Timer, Bell, ChevronRight, X } from 'lucide-react'; + +const ONBOARDING_KEY = 'cm-onboarding-complete'; + +interface OnboardingStep { + title: string; + description: string; + icon: React.ReactNode; + hint: string; +} + +const STEPS: OnboardingStep[] = [ + { + title: 'Create a Timer', + description: 'Tap "New Timer" or press N to create your first timer. Try natural language like "meeting in 30 min".', + icon: , + hint: 'Timers can be alarms, countdowns, Pomodoro sessions, or event countdowns.', + }, + { + title: 'Set Pre-Warning Cascades', + description: 'Choose how early you want reminders — Aggressive gives you many warnings, Light just a few gentle nudges.', + icon: , + hint: 'Cascades are what make ChronoMind unique. Never be caught off-guard again.', + }, + { + title: 'Watch Your Timeline', + description: 'Active timers appear here sorted by fire time. Each card shows countdown, cascade progress, and urgency level.', + icon: , + hint: 'Try Focus mode (eye icon) for distraction-free sessions, or Routines for multi-step workflows.', + }, +]; + +export function OnboardingOverlay() { + const [step, setStep] = useState(0); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + const done = localStorage.getItem(ONBOARDING_KEY); + if (!done) setVisible(true); + }, []); + + const dismiss = () => { + setVisible(false); + localStorage.setItem(ONBOARDING_KEY, 'true'); + }; + + const next = () => { + if (step < STEPS.length - 1) { + setStep(step + 1); + } else { + dismiss(); + } + }; + + if (!visible) return null; + + const current = STEPS[step]; + + return ( +
+ {/* Backdrop */} +
+ + {/* Card */} +
+ {/* Close */} + + + {/* Step indicator */} +
+ {STEPS.map((_, i) => ( +
+ ))} +
+ + {/* Content */} +
+
+ {current.icon} +
+

+ {current.title} +

+

+ {current.description} +

+

+ {current.hint} +

+
+ + {/* Actions */} +
+ {step > 0 && ( + + )} + +
+ + {/* Skip link */} +
+ +
+
+
+ ); +} diff --git a/web/src/lib/ambient-sounds.ts b/web/src/lib/ambient-sounds.ts new file mode 100644 index 0000000..fd55743 --- /dev/null +++ b/web/src/lib/ambient-sounds.ts @@ -0,0 +1,214 @@ +// ── Ambient Background Sounds ──────────────────────────────── +// Web Audio API noise generators for focus mode. +// No external audio files — all generated programmatically. + +export type AmbientSoundType = 'rain' | 'white_noise' | 'brown_noise' | 'coffee_shop'; + +export interface AmbientSoundConfig { + type: AmbientSoundType; + label: string; + description: string; +} + +export const AMBIENT_SOUNDS: AmbientSoundConfig[] = [ + { type: 'rain', label: 'Rain', description: 'Gentle rainfall' }, + { type: 'white_noise', label: 'White Noise', description: 'Even static hiss' }, + { type: 'brown_noise', label: 'Brown Noise', description: 'Deep, warm rumble' }, + { type: 'coffee_shop', label: 'Coffee Shop', description: 'Low murmur ambience' }, +]; + +let audioCtx: AudioContext | null = null; +let activeNodes: AudioNode[] = []; +let gainNode: GainNode | null = null; + +function getAudioContext(): AudioContext { + if (!audioCtx) { + audioCtx = new AudioContext(); + } + if (audioCtx.state === 'suspended') { + audioCtx.resume(); + } + return audioCtx; +} + +function stopAll(): void { + for (const node of activeNodes) { + try { + node.disconnect(); + } catch { + // already disconnected + } + } + activeNodes = []; + gainNode = null; +} + +/** + * Create white noise: uniform random samples. + */ +function createWhiteNoise(ctx: AudioContext, gain: GainNode): void { + const bufferSize = ctx.sampleRate * 2; // 2 seconds looping + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1; + } + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.loop = true; + source.connect(gain); + source.start(); + activeNodes.push(source); +} + +/** + * Create brown noise: accumulated random walk with low-pass filtering. + */ +function createBrownNoise(ctx: AudioContext, gain: GainNode): void { + const bufferSize = ctx.sampleRate * 2; + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + let last = 0; + for (let i = 0; i < bufferSize; i++) { + const white = Math.random() * 2 - 1; + last = (last + 0.02 * white) / 1.02; + data[i] = last * 3.5; // normalize + } + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.loop = true; + source.connect(gain); + source.start(); + activeNodes.push(source); +} + +/** + * Create rain-like sound: filtered noise with gentle modulation. + */ +function createRain(ctx: AudioContext, gain: GainNode): void { + // Base: brown noise for body + const bufferSize = ctx.sampleRate * 4; + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + let last = 0; + for (let i = 0; i < bufferSize; i++) { + const white = Math.random() * 2 - 1; + last = (last + 0.02 * white) / 1.02; + // Add occasional louder "drops" + const drop = Math.random() < 0.001 ? (Math.random() * 0.3) : 0; + data[i] = (last * 2.5) + drop; + } + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.loop = true; + + // Band-pass filter to shape like rain + const filter = ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.value = 2000; + filter.Q.value = 0.5; + + source.connect(filter); + filter.connect(gain); + source.start(); + activeNodes.push(source, filter); +} + +/** + * Create coffee shop ambience: layered filtered noise. + */ +function createCoffeeShop(ctx: AudioContext, gain: GainNode): void { + // Layer 1: low murmur (brown noise, low-pass) + const bufferSize = ctx.sampleRate * 3; + const buffer1 = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data1 = buffer1.getChannelData(0); + let last1 = 0; + for (let i = 0; i < bufferSize; i++) { + const white = Math.random() * 2 - 1; + last1 = (last1 + 0.02 * white) / 1.02; + data1[i] = last1 * 2; + } + const source1 = ctx.createBufferSource(); + source1.buffer = buffer1; + source1.loop = true; + const lpf = ctx.createBiquadFilter(); + lpf.type = 'lowpass'; + lpf.frequency.value = 600; + const gain1 = ctx.createGain(); + gain1.gain.value = 0.7; + source1.connect(lpf); + lpf.connect(gain1); + gain1.connect(gain); + source1.start(); + + // Layer 2: high chatter (white noise, band-pass) + const buffer2 = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data2 = buffer2.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data2[i] = Math.random() * 2 - 1; + } + const source2 = ctx.createBufferSource(); + source2.buffer = buffer2; + source2.loop = true; + const bpf = ctx.createBiquadFilter(); + bpf.type = 'bandpass'; + bpf.frequency.value = 1500; + bpf.Q.value = 1.5; + const gain2 = ctx.createGain(); + gain2.gain.value = 0.15; + source2.connect(bpf); + bpf.connect(gain2); + gain2.connect(gain); + source2.start(); + + activeNodes.push(source1, lpf, gain1, source2, bpf, gain2); +} + +/** + * Start playing an ambient sound at given volume (0-1). + */ +export function startAmbientSound(type: AmbientSoundType, volume: number = 0.3): void { + stopAll(); + const ctx = getAudioContext(); + gainNode = ctx.createGain(); + gainNode.gain.value = Math.max(0, Math.min(1, volume)); + gainNode.connect(ctx.destination); + + switch (type) { + case 'rain': + createRain(ctx, gainNode); + break; + case 'white_noise': + createWhiteNoise(ctx, gainNode); + break; + case 'brown_noise': + createBrownNoise(ctx, gainNode); + break; + case 'coffee_shop': + createCoffeeShop(ctx, gainNode); + break; + } +} + +/** + * Stop all ambient sounds. + */ +export function stopAmbientSound(): void { + stopAll(); +} + +/** + * Set the volume of the currently playing ambient sound. + */ +export function setAmbientVolume(volume: number): void { + if (gainNode) { + gainNode.gain.value = Math.max(0, Math.min(1, volume)); + } +} + +/** + * Check if ambient sound is currently playing. + */ +export function isAmbientPlaying(): boolean { + return activeNodes.length > 0; +}