| 1 | import { useSyncExternalStore } from "react"; |
| 2 | |
| 3 | /** |
| 4 | * Appearance: Auto, Light or Dark, as on g1t.sh (apps/web/app/lib/theme.ts). |
| 5 | * The choice is kept in a cookie (`sudo_theme`), so the server draws the |
| 6 | * page in it from the first byte, with no flash of the other theme. Light |
| 7 | * and Dark are data-theme on <html>; Auto sets nothing and the stylesheet |
| 8 | * follows the system's prefers-color-scheme. Every colour token is a |
| 9 | * light-dark() pair (app.css), so the browser picks the palette from the |
| 10 | * page's color-scheme. |
| 11 | */ |
| 12 | |
| 13 | export type ThemeChoice = "auto" | "light" | "dark"; |
| 14 | |
| 15 | export const THEME_CHOICES: ThemeChoice[] = ["auto", "light", "dark"]; |
| 16 | |
| 17 | /** The cookie that keeps the choice, on sudo's own host. */ |
| 18 | export const THEME_COOKIE = "sudo_theme"; |
| 19 | |
| 20 | /** Before anyone chooses: whatever their system is set to. */ |
| 21 | export const DEFAULT_THEME: ThemeChoice = "auto"; |
| 22 | |
| 23 | /** The browser bar's colour in each theme: the page's background. */ |
| 24 | export const THEME_COLOR = { light: "#fbfbfa", dark: "#0f0f11" } as const; |
| 25 | |
| 26 | export const THEME_LABEL: Record<ThemeChoice, string> = { auto: "Auto", light: "Light", dark: "Dark" }; |
| 27 | |
| 28 | /** The choice in the cookie's value; the default for anything else. */ |
| 29 | export function readTheme(value: string | null | undefined): ThemeChoice { |
| 30 | return value === "light" || value === "dark" || value === "auto" ? value : DEFAULT_THEME; |
| 31 | } |
| 32 | |
| 33 | /** The Set-Cookie value that keeps the choice, for a year. */ |
| 34 | export function themeCookie(choice: ThemeChoice, secure: boolean): string { |
| 35 | return `${THEME_COOKIE}=${choice}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? "; Secure" : ""}`; |
| 36 | } |
| 37 | |
| 38 | /** What <html> carries for a choice: data-theme for Light and Dark, nothing for Auto. */ |
| 39 | export function themeAttribute(choice: ThemeChoice): "light" | "dark" | undefined { |
| 40 | return choice === "auto" ? undefined : choice; |
| 41 | } |
| 42 | |
| 43 | // The choice made in this tab since the page loaded; null until one is. |
| 44 | let chosen: ThemeChoice | null = null; |
| 45 | const listeners = new Set<() => void>(); |
| 46 | |
| 47 | function subscribe(listener: () => void) { |
| 48 | listeners.add(listener); |
| 49 | return () => listeners.delete(listener); |
| 50 | } |
| 51 | |
| 52 | /** The choice: the one made in this tab, else the one the page was drawn with (the cookie's). */ |
| 53 | export function useThemeChoice(drawn: ThemeChoice | undefined): ThemeChoice { |
| 54 | const initial = drawn ?? DEFAULT_THEME; |
| 55 | return useSyncExternalStore(subscribe, () => chosen ?? initial, () => initial); |
| 56 | } |
| 57 | |
| 58 | /** |
| 59 | * Switch to `choice` at once and remember it: <html>'s data-theme changes |
| 60 | * now, the cookie keeps it for the next page the server draws, and every |
| 61 | * switch on the page follows. |
| 62 | */ |
| 63 | export function setTheme(choice: ThemeChoice) { |
| 64 | chosen = choice; |
| 65 | if (typeof document !== "undefined") { |
| 66 | const root = document.documentElement; |
| 67 | const attribute = themeAttribute(choice); |
| 68 | if (attribute) root.dataset.theme = attribute; |
| 69 | else delete root.dataset.theme; |
| 70 | document.cookie = themeCookie(choice, window.location.protocol === "https:"); |
| 71 | } |
| 72 | for (const listener of listeners) listener(); |
| 73 | } |