[verified] feat: add immersive themes and focus controls

This commit is contained in:
2026-07-23 02:00:41 +03:00
parent 6baa13dc90
commit 0ee986e686
8 changed files with 856 additions and 33 deletions
+4 -2
View File
@@ -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 - Custom focus, short-break, and long-break durations
- Automatic focus-to-break sequencing, enabled by default with an opt-out setting - Automatic focus-to-break sequencing, enabled by default with an opt-out setting
- Six built-in backgrounds: three gradients and three local illustrated scenes - Ten built-in themes, including minimalist black/white and animated lava-style backgrounds
- Adjustable background overlay and text color - Adjustable background overlay and a separate persistent clock-color override
- Four clock and timer font styles - Four clock and timer font styles
- Live customization preview with one-click reset - Live customization preview with one-click reset
- Automatic settings persistence in IndexedDB - 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. All preferences stay in the browser. No account or backend is required.
+327 -5
View File
@@ -2,6 +2,7 @@
--accent: #8d6bff; --accent: #8d6bff;
--accent-strong: #7250ed; --accent-strong: #7250ed;
--glow: rgba(141, 107, 255, 0.38); --glow: rgba(141, 107, 255, 0.38);
--primary-text: #fff;
position: relative; position: relative;
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
@@ -40,12 +41,93 @@
--accent: #42d6ad; --accent: #42d6ad;
--accent-strong: #20ad87; --accent-strong: #20ad87;
--glow: rgba(66, 214, 173, 0.32); --glow: rgba(66, 214, 173, 0.32);
--primary-text: #111;
} }
.app.phase-longBreak { .app.phase-longBreak {
--accent: #ff9d66; --accent: #ff9d66;
--accent-strong: #ef754c; --accent-strong: #ef754c;
--glow: rgba(255, 157, 102, 0.32); --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 { .orb {
@@ -72,6 +154,26 @@
transition: background 400ms ease; 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 { .topbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -85,6 +187,9 @@
} }
.customize-button, .customize-button,
.fullscreen-button,
.focus-mode-button,
.focus-mode-exit,
.icon-button, .icon-button,
.reset-settings { .reset-settings {
color: inherit; color: inherit;
@@ -109,6 +214,102 @@
background: rgba(255, 255, 255, 0.1); 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 { .brand {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -136,7 +337,7 @@
padding: 9px 14px; padding: 9px 14px;
border: 1px solid rgba(255, 255, 255, 0.13); border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 999px; border-radius: 999px;
color: inherit; color: var(--clock-color, currentColor);
background: rgba(13, 12, 30, 0.25); background: rgba(13, 12, 30, 0.25);
font-family: var(--app-font, 'Space Grotesk', sans-serif); font-family: var(--app-font, 'Space Grotesk', sans-serif);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -235,9 +436,16 @@ h1 {
box-shadow: 0 0 10px var(--glow); 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 { .countdown {
display: block; display: block;
margin: 6px 0 18px; margin: 6px 0 18px;
color: var(--clock-color, currentColor);
font-family: var(--app-font, 'Space Grotesk', sans-serif); font-family: var(--app-font, 'Space Grotesk', sans-serif);
font-size: clamp(5rem, 15vw, 8.8rem); font-size: clamp(5rem, 15vw, 8.8rem);
font-weight: 600; font-weight: 600;
@@ -268,6 +476,7 @@ h1 {
.primary-action { .primary-action {
border: 0; border: 0;
color: var(--primary-text);
background: var(--accent-strong); background: var(--accent-strong);
box-shadow: 0 12px 32px var(--glow); box-shadow: 0 12px 32px var(--glow);
} }
@@ -300,7 +509,8 @@ h1 {
animation: pulse 1.6s ease-in-out infinite; animation: pulse 1.6s ease-in-out infinite;
} }
.app > footer { .app > footer,
.standard-interface > footer {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
@@ -547,9 +757,53 @@ h1 {
accent-color: #8d6bff; accent-color: #8d6bff;
} }
.color-control { .clock-color-control {
grid-template-columns: 1fr auto; display: grid;
gap: 10px;
}
.clock-color-button,
.theme-color-button {
color: inherit;
font: inherit;
cursor: pointer;
}
.clock-color-button {
display: flex;
align-items: center; 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 { .color-input-wrap {
@@ -573,6 +827,24 @@ h1 {
font-size: 0.74rem; 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 { .settings-footer {
border-top: 1px solid rgba(255, 255, 255, 0.09); border-top: 1px solid rgba(255, 255, 255, 0.09);
} }
@@ -593,6 +865,28 @@ h1 {
50% { opacity: 0.35; } 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 { @keyframes fade-in {
from { opacity: 0; } from { opacity: 0; }
} }
@@ -631,6 +925,15 @@ h1 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.clock-color-options {
grid-template-columns: 1fr;
align-items: stretch;
}
.clock-color-actions {
grid-template-columns: 1fr 1fr;
}
.timer-card { .timer-card {
padding: 28px 18px; padding: 28px 18px;
border-radius: 28px; border-radius: 28px;
@@ -655,8 +958,27 @@ h1 {
flex: 1; flex: 1;
} }
.app > footer { .app > footer,
.standard-interface > footer {
flex-wrap: wrap; 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;
} }
} }
+206 -4
View File
@@ -59,17 +59,130 @@ describe('Pomodoro app', () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
Reflect.deleteProperty(document, 'fullscreenElement')
Reflect.deleteProperty(document, 'exitFullscreen')
}) })
it('shows a live clock and the initial focus timer', () => { it('shows a live clock and the initial focus timer', () => {
renderApp() renderApp()
expect(screen.getByLabelText('Current time')).toHaveTextContent('10:08') 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('Focus session')).toBeInTheDocument()
expect(screen.getByText('0 / 4 sessions')).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 () => { it('starts, pauses, resumes, and resets the countdown', async () => {
const sound = await renderHydratedApp() const sound = await renderHydratedApp()
@@ -93,6 +206,16 @@ describe('Pomodoro app', () => {
expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument() 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 () => { it('announces completion and automatically starts the short break', async () => {
const sound = await renderHydratedApp() const sound = await renderHydratedApp()
@@ -104,6 +227,7 @@ describe('Pomodoro app', () => {
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('05:00') expect(screen.getByLabelText('Time remaining')).toHaveTextContent('05:00')
expect(screen.getByText('1 / 4 sessions')).toBeInTheDocument() expect(screen.getByText('1 / 4 sessions')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Pause' })).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 () => { 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) 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() await renderHydratedApp()
fireEvent.click(screen.getByRole('button', { name: 'Customize' })) fireEvent.click(screen.getByRole('button', { name: 'Customize' }))
fireEvent.click(screen.getByRole('radio', { name: 'Forest stillness' })) fireEvent.click(screen.getByRole('radio', { name: 'Forest stillness' }))
fireEvent.change(screen.getByLabelText('Clock font'), { target: { value: 'serif' } }) fireEvent.change(screen.getByLabelText('Clock font'), { target: { value: 'serif' } })
fireEvent.change(screen.getByLabelText('Overlay darkness'), { target: { value: '0.4' } }) fireEvent.change(screen.getByLabelText('Overlay darkness'), { target: { value: '0.4' } })
fireEvent.change(screen.getByLabelText('Text color'), { target: { value: '#fff2d6' } })
const app = screen.getByTestId('app') const app = screen.getByTestId('app')
expect(app.style.getPropertyValue('--background-image')).toContain('forest.svg') expect(app.style.getPropertyValue('--background-image')).toContain('forest.svg')
expect(app.style.getPropertyValue('--overlay-opacity')).toBe('0.4') expect(app.style.getPropertyValue('--overlay-opacity')).toBe('0.4')
expect(app.style.getPropertyValue('--app-font')).toContain('Georgia') 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 () => { 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', () => { it('shows a keyboard focus indicator around visually hidden background radios', () => {
expect(appCss).toMatch(/\.background-option:focus-within\s*\{/) 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/)
})
}) })
+173 -10
View File
@@ -33,6 +33,12 @@ const PHASE_LABELS: Record<TimerPhase, string> = {
longBreak: 'Long break', longBreak: 'Long break',
} }
const PHASE_TIME_LABELS: Record<TimerPhase, string> = {
focus: 'Focus',
shortBreak: 'Short break',
longBreak: 'Long break',
}
const PHASE_BUTTONS: Array<{ phase: TimerPhase; label: string }> = [ const PHASE_BUTTONS: Array<{ phase: TimerPhase; label: string }> = [
{ phase: 'focus', label: 'Focus' }, { phase: 'focus', label: 'Focus' },
{ phase: 'shortBreak', label: 'Short break' }, { 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')}` 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, { return new Intl.DateTimeFormat(undefined, {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: includeSeconds ? '2-digit' : undefined,
hour12: false, hour12: false,
}).format(date) }).format(date)
} }
@@ -68,8 +75,16 @@ function App({
const [settings, setSettings] = useState(DEFAULT_SETTINGS) const [settings, setSettings] = useState(DEFAULT_SETTINGS)
const [persistenceState, setPersistenceState] = useState<'loading' | 'ready' | 'error'>('loading') const [persistenceState, setPersistenceState] = useState<'loading' | 'ready' | 'error'>('loading')
const [settingsOpen, setSettingsOpen] = useState(false) 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 completionPending = useRef(false)
const appRef = useRef<HTMLElement>(null)
const customizeButtonRef = useRef<HTMLButtonElement>(null) const customizeButtonRef = useRef<HTMLButtonElement>(null)
const focusModeButtonRef = useRef<HTMLButtonElement>(null)
const focusExitButtonRef = useRef<HTMLButtonElement>(null)
const focusModeWasActive = useRef(false)
const focusExitTimer = useRef<number | null>(null)
const saveInFlight = useRef(false) const saveInFlight = useRef(false)
const pendingSettingsSave = useRef<AppSettings | null>(null) const pendingSettingsSave = useRef<AppSettings | null>(null)
@@ -172,6 +187,14 @@ function App({
} }
}, [updateTimer]) }, [updateTimer])
useEffect(() => {
const syncFullscreenState = () => {
setIsFullscreen(document.fullscreenElement === appRef.current)
}
document.addEventListener('fullscreenchange', syncFullscreenState)
return () => document.removeEventListener('fullscreenchange', syncFullscreenState)
}, [])
const handlePrimaryAction = () => { const handlePrimaryAction = () => {
if (timer.status === 'running') { if (timer.status === 'running') {
setTimer((current) => pauseTimer(current, Date.now())) setTimer((current) => pauseTimer(current, Date.now()))
@@ -187,6 +210,68 @@ function App({
customizeButtonRef.current?.focus() 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 settingsHydrated = persistenceState !== 'loading'
const cycleProgress = timer.completedFocusSessions % 4 const cycleProgress = timer.completedFocusSessions % 4
@@ -202,15 +287,30 @@ function App({
const appStyle = { const appStyle = {
'--app-font': selectedFont.css, '--app-font': selectedFont.css,
'--background-image': selectedBackground.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), '--overlay-opacity': String(settings.overlayOpacity),
color: settings.textColor, color: selectedBackground.foregroundColor,
} as CSSProperties } as CSSProperties
return ( return (
<main className={`app phase-${timer.phase}`} data-testid="app" style={appStyle}> <main
className={`app phase-${timer.phase} theme-${selectedBackground.tone} theme-${selectedBackground.id}${
selectedBackground.motion ? ` theme-motion-${selectedBackground.motion}` : ''
}`}
data-testid="app"
onPointerDown={focusMode ? revealFocusExit : undefined}
onPointerMove={focusMode ? revealFocusExit : undefined}
ref={appRef}
style={appStyle}
>
<div className="orb orb-one" aria-hidden="true" /> <div className="orb orb-one" aria-hidden="true" />
<div className="orb orb-two" aria-hidden="true" /> <div className="orb orb-two" aria-hidden="true" />
{!focusMode && (
<div className="standard-interface">
<header className="topbar"> <header className="topbar">
<a className="brand" href="/" aria-label="Pomodoro home"> <a className="brand" href="/" aria-label="Pomodoro home">
<span className="brand-mark" aria-hidden="true">P</span> <span className="brand-mark" aria-hidden="true">P</span>
@@ -230,10 +330,18 @@ function App({
<time className="clock" aria-label="Current time"> <time className="clock" aria-label="Current time">
{formatClock(currentTime)} {formatClock(currentTime)}
</time> </time>
<button
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
className="fullscreen-button"
onClick={() => void toggleFullscreen()}
type="button"
>
<span aria-hidden="true"></span>
</button>
</div> </div>
</header> </header>
<section className="timer-card" aria-live="polite"> <section className="timer-card">
<p className="eyebrow">Stay present</p> <p className="eyebrow">Stay present</p>
<h1>{PHASE_LABELS[timer.phase]}</h1> <h1>{PHASE_LABELS[timer.phase]}</h1>
<p className="supporting-copy"> <p className="supporting-copy">
@@ -259,13 +367,23 @@ function App({
<div className="session-row"> <div className="session-row">
<div className="session-dots" aria-hidden="true"> <div className="session-dots" aria-hidden="true">
{[1, 2, 3, 4].map((session) => ( {[1, 2, 3, 4].map((session) => (
<span className={session <= visibleProgress ? 'complete' : ''} key={session} /> <span
className={[
session <= visibleProgress ? 'complete' : '',
timer.phase === 'focus'
&& timer.status === 'running'
&& session === visibleProgress + 1
? 'current'
: '',
].filter(Boolean).join(' ')}
key={session}
/>
))} ))}
</div> </div>
<span>{visibleProgress} / 4 sessions</span> <span>{visibleProgress} / 4 sessions</span>
</div> </div>
<output className="countdown" aria-label="Time remaining"> <output aria-label="Time remaining" aria-live="off" className="countdown" role="timer">
{formatDuration(timer.remainingMs)} {formatDuration(timer.remainingMs)}
</output> </output>
@@ -292,13 +410,13 @@ function App({
</button> </button>
</div> </div>
<p className="status-line"> <p className="status-line" role="status">
<span className={`status-dot ${timer.status}`} aria-hidden="true" /> <span className={`status-dot ${timer.status}`} aria-hidden="true" />
{timer.status === 'running' {timer.status === 'running'
? 'Timer is running' ? `${PHASE_LABELS[timer.phase]} timer is running`
: timer.status === 'paused' : timer.status === 'paused'
? 'Paused — your place is saved' ? `${PHASE_LABELS[timer.phase]} paused — your place is saved`
: 'Ready when you are'} : `${PHASE_LABELS[timer.phase]} ready when you are`}
</p> </p>
</section> </section>
@@ -318,6 +436,51 @@ function App({
persistenceError={persistenceState === 'error'} persistenceError={persistenceState === 'error'}
settings={settings} settings={settings}
/> />
)}
<button
aria-label="Enter focus mode"
className="focus-mode-button"
onClick={() => {
setFocusMode(true)
revealFocusExit()
}}
ref={focusModeButtonRef}
type="button"
>
<span aria-hidden="true"></span>
<span>Focus</span>
</button>
</div>
)}
{focusMode && (
<section className="focus-mode-view">
{timer.status === 'idle' ? (
<time aria-label="Current time with seconds" className="focus-mode-time">
{formatClock(currentTime, true)}
</time>
) : (
<output
aria-live="off"
aria-label={`${PHASE_TIME_LABELS[timer.phase]} time remaining`}
className="focus-mode-time"
role="timer"
>
{formatDuration(timer.remainingMs)}
</output>
)}
<button
aria-label="Exit focus mode"
className={`focus-mode-exit${focusExitVisible ? ' visible' : ''}`}
onClick={exitFocusMode}
onFocus={revealFocusExit}
ref={focusExitButtonRef}
type="button"
>
<span aria-hidden="true">×</span>
Exit focus
</button>
</section>
)} )}
</main> </main>
) )
+58 -12
View File
@@ -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 { BACKGROUND_OPTIONS, FONT_OPTIONS } from './options'
import type { AppSettings } from './settings' import type { AppSettings } from './settings'
@@ -21,6 +21,11 @@ const FOCUSABLE_SELECTOR = [
function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset }: SettingsPanelProps) { function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset }: SettingsPanelProps) {
const panelRef = useRef<HTMLElement>(null) const panelRef = useRef<HTMLElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null) const closeButtonRef = useRef<HTMLButtonElement>(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(() => { useEffect(() => {
closeButtonRef.current?.focus() closeButtonRef.current?.focus()
@@ -173,18 +178,59 @@ function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset
/> />
</label> </label>
<label className="color-control"> <div className="clock-color-control">
<span>Text color</span> <button
<span className="color-input-wrap"> aria-expanded={clockColorOpen}
<input className="clock-color-button"
aria-label="Text color" onClick={() => setClockColorOpen((open) => !open)}
onChange={(event) => update('textColor', event.target.value)} type="button"
type="color" >
value={settings.textColor} <span>Clock color</span>
<span
aria-hidden="true"
className="clock-color-swatch"
style={{ background: displayedClockColor }}
/> />
<code>{settings.textColor.toUpperCase()}</code> </button>
</span> {clockColorOpen && (
</label> <div className="clock-color-options">
<label>
<span>Custom clock color</span>
<span className="color-input-wrap">
<input
aria-label="Custom clock color"
onChange={(event) => onChange({
...settings,
textColor: event.target.value,
useThemeClockColor: false,
})}
type="color"
value={settings.textColor}
/>
<code>{settings.textColor.toUpperCase()}</code>
</span>
</label>
<div className="clock-color-actions">
<button
className="theme-color-button"
disabled={!settings.useThemeClockColor}
onClick={() => update('useThemeClockColor', false)}
type="button"
>
Use custom color
</button>
<button
className="theme-color-button"
disabled={settings.useThemeClockColor}
onClick={() => update('useThemeClockColor', true)}
type="button"
>
Use theme color
</button>
</div>
</div>
)}
</div>
</section> </section>
</div> </div>
+60
View File
@@ -5,42 +5,102 @@ export const BACKGROUND_OPTIONS: Array<{
label: string label: string
css: string css: string
preview: string preview: string
clockColor: string
foregroundColor: string
tone: 'dark' | 'light'
motion?: 'lava' | 'liquid'
}> = [ }> = [
{ {
id: 'violet', id: 'violet',
label: 'Violet haze', label: 'Violet haze',
css: 'radial-gradient(circle at 72% 18%, #8a416f 0%, transparent 38%), linear-gradient(135deg, #15142a 0%, #31265b 52%, #17162e 100%)', css: 'radial-gradient(circle at 72% 18%, #8a416f 0%, transparent 38%), linear-gradient(135deg, #15142a 0%, #31265b 52%, #17162e 100%)',
preview: 'linear-gradient(135deg, #19172f, #684167)', preview: 'linear-gradient(135deg, #19172f, #684167)',
clockColor: '#f8f7ff',
foregroundColor: '#f8f7ff',
tone: 'dark',
}, },
{ {
id: 'sunset', id: 'sunset',
label: 'Soft sunset', label: 'Soft sunset',
css: 'radial-gradient(circle at 72% 20%, #f09a78 0%, transparent 32%), linear-gradient(145deg, #242140 0%, #8f4b62 56%, #382743 100%)', css: 'radial-gradient(circle at 72% 20%, #f09a78 0%, transparent 32%), linear-gradient(145deg, #242140 0%, #8f4b62 56%, #382743 100%)',
preview: 'linear-gradient(135deg, #33294e, #ef8a70)', preview: 'linear-gradient(135deg, #33294e, #ef8a70)',
clockColor: '#fff7ef',
foregroundColor: '#fff7ef',
tone: 'dark',
}, },
{ {
id: 'aurora', id: 'aurora',
label: 'Quiet aurora', label: 'Quiet aurora',
css: 'radial-gradient(circle at 28% 72%, #2e9f83 0%, transparent 38%), radial-gradient(circle at 78% 18%, #5755a8 0%, transparent 42%), #101e2d', css: 'radial-gradient(circle at 28% 72%, #2e9f83 0%, transparent 38%), radial-gradient(circle at 78% 18%, #5755a8 0%, transparent 42%), #101e2d',
preview: 'linear-gradient(135deg, #183244, #2e9f83)', preview: 'linear-gradient(135deg, #183244, #2e9f83)',
clockColor: '#effffb',
foregroundColor: '#effffb',
tone: 'dark',
}, },
{ {
id: 'dusk', id: 'dusk',
label: 'Mountain dusk', label: 'Mountain dusk',
css: 'url("/backgrounds/dusk.svg")', css: 'url("/backgrounds/dusk.svg")',
preview: 'linear-gradient(135deg, #37416f, #d68a79)', preview: 'linear-gradient(135deg, #37416f, #d68a79)',
clockColor: '#fff5ed',
foregroundColor: '#fff5ed',
tone: 'dark',
}, },
{ {
id: 'forest', id: 'forest',
label: 'Forest stillness', label: 'Forest stillness',
css: 'url("/backgrounds/forest.svg")', css: 'url("/backgrounds/forest.svg")',
preview: 'linear-gradient(135deg, #153c3a, #6f9c79)', preview: 'linear-gradient(135deg, #153c3a, #6f9c79)',
clockColor: '#f1fff1',
foregroundColor: '#f1fff1',
tone: 'dark',
}, },
{ {
id: 'ocean', id: 'ocean',
label: 'Open ocean', label: 'Open ocean',
css: 'url("/backgrounds/ocean.svg")', css: 'url("/backgrounds/ocean.svg")',
preview: 'linear-gradient(135deg, #173f5f, #53b6c3)', preview: 'linear-gradient(135deg, #173f5f, #53b6c3)',
clockColor: '#effcff',
foregroundColor: '#effcff',
tone: 'dark',
},
{
id: 'black',
label: 'Minimal black',
css: 'linear-gradient(135deg, #050505, #111111)',
preview: 'linear-gradient(135deg, #020202, #171717)',
clockColor: '#ffffff',
foregroundColor: '#f7f7f5',
tone: 'dark',
},
{
id: 'white',
label: 'Minimal white',
css: 'linear-gradient(135deg, #ffffff, #efefeb)',
preview: 'linear-gradient(135deg, #ffffff, #e5e5df)',
clockColor: '#111111',
foregroundColor: '#171717',
tone: 'light',
},
{
id: 'lava',
label: 'Lava bloom',
css: 'radial-gradient(circle at 18% 28%, #ff4d6d 0%, transparent 34%), radial-gradient(circle at 78% 70%, #ff9f1c 0%, transparent 38%), radial-gradient(circle at 62% 18%, #8338ec 0%, transparent 32%), #210b2c',
preview: 'linear-gradient(135deg, #ff4d6d, #ff9f1c 54%, #6f2dbd)',
clockColor: '#fff7ee',
foregroundColor: '#fff7ee',
tone: 'dark',
motion: 'lava',
},
{
id: 'liquid',
label: 'Liquid aurora',
css: 'radial-gradient(circle at 20% 72%, #00d4a6 0%, transparent 38%), radial-gradient(circle at 82% 24%, #6c63ff 0%, transparent 36%), radial-gradient(circle at 46% 42%, #0088ff 0%, transparent 42%), #07182b',
preview: 'linear-gradient(135deg, #00c9a7, #0088ff 54%, #6c63ff)',
clockColor: '#efffff',
foregroundColor: '#efffff',
tone: 'dark',
motion: 'liquid',
}, },
] ]
+14
View File
@@ -9,6 +9,20 @@ import {
import { IndexedDbSettingsStore } from './settings-store' import { IndexedDbSettingsStore } from './settings-store'
describe('application settings', () => { describe('application settings', () => {
it('uses the selected theme clock color by default', () => {
expect(DEFAULT_SETTINGS.useThemeClockColor).toBe(true)
})
it('preserves a legacy custom clock color as an override', () => {
const legacySettings: Partial<AppSettings> = {
...DEFAULT_SETTINGS,
textColor: '#23ab67',
}
delete legacySettings.useThemeClockColor
expect(normalizeSettings(legacySettings).useThemeClockColor).toBe(false)
})
it('automatically starts the next timer by default', () => { it('automatically starts the next timer by default', () => {
expect(DEFAULT_SETTINGS.autoStartNext).toBe(true) expect(DEFAULT_SETTINGS.autoStartNext).toBe(true)
}) })
+14
View File
@@ -10,6 +10,10 @@ export const BACKGROUND_IDS = [
'dusk', 'dusk',
'forest', 'forest',
'ocean', 'ocean',
'black',
'white',
'lava',
'liquid',
] as const ] as const
export type BackgroundId = (typeof BACKGROUND_IDS)[number] export type BackgroundId = (typeof BACKGROUND_IDS)[number]
@@ -22,6 +26,7 @@ export interface AppSettings {
backgroundId: BackgroundId backgroundId: BackgroundId
overlayOpacity: number overlayOpacity: number
textColor: string textColor: string
useThemeClockColor: boolean
} }
export const DEFAULT_SETTINGS: AppSettings = { export const DEFAULT_SETTINGS: AppSettings = {
@@ -33,6 +38,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
backgroundId: 'violet', backgroundId: 'violet',
overlayOpacity: 0.2, overlayOpacity: 0.2,
textColor: '#f8f7ff', textColor: '#f8f7ff',
useThemeClockColor: true,
} }
function clampInteger(value: unknown, minimum: number, maximum: number, fallback: number): number { function clampInteger(value: unknown, minimum: number, maximum: number, fallback: number): number {
@@ -71,6 +77,14 @@ export function normalizeSettings(value: unknown): AppSettings {
typeof candidate.textColor === 'string' && /^#[0-9a-f]{6}$/i.test(candidate.textColor) typeof candidate.textColor === 'string' && /^#[0-9a-f]{6}$/i.test(candidate.textColor)
? candidate.textColor ? candidate.textColor
: DEFAULT_SETTINGS.textColor, : DEFAULT_SETTINGS.textColor,
useThemeClockColor:
typeof candidate.useThemeClockColor === 'boolean'
? candidate.useThemeClockColor
: !(
typeof candidate.textColor === 'string'
&& /^#[0-9a-f]{6}$/i.test(candidate.textColor)
&& candidate.textColor.toLowerCase() !== DEFAULT_SETTINGS.textColor
),
} }
} }