Skip to content
103 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 { Monitor, Moon, Sun } from "lucide-react";
2import { DropdownMenu as Menu } from "radix-ui";
3import { type KeyboardEvent, type ReactNode, useRef } from "react";
4import { useRouteLoaderData } from "react-router";
5
6import { cn } from "../lib/cn";
7import { THEME_CHOICES, THEME_LABEL, type ThemeChoice, setTheme, useThemeChoice } from "../lib/theme";
8import type { PaletteCommand } from "./command-palette";
9import { Hint } from "./ui/hint";
10
11/** The person's Appearance: as the page was drawn with it, or as switched since. */
12export function useTheme(): ThemeChoice {
13 const root = useRouteLoaderData("root") as { theme?: ThemeChoice } | undefined;
14 return useThemeChoice(root?.theme);
15}
16
17const ICON: Record<ThemeChoice, (size: number) => ReactNode> = {
18 auto: (size) => <Monitor size={size} />,
19 light: (size) => <Sun size={size} />,
20 dark: (size) => <Moon size={size} />,
21};
22
23const TRACK = "grid grid-cols-3 gap-0.5 rounded-lg bg-raised p-0.5 ring-1 ring-line ring-inset";
24const SEGMENT =
25 "flex min-w-0 items-center justify-center gap-1.5 rounded-md font-medium text-muted outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[on=true]:bg-surface data-[on=true]:text-fg data-[on=true]:shadow-[0_0_0_1px_var(--color-line-strong),0_1px_2px_rgb(0_0_0/0.12)]";
26
27/**
28 * Auto, Light and Dark side by side, the current one raised: on a
29 * settings page, or in a phone's sheet (`size="large"`). A radio group:
30 * the arrow keys move between them.
31 */
32export function ThemeSwitch({ size = "small", className }: { size?: "small" | "large"; className?: string }) {
33 const theme = useTheme();
34 const buttons = useRef<(HTMLButtonElement | null)[]>([]);
35 const move = (event: KeyboardEvent, index: number) => {
36 const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
37 if (!step) return;
38 event.preventDefault();
39 const next = THEME_CHOICES[(index + step + THEME_CHOICES.length) % THEME_CHOICES.length]!;
40 setTheme(next);
41 buttons.current[THEME_CHOICES.indexOf(next)]?.focus();
42 };
43 return (
44 <div role="radiogroup" aria-label="Appearance" className={cn(TRACK, className)}>
45 {THEME_CHOICES.map((choice, index) => (
46 <button
47 key={choice}
48 ref={(element) => {
49 buttons.current[index] = element;
50 }}
51 type="button"
52 role="radio"
53 aria-checked={theme === choice}
54 tabIndex={theme === choice ? 0 : -1}
55 data-on={theme === choice}
56 onClick={() => setTheme(choice)}
57 onKeyDown={(event) => move(event, index)}
58 className={cn(SEGMENT, size === "large" ? "h-10 text-sm" : "h-7 text-xs")}
59 >
60 {ICON[choice](size === "large" ? 16 : 13)}
61 {THEME_LABEL[choice]}
62 </button>
63 ))}
64 </div>
65 );
66}
67
68/** The palette's commands for switching: "Use light theme" and the rest (components/command-palette.tsx). */
69export const THEME_COMMANDS: PaletteCommand[] = [
70 { label: "Use light theme", hint: "Appearance", icon: ICON.light(15), run: () => setTheme("light") },
71 { label: "Use dark theme", hint: "Appearance", icon: ICON.dark(15), run: () => setTheme("dark") },
72 { label: "Use auto theme", hint: "Appearance · Follow the system", icon: ICON.auto(15), run: () => setTheme("auto") },
73];
74
75/**
76 * The same switch as a row of the account menu: a group of menu radio
77 * items, so the menu's arrow keys reach it, that switch without closing
78 * the menu.
79 */
80export function ThemeMenuSwitch({ className }: { className?: string }) {
81 const theme = useTheme();
82 return (
83 <div className={cn("flex items-center gap-2.5 px-2.5 py-1", className)}>
84 <span className="grow text-[0.8125rem] text-fg/90">Appearance</span>
85 <Menu.RadioGroup value={theme} onValueChange={(value) => setTheme(value as ThemeChoice)} aria-label="Appearance" className={cn(TRACK, "w-[7.5rem] shrink-0")}>
86 {THEME_CHOICES.map((choice) => (
87 <Hint key={choice} label={THEME_LABEL[choice]} side="bottom">
88 <Menu.RadioItem
89 value={choice}
90 aria-label={THEME_LABEL[choice]}
91 data-on={theme === choice}
92 // Switching keeps the menu open, so the change can be seen.
93 onSelect={(event) => event.preventDefault()}
94 className={cn(SEGMENT, "h-7 cursor-default data-highlighted:text-fg data-highlighted:ring-1 data-highlighted:ring-line-strong")}
95 >
96 {ICON[choice](14)}
97 </Menu.RadioItem>
98 </Hint>
99 ))}
100 </Menu.RadioGroup>
101 </div>
102 );
103}