feat: add theme palettes (#1326)

Add Neutral, Indigo, Forest, and Sand palettes independently of Light, Dark, and System. Persist and hydrate the selection, add the accessible settings picker, and include the validated review fixes for keyboard navigation and Swedish copy.
This commit is contained in:
Mattsson
2026-08-01 16:02:12 +02:00
committed by GitHub
parent f24e1076cf
commit d684e3c440
10 changed files with 422 additions and 8 deletions
+99
View File
@@ -0,0 +1,99 @@
'use client'
import {
createContext,
useCallback,
useContext,
useMemo,
useSyncExternalStore,
type ReactNode,
} from 'react'
import {
DEFAULT_PALETTE,
PALETTE_STORAGE_KEY,
PALETTE_VALUES,
isPalette,
type Palette,
} from '@/lib/theme/palettes'
const PALETTE_CHANGE_EVENT = 'accounted:palette-change'
interface PaletteContextValue {
palette: Palette
setPalette: (palette: Palette) => void
}
const PaletteContext = createContext<PaletteContextValue | null>(null)
const paletteInitScript = `
(function () {
var palette = ${JSON.stringify(DEFAULT_PALETTE)};
try {
var stored = window.localStorage.getItem(${JSON.stringify(PALETTE_STORAGE_KEY)});
if (${JSON.stringify(PALETTE_VALUES)}.indexOf(stored) !== -1) palette = stored;
} catch (_) {}
document.documentElement.dataset.palette = palette;
})();
`
function applyPalette(palette: Palette) {
document.documentElement.dataset.palette = palette
}
function getPaletteSnapshot(): Palette {
const palette = document.documentElement.dataset.palette
return isPalette(palette) ? palette : DEFAULT_PALETTE
}
function subscribeToPalette(onStoreChange: () => void) {
function handlePaletteChange() {
onStoreChange()
}
function handleStorage(event: StorageEvent) {
if (event.key !== PALETTE_STORAGE_KEY && event.key !== null) return
const palette = isPalette(event.newValue) ? event.newValue : DEFAULT_PALETTE
applyPalette(palette)
onStoreChange()
}
window.addEventListener(PALETTE_CHANGE_EVENT, handlePaletteChange)
window.addEventListener('storage', handleStorage)
return () => {
window.removeEventListener(PALETTE_CHANGE_EVENT, handlePaletteChange)
window.removeEventListener('storage', handleStorage)
}
}
export function PaletteProvider({ children }: { children: ReactNode }) {
const palette = useSyncExternalStore(
subscribeToPalette,
getPaletteSnapshot,
() => DEFAULT_PALETTE,
)
const setPalette = useCallback((nextPalette: Palette) => {
applyPalette(nextPalette)
try {
window.localStorage.setItem(PALETTE_STORAGE_KEY, nextPalette)
} catch {
// The active tab still keeps the selected palette when storage is blocked.
}
window.dispatchEvent(new Event(PALETTE_CHANGE_EVENT))
}, [])
const value = useMemo(() => ({ palette, setPalette }), [palette, setPalette])
return (
<>
<script suppressHydrationWarning dangerouslySetInnerHTML={{ __html: paletteInitScript }} />
<PaletteContext.Provider value={value}>{children}</PaletteContext.Provider>
</>
)
}
export function usePalette() {
const context = useContext(PaletteContext)
if (!context) throw new Error('usePalette must be used within PaletteProvider')
return context
}