From e3929953fb3dcbd539475b1babbbf6bf5ece3c92 Mon Sep 17 00:00:00 2001 From: Po1nt9 <3399308435@qq.com> Date: Fri, 29 May 2026 06:23:32 +0800 Subject: [PATCH] feat: add dark/light theme toggle and UI improvements - Add theme toggle (light/dark/system) in Settings > Interface with real-time preview - Persist theme preference to local storage - Apply saved theme on app startup - Center welcome screen vertically - Optimize dark theme background with subtle blue tint - Fix pre-existing test failures in ingest-queue and source-path-collision tests Co-Authored-By: Claude Opus 4.7 --- src/components/graph/graph-view.tsx | 2 +- src/components/project/welcome-screen.tsx | 2 +- .../settings/sections/interface-section.tsx | 38 +++++++++++++++- src/components/settings/settings-types.ts | 1 + src/components/settings/settings-view.tsx | 42 ++++++++++++++++- src/i18n/en.json | 7 ++- src/i18n/zh.json | 7 ++- src/index.css | 18 ++++---- src/lib/ingest-queue.integration.test.ts | 11 ++++- src/lib/ingest-source-path-collision.test.ts | 4 +- src/lib/project-store.ts | 12 +++++ src/main.tsx | 45 ++++++++++++++++--- 12 files changed, 165 insertions(+), 24 deletions(-) diff --git a/src/components/graph/graph-view.tsx b/src/components/graph/graph-view.tsx index 74ae94079..1a8cd1fb8 100644 --- a/src/components/graph/graph-view.tsx +++ b/src/components/graph/graph-view.tsx @@ -802,7 +802,7 @@ export function GraphView() { {/* Graph canvas */}
e.preventDefault()} onClick={() => setNodeMenu(null)} > diff --git a/src/components/project/welcome-screen.tsx b/src/components/project/welcome-screen.tsx index bdf698151..d1a08f9ff 100644 --- a/src/components/project/welcome-screen.tsx +++ b/src/components/project/welcome-screen.tsx @@ -31,7 +31,7 @@ export function WelcomeScreen({ } return ( -
+

{t("app.title")}

diff --git a/src/components/settings/sections/interface-section.tsx b/src/components/settings/sections/interface-section.tsx index 1af4c3f9b..f637bfe5f 100644 --- a/src/components/settings/sections/interface-section.tsx +++ b/src/components/settings/sections/interface-section.tsx @@ -5,6 +5,7 @@ import type { SettingsDraft, DraftSetter } from "../settings-types" interface Props { draft: SettingsDraft setDraft: DraftSetter + onThemeChange?: (theme: "light" | "dark" | "system") => void } const UI_LANGUAGES = [ @@ -12,7 +13,13 @@ const UI_LANGUAGES = [ { value: "zh", label: "中文" }, ] -export function InterfaceSection({ draft, setDraft }: Props) { +const THEMES = [ + { value: "light" as const, labelKey: "settings.sections.interface.themeLight" }, + { value: "dark" as const, labelKey: "settings.sections.interface.themeDark" }, + { value: "system" as const, labelKey: "settings.sections.interface.themeSystem" }, +] + +export function InterfaceSection({ draft, setDraft, onThemeChange }: Props) { const { t } = useTranslation() return (
@@ -48,6 +55,35 @@ export function InterfaceSection({ draft, setDraft }: Props) { {t("settings.sections.interface.uiLanguageHint")}

+ +
+ +
+ {THEMES.map((th) => { + const active = draft.theme === th.value + return ( + + ) + })} +
+

+ {t("settings.sections.interface.themeHint")} +

+
) } diff --git a/src/components/settings/settings-types.ts b/src/components/settings/settings-types.ts index 87a06fd76..60379601a 100644 --- a/src/components/settings/settings-types.ts +++ b/src/components/settings/settings-types.ts @@ -65,6 +65,7 @@ export interface SettingsDraft { // UI uiLanguage: string + theme: "light" | "dark" | "system" // Source folder auto watch sourceWatchConfig: SourceWatchConfig diff --git a/src/components/settings/settings-view.tsx b/src/components/settings/settings-view.tsx index 88427b1b4..422bffa02 100644 --- a/src/components/settings/settings-view.tsx +++ b/src/components/settings/settings-view.tsx @@ -21,7 +21,7 @@ import { Button } from "@/components/ui/button" import { useWikiStore } from "@/stores/wiki-store" import { useChatStore } from "@/stores/chat-store" import { useUpdateStore, hasAvailableUpdate } from "@/stores/update-store" -import { loadSourceWatchConfig, saveLanguage } from "@/lib/project-store" +import { loadSourceWatchConfig, saveLanguage, saveTheme, loadTheme } from "@/lib/project-store" import type { SettingsDraft, DraftSetter } from "./settings-types" import { normalizeSourceWatchConfig } from "@/lib/source-watch-config" import { LlmProviderSection } from "./sections/llm-provider-section" @@ -90,6 +90,7 @@ function initialDraft( maxHistoryMessages: number, uiLanguage: string, projectPath?: string, + theme?: "light" | "dark" | "system", ): SettingsDraft { // Show absolute path: if stored path is empty, show default using project path // If stored path is relative (legacy), prepend project path @@ -145,6 +146,24 @@ function initialDraft( apiAllowUnauthenticated: apiConfig.allowUnauthenticated, apiToken: apiConfig.token, uiLanguage, + theme: theme ?? "system", + } +} + +function applyTheme(theme: "light" | "dark" | "system") { + const root = document.documentElement + if (theme === "system") { + // Remove the class and let the media query handle it + root.classList.remove("light", "dark") + // Check system preference + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + root.classList.add("dark") + } else { + root.classList.add("light") + } + } else { + root.classList.remove("light", "dark") + root.classList.add(theme) } } @@ -181,6 +200,7 @@ export function SettingsView() { const [active, setActive] = useState("llm") const [saved, setSaved] = useState(false) + const [currentTheme, setCurrentTheme] = useState<"light" | "dark" | "system">("system") const [draft, setDraftState] = useState(() => initialDraft( llmConfig, @@ -197,6 +217,16 @@ export function SettingsView() { ), ) + // Load theme on mount + useEffect(() => { + loadTheme().then((theme) => { + if (theme) { + setCurrentTheme(theme) + setDraftState((prev) => ({ ...prev, theme })) + } + }).catch(() => {}) + }, []) + useEffect(() => { let cancelled = false loadSourceWatchConfig(project?.id).then((config) => { @@ -394,6 +424,14 @@ export function SettingsView() { await saveLanguage(draft.uiLanguage) } + // Save theme + if (draft.theme !== currentTheme) { + await saveTheme(draft.theme) + setCurrentTheme(draft.theme) + // Apply theme immediately + applyTheme(draft.theme) + } + setSaved(true) setTimeout(() => setSaved(false), 2000) }, [ @@ -435,7 +473,7 @@ export function SettingsView() { case "output": return case "interface": - return + return case "maintenance": return case "changelog": diff --git a/src/i18n/en.json b/src/i18n/en.json index 35a0e9a76..3c9c80b91 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -492,7 +492,12 @@ "title": "Interface", "description": "UI language and appearance. Changes apply immediately and persist.", "uiLanguage": "UI Language", - "uiLanguageHint": "Affects labels, buttons, and other UI strings only. The AI output language is set separately in Output Preferences." + "uiLanguageHint": "Affects labels, buttons, and other UI strings only. The AI output language is set separately in Output Preferences.", + "theme": "Theme", + "themeHint": "Choose between light, dark, or system theme. System theme follows your operating system's appearance setting.", + "themeLight": "Light", + "themeDark": "Dark", + "themeSystem": "System" }, "maintenance": { "title": "Maintenance", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index a1ac1e037..418c3bcf6 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -492,7 +492,12 @@ "title": "界面", "description": "界面语言和外观样式。切换后立即生效并持久化。", "uiLanguage": "UI 语言", - "uiLanguageHint": "只影响按钮、标签这些 UI 文案,不影响 AI 输出语言(那个在\"输出偏好\"里单独设置)。" + "uiLanguageHint": "只影响按钮、标签这些 UI 文案,不影响 AI 输出语言(那个在\"输出偏好\"里单独设置)。", + "theme": "主题", + "themeHint": "选择浅色、深色或跟随系统主题。系统主题会跟随操作系统的外观设置。", + "themeLight": "浅色", + "themeDark": "深色", + "themeSystem": "跟随系统" }, "maintenance": { "title": "维护", diff --git a/src/index.css b/src/index.css index 1a9c35d9e..b153bb064 100644 --- a/src/index.css +++ b/src/index.css @@ -84,22 +84,22 @@ } .dark { - --background: oklch(0.145 0 0); + --background: oklch(0.16 0.005 260); --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); + --card: oklch(0.205 0.005 260); --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); + --popover: oklch(0.205 0.005 260); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); + --secondary: oklch(0.269 0.005 260); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); + --muted: oklch(0.269 0.005 260); --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); + --accent: oklch(0.269 0.005 260); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); + --border: oklch(1 0 0 / 12%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.87 0 0); @@ -107,11 +107,11 @@ --chart-3: oklch(0.439 0 0); --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); + --sidebar: oklch(0.18 0.005 260); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent: oklch(0.24 0.005 260); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); diff --git a/src/lib/ingest-queue.integration.test.ts b/src/lib/ingest-queue.integration.test.ts index a434d147c..869f46907 100644 --- a/src/lib/ingest-queue.integration.test.ts +++ b/src/lib/ingest-queue.integration.test.ts @@ -94,6 +94,8 @@ describe("ingest-queue persistence — write", () => { return false } }) + // Add small delay to ensure file is fully written + await new Promise(resolve => setTimeout(resolve, 50)) const parsed = JSON.parse(await readQueueFile()) expect(parsed[0].sourcePath).toBe("raw/sources/a.md") }) @@ -105,12 +107,15 @@ describe("ingest-queue persistence — write", () => { ]) await waitFor(async () => { try { - const parsed = JSON.parse(await readQueueFile()) + const c = await readQueueFile() + const parsed = JSON.parse(c) return parsed.length === 1 } catch { return false } }) + // Add small delay to ensure file is fully written + await new Promise(resolve => setTimeout(resolve, 50)) const parsed = JSON.parse(await readQueueFile()) expect(parsed).toHaveLength(1) expect(parsed[0].sourcePath).toBe("raw/sources/a.md") @@ -129,6 +134,8 @@ describe("ingest-queue persistence — write", () => { return false } }) + // Add small delay to ensure file is fully written + await new Promise(resolve => setTimeout(resolve, 50)) const parsed = JSON.parse(await readQueueFile()) as Array<{ sourcePath: string; folderContext: string }> const paths = parsed.map((p) => p.sourcePath) expect(paths).toContain("raw/sources/注意力机制.pdf") @@ -269,6 +276,8 @@ describe("ingest-queue persistence — restore round-trip", () => { return false } }) + // Add small delay to ensure file is fully written + await new Promise(resolve => setTimeout(resolve, 50)) // Verify on-disk state before we blow away memory const onDisk = JSON.parse(await readQueueFile()) as Array<{ sourcePath: string; folderContext: string }> expect(onDisk[0].sourcePath).toBe("raw/sources/注意力.pdf") diff --git a/src/lib/ingest-source-path-collision.test.ts b/src/lib/ingest-source-path-collision.test.ts index a8aa7c540..99d5115c3 100644 --- a/src/lib/ingest-source-path-collision.test.ts +++ b/src/lib/ingest-source-path-collision.test.ts @@ -522,11 +522,11 @@ describe("autoIngest source summary paths", () => { ) const canonicalSummary = `wiki/sources/${sourceSummarySlugFromIdentity("project-a/config.yaml")}.md` - const canonicalSummaryPath = path.join(tmp.path, canonicalSummary) + const canonicalSummaryPath = path.join(tmp.path, canonicalSummary).replace(/\\/g, "/") const staleSummaryPath = path.join(tmp.path, "wiki", "sources", "config.md") const content = await fs.readFile(canonicalSummaryPath, "utf8") - expect(writtenPaths).toEqual([canonicalSummaryPath]) + expect(writtenPaths.map((p) => p.replace(/\\/g, "/"))).toEqual([canonicalSummaryPath]) await expect(fs.access(staleSummaryPath)).rejects.toThrow() expect(content).toContain('sources: ["project-a/config.yaml"]') }) diff --git a/src/lib/project-store.ts b/src/lib/project-store.ts index bfcb10ae5..6aff30ca0 100644 --- a/src/lib/project-store.ts +++ b/src/lib/project-store.ts @@ -217,6 +217,18 @@ export async function loadLanguage(): Promise { return (await store.get(LANGUAGE_KEY)) ?? null } +const THEME_KEY = "theme" + +export async function saveTheme(theme: "light" | "dark" | "system"): Promise { + const store = await getStore() + await store.set(THEME_KEY, theme) +} + +export async function loadTheme(): Promise<"light" | "dark" | "system" | null> { + const store = await getStore() + return (await store.get<"light" | "dark" | "system">(THEME_KEY)) ?? null +} + const OUTPUT_LANGUAGE_KEY = "outputLanguage" const PROJECT_OUTPUT_LANGUAGE_KEY = "projectOutputLanguages" const PROJECT_FILE_SYNC_KEY = "projectFileSyncEnabled" diff --git a/src/main.tsx b/src/main.tsx index ecfe61559..99acfa8ee 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,9 +3,44 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import "./index.css"; import "@/i18n"; +import { loadTheme } from "@/lib/project-store"; -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - -); +// Apply theme before render to avoid flash +async function initApp() { + const root = document.documentElement; + + // Try to load saved theme + try { + const savedTheme = await loadTheme(); + if (savedTheme) { + if (savedTheme === "system") { + // Follow system preference + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + root.classList.add("dark"); + } else { + root.classList.add("light"); + } + } else { + root.classList.add(savedTheme); + } + } else { + // Default to system preference + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + root.classList.add("dark"); + } else { + root.classList.add("light"); + } + } + } catch { + // Default to light if loading fails + root.classList.add("light"); + } + + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + ); +} + +initApp();