diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..c2b67d2 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,23 @@ +# Don't Touch Design Notes + +## Visual System + +The app stays dark, quiet, and work-focused. Use neutral surfaces, restrained borders, and one calm blue accent for interactive affordances. + +## Semantic Color + +- Blue: interactive controls and selected setup surfaces. +- Green: healthy/complete/private-safe states. +- Amber: hand-near-face reminders and recoverable attention states. +- Red: destructive actions or unrecoverable failures only. + +## Interaction + +- Primary monitoring action stays immediate. +- Camera/model failures appear as persistent recovery panels near controls. +- Settings are organized as Setup, Detection, Alerts, Habit support, Privacy & data, and App. +- Detection presets offer Gentle, Balanced, and Strict before exposing advanced sliders. + +## Motion + +Motion is subtle and non-essential. Avoid shake, blink, scanner lines, heavy pulsing, and animated warning backgrounds. Progress fills may update directly without width transitions. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..d32da0a --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,46 @@ +# Don't Touch Product Context + +## Register + +Product. + +## Users + +People who want help reducing face-touching habits, including hair pulling, skin picking, rubbing, or other repetitive hand-to-face patterns. + +## Purpose + +Detect hand proximity near the face locally and provide timely reminders that help the user interrupt the habit without shame, panic, or surveillance anxiety. + +## Tone + +Private coach. + +Use firm, short, actionable language: + +- Hand near face +- Move your hand away +- Clear to close +- Start monitoring + +Avoid punitive or alarmist language: + +- Warning +- Violation +- Face touch detected +- Remove your hand immediately + +## Privacy Principles + +- Camera access starts only after the user presses Start. +- Video processing stays local to the device. +- Recovery UI should explain camera/model issues without implying user fault. +- Error details belong behind disclosure controls unless required for action. + +## Accessibility Principles + +- Keyboard users must be able to reach every control. +- Active dialogs trap focus and close with Escape. +- State must not rely on color alone. +- Motion should respect reduced-motion preferences. +- Alerts can be immediate without shaking, blinking, or hostile visuals. diff --git a/electron/main/index.ts b/electron/main/index.ts index c3cae6a..afcba0d 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -13,12 +13,28 @@ interface AppSettings { autoStart: boolean minimizeToTray: boolean startMinimized: boolean + hidePreview: boolean + closeAction: 'ask' | 'quit' | 'tray' + alertSoundId: string + alertVolume: number + rightRailCollapsed: boolean + alertTimeoutDefaultMinutes: 5 | 10 | 15 | 30 | 45 | 60 + cameraQuality: 'balanced' | 'high' | 'maximum' + detectionDebugHud: boolean } const DEFAULT_APP_SETTINGS: AppSettings = { autoStart: false, minimizeToTray: true, startMinimized: false, + hidePreview: false, + closeAction: 'ask', + alertSoundId: 'tone-chime', + alertVolume: 0.5, + rightRailCollapsed: false, + alertTimeoutDefaultMinutes: 15, + cameraQuality: 'high', + detectionDebugHud: false, } const APP_SETTINGS_FILE = path.join(app.getPath('userData'), 'app-settings.json') @@ -32,11 +48,7 @@ function loadAppSettings(): AppSettings { // must not leave fields undefined, which would silently break the // close/minimize-to-tray behavior that branches on these booleans. if (parsed && typeof parsed === 'object') { - return { - autoStart: Boolean(parsed.autoStart ?? DEFAULT_APP_SETTINGS.autoStart), - minimizeToTray: Boolean(parsed.minimizeToTray ?? DEFAULT_APP_SETTINGS.minimizeToTray), - startMinimized: Boolean(parsed.startMinimized ?? DEFAULT_APP_SETTINGS.startMinimized), - } + return coerceAppSettings(parsed) } } } catch (err) { @@ -55,6 +67,47 @@ function saveAppSettings(settings: AppSettings): void { let appSettings = loadAppSettings() +function isCloseAction(value: unknown): value is AppSettings['closeAction'] { + return value === 'ask' || value === 'quit' || value === 'tray' +} + +function isAlertTimeoutDefaultMinutes(value: unknown): value is AppSettings['alertTimeoutDefaultMinutes'] { + return value === 5 || value === 10 || value === 15 || value === 30 || value === 45 || value === 60 +} + +function isCameraQuality(value: unknown): value is AppSettings['cameraQuality'] { + return value === 'balanced' || value === 'high' || value === 'maximum' +} + +function coerceVolume(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.min(1, value)) + : DEFAULT_APP_SETTINGS.alertVolume +} + +function coerceAppSettings(value: unknown): AppSettings { + if (!value || typeof value !== 'object') return { ...DEFAULT_APP_SETTINGS } + const parsed = value as Partial + + return { + autoStart: Boolean(parsed.autoStart ?? DEFAULT_APP_SETTINGS.autoStart), + minimizeToTray: Boolean(parsed.minimizeToTray ?? DEFAULT_APP_SETTINGS.minimizeToTray), + startMinimized: Boolean(parsed.startMinimized ?? DEFAULT_APP_SETTINGS.startMinimized), + hidePreview: Boolean(parsed.hidePreview ?? DEFAULT_APP_SETTINGS.hidePreview), + closeAction: isCloseAction(parsed.closeAction) ? parsed.closeAction : DEFAULT_APP_SETTINGS.closeAction, + alertSoundId: typeof parsed.alertSoundId === 'string' ? parsed.alertSoundId : DEFAULT_APP_SETTINGS.alertSoundId, + alertVolume: coerceVolume(parsed.alertVolume), + rightRailCollapsed: Boolean(parsed.rightRailCollapsed ?? DEFAULT_APP_SETTINGS.rightRailCollapsed), + alertTimeoutDefaultMinutes: isAlertTimeoutDefaultMinutes(parsed.alertTimeoutDefaultMinutes) + ? parsed.alertTimeoutDefaultMinutes + : DEFAULT_APP_SETTINGS.alertTimeoutDefaultMinutes, + cameraQuality: isCameraQuality(parsed.cameraQuality) + ? parsed.cameraQuality + : DEFAULT_APP_SETTINGS.cameraQuality, + detectionDebugHud: Boolean(parsed.detectionDebugHud ?? DEFAULT_APP_SETTINGS.detectionDebugHud), + } +} + const __dirname = path.dirname(fileURLToPath(import.meta.url)) // The built directory structure @@ -92,6 +145,7 @@ let win: BrowserWindow | null = null let alertWindow: BrowserWindow | null = null let tray: Tray | null = null let isQuitting = false +let alertShowRequestId = 0 // URL validation for external links function isValidExternalUrl(url: string): boolean { @@ -288,11 +342,17 @@ async function createWindow() { // IPC Handlers for fullscreen alert ipcMain.handle('show-fullscreen-alert', (_, data) => { + const showRequestId = ++alertShowRequestId + if (!alertWindow) { createAlertWindow() } const sendDataAndShow = () => { + if (showRequestId !== alertShowRequestId || !alertWindow) { + return + } + // Ensure window covers entire screen including taskbar const primaryDisplay = screen.getPrimaryDisplay() const { bounds } = primaryDisplay @@ -314,6 +374,8 @@ ipcMain.handle('show-fullscreen-alert', (_, data) => { }) ipcMain.handle('hide-fullscreen-alert', () => { + alertShowRequestId++ + if (alertWindow) { alertWindow.hide() } @@ -354,13 +416,13 @@ ipcMain.handle('get-app-settings', () => { }) ipcMain.handle('set-app-settings', (_, settings: AppSettings) => { - appSettings = settings - saveAppSettings(settings) + appSettings = coerceAppSettings(settings) + saveAppSettings(appSettings) // Update auto-start setting app.setLoginItemSettings({ - openAtLogin: settings.autoStart, - openAsHidden: settings.startMinimized, + openAtLogin: appSettings.autoStart, + openAsHidden: appSettings.startMinimized, }) return true diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 48c4350..e3045fd 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -129,6 +129,7 @@ const safeDOM = { */ function useLoading() { const className = `loaders-css__square-spin` + let loadingRemoved = false const styleContent = ` @keyframes square-spin { 25% { transform: perspective(100px) rotateX(180deg) rotateY(0); } @@ -166,10 +167,14 @@ function useLoading() { return { appendLoading() { + if (loadingRemoved) { + return + } safeDOM.append(document.head, oStyle) safeDOM.append(document.body, oDiv) }, removeLoading() { + loadingRemoved = true safeDOM.remove(document.head, oStyle) safeDOM.remove(document.body, oDiv) }, @@ -181,8 +186,8 @@ function useLoading() { const { appendLoading, removeLoading } = useLoading() domReady().then(appendLoading) -window.onmessage = (ev) => { - ev.data.payload === 'removeLoading' && removeLoading() -} +window.addEventListener('message', (ev) => { + ev.data?.payload === 'removeLoading' && removeLoading() +}) -setTimeout(removeLoading, 4999) \ No newline at end of file +setTimeout(removeLoading, 4999) diff --git a/src/App.css b/src/App.css index 5b169c5..7bff67f 100644 --- a/src/App.css +++ b/src/App.css @@ -1,6 +1,7 @@ /* App Container */ .app-container { height: 100vh; + height: 100dvh; width: 100vw; max-width: 100%; display: flex; @@ -44,6 +45,10 @@ .logo-icon { font-size: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #7dd3fc; } .logo-text { @@ -55,7 +60,7 @@ .app-version { font-size: 10px; font-weight: 500; - color: #666; + color: #94a3b8; padding: 2px 6px; background: rgba(255, 255, 255, 0.05); border-radius: 4px; @@ -76,12 +81,6 @@ width: 8px; height: 8px; border-radius: 50%; - animation: pulse-dot 2s ease-in-out infinite; -} - -@keyframes pulse-dot { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } } .header-right { @@ -100,9 +99,11 @@ background: transparent; border: none; border-radius: 6px; - color: #888; + color: #94a3b8; cursor: pointer; - transition: all 0.15s; + transition: + background-color var(--motion-fast) var(--motion-ease), + color var(--motion-fast) var(--motion-ease); } .header-btn:hover { @@ -131,10 +132,12 @@ border-radius: 4px; color: #aaa; cursor: pointer; - transition: all 0.15s; font-size: 14px; font-weight: 400; line-height: 1; + transition: + background-color var(--motion-fast) var(--motion-ease), + color var(--motion-fast) var(--motion-ease); } .window-btn:hover { @@ -177,6 +180,7 @@ justify-content: center; background: #000; position: relative; + isolation: isolate; } .video-preview { @@ -197,8 +201,10 @@ left: 0; width: 100%; height: 100%; + object-fit: contain; pointer-events: none; transform: scaleX(-1); + z-index: var(--z-video-overlay); } .video-placeholder { @@ -220,7 +226,7 @@ .placeholder-hint { font-size: 13px; opacity: 0.6; - color: #666; + color: #94a3b8; } /* Detection Progress Bar */ @@ -235,83 +241,171 @@ .progress-fill { height: 100%; - transition: width 0.1s linear; } .progress-fill.warning { - background: linear-gradient(90deg, #ffa500, #ff4444); + background: #f59e0b; } .progress-fill.cooldown { - background: #444; + background: #64748b; } -/* Side Panel */ -.side-panel { +/* Activity Rail */ +.activity-rail { width: 260px; background: #15151d; border-left: 1px solid rgba(255, 255, 255, 0.06); display: flex; flex-direction: column; - padding: 16px; - gap: 16px; + padding: 10px 12px 14px; + gap: 10px; flex-shrink: 0; overflow-y: auto; + transition: + width var(--motion-slow) var(--motion-ease), + padding var(--motion-slow) var(--motion-ease), + background-color var(--motion-standard) var(--motion-ease), + border-color var(--motion-standard) var(--motion-ease); +} + +.activity-rail.collapsed { + width: 46px; + padding: 10px 6px; + overflow: hidden; +} + +.activity-rail-header { + display: flex; + justify-content: flex-end; + align-items: center; + min-height: 28px; +} + +.activity-rail-toggle { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + background: rgba(255, 255, 255, 0.045); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: #94a3b8; + cursor: pointer; + transition: + background-color var(--motion-fast) var(--motion-ease), + border-color var(--motion-fast) var(--motion-ease), + color var(--motion-fast) var(--motion-ease), + transform var(--motion-fast) var(--motion-ease); +} + +.activity-rail.collapsed .activity-rail-toggle { + margin-inline: auto; +} + +.activity-rail-toggle:hover { + background: rgba(125, 211, 252, 0.08); + border-color: rgba(125, 211, 252, 0.28); + color: #7dd3fc; +} + +.activity-rail-toggle:active { + transform: translateY(1px); +} + +.activity-rail-content { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + animation: railContentIn var(--motion-standard) var(--motion-ease); +} + +.rail-chevron-stacked { + display: none; +} + +@keyframes railContentIn { + from { + opacity: 0; + transform: translateX(6px); + filter: blur(2px); + } + to { + opacity: 1; + transform: translateX(0); + filter: blur(0); + } } /* Quick Actions */ .quick-actions { display: flex; - gap: 10px; + flex-direction: column; + gap: 8px; } .quick-btn { - flex: 1; + width: 100%; display: flex; - flex-direction: column; + flex-direction: row; align-items: center; - gap: 6px; - padding: 14px 10px; - background: rgba(255, 255, 255, 0.04); + justify-content: flex-start; + gap: 9px; + min-height: 38px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.035); border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 10px; - color: #999; + border-radius: 8px; + color: #cbd5e1; font-size: 12px; cursor: pointer; - transition: all 0.15s; + transition: + background-color var(--motion-fast) var(--motion-ease), + border-color var(--motion-fast) var(--motion-ease), + color var(--motion-fast) var(--motion-ease), + transform var(--motion-fast) var(--motion-ease); } .quick-btn:hover { background: rgba(255, 255, 255, 0.08); color: #fff; border-color: rgba(255, 255, 255, 0.15); + transform: translateY(-1px); } .quick-btn.meditation:hover { - border-color: rgba(187, 134, 252, 0.4); - color: #bb86fc; - background: rgba(187, 134, 252, 0.1); + border-color: rgba(125, 211, 252, 0.35); + color: #7dd3fc; + background: rgba(125, 211, 252, 0.08); } -.quick-btn span:first-child { - font-size: 22px; +.quick-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + flex: 0 0 20px; + color: #7dd3fc; } /* Zone Info */ .zone-info { background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 10px; - padding: 12px; + border-radius: 8px; + padding: 10px; } .zone-title { display: block; font-size: 11px; - color: #666; + color: #94a3b8; text-transform: uppercase; letter-spacing: 0.5px; - margin-bottom: 10px; + margin-bottom: 8px; } .zone-list { @@ -321,16 +415,65 @@ } .zone-tag { - padding: 5px 10px; - background: rgba(0, 255, 255, 0.08); + padding: 5px 9px; + background: rgba(125, 211, 252, 0.08); + border: 1px solid transparent; border-radius: 6px; font-size: 11px; - color: #00bcd4; + color: #7dd3fc; } .zone-tag.active { - background: rgba(255, 68, 68, 0.25); - color: #ff6b6b; + background: rgba(245, 158, 11, 0.18); + color: #fbbf24; + border: 1px solid rgba(245, 158, 11, 0.28); +} + +.setup-card { + background: rgba(125, 211, 252, 0.06); + border: 1px solid rgba(125, 211, 252, 0.18); + border-radius: 8px; + padding: 12px; +} + +.setup-card-kicker { + display: block; + color: #7dd3fc; + font-size: 11px; + font-weight: 600; + margin-bottom: 6px; +} + +.setup-card h2 { + color: #f8fafc; + font-size: 14px; + line-height: 1.25; + margin: 0 0 6px 0; +} + +.setup-card p { + color: #94a3b8; + font-size: 11px; + line-height: 1.45; + margin: 0 0 12px 0; +} + +.setup-card button { + width: 100%; + padding: 8px 10px; + background: rgba(125, 211, 252, 0.12); + border: 1px solid rgba(125, 211, 252, 0.35); + border-radius: 6px; + color: #e0f2fe; + font-weight: 600; + transition: + background-color var(--motion-fast) var(--motion-ease), + border-color var(--motion-fast) var(--motion-ease); +} + +.setup-card button:hover { + background: rgba(125, 211, 252, 0.18); + border-color: rgba(125, 211, 252, 0.55); } /* Footer */ @@ -344,19 +487,109 @@ flex-shrink: 0; } -/* Error Toast */ -.error-toast { - position: fixed; - bottom: 80px; - left: 50%; - transform: translateX(-50%); - background: rgba(255, 68, 68, 0.9); - color: #fff; - padding: 10px 20px; +.recovery-panel { + display: grid; + grid-template-columns: minmax(180px, 260px) 1fr; + gap: 14px; + padding: 14px 16px; + background: #171923; + border-top: 1px solid rgba(245, 158, 11, 0.22); +} + +.recovery-panel-copy { + min-width: 0; +} + +.recovery-kicker { + display: block; + color: #fbbf24; + font-size: 11px; + font-weight: 700; + margin-bottom: 4px; +} + +.recovery-panel h2 { + color: #f8fafc; + font-size: 15px; + margin: 0 0 4px 0; +} + +.recovery-panel p { + color: #94a3b8; + font-size: 12px; + line-height: 1.4; + margin: 0; +} + +.recovery-issues { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.recovery-issue { + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; + align-items: start; + padding: 12px; + background: rgba(255, 255, 255, 0.035); + border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 8px; +} + +.recovery-issue h3 { + color: #f8fafc; font-size: 13px; - z-index: 100; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + margin: 0 0 4px 0; +} + +.recovery-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.recovery-primary, +.recovery-secondary { + padding: 8px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.recovery-primary { + background: rgba(125, 211, 252, 0.14); + border: 1px solid rgba(125, 211, 252, 0.45); + color: #e0f2fe; +} + +.recovery-secondary { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.12); + color: #cbd5e1; +} + +.recovery-details { + grid-column: 1 / -1; + color: #94a3b8; + font-size: 11px; +} + +.recovery-details summary { + cursor: pointer; +} + +.recovery-details code { + display: block; + margin: 6px 0 0 0; + padding: 8px; + overflow-wrap: anywhere; + color: #cbd5e1; + background: rgba(0, 0, 0, 0.22); } /* Scrollbar styling */ @@ -384,26 +617,20 @@ align-items: center; justify-content: space-between; padding: 8px 16px; - background: linear-gradient(90deg, #1a5f2a 0%, #2d8a3e 100%); - border-bottom: 1px solid rgba(0, 255, 136, 0.2); + background: #1f4d35; + border-bottom: 1px solid rgba(52, 211, 153, 0.2); transition: background 0.2s; flex-shrink: 0; } .update-banner.downloading { - background: linear-gradient(90deg, #1a4a5f 0%, #2d6a8a 100%); - border-bottom-color: rgba(0, 200, 255, 0.2); + background: #1e4a5f; + border-bottom-color: rgba(125, 211, 252, 0.2); } .update-banner.ready { - background: linear-gradient(90deg, #5f1a5f 0%, #8a2d8a 100%); - border-bottom-color: rgba(200, 0, 255, 0.2); - animation: pulse-ready 2s ease-in-out infinite; -} - -@keyframes pulse-ready { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.85; } + background: #4a2d5f; + border-bottom-color: rgba(216, 180, 254, 0.2); } .update-banner-content { @@ -431,8 +658,10 @@ border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 14px; cursor: pointer; - transition: all 0.2s; font-weight: 500; + transition: + background-color var(--motion-fast) var(--motion-ease), + border-color var(--motion-fast) var(--motion-ease); } .update-banner-action-btn:hover { @@ -443,12 +672,6 @@ .update-banner-action-btn.install { background: rgba(255, 255, 255, 0.25); border-color: rgba(255, 255, 255, 0.5); - animation: pulse-btn 1.5s ease-in-out infinite; -} - -@keyframes pulse-btn { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.05); } } .update-banner-details { @@ -475,9 +698,8 @@ .update-banner-progress-fill { height: 100%; - background: linear-gradient(90deg, #00c8ff, #00ff88); + background: #7dd3fc; border-radius: 3px; - transition: width 0.3s ease; } .update-banner-close { @@ -488,10 +710,149 @@ cursor: pointer; font-size: 14px; border-radius: 4px; - transition: all 0.2s; + transition: + background-color var(--motion-fast) var(--motion-ease), + color var(--motion-fast) var(--motion-ease); } .update-banner-close:hover { color: #fff; background: rgba(255, 255, 255, 0.1); } + +@media (max-width: 720px) { + .app-container { + overflow: auto; + } + + .app-main { + flex-direction: column; + overflow: visible; + min-height: 0; + } + + .video-area { + min-height: 380px; + flex: 1 0 52vh; + } + + .activity-rail { + width: 100%; + max-height: none; + overflow: visible; + border-left: none; + border-top: 1px solid rgba(255, 255, 255, 0.06); + } + + .rail-chevron-horizontal { + display: none; + } + + .rail-chevron-stacked { + display: block; + } + + .activity-rail.collapsed { + width: 100%; + min-height: 50px; + align-items: flex-end; + } + + .activity-rail-content { + flex-direction: row; + flex-wrap: wrap; + align-items: stretch; + } + + .activity-rail-content > * { + flex: 1 1 220px; + } + + .quick-actions { + min-width: 220px; + } + + .zone-info, + .setup-card { + min-width: 260px; + } + + .recovery-panel { + grid-template-columns: 1fr; + } + + .recovery-issue { + grid-template-columns: 1fr; + } + + .recovery-actions { + justify-content: flex-start; + } +} + +@media (max-width: 560px) { + .app-header { + height: auto; + min-height: 48px; + align-items: flex-start; + gap: 10px; + padding: 8px 10px; + } + + .header-left { + min-width: 0; + flex-wrap: wrap; + gap: 8px; + } + + .logo-text { + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .status-indicator { + order: 2; + } + + .window-controls { + margin-left: 4px; + padding-left: 8px; + } + + .video-area { + min-height: 320px; + flex-basis: 48vh; + } + + .activity-rail { + padding: 12px; + gap: 12px; + } + + .quick-actions { + flex-basis: 100%; + } + + .app-footer { + position: sticky; + bottom: 0; + z-index: var(--z-floating); + padding: 10px; + } + + .update-banner, + .update-banner-content { + align-items: flex-start; + gap: 8px; + } + + .update-banner-content { + flex-wrap: wrap; + } + + .update-banner-progress { + width: 100%; + } +} diff --git a/src/App.tsx b/src/App.tsx index a4ffb0e..196b897 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,16 +5,29 @@ import { useStatistics } from './hooks/useStatistics' import { VideoPreview } from './components/VideoPreview' import { Controls } from './components/Controls' import { SettingsPanel } from './components/SettingsPanel' -import { DailyStatsCard } from './components/DailyStatsCard' +import { ActivityRail } from './components/ActivityRail' import { MeditationModal } from './components/MeditationModal' import { CalendarView } from './components/CalendarView' import { AboutModal } from './components/AboutModal' import { CloseConfirmModal } from './components/CloseConfirmModal' +import { RecoveryPanel } from './components/RecoveryPanel' import { useLanguage } from './i18n/LanguageContext' -import { AppSettings, DEFAULT_APP_SETTINGS } from './types/app-settings' +import { AlertTimeoutState, AppSettings, DEFAULT_APP_SETTINGS } from './types/app-settings' import { STORAGE_KEYS } from './constants/storage-keys' import { IPC_CHANNELS } from './constants/ipc-channels' import { safeInvoke } from './utils/ipc' +import { + clearAlertTimeout, + coerceAlertTimeoutMinutes, + createAlertTimeout, + formatAlertTimeoutRemaining, + getAlertTimeoutRemainingMs, + isAlertTimeoutActive, + loadAlertTimeout, + saveAlertTimeout, +} from './utils/alertTimeout' +import { shouldSuppressReminderSideEffects } from './utils/alertGate' +import { coerceCameraQuality } from './utils/cameraQuality' import { AlertSoundService } from './audio/AlertSoundService' import { PRESET_BASE_URL } from './audio/soundPresets' import { resolveCustomSoundUrl } from './audio/customSoundStorage' @@ -37,6 +50,7 @@ function App() { const videoRef = useRef(null) const canvasRef = useRef(null) const [isRunning, setIsRunning] = useState(false) + const [isStartingCamera, setIsStartingCamera] = useState(false) const [showAlert, setShowAlert] = useState(false) const [showMeditationModal, setShowMeditationModal] = useState(false) const [showCalendar, setShowCalendar] = useState(false) @@ -44,6 +58,13 @@ function App() { const [showCloseModal, setShowCloseModal] = useState(false) const [appVersion, setAppVersion] = useState('') const detectingStartTimeRef = useRef(null) + const [setupComplete, setSetupComplete] = useState(() => { + try { + return localStorage.getItem(STORAGE_KEYS.SETUP_COMPLETE) === 'true' + } catch { + return false + } + }) // Update notification state const [updateAvailable, setUpdateAvailable] = useState(null) @@ -57,16 +78,46 @@ function App() { try { const stored = localStorage.getItem(STORAGE_KEYS.APP_SETTINGS) if (stored) { - return { ...DEFAULT_APP_SETTINGS, ...JSON.parse(stored) } + const parsed = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(stored) } + return { + ...parsed, + rightRailCollapsed: Boolean(parsed.rightRailCollapsed), + alertTimeoutDefaultMinutes: coerceAlertTimeoutMinutes(parsed.alertTimeoutDefaultMinutes), + cameraQuality: coerceCameraQuality(parsed.cameraQuality), + detectionDebugHud: Boolean(parsed.detectionDebugHud), + } } } catch { // Ignore } return DEFAULT_APP_SETTINGS }) + const [alertTimeout, setAlertTimeout] = useState(() => { + try { + return loadAlertTimeout(localStorage) + } catch { + return null + } + }) + const [nowMs, setNowMs] = useState(() => Date.now()) + const [resumeAfterHandClear, setResumeAfterHandClear] = useState(false) + + const alertTimeoutActive = isAlertTimeoutActive(alertTimeout, nowMs) + const alertTimeoutRemainingMs = getAlertTimeoutRemainingMs(alertTimeout, nowMs) + const remindersSuppressed = shouldSuppressReminderSideEffects({ + alertTimeoutActive, + resumeAfterHandClear, + }) const updateAppSettings = (newSettings: Partial) => { - const updated = { ...appSettings, ...newSettings } + const merged = { ...appSettings, ...newSettings } + const updated = { + ...merged, + rightRailCollapsed: Boolean(merged.rightRailCollapsed), + alertTimeoutDefaultMinutes: coerceAlertTimeoutMinutes(merged.alertTimeoutDefaultMinutes), + cameraQuality: coerceCameraQuality(merged.cameraQuality), + detectionDebugHud: Boolean(merged.detectionDebugHud), + } setAppSettings(updated) try { localStorage.setItem(STORAGE_KEYS.APP_SETTINGS, JSON.stringify(updated)) @@ -148,11 +199,13 @@ function App() { stream, error: cameraError, devices: cameraDevices, + streamInfo: cameraStreamInfo, selectedDeviceId: selectedCameraId, startCamera, stopCamera, setSelectedDeviceId: setSelectedCameraId, - } = useCamera() + refreshDevices, + } = useCamera(appSettings.cameraQuality) const { todayTouchCount, @@ -181,6 +234,7 @@ function App() { handsCount, startDetection, stopDetection, + retryModelLoad, isHandNearHead, updateConfig, } = useDetection({ @@ -189,8 +243,56 @@ function App() { onAlert: handleAlert, }) + useEffect(() => { + if (!alertTimeout) return + + const timer = window.setInterval(() => setNowMs(Date.now()), 1000) + return () => window.clearInterval(timer) + }, [alertTimeout]) + + useEffect(() => { + if (!alertTimeout) return + + if (!alertTimeoutActive) { + clearAlertTimeout(localStorage) + setAlertTimeout(null) + setResumeAfterHandClear(isRunning && isHandNearHead()) + } + }, [alertTimeout, alertTimeoutActive, isHandNearHead, isRunning]) + + useEffect(() => { + if (resumeAfterHandClear && !isHandNearHead()) { + setResumeAfterHandClear(false) + } + }, [resumeAfterHandClear, isHandNearHead, isNearHead]) + + const handleStartAlertTimeout = useCallback(() => { + const timeout = createAlertTimeout(appSettings.alertTimeoutDefaultMinutes) + setNowMs(Date.now()) + setAlertTimeout(timeout) + setResumeAfterHandClear(false) + saveAlertTimeout(localStorage, timeout) + + alertSoundServiceRef.current.stop() + if (showAlert) { + setShowAlert(false) + safeInvoke(IPC_CHANNELS.HIDE_FULLSCREEN_ALERT) + } + }, [appSettings.alertTimeoutDefaultMinutes, showAlert]) + + const handleStopAlertTimeout = useCallback(() => { + clearAlertTimeout(localStorage) + setAlertTimeout(null) + setNowMs(Date.now()) + setResumeAfterHandClear(isRunning && isHandNearHead()) + }, [isHandNearHead, isRunning]) + useEffect(() => { if (detectionState === 'DETECTING') { + if (remindersSuppressed) { + detectingStartTimeRef.current = null + return + } // Set the start time once, when detection begins — NOT on every activeZone // change (the fingertip drifts across zones each frame, which previously kept // resetting this ref and under-reported the touch duration). @@ -199,22 +301,26 @@ function App() { } } else if (detectionState === 'ALERT' && detectingStartTimeRef.current !== null) { const duration = Date.now() - detectingStartTimeRef.current - recordTouch(duration, activeZone) + if (!remindersSuppressed) { + recordTouch(duration, activeZone) + } detectingStartTimeRef.current = null } else if (detectionState === 'IDLE' || detectionState === 'COOLDOWN') { // Detection ended (with or without an alert) — reset for the next cycle. detectingStartTimeRef.current = null } - }, [detectionState, activeZone, recordTouch]) + }, [detectionState, activeZone, recordTouch, remindersSuppressed]) useEffect(() => { - if (shouldRecommendMeditation && !showMeditationModal && !showAlert) { + if (shouldRecommendMeditation && !showMeditationModal && !showAlert && !remindersSuppressed) { setShowMeditationModal(true) setMeditationRecommended() } - }, [shouldRecommendMeditation, showMeditationModal, showAlert, setMeditationRecommended]) + }, [shouldRecommendMeditation, showMeditationModal, showAlert, setMeditationRecommended, remindersSuppressed]) function handleAlert() { + if (remindersSuppressed) return + setShowAlert(true) safeInvoke(IPC_CHANNELS.SHOW_FULLSCREEN_ALERT, { @@ -310,11 +416,38 @@ function App() { stopCamera() setIsRunning(false) } else { - await startCamera() - setIsRunning(true) + setIsStartingCamera(true) + try { + const started = await startCamera() + setIsRunning(started) + } finally { + setIsStartingCamera(false) + } } }, [isRunning, startCamera, stopCamera, stopDetection]) + const handleRetryCamera = useCallback(async (useDefaultCamera = false) => { + if (useDefaultCamera) { + setSelectedCameraId(null) + } + setIsStartingCamera(true) + try { + const started = await startCamera(useDefaultCamera ? null : undefined) + setIsRunning(started) + } finally { + setIsStartingCamera(false) + } + }, [setSelectedCameraId, startCamera]) + + const handleSetupComplete = useCallback(() => { + setSetupComplete(true) + try { + localStorage.setItem(STORAGE_KEYS.SETUP_COMPLETE, 'true') + } catch { + // Ignore storage errors + } + }, []) + // Tray "Start/Stop Detection" sends this; mirror the in-app toggle so the tray // control actually starts/stops detection (it previously had no renderer listener). useEffect(() => { @@ -348,14 +481,22 @@ function App() { const getStatusText = () => { if (!isModelLoaded) return { text: t.statusInit, color: '#ffa500' } - if (!isRunning) return { text: t.statusStandby, color: '#666' } - if (detectionState === 'ALERT') return { text: t.statusAlert, color: '#ff4444' } - if (detectionState === 'DETECTING') return { text: t.statusDetecting, color: '#ffa500' } - if (detectionState === 'COOLDOWN') return { text: t.statusCooldown, color: '#666' } - return { text: t.statusMonitoring, color: '#00ff88' } + if (!isRunning) return { text: t.statusStandby, color: '#94a3b8' } + if (alertTimeoutActive) { + return { + text: `${t.alertTimeoutButton} · ${formatAlertTimeoutRemaining(alertTimeoutRemainingMs)}`, + color: '#7dd3fc', + } + } + if (resumeAfterHandClear) return { text: t.alertTimeoutClearToResume, color: '#fbbf24' } + if (detectionState === 'ALERT') return { text: t.statusAlert, color: '#f59e0b' } + if (detectionState === 'DETECTING') return { text: t.statusDetecting, color: '#f59e0b' } + if (detectionState === 'COOLDOWN') return { text: t.statusCooldown, color: '#94a3b8' } + return { text: t.statusMonitoring, color: '#34d399' } } const status = getStatusText() + const recoveryIssues = [cameraError, modelError].filter((issue): issue is NonNullable => Boolean(issue)) return (
@@ -363,7 +504,11 @@ function App() {
- 🛡️ + {t.appTitle} {appVersion && v{appVersion}}
@@ -388,8 +533,13 @@ function App() { onExportData={exportData} onImportData={importData} cameraDevices={cameraDevices} + cameraStreamInfo={cameraStreamInfo} selectedCameraId={selectedCameraId} onCameraChange={setSelectedCameraId} + cameraQuality={appSettings.cameraQuality} + onCameraQualityChange={(cameraQuality) => updateAppSettings({ cameraQuality })} + detectionDebugHud={appSettings.detectionDebugHud} + onDetectionDebugHudChange={(detectionDebugHud) => updateAppSettings({ detectionDebugHud })} hidePreview={appSettings.hidePreview} onHidePreviewChange={(hide) => updateAppSettings({ hidePreview: hide })} closeAction={appSettings.closeAction} @@ -398,6 +548,8 @@ function App() { alertVolume={appSettings.alertVolume} onAlertSoundChange={(changes) => updateAppSettings(changes)} onPreviewSound={(id) => void alertSoundServiceRef.current.preview(id, appSettings.alertVolume)} + alertTimeoutDefaultMinutes={appSettings.alertTimeoutDefaultMinutes} + onAlertTimeoutDefaultChange={(minutes) => updateAppSettings({ alertTimeoutDefaultMinutes: minutes })} />
- -
- -
- {t.settingsDetectionZones} -
- {config.enabledZones.map((zone) => ( - - {t[`zone${zone.charAt(0).toUpperCase() + zone.slice(1)}` as keyof typeof t]} - - ))} -
-
- + updateAppSettings({ rightRailCollapsed: !appSettings.rightRailCollapsed })} + onOpenCalendar={() => setShowCalendar(true)} + onOpenMeditation={() => setShowMeditationModal(true)} + onSetupComplete={handleSetupComplete} + /> + void refreshDevices()} + onRetryModel={retryModelLoad} + /> + {/* Footer Controls */}
- {/* Error */} - {(cameraError || modelError) && ( -
- {cameraError &&
{t.cameraError}: {cameraError}
} - {modelError &&
{t.controlLoading} {modelError}
} -
- )} - {/* Modals */} {showMeditationModal && ( { const url = await this.resolveUrl(id) const audio = new Audio(url) + this.stop() + this.currentAudio = audio audio.volume = clampVolume(volume) + audio.addEventListener('ended', () => { + if (this.currentAudio === audio) { + this.currentAudio = null + } + }, { once: true }) try { await audio.play() } catch (err) { + if (this.currentAudio === audio) { + this.currentAudio = null + } logger.warn(`AlertSoundService: playback failed for "${id}", using sine fallback`, err) ;(this.opts.sineFallback ?? defaultSineFallback)() } @@ -66,4 +77,11 @@ export class AlertSoundService { async preview(id: string, volume: number): Promise { return this.play(id, volume) } + + stop(): void { + if (!this.currentAudio) return + this.currentAudio.pause() + this.currentAudio.currentTime = 0 + this.currentAudio = null + } } diff --git a/src/components/AboutModal.tsx b/src/components/AboutModal.tsx index 15d7b8a..15d8db6 100644 --- a/src/components/AboutModal.tsx +++ b/src/components/AboutModal.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react' import { useLanguage } from '../i18n/LanguageContext' +import { useFocusTrap } from '../hooks/useFocusTrap' interface AboutModalProps { onClose: () => void @@ -24,6 +25,7 @@ export function AboutModal({ onClose }: AboutModalProps) { const { t } = useLanguage() const currentYear = new Date().getFullYear() const [version, setVersion] = useState('...') + const aboutModalRef = useFocusTrap() // Update states const [checking, setChecking] = useState(false) @@ -38,6 +40,15 @@ export function AboutModal({ onClose }: AboutModalProps) { window.appInfo?.getVersion().then(setVersion).catch(() => setVersion('1.0.0')) }, []) + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + const openExternal = (url: string) => { window.ipcRenderer?.invoke('open-external', url) } @@ -211,11 +222,15 @@ export function AboutModal({ onClose }: AboutModalProps) { return (
-
e.stopPropagation()}> +
e.stopPropagation()}>
- +

{t.appTitle}

v{version}
@@ -228,7 +243,7 @@ export function AboutModal({ onClose }: AboutModalProps) {

- {t.aboutDescription || 'AI-powered face touch detection app to help overcome habits like trichotillomania and skin picking.'} + {t.aboutDescription || 'Local hand-near-face reminders for reducing face-touching habits.'}

@@ -257,8 +272,8 @@ export function AboutModal({ onClose }: AboutModalProps) { {t.aboutPrivacyText || 'All video processing occurs locally on your device. No images, videos, or personal data are collected, stored, or transmitted to external servers.'}

- 🔒 {t.aboutLocalOnly || 'Local Processing'} - 🛡️ {t.aboutNoData || 'No Data Collection'} + {t.aboutLocalOnly || 'Local processing'} + {t.aboutNoData || 'No cloud video upload'}

{t.aboutCompliance || 'Compliant with GDPR (EU), CCPA (California), PIPEDA (Canada), and international privacy regulations.'} @@ -301,19 +316,19 @@ export function AboutModal({ onClose }: AboutModalProps) { display: flex; align-items: center; justify-content: center; - z-index: 2000; + z-index: var(--z-modal-backdrop); backdrop-filter: blur(8px); } .about-modal { - background: linear-gradient(145deg, #1a1a2e 0%, #16213e 100%); - border: 1px solid rgba(0, 255, 255, 0.15); - border-radius: 16px; + background: #1b1c25; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; width: 400px; max-height: 85vh; overflow-y: auto; position: relative; - box-shadow: 0 25px 80px rgba(0, 0, 0, 0.6); + box-shadow: 0 8px 8px rgba(0, 0, 0, 0.35); } .close-btn { @@ -322,7 +337,7 @@ export function AboutModal({ onClose }: AboutModalProps) { right: 12px; background: none; border: none; - color: #666; + color: #94a3b8; font-size: 24px; cursor: pointer; width: 32px; @@ -355,22 +370,19 @@ export function AboutModal({ onClose }: AboutModalProps) { margin: 0; font-size: 22px; font-weight: 700; - background: linear-gradient(90deg, #00ffff, #00ff88); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; + color: #f8fafc; } .version { display: inline-block; margin-top: 10px; padding: 5px 14px; - background: rgba(0, 255, 255, 0.1); - border: 1px solid rgba(0, 255, 255, 0.25); + background: rgba(125, 211, 252, 0.1); + border: 1px solid rgba(125, 211, 252, 0.25); border-radius: 14px; font-size: 12px; font-weight: 500; - color: #00ffff; + color: #7dd3fc; } .about-content { @@ -394,8 +406,8 @@ export function AboutModal({ onClose }: AboutModalProps) { font-size: 11px; font-weight: 600; color: #888; - text-transform: uppercase; - letter-spacing: 1.2px; + text-transform: none; + letter-spacing: 0; } .about-section ul { @@ -412,15 +424,15 @@ export function AboutModal({ onClose }: AboutModalProps) { /* Update Section Styles */ .update-section { - background: rgba(0, 255, 255, 0.05); - border: 1px solid rgba(0, 255, 255, 0.15); + background: rgba(125, 211, 252, 0.06); + border: 1px solid rgba(125, 211, 252, 0.16); border-radius: 10px; padding: 14px; margin-bottom: 20px; } .update-section h4 { - color: #00ffff; + color: #7dd3fc; margin-bottom: 12px; } @@ -440,13 +452,13 @@ export function AboutModal({ onClose }: AboutModalProps) { .update-btn.check { width: 100%; - background: rgba(0, 255, 255, 0.1); - border: 1px solid rgba(0, 255, 255, 0.3); - color: #00ffff; + background: rgba(125, 211, 252, 0.1); + border: 1px solid rgba(125, 211, 252, 0.3); + color: #7dd3fc; } .update-btn.check:hover:not(:disabled) { - background: rgba(0, 255, 255, 0.2); + background: rgba(125, 211, 252, 0.16); } .update-btn.check:disabled { @@ -455,23 +467,23 @@ export function AboutModal({ onClose }: AboutModalProps) { } .update-btn.download { - background: rgba(0, 255, 136, 0.2); - border: 1px solid rgba(0, 255, 136, 0.4); - color: #00ff88; + background: rgba(52, 211, 153, 0.14); + border: 1px solid rgba(52, 211, 153, 0.35); + color: #86efac; } .update-btn.download:hover { - background: rgba(0, 255, 136, 0.3); + background: rgba(52, 211, 153, 0.2); } .update-btn.install { - background: rgba(0, 255, 136, 0.3); - border: 1px solid #00ff88; - color: #00ff88; + background: rgba(52, 211, 153, 0.18); + border: 1px solid #34d399; + color: #86efac; } .update-btn.install:hover { - background: rgba(0, 255, 136, 0.4); + background: rgba(52, 211, 153, 0.24); } .update-btn.later { @@ -495,7 +507,7 @@ export function AboutModal({ onClose }: AboutModalProps) { flex-direction: row; align-items: center; justify-content: center; - color: #00ff88; + color: #86efac; font-size: 13px; gap: 8px; } @@ -516,7 +528,7 @@ export function AboutModal({ onClose }: AboutModalProps) { .update-status.ready .update-text { text-align: center; - color: #00ff88; + color: #86efac; font-size: 13px; } @@ -551,8 +563,8 @@ export function AboutModal({ onClose }: AboutModalProps) { } .version-badge.new { - background: rgba(0, 255, 136, 0.2); - color: #00ff88; + background: rgba(52, 211, 153, 0.14); + color: #86efac; } .version-arrow { @@ -569,22 +581,21 @@ export function AboutModal({ onClose }: AboutModalProps) { .progress-fill { height: 100%; - background: linear-gradient(90deg, #00ffff, #00ff88); + background: #7dd3fc; border-radius: 3px; - transition: width 0.3s ease; } .update-status.downloading .update-text { text-align: center; - color: #00ffff; + color: #7dd3fc; font-size: 12px; } .spinner { width: 14px; height: 14px; - border: 2px solid rgba(0, 255, 255, 0.2); - border-top-color: #00ffff; + border: 2px solid rgba(125, 211, 252, 0.2); + border-top-color: #7dd3fc; border-radius: 50%; animation: spin 0.8s linear infinite; } @@ -610,14 +621,14 @@ export function AboutModal({ onClose }: AboutModalProps) { } .privacy-section { - background: rgba(0, 255, 136, 0.05); - border: 1px solid rgba(0, 255, 136, 0.15); + background: rgba(52, 211, 153, 0.06); + border: 1px solid rgba(52, 211, 153, 0.16); border-radius: 10px; padding: 14px; } .privacy-section h4 { - color: #00ff88; + color: #86efac; margin-bottom: 8px; } @@ -637,11 +648,11 @@ export function AboutModal({ onClose }: AboutModalProps) { .privacy-badge { padding: 4px 10px; - background: rgba(0, 255, 136, 0.1); + background: rgba(52, 211, 153, 0.1); border-radius: 12px; font-size: 10px; font-weight: 500; - color: #00ff88; + color: #86efac; } .compliance-text { diff --git a/src/components/ActivityRail.tsx b/src/components/ActivityRail.tsx new file mode 100644 index 0000000..6a91806 --- /dev/null +++ b/src/components/ActivityRail.tsx @@ -0,0 +1,115 @@ +import { DailyStatsCard } from './DailyStatsCard' +import { DailyStats, HabitSettings, UserProgress } from '../types/statistics' +import { DetectionZone } from '../detection/types' +import { useLanguage } from '../i18n/LanguageContext' + +interface ActivityRailProps { + collapsed: boolean + todayStats: DailyStats + progress: UserProgress + habitSettings: HabitSettings + setupComplete: boolean + enabledZones: DetectionZone[] + activeZone: DetectionZone | null + onToggleCollapsed: () => void + onOpenCalendar: () => void + onOpenMeditation: () => void + onSetupComplete: () => void +} + +function zoneLabelKey(zone: DetectionZone): string { + return `zone${zone.charAt(0).toUpperCase()}${zone.slice(1)}` +} + +export function ActivityRail({ + collapsed, + todayStats, + progress, + habitSettings, + setupComplete, + enabledZones, + activeZone, + onToggleCollapsed, + onOpenCalendar, + onOpenMeditation, + onSetupComplete, +}: ActivityRailProps) { + const { t } = useLanguage() + const toggleLabel = collapsed + ? t.activityPanelShow || 'Show activity panel' + : t.activityPanelHide || 'Hide activity panel' + + return ( +

+ ) +} diff --git a/src/components/AlertOverlay.tsx b/src/components/AlertOverlay.tsx index 09ad769..4810157 100644 --- a/src/components/AlertOverlay.tsx +++ b/src/components/AlertOverlay.tsx @@ -8,11 +8,9 @@ interface AlertOverlayProps { export function AlertOverlay({ onDismiss, canDismiss = true }: AlertOverlayProps) { const { t } = useLanguage() - const [isShaking, setIsShaking] = useState(false) const [showHint, setShowHint] = useState(false) const overlayRef = useRef(null) - // Auto-focus on mount for accessibility useEffect(() => { overlayRef.current?.focus() }, []) @@ -20,17 +18,16 @@ export function AlertOverlay({ onDismiss, canDismiss = true }: AlertOverlayProps const handleDismiss = useCallback(() => { if (canDismiss) { onDismiss?.() - } else { - setIsShaking(true) - setShowHint(true) - setTimeout(() => setIsShaking(false), 500) - setTimeout(() => setShowHint(false), 2000) + return } + + setShowHint(true) + window.setTimeout(() => setShowHint(false), 2000) }, [canDismiss, onDismiss]) useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' || e.key === ' ' || e.key === 'Enter') { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' || event.key === ' ' || event.key === 'Enter') { handleDismiss() } } @@ -47,302 +44,165 @@ export function AlertOverlay({ onDismiss, canDismiss = true }: AlertOverlayProps role="alertdialog" aria-modal="true" aria-labelledby="alert-title" + aria-describedby="alert-subtitle" tabIndex={-1} > - {/* Animated background lines */} -