feat: add theme selector with multiple dark themes

Add theme dropdown in settings with Light, Dark, System plus
Zinc, Midnight, Claude, and Ghostty dark variants. Each theme
has its own accent color and surface tints. Desaturate terminal
ANSI colors across all dark themes. Add surfaceSidebarHover
semantic token so hover states go the right direction in both
light and dark modes. Self-heal if a stored theme no longer exists.
This commit is contained in:
Mohamed Boudra
2026-04-10 16:21:05 +07:00
parent 120c1b46a4
commit 2f77674c55
9 changed files with 375 additions and 135 deletions
+2 -1
View File
@@ -14,6 +14,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { THEME_TO_UNISTYLES } from "@/styles/theme";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
@@ -561,7 +562,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(settings.theme);
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]);
}
}, [settingsLoading, settings.theme]);
@@ -472,7 +472,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
},
tabActive: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
tabText: {
fontSize: theme.fontSize.sm,
@@ -849,7 +849,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
},
entryRowActive: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
indentGuide: {
position: "absolute",
+3 -3
View File
@@ -885,10 +885,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
},
newAgentButtonHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
newAgentButtonActive: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
hostTrigger: {
flexDirection: "row",
@@ -901,7 +901,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
},
hostTriggerHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
hostStatusDot: {
width: 8,
@@ -1980,7 +1980,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
projectRowHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
projectRowPressed: {
backgroundColor: theme.colors.surface2,
@@ -2050,7 +2050,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
projectActionButtonHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
projectActionButtonText: {
color: theme.colors.foregroundMuted,
@@ -2065,7 +2065,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
projectIconActionButtonHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
projectIconActionButtonHidden: {
opacity: 0,
@@ -2143,7 +2143,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
workspaceRowHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
@@ -2157,7 +2157,7 @@ const styles = StyleSheet.create((theme) => ({
...theme.shadow.md,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowContainer: {
position: "relative",
+8 -1
View File
@@ -6,10 +6,14 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
export type SendBehavior = "interrupt" | "queue";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
export interface AppSettings {
theme: "dark" | "light" | "auto";
theme: ThemeName | "auto";
manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior;
}
@@ -78,6 +82,9 @@ export async function loadSettingsFromStorage(): Promise<AppSettings> {
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<AppSettings>;
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
parsed.theme = DEFAULT_APP_SETTINGS.theme;
}
return { ...DEFAULT_APP_SETTINGS, ...parsed };
}
+96 -23
View File
@@ -10,6 +10,7 @@ import {
Sun,
Moon,
Monitor,
ChevronDown,
Globe,
Settings,
RotateCw,
@@ -24,6 +25,7 @@ import {
Smartphone,
} from "lucide-react-native";
import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings";
import { THEME_SWATCHES, type ThemeName } from "@/styles/theme";
import type { HostProfile, HostConnection } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
@@ -47,6 +49,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
@@ -362,11 +365,53 @@ interface GeneralSectionProps {
handleSendBehaviorChange: (behavior: SendBehavior) => void;
}
function ThemeIcon({ theme, size, color }: { theme: AppSettings["theme"]; size: number; color: string }) {
switch (theme) {
case "light":
return <Sun size={size} color={color} />;
case "dark":
return <Moon size={size} color={color} />;
case "auto":
return <Monitor size={size} color={color} />;
default:
return <ThemeSwatch color={THEME_SWATCHES[theme]} size={size} />;
}
}
function ThemeSwatch({ color, size }: { color: string; size: number }) {
return (
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.15)",
}}
/>
);
}
const THEME_LABELS: Record<AppSettings["theme"], string> = {
light: "Light",
dark: "Dark",
zinc: "Zinc",
midnight: "Midnight",
claude: "Claude",
ghostty: "Ghostty",
auto: "System",
};
function GeneralSection({
settings,
handleThemeChange,
handleSendBehaviorChange,
}: GeneralSectionProps) {
const { theme } = useUnistyles();
const iconSize = theme.iconSize.md;
const iconColor = theme.colors.foregroundMuted;
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>General</Text>
@@ -375,29 +420,43 @@ function GeneralSection({
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Theme</Text>
</View>
<SegmentedControl
size="sm"
hideLabels={Platform.OS !== "web"}
value={settings.theme}
onValueChange={handleThemeChange}
options={[
{
value: "light",
label: "Light",
icon: ({ color, size }) => <Sun size={size} color={color} />,
},
{
value: "dark",
label: "Dark",
icon: ({ color, size }) => <Moon size={size} color={color} />,
},
{
value: "auto",
label: "System",
icon: ({ color, size }) => <Monitor size={size} color={color} />,
},
]}
/>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.themeTrigger,
pressed && { opacity: 0.85 },
]}
>
<ThemeIcon theme={settings.theme} size={iconSize} color={iconColor} />
<Text style={styles.themeTriggerText}>
{THEME_LABELS[settings.theme]}
</Text>
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{(["light", "dark", "auto"] as const).map((t) => (
<DropdownMenuItem
key={t}
selected={settings.theme === t}
onSelect={() => handleThemeChange(t)}
leading={<ThemeIcon theme={t} size={iconSize} color={iconColor} />}
>
{THEME_LABELS[t]}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => (
<DropdownMenuItem
key={t}
selected={settings.theme === t}
onSelect={() => handleThemeChange(t)}
leading={<ThemeIcon theme={t} size={iconSize} color={iconColor} />}
>
{THEME_LABELS[t]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
@@ -1806,6 +1865,20 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
themeTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
},
themeTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
disabled: {
opacity: theme.opacity[50],
},
+243 -96
View File
@@ -103,6 +103,8 @@ export const baseColors = {
},
} as const;
export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty";
// Semantic color tokens - Layer-based system
const lightSemanticColors = {
// Surfaces (layers) - shifted one step lighter
@@ -113,10 +115,11 @@ const lightSemanticColors = {
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
surfaceSidebarHover: "#e9e9ec", // Sidebar hover (darker in light mode)
surfaceWorkspace: "#ffffff", // Workspace main background
// Text
foreground: "#09090b",
foreground: "#1a1a1e",
foregroundMuted: "#71717a",
// Controls
@@ -140,11 +143,11 @@ const lightSemanticColors = {
// Legacy aliases (for gradual migration)
background: "#ffffff",
popover: "#ffffff",
popoverForeground: "#09090b",
popoverForeground: "#1a1a1e",
primary: "#18181b",
primaryForeground: "#fafafa",
secondary: "#f4f4f5",
secondaryForeground: "#09090b",
secondaryForeground: "#1a1a1e",
muted: "#f4f4f5",
mutedForeground: "#71717a",
accentBorder: "#ececf1",
@@ -153,13 +156,13 @@ const lightSemanticColors = {
terminal: {
background: "#ffffff",
foreground: "#09090b",
cursor: "#09090b",
foreground: "#1a1a1e",
cursor: "#1a1a1e",
cursorAccent: "#ffffff",
selectionBackground: "rgba(0, 0, 0, 0.15)",
selectionForeground: "#09090b",
selectionForeground: "#1a1a1e",
black: "#09090b",
black: "#1a1a1e",
red: "#dc2626",
green: "#16a34a",
yellow: "#ca8a04",
@@ -179,80 +182,194 @@ const lightSemanticColors = {
},
} as const;
const darkSemanticColors = {
// Surfaces (layers) — subtle teal tint
surface0: "#181B1A", // App background
surface1: "#1E2120", // Subtle hover
surface2: "#272A29", // Elevated: badges, inputs, sheets
surface3: "#434645", // Highest elevation
surface4: "#595B5B", // Extra emphasis
surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: "#141716", // Sidebar background (darker than main)
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
// ---------------------------------------------------------------------------
// Dark theme variant builder
// ---------------------------------------------------------------------------
// Text
foreground: "#fafafa",
interface DarkThemeConfig {
surface0: string;
surface1: string;
surface2: string;
surface3: string;
surface4: string;
surfaceDiffEmpty: string;
surfaceSidebar: string;
surfaceSidebarHover: string;
foregroundMuted: string;
scrollbarHandle: string;
border: string;
borderAccent: string;
accent: string;
accentBright: string;
}
const darkTerminalAnsi = {
red: "#e07070",
green: "#5dba80",
yellow: "#d4a44a",
blue: "#6a9de0",
magenta: "#b07ad0",
cyan: "#4aabb8",
white: "#d4d4d8",
brightRed: "#e89090",
brightGreen: "#7ecf9a",
brightYellow: "#e0be6e",
brightBlue: "#8ab4e8",
brightMagenta: "#c49ae0",
brightCyan: "#6ec2cc",
brightWhite: "#f0f0f2",
} as const;
function buildDarkSemanticColors(tint: DarkThemeConfig) {
return {
surface0: tint.surface0,
surface1: tint.surface1,
surface2: tint.surface2,
surface3: tint.surface3,
surface4: tint.surface4,
surfaceDiffEmpty: tint.surfaceDiffEmpty,
surfaceSidebar: tint.surfaceSidebar,
surfaceSidebarHover: tint.surfaceSidebarHover,
surfaceWorkspace: tint.surface1,
foreground: "#fafafa",
foregroundMuted: tint.foregroundMuted,
scrollbarHandle: tint.scrollbarHandle,
border: tint.border,
borderAccent: tint.borderAccent,
accent: tint.accent,
accentBright: tint.accentBright,
accentForeground: "#ffffff",
destructive: "#ef4444",
destructiveForeground: "#ffffff",
success: tint.accent,
successForeground: "#ffffff",
// Legacy aliases (for gradual migration)
background: tint.surface0,
popover: tint.surface2,
popoverForeground: "#fafafa",
primary: "#fafafa",
primaryForeground: tint.surface0,
secondary: tint.surface2,
secondaryForeground: "#fafafa",
muted: tint.surface2,
mutedForeground: tint.foregroundMuted,
accentBorder: tint.borderAccent,
input: tint.surface2,
ring: "#d4d4d8",
terminal: {
background: tint.surface0,
foreground: "#fafafa",
cursor: "#fafafa",
cursorAccent: tint.surface0,
selectionBackground: "rgba(255, 255, 255, 0.2)",
selectionForeground: "#fafafa",
black: tint.surfaceSidebar,
...darkTerminalAnsi,
brightBlack: tint.surface3,
},
};
}
// ---------------------------------------------------------------------------
// Dark tint definitions
// ---------------------------------------------------------------------------
// Paseo — subtle teal-green tint (default)
const paseoDarkColors = buildDarkSemanticColors({
surface0: "#181B1A",
surface1: "#1E2120",
surface2: "#272A29",
surface3: "#434645",
surface4: "#595B5B",
surfaceDiffEmpty: "#252827",
surfaceSidebar: "#141716",
surfaceSidebarHover: "#1c1f1e",
foregroundMuted: "#A1A5A4",
// Controls
scrollbarHandle: "#717574", // zinc-500 w/ teal tint
// Borders
scrollbarHandle: "#717574",
border: "#252B2A",
borderAccent: "#2F3534",
// Brand
accent: "#20744A",
accentBright: "#7ccba0",
accentForeground: "#ffffff",
});
// Semantic
destructive: "#ef4444",
destructiveForeground: "#ffffff",
success: "#20744A",
successForeground: "#ffffff",
// Zinc — neutral gray, no tint
const zincDarkColors = buildDarkSemanticColors({
surface0: "#18181b",
surface1: "#1f1f22",
surface2: "#27272a",
surface3: "#3f3f46",
surface4: "#52525b",
surfaceDiffEmpty: "#242427",
surfaceSidebar: "#131316",
surfaceSidebarHover: "#1b1b1e",
foregroundMuted: "#a1a1aa",
scrollbarHandle: "#71717a",
border: "#27272a",
borderAccent: "#303036",
accent: "#20744A",
accentBright: "#7ccba0",
});
// Legacy aliases (for gradual migration)
background: "#181B1A",
popover: "#272A29",
popoverForeground: "#fafafa",
primary: "#fafafa",
primaryForeground: "#181B1A",
secondary: "#272A29",
secondaryForeground: "#fafafa",
muted: "#272A29",
mutedForeground: "#A1A5A4",
accentBorder: "#2F3534",
input: "#272A29",
ring: "#d4d4d8",
// Midnight — subtle blue tint
const midnightDarkColors = buildDarkSemanticColors({
surface0: "#161820",
surface1: "#1c1e27",
surface2: "#252731",
surface3: "#3c3e4c",
surface4: "#535564",
surfaceDiffEmpty: "#222430",
surfaceSidebar: "#121420",
surfaceSidebarHover: "#1a1c28",
foregroundMuted: "#9a9db0",
scrollbarHandle: "#6b6e82",
border: "#242636",
borderAccent: "#2e3040",
accent: "#3b6fcf",
accentBright: "#7eaaeb",
});
terminal: {
background: "#181B1A",
foreground: "#fafafa",
cursor: "#fafafa",
cursorAccent: "#181B1A",
selectionBackground: "rgba(255, 255, 255, 0.2)",
selectionForeground: "#fafafa",
// Claude — warm neutral with subtle orange undertone
const claudeDarkColors = buildDarkSemanticColors({
surface0: "#1f1f1e",
surface1: "#262523",
surface2: "#2f2d2b",
surface3: "#4a4745",
surface4: "#605d5b",
surfaceDiffEmpty: "#2a2826",
surfaceSidebar: "#1a1918",
surfaceSidebarHover: "#222120",
foregroundMuted: "#ada9a5",
scrollbarHandle: "#78746f",
border: "#2c2a27",
borderAccent: "#36332f",
accent: "#d97757",
accentBright: "#e89a7f",
});
black: "#141716",
red: "#ef4444",
green: "#22c55e",
yellow: "#f59e0b",
blue: "#3b82f6",
magenta: "#a855f7",
cyan: "#06b6d4",
white: "#e4e4e7",
brightBlack: "#434645",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#fbbf24",
brightBlue: "#60a5fa",
brightMagenta: "#c084fc",
brightCyan: "#22d3ee",
brightWhite: "#ffffff",
},
} as const;
// Ghostty — blue-tinted dark based on Ghostty default background
const ghosttyDarkColors = buildDarkSemanticColors({
surface0: "#282c34",
surface1: "#2f333d",
surface2: "#383c48",
surface3: "#4a4f5e",
surface4: "#5b6175",
surfaceDiffEmpty: "#323643",
surfaceSidebar: "#21252d",
surfaceSidebarHover: "#292d36",
foregroundMuted: "#c8ccd8",
scrollbarHandle: "#a0a4b2",
border: "#353a47",
borderAccent: "#3f4454",
accent: "#89b4fa",
accentBright: "#b4d0fc",
});
const commonTheme = {
spacing: {
@@ -323,35 +440,45 @@ const commonTheme = {
},
} as const;
export const darkTheme = {
colorScheme: "dark" as const,
colors: {
...darkSemanticColors,
palette: baseColors,
const darkShadow = {
sm: {
shadowColor: "rgba(0, 0, 0, 0.25)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 4,
elevation: 2,
},
shadow: {
sm: {
shadowColor: "rgba(0, 0, 0, 0.25)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 4,
elevation: 2,
},
md: {
shadowColor: "rgba(0, 0, 0, 0.20)",
shadowOffset: { width: 0, height: 4 },
shadowRadius: 8,
elevation: 8,
},
lg: {
shadowColor: "rgba(0, 0, 0, 0.40)",
shadowOffset: { width: 0, height: 12 },
shadowRadius: 24,
elevation: 8,
},
md: {
shadowColor: "rgba(0, 0, 0, 0.20)",
shadowOffset: { width: 0, height: 4 },
shadowRadius: 8,
elevation: 8,
},
lg: {
shadowColor: "rgba(0, 0, 0, 0.40)",
shadowOffset: { width: 0, height: 12 },
shadowRadius: 24,
elevation: 8,
},
...commonTheme,
} as const;
function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColors>) {
return {
colorScheme: "dark" as const,
colors: {
...semanticColors,
palette: baseColors,
},
shadow: darkShadow,
...commonTheme,
} as const;
}
export const darkTheme = buildDarkTheme(paseoDarkColors);
export const darkZincTheme = buildDarkTheme(zincDarkColors);
export const darkMidnightTheme = buildDarkTheme(midnightDarkColors);
export const darkClaudeTheme = buildDarkTheme(claudeDarkColors);
export const darkGhosttyTheme = buildDarkTheme(ghosttyDarkColors);
export const lightTheme = {
colorScheme: "light" as const,
colors: {
@@ -386,3 +513,23 @@ export const theme = darkTheme;
// Export a union type that works for both themes
export type Theme = typeof darkTheme | typeof lightTheme;
type UnistylesThemeKey = "light" | "dark" | "darkZinc" | "darkMidnight" | "darkClaude" | "darkGhostty";
export const THEME_TO_UNISTYLES: Record<ThemeName, UnistylesThemeKey> = {
light: "light",
dark: "dark",
zinc: "darkZinc",
midnight: "darkMidnight",
claude: "darkClaude",
ghostty: "darkGhostty",
};
export const THEME_SWATCHES: Record<ThemeName, string> = {
light: "#ffffff",
dark: "#2D8B62",
zinc: "#808080",
midnight: "#4A6BA8",
claude: "#D97757",
ghostty: "#8caaee",
};
+16 -4
View File
@@ -1,11 +1,21 @@
import { StyleSheet } from "react-native-unistyles";
// import { UnistylesRuntime } from "react-native-unistyles";
import { lightTheme, darkTheme } from "./theme";
import {
lightTheme,
darkTheme,
darkZincTheme,
darkMidnightTheme,
darkClaudeTheme,
darkGhosttyTheme,
} from "./theme";
StyleSheet.configure({
themes: {
light: lightTheme,
dark: darkTheme,
darkZinc: darkZincTheme,
darkMidnight: darkMidnightTheme,
darkClaude: darkClaudeTheme,
darkGhostty: darkGhosttyTheme,
},
breakpoints: {
xs: 0,
@@ -23,6 +33,10 @@ StyleSheet.configure({
type AppThemes = {
light: typeof lightTheme;
dark: typeof darkTheme;
darkZinc: typeof darkZincTheme;
darkMidnight: typeof darkMidnightTheme;
darkClaude: typeof darkClaudeTheme;
darkGhostty: typeof darkGhosttyTheme;
};
type AppBreakpoints = {
@@ -37,5 +51,3 @@ declare module "react-native-unistyles" {
export interface UnistylesThemes extends AppThemes {}
export interface UnistylesBreakpoints extends AppBreakpoints {}
}
// UnistylesRuntime.setRootViewBackgroundColor(lightTheme.colors.background);