/** * The theme, as one piece of state every toggle on the page shares. * * has already decided the theme before this runs; the store starts from * what it wrote on (data-theme-choice, data-theme, data-theme-key). `choice` is what * the visitor picked — light, dark or system — and `resolved` is what is showing. * * `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes * through the same write as `set()` and `toggle()`: the attributes, then localStorage. * * M3's contrast level is the same pair: `contrast` is the choice (standard, medium, high or * system) and `resolvedContrast` the level on screen, which is `standard` wherever * carries no data-contrast — the stylesheet's plain blocks are that level. `setContrast()` * writes both, through the same attribute the head script wrote before the first paint. * * `scheme` is the colour profile on screen (, which the server chose), and * `previewScheme(name)` shows another one on this page without storing anything — the * application saves a choice itself, and the next full load draws what it saved. */ document.addEventListener('alpine:init', () => { const root = document.documentElement const media = window.matchMedia('(prefers-color-scheme: dark)') const contrastMedia = window.matchMedia('(prefers-contrast: more)') const choices = ['light', 'dark', 'system'] const levels = ['standard', 'medium', 'high', 'system'] const resolve = (choice) => (choice === 'system' ? (media.matches ? 'dark' : 'light') : choice) const resolveContrast = (choice) => (choice === 'system' ? (contrastMedia.matches ? 'high' : 'standard') : choice) window.Alpine.store('theme', { choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system', resolved: root.dataset.theme === 'dark' ? 'dark' : 'light', contrast: levels.includes(root.dataset.contrastChoice) ? root.dataset.contrastChoice : 'system', resolvedContrast: root.dataset.contrast === 'medium' || root.dataset.contrast === 'high' ? root.dataset.contrast : 'standard', scheme: root.dataset.scheme || null, get value() { return this.choice }, set value(choice) { this.set(choice) }, set(choice) { if (!choices.includes(choice)) { return } this.choice = choice this.resolved = resolve(choice) root.dataset.themeChoice = choice root.dataset.theme = this.resolved try { localStorage.setItem(root.dataset.themeKey || 'material-theme', choice) } catch { // Blocked storage: the page still switches, it just will not remember. } }, toggle() { this.set(this.resolved === 'dark' ? 'light' : 'dark') }, setContrast(choice) { if (!levels.includes(choice)) { return } this.contrast = choice this.resolvedContrast = resolveContrast(choice) root.dataset.contrastChoice = choice if (this.resolvedContrast === 'standard') { delete root.dataset.contrast } else { root.dataset.contrast = this.resolvedContrast } try { localStorage.setItem(root.dataset.contrastKey || 'material-contrast', choice) } catch { // Blocked storage: the page still switches, it just will not remember. } }, previewScheme(name) { if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) { return } this.scheme = name root.dataset.scheme = name }, }) // The head script repaints on an OS change while the choice is `system`; this keeps the // store's `resolved` — which a toggle's icon reads — in step with it. media.addEventListener('change', () => { const theme = window.Alpine.store('theme') if (theme.choice === 'system') { theme.resolved = resolve('system') } }) contrastMedia.addEventListener('change', () => { const theme = window.Alpine.store('theme') if (theme.contrast === 'system') { theme.resolvedContrast = resolveContrast('system') } }) })