sudo no longer blinks unstyled between pages, and has Appearance: Auto, Light or Dark, drawn in the chosen theme from the first byte. The stylesheet was imported for React Router's <Links />, which moved it among the head's links as pages changed. Each move took it off the page for a frame, so the page showed unstyled (in a headless run the body's background went transparent mid-navigation). It is a React 19 stylesheet resource now (precedence), as on g1t.sh: drawn once and never moved. The favicon and font preloads sit in the head beside it. Appearance works as on g1t.sh. The choice is a cookie (sudo_theme) the root loader reads, so <html> carries data-theme and the browser bar's colour from the server. Auto sets nothing and follows the system. Switching changes the page at once and keeps the cookie. Every colour token is a light-dark() pair, and the charts and bars that named the dark-only tokens use the paired ones. The switch sits in the panel at the foot of the menu, on phones too. Checked: apps/sudo tsc -b, npm test (163; new: the theme), and a built sudo run locally with sign-in stubbed (not committed) through ten pages in headless Chrome. The stylesheet was never removed and the background never blinked. A light cookie on a dark system drew light from the first byte, Dark switched at once and kept the cookie, and Auto followed the system.
- G1t-Session:
- ses_01m5075p3weck8yhgj6kdx5nqb
10 files+204−460/10 viewed
| 8 | 8 | --font-display: var(--g1t-font-display); | |
| 9 | 9 | --font-mono: var(--g1t-font-mono); | |
| 10 | 10 | ||
| 11 | − | --color-bg: var(--g1t-bg); | |
| 12 | − | --color-surface: var(--g1t-surface); | |
| 13 | − | --color-raised: var(--g1t-raised); | |
| 14 | − | --color-line: var(--g1t-line); | |
| 15 | − | --color-line-strong: var(--g1t-line-strong); | |
| 11 | + | /* | |
| 12 | + | * Every colour is a pair: light-dark() picks the light palette or the | |
| 13 | + | * dark one by the page's color-scheme, which follows the Appearance | |
| 14 | + | * switch (lib/theme.ts), as on g1t.sh. | |
| 15 | + | */ | |
| 16 | + | --color-bg: light-dark(var(--g1t-light-bg), var(--g1t-bg)); | |
| 17 | + | --color-surface: light-dark(var(--g1t-light-surface), var(--g1t-surface)); | |
| 18 | + | --color-raised: light-dark(var(--g1t-light-raised), var(--g1t-raised)); | |
| 19 | + | --color-line: light-dark(var(--g1t-light-line), var(--g1t-line)); | |
| 20 | + | --color-line-strong: light-dark(var(--g1t-light-line-strong), var(--g1t-line-strong)); | |
| 16 | 21 | ||
| 17 | − | --color-fg: var(--g1t-fg); | |
| 18 | − | --color-fg-soft: var(--g1t-fg-soft); | |
| 19 | − | --color-muted: var(--g1t-muted); | |
| 20 | − | --color-faint: var(--g1t-faint); | |
| 22 | + | --color-fg: light-dark(var(--g1t-light-fg), var(--g1t-fg)); | |
| 23 | + | --color-fg-soft: light-dark(var(--g1t-light-fg-soft), var(--g1t-fg-soft)); | |
| 24 | + | /* A primary button's hover: brighter than fg on dark, softer on light. */ | |
| 25 | + | --color-fg-hover: light-dark(#3a3a41, #ffffff); | |
| 26 | + | --color-muted: light-dark(var(--g1t-light-muted), var(--g1t-muted)); | |
| 27 | + | --color-faint: light-dark(var(--g1t-light-faint), var(--g1t-faint)); | |
| 21 | 28 | ||
| 22 | − | --color-accent: var(--g1t-accent); | |
| 23 | − | --color-accent-hover: var(--g1t-accent-hover); | |
| 24 | − | --color-accent-dim: var(--g1t-accent-dim); | |
| 29 | + | --color-accent: light-dark(var(--g1t-light-accent), var(--g1t-accent)); | |
| 30 | + | --color-accent-hover: light-dark(var(--g1t-light-accent-hover), var(--g1t-accent-hover)); | |
| 31 | + | --color-accent-dim: light-dark(var(--g1t-light-accent-dim), var(--g1t-accent-dim)); | |
| 25 | 32 | ||
| 26 | − | --color-success: var(--g1t-success); | |
| 27 | − | --color-success-dim: var(--g1t-success-dim); | |
| 33 | + | --color-success: light-dark(var(--g1t-light-success), var(--g1t-success)); | |
| 34 | + | --color-success-dim: light-dark(var(--g1t-light-success-dim), var(--g1t-success-dim)); | |
| 28 | 35 | ||
| 29 | − | --color-info: var(--g1t-info); | |
| 30 | − | --color-merged: var(--g1t-merged); | |
| 31 | − | --color-warn: var(--g1t-warn); | |
| 32 | − | --color-danger: var(--g1t-danger); | |
| 36 | + | --color-info: light-dark(var(--g1t-light-info), var(--g1t-info)); | |
| 37 | + | --color-merged: light-dark(var(--g1t-light-merged), var(--g1t-merged)); | |
| 38 | + | --color-warn: light-dark(var(--g1t-light-warn), var(--g1t-warn)); | |
| 39 | + | --color-danger: light-dark(var(--g1t-light-danger), var(--g1t-danger)); | |
| 33 | 40 | } | |
| 34 | 41 | ||
| 42 | + | /* | |
| 43 | + | * Appearance (lib/theme.ts): with no choice, or Auto, the page follows | |
| 44 | + | * the system; a choice of Light or Dark is data-theme on <html>, drawn by | |
| 45 | + | * the server, so a page never shows the other theme first. | |
| 46 | + | */ | |
| 35 | 47 | html { | |
| 48 | + | color-scheme: light dark; | |
| 49 | + | } | |
| 50 | + | html[data-theme="light"] { | |
| 51 | + | color-scheme: light; | |
| 52 | + | } | |
| 53 | + | html[data-theme="dark"] { | |
| 36 | 54 | color-scheme: dark; | |
| 37 | 55 | } | |
| 38 | 56 |
| 9 | 9 | import { usd } from "~/lib/money"; | |
| 10 | 10 | ||
| 11 | 11 | /** The two series: what was charged (lavender) beside what it cost g1t (gray). */ | |
| 12 | − | const CHARGED = "var(--g1t-accent)"; | |
| 12 | + | const CHARGED = "var(--color-accent)"; | |
| 13 | 13 | const COST = "#7a7a84"; | |
| 14 | 14 | ||
| 15 | 15 | function Legend() { | |
| ⋯ | |||
| 43 | 43 | x2={plot.x + plot.width} | |
| 44 | 44 | y1={tick.y} | |
| 45 | 45 | y2={tick.y} | |
| 46 | − | stroke="var(--g1t-line)" | |
| 46 | + | stroke="var(--color-line)" | |
| 47 | 47 | strokeWidth="1" | |
| 48 | 48 | strokeDasharray={tick.value === 0 ? undefined : "2 4"} | |
| 49 | 49 | /> | |
| ⋯ | |||
| 153 | 153 | const width = Math.max(0, Math.min(1, share)) * 100; | |
| 154 | 154 | return ( | |
| 155 | 155 | <svg viewBox="0 0 100 4" preserveAspectRatio="none" className="block h-1.5 w-full" role="img" aria-label={label}> | |
| 156 | − | <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--g1t-raised)" /> | |
| 156 | + | <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--color-raised)" /> | |
| 157 | 157 | {width > 0 && <rect x="0" y="0" width={Math.max(1.5, width)} height="4" rx="2" fill={CHARGED} />} | |
| 158 | 158 | </svg> | |
| 159 | 159 | ); | |
| 8 | 8 | import { type DayFigures, marginPercent, percentLabel } from "~/lib/costs"; | |
| 9 | 9 | import { usd } from "~/lib/money"; | |
| 10 | 10 | ||
| 11 | − | const CHARGED = "var(--g1t-accent)"; | |
| 11 | + | const CHARGED = "var(--color-accent)"; | |
| 12 | 12 | const COST = "#7a7a84"; | |
| 13 | − | const LOSS = "var(--g1t-danger)"; | |
| 13 | + | const LOSS = "var(--color-danger)"; | |
| 14 | 14 | ||
| 15 | 15 | const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; | |
| 16 | 16 | ||
| ⋯ | |||
| 65 | 65 | const y = baseline - (value / top) * plot.height; | |
| 66 | 66 | return ( | |
| 67 | 67 | <g key={value}> | |
| 68 | − | <line x1={plot.x} x2={plot.x + plot.width} y1={y} y2={y} stroke="var(--g1t-line)" strokeWidth="1" strokeDasharray={value === 0 ? undefined : "2 4"} /> | |
| 68 | + | <line x1={plot.x} x2={plot.x + plot.width} y1={y} y2={y} stroke="var(--color-line)" strokeWidth="1" strokeDasharray={value === 0 ? undefined : "2 4"} /> | |
| 69 | 69 | <text x={plot.x - 8} y={y + 3.5} textAnchor="end" fontSize="11" className="fill-faint tabular"> | |
| 70 | 70 | {axisDollars(value)} | |
| 71 | 71 | </text> | |
| 21 | 21 | <svg viewBox={`0 0 ${p.width} ${p.height}`} className="block h-auto w-full" role="img" aria-label={`${name}: how long each check took, ${summaryWords(latencySummary(history.samples))}`}> | |
| 22 | 22 | {p.ticks.map((t) => ( | |
| 23 | 23 | <g key={t.label}> | |
| 24 | − | <line x1={p.plot.x} x2={p.plot.x + p.plot.width} y1={t.y} y2={t.y} stroke="var(--g1t-line)" strokeWidth="1" strokeDasharray={t.label === "0" ? undefined : "2 4"} /> | |
| 24 | + | <line x1={p.plot.x} x2={p.plot.x + p.plot.width} y1={t.y} y2={t.y} stroke="var(--color-line)" strokeWidth="1" strokeDasharray={t.label === "0" ? undefined : "2 4"} /> | |
| 25 | 25 | <text x={p.plot.x - 6} y={t.y + 3.5} textAnchor="end" fontSize="10" className="fill-faint tabular"> | |
| 26 | 26 | {t.label} | |
| 27 | 27 | </text> | |
| ⋯ | |||
| 29 | 29 | ))} | |
| 30 | 30 | {p.slowY != null && ( | |
| 31 | 31 | <g> | |
| 32 | − | <line x1={p.plot.x} x2={p.plot.x + p.plot.width} y1={p.slowY} y2={p.slowY} stroke="var(--g1t-warn)" strokeWidth="1" strokeDasharray="4 3" /> | |
| 32 | + | <line x1={p.plot.x} x2={p.plot.x + p.plot.width} y1={p.slowY} y2={p.slowY} stroke="var(--color-warn)" strokeWidth="1" strokeDasharray="4 3" /> | |
| 33 | 33 | <text x={p.plot.x + p.plot.width} y={p.slowY - 3} textAnchor="end" fontSize="10" className="fill-warn"> | |
| 34 | 34 | slow over {msWords(history.slow_ms!)} | |
| 35 | 35 | </text> | |
| ⋯ | |||
| 38 | 38 | {p.down.map((d, i) => ( | |
| 39 | 39 | <rect key={`d${i}`} x={d.x - 1} y={p.plot.y} width="2" height={p.plot.height} className="fill-danger/35" /> | |
| 40 | 40 | ))} | |
| 41 | − | <path d={p.line} fill="none" stroke="var(--g1t-accent)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" /> | |
| 41 | + | <path d={p.line} fill="none" stroke="var(--color-accent)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" /> | |
| 42 | 42 | {p.slow.map((s, i) => ( | |
| 43 | 43 | <circle key={`s${i}`} cx={s.x} cy={s.y} r="2.5" className="fill-warn" /> | |
| 44 | 44 | ))} | |
| 29 | 29 | y={p.y * 10 + 1} | |
| 30 | 30 | width="8" | |
| 31 | 31 | height="8" | |
| 32 | − | fill={p.one ? "var(--g1t-accent)" : "currentColor"} | |
| 32 | + | fill={p.one ? "var(--color-accent)" : "currentColor"} | |
| 33 | 33 | /> | |
| 34 | 34 | )); | |
| 35 | 35 | } |
| 27 | 27 | type LucideIcon, | |
| 28 | 28 | Megaphone, | |
| 29 | 29 | Menu, | |
| 30 | + | Monitor, | |
| 31 | + | Moon, | |
| 30 | 32 | ScrollText, | |
| 31 | 33 | Server, | |
| 32 | 34 | ShieldAlert, | |
| 33 | 35 | ShieldCheck, | |
| 34 | 36 | Signpost, | |
| 37 | + | Sun, | |
| 35 | 38 | Siren, | |
| 36 | 39 | Tags, | |
| 37 | 40 | Ticket, | |
| ⋯ | |||
| 42 | 45 | X, | |
| 43 | 46 | Zap, | |
| 44 | 47 | } from "lucide-react"; | |
| 45 | − | import { Link, useLocation } from "react-router"; | |
| 48 | + | import { Link, useLocation, useRouteLoaderData } from "react-router"; | |
| 46 | 49 | ||
| 47 | 50 | import { Logo } from "~/components/logo"; | |
| 48 | 51 | import { useZone } from "~/components/ui"; | |
| 49 | 52 | import { NAV, type NavCounts, type NavGroup, type NavIcon, type NavItem, countFor, holdsCurrent, isCurrent } from "~/lib/nav"; | |
| 53 | + | import { THEME_CHOICES, THEME_LABEL, type ThemeChoice, setTheme, useThemeChoice } from "~/lib/theme"; | |
| 50 | 54 | import { zoneAbbr } from "~/lib/time"; | |
| 51 | 55 | ||
| 52 | 56 | const ICONS: Record<NavIcon, LucideIcon> = { | |
| ⋯ | |||
| 235 | 239 | ); | |
| 236 | 240 | } | |
| 237 | 241 | ||
| 242 | + | const THEME_ICON: Record<ThemeChoice, LucideIcon> = { auto: Monitor, light: Sun, dark: Moon }; | |
| 243 | + | ||
| 244 | + | /** Appearance: Auto, Light or Dark, switched at once and kept in a cookie (lib/theme.ts). */ | |
| 245 | + | function ThemeSwitch() { | |
| 246 | + | const root = useRouteLoaderData<{ theme?: ThemeChoice }>("root"); | |
| 247 | + | const current = useThemeChoice(root?.theme); | |
| 248 | + | return ( | |
| 249 | + | <div role="radiogroup" aria-label="Appearance" className="mt-3 flex rounded-md border border-line p-0.5"> | |
| 250 | + | {THEME_CHOICES.map((choice) => { | |
| 251 | + | const Icon = THEME_ICON[choice]; | |
| 252 | + | const on = choice === current; | |
| 253 | + | return ( | |
| 254 | + | <button | |
| 255 | + | key={choice} | |
| 256 | + | type="button" | |
| 257 | + | role="radio" | |
| 258 | + | aria-checked={on} | |
| 259 | + | onClick={() => setTheme(choice)} | |
| 260 | + | className={`flex h-7 flex-1 items-center justify-center gap-1.5 rounded text-xs transition-colors ${ | |
| 261 | + | on ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg" | |
| 262 | + | }`} | |
| 263 | + | > | |
| 264 | + | <Icon size={13} aria-hidden /> | |
| 265 | + | {THEME_LABEL[choice]} | |
| 266 | + | </button> | |
| 267 | + | ); | |
| 268 | + | })} | |
| 269 | + | </div> | |
| 270 | + | ); | |
| 271 | + | } | |
| 272 | + | ||
| 238 | 273 | /** Who is signed in: a panel of its own at the foot of the menu. */ | |
| 239 | 274 | function SignedIn({ email }: { email: string | null | undefined }) { | |
| 240 | 275 | return ( | |
| ⋯ | |||
| 247 | 282 | )} | |
| 248 | 283 | <p className="mt-1.5 text-[0.6875rem] leading-snug text-faint">g1t staff only. Every change is recorded with who made it.</p> | |
| 249 | 284 | <TimesIn /> | |
| 285 | + | <ThemeSwitch /> | |
| 250 | 286 | </div> | |
| 251 | 287 | ); | |
| 252 | 288 | } | |
| 79 | 79 | "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium whitespace-nowrap transition-colors disabled:opacity-50"; | |
| 80 | 80 | ||
| 81 | 81 | const BUTTON_VARIANTS: Record<Variant, string> = { | |
| 82 | − | primary: "bg-fg text-bg hover:bg-white", | |
| 82 | + | primary: "bg-fg text-bg hover:bg-fg-hover", | |
| 83 | 83 | lavender: "bg-accent text-bg hover:bg-accent-hover", | |
| 84 | 84 | quiet: "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg", | |
| 85 | 85 | danger: "border border-danger/40 text-danger hover:border-danger/70 hover:bg-danger/10", | |
| ⋯ | |||
| 213 | 213 | } | |
| 214 | 214 | ||
| 215 | 215 | const STATE = { | |
| 216 | − | ok: { label: "OK", fill: "var(--g1t-success)", text: "text-success" }, | |
| 217 | − | warning: { label: "Warning", fill: "var(--g1t-warn)", text: "text-warn" }, | |
| 218 | − | stopped: { label: "Stopped", fill: "var(--g1t-danger)", text: "text-danger" }, | |
| 216 | + | ok: { label: "OK", fill: "var(--color-success)", text: "text-success" }, | |
| 217 | + | warning: { label: "Warning", fill: "var(--color-warn)", text: "text-warn" }, | |
| 218 | + | stopped: { label: "Stopped", fill: "var(--color-danger)", text: "text-danger" }, | |
| 219 | 219 | } as const; | |
| 220 | 220 | ||
| 221 | 221 | export function StateBadge({ state }: { state: Limit["state"] }) { | |
| ⋯ | |||
| 241 | 241 | {percent != null && <span className={`tabular ${STATE[state].text}`}>{percent}%</span>} | |
| 242 | 242 | </div> | |
| 243 | 243 | <svg viewBox="0 0 100 4" preserveAspectRatio="none" className="mt-1.5 block h-1.5 w-full" role="img" aria-label={`${STATE[state].label}: ${percent ?? 0}% of the limit`}> | |
| 244 | − | <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--g1t-raised)" /> | |
| 244 | + | <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--color-raised)" /> | |
| 245 | 245 | {ceilingMicros != null && share > 0 && ( | |
| 246 | 246 | <rect x="0" y="0" width={Math.max(2, share * 100)} height="4" rx="2" fill={STATE[state].fill} /> | |
| 247 | 247 | )} | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { THEME_COOKIE, readTheme, themeAttribute, themeCookie } from "./theme.ts"; | |
| 5 | + | ||
| 6 | + | test("the theme: the cookie's choice, else Auto; Light and Dark on <html>, Auto nothing", () => { | |
| 7 | + | assert.equal(readTheme("light"), "light"); | |
| 8 | + | assert.equal(readTheme("dark"), "dark"); | |
| 9 | + | assert.equal(readTheme("auto"), "auto"); | |
| 10 | + | assert.equal(readTheme("sepia"), "auto"); | |
| 11 | + | assert.equal(readTheme(null), "auto"); | |
| 12 | + | assert.equal(themeAttribute("light"), "light"); | |
| 13 | + | assert.equal(themeAttribute("auto"), undefined); | |
| 14 | + | assert.equal(themeCookie("dark", true), `${THEME_COOKIE}=dark; Path=/; Max-Age=31536000; SameSite=Lax; Secure`); | |
| 15 | + | assert.ok(!themeCookie("dark", false).includes("Secure")); | |
| 16 | + | }); |
| 1 | + | import { 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 | + | ||
| 13 | + | export type ThemeChoice = "auto" | "light" | "dark"; | |
| 14 | + | ||
| 15 | + | export const THEME_CHOICES: ThemeChoice[] = ["auto", "light", "dark"]; | |
| 16 | + | ||
| 17 | + | /** The cookie that keeps the choice, on sudo's own host. */ | |
| 18 | + | export const THEME_COOKIE = "sudo_theme"; | |
| 19 | + | ||
| 20 | + | /** Before anyone chooses: whatever their system is set to. */ | |
| 21 | + | export const DEFAULT_THEME: ThemeChoice = "auto"; | |
| 22 | + | ||
| 23 | + | /** The browser bar's colour in each theme: the page's background. */ | |
| 24 | + | export const THEME_COLOR = { light: "#fbfbfa", dark: "#0f0f11" } as const; | |
| 25 | + | ||
| 26 | + | export 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. */ | |
| 29 | + | export 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. */ | |
| 34 | + | export 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. */ | |
| 39 | + | export 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. | |
| 44 | + | let chosen: ThemeChoice | null = null; | |
| 45 | + | const listeners = new Set<() => void>(); | |
| 46 | + | ||
| 47 | + | function 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). */ | |
| 53 | + | export 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 | + | */ | |
| 63 | + | export 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 | + | } |
| 2 | 2 | import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration, useRouteLoaderData } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/root"; | |
| 5 | − | import "./app.css"; | |
| 5 | + | import appCss from "./app.css?url"; | |
| 6 | 6 | import displayFont from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2?url"; | |
| 7 | 7 | import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url"; | |
| 8 | 8 | import { Progress, useLeaving, useSettledForms } from "./components/progress"; | |
| ⋯ | |||
| 13 | 13 | import { admin, statusAdmin } from "./lib/services.server"; | |
| 14 | 14 | import { settle } from "./lib/settle"; | |
| 15 | 15 | import { requireStaff, zoneContext } from "./lib/staff"; | |
| 16 | − | ||
| 17 | − | export const links: Route.LinksFunction = () => [ | |
| 18 | − | { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }, | |
| 19 | − | // The faces are served by sudo itself (@g1t/theme); these two are on every page. | |
| 20 | − | { rel: "preload", href: sansFont, as: "font", type: "font/woff2", crossOrigin: "anonymous" }, | |
| 21 | − | { rel: "preload", href: displayFont, as: "font", type: "font/woff2", crossOrigin: "anonymous" }, | |
| 22 | − | ]; | |
| 16 | + | import { THEME_COLOR, THEME_COOKIE, readTheme, themeAttribute, useThemeChoice } from "./lib/theme"; | |
| 17 | + | import { readCookie } from "./lib/time"; | |
| 23 | 18 | ||
| 24 | 19 | export const meta: Route.MetaFunction = () => [ | |
| 25 | 20 | { title: "sudo · g1t" }, | |
| 26 | 21 | { name: "robots", content: "noindex, nofollow" }, | |
| 27 | 22 | ]; | |
| 28 | 23 | ||
| 29 | − | export async function loader({ context }: Route.LoaderArgs) { | |
| 24 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 30 | 25 | const { email } = requireStaff(context); | |
| 31 | 26 | // The sidebar's counts: a service that does not answer shows none. | |
| 32 | 27 | const [incidents, alerts, caps, guard, erasures] = await Promise.all([ | |
| ⋯ | |||
| 50 | 45 | // A platform pause (billing's platform.rs): red on every page while any | |
| 51 | 46 | // level is paused, by staff or by the usage watcher. | |
| 52 | 47 | const paused = guard.ok ? pauseBanner(guard.value) : null; | |
| 53 | − | return { email, counts, zone, zoneChosen: chosen, margin, spend, paused }; | |
| 48 | + | // Their Appearance, so the page is drawn in it from the first byte (lib/theme.ts). | |
| 49 | + | const theme = readTheme(readCookie(request.headers.get("cookie"), THEME_COOKIE)); | |
| 50 | + | return { email, counts, zone, zoneChosen: chosen, margin, spend, paused, theme }; | |
| 54 | 51 | } | |
| 55 | 52 | ||
| 56 | 53 | export function Layout({ children }: { children: React.ReactNode }) { | |
| 57 | 54 | const root = useRouteLoaderData<typeof loader>("root"); | |
| 58 | 55 | const leaving = useLeaving(); | |
| 59 | 56 | useSettledForms(); | |
| 57 | + | const theme = useThemeChoice(root?.theme); | |
| 60 | 58 | return ( | |
| 61 | − | <html lang="en"> | |
| 59 | + | <html lang="en" data-theme={themeAttribute(theme)}> | |
| 62 | 60 | <head> | |
| 63 | 61 | <meta charSet="utf-8" /> | |
| 64 | 62 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 65 | − | <meta name="theme-color" content="#0f0f11" /> | |
| 63 | + | {/* The browser bar matches the page: one colour for a chosen theme, the system's for Auto. */} | |
| 64 | + | {theme === "auto" ? ( | |
| 65 | + | <> | |
| 66 | + | <meta name="theme-color" media="(prefers-color-scheme: light)" content={THEME_COLOR.light} /> | |
| 67 | + | <meta name="theme-color" media="(prefers-color-scheme: dark)" content={THEME_COLOR.dark} /> | |
| 68 | + | </> | |
| 69 | + | ) : ( | |
| 70 | + | <meta name="theme-color" content={THEME_COLOR[theme]} /> | |
| 71 | + | )} | |
| 66 | 72 | <Meta /> | |
| 73 | + | {/* A stylesheet resource (React 19): drawn in the head once and never | |
| 74 | + | moved or taken out as pages change, so no page shows unstyled. | |
| 75 | + | Left to <Links />, it moved between pages and blinked. */} | |
| 76 | + | <link rel="stylesheet" href={appCss} precedence="default" /> | |
| 77 | + | {/* On every page, so in the head itself rather than <Links />, which moves what it holds between pages. | |
| 78 | + | The faces are served by sudo itself (@g1t/theme). */} | |
| 79 | + | <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> | |
| 80 | + | <link rel="preload" href={sansFont} as="font" type="font/woff2" crossOrigin="anonymous" /> | |
| 81 | + | <link rel="preload" href={displayFont} as="font" type="font/woff2" crossOrigin="anonymous" /> | |
| 67 | 82 | <Links /> | |
| 68 | 83 | </head> | |
| 69 | 84 | <body className="min-h-screen"> | |
Comments
Sign in to comment.