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 + + + +

+ Switch to {next} mode ({THEME_SHORTCUT}) +

+ {theme === "system" &&

Following your system until you pick one

} +
+
+ ); +} 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;