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. | 1 | import { Monitor, Moon, Sun } from "lucide-react"; |
| 2 | import { DropdownMenu as Menu } from "radix-ui"; | |
| 3 | import { type KeyboardEvent, type ReactNode, useRef } from "react"; | |
| 4 | import { useRouteLoaderData } from "react-router"; | |
| 5 | ||
| 6 | import { cn } from "../lib/cn"; | |
| 7 | import { THEME_CHOICES, THEME_LABEL, type ThemeChoice, setTheme, useThemeChoice } from "../lib/theme"; | |
| 8 | import type { PaletteCommand } from "./command-palette"; | |
| 9 | import { Hint } from "./ui/hint"; | |
| 10 | ||
| 11 | /** The person's Appearance: as the page was drawn with it, or as switched since. */ | |
| 12 | export function useTheme(): ThemeChoice { | |
| 13 | const root = useRouteLoaderData("root") as { theme?: ThemeChoice } | undefined; | |
| 14 | return useThemeChoice(root?.theme); | |
| 15 | } | |
| 16 | ||
| 17 | const 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 | ||
| 23 | const TRACK = "grid grid-cols-3 gap-0.5 rounded-lg bg-raised p-0.5 ring-1 ring-line ring-inset"; | |
| 24 | const 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 | */ | |
| 32 | export 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). */ | |
| 69 | export 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 | */ | |
| 80 | export 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 | } |