import type { EkklesiaAppearance, EkklesiaMotionPreference } from "@ekklesia/design-tokens"; /** * This browser's copy of the signed-in person's appearance and motion (ADR 0062). * * **A cache, never the source of truth.** The person's account holds their preferences, and every * Dashboard load replaces this copy with what the account says. The copy exists for one reason: * the account is only known once the API has answered, and by then the page has painted. The * Dashboard's pre-paint script (`appearance-boot.ts`) reads it synchronously, before the body is * parsed, so someone who chose Dark does not see a light page first. * * It holds two words — the appearance and the motion preference — under one namespaced key. No * name, email, user id, or anything else about the account: it is safe for anyone with access to * this browser profile to read, and it is cleared on sign-out so the next person starts from the * system's setting. * * This file is imported by the pre-paint script, so it imports only types: the script must stay * tiny and must not pull the token package into a render-blocking request. */ export interface AppearanceChoice { readonly appearance: EkklesiaAppearance; readonly motion: EkklesiaMotionPreference; } /** One namespaced key, beside the sidebar's (`ekklesia.dashboard.sidebar`). */ export const APPEARANCE_CACHE_KEY = "ekklesia.dashboard.appearance"; // Repeated rather than imported from the token package, to keep the pre-paint script small; the // shell's tests hold both lists equal to `EKKLESIA_APPEARANCES` and `EKKLESIA_MOTION_PREFERENCES`. const APPEARANCES: readonly string[] = ["system", "light", "dark"]; const MOTION_PREFERENCES: readonly string[] = ["system", "reduce", "allow"]; /** The serialized form: ` `, for example `dark reduce`. */ export function serializeAppearanceChoice(choice: AppearanceChoice): string { return `${choice.appearance} ${choice.motion}`; } /** Strict: exactly two known words. Anything else — including an older or tampered value — is no * cache at all, and the page follows the system until the account answers. */ export function parseAppearanceCache(value: unknown): AppearanceChoice | undefined { if (typeof value !== "string") { return undefined; } const parts = value.split(" "); const [appearance, motion] = parts; if ( parts.length !== 2 || appearance === undefined || motion === undefined || !APPEARANCES.includes(appearance) || !MOTION_PREFERENCES.includes(motion) ) { return undefined; } return { appearance: appearance as EkklesiaAppearance, motion: motion as EkklesiaMotionPreference, }; } export interface AppearanceCache { read(): AppearanceChoice | undefined; write(choice: AppearanceChoice): void; clear(): void; } /** * The browser's `localStorage`, handed in by the entry point. Exactly like the sidebar preference, * every access is guarded: storage can be missing, blocked, or full, and losing the cache only * costs the pre-paint hint, never the preference itself. */ export function createLocalAppearanceCache( storage: Pick | undefined, ): AppearanceCache { return { clear() { try { storage?.removeItem(APPEARANCE_CACHE_KEY); } catch { // Nothing to undo: an unreadable cache is already no cache. } }, read() { try { return parseAppearanceCache(storage?.getItem(APPEARANCE_CACHE_KEY)); } catch { return undefined; } }, write(choice) { const serialized = serializeAppearanceChoice(choice); try { storage?.setItem(APPEARANCE_CACHE_KEY, serialized); } catch { // The account still holds the preference; only the pre-paint hint is lost. } }, }; } /** Nothing persisted: for tests, and for a host composed without browser storage. */ export function createMemoryAppearanceCache(initial?: AppearanceChoice): AppearanceCache { let choice = initial; return { clear() { choice = undefined; }, read: () => choice, write(next) { choice = next; }, }; }