diff --git a/README.md b/README.md index 60dd40e..fed65eb 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,13 @@ A browser-based Pomodoro timer and clock built with React, TypeScript, and Vite. - Custom focus, short-break, and long-break durations - Automatic focus-to-break sequencing, enabled by default with an opt-out setting -- Six built-in backgrounds: three gradients and three local illustrated scenes -- Adjustable background overlay and text color +- Ten built-in themes, including minimalist black/white and animated lava-style backgrounds +- Adjustable background overlay and a separate persistent clock-color override - Four clock and timer font styles - Live customization preview with one-click reset - Automatic settings persistence in IndexedDB +- Slowly pulsing indicator for the active focus session +- Browser fullscreen control and a distraction-free focus mode All preferences stay in the browser. No account or backend is required. diff --git a/src/App.css b/src/App.css index 32b7606..7d2921b 100644 --- a/src/App.css +++ b/src/App.css @@ -2,6 +2,7 @@ --accent: #8d6bff; --accent-strong: #7250ed; --glow: rgba(141, 107, 255, 0.38); + --primary-text: #fff; position: relative; display: grid; grid-template-rows: auto 1fr auto; @@ -40,12 +41,93 @@ --accent: #42d6ad; --accent-strong: #20ad87; --glow: rgba(66, 214, 173, 0.32); + --primary-text: #111; } .app.phase-longBreak { --accent: #ff9d66; --accent-strong: #ef754c; --glow: rgba(255, 157, 102, 0.32); + --primary-text: #111; +} + +.app.theme-light { + --accent: #6850c8; +} + +.app.theme-light.phase-shortBreak { + --accent: #176b58; +} + +.app.theme-light.phase-longBreak { + --accent: #a84524; +} + +.app.theme-black .orb, +.app.theme-white .orb { + display: none; +} + +.app.theme-light::after { + background: rgb(0 0 0 / var(--light-overlay-opacity, 0.07)); +} + +.theme-light .timer-card { + border-color: rgba(17, 17, 17, 0.12); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.42)); + box-shadow: 0 28px 90px rgba(35, 35, 30, 0.14), inset 0 1px rgba(255, 255, 255, 0.82); +} + +.theme-light .brand-mark, +.theme-light .customize-button, +.theme-light .fullscreen-button, +.theme-light .focus-mode-button, +.theme-light .focus-mode-exit, +.theme-light .clock { + border-color: rgba(17, 17, 17, 0.13); + background: rgba(255, 255, 255, 0.54); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.8); +} + +.theme-light .supporting-copy, +.theme-light .session-row, +.theme-light .status-line, +.theme-light.app > footer, +.theme-light .standard-interface > footer { + color: rgba(17, 17, 17, 0.68); +} + +.theme-light .topbar :is(button, a):focus-visible, +.theme-light .timer-card button:focus-visible, +.theme-light .focus-mode-button:focus-visible, +.theme-light .focus-mode-exit:focus-visible { + outline-color: #111; +} + +.theme-light .phase-switcher { + border-color: rgba(17, 17, 17, 0.1); + background: rgba(17, 17, 17, 0.055); +} + +.theme-light .phase-switcher button { + color: #5f5f5f; +} + +.theme-light .phase-switcher button:hover, +.theme-light .phase-switcher button.active { + color: #111; + background: rgba(17, 17, 17, 0.09); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.68); +} + +.theme-light .session-dots span { + background: rgba(17, 17, 17, 0.16); +} + +.theme-light .secondary-action { + border-color: rgba(17, 17, 17, 0.13); + color: #171717; + background: rgba(17, 17, 17, 0.06); } .orb { @@ -72,6 +154,26 @@ transition: background 400ms ease; } +.app.theme-motion-lava::before, +.app.theme-motion-liquid::before { + background-size: 180% 180%; + will-change: background-position, transform; +} + +.app.theme-motion-lava::before { + animation: lava-drift 18s ease-in-out infinite alternate; +} + +.app.theme-motion-liquid::before { + animation: liquid-float 22s ease-in-out infinite alternate; +} + +.app.theme-motion-lava .orb, +.app.theme-motion-liquid .orb { + opacity: 0.38; + animation: orb-breathe 14s ease-in-out infinite alternate; +} + .topbar { display: flex; align-items: center; @@ -85,6 +187,9 @@ } .customize-button, +.fullscreen-button, +.focus-mode-button, +.focus-mode-exit, .icon-button, .reset-settings { color: inherit; @@ -109,6 +214,102 @@ background: rgba(255, 255, 255, 0.1); } +.fullscreen-button { + display: grid; + width: 40px; + height: 40px; + padding: 0; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 50%; + background: rgba(13, 12, 30, 0.25); + backdrop-filter: blur(14px); + transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; +} + +.fullscreen-button:hover { + border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.1); + transform: scale(1.04); +} + +.standard-interface { + display: contents; +} + +.focus-mode-button { + position: fixed; + right: clamp(18px, 3vw, 34px); + bottom: clamp(18px, 3vw, 30px); + z-index: 3; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 999px; + background: rgba(13, 12, 30, 0.36); + box-shadow: 0 12px 32px rgba(6, 5, 22, 0.18); + backdrop-filter: blur(16px); + transition: border-color 180ms ease, background 180ms ease, transform 180ms ease; +} + +.focus-mode-button:hover { + border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.1); + transform: translateY(-2px); +} + +.focus-mode-view { + position: fixed; + inset: 0; + z-index: 2; + display: grid; + place-items: center; + cursor: default; +} + +.focus-mode-time { + color: var(--clock-color, currentColor); + font-family: var(--app-font, 'Space Grotesk', sans-serif); + font-size: clamp(3.8rem, 16vw, 11rem); + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: -0.075em; + line-height: 0.92; + text-shadow: 0 18px 60px rgba(7, 5, 25, 0.28); +} + +.focus-mode-exit { + position: fixed; + right: clamp(18px, 3vw, 34px); + bottom: clamp(18px, 3vw, 30px); + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 999px; + color: inherit; + background: rgba(13, 12, 30, 0.4); + opacity: 0; + pointer-events: none; + transform: translateY(8px); + backdrop-filter: blur(16px); + transition: opacity 420ms ease, transform 420ms ease, background 180ms ease; +} + +.focus-mode-exit.visible, +.focus-mode-exit:focus-visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.focus-mode-exit:hover { + background: rgba(255, 255, 255, 0.12); +} + .brand { display: inline-flex; align-items: center; @@ -136,7 +337,7 @@ padding: 9px 14px; border: 1px solid rgba(255, 255, 255, 0.13); border-radius: 999px; - color: inherit; + color: var(--clock-color, currentColor); background: rgba(13, 12, 30, 0.25); font-family: var(--app-font, 'Space Grotesk', sans-serif); font-variant-numeric: tabular-nums; @@ -235,9 +436,16 @@ h1 { box-shadow: 0 0 10px var(--glow); } +.session-dots span.current { + background: var(--accent); + box-shadow: 0 0 7px var(--glow); + animation: session-glow 3.6s ease-in-out infinite; +} + .countdown { display: block; margin: 6px 0 18px; + color: var(--clock-color, currentColor); font-family: var(--app-font, 'Space Grotesk', sans-serif); font-size: clamp(5rem, 15vw, 8.8rem); font-weight: 600; @@ -268,6 +476,7 @@ h1 { .primary-action { border: 0; + color: var(--primary-text); background: var(--accent-strong); box-shadow: 0 12px 32px var(--glow); } @@ -300,7 +509,8 @@ h1 { animation: pulse 1.6s ease-in-out infinite; } -.app > footer { +.app > footer, +.standard-interface > footer { display: flex; justify-content: center; gap: 10px; @@ -547,9 +757,53 @@ h1 { accent-color: #8d6bff; } -.color-control { - grid-template-columns: 1fr auto; +.clock-color-control { + display: grid; + gap: 10px; +} + +.clock-color-button, +.theme-color-button { + color: inherit; + font: inherit; + cursor: pointer; +} + +.clock-color-button { + display: flex; align-items: center; + justify-content: space-between; + width: 100%; + padding: 12px 13px; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 12px; + background: rgba(255, 255, 255, 0.055); +} + +.clock-color-swatch { + width: 24px; + height: 24px; + border: 2px solid rgba(255, 255, 255, 0.55); + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2); +} + +.clock-color-options { + display: grid; + grid-template-columns: 1fr auto; + align-items: end; + gap: 12px; + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.035); +} + +.clock-color-options label { + display: grid; + gap: 8px; + color: rgba(255, 255, 255, 0.58); + font-size: 0.72rem; } .color-input-wrap { @@ -573,6 +827,24 @@ h1 { font-size: 0.74rem; } +.theme-color-button { + padding: 9px 11px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 9px; + background: rgba(255, 255, 255, 0.06); + font-size: 0.72rem; +} + +.clock-color-actions { + display: grid; + gap: 8px; +} + +.theme-color-button:disabled { + opacity: 0.42; + cursor: default; +} + .settings-footer { border-top: 1px solid rgba(255, 255, 255, 0.09); } @@ -593,6 +865,28 @@ h1 { 50% { opacity: 0.35; } } +@keyframes session-glow { + 0%, 100% { opacity: 0.42; box-shadow: 0 0 5px transparent; } + 50% { opacity: 1; box-shadow: 0 0 14px var(--glow); } +} + +@keyframes lava-drift { + 0% { background-position: 0% 20%; transform: scale(1); } + 50% { background-position: 85% 70%; transform: scale(1.08); } + 100% { background-position: 25% 100%; transform: scale(1.03); } +} + +@keyframes liquid-float { + 0% { background-position: 10% 90%; transform: scale(1.02) rotate(0deg); } + 50% { background-position: 90% 15%; transform: scale(1.1) rotate(1deg); } + 100% { background-position: 35% 55%; transform: scale(1.04) rotate(-1deg); } +} + +@keyframes orb-breathe { + 0% { transform: translate3d(-4%, 2%, 0) scale(0.9); } + 100% { transform: translate3d(8%, -7%, 0) scale(1.18); } +} + @keyframes fade-in { from { opacity: 0; } } @@ -631,6 +925,15 @@ h1 { grid-template-columns: 1fr; } + .clock-color-options { + grid-template-columns: 1fr; + align-items: stretch; + } + + .clock-color-actions { + grid-template-columns: 1fr 1fr; + } + .timer-card { padding: 28px 18px; border-radius: 28px; @@ -655,8 +958,27 @@ h1 { flex: 1; } - .app > footer { + .app > footer, + .standard-interface > footer { flex-wrap: wrap; + font-size: 0.72rem; + gap: 0.35rem 0.65rem; + padding-bottom: 4.25rem; + } +} + +@media (max-width: 360px) { + .brand > span:last-child { + display: none; + } + + .brand, + .topbar-actions { + gap: 6px; + } + + .clock { + padding-inline: 10px; } } diff --git a/src/App.test.tsx b/src/App.test.tsx index be8b712..001b346 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -59,17 +59,130 @@ describe('Pomodoro app', () => { afterEach(() => { vi.useRealTimers() + Reflect.deleteProperty(document, 'fullscreenElement') + Reflect.deleteProperty(document, 'exitFullscreen') }) it('shows a live clock and the initial focus timer', () => { renderApp() expect(screen.getByLabelText('Current time')).toHaveTextContent('10:08') - expect(screen.getByLabelText('Time remaining')).toHaveTextContent('25:00') + const countdown = screen.getByRole('timer', { name: 'Time remaining' }) + expect(countdown).toHaveTextContent('25:00') + expect(countdown).toHaveAttribute('aria-live', 'off') + expect(countdown.closest('section')).not.toHaveAttribute('aria-live') + expect(screen.queryByRole('status', { name: 'Time remaining' })).not.toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Focus session ready when you are') expect(screen.getByText('Focus session')).toBeInTheDocument() expect(screen.getByText('0 / 4 sessions')).toBeInTheDocument() }) + it('enters and exits fullscreen from the top-right control', async () => { + await renderHydratedApp() + const app = screen.getByTestId('app') + let fullscreenElement: Element | null = null + const requestFullscreen = vi.fn(async () => { + fullscreenElement = app + document.dispatchEvent(new Event('fullscreenchange')) + }) + const exitFullscreen = vi.fn(async () => { + fullscreenElement = null + document.dispatchEvent(new Event('fullscreenchange')) + }) + Object.defineProperty(app, 'requestFullscreen', { configurable: true, value: requestFullscreen }) + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: () => fullscreenElement, + }) + Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen }) + + fireEvent.click(screen.getByRole('button', { name: 'Enter fullscreen' })) + await act(async () => Promise.resolve()) + expect(requestFullscreen).toHaveBeenCalledOnce() + expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Exit fullscreen' })) + await act(async () => Promise.resolve()) + expect(exitFullscreen).toHaveBeenCalledOnce() + expect(screen.getByRole('button', { name: 'Enter fullscreen' })).toBeInTheDocument() + }) + + it('keeps fullscreen state synchronized when exiting fullscreen is rejected', async () => { + await renderHydratedApp() + const app = screen.getByTestId('app') + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: () => app, + }) + Object.defineProperty(document, 'exitFullscreen', { + configurable: true, + value: vi.fn(async () => { + throw new Error('Fullscreen exit denied') + }), + }) + fireEvent(document, new Event('fullscreenchange')) + + fireEvent.click(screen.getByRole('button', { name: 'Exit fullscreen' })) + await act(async () => Promise.resolve()) + + expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument() + }) + + it('shows only a centered clock with seconds in idle focus mode', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-23T10:08:09')) + await renderHydratedApp() + + const focusModeTrigger = screen.getByRole('button', { name: 'Enter focus mode' }) + fireEvent.click(focusModeTrigger) + expect(screen.getByLabelText('Current time with seconds')).toHaveTextContent('10:08:09') + expect(screen.queryByRole('button', { name: 'Customize' })).not.toBeInTheDocument() + expect(screen.queryByLabelText('Time remaining')).not.toBeInTheDocument() + + const exitButton = screen.getByRole('button', { name: 'Exit focus mode' }) + expect(exitButton).toHaveFocus() + expect(exitButton.closest('section')).not.toHaveAttribute('aria-live') + expect(exitButton).toHaveClass('visible') + act(() => vi.advanceTimersByTime(2_100)) + expect(exitButton).not.toHaveClass('visible') + + const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + window.dispatchEvent(tabEvent) + expect(tabEvent.defaultPrevented).toBe(true) + expect(exitButton).toHaveFocus() + + fireEvent.pointerDown(screen.getByTestId('app')) + expect(exitButton).toHaveClass('visible') + fireEvent.click(exitButton) + expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Enter focus mode' })).toHaveFocus() + }) + + it('shows the enlarged countdown in focus mode once the timer has started', async () => { + await renderHydratedApp() + fireEvent.click(screen.getByRole('button', { name: 'Start' })) + const focusModeTrigger = screen.getByRole('button', { name: 'Enter focus mode' }) + fireEvent.click(focusModeTrigger) + + const focusCountdown = screen.getByRole('timer', { name: 'Focus time remaining' }) + expect(focusCountdown).toHaveTextContent('25:00') + expect(focusCountdown).toHaveAttribute('aria-live', 'off') + expect(screen.queryByRole('status', { name: 'Focus time remaining' })).not.toBeInTheDocument() + expect(screen.queryByLabelText('Current time with seconds')).not.toBeInTheDocument() + fireEvent.keyDown(window, { key: 'Escape' }) + expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Enter focus mode' })).toHaveFocus() + }) + + it('names the active break phase in the focus-mode countdown', async () => { + await renderHydratedApp() + fireEvent.click(screen.getByRole('button', { name: 'Short break' })) + fireEvent.click(screen.getByRole('button', { name: 'Start' })) + fireEvent.click(screen.getByRole('button', { name: 'Enter focus mode' })) + + expect(screen.getByLabelText('Short break time remaining')).toHaveTextContent('05:00') + }) + it('starts, pauses, resumes, and resets the countdown', async () => { const sound = await renderHydratedApp() @@ -93,6 +206,16 @@ describe('Pomodoro app', () => { expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument() }) + it('slowly pulses the indicator for the active focus session', async () => { + await renderHydratedApp() + fireEvent.click(screen.getByRole('button', { name: 'Start' })) + + const sessionDots = document.querySelectorAll('.session-dots span') + expect(sessionDots[0]).toHaveClass('current') + expect(sessionDots[1]).not.toHaveClass('current') + expect(appCss).toMatch(/\.session-dots span\.current[\s\S]*animation:\s*session-glow 3\.6s/) + }) + it('announces completion and automatically starts the short break', async () => { const sound = await renderHydratedApp() @@ -104,6 +227,7 @@ describe('Pomodoro app', () => { expect(screen.getByLabelText('Time remaining')).toHaveTextContent('05:00') expect(screen.getByText('1 / 4 sessions')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Short break timer is running') }) it('plays one completion sound when a delayed update spans multiple segments', async () => { @@ -185,20 +309,73 @@ describe('Pomodoro app', () => { expect(store.settings?.autoStartNext).toBe(false) }) - it('applies background, overlay, font, and text color customization', async () => { + it('applies background, overlay, and font customization', async () => { await renderHydratedApp() fireEvent.click(screen.getByRole('button', { name: 'Customize' })) fireEvent.click(screen.getByRole('radio', { name: 'Forest stillness' })) fireEvent.change(screen.getByLabelText('Clock font'), { target: { value: 'serif' } }) fireEvent.change(screen.getByLabelText('Overlay darkness'), { target: { value: '0.4' } }) - fireEvent.change(screen.getByLabelText('Text color'), { target: { value: '#fff2d6' } }) + const app = screen.getByTestId('app') expect(app.style.getPropertyValue('--background-image')).toContain('forest.svg') expect(app.style.getPropertyValue('--overlay-opacity')).toBe('0.4') expect(app.style.getPropertyValue('--app-font')).toContain('Georgia') - expect(app).toHaveStyle({ color: '#fff2d6' }) + }) + + it('applies minimalist black and white theme colors to the clocks', async () => { + await renderHydratedApp() + fireEvent.click(screen.getByRole('button', { name: 'Customize' })) + + fireEvent.click(screen.getByRole('radio', { name: 'Minimal white' })) + const app = screen.getByTestId('app') + expect(app).toHaveClass('theme-light') + expect(app.style.getPropertyValue('--clock-color')).toBe('#111111') + + fireEvent.click(screen.getByRole('radio', { name: 'Minimal black' })) + expect(app).toHaveClass('theme-dark') + expect(app.style.getPropertyValue('--clock-color')).toBe('#ffffff') + }) + + it('offers smoothly animated lava-style backgrounds', async () => { + await renderHydratedApp() + fireEvent.click(screen.getByRole('button', { name: 'Customize' })) + + fireEvent.click(screen.getByRole('radio', { name: 'Lava bloom' })) + const app = screen.getByTestId('app') + expect(app).toHaveClass('theme-motion-lava') + + fireEvent.click(screen.getByRole('radio', { name: 'Liquid aurora' })) + expect(app).toHaveClass('theme-motion-liquid') + expect(appCss).toMatch(/@keyframes lava-drift/) + expect(appCss).toMatch(/@keyframes liquid-float/) + }) + + it('keeps a custom clock color behind a separate control when themes change', async () => { + const store = new MemorySettingsStore() + await renderHydratedApp(store) + fireEvent.click(screen.getByRole('button', { name: 'Customize' })) + + expect(screen.queryByLabelText('Custom clock color')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Clock color' })) + fireEvent.change(screen.getByLabelText('Custom clock color'), { + target: { value: '#24cc88' }, + }) + + const app = screen.getByTestId('app') + expect(app.style.getPropertyValue('--clock-color')).toBe('#24cc88') + fireEvent.click(screen.getByRole('radio', { name: 'Minimal white' })) + expect(app.style.getPropertyValue('--clock-color')).toBe('#24cc88') + expect(store.settings?.useThemeClockColor).toBe(false) + + fireEvent.click(screen.getByRole('button', { name: 'Use theme color' })) + expect(app.style.getPropertyValue('--clock-color')).toBe('#111111') + expect(store.settings?.textColor).toBe('#24cc88') + + fireEvent.click(screen.getByRole('button', { name: 'Use custom color' })) + expect(app.style.getPropertyValue('--clock-color')).toBe('#24cc88') + expect(store.settings?.useThemeClockColor).toBe(false) }) it('restores and persists customization through the settings store', async () => { @@ -335,4 +512,29 @@ describe('Pomodoro app', () => { it('shows a keyboard focus indicator around visually hidden background radios', () => { expect(appCss).toMatch(/\.background-option:focus-within\s*\{/) }) + + it('uses a dark keyboard focus indicator on the white theme', () => { + expect(appCss).toMatch(/\.theme-light \.topbar :is\(button, a\):focus-visible[\s\S]*outline-color:\s*#111/) + }) + + it('keeps secondary text readable on the white theme', () => { + expect(appCss).toMatch(/\.theme-light \.supporting-copy[\s\S]*color:\s*rgba\(17, 17, 17, 0\.68\)/) + }) + + it('keeps top-bar controls inside a 320px viewport', () => { + expect(appCss).toMatch(/@media \(max-width:\s*360px\)[\s\S]*\.brand > span:last-child[\s\S]*display:\s*none/) + }) + + it('uses high-contrast primary-action text during break phases', () => { + expect(appCss).toMatch(/\.app\.phase-shortBreak[\s\S]*--primary-text:\s*#111/) + expect(appCss).toMatch(/\.app\.phase-longBreak[\s\S]*--primary-text:\s*#111/) + expect(appCss).toMatch(/\.primary-action[\s\S]*color:\s*var\(--primary-text\)/) + }) + + it('uses high-contrast accents and inactive controls in the light theme', () => { + expect(appCss).toMatch(/\.app\.theme-light\s*{[\s\S]*--accent:\s*#6850c8/) + expect(appCss).toMatch(/\.app\.theme-light\.phase-shortBreak[\s\S]*--accent:\s*#176b58/) + expect(appCss).toMatch(/\.app\.theme-light\.phase-longBreak[\s\S]*--accent:\s*#a84524/) + expect(appCss).toMatch(/\.theme-light \.phase-switcher button\s*{[\s\S]*color:\s*#5f5f5f/) + }) }) diff --git a/src/App.tsx b/src/App.tsx index 6ae0ec9..ac7c35b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,12 @@ const PHASE_LABELS: Record = { longBreak: 'Long break', } +const PHASE_TIME_LABELS: Record = { + focus: 'Focus', + shortBreak: 'Short break', + longBreak: 'Long break', +} + const PHASE_BUTTONS: Array<{ phase: TimerPhase; label: string }> = [ { phase: 'focus', label: 'Focus' }, { phase: 'shortBreak', label: 'Short break' }, @@ -51,10 +57,11 @@ function formatDuration(milliseconds: number): string { return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` } -function formatClock(date: Date): string { +function formatClock(date: Date, includeSeconds = false): string { return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', + second: includeSeconds ? '2-digit' : undefined, hour12: false, }).format(date) } @@ -68,8 +75,16 @@ function App({ const [settings, setSettings] = useState(DEFAULT_SETTINGS) const [persistenceState, setPersistenceState] = useState<'loading' | 'ready' | 'error'>('loading') const [settingsOpen, setSettingsOpen] = useState(false) + const [isFullscreen, setIsFullscreen] = useState(false) + const [focusMode, setFocusMode] = useState(false) + const [focusExitVisible, setFocusExitVisible] = useState(false) const completionPending = useRef(false) + const appRef = useRef(null) const customizeButtonRef = useRef(null) + const focusModeButtonRef = useRef(null) + const focusExitButtonRef = useRef(null) + const focusModeWasActive = useRef(false) + const focusExitTimer = useRef(null) const saveInFlight = useRef(false) const pendingSettingsSave = useRef(null) @@ -172,6 +187,14 @@ function App({ } }, [updateTimer]) + useEffect(() => { + const syncFullscreenState = () => { + setIsFullscreen(document.fullscreenElement === appRef.current) + } + document.addEventListener('fullscreenchange', syncFullscreenState) + return () => document.removeEventListener('fullscreenchange', syncFullscreenState) + }, []) + const handlePrimaryAction = () => { if (timer.status === 'running') { setTimer((current) => pauseTimer(current, Date.now())) @@ -187,6 +210,68 @@ function App({ customizeButtonRef.current?.focus() } + const toggleFullscreen = async () => { + try { + if (document.fullscreenElement) { + await document.exitFullscreen() + } else { + await appRef.current?.requestFullscreen() + } + } catch { + setIsFullscreen(document.fullscreenElement === appRef.current) + } + } + + const exitFocusMode = useCallback(() => { + if (focusExitTimer.current !== null) { + window.clearTimeout(focusExitTimer.current) + focusExitTimer.current = null + } + setFocusExitVisible(false) + setFocusMode(false) + }, []) + + const revealFocusExit = useCallback(() => { + setFocusExitVisible(true) + if (focusExitTimer.current !== null) { + window.clearTimeout(focusExitTimer.current) + } + focusExitTimer.current = window.setTimeout(() => { + setFocusExitVisible(false) + focusExitTimer.current = null + }, 2_000) + }, []) + + useEffect(() => { + if (!focusMode) return + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') { + exitFocusMode() + } else if (event.key === 'Tab') { + event.preventDefault() + revealFocusExit() + focusExitButtonRef.current?.focus() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => { + window.removeEventListener('keydown', handleKeyDown) + if (focusExitTimer.current !== null) { + window.clearTimeout(focusExitTimer.current) + focusExitTimer.current = null + } + } + }, [exitFocusMode, focusMode, revealFocusExit]) + + useEffect(() => { + if (focusMode) { + focusExitButtonRef.current?.focus() + } else if (focusModeWasActive.current) { + focusModeButtonRef.current?.focus() + } + focusModeWasActive.current = focusMode + }, [focusMode]) + const settingsHydrated = persistenceState !== 'loading' const cycleProgress = timer.completedFocusSessions % 4 @@ -202,15 +287,30 @@ function App({ const appStyle = { '--app-font': selectedFont.css, '--background-image': selectedBackground.css, + '--clock-color': settings.useThemeClockColor + ? selectedBackground.clockColor + : settings.textColor, + '--light-overlay-opacity': String(settings.overlayOpacity * 0.35), '--overlay-opacity': String(settings.overlayOpacity), - color: settings.textColor, + color: selectedBackground.foregroundColor, } as CSSProperties return ( -
+
+ )} + + {focusMode && ( +
+ {timer.status === 'idle' ? ( + + ) : ( + + {formatDuration(timer.remainingMs)} + + )} + +
)}
) diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index ee07f4e..aae940c 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, type CSSProperties, type KeyboardEvent } from 'react' +import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react' import { BACKGROUND_OPTIONS, FONT_OPTIONS } from './options' import type { AppSettings } from './settings' @@ -21,6 +21,11 @@ const FOCUSABLE_SELECTOR = [ function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset }: SettingsPanelProps) { const panelRef = useRef(null) const closeButtonRef = useRef(null) + const [clockColorOpen, setClockColorOpen] = useState(false) + const themeClockColor = BACKGROUND_OPTIONS.find( + (background) => background.id === settings.backgroundId, + )?.clockColor ?? settings.textColor + const displayedClockColor = settings.useThemeClockColor ? themeClockColor : settings.textColor useEffect(() => { closeButtonRef.current?.focus() @@ -173,18 +178,59 @@ function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset /> -