MyKindlebox/mykindlebox/client/src/lib/theme.js

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.
}
}