diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css
index 4fd02863..64d2b9a3 100644
--- a/apps/x/apps/renderer/src/App.css
+++ b/apps/x/apps/renderer/src/App.css
@@ -123,6 +123,9 @@
--sidebar-accent-foreground: var(--text-color, oklch(0.205 0 0));
--sidebar-border: var(--sub-alt-color, oklch(0.922 0 0));
--sidebar-ring: var(--main-color, oklch(0.708 0 0));
+ --scrollbar-track: oklch(0.95 0 0);
+ --scrollbar-thumb: oklch(0.75 0 0);
+ --scrollbar-thumb-hover: oklch(0.65 0 0);
}
.dark {
@@ -157,15 +160,39 @@
--sidebar-accent-foreground: var(--text-color, oklch(0.985 0 0));
--sidebar-border: var(--sub-alt-color, oklch(1 0 0 / 10%));
--sidebar-ring: var(--main-color, oklch(0.556 0 0));
+ --scrollbar-track: oklch(0.2 0 0);
+ --scrollbar-thumb: oklch(0.4 0 0);
+ --scrollbar-thumb-hover: oklch(0.5 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
+ scrollbar-width: thin;
}
+
body {
@apply bg-background text-foreground;
}
+
+ ::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ }
+
+ ::-webkit-scrollbar-track {
+ background: var(--scrollbar-track);
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background: var(--scrollbar-thumb);
+ border-radius: 4px;
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background: var(--scrollbar-thumb-hover);
+ }
}
/* Markdown content base styles for Streamdown/MessageResponse */
diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx
index b60ec736..7d986401 100644
--- a/apps/x/apps/renderer/src/components/settings-dialog.tsx
+++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx
@@ -1,8 +1,8 @@
"use client"
import * as React from "react"
-import { useState, useEffect } from "react"
-import { Server, Key, Shield } from "lucide-react"
+import { useState, useEffect, useCallback } from "react"
+import { Server, Key, Shield, Palette, Monitor, Sun, Moon } from "lucide-react"
import {
Dialog,
@@ -11,14 +11,15 @@ import {
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
+import { useTheme, type Theme } from "@/contexts/theme-context"
-type ConfigTab = "models" | "mcp" | "security"
+type ConfigTab = "models" | "mcp" | "security" | "appearance"
interface TabConfig {
id: ConfigTab
label: string
icon: React.ElementType
- path: string
+ path?: string
description: string
}
@@ -44,12 +45,82 @@ const tabs: TabConfig[] = [
path: "config/security.json",
description: "Configure allowed shell commands",
},
+ {
+ id: "appearance",
+ label: "Appearance",
+ icon: Palette,
+ description: "Customize the look and feel",
+ },
]
interface SettingsDialogProps {
children: React.ReactNode
}
+function ThemeOption({
+ label,
+ icon: Icon,
+ isSelected,
+ onClick,
+}: {
+ label: string
+ icon: React.ElementType
+ isSelected: boolean
+ onClick: () => void
+}) {
+ return (
+
+ )
+}
+
+function AppearanceSettings() {
+ const { theme, setTheme } = useTheme()
+
+ return (
+
+
+
Theme
+
+ Select your preferred color scheme
+
+
+ setTheme("light")}
+ />
+ setTheme("dark")}
+ />
+ setTheme("system")}
+ />
+
+
+
+ )
+}
+
export function SettingsDialog({ children }: SettingsDialogProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState("models")
@@ -60,9 +131,20 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
const [error, setError] = useState(null)
const activeTabConfig = tabs.find((t) => t.id === activeTab)!
+ const isConfigTab = activeTab !== "appearance"
- const loadConfig = async (tab: ConfigTab) => {
+ const formatJson = (jsonString: string): string => {
+ try {
+ return JSON.stringify(JSON.parse(jsonString), null, 2)
+ } catch {
+ return jsonString
+ }
+ }
+
+ const loadConfig = useCallback(async (tab: ConfigTab) => {
+ if (tab === "appearance") return
const tabConfig = tabs.find((t) => t.id === tab)!
+ if (!tabConfig.path) return
setLoading(true)
setError(null)
try {
@@ -72,16 +154,17 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
const formattedContent = formatJson(result.data)
setContent(formattedContent)
setOriginalContent(formattedContent)
- } catch (err) {
+ } catch {
setError(`Failed to load ${tabConfig.label} config`)
setContent("")
setOriginalContent("")
} finally {
setLoading(false)
}
- }
+ }, [])
const saveConfig = async () => {
+ if (!isConfigTab || !activeTabConfig.path) return
setSaving(true)
setError(null)
try {
@@ -103,14 +186,6 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
}
}
- const formatJson = (jsonString: string): string => {
- try {
- return JSON.stringify(JSON.parse(jsonString), null, 2)
- } catch {
- return jsonString
- }
- }
-
const handleFormat = () => {
setContent(formatJson(content))
}
@@ -118,10 +193,10 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
const hasChanges = content !== originalContent
useEffect(() => {
- if (open) {
+ if (open && activeTab !== "appearance") {
loadConfig(activeTab)
}
- }, [open, activeTab])
+ }, [open, activeTab, loadConfig])
const handleTabChange = (tab: ConfigTab) => {
if (hasChanges) {
@@ -173,9 +248,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
- {/* Editor */}
+ {/* Content */}
- {loading ? (
+ {activeTab === "appearance" ? (
+
+ ) : loading ? (
Loading...
@@ -190,36 +267,38 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
)}
- {/* Footer */}
-
-
- {error && (
-
{error}
- )}
- {hasChanges && !error && (
-
- Unsaved changes
-
- )}
+ {/* Footer - only show for config tabs */}
+ {isConfigTab && (
+
+
+ {error && (
+ {error}
+ )}
+ {hasChanges && !error && (
+
+ Unsaved changes
+
+ )}
+
+
+
+
+
-
-
-
-
-
+ )}
diff --git a/apps/x/apps/renderer/src/contexts/theme-context.tsx b/apps/x/apps/renderer/src/contexts/theme-context.tsx
new file mode 100644
index 00000000..1149cb42
--- /dev/null
+++ b/apps/x/apps/renderer/src/contexts/theme-context.tsx
@@ -0,0 +1,93 @@
+"use client"
+
+import * as React from "react"
+
+export type Theme = "light" | "dark" | "system"
+
+type ThemeContextProps = {
+ theme: Theme
+ resolvedTheme: "light" | "dark"
+ setTheme: (theme: Theme) => void
+}
+
+const ThemeContext = React.createContext(null)
+
+const STORAGE_KEY = "rowboat-theme"
+
+function getSystemTheme(): "light" | "dark" {
+ if (typeof window === "undefined") return "light"
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
+}
+
+export function useTheme() {
+ const context = React.useContext(ThemeContext)
+ if (!context) {
+ throw new Error("useTheme must be used within a ThemeProvider.")
+ }
+ return context
+}
+
+export function ThemeProvider({
+ defaultTheme = "system",
+ children,
+}: {
+ defaultTheme?: Theme
+ children: React.ReactNode
+}) {
+ const [theme, setThemeState] = React.useState(() => {
+ if (typeof window === "undefined") return defaultTheme
+ const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
+ return stored || defaultTheme
+ })
+
+ const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
+ if (theme === "system") return getSystemTheme()
+ return theme
+ })
+
+ // Apply theme to document
+ React.useEffect(() => {
+ const root = document.documentElement
+ const resolved = theme === "system" ? getSystemTheme() : theme
+
+ root.classList.remove("light", "dark")
+ root.classList.add(resolved)
+ setResolvedTheme(resolved)
+ }, [theme])
+
+ // Listen for system theme changes
+ React.useEffect(() => {
+ if (theme !== "system") return
+
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
+ const handleChange = () => {
+ const resolved = getSystemTheme()
+ document.documentElement.classList.remove("light", "dark")
+ document.documentElement.classList.add(resolved)
+ setResolvedTheme(resolved)
+ }
+
+ mediaQuery.addEventListener("change", handleChange)
+ return () => mediaQuery.removeEventListener("change", handleChange)
+ }, [theme])
+
+ const setTheme = React.useCallback((newTheme: Theme) => {
+ localStorage.setItem(STORAGE_KEY, newTheme)
+ setThemeState(newTheme)
+ }, [])
+
+ const contextValue = React.useMemo(
+ () => ({
+ theme,
+ resolvedTheme,
+ setTheme,
+ }),
+ [theme, resolvedTheme, setTheme]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/main.tsx b/apps/x/apps/renderer/src/main.tsx
index 0feeaa41..7ad7ac86 100644
--- a/apps/x/apps/renderer/src/main.tsx
+++ b/apps/x/apps/renderer/src/main.tsx
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { PostHogProvider } from 'posthog-js/react'
+import { ThemeProvider } from '@/contexts/theme-context'
const options = {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
@@ -12,7 +13,9 @@ const options = {
createRoot(document.getElementById('root')!).render(
-
+
+
+
,
)