import { useSyncExternalStore } from 'react' /** The scheme a device shows: the system's light or dark, or one fixed in Preferences. */ export type ThemeChoice = 'system' | 'light' | 'dark' /** Each choice with its name, in the order the pickers show them. */ export const themeChoices: { value: ThemeChoice; label: string }[] = [ { value: 'system', label: 'System' }, { value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }, ] export const themeKey = 'scn-chat:theme' /** * `--color-chrome` in each scheme, as the browser's `theme-color` takes only a plain color. An * installed app's title bar shows it. The server's manifest repeats the light one, and the theme * test fails when any of them drift from the token. */ export const chromeColors = { light: '#f7f7f7', dark: '#0e0e0e' } /** The shell's `theme-color` tags, one per scheme, each naming its own in `data-scheme`. */ const themeColorTags = 'meta[name="theme-color"]' /** * Runs in the page's head before the first paint, so a fixed choice never flashes the other * scheme, in the page or in an installed app's title bar. It reads the store the same way * storedTheme does. */ export const themeScript = `try{var t=localStorage.getItem('${themeKey}');if(t==='light'||t==='dark'){document.documentElement.dataset.theme=t;var c=${JSON.stringify(chromeColors)};document.querySelectorAll('${themeColorTags}').forEach(function(m){m.content=c[t]})}}catch(e){}` export function themeOf(value: string | null): ThemeChoice { return value === 'light' || value === 'dark' ? value : 'system' } /** The choice stored on this device, or system when there is none or the store can't be read. */ export function storedTheme(): ThemeChoice { try { return themeOf(localStorage.getItem(themeKey)) } catch { return 'system' } } // What this page shows, which stands in for the store when it can't be kept. let shown: ThemeChoice | null = null const listeners = new Set<() => void>() /** Stores the choice on this device and shows it at once. */ export function chooseTheme(choice: ThemeChoice) { try { if (choice === 'system') localStorage.removeItem(themeKey) else localStorage.setItem(themeKey, choice) } catch { // The choice still applies to this page when it can't be kept. } showTheme(choice) } /** The choice this device shows, kept in step as it changes here or in another tab. */ export function useThemeChoice(): ThemeChoice { return useSyncExternalStore( (listener) => { listeners.add(listener) return () => listeners.delete(listener) }, () => shown ?? storedTheme(), // The server can't know the device's choice, so the page starts from the system's. () => 'system', ) } /** Sets the scheme the page's colors follow, through the data-theme attribute on the root. */ export function showTheme(choice: ThemeChoice) { shown = choice for (const listener of listeners) listener() switchInstantly(() => { const root = document.documentElement if (choice === 'system') delete root.dataset.theme else root.dataset.theme = choice for (const meta of document.querySelectorAll(themeColorTags)) { const scheme = choice === 'system' ? meta.dataset.scheme : choice if (scheme === 'light' || scheme === 'dark') meta.content = chromeColors[scheme] } }) } /** * Changes the scheme with transitions held off until the new colors have painted. The colors follow * the scheme through light-dark(), so otherwise every color transition on the page would run * together and the switch would smear. */ export function switchInstantly(change: () => void) { const style = document.createElement('style') style.textContent = '*,*::before,*::after{transition:none !important}' document.head.append(style) change() // Reading layout makes the browser apply the new colors while transitions are off. void document.body.offsetHeight requestAnimationFrame(() => requestAnimationFrame(() => style.remove())) }