[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
+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 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>
+60
View File
@@ -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',
},
]
+14
View File
@@ -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)
})
+14
View File
@@ -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
),
}
}