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
+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],
},