| 1 | import { useSyncExternalStore } from "react"; |
| 2 | |
| 3 | /** |
| 4 | * Appearance: Auto, Light or Dark. Each person's choice is kept in a |
| 5 | * cookie (`g1t_theme`), so the server draws the page in it from the first |
| 6 | * byte, with no flash of the other theme. Light and Dark are data-theme on |
| 7 | * <html>; Auto sets nothing and the stylesheet follows the system's |
| 8 | * prefers-color-scheme (app.css). Every colour token is a light-dark() |
| 9 | * pair, so the browser picks the palette from the page's color-scheme. |
| 10 | */ |
| 11 | |
| 12 | export type ThemeChoice = "auto" | "light" | "dark"; |
| 13 | |
| 14 | export const THEME_CHOICES: ThemeChoice[] = ["auto", "light", "dark"]; |
| 15 | |
| 16 | /** The cookie that keeps the choice. */ |
| 17 | export const THEME_COOKIE = "g1t_theme"; |
| 18 | |
| 19 | /** What a page is drawn in before anyone chooses: whatever their system is set to. */ |
| 20 | export const DEFAULT_THEME: ThemeChoice = "auto"; |
| 21 | |
| 22 | /** The browser bar's colour in each theme: the page's background. */ |
| 23 | export const THEME_COLOR = { light: "#fbfbfa", dark: "#0f0f11" } as const; |
| 24 | |
| 25 | /** The choice in the cookie's value; the default for anything else. */ |
| 26 | export function readTheme(value: string | null | undefined): ThemeChoice { |
| 27 | return value === "light" || value === "dark" || value === "auto" ? value : DEFAULT_THEME; |
| 28 | } |
| 29 | |
| 30 | /** The Set-Cookie value that keeps the choice, for a year. */ |
| 31 | export function themeCookie(choice: ThemeChoice, secure: boolean): string { |
| 32 | return `${THEME_COOKIE}=${choice}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? "; Secure" : ""}`; |
| 33 | } |
| 34 | |
| 35 | /** What <html> carries for a choice: data-theme for Light and Dark, nothing for Auto. */ |
| 36 | export function themeAttribute(choice: ThemeChoice): "light" | "dark" | undefined { |
| 37 | return choice === "auto" ? undefined : choice; |
| 38 | } |
| 39 | |
| 40 | /** The words for a choice, as the switch and the command palette say them. */ |
| 41 | export const THEME_LABEL: Record<ThemeChoice, string> = { auto: "Auto", light: "Light", dark: "Dark" }; |
| 42 | |
| 43 | // The choice made in this tab since the page loaded; null until one is. |
| 44 | // Only ever set in the browser: on the server the page's own cookie rules. |
| 45 | let chosen: ThemeChoice | null = null; |
| 46 | const listeners = new Set<() => void>(); |
| 47 | |
| 48 | function subscribe(listener: () => void) { |
| 49 | listeners.add(listener); |
| 50 | return () => listeners.delete(listener); |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * The person's choice: the one made in this tab, else the one the page was |
| 55 | * drawn with (the root loader's, from the cookie). |
| 56 | */ |
| 57 | export function useThemeChoice(drawn: ThemeChoice | undefined): ThemeChoice { |
| 58 | const initial = drawn ?? DEFAULT_THEME; |
| 59 | return useSyncExternalStore( |
| 60 | subscribe, |
| 61 | () => chosen ?? initial, |
| 62 | () => initial, |
| 63 | ); |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * Switch to `choice` at once and remember it: <html>'s data-theme and the |
| 68 | * browser bar's colour change now, the cookie keeps it for the next page |
| 69 | * the server draws, and every switch on the page follows. |
| 70 | */ |
| 71 | export function setTheme(choice: ThemeChoice) { |
| 72 | chosen = choice; |
| 73 | if (typeof document !== "undefined") { |
| 74 | const root = document.documentElement; |
| 75 | const attribute = themeAttribute(choice); |
| 76 | if (attribute) root.dataset.theme = attribute; |
| 77 | else delete root.dataset.theme; |
| 78 | document.cookie = themeCookie(choice, window.location.protocol === "https:"); |
| 79 | } |
| 80 | for (const listener of listeners) listener(); |
| 81 | } |
| 82 | |
| 83 | /** The theme a choice comes to on this screen now: Auto asks the system. */ |
| 84 | export function resolvedTheme(choice: ThemeChoice): "light" | "dark" { |
| 85 | if (choice !== "auto") return choice; |
| 86 | if (typeof window === "undefined" || !window.matchMedia) return "dark"; |
| 87 | return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; |
| 88 | } |
| 89 | |
| 90 | /** The theme drawn right now, from <html>, for code that colours outside CSS (mermaid, the editor). */ |
| 91 | export function currentTheme(): "light" | "dark" { |
| 92 | if (typeof document === "undefined") return "dark"; |
| 93 | const attribute = document.documentElement.dataset.theme; |
| 94 | return attribute === "light" || attribute === "dark" ? attribute : resolvedTheme("auto"); |
| 95 | } |
| 96 | |
| 97 | function subscribeDrawn(listener: () => void) { |
| 98 | const unsubscribe = subscribe(listener); |
| 99 | const media = typeof window !== "undefined" && window.matchMedia ? window.matchMedia("(prefers-color-scheme: light)") : null; |
| 100 | media?.addEventListener("change", listener); |
| 101 | return () => { |
| 102 | unsubscribe(); |
| 103 | media?.removeEventListener("change", listener); |
| 104 | }; |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * The theme drawn right now, Light or Dark, kept current as it is switched |
| 109 | * or the system changes under Auto. Needs no router, so it works inside |
| 110 | * the editor's own blocks. The server says Dark; the browser corrects it. |
| 111 | */ |
| 112 | export function useDrawnTheme(): "light" | "dark" { |
| 113 | return useSyncExternalStore(subscribeDrawn, currentTheme, () => "dark"); |
| 114 | } |