Skip to content
114 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how.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}