98 lines
3.5 KiB
JavaScript
98 lines
3.5 KiB
JavaScript
// Theme registry. The actual palettes live as CSS variables in src/index.css
|
|
// (one `[data-theme='<palette>-<light|dark>']` block each); this module owns
|
|
// the list of palettes and modes, how a stored choice resolves to one of those
|
|
// blocks, and the browser chrome `theme-color`.
|
|
//
|
|
// A stored theme id is `<palette>-<mode>`, e.g. `confetti-system`. The
|
|
// `system` mode follows the OS light/dark preference live.
|
|
//
|
|
// Persistence: the chosen id is stored per user in the backend (settings key
|
|
// `theme`) so it syncs across devices. localStorage mirrors it only as a
|
|
// fast-path cache so the inline script in index.html can apply it before the
|
|
// settings request resolves (avoiding a flash of the default theme).
|
|
|
|
export const PALETTES = [
|
|
{
|
|
id: 'confetti',
|
|
label: 'Confetti',
|
|
description: 'Party colors on calm surfaces.',
|
|
swatch: ['#6a48ee', '#ee4e8b', '#ff8a3d', '#10a593', '#ffc23c', '#3b82f6'],
|
|
themeColor: { light: '#faf8ff', dark: '#110e1d' },
|
|
},
|
|
{
|
|
id: 'meadow',
|
|
label: 'Meadow',
|
|
description: 'Soft pastels with a sage accent.',
|
|
swatch: ['#7fa889', '#e79bb0', '#f1b784', '#a99bdc', '#efd474', '#93badf'],
|
|
themeColor: { light: '#f7f6ef', dark: '#141612' },
|
|
},
|
|
]
|
|
|
|
export const MODES = [
|
|
{ value: 'system', label: 'Auto' },
|
|
{ value: 'light', label: 'Light' },
|
|
{ value: 'dark', label: 'Dark' },
|
|
]
|
|
|
|
export const DEFAULT_THEME = 'confetti-system'
|
|
|
|
export const THEME_STORAGE_KEY = 'mykindlebox-theme'
|
|
|
|
// Themes from before the Confetti redesign. Midnight was the only dark one;
|
|
// everything else maps to the default (which follows the OS).
|
|
const LEGACY_THEMES = { midnight: 'confetti-dark' }
|
|
|
|
const PALETTE_IDS = new Set(PALETTES.map((p) => p.id))
|
|
const MODE_IDS = new Set(MODES.map((m) => m.value))
|
|
|
|
export function parseTheme(id) {
|
|
const [palette, mode] = String(LEGACY_THEMES[id] ?? id ?? '').split('-')
|
|
if (PALETTE_IDS.has(palette) && MODE_IDS.has(mode)) return { palette, mode }
|
|
return parseTheme(DEFAULT_THEME)
|
|
}
|
|
|
|
export function composeTheme(palette, mode) {
|
|
return `${palette}-${mode}`
|
|
}
|
|
|
|
// Falls back to the default for unknown/missing ids so a stray DB value or an
|
|
// old cached id can never leave the app unstyled.
|
|
export function normalizeTheme(id) {
|
|
const { palette, mode } = parseTheme(id)
|
|
return composeTheme(palette, mode)
|
|
}
|
|
|
|
const darkQuery = typeof window !== 'undefined' && window.matchMedia
|
|
? window.matchMedia('(prefers-color-scheme: dark)')
|
|
: null
|
|
|
|
let current = DEFAULT_THEME
|
|
|
|
function paint() {
|
|
const { palette, mode } = parseTheme(current)
|
|
const resolved = mode === 'system' ? (darkQuery?.matches ? 'dark' : 'light') : mode
|
|
document.documentElement.dataset.theme = composeTheme(palette, resolved)
|
|
const meta = document.querySelector('meta[name="theme-color"]')
|
|
const entry = PALETTES.find((p) => p.id === palette)
|
|
if (meta && entry) meta.setAttribute('content', entry.themeColor[resolved])
|
|
}
|
|
|
|
// An OS light/dark flip repaints only while the choice is `system`.
|
|
darkQuery?.addEventListener?.('change', () => {
|
|
if (parseTheme(current).mode === 'system') paint()
|
|
})
|
|
|
|
// Applies a theme choice to the document (swapping the CSS variables and the
|
|
// browser-chrome color) and mirrors it to localStorage for next load's
|
|
// flash-free start.
|
|
export function applyTheme(id) {
|
|
current = normalizeTheme(id)
|
|
if (typeof document !== 'undefined') paint()
|
|
try {
|
|
localStorage.setItem(THEME_STORAGE_KEY, current)
|
|
} catch {
|
|
// localStorage can throw in private mode / when disabled — non-fatal,
|
|
// the backend setting remains the source of truth.
|
|
}
|
|
}
|