import { DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, DESIGN_TOKEN_MOTION_ATTRIBUTE, type DesignTokenColorScheme, } from "./token.js"; /** * A person's appearance preference (DESIGN.md §3.2, ADR 0060). `system` follows the operating * system's light or dark setting, live; it is the default. */ export const EKKLESIA_APPEARANCES = Object.freeze(["system", "light", "dark"] as const); export type EkklesiaAppearance = (typeof EKKLESIA_APPEARANCES)[number]; /** * A person's motion preference (DESIGN.md §3.3, ADR 0060). `system` follows * `prefers-reduced-motion`; `reduce` always reduces; `allow` keeps the platform's subtle motion even * when the system asks for less. */ export const EKKLESIA_MOTION_PREFERENCES = Object.freeze(["system", "reduce", "allow"] as const); export type EkklesiaMotionPreference = (typeof EKKLESIA_MOTION_PREFERENCES)[number]; export const EKKLESIA_DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)"; export const EKKLESIA_REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; /** Reads an untrusted stored or received value; anything unrecognised is the default, `system`. */ export function parseEkklesiaAppearance(value: unknown): EkklesiaAppearance { return EKKLESIA_APPEARANCES.includes(value as EkklesiaAppearance) ? (value as EkklesiaAppearance) : "system"; } /** Reads an untrusted stored or received value; anything unrecognised is the default, `system`. */ export function parseEkklesiaMotionPreference(value: unknown): EkklesiaMotionPreference { return EKKLESIA_MOTION_PREFERENCES.includes(value as EkklesiaMotionPreference) ? (value as EkklesiaMotionPreference) : "system"; } /** The scheme an appearance shows, given whether the system currently prefers dark. */ export function resolveEkklesiaColorScheme( appearance: EkklesiaAppearance, systemPrefersDark: boolean, ): DesignTokenColorScheme { if (appearance === "system") { return systemPrefersDark ? "dark" : "light"; } return appearance; } /** Whether motion is reduced, given the preference and whether the system currently asks for less. */ export function resolveEkklesiaReducedMotion( preference: EkklesiaMotionPreference, systemPrefersReduced: boolean, ): boolean { return preference === "system" ? systemPrefersReduced : preference === "reduce"; } /** The element a host themes: its document's root element, or an isolated root's host element. */ export interface EkklesiaThemeTarget { setAttribute(name: string, value: string): void; } /** A media query list, structurally: what `window.matchMedia` returns. */ export interface EkklesiaMediaQueryList { readonly matches: boolean; addEventListener(type: "change", listener: () => void): void; removeEventListener(type: "change", listener: () => void): void; } export interface EkklesiaThemeControllerOptions { readonly target: EkklesiaThemeTarget; /** The browser's `matchMedia`, handed in by the host. Without one, the system is taken to prefer * light and full motion. */ readonly matchMedia?: (query: string) => EkklesiaMediaQueryList; readonly appearance?: EkklesiaAppearance; readonly motion?: EkklesiaMotionPreference; } /** * The host's handle on its theme (ADR 0060). * * The **stylesheet** does the work: installed tokens resolve light, dark, and reduced motion in CSS * from the system's media features and two attributes on the theme root, so a change of OS setting * restyles everything at once — every isolated module root included, since custom properties and * `color-scheme` inherit — with no script and no reload. This controller only sets those attributes, * and answers what is currently in effect for code that needs to know. */ export interface EkklesiaThemeController { appearance(): EkklesiaAppearance; motion(): EkklesiaMotionPreference; /** The scheme on screen now: the explicit appearance, or the system's while it is `system`. */ colorScheme(): DesignTokenColorScheme; /** Whether motion is reduced now. */ reducedMotion(): boolean; setAppearance(appearance: EkklesiaAppearance): void; setMotion(motion: EkklesiaMotionPreference): void; /** Notifies after a preference changes, and after the system's setting changes. */ subscribe(listener: () => void): () => void; } /** * Applies a theme to its target **immediately** — before anything renders, which is what keeps a * persisted explicit choice from flashing the other scheme first — and returns its controller. * Preferences are taken through {@link parseEkklesiaAppearance} and * {@link parseEkklesiaMotionPreference}, so an untrusted stored value can be passed straight in. */ export function createEkklesiaThemeController( options: EkklesiaThemeControllerOptions, ): EkklesiaThemeController { let appearance = parseEkklesiaAppearance(options.appearance); let motion = parseEkklesiaMotionPreference(options.motion); const darkQuery = options.matchMedia?.(EKKLESIA_DARK_SCHEME_QUERY); const motionQuery = options.matchMedia?.(EKKLESIA_REDUCED_MOTION_QUERY); const listeners = new Set<() => void>(); const notify = (): void => { for (const listener of [...listeners]) { listener(); } }; options.target.setAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, appearance); options.target.setAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE, motion); return { appearance: () => appearance, colorScheme: () => resolveEkklesiaColorScheme(appearance, darkQuery?.matches ?? false), motion: () => motion, reducedMotion: () => resolveEkklesiaReducedMotion(motion, motionQuery?.matches ?? false), setAppearance(next) { const parsed = parseEkklesiaAppearance(next); if (parsed === appearance) { return; } appearance = parsed; options.target.setAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, appearance); notify(); }, setMotion(next) { const parsed = parseEkklesiaMotionPreference(next); if (parsed === motion) { return; } motion = parsed; options.target.setAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE, motion); notify(); }, subscribe(listener) { if (listeners.size === 0) { darkQuery?.addEventListener("change", notify); motionQuery?.addEventListener("change", notify); } listeners.add(listener); return () => { listeners.delete(listener); if (listeners.size === 0) { darkQuery?.removeEventListener("change", notify); motionQuery?.removeEventListener("change", notify); } }; }, }; }