apsis-book/frontend/src/lib/theme.ts

80 lines
2.4 KiB
TypeScript

/*
* The colour theme.
*
* The palette lives in the stylesheet as two sets of the same variables; the
* choice is made by setting `data-theme` on the document element. Storing it
* the same way as the language means the two are remembered together and a
* reader who chooses once does not choose again.
*
* The choice is not a preference only: a dark page in a bright room is hard
* to read, and so is a bright one at night. The reader decides.
*/
import { writable } from 'svelte/store';
export type Theme = 'dark' | 'light';
const STORAGE_KEY = 'apsis.theme';
function isTheme(value: string | null): value is Theme {
return value === 'dark' || value === 'light';
}
/**
* The stored choice, the operating system's preference, or the default.
*
* The system preference is only consulted when nothing has been stored, so
* that a reader who has chosen once keeps that choice on a device whose
* system setting later changes.
*/
function initialTheme(): Theme {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (isTheme(stored)) return stored;
} catch {
// A blocked or absent store is not an error; the system preference or
// the default applies.
}
try {
if (window.matchMedia('(prefers-color-scheme: light)').matches) return 'light';
} catch {
// A browser without the query is rare; the default applies.
}
return 'dark';
}
/** The current theme. */
export const theme = writable<Theme>(initialTheme());
/** Applies a theme to the store, the storage, and the document. */
export function setTheme(next: Theme): void {
theme.set(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Persisting is best effort.
}
if (typeof document !== 'undefined') {
document.documentElement.dataset.theme = next;
}
}
theme.subscribe((current) => {
// The subscription keeps the document attribute in step with the store
// even when the store is set directly.
if (typeof document !== 'undefined') {
document.documentElement.dataset.theme = current;
}
});
/**
* Switches between the two themes and reports the one now in force.
*
* The result is returned so that a caller can describe the button in the
* language of the theme it switches to.
*/
export function toggleTheme(current: Theme): Theme {
const next: Theme = current === 'dark' ? 'light' : 'dark';
setTheme(next);
return next;
}