diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 03483af99..e1b41b450 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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]); diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index fcfe9a140..f324ae158 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -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, diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 983fd1147..b1d0230f9 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -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", diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 5d9b27e63..c3eb01e31 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -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, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 5ae598445..e6fb347b7 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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", diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts index 762f610e5..1c3793e49 100644 --- a/packages/app/src/hooks/use-settings.ts +++ b/packages/app/src/hooks/use-settings.ts @@ -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([...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 { const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY); if (stored) { const parsed = JSON.parse(stored) as Partial; + if (parsed.theme && !VALID_THEMES.has(parsed.theme)) { + parsed.theme = DEFAULT_APP_SETTINGS.theme; + } return { ...DEFAULT_APP_SETTINGS, ...parsed }; } diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 507a5a220..f25aa969b 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -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 ; + case "dark": + return ; + case "auto": + return ; + default: + return ; + } +} + +function ThemeSwatch({ color, size }: { color: string; size: number }) { + return ( + + ); +} + +const THEME_LABELS: Record = { + 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 ( General @@ -375,29 +420,43 @@ function GeneralSection({ Theme - , - }, - { - value: "dark", - label: "Dark", - icon: ({ color, size }) => , - }, - { - value: "auto", - label: "System", - icon: ({ color, size }) => , - }, - ]} - /> + + [ + styles.themeTrigger, + pressed && { opacity: 0.85 }, + ]} + > + + + {THEME_LABELS[settings.theme]} + + + + + {(["light", "dark", "auto"] as const).map((t) => ( + handleThemeChange(t)} + leading={} + > + {THEME_LABELS[t]} + + ))} + + {(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => ( + handleThemeChange(t)} + leading={} + > + {THEME_LABELS[t]} + + ))} + + @@ -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], }, diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 1c72d8b59..c0f40f86b 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -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) { + 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 = { + light: "light", + dark: "dark", + zinc: "darkZinc", + midnight: "darkMidnight", + claude: "darkClaude", + ghostty: "darkGhostty", +}; + +export const THEME_SWATCHES: Record = { + light: "#ffffff", + dark: "#2D8B62", + zinc: "#808080", + midnight: "#4A6BA8", + claude: "#D97757", + ghostty: "#8caaee", +}; diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index 4bcb6e072..3bb09dc8a 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -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);