Skip to content

Commit

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.

syntaqxcommitted Parente2212f1Browse files
43 files+571−1210/43 viewed
+32−3
556556 | **Workspace** | Usage, billing, integrations, policies and settings. |
557557
558558 A bar on the dock's edge marks the app you are in. Your account is the
559−avatar at the foot: your status, your profile and settings, the
560−documentation, support, status, the keyboard's shortcuts, and signing out.
559+avatar at the foot: your status, your profile and settings,
560+[appearance](#appearance), the documentation, support, status, the
561+keyboard's shortcuts, and signing out.
562+
563+### Appearance
564+
565+g1t comes in a dark theme and a light one. Until you choose, it is dark.
566+
567+| Choice | What you see |
568+| --- | --- |
569+| **Auto** | Follows your system's light or dark setting, and changes when it does. |
570+| **Light** | A near-white page with white panels and a deeper lavender accent. |
571+| **Dark** | g1t's very dark gray with the lavender accent. |
572+
573+To change it:
561574
575+1. Select your avatar at the foot of the dock. On a phone, open **More**,
576+ then **You and help**.
577+2. Under **Appearance**, select **Auto** (the screen icon), **Light** (the
578+ sun) or **Dark** (the moon).
579+
580+The page changes at once. You can also choose under
581+[Settings → Account](https://g1t.sh/settings/account), or press
582+<kbd>Ctrl</kbd> <kbd>K</kbd> (<kbd>⌘</kbd> <kbd>K</kbd> on a Mac) and run
583+**Use light theme**, **Use dark theme** or **Use auto theme**. Signed out,
584+the same commands are in ⌘K, and the phone's menu has the switch.
585+
586+Your choice is kept in this browser, in a `g1t_theme` cookie, so every page
587+is drawn in it from the start; another browser or device starts dark.
588+Product screenshots on g1t.sh's home page, and the g1t mark on agents'
589+avatars, stay dark in either theme.
590+
562591 ### Apps
563592
564593 Every other app in the workspace is a page you can pin to your dock:
653682 | | What it does |
654683 | --- | --- |
655684 | **The bar along the bottom** | **Today**, **Chat**, **Notifications**, **Agents** and **Code** (for members with Code access), each with what is unread, and **More**. It steps aside while the keyboard is up and inside a conversation. |
656−| **More** | A panel above the bar with **All apps**, the Marketplace (coming), your pinned apps, **Artifacts**, **People**, **Workspace**, and **You and help**: your status, profile and settings, the documentation, support, status, the keyboard's shortcuts and signing out. |
685+| **More** | A panel above the bar with **All apps**, the Marketplace (coming), your pinned apps, **Artifacts**, **People**, **Workspace**, and **You and help**: your status, profile and settings, [appearance](#appearance), the documentation, support, status, the keyboard's shortcuts and signing out. |
657686 | **The sidebar button**, at the left of the page's header | Opens the sidebar of the app you are in from the left: the same lists and links as on a computer. Tap outside it, or open a page, and it closes. |
658687 | **The tab you are already on** | Tap it again to open that app's sidebar too. |
659688 | **The workspace's name** in the page's header | Switches workspace, or opens your profile or settings. |
+76−53
1111 /* Hanken and Bricolage want less tightening than a text face set close. */
1212 --tracking-tight: -0.012em;
1313
14− --color-bg: var(--g1t-bg);
14+ /*
15+ * Every colour is a pair: light-dark() picks the light palette or the
16+ * dark one by the element's color-scheme, which follows the Appearance
17+ * setting (lib/theme.ts, set on <html> below). Something drawn dark on
18+ * purpose in either theme, such as a product screenshot, sets
19+ * `scheme-dark` and its tokens stay dark.
20+ */
21+ --color-bg: light-dark(var(--g1t-light-bg), var(--g1t-bg));
1522 /* Behind the page panel, where the sidebar sits flat (components/shell.tsx). */
16− --color-shell: #0b0b0d;
23+ --color-shell: light-dark(#ececea, #0b0b0d);
1724 /* The dock's own surface: a floating bar a step above the shell. */
18− --color-dock: #15151a;
19− --color-surface: var(--g1t-surface);
20− --color-raised: var(--g1t-raised);
21− --color-line: var(--g1t-line);
22− --color-line-strong: var(--g1t-line-strong);
25+ --color-dock: light-dark(#ffffff, #15151a);
26+ --color-surface: light-dark(var(--g1t-light-surface), var(--g1t-surface));
27+ --color-raised: light-dark(var(--g1t-light-raised), var(--g1t-raised));
28+ --color-line: light-dark(var(--g1t-light-line), var(--g1t-line));
29+ --color-line-strong: light-dark(var(--g1t-light-line-strong), var(--g1t-line-strong));
2330
24− --color-fg: var(--g1t-fg);
25− --color-fg-soft: var(--g1t-fg-soft);
26− --color-muted: var(--g1t-muted);
27− --color-faint: var(--g1t-faint);
31+ --color-fg: light-dark(var(--g1t-light-fg), var(--g1t-fg));
32+ --color-fg-soft: light-dark(var(--g1t-light-fg-soft), var(--g1t-fg-soft));
33+ /* A primary button's hover: brighter than fg on dark, softer on light. */
34+ --color-fg-hover: light-dark(#3a3a41, #ffffff);
35+ --color-muted: light-dark(var(--g1t-light-muted), var(--g1t-muted));
36+ --color-faint: light-dark(var(--g1t-light-faint), var(--g1t-faint));
2837
29− --color-accent: var(--g1t-accent);
30− --color-accent-hover: var(--g1t-accent-hover);
31− --color-accent-dim: var(--g1t-accent-dim);
38+ --color-accent: light-dark(var(--g1t-light-accent), var(--g1t-accent));
39+ --color-accent-hover: light-dark(var(--g1t-light-accent-hover), var(--g1t-accent-hover));
40+ --color-accent-dim: light-dark(var(--g1t-light-accent-dim), var(--g1t-accent-dim));
41+ --color-accent-low: light-dark(var(--g1t-light-accent-low), var(--g1t-accent-low));
42+ /* The accent at its palest on dark, a middle lavender on light: the headline's gradient. */
43+ --color-accent-high: light-dark(#8b76e0, #d9d1ff);
3244
33− --color-success: var(--g1t-success);
34− --color-success-dim: var(--g1t-success-dim);
45+ --color-success: light-dark(var(--g1t-light-success), var(--g1t-success));
46+ --color-success-dim: light-dark(var(--g1t-light-success-dim), var(--g1t-success-dim));
47+ --color-success-low: light-dark(var(--g1t-light-success-low), var(--g1t-success-low));
3548
36− --color-info: var(--g1t-info);
37− --color-merged: var(--g1t-merged);
38− --color-warn: var(--g1t-warn);
39− --color-danger: var(--g1t-danger);
49+ --color-info: light-dark(var(--g1t-light-info), var(--g1t-info));
50+ --color-merged: light-dark(var(--g1t-light-merged), var(--g1t-merged));
51+ --color-warn: light-dark(var(--g1t-light-warn), var(--g1t-warn));
52+ --color-danger: light-dark(var(--g1t-light-danger), var(--g1t-danger));
4053
4154 --animate-fade-up: fade-up 0.6s ease-out both;
4255 --animate-dash: dash 1.2s linear infinite;
150163 }
151164 }
152165
166+/*
167+ * Appearance (lib/theme.ts): with no choice, or Auto, the page follows
168+ * the system; a choice of Light or Dark is data-theme on <html>.
169+ */
153170 html {
154− color-scheme: dark;
171+ color-scheme: light dark;
155172 scroll-behavior: smooth;
156173 /* A tap shows the control's own active state, not the browser's grey flash. */
157174 -webkit-tap-highlight-color: transparent;
159176 -webkit-text-size-adjust: 100%;
160177 text-size-adjust: 100%;
161178 }
179+html[data-theme="light"] {
180+ color-scheme: light;
181+}
182+html[data-theme="dark"] {
183+ color-scheme: dark;
184+}
162185
163186 /*
164187 * The app's frame (components/shell.tsx), set on <body data-frame="app">:
731754 @source "../../../node_modules/@blocknote/shadcn/dist";
732755
733756 @theme {
734− --color-background: var(--g1t-bg);
735− --color-foreground: var(--g1t-fg);
736− --color-popover: var(--g1t-surface);
737− --color-popover-foreground: var(--g1t-fg);
738− --color-card: var(--g1t-surface);
739− --color-card-foreground: var(--g1t-fg);
740− --color-primary: var(--g1t-accent);
741− --color-primary-foreground: var(--g1t-bg);
742− --color-secondary: var(--g1t-raised);
743− --color-secondary-foreground: var(--g1t-fg);
744− --color-muted-foreground: var(--g1t-muted);
745− --color-accent-foreground: var(--g1t-fg);
746− --color-destructive: var(--g1t-danger);
747− --color-destructive-foreground: var(--g1t-bg);
748− --color-border: var(--g1t-line);
749− --color-input: var(--g1t-line);
750− --color-ring: var(--g1t-accent);
757+ --color-background: var(--color-bg);
758+ --color-foreground: var(--color-fg);
759+ --color-popover: var(--color-surface);
760+ --color-popover-foreground: var(--color-fg);
761+ --color-card: var(--color-surface);
762+ --color-card-foreground: var(--color-fg);
763+ --color-primary: var(--color-accent);
764+ --color-primary-foreground: var(--color-bg);
765+ --color-secondary: var(--color-raised);
766+ --color-secondary-foreground: var(--color-fg);
767+ --color-muted-foreground: var(--color-muted);
768+ --color-accent-foreground: var(--color-fg);
769+ --color-destructive: var(--color-danger);
770+ --color-destructive-foreground: var(--color-bg);
771+ --color-border: var(--color-line);
772+ --color-input: var(--color-line);
773+ --color-ring: var(--color-accent);
751774 }
752775
753776 .bn-shadcn {
754− --color-accent: var(--g1t-raised);
755− --color-muted: var(--g1t-raised);
777+ --color-accent: var(--color-raised);
778+ --color-muted: var(--color-raised);
756779 --bn-colors-editor-background: transparent;
757− --bn-colors-editor-text: var(--g1t-fg);
758− --bn-colors-menu-background: var(--g1t-surface);
759− --bn-colors-menu-text: var(--g1t-fg);
760− --bn-colors-hovered-background: var(--g1t-raised);
761− --bn-colors-side-menu: var(--g1t-faint);
762− --bn-colors-border: var(--g1t-line);
780+ --bn-colors-editor-text: var(--color-fg);
781+ --bn-colors-menu-background: var(--color-surface);
782+ --bn-colors-menu-text: var(--color-fg);
783+ --bn-colors-hovered-background: var(--color-raised);
784+ --bn-colors-side-menu: var(--color-faint);
785+ --bn-colors-border: var(--color-line);
763786 --bn-border-radius: 8px;
764787 }
765788
768791 font-size: 1rem;
769792 line-height: 1.65;
770793 background: transparent;
771− color: var(--g1t-fg);
794+ color: var(--color-fg);
772795 }
773796
774797 /* The drag handle and + sit in the margin, so the text lines up with the title. */
790813 font-size: 0.875em;
791814 padding: 0.1em 0.35em;
792815 border-radius: 4px;
793− background: var(--g1t-raised);
816+ background: var(--color-raised);
794817 }
795818
796819 .g1t-editor .bn-block-content[data-content-type="codeBlock"] {
797820 border-radius: 10px;
798− background: var(--g1t-surface);
799− border: 1px solid var(--g1t-line);
821+ background: var(--color-surface);
822+ border: 1px solid var(--color-line);
800823 }
801824
802825 .g1t-editor .bn-thread-mark {
803− background: color-mix(in srgb, var(--g1t-warn) 22%, transparent);
804− border-bottom: 2px solid color-mix(in srgb, var(--g1t-warn) 60%, transparent);
826+ background: color-mix(in srgb, var(--color-warn) 22%, transparent);
827+ border-bottom: 2px solid color-mix(in srgb, var(--color-warn) 60%, transparent);
805828 }
806829
807830 .g1t-editor a {
808− color: var(--g1t-accent);
831+ color: var(--color-accent);
809832 }
810833
811834 /* A page read before the editor loads: the same measure as the editor. */
+1−1
135135 {said.notStarted.fix && (
136136 <Link
137137 to={said.notStarted.fix.to}
138− className="inline-flex items-center gap-1 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg hover:bg-white"
138+ className="inline-flex items-center gap-1 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg hover:bg-fg-hover"
139139 >
140140 {said.notStarted.fix.label} <ArrowUpRight size={12} />
141141 </Link>
+1−1
200200 <SubmitButton
201201 fetcher={fetcher}
202202 pending="Sending…"
203− className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
203+ className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover disabled:opacity-50"
204204 >
205205 Send
206206 </SubmitButton>
+1−1
3636 <button
3737 type="button"
3838 onClick={() => choose("yes")}
39− className="flex-1 rounded-lg bg-fg px-3 py-2 font-medium text-bg transition-colors hover:bg-white"
39+ className="flex-1 rounded-lg bg-fg px-3 py-2 font-medium text-bg transition-colors hover:bg-fg-hover"
4040 >
4141 Allow
4242 </button>
+3−3
330330 <Form method="post" className="mt-3 space-y-3 text-sm">
331331 <input type="hidden" name="intent" value="ai-reload" />
332332 <label className="flex items-center gap-2">
333− <input type="checkbox" name="enabled" defaultChecked={r.enabled} className="accent-[var(--g1t-accent)]" />
333+ <input type="checkbox" name="enabled" defaultChecked={r.enabled} className="accent-[var(--color-accent)]" />
334334 Reload from the saved card
335335 </label>
336336 <div className="flex flex-wrap items-center gap-2 text-muted">
388388 <legend className="mb-1.5 text-xs text-muted">Alert at</legend>
389389 {[50, 75, 90, 100].map((level) => (
390390 <label key={level} className="flex items-center gap-1.5">
391− <input type="checkbox" name="alert" value={String(level)} defaultChecked={levels.includes(level)} className="accent-[var(--g1t-accent)]" />
391+ <input type="checkbox" name="alert" value={String(level)} defaultChecked={levels.includes(level)} className="accent-[var(--color-accent)]" />
392392 {level}%
393393 </label>
394394 ))}
395395 </fieldset>
396396 <label className="flex items-center gap-2">
397− <input type="checkbox" name="pause" defaultChecked={pause} className="accent-[var(--g1t-accent)]" />
397+ <input type="checkbox" name="pause" defaultChecked={pause} className="accent-[var(--color-accent)]" />
398398 Pause usage at 100%
399399 <span className="text-xs text-faint">Off, the budget only alerts; g1t's own limit still applies.</span>
400400 </label>
+1−1
163163 key={toast.id}
164164 role={toast.ok ? "status" : "alert"}
165165 className={cn(
166− "pointer-events-auto flex w-full animate-pop-in items-start gap-2.5 rounded-xl bg-[#17171b] py-2.5 pr-2 pl-3 text-[0.8125rem] leading-snug text-fg shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 motion-reduce:animate-none",
166+ "pointer-events-auto flex w-full animate-pop-in items-start gap-2.5 rounded-xl bg-surface py-2.5 pr-2 pl-3 text-[0.8125rem] leading-snug text-fg shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 motion-reduce:animate-none",
167167 toast.ok ? "ring-success/35" : "ring-danger/45",
168168 )}
169169 >
+2−3
182182 if (!language || code.length > 20_000) return;
183183 let cancelled = false;
184184 void import("../../lib/shiki")
185− .then(async ({ THEME, getHighlighter, languageNamed }) => {
185+ .then(async ({ getHighlighter, languageNamed, tokenRows }) => {
186186 const lang = languageNamed(language);
187187 if (!lang) return null;
188− const core = await getHighlighter();
189− return core.codeToTokens(code, { lang, theme: THEME }).tokens.map((row) => row.map((token) => ({ content: token.content, color: token.color })));
188+ return tokenRows(await getHighlighter(), code, lang);
190189 })
191190 .then((tokens) => {
192191 if (!cancelled && tokens) setRows(tokens);
+27−7
7777 else navigate(to);
7878 };
7979 const words = trimmed.toLowerCase().split(/\s+/).filter(Boolean);
80− const pages = commands
81− .filter((command) => {
82− const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
83− return words.every((word) => text.includes(word));
84− })
85− .slice(0, trimmed ? 8 : 12);
80+ const matching = commands.filter((command) => {
81+ const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
82+ return words.every((word) => text.includes(word));
83+ });
84+ const pages = matching.filter((command) => command.to != null).slice(0, trimmed ? 8 : 12);
85+ // Things done in place, such as switching the theme, once something is typed.
86+ const actions = trimmed ? matching.filter((command) => command.run != null).slice(0, 6) : [];
8687 const itemClass = "gap-3 rounded-lg px-3 py-2";
8788
8889 return (
165166 <CommandItem
166167 key={`${command.to}-${command.label}`}
167168 value={`page:${command.to}:${command.label}`}
168− onSelect={() => go(command.to)}
169+ onSelect={() => go(command.to!)}
170+ className={itemClass}
171+ >
172+ <span className="shrink-0 text-faint">{command.icon}</span>
173+ <span className="min-w-0 grow truncate">{command.label}</span>
174+ {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
175+ </CommandItem>
176+ ))}
177+ </CommandGroup>
178+ )}
179+ {actions.length > 0 && (
180+ <CommandGroup heading="Commands">
181+ {actions.map((command) => (
182+ <CommandItem
183+ key={`run-${command.label}`}
184+ value={`run:${command.label}`}
185+ onSelect={() => {
186+ onOpenChange(false);
187+ command.run!();
188+ }}
169189 className={itemClass}
170190 >
171191 <span className="shrink-0 text-faint">{command.icon}</span>
+5−2
1010 */
1111 const Dialog = lazy(() => import("./command-palette-dialog"));
1212
13−/** A page or action the palette can jump to. */
14−export type PaletteCommand = { label: string; hint?: string; to: string; icon: ReactNode };
13+/**
14+ * A page the palette can jump to (`to`), or something it does in place
15+ * (`run`, listed under Commands), such as switching the theme.
16+ */
17+export type PaletteCommand = { label: string; hint?: string; icon: ReactNode } & ({ to: string; run?: undefined } | { run: () => void; to?: undefined });
1518
1619 /** Opens and closes the palette on ⌘K or Ctrl-K, from anywhere on the page. */
1720 export function usePaletteShortcut(toggle: () => void) {
+2−2
199199 <button
200200 type="button"
201201 onClick={() => fetcher.submit({ intent: "keep", id: memory.id }, { method: "post", action })}
202− className="inline-flex items-center gap-1 rounded-md bg-fg px-2 py-1 text-xs font-medium text-bg hover:bg-white"
202+ className="inline-flex items-center gap-1 rounded-md bg-fg px-2 py-1 text-xs font-medium text-bg hover:bg-fg-hover"
203203 >
204204 <Check size={12} />
205205 Keep
457457 className="w-full rounded-md border border-line bg-bg py-2 pr-3 pl-9 text-sm outline-none placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
458458 />
459459 </label>
460− <button type="submit" className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white">
460+ <button type="submit" className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover">
461461 {searching ? "Searching…" : "Search"}
462462 </button>
463463 </Form>
+8−5
1616 import { useEffect, useId, useState, type ReactNode } from "react";
1717
1818 import { citationHref } from "../../../lib/folios";
19+import { useDrawnTheme } from "../../../lib/theme";
1920
2021 /** What an embed shows, from the site (`-/artifacts/api?embed=`). */
2122 export type EmbedCard = { kind: "issue" | "pull" | "channel" | "project" | "page" | "link"; title: string; subtitle: string | null; state: string | null; href: string };
6566 const id = useId().replace(/[^a-zA-Z0-9]/g, "");
6667 const [svg, setSvg] = useState<string | null>(null);
6768 const [error, setError] = useState<string | null>(null);
69+ // Drawn again in the other theme's colours when the page switches.
70+ const theme = useDrawnTheme();
6871 useEffect(() => {
6972 let cancelled = false;
7073 const timer = setTimeout(async () => {
7174 try {
7275 const mermaid = (await import("mermaid")).default;
73− mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict", fontFamily: "inherit" });
76+ mermaid.initialize({ startOnLoad: false, theme: theme === "light" ? "default" : "dark", securityLevel: "strict", fontFamily: "inherit" });
7477 const { svg } = await mermaid.render(`mermaid-${id}-${Date.now()}`, code || "flowchart LR\n A --> B");
7578 if (!cancelled) {
7679 setSvg(svg);
8487 cancelled = true;
8588 clearTimeout(timer);
8689 };
87− }, [code, id]);
90+ }, [code, id, theme]);
8891 if (error) return <p className="text-xs text-danger">{error}</p>;
8992 if (!svg) return <p className="text-xs text-faint">Drawing…</p>;
9093 // Mermaid sanitizes what it draws (securityLevel "strict").
104107 onChange={(e) => setDraft(e.target.value)}
105108 onBlur={() => draft !== value && onChange(draft)}
106109 onKeyDown={(e) => e.stopPropagation()}
107− className={`w-full resize-y rounded-md border border-line bg-bg px-2.5 py-2 text-[0.8125rem] leading-relaxed text-fg outline-none focus:border-[var(--g1t-accent)] ${mono ? "font-mono" : ""}`}
110+ className={`w-full resize-y rounded-md border border-line bg-bg px-2.5 py-2 text-[0.8125rem] leading-relaxed text-fg outline-none focus:border-[var(--color-accent)] ${mono ? "font-mono" : ""}`}
108111 />
109112 );
110113 }
262265 const p = inlineContent.props;
263266 if (p.kind === "page") {
264267 return (
265− <a href={p.href || "#"} className="rounded px-0.5 font-medium text-[var(--g1t-accent)] no-underline hover:underline">
268+ <a href={p.href || "#"} className="rounded px-0.5 font-medium text-[var(--color-accent)] no-underline hover:underline">
266269 <FileText size={13} className="mr-0.5 inline -translate-y-px" aria-hidden="true" />
267270 {p.name || "Untitled"}
268271 </a>
269272 );
270273 }
271− return <span className={`rounded px-1 py-px font-medium ${p.kind === "agent" ? "bg-[color-mix(in_srgb,var(--g1t-accent)_15%,transparent)] text-[var(--g1t-accent)]" : "bg-info/12 text-info"}`}>@{p.name}</span>;
274+ return <span className={`rounded px-1 py-px font-medium ${p.kind === "agent" ? "bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-[var(--color-accent)]" : "bg-info/12 text-info"}`}>@{p.name}</span>;
272275 },
273276 },
274277 );
+4−1
1818 import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1919
2020 import { canDo, cursorColour } from "../../../lib/folios";
21+import { useDrawnTheme } from "../../../lib/theme";
2122 import { schema, type DocEditorInstance } from "./blocks";
2223 import { CiteDialog } from "./code";
2324 import { EditorSkeleton } from "./editor-skeleton";
114115
115116 function LiveEditor({ slug, folioId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, projects, provider }: DocEditorProps & { provider: FolioProvider }) {
116117 const editable = canDo(role, "edit");
118+ // BlockNote's own parts follow the page's Appearance (lib/theme.ts).
119+ const theme = useDrawnTheme();
117120 const [citing, setCiting] = useState(false);
118121 const colour = cursorColour(me.name);
119122 // Below 1280px the comments sit under the page: opening them goes there.
308311 {struck && (
309312 <style>{`${struck} { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--g1t-danger) 70%, transparent); background: color-mix(in srgb, var(--g1t-danger) 8%, transparent); border-radius: 4px; }`}</style>
310313 )}
311− <BlockNoteView editor={editor as never} editable={editable} theme="dark" renderEditor={false} slashMenu={false} comments={canDo(role, "view")} className="g1t-bn">
314+ <BlockNoteView editor={editor as never} editable={editable} theme={theme} renderEditor={false} slashMenu={false} comments={canDo(role, "view")} className="g1t-bn">
312315 <div className={`grid gap-8 ${showComments ? "xl:grid-cols-[minmax(0,1fr)_18rem]" : ""}`}>
313316 <div className="relative min-w-0">
314317 <BlockNoteViewEditor />
+2−2
351351 <div className="relative mx-auto max-w-6xl px-4 pt-20 text-center sm:pt-24">
352352 <Link
353353 to={`${DOCS}/guides/chat/`}
354− className="inline-flex animate-fade-up items-center gap-2 rounded-full bg-bg/50 px-3 py-1 text-xs text-fg-soft ring-1 ring-white/10 backdrop-blur transition-colors hover:bg-bg/70"
354+ className="inline-flex animate-fade-up items-center gap-2 rounded-full bg-bg/50 px-3 py-1 text-xs text-fg-soft ring-1 ring-line backdrop-blur transition-colors hover:bg-bg/70"
355355 >
356356 <span className="size-1.5 shrink-0 rounded-full bg-accent" />
357357 For engineering teams running AI coding agents: chat, agents, docs and code in one workspace
359359 </Link>
360360 <h1 className="mx-auto mt-7 max-w-4xl animate-fade-up text-[2.75rem] leading-[1.04] font-semibold tracking-tight text-balance sm:text-7xl">
361361 Your team and its agents,{" "}
362− <span className="bg-gradient-to-r from-fg via-[#d9d1ff] to-accent bg-clip-text text-transparent">
362+ <span className="bg-gradient-to-r from-fg via-accent-high to-accent bg-clip-text text-transparent">
363363 working in one place.
364364 </span>
365365 </h1>
+1−1
2828 y={p.y * 10 + 1}
2929 width="8"
3030 height="8"
31− fill={p.one ? "var(--g1t-accent)" : "currentColor"}
31+ fill={p.one ? "var(--color-accent)" : "currentColor"}
3232 />
3333 ));
3434 }
+3−3
270270 <Link
271271 to={row.to}
272272 prefetch="intent"
273− className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg transition-colors hover:bg-white"
273+ className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg transition-colors hover:bg-fg-hover"
274274 >
275275 Review and respond <ArrowUpRight size={14} />
276276 </Link>
654654 {!step.done && step.to && (
655655 <Link
656656 to={step.to}
657− className="shrink-0 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg transition-colors hover:bg-white"
657+ className="shrink-0 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg transition-colors hover:bg-fg-hover"
658658 >
659659 {step.action}
660660 </Link>
10311031 action={
10321032 <Link
10331033 to={workspace ? `/new?workspace=${workspace}` : "/new"}
1034− className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-white"
1034+ className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-fg-hover"
10351035 >
10361036 Create or import a project
10371037 </Link>
+4−0
2727 import { Avatar } from "./ui";
2828 import { TabStrip } from "./ui/tab-strip";
2929 import { StatusDialog } from "./presence";
30+import { ThemeSwitch } from "./theme-switch";
3031 import { type AppKey, type PinnableApp, appOf } from "../lib/apps";
3132 import { setPresence, useOwnPresence } from "../lib/notify-client";
3233 import { dndOn, liveStatus, pauseUntil, untilLabel } from "../lib/presence";
464465 Your settings
465466 </SheetRow>
466467 </SheetGroup>
468+ <SheetGroup title="Appearance">
469+ <ThemeSwitch size="large" className="mx-3 mt-1 mb-1.5" />
470+ </SheetGroup>
467471 <SheetGroup title="Help">
468472 <SheetRow href="https://docs.g1t.sh/" icon={<BookOpen />}>
469473 Documentation
+3−3
119119 return (
120120 <div
121121 role="status"
122− className="g1t-toast group/toast pointer-events-auto relative overflow-hidden rounded-xl bg-[#17171b] shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 ring-line-strong"
122+ className="g1t-toast group/toast pointer-events-auto relative overflow-hidden rounded-xl bg-surface shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 ring-line-strong"
123123 >
124124 {loud && <span aria-hidden="true" className="absolute inset-y-0 left-0 w-[3px] bg-accent" />}
125125 <button type="button" onClick={open} className="flex w-full items-start gap-3 p-3 pr-9 text-left outline-none focus-visible:bg-raised/60">
163163 function PushOffer() {
164164 const [busy, setBusy] = useState(false);
165165 return (
166− <div className="g1t-toast pointer-events-auto flex items-center gap-3 rounded-xl bg-[#17171b] p-3 shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 ring-accent/35">
166+ <div className="g1t-toast pointer-events-auto flex items-center gap-3 rounded-xl bg-surface p-3 shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 ring-accent/35">
167167 <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/15 text-accent">
168168 <BellRing size={16} />
169169 </span>
254254 </div>
255255 )}
256256 {folded.length > 0 && (
257− <div className="pointer-events-auto flex h-[30px] items-center justify-between gap-2 self-center rounded-full bg-[#17171b] pr-1 pl-3 text-xs text-muted shadow-[0_0_0_6px_var(--color-bg),0_10px_30px_-10px_rgba(0,0,0,0.9)] ring-1 ring-line-strong">
257+ <div className="pointer-events-auto flex h-[30px] items-center justify-between gap-2 self-center rounded-full bg-surface pr-1 pl-3 text-xs text-muted shadow-[0_0_0_6px_var(--color-bg),0_10px_30px_-10px_rgba(0,0,0,0.9)] ring-1 ring-line-strong">
258258 <span className="font-medium text-fg tabular-nums">+{folded.length} more</span>
259259 <button
260260 type="button"
+1−1
1515 return (
1616 <span
1717 aria-hidden="true"
18− className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] ring-1 ring-line-strong ring-inset"
18+ className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark"
1919 style={{ width: size, height: size, borderRadius: Math.round(size * 0.26) }}
2020 >
2121 <Mark className="size-full" />
+2−2
12371237 docs: <DocsMain frame={frame} />,
12381238 };
12391239 return (
1240− <div ref={outer} className="relative w-full overflow-hidden rounded-2xl shadow-2xl shadow-black/50 ring-1 ring-line" style={{ aspectRatio: `${W} / ${H}` }}>
1240+ <div ref={outer} className="relative w-full overflow-hidden rounded-2xl text-fg shadow-2xl shadow-black/50 ring-1 ring-line scheme-dark" style={{ aspectRatio: `${W} / ${H}` }}>
12411241 <div
12421242 ref={canvas}
12431243 className="absolute top-0 left-0 flex origin-top-left bg-bg text-left"
12961296 function Phone({ frame }: { frame: Frame }) {
12971297 const scenes: Scene[] = ["code", "chat", "agents", "docs"];
12981298 return (
1299− <div className="relative flex h-[34rem] w-full flex-col overflow-hidden rounded-2xl bg-bg text-left shadow-2xl shadow-black/50 ring-1 ring-line">
1299+ <div className="relative flex h-[34rem] w-full flex-col overflow-hidden rounded-2xl bg-bg text-left text-fg shadow-2xl shadow-black/50 ring-1 ring-line scheme-dark">
13001300 <div className="flex h-12 shrink-0 items-center gap-2 border-b border-line px-3">
13011301 <Avatar name="acme" size={26} square />
13021302 <span key={frame.scene} className="tour-fade flex min-w-0 items-center gap-1.5 text-[13px] font-semibold text-fg">
+9−1
1212 import type { User } from "@g1t/contracts";
1313
1414 import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette";
15+import { THEME_COMMANDS, ThemeMenuSwitch } from "./theme-switch";
1516 import { Logo } from "./logo";
1617 import { Avatar, ButtonLink, notACredential } from "./ui";
1718 import {
9394 <CommandPalette
9495 open={palette}
9596 onOpenChange={setPalette}
96− commands={user ? PUBLIC_COMMANDS.slice(0, 4) : PUBLIC_COMMANDS.map((command) => (command.to === "/register" ? { ...command, label: signUp.primary } : command))}
97+ commands={[
98+ ...(user ? PUBLIC_COMMANDS.slice(0, 4) : PUBLIC_COMMANDS.map((command) => (command.to === "/register" ? { ...command, label: signUp.primary } : command))),
99+ ...THEME_COMMANDS,
100+ ]}
97101 repo={repo}
98102 />
99103 <nav aria-label="Main" className="hidden items-center gap-0.5 sm:flex">
122126 Docs
123127 </Link>
124128 </DropdownMenuItem>
129+ <DropdownMenuSeparator />
130+ <ThemeMenuSwitch className="px-2" />
125131 {!user && (
126132 <>
127133 <DropdownMenuSeparator />
166172 </Link>
167173 </DropdownMenuItem>
168174 <DropdownMenuSeparator />
175+ <ThemeMenuSwitch className="px-2" />
176+ <DropdownMenuSeparator />
169177 {/* Submitted from here: the menu closes on select, and a button
170178 that has left the page cannot submit a form. */}
171179 <DropdownMenuItem
+1−1
3737 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
3838 const SMALL_BUTTON =
3939 "rounded-md border border-line px-2.5 py-1 text-xs font-medium text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-50";
40−const PRIMARY = "rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg transition-colors hover:bg-white disabled:opacity-50";
40+const PRIMARY = "rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg transition-colors hover:bg-fg-hover disabled:opacity-50";
4141
4242 /** A section's title, what it is for, and what can be done there. */
4343 export function SectionHeader({ title, about, actions }: { title: string; about: ReactNode; actions?: ReactNode }) {
+1−1
217217 <button
218218 type="submit"
219219 disabled={fetcher.state !== "idle" || !reason}
220− className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
220+ className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover disabled:opacity-50"
221221 >
222222 {fetcher.state !== "idle" ? "Dismissing…" : "Dismiss alert"}
223223 </button>
+5−0
4545 import type { AccountMenuData } from "../routes/settings-menu-json";
4646 import { useLiveBadges } from "../lib/notify-client";
4747 import { OwnPresenceDot, OwnPresenceItems, StatusDialog } from "./presence";
48+import { THEME_COMMANDS, ThemeMenuSwitch } from "./theme-switch";
4849
4950 /**
5051 * What the sidebar needs, worked out by the root loader. For a visitor who
410411 )}
411412 </DropdownMenuGroup>
412413 <DropdownMenuSeparator className="my-1.5" />
414+ {/* Auto, Light or Dark, switched in place (lib/theme.ts). */}
415+ <ThemeMenuSwitch />
416+ <DropdownMenuSeparator className="my-1.5" />
413417 <DropdownMenuGroup>
414418 <DropdownMenuItem asChild className={MENU_ROW}>
415419 <a href="https://docs.g1t.sh/">
15611565 ...(Object.keys(ACCOUNT_SETTINGS) as AccountSettingsPage[])
15621566 .filter((page) => page !== "profile")
15631567 .map((page) => ({ label: ACCOUNT_SETTINGS[page].title, hint: "Your settings", to: `/settings/${page}`, icon: <Settings size={15} /> })),
1568+ ...THEME_COMMANDS,
15641569 ];
15651570 const repo = shell.repo;
15661571 if (repo) {
+103−0
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+}
+2−2
118118 "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium transition-colors disabled:opacity-50";
119119
120120 const BUTTON_VARIANTS: Record<Variant, string> = {
121− primary: "bg-fg text-bg hover:bg-white",
121+ primary: "bg-fg text-bg hover:bg-fg-hover",
122122 accent: "bg-accent text-bg hover:bg-accent-hover",
123123 quiet:
124124 "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg",
315315 return (
316316 <span
317317 aria-hidden="true"
318− className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset"
318+ className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark"
319319 style={{ width: size, height: size, borderRadius: size * 0.24 }}
320320 >
321321 <Mark className="size-full" />
+3−3
6969 {options.map((option) => (
7070 <li key={option.value}>
7171 <label className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-surface">
72− <input type="checkbox" name={name} value={option.value} defaultChecked={chosen.includes(option.value)} className="accent-[var(--g1t-accent)]" />
72+ <input type="checkbox" name={name} value={option.value} defaultChecked={chosen.includes(option.value)} className="accent-[var(--color-accent)]" />
7373 <span className="truncate">{option.label}</span>
7474 </label>
7575 </li>
7676 ))}
7777 </ul>
7878 )}
79− <button type="submit" className="mt-2 w-full rounded-md bg-fg px-2 py-1.5 text-sm font-medium text-bg hover:bg-white">
79+ <button type="submit" className="mt-2 w-full rounded-md bg-fg px-2 py-1.5 text-sm font-medium text-bg hover:bg-fg-hover">
8080 Apply
8181 </button>
8282 </div>
374374 />
375375 ))}
376376 </div>
377− <div className="absolute inset-y-0 left-1/2 -z-0 w-full -translate-x-1/2 rounded group-hover:bg-white/[0.03] group-focus-visible:bg-white/[0.05]" aria-hidden="true" />
377+ <div className="absolute inset-y-0 left-1/2 -z-0 w-full -translate-x-1/2 rounded group-hover:bg-fg/[0.04] group-focus-visible:bg-fg/[0.06]" aria-hidden="true" />
378378 <div
379379 className={cn(
380380 "pointer-events-none absolute bottom-full z-10 mb-2 hidden w-52 rounded-lg border border-line-strong bg-raised p-2.5 text-xs shadow-xl group-hover:block group-focus-visible:block",
+4−3
5454 const hue = LABEL_HUES[name] ?? hueFor(name);
5555 const own = chipStyle(color);
5656 const style: CSSProperties = Object.keys(own).length > 0 ? own : {
57− color: `oklch(0.84 0.11 ${hue})`,
58− borderColor: `oklch(0.84 0.11 ${hue} / 0.35)`,
59− backgroundColor: `oklch(0.84 0.11 ${hue} / 0.1)`,
57+ // Light text on a dark page, deep text on a light one (lib/theme.ts).
58+ color: `light-dark(oklch(0.46 0.13 ${hue}), oklch(0.84 0.11 ${hue}))`,
59+ borderColor: `light-dark(oklch(0.55 0.13 ${hue} / 0.35), oklch(0.84 0.11 ${hue} / 0.35))`,
60+ backgroundColor: `light-dark(oklch(0.55 0.13 ${hue} / 0.08), oklch(0.84 0.11 ${hue} / 0.1))`,
6061 };
6162 return (
6263 <span
+1−1
6969 },
7070 account: {
7171 title: "Account",
72− about: "Your username, and deleting your account.",
72+ about: "Your username, how g1t looks to you, and deleting your account.",
7373 },
7474 };
7575
+1−1
2323 * once. Bump `HIGHLIGHT_VERSION` when the theme, the grammars, Shiki or
2424 * `linesToHtml` change what they make.
2525 */
26−export const HIGHLIGHT_VERSION = "v1";
26+export const HIGHLIGHT_VERSION = "v2";
2727
2828 function dataCentre(): SharedCache | null {
2929 try {
+24−4
3434 dockerfile: "docker",
3535 };
3636
37−export const THEME = "vitesse-dark";
37+/**
38+ * Code is coloured for both themes at once: each token's colour is
39+ * `light-dark(light, dark)`, so the page's Appearance (lib/theme.ts)
40+ * picks one with no second pass, and highlighted HTML kept in a cache is
41+ * right in either.
42+ */
43+export const THEMES = { light: "vitesse-light", dark: "vitesse-dark" } as const;
3844
3945 /** The language of a file, from its name, if it is one g1t highlights. */
4046 export function languageOf(path: string): string | null {
5359
5460 export function getHighlighter(): Promise<HighlighterCore> {
5561 highlighter ??= createHighlighterCore({
56− themes: [import("shiki/themes/vitesse-dark.mjs")],
62+ themes: [import("shiki/themes/vitesse-light.mjs"), import("shiki/themes/vitesse-dark.mjs")],
5763 langs: [
5864 import("shiki/langs/rust.mjs"),
5965 import("shiki/langs/typescript.mjs"),
8288 return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
8389 }
8490
91+/** A piece of a line and its colour, a `light-dark()` pair, if it has one. */
92+export type CodeToken = { content: string; color?: string };
93+
94+/** Each line of `text` as coloured tokens, for both themes. */
95+export function tokenRows(core: HighlighterCore, text: string, lang: string): CodeToken[][] {
96+ return core.codeToTokensWithThemes(text, { lang, themes: THEMES }).map((row) =>
97+ row.map((token) => {
98+ const light = token.variants.light?.color;
99+ const dark = token.variants.dark?.color;
100+ const color = light && dark ? `light-dark(${light},${dark})` : (light ?? dark);
101+ return color ? { content: token.content, color } : { content: token.content };
102+ }),
103+ );
104+}
105+
85106 /** Each line of `text` as HTML, coloured. */
86107 export function linesToHtml(core: HighlighterCore, text: string, lang: string): string[] {
87− const { tokens } = core.codeToTokens(text, { lang, theme: THEME });
88− return tokens.map((row) =>
108+ return tokenRows(core, text, lang).map((row) =>
89109 row
90110 .map((token) =>
91111 token.color ? `<span style="color:${token.color}">${escape(token.content)}</span>` : escape(token.content),
+26−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { DEFAULT_THEME, THEME_CHOICES, THEME_COOKIE, readTheme, resolvedTheme, themeAttribute, themeCookie } from "./theme.ts";
5+
6+test("the cookie keeps auto, light or dark, and anything else is the default", () => {
7+ for (const choice of THEME_CHOICES) assert.equal(readTheme(choice), choice);
8+ for (const junk of [null, undefined, "", "LIGHT", "sepia", "dark; Path=/"]) assert.equal(readTheme(junk), DEFAULT_THEME);
9+});
10+
11+test("the cookie is kept a year, on every path, and secure on https", () => {
12+ assert.equal(themeCookie("light", true), `${THEME_COOKIE}=light; Path=/; Max-Age=31536000; SameSite=Lax; Secure`);
13+ assert.equal(themeCookie("auto", false), `${THEME_COOKIE}=auto; Path=/; Max-Age=31536000; SameSite=Lax`);
14+});
15+
16+test("Light and Dark are data-theme on <html>; Auto leaves it to the system", () => {
17+ assert.equal(themeAttribute("light"), "light");
18+ assert.equal(themeAttribute("dark"), "dark");
19+ assert.equal(themeAttribute("auto"), undefined);
20+});
21+
22+test("a chosen theme is itself; Auto with no browser to ask is dark", () => {
23+ assert.equal(resolvedTheme("light"), "light");
24+ assert.equal(resolvedTheme("dark"), "dark");
25+ assert.equal(resolvedTheme("auto"), "dark");
26+});
+114−0
1+import { useSyncExternalStore } from "react";
2+
3+/**
4+ * Appearance: Auto, Light or Dark. Each person's choice is kept in a
5+ * cookie (`g1t_theme`), so the server draws the page in it from the first
6+ * byte, with no flash of the other theme. Light and Dark are data-theme on
7+ * <html>; Auto sets nothing and the stylesheet follows the system's
8+ * prefers-color-scheme (app.css). Every colour token is a light-dark()
9+ * pair, so the browser picks the palette from the page's color-scheme.
10+ */
11+
12+export type ThemeChoice = "auto" | "light" | "dark";
13+
14+export const THEME_CHOICES: ThemeChoice[] = ["auto", "light", "dark"];
15+
16+/** The cookie that keeps the choice. */
17+export const THEME_COOKIE = "g1t_theme";
18+
19+/** What a page is drawn in before anyone chooses: g1t's own dark. */
20+export const DEFAULT_THEME: ThemeChoice = "dark";
21+
22+/** The browser bar's colour in each theme: the page's background. */
23+export const THEME_COLOR = { light: "#fbfbfa", dark: "#0f0f11" } as const;
24+
25+/** The choice in the cookie's value; the default for anything else. */
26+export function readTheme(value: string | null | undefined): ThemeChoice {
27+ return value === "light" || value === "dark" || value === "auto" ? value : DEFAULT_THEME;
28+}
29+
30+/** The Set-Cookie value that keeps the choice, for a year. */
31+export function themeCookie(choice: ThemeChoice, secure: boolean): string {
32+ return `${THEME_COOKIE}=${choice}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? "; Secure" : ""}`;
33+}
34+
35+/** What <html> carries for a choice: data-theme for Light and Dark, nothing for Auto. */
36+export function themeAttribute(choice: ThemeChoice): "light" | "dark" | undefined {
37+ return choice === "auto" ? undefined : choice;
38+}
39+
40+/** The words for a choice, as the switch and the command palette say them. */
41+export const THEME_LABEL: Record<ThemeChoice, string> = { auto: "Auto", light: "Light", dark: "Dark" };
42+
43+// The choice made in this tab since the page loaded; null until one is.
44+// Only ever set in the browser: on the server the page's own cookie rules.
45+let chosen: ThemeChoice | null = null;
46+const listeners = new Set<() => void>();
47+
48+function subscribe(listener: () => void) {
49+ listeners.add(listener);
50+ return () => listeners.delete(listener);
51+}
52+
53+/**
54+ * The person's choice: the one made in this tab, else the one the page was
55+ * drawn with (the root loader's, from the cookie).
56+ */
57+export function useThemeChoice(drawn: ThemeChoice | undefined): ThemeChoice {
58+ const initial = drawn ?? DEFAULT_THEME;
59+ return useSyncExternalStore(
60+ subscribe,
61+ () => chosen ?? initial,
62+ () => initial,
63+ );
64+}
65+
66+/**
67+ * Switch to `choice` at once and remember it: <html>'s data-theme and the
68+ * browser bar's colour change now, the cookie keeps it for the next page
69+ * the server draws, and every switch on the page follows.
70+ */
71+export function setTheme(choice: ThemeChoice) {
72+ chosen = choice;
73+ if (typeof document !== "undefined") {
74+ const root = document.documentElement;
75+ const attribute = themeAttribute(choice);
76+ if (attribute) root.dataset.theme = attribute;
77+ else delete root.dataset.theme;
78+ document.cookie = themeCookie(choice, window.location.protocol === "https:");
79+ }
80+ for (const listener of listeners) listener();
81+}
82+
83+/** The theme a choice comes to on this screen now: Auto asks the system. */
84+export function resolvedTheme(choice: ThemeChoice): "light" | "dark" {
85+ if (choice !== "auto") return choice;
86+ if (typeof window === "undefined" || !window.matchMedia) return "dark";
87+ return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
88+}
89+
90+/** The theme drawn right now, from <html>, for code that colours outside CSS (mermaid, the editor). */
91+export function currentTheme(): "light" | "dark" {
92+ if (typeof document === "undefined") return "dark";
93+ const attribute = document.documentElement.dataset.theme;
94+ return attribute === "light" || attribute === "dark" ? attribute : resolvedTheme("auto");
95+}
96+
97+function subscribeDrawn(listener: () => void) {
98+ const unsubscribe = subscribe(listener);
99+ const media = typeof window !== "undefined" && window.matchMedia ? window.matchMedia("(prefers-color-scheme: light)") : null;
100+ media?.addEventListener("change", listener);
101+ return () => {
102+ unsubscribe();
103+ media?.removeEventListener("change", listener);
104+ };
105+}
106+
107+/**
108+ * The theme drawn right now, Light or Dark, kept current as it is switched
109+ * or the system changes under Auto. Needs no router, so it works inside
110+ * the editor's own blocks. The server says Dark; the browser corrects it.
111+ */
112+export function useDrawnTheme(): "light" | "dark" {
113+ return useSyncExternalStore(subscribeDrawn, currentTheme, () => "dark");
114+}
+33−0
5252 test("focus borders in the dim accent stand out from the page", () => {
5353 assert.ok(contrast(token("accent-dim"), token("bg")) >= 3);
5454 });
55+
56+// The light palette (Appearance, lib/theme.ts): the same names after `light-`.
57+
58+test("the light accent is a deeper lavender, and light success is green", () => {
59+ assert.equal(token("light-accent"), "#6b52c8");
60+ assert.equal(token("light-success"), "#0d7a52");
61+});
62+
63+test("every dark token has a light one", () => {
64+ const dark = [...css.matchAll(/--g1t-(?!light-|font-)([a-z-]+):/g)].map((match) => match[1]);
65+ assert.ok(dark.length > 10);
66+ for (const name of dark) token(`light-${name}`);
67+});
68+
69+test("light text colours meet WCAG AA on every light surface", () => {
70+ for (const surface of ["bg", "surface", "raised"]) {
71+ for (const ink of ["fg", "fg-soft", "muted", "faint", "accent", "accent-hover", "success", "merged", "info", "warn", "danger"]) {
72+ const ratio = contrast(token(`light-${ink}`), token(`light-${surface}`));
73+ assert.ok(ratio >= 4.5, `--g1t-light-${ink} on --g1t-light-${surface} is ${ratio.toFixed(2)}:1`);
74+ }
75+ }
76+});
77+
78+test("the light page reads on an accent or success button", () => {
79+ for (const fill of ["accent", "accent-hover", "success"]) {
80+ const ratio = contrast(token("light-bg"), token(`light-${fill}`));
81+ assert.ok(ratio >= 4.5, `--g1t-light-bg on --g1t-light-${fill} is ${ratio.toFixed(2)}:1`);
82+ }
83+});
84+
85+test("light focus borders in the dim accent stand out from the page", () => {
86+ assert.ok(contrast(token("light-accent-dim"), token("light-bg")) >= 3);
87+});
+15−2
5050 import { isNeedsSignIn } from "./lib/website-token";
5151 import { setLiveViaToken } from "./lib/live-socket";
5252 import { LiveNotifications } from "./components/notifications/live-notifications";
53+import { THEME_COLOR, THEME_COOKIE, readTheme, themeAttribute, useThemeChoice } from "./lib/theme";
5354
5455
5556 export const links: Route.LinksFunction = () => [
101102 shell,
102103 // Whether they folded the sidebar away, so the page is drawn that way from the start.
103104 sidebarClosed: sidebarClosed(readCookie(cookies, SIDEBAR_COOKIE)),
105+ // Their Appearance, so the page is drawn in it from the first byte (lib/theme.ts).
106+ theme: readTheme(readCookie(cookies, THEME_COOKIE)),
104107 inviteOnly: mode !== "open",
105108 addresses: addresses(),
106109 // Visitors from where the law asks first are asked before analytics runs.
368371 // has not turned it on: they keep their place, and cannot use it yet.
369372 const held = user?.held && user.held.length > 0 && pathname !== "/settings/two-factor" && <PolicyNotice held={user.held} />;
370373 const leaving = useLeaving();
374+ // Auto, Light or Dark: as the cookie said when the page was drawn, or as just switched.
375+ const theme = useThemeChoice(root?.theme);
371376 // Which frame the page is drawn in (lib/chrome.ts): on its own, the app's, or the public one.
372377 const frame = frameOf(pathname, {
373378 signedIn: user != null && !awaitsConfirmation(user),
382387 </>
383388 ) : null;
384389 return (
385− <html lang="en">
390+ <html lang="en" data-theme={themeAttribute(theme)}>
386391 <head>
387392 <meta charSet="utf-8" />
388393 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
389− <meta name="theme-color" content="#0f0f11" />
394+ {/* The browser bar matches the page: one colour for a chosen theme, the system's for Auto. */}
395+ {theme === "auto" ? (
396+ <>
397+ <meta name="theme-color" media="(prefers-color-scheme: light)" content={THEME_COLOR.light} />
398+ <meta name="theme-color" media="(prefers-color-scheme: dark)" content={THEME_COLOR.dark} />
399+ </>
400+ ) : (
401+ <meta name="theme-color" content={THEME_COLOR[theme]} />
402+ )}
390403 <meta name="mobile-web-app-capable" content="yes" />
391404 <meta name="apple-mobile-web-app-capable" content="yes" />
392405 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+2−2
124124 <div className="mt-4">
125125 <a
126126 href={`/auth/github?link=1&next=${encodeURIComponent(here)}`}
127− className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white"
127+ className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover"
128128 >
129129 <GithubMark /> Connect GitHub
130130 </a>
157157 <div className="mt-4">
158158 <a
159159 href={`/integrations/github/install?workspace=${workspace}`}
160− className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white"
160+ className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover"
161161 >
162162 <GithubMark /> Install on GitHub
163163 </a>
+1−1
10841084 <Quiet
10851085 action={
10861086 member ? (
1087− <Link to={`${base}/issues/new`} className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-xs font-medium text-bg hover:bg-white">
1087+ <Link to={`${base}/issues/new`} className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-xs font-medium text-bg hover:bg-fg-hover">
10881088 <Bot size={12} /> Open an issue for an agent
10891089 </Link>
10901090 ) : null
+1−1
5959 actions={
6060 scanning.entitled && can.manage_settings && !scanning.configured ? (
6161 <Form method="post" action={`${base}/security/code-scanning/setup`}>
62− <button type="submit" disabled={settingUp} className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50">
62+ <button type="submit" disabled={settingUp} className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover disabled:opacity-50">
6363 {settingUp ? "Opening a pull request…" : "Set up code scanning"}
6464 </button>
6565 </Form>
+1−1
135135 <button
136136 type="submit"
137137 disabled={disabled || fetcher.state !== "idle"}
138− className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
138+ className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover disabled:opacity-50"
139139 >
140140 {fetcher.state !== "idle" ? "Saving…" : "Save"}
141141 </button>
+10−0
33 import type { Route } from "./+types/account";
44 import { DeleteAccountAction } from "../../components/delete-account";
55 import { DangerZone } from "../../components/danger-zone";
6+import { ThemeSwitch } from "../../components/theme-switch";
67 import { githubSignIn } from "../../lib/github.server";
78 import { page } from "../../lib/meta";
89 import { accounts } from "../../lib/services.server";
6364 .
6465 </p>
6566 </section>
67+ <section aria-labelledby="appearance-heading">
68+ <h2 id="appearance-heading" className="font-medium">
69+ Appearance
70+ </h2>
71+ <p className="mt-1 text-sm text-muted">
72+ Light, dark, or Auto to follow your system. This browser remembers it.
73+ </p>
74+ <ThemeSwitch className="mt-3 w-full max-w-xs" />
75+ </section>
6676 <DangerZone>
6777 <DeleteAccountAction
6878 username={username}
+1−1
6161 </button>
6262 ) : (
6363 <>
64− <button type="submit" name="decision" value="approve" disabled={busy} className="rounded-md bg-fg px-3 py-1 text-xs font-medium text-bg hover:bg-white disabled:opacity-50">
64+ <button type="submit" name="decision" value="approve" disabled={busy} className="rounded-md bg-fg px-3 py-1 text-xs font-medium text-bg hover:bg-fg-hover disabled:opacity-50">
6565 Approve
6666 </button>
6767 <button type="submit" name="decision" value="deny" disabled={busy} className="rounded-md border border-danger/40 px-3 py-1 text-xs font-medium text-danger hover:bg-danger/10 disabled:opacity-50">
+1−1
7373 disabled={disabled}
7474 />
7575 <div className="flex items-center gap-3 pt-1">
76− <button type="submit" disabled={disabled || fetcher.state !== "idle"} className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50">
76+ <button type="submit" disabled={disabled || fetcher.state !== "idle"} className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover disabled:opacity-50">
7777 {fetcher.state !== "idle" ? "Saving…" : "Save"}
7878 </button>
7979 {!owner && <span className="text-sm text-muted">Only an owner can change these.</span>}
+33−0
4747 --g1t-merged: #a678f5;
4848 --g1t-warn: #ffbd8c;
4949 --g1t-danger: #ff8394;
50+
51+ /*
52+ * The light palette, for a site that offers one (the app's Appearance
53+ * setting; apps/web/app/app.css picks between the two with
54+ * light-dark()). The same names with `light-` in front. A near-white
55+ * page, white panels, and a deeper lavender so the accent still reads
56+ * as text: every text colour here is at least 4.5:1 on bg, surface
57+ * and raised (apps/web/app/lib/tokens.test.ts).
58+ */
59+ --g1t-light-bg: #fbfbfa;
60+ --g1t-light-surface: #ffffff;
61+ --g1t-light-raised: #f1f1ef;
62+ --g1t-light-line: #e4e4e1;
63+ --g1t-light-line-strong: #d2d2ce;
64+
65+ --g1t-light-fg: #18181b;
66+ --g1t-light-fg-soft: #2e2e33;
67+ --g1t-light-muted: #5c5c66;
68+ --g1t-light-faint: #686871;
69+
70+ --g1t-light-accent: #6b52c8;
71+ --g1t-light-accent-hover: #5b42b8;
72+ --g1t-light-accent-dim: #9584dc;
73+ --g1t-light-accent-low: #ece8fb;
74+
75+ --g1t-light-success: #0d7a52;
76+ --g1t-light-success-dim: #3a9d77;
77+ --g1t-light-success-low: #e2f4ec;
78+
79+ --g1t-light-info: #2160cc;
80+ --g1t-light-merged: #7c3fd1;
81+ --g1t-light-warn: #a84f00;
82+ --g1t-light-danger: #c42a43;
5083 }