Files
vngeoguessr/src/lib/theme.js
T
tiennm99 30cfe63e5f refactor(client): read browser preferences through one store hook
Every localStorage-backed preference (name, theme, sound, last region,
daily record, hint seen) now goes through lib/storage.js, which never
throws and notifies watchers in this tab and others, and components
read it with useStoredValue over useSyncExternalStore instead of
seeding state in an effect. That fixes the breakpoint pair of theme
toggles disagreeing after a click, takes the bare localStorage calls
out of the username module and the first-round hint (a blocked-storage
browser threw inside the home page's landing effect), and lets the
daily replay derive from the stored record rather than from six
setState calls in the mount effect.

Callback props reach the imperative Leaflet and PhotoSphere handlers
through useEffectEvent. The leaderboard modal fetches from the action
that changed the board, the search box resets on the prop's edge, the
count-up derives its idle values, and the username form mounts with
the dialog so it needs no re-seeding. The react-hooks rules that
flagged twenty-one warnings are now errors, and nothing violates them.
2026-09-21 16:21:14 +07:00

89 lines
3.0 KiB
JavaScript

// Theme selection: an explicit light/dark choice, or following the OS.
//
// The resolved theme is expressed as a `dark` class on <html>, which is what
// the `dark` custom variant in globals.css keys off, so Tailwind's dark:
// utilities and the token palette switch together. layout.js runs an inlined
// copy of resolveDark before paint; keep the two in step.
import { readItem, writeItem, watchItem } from './storage.js';
export const THEME_STORAGE_KEY = 'vngeoguessr_theme';
export const DEFAULT_THEME = 'system';
// Icons live with the toggle component (Lucide, following currentColor);
// this module stays free of UI imports.
export const THEMES = [
{ value: 'light', label: 'Light theme' },
{ value: 'dark', label: 'Dark theme' },
{ value: 'system', label: 'Match system theme' },
];
const DARK_QUERY = '(prefers-color-scheme: dark)';
/**
* Read the stored choice, falling back to following the system.
* @returns {string} 'light', 'dark' or 'system'.
*/
export function getStoredTheme() {
const stored = readItem(THEME_STORAGE_KEY);
return THEMES.some((theme) => theme.value === stored) ? stored : DEFAULT_THEME;
}
/**
* Persist a theme choice.
* @param {string} choice 'light', 'dark' or 'system'.
* @returns {void}
*/
export function setStoredTheme(choice) {
writeItem(THEME_STORAGE_KEY, choice);
}
/**
* Be told when the stored choice changes. Every mounted toggle subscribes, so
* a breakpoint pair of them never disagrees after a click.
* @param {Function} onChange
* @returns {Function} Unsubscribe.
*/
export function watchStoredTheme(onChange) {
return watchItem(THEME_STORAGE_KEY, onChange);
}
/**
* Resolve a choice to whether dark should be shown right now.
* @param {string} choice 'light', 'dark' or 'system'.
* @returns {boolean} True when the dark palette applies.
*/
export function resolveDark(choice) {
if (choice === 'dark') return true;
if (choice === 'light') return false;
return window.matchMedia(DARK_QUERY).matches;
}
/**
* Apply a theme choice to the document.
* @param {string} choice 'light', 'dark' or 'system'.
* @returns {void}
*/
export function applyTheme(choice) {
const dark = resolveDark(choice);
const root = document.documentElement;
root.classList.toggle('dark', dark);
// Tells the browser which palette to use for scrollbars, form controls and
// the caret, which CSS variables alone cannot reach.
root.style.colorScheme = dark ? 'dark' : 'light';
}
/**
* Watch the OS preference. Fires on every OS flip regardless of the stored
* choice -- gating on 'system' is the caller's job, and a caller that instead
* re-applies getStoredTheme() is safe because that is a no-op for an explicit
* light or dark.
* @param {Function} onChange Called when the OS preference flips.
* @returns {Function} Unsubscribe.
*/
export function watchSystemTheme(onChange) {
const query = window.matchMedia(DARK_QUERY);
query.addEventListener('change', onChange);
return () => query.removeEventListener('change', onChange);
}