[verified] feat: add immersive themes and focus controls
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+327
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+206
-4
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
+173
-10
@@ -33,6 +33,12 @@ const PHASE_LABELS: Record<TimerPhase, string> = {
|
||||
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 }> = [
|
||||
{ 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<HTMLElement>(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 pendingSettingsSave = useRef<AppSettings | null>(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 (
|
||||
<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-two" aria-hidden="true" />
|
||||
|
||||
{!focusMode && (
|
||||
<div className="standard-interface">
|
||||
<header className="topbar">
|
||||
<a className="brand" href="/" aria-label="Pomodoro home">
|
||||
<span className="brand-mark" aria-hidden="true">P</span>
|
||||
@@ -230,10 +330,18 @@ function App({
|
||||
<time className="clock" aria-label="Current time">
|
||||
{formatClock(currentTime)}
|
||||
</time>
|
||||
<button
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
className="fullscreen-button"
|
||||
onClick={() => void toggleFullscreen()}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">⛶</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="timer-card" aria-live="polite">
|
||||
<section className="timer-card">
|
||||
<p className="eyebrow">Stay present</p>
|
||||
<h1>{PHASE_LABELS[timer.phase]}</h1>
|
||||
<p className="supporting-copy">
|
||||
@@ -259,13 +367,23 @@ function App({
|
||||
<div className="session-row">
|
||||
<div className="session-dots" aria-hidden="true">
|
||||
{[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>
|
||||
<span>{visibleProgress} / 4 sessions</span>
|
||||
</div>
|
||||
|
||||
<output className="countdown" aria-label="Time remaining">
|
||||
<output aria-label="Time remaining" aria-live="off" className="countdown" role="timer">
|
||||
{formatDuration(timer.remainingMs)}
|
||||
</output>
|
||||
|
||||
@@ -292,13 +410,13 @@ function App({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="status-line">
|
||||
<p className="status-line" role="status">
|
||||
<span className={`status-dot ${timer.status}`} aria-hidden="true" />
|
||||
{timer.status === 'running'
|
||||
? 'Timer is running'
|
||||
? `${PHASE_LABELS[timer.phase]} timer is running`
|
||||
: timer.status === 'paused'
|
||||
? 'Paused — your place is saved'
|
||||
: 'Ready when you are'}
|
||||
? `${PHASE_LABELS[timer.phase]} paused — your place is saved`
|
||||
: `${PHASE_LABELS[timer.phase]} ready when you are`}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -318,6 +436,51 @@ function App({
|
||||
persistenceError={persistenceState === 'error'}
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -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<HTMLElement>(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(() => {
|
||||
closeButtonRef.current?.focus()
|
||||
@@ -173,18 +178,59 @@ function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="color-control">
|
||||
<span>Text color</span>
|
||||
<span className="color-input-wrap">
|
||||
<input
|
||||
aria-label="Text color"
|
||||
onChange={(event) => update('textColor', event.target.value)}
|
||||
type="color"
|
||||
value={settings.textColor}
|
||||
<div className="clock-color-control">
|
||||
<button
|
||||
aria-expanded={clockColorOpen}
|
||||
className="clock-color-button"
|
||||
onClick={() => setClockColorOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span>Clock color</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="clock-color-swatch"
|
||||
style={{ background: displayedClockColor }}
|
||||
/>
|
||||
<code>{settings.textColor.toUpperCase()}</code>
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
{clockColorOpen && (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,42 +5,102 @@ export const BACKGROUND_OPTIONS: Array<{
|
||||
label: string
|
||||
css: string
|
||||
preview: string
|
||||
clockColor: string
|
||||
foregroundColor: string
|
||||
tone: 'dark' | 'light'
|
||||
motion?: 'lava' | 'liquid'
|
||||
}> = [
|
||||
{
|
||||
id: 'violet',
|
||||
label: 'Violet haze',
|
||||
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)',
|
||||
clockColor: '#f8f7ff',
|
||||
foregroundColor: '#f8f7ff',
|
||||
tone: 'dark',
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
label: 'Soft sunset',
|
||||
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)',
|
||||
clockColor: '#fff7ef',
|
||||
foregroundColor: '#fff7ef',
|
||||
tone: 'dark',
|
||||
},
|
||||
{
|
||||
id: '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',
|
||||
preview: 'linear-gradient(135deg, #183244, #2e9f83)',
|
||||
clockColor: '#effffb',
|
||||
foregroundColor: '#effffb',
|
||||
tone: 'dark',
|
||||
},
|
||||
{
|
||||
id: 'dusk',
|
||||
label: 'Mountain dusk',
|
||||
css: 'url("/backgrounds/dusk.svg")',
|
||||
preview: 'linear-gradient(135deg, #37416f, #d68a79)',
|
||||
clockColor: '#fff5ed',
|
||||
foregroundColor: '#fff5ed',
|
||||
tone: 'dark',
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
label: 'Forest stillness',
|
||||
css: 'url("/backgrounds/forest.svg")',
|
||||
preview: 'linear-gradient(135deg, #153c3a, #6f9c79)',
|
||||
clockColor: '#f1fff1',
|
||||
foregroundColor: '#f1fff1',
|
||||
tone: 'dark',
|
||||
},
|
||||
{
|
||||
id: 'ocean',
|
||||
label: 'Open ocean',
|
||||
css: 'url("/backgrounds/ocean.svg")',
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -9,6 +9,20 @@ import {
|
||||
import { IndexedDbSettingsStore } from './settings-store'
|
||||
|
||||
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', () => {
|
||||
expect(DEFAULT_SETTINGS.autoStartNext).toBe(true)
|
||||
})
|
||||
|
||||
@@ -10,6 +10,10 @@ export const BACKGROUND_IDS = [
|
||||
'dusk',
|
||||
'forest',
|
||||
'ocean',
|
||||
'black',
|
||||
'white',
|
||||
'lava',
|
||||
'liquid',
|
||||
] as const
|
||||
export type BackgroundId = (typeof BACKGROUND_IDS)[number]
|
||||
|
||||
@@ -22,6 +26,7 @@ export interface AppSettings {
|
||||
backgroundId: BackgroundId
|
||||
overlayOpacity: number
|
||||
textColor: string
|
||||
useThemeClockColor: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -33,6 +38,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
backgroundId: 'violet',
|
||||
overlayOpacity: 0.2,
|
||||
textColor: '#f8f7ff',
|
||||
useThemeClockColor: true,
|
||||
}
|
||||
|
||||
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)
|
||||
? candidate.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
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user