diff --git a/apps/desktop/public/theme-init.js b/apps/desktop/public/theme-init.js
index 6c8cda5d..daa78eb5 100644
--- a/apps/desktop/public/theme-init.js
+++ b/apps/desktop/public/theme-init.js
@@ -4,6 +4,7 @@
var t = "light";
try {
t = localStorage.getItem("godmode-theme") || "light";
+ if (t !== "dark" && t !== "system") t = "light";
} catch (e) {}
if (t === "system") t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
var r = document.documentElement;
diff --git a/apps/desktop/src/components/layout/app-shell.tsx b/apps/desktop/src/components/layout/app-shell.tsx
index 17a65aaa..820eaead 100644
--- a/apps/desktop/src/components/layout/app-shell.tsx
+++ b/apps/desktop/src/components/layout/app-shell.tsx
@@ -65,6 +65,8 @@ import { ClaudeUpdateButton } from "@/components/layout/claude-update-button";
import { MacPermissionsButton } from "@/components/permissions/mac-permissions";
import { DeviceIndicator, PrimaryOfflineBanner } from "@/components/layout/device-status";
import { LidModeButton } from "@/components/layout/lid-mode";
+import { ThemeToggle } from "@/components/layout/theme-toggle";
+import { useTheme } from "@/components/theme-provider";
import { PageScrollContext } from "@/components/layout/page-scroll";
import { Callout } from "@/components/settings/settings-kit";
import { LicenseBanner } from "@/components/license/license-banner";
@@ -112,6 +114,7 @@ export function AppShell({ children }: { children: ReactNode }) {
const { data: boot } = useBootstrap();
const navigate = useNavigate();
const setCommandOpen = useUi((s) => s.setCommandOpen);
+ const { toggle: toggleTheme } = useTheme();
const collapsed = useUi((s) => s.sidebarCollapsed);
const setCollapsed = useUi((s) => s.setSidebarCollapsed);
const runningCount = useRunningCount();
@@ -174,11 +177,14 @@ export function AppShell({ children }: { children: ReactNode }) {
} else if (mod && e.key === ",") {
e.preventDefault();
navigate("/settings/general");
+ } else if (mod && e.shiftKey && !e.altKey && letterOf(e) === "l") {
+ e.preventDefault();
+ if (!e.repeat) toggleTheme();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
- }, [navigate, setCommandOpen]);
+ }, [navigate, setCommandOpen, toggleTheme]);
const workNav: NavItem[] = [
{ to: "/inbox", label: "Inbox", icon: , badge: inboxCount || undefined },
@@ -614,6 +620,7 @@ function FooterBar() {
Lock vault
+
+ );
+}
diff --git a/apps/desktop/src/components/settings/general-section.tsx b/apps/desktop/src/components/settings/general-section.tsx
index 965b8988..0d61f2ff 100644
--- a/apps/desktop/src/components/settings/general-section.tsx
+++ b/apps/desktop/src/components/settings/general-section.tsx
@@ -5,6 +5,7 @@ import type { Settings } from "@godmode/shared";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useTheme } from "@/components/theme-provider";
+import { THEME_SHORTCUT } from "@/components/layout/theme-toggle";
import { isTauri } from "@/lib/core";
import { notifyDesktop } from "@/lib/desktop";
import { errorMessage } from "@/lib/api";
@@ -51,11 +52,6 @@ export function GeneralSection({ settings }: { settings: Settings }) {
}
};
- const changeTheme = (t: Theme) => {
- setTheme(t);
- patch({ general: { theme: t } });
- };
-
return (
@@ -82,11 +78,11 @@ export function GeneralSection({ settings }: { settings: Settings }) {
}>
-
+
aria-label="Theme"
value={theme}
- onChange={changeTheme}
+ onChange={setTheme}
options={[
{ value: "light", label: "Light", icon: },
{ value: "dark", label: "Dark", icon: },
diff --git a/apps/desktop/src/components/theme-provider.tsx b/apps/desktop/src/components/theme-provider.tsx
index 84266ae5..f6116333 100644
--- a/apps/desktop/src/components/theme-provider.tsx
+++ b/apps/desktop/src/components/theme-provider.tsx
@@ -1,21 +1,29 @@
-import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
-import { isTauri } from "@/lib/core";
+import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { THEMES, type Bootstrap, type Settings } from "@godmode/shared";
+import { api, errorMessage } from "@/lib/api";
+import { cloudContext, isTauri } from "@/lib/core";
+import { qk } from "@/lib/queryKeys";
-type Theme = "dark" | "light" | "system";
+export type Theme = Settings["general"]["theme"];
+type Resolved = "dark" | "light";
const DEFAULT_THEME: Theme = "light";
+const STORAGE_KEY = "godmode-theme";
-const ThemeContext = createContext<{ theme: Theme; resolved: "dark" | "light"; setTheme: (t: Theme) => void }>({
+const ThemeContext = createContext<{ theme: Theme; resolved: Resolved; setTheme: (t: Theme) => void; toggle: () => void }>({
theme: DEFAULT_THEME,
resolved: "light",
setTheme: () => {},
+ toggle: () => {},
});
/** Paper / anthracite — must match --background in index.css. */
const WINDOW_BG = { light: "#faf9f5", dark: "#1c1b19" } as const;
/** Desktop: keep the native window (title bar, resize fill) in step with the app theme. Best effort. */
-function syncNativeWindow(theme: "dark" | "light") {
+function syncNativeWindow(theme: Resolved) {
if (!isTauri) return;
void import("@tauri-apps/api/webviewWindow")
.then(async ({ getCurrentWebviewWindow }) => {
@@ -25,50 +33,128 @@ function syncNativeWindow(theme: "dark" | "light") {
.catch(() => {});
}
-function systemTheme(): "dark" | "light" {
- return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
+const darkQuery = () => window.matchMedia?.("(prefers-color-scheme: dark)");
+
+function systemTheme(): Resolved {
+ return darkQuery()?.matches ? "dark" : "light";
+}
+
+function readStored(): Theme {
+ try {
+ const t = localStorage.getItem(STORAGE_KEY);
+ return t === "dark" || t === "system" ? t : DEFAULT_THEME;
+ } catch {
+ return DEFAULT_THEME;
+ }
}
+function store(t: Theme) {
+ try {
+ localStorage.setItem(STORAGE_KEY, t);
+ } catch {
+ /* ignore */
+ }
+}
+
+/** The palette the page should show; a crossfade that runs late still paints the newest one. */
+let target: Resolved = "light";
+
+/** Swaps the palette in one step: no half-faded controls, and a soft crossfade where the webview can do it. */
+function applyTheme(r: Resolved, animate: boolean) {
+ const root = document.documentElement;
+ target = r;
+ const swap = () => {
+ const t = target;
+ root.classList.add("theme-switching");
+ root.classList.toggle("dark", t === "dark");
+ // aicss components key their palette off data-theme; without it they follow the OS instead of the app.
+ root.dataset.theme = t;
+ root.style.colorScheme = t;
+ document.querySelector('meta[name="theme-color"]')?.setAttribute("content", WINDOW_BG[t]);
+ void root.offsetHeight;
+ requestAnimationFrame(() => root.classList.remove("theme-switching"));
+ };
+ const changed = root.classList.contains("dark") !== (r === "dark") || root.dataset.theme !== r;
+ const calm = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
+ if (changed && animate && !calm && typeof document.startViewTransition === "function") document.startViewTransition(swap);
+ else swap();
+ syncNativeWindow(r);
+}
+
+const isTheme = (t: unknown): t is Theme => THEMES.includes(t as Theme);
+
+/** People the owner let into the cloud dashboard keep a theme of their own in their browser. */
+const personal = !!cloudContext && cloudContext.role !== "owner";
+
+/**
+ * The theme is a setting of this Godmode (Settings → General), so the desktop app, the browser dashboard, the phone and
+ * agents all see the same one. localStorage only remembers it for the first paint (public/theme-init.js).
+ */
export function ThemeProvider({ children }: { children: ReactNode }) {
- const [theme, setThemeState] = useState(() => {
- try {
- return (localStorage.getItem("godmode-theme") as Theme) || DEFAULT_THEME;
- } catch {
- return DEFAULT_THEME;
- }
- });
- const [resolved, setResolved] = useState<"dark" | "light">(theme === "system" ? systemTheme() : theme);
+ const qc = useQueryClient();
+ const [theme, setThemeState] = useState(readStored);
+ const [system, setSystem] = useState(systemTheme);
+ const resolved: Resolved = theme === "system" ? system : theme;
+ const painted = useRef(false);
+ /** Saves still on their way: until the last one is answered, a refresh may still carry the old theme. */
+ const saving = useRef(0);
- useEffect(() => {
- const apply = () => {
- const r = theme === "system" ? systemTheme() : theme;
- setResolved(r);
- const root = document.documentElement;
- root.classList.toggle("dark", r === "dark");
- // aicss components key their palette off data-theme; without it they follow the OS instead of the app.
- root.dataset.theme = r;
- root.style.colorScheme = r;
- document.querySelector('meta[name="theme-color"]')?.setAttribute("content", WINDOW_BG[r]);
- syncNativeWindow(r);
- };
- apply();
- if (theme === "system") {
- const mq = window.matchMedia("(prefers-color-scheme: light)");
- mq.addEventListener("change", apply);
- return () => mq.removeEventListener("change", apply);
- }
- }, [theme]);
-
- const setTheme = (t: Theme) => {
+ // Reads the setting from whatever the app already loaded; never asks the core itself (e.g. on the sign-in page).
+ const { data: saved } = useQuery({ queryKey: qk.bootstrap, queryFn: api.bootstrap, enabled: false, select: (b) => b.settings.general.theme });
+
+ const adopt = useCallback((t: unknown) => {
+ if (personal || !isTheme(t)) return;
setThemeState(t);
- try {
- localStorage.setItem("godmode-theme", t);
- } catch {
- /* ignore */
- }
- };
+ store(t);
+ }, []);
+
+ useEffect(() => {
+ if (saving.current === 0) adopt(saved);
+ }, [saved, adopt]);
+
+ useEffect(() => {
+ const mq = darkQuery();
+ if (!mq) return;
+ const onChange = () => setSystem(systemTheme());
+ mq.addEventListener("change", onChange);
+ return () => mq.removeEventListener("change", onChange);
+ }, []);
+
+ useEffect(() => {
+ applyTheme(resolved, painted.current);
+ painted.current = true;
+ }, [resolved]);
+
+ const { mutate: persist } = useMutation({
+ scope: { id: "theme" },
+ mutationFn: (t: Theme) => api.settings.update({ general: { theme: t } }),
+ onSuccess: async () => {
+ if (--saving.current > 0) return;
+ await Promise.all([qc.invalidateQueries({ queryKey: qk.settings }), qc.invalidateQueries({ queryKey: qk.bootstrap })]);
+ if (saving.current === 0) adopt(qc.getQueryData(qk.bootstrap)?.settings.general.theme);
+ },
+ // Not saved (a secondary computer while its primary is away, a dashboard that may only look): the choice stays here.
+ onError: (err) => {
+ saving.current--;
+ const status = (err as { status?: number }).status;
+ if (status !== 403 && status !== 409) toast.error("Could not save the theme", { description: errorMessage(err) });
+ },
+ });
+
+ const setTheme = useCallback(
+ (t: Theme) => {
+ setThemeState(t);
+ store(t);
+ if (personal) return;
+ saving.current++;
+ persist(t);
+ },
+ [persist],
+ );
+
+ const toggle = useCallback(() => setTheme(resolved === "dark" ? "light" : "dark"), [resolved, setTheme]);
- return {children};
+ return {children};
}
export const useTheme = () => useContext(ThemeContext);
diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css
index fd09e077..7b718ab1 100644
--- a/apps/desktop/src/index.css
+++ b/apps/desktop/src/index.css
@@ -237,6 +237,12 @@
* {
@apply border-border outline-ring/50;
}
+ .theme-switching,
+ .theme-switching *,
+ .theme-switching *::before,
+ .theme-switching *::after {
+ transition: none !important;
+ }
html,
body,
#root {
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 95a1b352..3a76d0c8 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -1,6 +1,6 @@
import { Geist_400Regular, Geist_500Medium, Geist_600SemiBold, Geist_700Bold } from "@expo-google-fonts/geist";
import { GeistMono_400Regular, GeistMono_500Medium } from "@expo-google-fonts/geist-mono";
-import { QueryClientProvider } from "@tanstack/react-query";
+import { QueryClientProvider, useQuery } from "@tanstack/react-query";
import { useFonts } from "expo-font";
import { Image } from "expo-image";
import { Stack, ThemeProvider } from "expo-router";
@@ -10,8 +10,10 @@ import { useEffect, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { AppLock } from "@/components/app-lock";
+import { api } from "@/lib/api";
+import { loadAppearance, useAppearance } from "@/lib/appearance";
import { forgetQueries, persistQueries, restoreQueries } from "@/lib/persist";
-import { queryClient } from "@/lib/query";
+import { qk, queryClient } from "@/lib/query";
import { startRealtime } from "@/lib/realtime";
import { useSession } from "@/lib/session";
import { fonts, useColors, useNavigationTheme } from "@/lib/theme";
@@ -37,8 +39,10 @@ export default function RootLayout() {
const [fontsLoaded, fontError] = useFonts(FONT_FILES);
const fontsReady = fontsLoaded || !!fontError;
+ const [appearanceReady, setAppearanceReady] = useState(false);
useEffect(() => {
void useSession.getState().load();
+ void loadAppearance().finally(() => setAppearanceReady(true));
}, []);
// What the last session saw is on screen before the computer answers.
@@ -51,8 +55,8 @@ export default function RootLayout() {
}, [ready, restored]);
useEffect(() => {
- if (restored && fontsReady) void SplashScreen.hideAsync();
- }, [restored, fontsReady]);
+ if (restored && fontsReady && appearanceReady) void SplashScreen.hideAsync();
+ }, [restored, fontsReady, appearanceReady]);
useEffect(() => {
if (!restored) return;
@@ -70,13 +74,14 @@ export default function RootLayout() {
};
}, [deviceId, restored]);
- if (!ready || !restored || !fontsReady) return null;
+ if (!ready || !restored || !fontsReady || !appearanceReady) return null;
return (
+
@@ -114,3 +119,12 @@ export default function RootLayout() {
);
}
+
+/** Follows the computer's theme, also when the desktop, another phone or an agent changes it. */
+function ThemeSync({ paired }: { paired: boolean }) {
+ const { data } = useQuery({ queryKey: qk.settings, queryFn: api.settings.get, enabled: paired, select: (s) => s.general.theme });
+ useEffect(() => {
+ if (data) useAppearance.getState().setTheme(data);
+ }, [data]);
+ return null;
+}
diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx
index 84577bf2..8c381040 100644
--- a/apps/mobile/src/app/settings/index.tsx
+++ b/apps/mobile/src/app/settings/index.tsx
@@ -3,13 +3,14 @@ import Constants from "expo-constants";
import * as LocalAuthentication from "expo-local-authentication";
import { router, Stack } from "expo-router";
import { Alert, ScrollView, StyleSheet, View } from "react-native";
-import { EFFORT_LABELS, findModel } from "@godmode/shared";
+import { EFFORT_LABELS, findModel, type Theme } from "@godmode/shared";
import { CloseButton } from "@/components/close-button";
-import { FieldRow, Group, LinkRow, SwitchRow } from "@/components/form";
+import { FieldRow, Group, LinkRow, SegmentRow, SwitchRow } from "@/components/form";
import { Icon, type IconName } from "@/components/icon";
import { Logo } from "@/components/logo";
import { Button, Card, LiveDot, Row, T } from "@/components/ui";
import { api } from "@/lib/api";
+import { useAppearance } from "@/lib/appearance";
import { useModelCatalog } from "@/lib/composer";
import { hostOf } from "@/lib/format";
import { useAgents } from "@/lib/hooks";
@@ -22,10 +23,17 @@ import { useWorkspace } from "@/lib/workspace";
const PLATFORM: Record = { darwin: "macOS", win32: "Windows", linux: "Linux" };
+const THEME_OPTIONS = [
+ { value: "light", label: "Light" },
+ { value: "dark", label: "Dark" },
+ { value: "system", label: "System" },
+] as const satisfies readonly { value: Theme; label: string }[];
+
export default function Settings() {
const c = useColors();
const connection = useSession((s) => s.connection);
const appLock = useSession((s) => s.appLock);
+ const theme = useAppearance((s) => s.theme);
const status = useLive((s) => s.status);
const me = useQuery({ queryKey: qk.me, queryFn: api.me });
const settings = useSettings();
@@ -86,6 +94,18 @@ export default function Settings() {
{s ? void patchSettings({ general: { userName } })} /> : null}
+ {s ? (
+ {
+ useAppearance.getState().setTheme(next);
+ void patchSettings({ general: { theme: next } });
+ }}
+ />
+ ) : null}
>;
+ general?: Partial>;
runner?: Partial>;
browser?: Partial>;
computer?: Partial>;
diff --git a/apps/mobile/src/lib/appearance.ts b/apps/mobile/src/lib/appearance.ts
new file mode 100644
index 00000000..d7003661
--- /dev/null
+++ b/apps/mobile/src/lib/appearance.ts
@@ -0,0 +1,35 @@
+import * as SecureStore from "expo-secure-store";
+import { Appearance } from "react-native";
+import { create } from "zustand";
+import { THEMES, type Theme } from "@godmode/shared";
+
+const KEY = "godmode.theme";
+const STORE_OPTIONS: SecureStore.SecureStoreOptions = { keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY };
+
+function apply(theme: Theme) {
+ Appearance.setColorScheme(theme === "system" ? "unspecified" : theme);
+}
+
+interface AppearanceState {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+}
+
+/** The computer's theme (Settings → General), light until the human picks another; kept on the phone for the next cold start. */
+export const useAppearance = create((set, get) => ({
+ theme: "light",
+ setTheme: (theme) => {
+ if (!THEMES.includes(theme) || get().theme === theme) return;
+ apply(theme);
+ set({ theme });
+ void SecureStore.setItemAsync(KEY, theme, STORE_OPTIONS).catch(() => undefined);
+ },
+}));
+
+apply("light");
+
+/** Before the first screen, so the app never opens in the phone's scheme when the computer says otherwise. */
+export async function loadAppearance(): Promise {
+ const saved = (await SecureStore.getItemAsync(KEY, STORE_OPTIONS).catch(() => null)) as Theme | null;
+ if (saved && THEMES.includes(saved)) useAppearance.getState().setTheme(saved);
+}
diff --git a/apps/mobile/src/lib/theme.ts b/apps/mobile/src/lib/theme.ts
index 8c05d39c..cd011f39 100644
--- a/apps/mobile/src/lib/theme.ts
+++ b/apps/mobile/src/lib/theme.ts
@@ -1,5 +1,6 @@
import { useColorScheme, type TextStyle } from "react-native";
import { DarkTheme, DefaultTheme, type Theme } from "expo-router";
+import { useAppearance } from "./appearance";
/** Godmode's tokens (apps/desktop/src/index.css): warm paper and anthracite, one quiet green accent. */
const light = {
@@ -151,11 +152,14 @@ export const type = {
} as const;
export function useColors(): Colors {
- return useColorScheme() === "dark" ? dark : light;
+ return useIsDark() ? dark : light;
}
+/** The computer's theme; "system" follows this phone. */
export function useIsDark(): boolean {
- return useColorScheme() === "dark";
+ const theme = useAppearance((s) => s.theme);
+ const scheme = useColorScheme();
+ return theme === "system" ? scheme === "dark" : theme === "dark";
}
export function useNavigationTheme(): Theme {
diff --git a/docs/screenshots/dark-mode/chat-light-dark.png b/docs/screenshots/dark-mode/chat-light-dark.png
new file mode 100644
index 00000000..7dc4934f
Binary files /dev/null and b/docs/screenshots/dark-mode/chat-light-dark.png differ
diff --git a/docs/screenshots/dark-mode/home-light-dark.png b/docs/screenshots/dark-mode/home-light-dark.png
new file mode 100644
index 00000000..1b4890ea
Binary files /dev/null and b/docs/screenshots/dark-mode/home-light-dark.png differ
diff --git a/docs/screenshots/dark-mode/settings-light-dark.png b/docs/screenshots/dark-mode/settings-light-dark.png
new file mode 100644
index 00000000..96a38397
Binary files /dev/null and b/docs/screenshots/dark-mode/settings-light-dark.png differ
diff --git a/docs/screenshots/dark-mode/sidebar-toggle.png b/docs/screenshots/dark-mode/sidebar-toggle.png
new file mode 100644
index 00000000..fad1154d
Binary files /dev/null and b/docs/screenshots/dark-mode/sidebar-toggle.png differ
diff --git a/packages/core/src/mobile/scope.ts b/packages/core/src/mobile/scope.ts
index 786b7a39..c883ca57 100644
--- a/packages/core/src/mobile/scope.ts
+++ b/packages/core/src/mobile/scope.ts
@@ -112,7 +112,7 @@ const ROUTES: [method: string, path: RegExp][] = [
* phone listener and the cloud link stay on the computer.
*/
const SETTINGS: Record = {
- general: ["userName", "desktopNotifications"],
+ general: ["userName", "desktopNotifications", "theme"],
runner: [
"model",
"fallbackModel",
diff --git a/packages/core/src/services/agentSettings.ts b/packages/core/src/services/agentSettings.ts
index 44e19883..e7af212e 100644
--- a/packages/core/src/services/agentSettings.ts
+++ b/packages/core/src/services/agentSettings.ts
@@ -1,10 +1,9 @@
import type { Settings } from "@godmode/shared";
-import { INTERFACE_MODES } from "@godmode/shared";
import { HttpError } from "../util";
import { audit } from "./audit";
import { applyRuntimeSettings } from "./runtime";
import { DEFAULT_SETTINGS, getSettings, updateSettings } from "./settings";
-import { sameSettingShape, settingInRange } from "./settingShape";
+import { SETTING_CHOICES, sameSettingShape, settingInRange } from "./settingShape";
import { checkSettingsPatch } from "./settingsPatch";
type Sections = Record;
@@ -42,10 +41,6 @@ const AGENT_SETTINGS: Record = {
maintenance: ["autoFix", "autoUpdate"],
};
-const CHOICES: Record = {
- "general.theme": ["dark", "light", "system"],
- "general.mode": INTERFACE_MODES,
-};
const FRACTIONS = new Set(["voice.rate"]);
/** 0 switches the limit off: that is the human's call. */
const AT_LEAST: Record = { "runner.runTimeoutMinutes": 1 };
@@ -104,10 +99,10 @@ export function agentSettingsRefusal(changes: Record, human: st
}
if (!listed(section).includes(name) || Array.isArray(was)) return `${path} stays with ${human}: ${guarded}.`;
const whole = typeof value !== "number" || ((Number.isInteger(value) || FRACTIONS.has(path)) && value >= (AT_LEAST[path] ?? -Infinity));
+ const choices = SETTING_CHOICES[path];
+ if (choices && typeof value === "string" && !choices.includes(value)) return `${path} must be one of: ${choices.join(", ")}.`;
if (!sameSettingShape(value, was, false) || !whole || !settingInRange(path, value)) return `Invalid value for ${path}: it is ${JSON.stringify(was)} now, keep its type and range.`;
if (typeof value === "string") {
- const choices = CHOICES[path];
- if (choices && !choices.includes(value)) return `${path} must be one of: ${choices.join(", ")}.`;
if (value.length > MAX_TEXT || NOT_ONE_LINE.test(value)) return `${path} takes one line of at most ${MAX_TEXT} characters.`;
}
}
diff --git a/packages/core/src/services/settingShape.ts b/packages/core/src/services/settingShape.ts
index 50505742..49efe0b0 100644
--- a/packages/core/src/services/settingShape.ts
+++ b/packages/core/src/services/settingShape.ts
@@ -1,3 +1,5 @@
+import { INTERFACE_MODES, THEMES } from "@godmode/shared";
+
/** Ranges for the numbers a phone or an agent may set (as in the app); for text, the maximum length. */
const SETTING_LIMITS: Record = {
"runner.maxConcurrentRuns": [1, 32],
@@ -15,7 +17,15 @@ const SETTING_LIMITS: Record = {
"general.userName": [0, 80],
};
+/** Settings that take one of a few words. */
+export const SETTING_CHOICES: Record = {
+ "general.theme": THEMES,
+ "general.mode": INTERFACE_MODES,
+};
+
export function settingInRange(key: string, value: unknown): boolean {
+ const choices = SETTING_CHOICES[key];
+ if (choices && typeof value === "string") return choices.includes(value);
const limit = SETTING_LIMITS[key];
if (!limit || value === null) return true;
const [min, max] = limit;
diff --git a/packages/core/src/services/settingsPatch.ts b/packages/core/src/services/settingsPatch.ts
index 023526eb..e1e15121 100644
--- a/packages/core/src/services/settingsPatch.ts
+++ b/packages/core/src/services/settingsPatch.ts
@@ -1,5 +1,5 @@
-import type { CaptchaSolver, Effort, InterfaceMode } from "@godmode/shared";
-import { CAPTCHA_SOLVERS, EFFORT_OPTIONS, INTERFACE_MODES, MAX_INSTRUCTIONS_LENGTH, MAX_MEMORY_CHARS, MIN_MEMORY_CHARS, POWER_MIN_BATTERY_OPTIONS, isClockTime, isModelId, isValidBranch, modelProvider, parseGitUrl, toE164 } from "@godmode/shared";
+import type { CaptchaSolver, Effort, InterfaceMode, Theme } from "@godmode/shared";
+import { CAPTCHA_SOLVERS, EFFORT_OPTIONS, INTERFACE_MODES, THEMES, MAX_INSTRUCTIONS_LENGTH, MAX_MEMORY_CHARS, MIN_MEMORY_CHARS, POWER_MIN_BATTERY_OPTIONS, isClockTime, isModelId, isValidBranch, modelProvider, parseGitUrl, toE164 } from "@godmode/shared";
import { isAbsolute } from "node:path";
import { isPremiumNumber } from "../calls/numbers";
import { get } from "../db";
@@ -19,8 +19,9 @@ export function checkSettingsPatch(patch: Record, needsGrant: (
// Making "reveal" the default secret access for new agents needs a fresh passphrase confirmation.
const security = patch.security as { defaultSecretAccess?: unknown } | undefined;
if (security?.defaultSecretAccess === "reveal" && getSettings().security.defaultSecretAccess !== "reveal") needsGrant();
- const mode = (patch.general as { mode?: unknown } | undefined)?.mode;
+ const { mode, theme } = (patch.general ?? {}) as { mode?: unknown; theme?: unknown };
if (mode !== undefined && !INTERFACE_MODES.includes(mode as InterfaceMode)) throw badRequest(`general.mode must be one of ${INTERFACE_MODES.join(", ")}`);
+ if (theme !== undefined && !THEMES.includes(theme as Theme)) throw badRequest(`general.theme must be one of ${THEMES.join(", ")}`);
const runner = patch.runner as { appendSystemPrompt?: unknown; ultracode?: unknown; spendTracking?: unknown } | undefined;
const instructions = runner?.appendSystemPrompt;
if (typeof instructions === "string" && instructions.length > MAX_INSTRUCTIONS_LENGTH) {
diff --git a/packages/core/test/mobile.test.ts b/packages/core/test/mobile.test.ts
index 8fbaaba7..1644c724 100644
--- a/packages/core/test/mobile.test.ts
+++ b/packages/core/test/mobile.test.ts
@@ -324,6 +324,8 @@ describe("phone access", () => {
expect((await phone("/api/settings", json("PUT", { runner: { spendTracking: "on" } }))).status).toBe(403);
expect((await phone("/api/settings", json("PUT", { runner: { spendTracking: false } }))).status).toBe(200);
expect((await phone("/api/settings", json("PUT", { runner: { effort: "high&calc" } }))).status).toBe(400);
+ const themed = await phone("/api/settings", json("PUT", { general: { theme: "dark" } }));
+ expect(((await themed.json()) as { general: { theme: string } }).general.theme).toBe("dark");
expect((await phone("/api/settings", json("PUT", { runner: { model: "x y" } }))).status).toBe(400);
for (const patch of [
{ runner: { claudePath: "/tmp/evil" } },
@@ -338,6 +340,7 @@ describe("phone access", () => {
{ runner: { maxConcurrentRuns: 100000 } },
{ runner: { runTimeoutMinutes: -1 } },
{ general: { userName: "x".repeat(81) } },
+ { general: { theme: "purple" } },
JSON.parse('{"memory":{"dreaming":{"__proto__":{}}}}') as object,
{ runner: { effort: 5 } },
{ memory: { dreaming: { cron: 1 } } },
diff --git a/packages/core/test/theme.test.ts b/packages/core/test/theme.test.ts
new file mode 100644
index 00000000..4343a772
--- /dev/null
+++ b/packages/core/test/theme.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, test } from "bun:test";
+import { DEFAULT_SETTINGS } from "../src/services/settings";
+import { checkSettingsPatch } from "../src/services/settingsPatch";
+import { settingInRange } from "../src/services/settingShape";
+
+describe("theme", () => {
+ test("light is the default", () => {
+ expect(DEFAULT_SETTINGS.general.theme).toBe("light");
+ });
+
+ test("only light, dark and system are saved", () => {
+ for (const theme of ["light", "dark", "system"]) expect(() => checkSettingsPatch({ general: { theme } }, () => {})).not.toThrow();
+ expect(() => checkSettingsPatch({ general: { theme: "purple" } }, () => {})).toThrow("general.theme");
+ expect(() => checkSettingsPatch({ general: { theme: 1 } }, () => {})).toThrow("general.theme");
+ });
+
+ test("phones and agents get the same choices", () => {
+ expect(settingInRange("general.theme", "dark")).toBe(true);
+ expect(settingInRange("general.theme", "purple")).toBe(false);
+ expect(settingInRange("general.mode", "easy")).toBe(true);
+ expect(settingInRange("general.mode", "simple")).toBe(false);
+ });
+});
diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts
index 3420fe67..21fc9575 100644
--- a/packages/shared/src/models.ts
+++ b/packages/shared/src/models.ts
@@ -1290,8 +1290,12 @@ export interface SelfRepairResult {
export type InterfaceMode = "easy" | "pro";
export const INTERFACE_MODES: readonly InterfaceMode[] = ["easy", "pro"];
+/** Light unless the human picks another; "system" follows each device's own setting. */
+export type Theme = "light" | "dark" | "system";
+export const THEMES: readonly Theme[] = ["light", "dark", "system"];
+
export interface GeneralSettings {
- theme: "dark" | "light" | "system";
+ theme: Theme;
/** How much of Godmode the dashboard shows at once; every feature stays in both. */
mode: InterfaceMode;
accent: string;