Skip to content

Commit 859c03c

Browse files
Maxch3306claude
andcommitted
feat: add Appearance settings with theme presets and color customization
- Add theme-utils.ts with HSL color math, CSS var derivation from 4 inputs (accent, background, foreground, contrast), and 5 presets (Default Dark/Light, Ocean, Rosewood, Monochrome) - Add appearance-store.ts (Zustand + localStorage) for theme state - Add AppearanceSection.tsx with theme mode toggle (light/dark/system), preset grid, color pickers with hex inputs, and contrast slider - Initialize theme before React render to prevent flash - Add range slider CSS styling - Add i18n keys for en and zh-TW - Redesign ChatInput to Codex-style (card container, bottom toolbar, circular send button, model selector below input) - Center chat messages and input with max-w-3xl - Equal-height setup page cards with grid layout Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 33d2549 commit 859c03c

12 files changed

Lines changed: 654 additions & 49 deletions

File tree

src/components/chat/ChatInput.tsx

Lines changed: 60 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Plus, ArrowUp, Square, ChevronDown } from "lucide-react";
12
import { useState, useRef, useEffect } from "react";
23
import { useTranslation } from "react-i18next";
34
import { useChatStore } from "@/stores/chat-store";
@@ -18,16 +19,13 @@ export function ChatInput() {
1819
const configuredProviders = useSettingsStore((s) => s.configuredProviders);
1920
const defaultModelId = useSettingsStore((s) => s.defaultModelId);
2021

21-
// Only show models from configured providers
2222
const availableModels = models.filter((m) => configuredProviders.has(m.provider));
2323

2424
const [selectedModelId, setSelectedModelId] = useState("");
2525

26-
// Set initial model: use default from config, or first available
2726
useEffect(() => {
2827
if (availableModels.length > 0 && !selectedModelId) {
2928
if (defaultModelId) {
30-
// Check if default model is in available models
3129
const match = availableModels.find((m) => `${m.provider}/${m.id}` === defaultModelId);
3230
if (match) {
3331
setSelectedModelId(defaultModelId);
@@ -81,43 +79,67 @@ export function ChatInput() {
8179
}
8280

8381
return (
84-
<div className="border-t border-border p-3 bg-card">
85-
{/* Model selector row */}
86-
{availableModels.length > 0 && (
87-
<div className="flex items-center gap-2 mb-2">
88-
<label className="text-xs text-muted-foreground shrink-0">{t("chat.model")}</label>
89-
<select
90-
value={selectedModelId}
91-
onChange={(e) => handleModelChange(e.target.value)}
92-
className="bg-background border border-input rounded-lg px-2 py-1 text-xs text-foreground focus:outline-none focus:border-primary max-w-xs truncate"
82+
<div className="px-4 pb-3 pt-1">
83+
{/* Main input container */}
84+
<div className="rounded-2xl border border-border bg-card shadow-sm">
85+
{/* Textarea area */}
86+
<div className="relative px-4 pt-3 pb-2">
87+
<textarea
88+
ref={textareaRef}
89+
value={text}
90+
onChange={(e) => setText(e.target.value)}
91+
onKeyDown={handleKeyDown}
92+
onInput={handleInput}
93+
placeholder={t("chat.typeMessage")}
94+
rows={1}
95+
className="w-full resize-none bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none overflow-hidden min-h-[24px]"
96+
/>
97+
</div>
98+
99+
{/* Bottom toolbar */}
100+
<div className="flex items-center justify-between px-3 pb-2.5">
101+
<div className="flex items-center gap-1">
102+
{/* Attach button */}
103+
<Button
104+
variant="ghost"
105+
size="icon"
106+
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
107+
>
108+
<Plus size={16} />
109+
</Button>
110+
111+
{/* Model selector */}
112+
{availableModels.length > 0 && (
113+
<div className="relative">
114+
<select
115+
value={selectedModelId}
116+
onChange={(e) => handleModelChange(e.target.value)}
117+
className="appearance-none bg-transparent text-xs text-muted-foreground hover:text-foreground cursor-pointer focus:outline-none pr-4 pl-2 py-1 rounded-md hover:bg-secondary transition-colors"
118+
>
119+
{availableModels.map((m) => (
120+
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
121+
{m.name || m.id}
122+
</option>
123+
))}
124+
</select>
125+
<ChevronDown
126+
size={10}
127+
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
128+
/>
129+
</div>
130+
)}
131+
</div>
132+
133+
{/* Send / Stop button */}
134+
<Button
135+
onClick={handleSubmit}
136+
size="icon"
137+
variant={streaming ? "destructive" : "default"}
138+
className="h-8 w-8 rounded-full"
93139
>
94-
{availableModels.map((m) => (
95-
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
96-
{m.name || m.id} ({m.provider})
97-
</option>
98-
))}
99-
</select>
140+
{streaming ? <Square size={14} /> : <ArrowUp size={16} />}
141+
</Button>
100142
</div>
101-
)}
102-
{/* Input row */}
103-
<div className="flex items-end gap-2">
104-
<textarea
105-
ref={textareaRef}
106-
value={text}
107-
onChange={(e) => setText(e.target.value)}
108-
onKeyDown={handleKeyDown}
109-
onInput={handleInput}
110-
placeholder={t("chat.typeMessage")}
111-
rows={1}
112-
className="flex-1 resize-none bg-background border border-input rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary overflow-hidden"
113-
/>
114-
<Button
115-
onClick={handleSubmit}
116-
variant={streaming ? "destructive" : "default"}
117-
size="sm"
118-
>
119-
{streaming ? t("chat.stop") : t("chat.send")}
120-
</Button>
121143
</div>
122144
</div>
123145
);

src/components/chat/ChatPanel.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -293,13 +293,17 @@ export function ChatPanel() {
293293
return (
294294
<div className="flex-1 flex flex-col overflow-hidden">
295295
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4">
296-
{messages.length === 0 && <WelcomeScreen />}
297-
{messages.map((msg) => (
298-
<ChatMessage key={msg.id} msg={msg} />
299-
))}
296+
<div className="max-w-3xl mx-auto">
297+
{messages.length === 0 && <WelcomeScreen />}
298+
{messages.map((msg) => (
299+
<ChatMessage key={msg.id} msg={msg} />
300+
))}
301+
</div>
300302
</div>
301303
<ToolActivityIndicator />
302-
<ChatInput />
304+
<div className="max-w-3xl mx-auto w-full">
305+
<ChatInput />
306+
</div>
303307
</div>
304308
);
305309
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { RotateCcw, Sun, Moon, Monitor } from "lucide-react";
2+
import { useTranslation } from "react-i18next";
3+
import { useAppearanceStore } from "@/stores/appearance-store";
4+
import { THEME_PRESETS } from "@/lib/theme-utils";
5+
import { Button } from "@/components/ui/button";
6+
import { Card } from "@/components/ui/card";
7+
import { Input } from "@/components/ui/input";
8+
import type { ThemeMode } from "@/stores/appearance-store";
9+
10+
function ThemeModeToggle() {
11+
const { t } = useTranslation();
12+
const themeMode = useAppearanceStore((s) => s.themeMode);
13+
const setThemeMode = useAppearanceStore((s) => s.setThemeMode);
14+
15+
const modes: { value: ThemeMode; label: string; icon: React.ReactNode }[] = [
16+
{ value: "light", label: t("settings.appearance.light"), icon: <Sun size={14} /> },
17+
{ value: "dark", label: t("settings.appearance.dark"), icon: <Moon size={14} /> },
18+
{ value: "system", label: t("settings.appearance.system"), icon: <Monitor size={14} /> },
19+
];
20+
21+
return (
22+
<div className="flex rounded-lg border border-border overflow-hidden">
23+
{modes.map((m) => (
24+
<button
25+
key={m.value}
26+
onClick={() => setThemeMode(m.value)}
27+
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${
28+
themeMode === m.value
29+
? "bg-primary text-primary-foreground"
30+
: "bg-card text-muted-foreground hover:text-foreground hover:bg-secondary"
31+
}`}
32+
>
33+
{m.icon}
34+
{m.label}
35+
</button>
36+
))}
37+
</div>
38+
);
39+
}
40+
41+
function PresetGrid() {
42+
const { t } = useTranslation();
43+
const activePresetId = useAppearanceStore((s) => s.activePresetId);
44+
const applyPreset = useAppearanceStore((s) => s.applyPreset);
45+
46+
return (
47+
<div className="grid grid-cols-2 gap-2">
48+
{THEME_PRESETS.map((preset) => {
49+
const isActive = activePresetId === preset.id;
50+
return (
51+
<button
52+
key={preset.id}
53+
onClick={() => applyPreset(preset.id)}
54+
className={`flex items-center gap-3 p-3 rounded-lg border-2 text-left transition-all ${
55+
isActive
56+
? "border-primary bg-primary/5"
57+
: "border-border bg-card hover:border-muted-foreground/30 hover:bg-secondary"
58+
}`}
59+
>
60+
{/* Color swatches */}
61+
<div className="flex flex-col gap-0.5 shrink-0">
62+
<div className="w-5 h-5 rounded-full border border-border" style={{ backgroundColor: preset.accent }} />
63+
<div className="flex gap-0.5">
64+
<div className="w-2.5 h-2.5 rounded-sm border border-border/50" style={{ backgroundColor: preset.background }} />
65+
<div className="w-2.5 h-2.5 rounded-sm border border-border/50" style={{ backgroundColor: preset.foreground }} />
66+
</div>
67+
</div>
68+
<span className="text-xs font-medium text-foreground">
69+
{t(preset.name)}
70+
</span>
71+
</button>
72+
);
73+
})}
74+
</div>
75+
);
76+
}
77+
78+
function ColorRow({
79+
label,
80+
value,
81+
onChange,
82+
}: {
83+
label: string;
84+
value: string;
85+
onChange: (hex: string) => void;
86+
}) {
87+
return (
88+
<div className="flex items-center justify-between gap-4">
89+
<span className="text-sm text-foreground">{label}</span>
90+
<div className="flex items-center gap-2">
91+
<div className="relative">
92+
<div
93+
className="w-8 h-8 rounded-full border border-border cursor-pointer"
94+
style={{ backgroundColor: value }}
95+
/>
96+
<input
97+
type="color"
98+
value={value}
99+
onChange={(e) => onChange(e.target.value)}
100+
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
101+
/>
102+
</div>
103+
<Input
104+
value={value.toUpperCase()}
105+
onChange={(e) => {
106+
const v = e.target.value;
107+
if (/^#[0-9a-f]{6}$/i.test(v)) {
108+
onChange(v);
109+
}
110+
}}
111+
className="w-24 h-8 text-xs font-mono bg-background"
112+
/>
113+
</div>
114+
</div>
115+
);
116+
}
117+
118+
function ContrastSlider() {
119+
const { t } = useTranslation();
120+
const contrast = useAppearanceStore((s) => s.contrast);
121+
const setContrast = useAppearanceStore((s) => s.setContrast);
122+
123+
return (
124+
<div>
125+
<div className="flex items-center justify-between mb-2">
126+
<span className="text-sm text-foreground">{t("settings.appearance.contrast")}</span>
127+
<span className="text-xs text-muted-foreground font-mono w-8 text-right">{contrast}</span>
128+
</div>
129+
<input
130+
type="range"
131+
min={0}
132+
max={100}
133+
value={contrast}
134+
onChange={(e) => setContrast(Number(e.target.value))}
135+
className="w-full appearance-slider"
136+
/>
137+
<p className="text-[10px] text-muted-foreground mt-1">
138+
{t("settings.appearance.contrastDesc")}
139+
</p>
140+
</div>
141+
);
142+
}
143+
144+
export function AppearanceSection() {
145+
const { t } = useTranslation();
146+
const accentColor = useAppearanceStore((s) => s.accentColor);
147+
const backgroundColor = useAppearanceStore((s) => s.backgroundColor);
148+
const foregroundColor = useAppearanceStore((s) => s.foregroundColor);
149+
const setAccentColor = useAppearanceStore((s) => s.setAccentColor);
150+
const setBackgroundColor = useAppearanceStore((s) => s.setBackgroundColor);
151+
const setForegroundColor = useAppearanceStore((s) => s.setForegroundColor);
152+
const resetToDefault = useAppearanceStore((s) => s.resetToDefault);
153+
154+
return (
155+
<div className="max-w-2xl mx-auto p-6">
156+
<h1 className="text-lg font-semibold text-foreground mb-6">
157+
{t("settings.appearance.title")}
158+
</h1>
159+
160+
<div className="space-y-6">
161+
{/* Theme Mode */}
162+
<section>
163+
<h2 className="text-sm font-medium text-muted-foreground mb-3">
164+
{t("settings.appearance.themeMode")}
165+
</h2>
166+
<ThemeModeToggle />
167+
</section>
168+
169+
{/* Presets */}
170+
<section>
171+
<h2 className="text-sm font-medium text-muted-foreground mb-3">
172+
{t("settings.appearance.presets")}
173+
</h2>
174+
<PresetGrid />
175+
</section>
176+
177+
{/* Colors */}
178+
<section>
179+
<h2 className="text-sm font-medium text-muted-foreground mb-3">
180+
{t("settings.appearance.colors")}
181+
</h2>
182+
<Card className="p-4 space-y-4">
183+
<ColorRow
184+
label={t("settings.appearance.accentColor")}
185+
value={accentColor}
186+
onChange={setAccentColor}
187+
/>
188+
<ColorRow
189+
label={t("settings.appearance.backgroundColor")}
190+
value={backgroundColor}
191+
onChange={setBackgroundColor}
192+
/>
193+
<ColorRow
194+
label={t("settings.appearance.foregroundColor")}
195+
value={foregroundColor}
196+
onChange={setForegroundColor}
197+
/>
198+
</Card>
199+
</section>
200+
201+
{/* Contrast */}
202+
<section>
203+
<ContrastSlider />
204+
</section>
205+
206+
{/* Reset */}
207+
<section>
208+
<Button variant="outline" size="sm" onClick={resetToDefault}>
209+
<RotateCcw size={12} />
210+
{t("settings.appearance.resetToDefault")}
211+
</Button>
212+
</section>
213+
</div>
214+
</div>
215+
);
216+
}

src/global.css

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,25 @@ html, body, #root {
6464
::-webkit-scrollbar-thumb:hover {
6565
background: hsl(var(--muted-foreground));
6666
}
67+
68+
/* Range slider */
69+
.appearance-slider {
70+
-webkit-appearance: none;
71+
appearance: none;
72+
height: 6px;
73+
border-radius: 3px;
74+
background: hsl(var(--border));
75+
outline: none;
76+
}
77+
78+
.appearance-slider::-webkit-slider-thumb {
79+
-webkit-appearance: none;
80+
appearance: none;
81+
width: 16px;
82+
height: 16px;
83+
border-radius: 50%;
84+
background: hsl(var(--primary));
85+
cursor: pointer;
86+
border: 2px solid hsl(var(--background));
87+
box-shadow: 0 0 0 1px hsl(var(--border));
88+
}

0 commit comments

Comments
 (0)