Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/public/theme-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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: <Inbox />, badge: inboxCount || undefined },
Expand Down Expand Up @@ -614,6 +620,7 @@ function FooterBar() {
<TooltipContent>Lock vault</TooltipContent>
</Tooltip>
<LidModeButton />
<ThemeToggle />
<Tooltip>
<TooltipTrigger asChild>
<Button variant={location.pathname.startsWith("/settings") ? "secondary" : "ghost"} size="icon" className="size-8" asChild>
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/src/components/layout/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { useStartAgentChat } from "@/components/agents/agent-actions";
import { ATTENTION_ICON } from "@/components/attention/attention-list";
import { useUi } from "@/stores/ui";
import { useTheme } from "@/components/theme-provider";
import { THEME_SHORTCUT } from "@/components/layout/theme-toggle";
import { api } from "@/lib/api";
import { qk } from "@/lib/queryKeys";
import { cloudContext } from "@/lib/core";
Expand Down Expand Up @@ -110,7 +111,7 @@ export function CommandPalette() {
const setOpen = useUi((s) => s.setCommandOpen);
const navigate = useNavigate();
const qc = useQueryClient();
const { resolved, setTheme } = useTheme();
const { resolved, toggle: toggleTheme } = useTheme();
const mode = useInterfaceMode();
const { setMode } = useSetInterfaceMode();
// In Easy mode pages go by their plain names; both names always find them.
Expand Down Expand Up @@ -285,12 +286,13 @@ export function CommandPalette() {
<Keyboard /> Keyboard shortcuts <CommandShortcut>?</CommandShortcut>
</CommandItem>
<CommandItem
value="switch light dark mode theme appearance night day"
onSelect={() => {
setTheme(resolved === "dark" ? "light" : "dark");
toggleTheme();
setOpen(false);
}}
>
{resolved === "dark" ? <Sun /> : <Moon />} Toggle theme
{resolved === "dark" ? <Sun /> : <Moon />} Switch to {resolved === "dark" ? "light" : "dark"} mode <CommandShortcut>{THEME_SHORTCUT}</CommandShortcut>
</CommandItem>
<CommandItem
value={`switch to ${MODE_LABEL[mode === "easy" ? "pro" : "easy"]} mode interface simple advanced beginner expert`}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/components/layout/shortcuts-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Fragment, type ReactNode } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Kbd } from "@/components/common";
import { modKey } from "@/lib/desktop";
import { THEME_SHORTCUT } from "@/components/layout/theme-toggle";
import { useUi } from "@/stores/ui";

/** "G then T" style jumps: the second key, where it goes, and what it's called. */
Expand All @@ -27,6 +28,7 @@ const GROUPS: { title: string; rows: { keys: ReactNode[]; what: string }[] }[] =
{ keys: [`${modKey}1–9`], what: "Switch to a workspace (in A–Z order)" },
{ keys: [`${modKey}0`], what: "All workspaces" },
{ keys: [`${modKey},`], what: "Settings" },
{ keys: [THEME_SHORTCUT], what: "Light or dark mode" },
{ keys: ["?"], what: "This list" },
],
},
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/components/layout/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useTheme } from "@/components/theme-provider";
import { modKey } from "@/lib/desktop";

export const THEME_SHORTCUT = `${modKey}⇧L`;

/** Light and dark in one click; "System" stays in Settings → General. */
export function ThemeToggle() {
const { theme, resolved, toggle } = useTheme();
const next = resolved === "dark" ? "light" : "dark";
return (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="size-8" aria-label={`Switch to ${next} mode`} onClick={toggle}>
{resolved === "dark" ? <Moon className="size-4" /> : <Sun className="size-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
Switch to {next} mode <span className="opacity-60">({THEME_SHORTCUT})</span>
</p>
{theme === "system" && <p className="opacity-70">Following your system until you pick one</p>}
</TooltipContent>
</Tooltip>
);
}
10 changes: 3 additions & 7 deletions apps/desktop/src/components/settings/general-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,11 +52,6 @@ export function GeneralSection({ settings }: { settings: Settings }) {
}
};

const changeTheme = (t: Theme) => {
setTheme(t);
patch({ general: { theme: t } });
};

return (
<div className="space-y-5">
<SectionHeading title="General" description="How Godmode greets you, looks and behaves on your desktop." />
Expand All @@ -82,11 +78,11 @@ export function GeneralSection({ settings }: { settings: Settings }) {
</SettingsGroup>

<SettingsGroup title="Appearance" icon={<Palette />}>
<SettingRow label="Theme" description="Warm paper by day, anthracite by night. System follows your OS setting.">
<SettingRow label="Theme" description={`Warm paper by day, anthracite by night. System follows your OS. Switch any time with ${THEME_SHORTCUT}.`}>
<Segmented<Theme>
aria-label="Theme"
value={theme}
onChange={changeTheme}
onChange={setTheme}
options={[
{ value: "light", label: "Light", icon: <Sun /> },
{ value: "dark", label: "Dark", icon: <Moon /> },
Expand Down
172 changes: 129 additions & 43 deletions apps/desktop/src/components/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand All @@ -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<Theme>(() => {
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<Theme>(readStored);
const [system, setSystem] = useState<Resolved>(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<Bootstrap>(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 <ThemeContext.Provider value={{ theme, resolved, setTheme }}>{children}</ThemeContext.Provider>;
return <ThemeContext.Provider value={{ theme, resolved, setTheme, toggle }}>{children}</ThemeContext.Provider>;
}

export const useTheme = () => useContext(ThemeContext);
6 changes: 6 additions & 0 deletions apps/desktop/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading