diff --git a/@applications/web/src/features/settings/SettingsPanel.tsx b/@applications/web/src/features/settings/SettingsPanel.tsx new file mode 100644 index 0000000..bed15eb --- /dev/null +++ b/@applications/web/src/features/settings/SettingsPanel.tsx @@ -0,0 +1,336 @@ +import { useCallback } from 'react'; +import type { ReactElement } from 'react'; +import styled from '@lilith/ui-styled-components'; +import { Tooltip } from '@lilith/ui-feedback'; +import { AnimatePresence, motion } from '@lilith/ui-motion'; +import type { VoiceSessionState } from '../voice/VoiceSession'; +import type { Settings, SettingsActions } from './useSettings'; + +export interface SettingsPanelProps { + open: boolean; + onClose: () => void; + sessionId: string; + connectionState: VoiceSessionState | 'disconnected'; + settings: Settings; + onSettings: SettingsActions; +} + +const Overlay = styled(motion.div)` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 500; + display: flex; + align-items: flex-end; +`; + +const Sheet = styled(motion.div)` + width: 100%; + max-width: 640px; + margin: 0 auto; + background: #0d0d1a; + border-top: 1px solid #1a1a2e; + border-radius: 20px 20px 0 0; + padding: 8px 0 max(24px, env(safe-area-inset-bottom)); + max-height: 80vh; + overflow-y: auto; +`; + +const Handle = styled.div` + width: 36px; + height: 4px; + border-radius: 2px; + background: #2d3748; + margin: 0 auto 20px; +`; + +const SectionTitle = styled.h2` + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #4a5568; + padding: 0 20px 8px; +`; + +const Row = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + border-bottom: 1px solid #1a1a2e; + + &:last-child { + border-bottom: none; + } +`; + +const RowLabel = styled.span` + font-size: 15px; + color: #e2e8f0; +`; + +const RowValue = styled.span` + font-size: 13px; + color: #4a5568; + font-family: 'SF Mono', 'Fira Mono', monospace; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const CopyButton = styled.button` + background: none; + border: 1px solid #2d3748; + border-radius: 6px; + color: #a0aec0; + font-size: 11px; + padding: 3px 8px; + cursor: pointer; + margin-left: 8px; + flex-shrink: 0; + + &:active { + background: #1a1a2e; + } +`; + +const CONNECTION_LABELS: Record = { + connected: { label: 'Connected', color: '#68d391' }, + disconnected: { label: 'Disconnected', color: '#4a5568' }, + connecting: { label: 'Connecting…', color: '#f9d94e' }, + error: { label: 'Error', color: '#f56565' }, +}; + +const StatusDot = styled.span<{ $color: string }>` + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: ${({ $color }) => $color}; + margin-right: 6px; +`; + +const StatusValue = styled.span<{ $color: string }>` + font-size: 13px; + color: ${({ $color }) => $color}; + display: flex; + align-items: center; +`; + +const Section = styled.div` + margin-bottom: 8px; +`; + +// Toggle +const ToggleTrack = styled.button<{ $on: boolean }>` + width: 44px; + height: 26px; + border-radius: 13px; + border: none; + background: ${({ $on }) => ($on ? '#553c9a' : '#2d3748')}; + position: relative; + cursor: pointer; + transition: background 200ms ease; + flex-shrink: 0; + outline: none; + + &:focus-visible { + box-shadow: 0 0 0 2px #553c9a; + } +`; + +const ToggleThumb = styled.span<{ $on: boolean }>` + position: absolute; + top: 3px; + left: ${({ $on }) => ($on ? '21px' : '3px')}; + width: 20px; + height: 20px; + border-radius: 50%; + background: #e2e8f0; + transition: left 200ms ease; +`; + +// Volume slider +const VolumeRow = styled.div` + display: flex; + align-items: center; + gap: 12px; + flex: 1; + max-width: 180px; +`; + +const VolumeSlider = styled.input` + -webkit-appearance: none; + appearance: none; + flex: 1; + height: 4px; + border-radius: 2px; + background: #2d3748; + outline: none; + cursor: pointer; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: #553c9a; + cursor: pointer; + } + + &::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: #553c9a; + cursor: pointer; + border: none; + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } +`; + +const VolumeLabel = styled.span` + font-size: 12px; + color: #4a5568; + min-width: 32px; + text-align: right; +`; + +function Toggle({ on, onChange, label }: { on: boolean; onChange: (v: boolean) => void; label: string }): ReactElement { + return ( + onChange(!on)} + role="switch" + aria-checked={on} + aria-label={label} + > + + + ); +} + +export function SettingsPanel({ + open, + onClose, + sessionId, + connectionState, + settings, + onSettings, +}: SettingsPanelProps): ReactElement { + const handleCopySession = useCallback(() => { + void navigator.clipboard.writeText(sessionId); + }, [sessionId]); + + const handleOverlayClick = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }, [onClose]); + + const conn = CONNECTION_LABELS[connectionState] ?? { label: 'Disconnected', color: '#4a5568' }; + + return ( + + {open && ( + + + + +
+ Voice Output (TTS) + + Enable TTS + + + + + + Volume + + onSettings.setTtsVolume(Number(e.target.value))} + aria-label="TTS volume" + /> + {Math.round(settings.ttsVolume * 100)}% + + +
+ +
+ Voice Input (STT) + + Enable microphone + + + + +
+ +
+ Connection + + Voice WebSocket + + + {conn.label} + + + + Session ID +
+ + {sessionId ? sessionId.slice(0, 12) + '…' : '—'} + + {sessionId && ( + + Copy + + )} +
+
+
+ +
+ About + + Companion + v1.0 + +
+
+
+ )} +
+ ); +} diff --git a/@applications/web/src/features/settings/useSettings.ts b/@applications/web/src/features/settings/useSettings.ts new file mode 100644 index 0000000..92277fe --- /dev/null +++ b/@applications/web/src/features/settings/useSettings.ts @@ -0,0 +1,58 @@ +import { useState, useCallback } from 'react'; + +export interface Settings { + ttsEnabled: boolean; + ttsVolume: number; + sttEnabled: boolean; +} + +export interface SettingsActions { + setTtsEnabled: (enabled: boolean) => void; + setTtsVolume: (volume: number) => void; + setSttEnabled: (enabled: boolean) => void; +} + +const STORAGE_KEY = 'companion_settings'; + +const DEFAULTS: Settings = { + ttsEnabled: true, + ttsVolume: 1.0, + sttEnabled: true, +}; + +function load(): Settings { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULTS; + return { ...DEFAULTS, ...(JSON.parse(raw) as Partial) }; + } catch { + return DEFAULTS; + } +} + +function save(settings: Settings): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch { + // storage unavailable — non-fatal + } +} + +export function useSettings(): Settings & SettingsActions { + const [settings, setSettings] = useState(load); + + const update = useCallback((patch: Partial) => { + setSettings((prev) => { + const next = { ...prev, ...patch }; + save(next); + return next; + }); + }, []); + + return { + ...settings, + setTtsEnabled: useCallback((enabled: boolean) => update({ ttsEnabled: enabled }), [update]), + setTtsVolume: useCallback((volume: number) => update({ ttsVolume: volume }), [update]), + setSttEnabled: useCallback((enabled: boolean) => update({ sttEnabled: enabled }), [update]), + }; +}