Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

73 linesCodeBlameRaw
1import { 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
13export type ThemeChoice = "auto" | "light" | "dark";
14
15export const THEME_CHOICES: ThemeChoice[] = ["auto", "light", "dark"];
16
17/** The cookie that keeps the choice, on sudo's own host. */
18export const THEME_COOKIE = "sudo_theme";
19
20/** Before anyone chooses: whatever their system is set to. */
21export const DEFAULT_THEME: ThemeChoice = "auto";
22
23/** The browser bar's colour in each theme: the page's background. */
24export const THEME_COLOR = { light: "#fbfbfa", dark: "#0f0f11" } as const;
25
26export 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. */
29export 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. */
34export 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. */
39export 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.
44let chosen: ThemeChoice | null = null;
45const listeners = new Set<() => void>();
46
47function 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). */
53export 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 */
63export 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}