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.

Commit

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
syntaqxcommitted Parentd770dfbBrowse files
10 files+204−460/10 viewed
+36−18
88 --font-display: var(--g1t-font-display);
99 --font-mono: var(--g1t-font-mono);
1010
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));
1621
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));
2128
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));
2532
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));
2835
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));
3340 }
3441
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+ */
3547 html {
48+ color-scheme: light dark;
49+}
50+html[data-theme="light"] {
51+ color-scheme: light;
52+}
53+html[data-theme="dark"] {
3654 color-scheme: dark;
3755 }
3856
+3−3
99 import { usd } from "~/lib/money";
1010
1111 /** 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)";
1313 const COST = "#7a7a84";
1414
1515 function Legend() {
4343 x2={plot.x + plot.width}
4444 y1={tick.y}
4545 y2={tick.y}
46− stroke="var(--g1t-line)"
46+ stroke="var(--color-line)"
4747 strokeWidth="1"
4848 strokeDasharray={tick.value === 0 ? undefined : "2 4"}
4949 />
153153 const width = Math.max(0, Math.min(1, share)) * 100;
154154 return (
155155 <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)" />
157157 {width > 0 && <rect x="0" y="0" width={Math.max(1.5, width)} height="4" rx="2" fill={CHARGED} />}
158158 </svg>
159159 );
+3−3
88 import { type DayFigures, marginPercent, percentLabel } from "~/lib/costs";
99 import { usd } from "~/lib/money";
1010
11−const CHARGED = "var(--g1t-accent)";
11+const CHARGED = "var(--color-accent)";
1212 const COST = "#7a7a84";
13−const LOSS = "var(--g1t-danger)";
13+const LOSS = "var(--color-danger)";
1414
1515 const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
1616
6565 const y = baseline - (value / top) * plot.height;
6666 return (
6767 <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"} />
6969 <text x={plot.x - 8} y={y + 3.5} textAnchor="end" fontSize="11" className="fill-faint tabular">
7070 {axisDollars(value)}
7171 </text>
+3−3
2121 <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))}`}>
2222 {p.ticks.map((t) => (
2323 <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"} />
2525 <text x={p.plot.x - 6} y={t.y + 3.5} textAnchor="end" fontSize="10" className="fill-faint tabular">
2626 {t.label}
2727 </text>
2929 ))}
3030 {p.slowY != null && (
3131 <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" />
3333 <text x={p.plot.x + p.plot.width} y={p.slowY - 3} textAnchor="end" fontSize="10" className="fill-warn">
3434 slow over {msWords(history.slow_ms!)}
3535 </text>
3838 {p.down.map((d, i) => (
3939 <rect key={`d${i}`} x={d.x - 1} y={p.plot.y} width="2" height={p.plot.height} className="fill-danger/35" />
4040 ))}
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" />
4242 {p.slow.map((s, i) => (
4343 <circle key={`s${i}`} cx={s.x} cy={s.y} r="2.5" className="fill-warn" />
4444 ))}
+1−1
2929 y={p.y * 10 + 1}
3030 width="8"
3131 height="8"
32− fill={p.one ? "var(--g1t-accent)" : "currentColor"}
32+ fill={p.one ? "var(--color-accent)" : "currentColor"}
3333 />
3434 ));
3535 }
+37−1
2727 type LucideIcon,
2828 Megaphone,
2929 Menu,
30+ Monitor,
31+ Moon,
3032 ScrollText,
3133 Server,
3234 ShieldAlert,
3335 ShieldCheck,
3436 Signpost,
37+ Sun,
3538 Siren,
3639 Tags,
3740 Ticket,
4245 X,
4346 Zap,
4447 } from "lucide-react";
45−import { Link, useLocation } from "react-router";
48+import { Link, useLocation, useRouteLoaderData } from "react-router";
4649
4750 import { Logo } from "~/components/logo";
4851 import { useZone } from "~/components/ui";
4952 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";
5054 import { zoneAbbr } from "~/lib/time";
5155
5256 const ICONS: Record<NavIcon, LucideIcon> = {
235239 );
236240 }
237241
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+
238273 /** Who is signed in: a panel of its own at the foot of the menu. */
239274 function SignedIn({ email }: { email: string | null | undefined }) {
240275 return (
247282 )}
248283 <p className="mt-1.5 text-[0.6875rem] leading-snug text-faint">g1t staff only. Every change is recorded with who made it.</p>
249284 <TimesIn />
285+ <ThemeSwitch />
250286 </div>
251287 );
252288 }
+5−5
7979 "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";
8080
8181 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",
8383 lavender: "bg-accent text-bg hover:bg-accent-hover",
8484 quiet: "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg",
8585 danger: "border border-danger/40 text-danger hover:border-danger/70 hover:bg-danger/10",
213213 }
214214
215215 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" },
219219 } as const;
220220
221221 export function StateBadge({ state }: { state: Limit["state"] }) {
241241 {percent != null && <span className={`tabular ${STATE[state].text}`}>{percent}%</span>}
242242 </div>
243243 <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)" />
245245 {ceilingMicros != null && share > 0 && (
246246 <rect x="0" y="0" width={Math.max(2, share * 100)} height="4" rx="2" fill={STATE[state].fill} />
247247 )}
+16−0
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+});
+73−0
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+}
+27−12
22 import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration, useRouteLoaderData } from "react-router";
33
44 import type { Route } from "./+types/root";
5−import "./app.css";
5+import appCss from "./app.css?url";
66 import displayFont from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2?url";
77 import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url";
88 import { Progress, useLeaving, useSettledForms } from "./components/progress";
1313 import { admin, statusAdmin } from "./lib/services.server";
1414 import { settle } from "./lib/settle";
1515 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";
2318
2419 export const meta: Route.MetaFunction = () => [
2520 { title: "sudo · g1t" },
2621 { name: "robots", content: "noindex, nofollow" },
2722 ];
2823
29−export async function loader({ context }: Route.LoaderArgs) {
24+export async function loader({ request, context }: Route.LoaderArgs) {
3025 const { email } = requireStaff(context);
3126 // The sidebar's counts: a service that does not answer shows none.
3227 const [incidents, alerts, caps, guard, erasures] = await Promise.all([
5045 // A platform pause (billing's platform.rs): red on every page while any
5146 // level is paused, by staff or by the usage watcher.
5247 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 };
5451 }
5552
5653 export function Layout({ children }: { children: React.ReactNode }) {
5754 const root = useRouteLoaderData<typeof loader>("root");
5855 const leaving = useLeaving();
5956 useSettledForms();
57+ const theme = useThemeChoice(root?.theme);
6058 return (
61− <html lang="en">
59+ <html lang="en" data-theme={themeAttribute(theme)}>
6260 <head>
6361 <meta charSet="utf-8" />
6462 <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+ )}
6672 <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" />
6782 <Links />
6883 </head>
6984 <body className="min-h-screen">

Comments

Sign in to comment.