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
4 changes: 4 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
TriangleAlertIcon,
} from "lucide-react";
import { SpotifyToggleButton } from "./SpotifyPlayer";
import { ThemeModeSwitcher } from "./ThemeModeSwitcher";
import { autoAnimate } from "@formkit/auto-animate";
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import {
Expand Down Expand Up @@ -1920,6 +1921,9 @@ export default function Sidebar() {
<span className="text-xs">Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<ThemeModeSwitcher />
</SidebarMenuItem>
</>
)}
</SidebarMenu>
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/ThemeModeSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "../hooks/useTheme";
import { cn } from "../lib/utils";

type Mode = "system" | "light" | "dark";

const MODES: { value: Mode; icon: typeof MonitorIcon; label: string }[] = [
{ value: "system", icon: MonitorIcon, label: "System" },
{ value: "light", icon: SunIcon, label: "Light" },
{ value: "dark", icon: MoonIcon, label: "Dark" },
];

export function ThemeModeSwitcher() {
const { theme, setTheme } = useTheme();

return (
<div className="flex h-7 items-center gap-0.5 rounded-lg bg-muted p-0.5">
{MODES.map(({ value, icon: Icon, label }) => {
const isActive = theme === value;
return (
<button
key={value}
type="button"
aria-label={label}
className={cn(
"flex h-6 flex-1 items-center justify-center rounded-md text-muted-foreground transition-colors",
isActive
? "bg-background text-foreground shadow-xs"
: "hover:text-foreground/80",
)}
onClick={() => setTheme(value)}
>
<Icon className="size-3.5" />
</button>
);
})}
</div>
);
}
72 changes: 67 additions & 5 deletions apps/web/src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";

type Theme = "light" | "dark" | "system";
type ColorTheme =
| "default"
| "iridescent-void"
| "solar-witch"
| "deep-sea-terminal"
| "cathedral-circuit"
| "neon-bento";

type ThemeSnapshot = {
theme: Theme;
systemDark: boolean;
colorTheme: ColorTheme;
};

export const COLOR_THEMES: { id: ColorTheme; label: string }[] = [
{ id: "default", label: "Default" },
{ id: "iridescent-void", label: "Iridescent Void" },
{ id: "solar-witch", label: "Solar Witch" },
{ id: "deep-sea-terminal", label: "Deep Sea Terminal" },
{ id: "cathedral-circuit", label: "Cathedral Circuit" },
{ id: "neon-bento", label: "Neon Bento" },
];

const STORAGE_KEY = "okcode:theme";
const COLOR_THEME_STORAGE_KEY = "okcode:color-theme";
const MEDIA_QUERY = "(prefers-color-scheme: dark)";

let listeners: Array<() => void> = [];
Expand All @@ -26,12 +45,42 @@ function getStored(): Theme {
return "system";
}

function getStoredColorTheme(): ColorTheme {
const raw = localStorage.getItem(COLOR_THEME_STORAGE_KEY);
if (
raw === "default" ||
raw === "iridescent-void" ||
raw === "solar-witch" ||
raw === "deep-sea-terminal" ||
raw === "cathedral-circuit" ||
raw === "neon-bento"
) {
return raw;
}
return "default";
}

function applyTheme(theme: Theme, suppressTransitions = false) {
if (suppressTransitions) {
document.documentElement.classList.add("no-transitions");
}
const isDark = theme === "dark" || (theme === "system" && getSystemDark());
document.documentElement.classList.toggle("dark", isDark);

// Apply color theme class
const colorTheme = getStoredColorTheme();
// Remove any existing theme-* classes
const existingThemeClasses = Array.from(document.documentElement.classList).filter((cls) =>
cls.startsWith("theme-"),
);
for (const cls of existingThemeClasses) {
document.documentElement.classList.remove(cls);
}
// Add the new theme class if not default
if (colorTheme !== "default") {
document.documentElement.classList.add(`theme-${colorTheme}`);
}

syncDesktopTheme(theme);
if (suppressTransitions) {
// Force a reflow so the no-transitions class takes effect before removal
Expand Down Expand Up @@ -63,12 +112,18 @@ applyTheme(getStored());
function getSnapshot(): ThemeSnapshot {
const theme = getStored();
const systemDark = theme === "system" ? getSystemDark() : false;

if (lastSnapshot && lastSnapshot.theme === theme && lastSnapshot.systemDark === systemDark) {
const colorTheme = getStoredColorTheme();

if (
lastSnapshot &&
lastSnapshot.theme === theme &&
lastSnapshot.systemDark === systemDark &&
lastSnapshot.colorTheme === colorTheme
) {
return lastSnapshot;
}

lastSnapshot = { theme, systemDark };
lastSnapshot = { theme, systemDark, colorTheme };
return lastSnapshot;
}

Expand All @@ -85,7 +140,7 @@ function subscribe(listener: () => void): () => void {

// Listen for storage changes from other tabs
const handleStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) {
if (e.key === STORAGE_KEY || e.key === COLOR_THEME_STORAGE_KEY) {
applyTheme(getStored(), true);
emitChange();
}
Expand All @@ -102,6 +157,7 @@ function subscribe(listener: () => void): () => void {
export function useTheme() {
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
const theme = snapshot.theme;
const colorTheme = snapshot.colorTheme;

const resolvedTheme: "light" | "dark" =
theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme;
Expand All @@ -112,10 +168,16 @@ export function useTheme() {
emitChange();
}, []);

const setColorTheme = useCallback((next: ColorTheme) => {
localStorage.setItem(COLOR_THEME_STORAGE_KEY, next);
applyTheme(getStored(), true);
emitChange();
}, []);

// Keep DOM in sync on mount/change
useEffect(() => {
applyTheme(theme);
}, [theme]);

return { theme, setTheme, resolvedTheme } as const;
return { theme, setTheme, resolvedTheme, colorTheme, setColorTheme } as const;
}
1 change: 1 addition & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "./themes.css";

@custom-variant dark (&:is(.dark, .dark *));

Expand Down
42 changes: 40 additions & 2 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { SidebarInset } from "../components/ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
import { useTheme, COLOR_THEMES } from "../hooks/useTheme";
import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { cn } from "../lib/utils";
import { ensureNativeApi, readNativeApi } from "../nativeApi";
Expand Down Expand Up @@ -188,7 +188,7 @@ function SettingResetButton({ label, onClick }: { label: string; onClick: () =>
}

function SettingsRouteView() {
const { theme, setTheme } = useTheme();
const { theme, setTheme, colorTheme, setColorTheme } = useTheme();
const { settings, defaults, updateSettings, resetSettings } = useAppSettings();
const serverConfigQuery = useQuery(serverConfigQueryOptions());
const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
Expand Down Expand Up @@ -253,6 +253,7 @@ function SettingsRouteView() {
settings.codexHomePath !== defaults.codexHomePath;
const changedSettingLabels = [
...(theme !== "system" ? ["Theme"] : []),
...(colorTheme !== "default" ? ["Color theme"] : []),
...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []),
...(settings.diffWordWrap !== defaults.diffWordWrap ? ["Diff line wrapping"] : []),
...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming
Expand Down Expand Up @@ -371,6 +372,7 @@ function SettingsRouteView() {
if (!confirmed) return;

setTheme("system");
setColorTheme("default");
resetSettings();
setOpenInstallProviders({
codex: false,
Expand Down Expand Up @@ -461,6 +463,42 @@ function SettingsRouteView() {
}
/>

<SettingsRow
title="Color theme"
description="Pick a color palette for light and dark modes."
resetAction={
colorTheme !== "default" ? (
<SettingResetButton
label="color theme"
onClick={() => setColorTheme("default")}
/>
) : null
}
control={
<Select
value={colorTheme}
onValueChange={(value) => {
const match = COLOR_THEMES.find((t) => t.id === value);
if (!match) return;
setColorTheme(match.id);
}}
>
<SelectTrigger className="w-full sm:w-40" aria-label="Color theme">
<SelectValue>
{COLOR_THEMES.find((t) => t.id === colorTheme)?.label ?? "Default"}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{COLOR_THEMES.map((t) => (
<SelectItem hideIndicator key={t.id} value={t.id}>
{t.label}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>

<SettingsRow
title="Time format"
description="System default follows your browser or OS clock preference."
Expand Down
Loading
Loading