Skip to content
114 linesCodeBlameRaw
1import { 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
12export type ThemeChoice = "auto" | "light" | "dark";
13
14export const THEME_CHOICES: ThemeChoice[] = ["auto", "light", "dark"];
15
16/** The cookie that keeps the choice. */
17export const THEME_COOKIE = "g1t_theme";
18
19/** What a page is drawn in before anyone chooses: g1t's own dark. */
20export const DEFAULT_THEME: ThemeChoice = "dark";
21
22/** The browser bar's colour in each theme: the page's background. */
23export const THEME_COLOR = { light: "#fbfbfa", dark: "#0f0f11" } as const;
24
25/** The choice in the cookie's value; the default for anything else. */
26export 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. */
31export 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. */
36export 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. */
41export 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.
45let chosen: ThemeChoice | null = null;
46const listeners = new Set<() => void>();
47
48function 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 */
57export 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 */
71export 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. */
84export 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). */
91export 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
97function 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 */
112export function useDrawnTheme(): "light" | "dark" {
113 return useSyncExternalStore(subscribeDrawn, currentTheme, () => "dark");
114}