[verified] feat: add immersive themes and focus controls
This commit is contained in:
@@ -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