فهرست منبع

fix: 优化设置界面视觉风格同步

Mochocyang 2 ماه پیش
والد
کامیت
0bb229469f

+ 1 - 1
src/components/layout/app-layout.tsx

@@ -337,7 +337,7 @@ export function AppLayout({ onSwitchProject }: AppLayoutProps) {
               <ActivityPanel />
             </div>
             <div
-              className="w-2 shrink-0 cursor-col-resize bg-border/40 transition-colors hover:bg-primary/40 active:bg-primary/50"
+              className="w-1 shrink-0 cursor-col-resize bg-border/20 transition-colors hover:bg-primary/25 active:bg-primary/30"
               onMouseDown={startDrag("left")}
             />
           </>

+ 57 - 0
src/components/settings/interface-visual-refresh.spec.ts

@@ -0,0 +1,57 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const root = resolve(__dirname, "../../..")
+const visualStyleSource = readFileSync(resolve(root, "src/lib/visual-style-settings.ts"), "utf8")
+const interfaceSectionSource = readFileSync(resolve(__dirname, "sections/interface-section.tsx"), "utf8")
+const settingsViewSource = readFileSync(resolve(__dirname, "settings-view.tsx"), "utf8")
+const appLayoutSource = readFileSync(resolve(root, "src/components/layout/app-layout.tsx"), "utf8")
+const indexCssSource = readFileSync(resolve(root, "src/index.css"), "utf8")
+
+describe("settings visual refresh style", () => {
+  it("registers the compact square visual style as a selectable interface style", () => {
+    expect(visualStyleSource).toContain('| "fangzheng"')
+    expect(visualStyleSource).toContain('fangzheng: "visual-fangzheng"')
+    expect(visualStyleSource).toContain("VISUAL_STYLE_STORAGE_VERSION")
+    expect(visualStyleSource).toContain("resolveStoredVisualStyle")
+    expect(visualStyleSource).toContain('"fangzheng"')
+    expect(interfaceSectionSource).toContain('value: "fangzheng"')
+    expect(interfaceSectionSource).toContain("直角工具型")
+    expect(interfaceSectionSource).toContain("无圆角")
+  })
+
+  it("defines light and dark tokens with no rounded corners for the compact style", () => {
+    expect(indexCssSource).toContain(":root.visual-fangzheng")
+    expect(indexCssSource).toContain(".visual-fangzheng.dark")
+    expect(indexCssSource).toContain("--radius: 0px")
+    expect(indexCssSource).toContain(".visual-fangzheng *")
+    expect(indexCssSource).toContain("--primary:")
+    expect(indexCssSource).toContain("--sidebar-accent:")
+    expect(indexCssSource).toContain("--sidebar-primary:")
+    expect(indexCssSource).toContain("--popover:")
+    expect(indexCssSource).toContain('.visual-fangzheng [role="listbox"]')
+    expect(indexCssSource).toContain('.visual-fangzheng [role="option"][aria-selected="true"]')
+    expect(indexCssSource).not.toContain(":root.visual-fangzheng {\n    --radius: 0px;\n    --background: #dde5f0")
+    expect(indexCssSource).not.toContain(".visual-fangzheng.dark {\n    --radius: 0px;\n    --background: #1a1f35")
+  })
+
+  it("keeps settings navigation bound to the active visual style tokens", () => {
+    expect(settingsViewSource).toContain("bg-sidebar")
+    expect(settingsViewSource).toContain("text-sidebar-foreground")
+    expect(settingsViewSource).toContain("border-sidebar-border")
+    expect(settingsViewSource).toContain("bg-sidebar-accent")
+    expect(settingsViewSource).not.toContain("bg-brand-50/60")
+    expect(settingsViewSource).not.toContain("ring-1 ring-border/70")
+  })
+
+  it("uses softer tokenized selection and divider styling instead of hard fixed outlines", () => {
+    expect(indexCssSource).not.toContain("rgba(62, 107, 88")
+    expect(indexCssSource).not.toContain("border-left: 2px solid #c8a96e")
+    expect(indexCssSource).not.toContain("border-left: 2px solid #d9c69a")
+    expect(indexCssSource).not.toContain("box-shadow: inset 0 0 0 1px var(--primary)")
+    expect(indexCssSource).toContain("background: color-mix(in oklch, var(--sidebar-accent)")
+    expect(appLayoutSource).toContain("w-1 shrink-0 cursor-col-resize bg-border/20")
+    expect(appLayoutSource).not.toContain("w-2 shrink-0 cursor-col-resize bg-border/40")
+  })
+})

+ 7 - 1
src/components/settings/sections/interface-section.tsx

@@ -31,6 +31,12 @@ const VISUAL_STYLE_OPTIONS = [
     description: "保留当前浅色、深色配色。",
     colors: ["#ffffff", "#171717", "#e5e5e5"],
   },
+  {
+    value: "fangzheng",
+    label: "直角工具型",
+    description: "冷灰背景、清晰按钮与选中态、无圆角、紧凑设置密度。",
+    colors: ["#F0F4F7", "#007A68", "#C8EFE6"],
+  },
   {
     value: "tianqing",
     label: "天青釉色",
@@ -112,7 +118,7 @@ export function InterfaceSection({ draft, setDraft }: Props) {
         <div>
           <Label>视觉风格</Label>
           <p className="mt-1 text-xs text-muted-foreground">
-            东方美学方案不会覆盖原设计,可随时切回经典原版
+            选择软件界面的整体视觉方案;直角工具型会同时调整浅色、深色、按钮和选中状态
           </p>
         </div>
         <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">

+ 8 - 6
src/components/settings/settings-view.tsx

@@ -554,12 +554,12 @@ export function SettingsView() {
     <div className="flex h-full overflow-hidden">
       {/* Sidebar — category nav. Matches the IconSidebar's pill-on-accent
           pattern so the two navigational surfaces feel like one app. */}
-      <aside className="flex w-56 shrink-0 flex-col border-r bg-brand-50/60">
+      <aside className="flex w-56 shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
         <div className="flex items-center gap-1.5 px-4 pb-2 pt-4 text-[11px] font-semibold uppercase tracking-wider">
           <PanelHeaderWithHelp
             title={t("settings.title")}
             helpKey="settings"
-            className="cursor-pointer text-muted-foreground transition-colors hover:text-primary"
+            className="cursor-pointer text-sidebar-foreground/65 transition-colors hover:text-sidebar-foreground"
           />
         </div>
         <nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
@@ -575,19 +575,21 @@ export function SettingsView() {
                 aria-current={isActive ? "page" : undefined}
                 className={`group mb-0.5 flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
                   isActive
-                    ? "bg-foreground/[0.08] font-medium text-foreground ring-1 ring-border/70"
-                    : "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
+                    ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
+                    : "text-sidebar-foreground/70 hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground"
                 }`}
               >
                 <Icon
                   className={`h-4 w-4 shrink-0 transition-colors ${
-                    isActive ? "text-primary" : "text-muted-foreground/80 group-hover:text-accent-foreground"
+                    isActive ? "text-sidebar-primary" : "text-sidebar-foreground/70 group-hover:text-sidebar-accent-foreground"
                   }`}
                 />
                 <span className="flex min-w-0 flex-1 flex-col items-start">
                   <span className="truncate">{t(c.labelKey)}</span>
                   {c.hintKey ? (
-                    <span className="truncate text-[10px] leading-tight text-muted-foreground/80">
+                    <span className={`truncate text-[10px] leading-tight ${
+                      isActive ? "text-sidebar-accent-foreground/70" : "text-sidebar-foreground/55"
+                    }`}>
                       {t(c.hintKey)}
                     </span>
                   ) : null}

+ 164 - 17
src/index.css

@@ -94,6 +94,50 @@
     --chart-5: oklch(0.269 0 0);
 }
 
+:root.visual-fangzheng {
+    --radius: 0px;
+    --background: oklch(0.975 0.006 245);
+    --foreground: oklch(0.17 0.018 245);
+    --card: oklch(0.995 0.002 245);
+    --card-foreground: oklch(0.18 0.018 245);
+    --popover: oklch(0.992 0.004 245);
+    --popover-foreground: oklch(0.18 0.018 245);
+    --primary: oklch(0.46 0.1 178);
+    --primary-foreground: oklch(0.99 0 0);
+    --secondary: oklch(0.94 0.008 245);
+    --secondary-foreground: oklch(0.22 0.018 245);
+    --muted: oklch(0.935 0.008 245);
+    --muted-foreground: oklch(0.46 0.02 245);
+    --accent: oklch(0.905 0.04 178);
+    --accent-foreground: oklch(0.22 0.052 178);
+    --destructive: oklch(0.55 0.18 28);
+    --border: oklch(0.84 0.014 245);
+    --input: oklch(0.84 0.014 245);
+    --ring: oklch(0.65 0.11 178);
+    --chart-1: oklch(0.48 0.11 178);
+    --chart-2: oklch(0.58 0.08 210);
+    --chart-3: oklch(0.64 0.12 78);
+    --chart-4: oklch(0.55 0.18 28);
+    --chart-5: oklch(0.36 0.03 245);
+    --sidebar: oklch(0.94 0.008 245);
+    --sidebar-foreground: oklch(0.2 0.018 245);
+    --sidebar-primary: oklch(0.48 0.11 178);
+    --sidebar-primary-foreground: oklch(0.99 0 0);
+    --sidebar-accent: oklch(0.89 0.04 178);
+    --sidebar-accent-foreground: oklch(0.2 0.052 178);
+    --sidebar-border: oklch(0.8 0.014 245);
+    --sidebar-ring: oklch(0.65 0.11 178);
+    --brand-50: oklch(0.95 0.008 245);
+    --brand-100: oklch(0.91 0.044 178);
+    --brand-200: oklch(0.82 0.07 178);
+    --brand-300: oklch(0.7 0.09 178);
+    --brand-400: oklch(0.58 0.1 178);
+    --brand-500: oklch(0.48 0.11 178);
+    --brand-600: oklch(0.38 0.11 178);
+    --brand-700: oklch(0.3 0.08 178);
+    --brand-800: oklch(0.22 0.052 178);
+}
+
 :root.visual-cangzhu {
     --background: oklch(0.95 0.021 91);
     --foreground: oklch(0.25 0.035 160);
@@ -353,6 +397,50 @@
     --brand-800: #f0f8f3;
 }
 
+.visual-fangzheng.dark {
+    --radius: 0px;
+    --background: oklch(0.18 0.02 245);
+    --foreground: oklch(0.91 0.012 245);
+    --card: oklch(0.225 0.022 245);
+    --card-foreground: oklch(0.91 0.012 245);
+    --popover: oklch(0.235 0.024 245);
+    --popover-foreground: oklch(0.91 0.012 245);
+    --primary: oklch(0.72 0.088 170);
+    --primary-foreground: oklch(0.15 0.024 245);
+    --secondary: oklch(0.265 0.024 245);
+    --secondary-foreground: oklch(0.88 0.012 245);
+    --muted: oklch(0.285 0.024 245);
+    --muted-foreground: oklch(0.72 0.02 245);
+    --accent: oklch(0.31 0.048 178);
+    --accent-foreground: oklch(0.91 0.035 170);
+    --destructive: oklch(0.7 0.16 28);
+    --border: oklch(0.36 0.028 245);
+    --input: oklch(0.36 0.028 245);
+    --ring: oklch(0.76 0.1 170);
+    --chart-1: oklch(0.74 0.09 170);
+    --chart-2: oklch(0.68 0.08 210);
+    --chart-3: oklch(0.78 0.12 82);
+    --chart-4: oklch(0.7 0.16 28);
+    --chart-5: oklch(0.5 0.034 245);
+    --sidebar: oklch(0.155 0.018 245);
+    --sidebar-foreground: oklch(0.9 0.012 245);
+    --sidebar-primary: oklch(0.74 0.09 170);
+    --sidebar-primary-foreground: oklch(0.15 0.024 245);
+    --sidebar-accent: oklch(0.27 0.048 178);
+    --sidebar-accent-foreground: oklch(0.91 0.035 170);
+    --sidebar-border: oklch(0.34 0.028 245);
+    --sidebar-ring: oklch(0.76 0.1 170);
+    --brand-50: oklch(0.24 0.027 245);
+    --brand-100: oklch(0.27 0.048 178);
+    --brand-200: oklch(0.32 0.052 178);
+    --brand-300: oklch(0.45 0.075 174);
+    --brand-400: oklch(0.6 0.085 170);
+    --brand-500: oklch(0.74 0.09 170);
+    --brand-600: oklch(0.82 0.072 170);
+    --brand-700: oklch(0.9 0.035 170);
+    --brand-800: oklch(0.96 0.018 170);
+}
+
 .visual-cangzhu.dark {
     --background: oklch(0.23 0.043 160);
     --foreground: oklch(0.91 0.035 145);
@@ -576,49 +664,108 @@
 }
 
 @layer components {
-    /* 选中项:竹青底 + 竹墨字 + 左边金竹线 */
+    .visual-fangzheng * {
+        border-radius: 0 !important;
+    }
+
+    .visual-fangzheng .qm-selected {
+        background: color-mix(in oklch, var(--sidebar-accent) 68%, var(--background));
+        color: var(--sidebar-accent-foreground);
+        border-left: 0;
+    }
+
+    .visual-fangzheng .qm-selected:hover {
+        background: color-mix(in oklch, var(--sidebar-accent) 78%, var(--background));
+    }
+
+    .visual-fangzheng .qm-selected-muted {
+        color: color-mix(in oklch, var(--sidebar-accent-foreground) 68%, var(--muted-foreground));
+    }
+
+    .visual-fangzheng .qm-hover:hover {
+        background: color-mix(in oklch, var(--sidebar-accent) 62%, transparent);
+        color: var(--sidebar-accent-foreground);
+    }
+
+    .visual-fangzheng [role="listbox"] {
+        background: var(--popover);
+        color: var(--popover-foreground);
+        border-color: var(--border);
+    }
+
+    .visual-fangzheng [role="option"][aria-selected="true"] {
+        background: color-mix(in oklch, var(--accent) 72%, var(--popover));
+        color: var(--accent-foreground);
+        box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--primary) 75%, var(--border));
+    }
+
+    .visual-fangzheng [role="option"][aria-selected="true"]:hover {
+        background: color-mix(in oklch, var(--accent) 86%, var(--popover));
+    }
+
+    /* 选中态跟随当前视觉风格的侧栏令牌,避免固定色和强线框。 */
     .qm-selected {
-        background: rgba(62, 107, 88, 0.14);
-        color: #1c3328;
-        border-left: 2px solid #c8a96e;
+        background: color-mix(in oklch, var(--sidebar-accent) 64%, var(--background));
+        color: var(--sidebar-accent-foreground);
+        border-left: 0;
     }
 
     .qm-selected:hover {
-        background: rgba(62, 107, 88, 0.2);
+        background: color-mix(in oklch, var(--sidebar-accent) 76%, var(--background));
     }
 
     .qm-selected-muted {
-        color: #2d5040;
+        color: color-mix(in oklch, var(--sidebar-accent-foreground) 68%, var(--muted-foreground));
     }
 
-    /* 悬停项:竹青淡底 */
+    /* 悬停态同样使用视觉风格令牌。 */
     .qm-hover:hover {
-        background: rgba(62, 107, 88, 0.08);
-        color: #1c3328;
+        background: color-mix(in oklch, var(--sidebar-accent) 52%, transparent);
+        color: var(--sidebar-accent-foreground);
     }
 
-    /* 深色主题下的选中/悬停 */
+    /* 深色模式只调整混合比例,不再切回固定品牌色。 */
     .dark .qm-selected,
     .deep-blue .qm-selected {
-        background: rgba(143, 192, 165, 0.18);
-        color: #daf0e2;
-        border-left: 2px solid #d9c69a;
+        background: color-mix(in oklch, var(--sidebar-accent) 72%, var(--background));
+        color: var(--sidebar-accent-foreground);
+        border-left: 0;
     }
 
     .dark .qm-selected:hover,
     .deep-blue .qm-selected:hover {
-        background: rgba(143, 192, 165, 0.26);
+        background: color-mix(in oklch, var(--sidebar-accent) 84%, var(--background));
     }
 
     .dark .qm-selected-muted,
     .deep-blue .qm-selected-muted {
-        color: #b5d9c3;
+        color: color-mix(in oklch, var(--sidebar-accent-foreground) 72%, var(--muted-foreground));
     }
 
     .dark .qm-hover:hover,
     .deep-blue .qm-hover:hover {
-        background: rgba(143, 192, 165, 0.1);
-        color: #daf0e2;
+        background: color-mix(in oklch, var(--sidebar-accent) 58%, transparent);
+        color: var(--sidebar-accent-foreground);
+    }
+
+    .visual-fangzheng.dark .qm-selected {
+        background: color-mix(in oklch, var(--sidebar-accent) 76%, var(--background));
+        color: var(--sidebar-accent-foreground);
+        border-left: 0;
+    }
+
+    .visual-fangzheng.dark .qm-selected:hover {
+        background: color-mix(in oklch, var(--sidebar-accent) 86%, var(--background));
+    }
+
+    .visual-fangzheng.dark .qm-hover:hover {
+        background: color-mix(in oklch, var(--sidebar-accent) 64%, transparent);
+        color: var(--sidebar-accent-foreground);
+    }
+
+    .visual-fangzheng.dark [role="option"][aria-selected="true"] {
+        background: color-mix(in oklch, var(--accent) 76%, var(--popover));
+        color: var(--accent-foreground);
     }
 }
 

+ 18 - 2
src/lib/project-store.ts

@@ -6,7 +6,12 @@ import type { McpConfig } from "@/lib/mcp/config"
 import { normalizeMcpConfig } from "@/lib/mcp/config"
 import { normalizeSourceWatchConfig } from "@/lib/source-watch-config"
 import { normalizeUiFontFamily, type UiFontFamily } from "@/lib/font-settings"
-import { normalizeVisualStyle, type VisualStyle } from "@/lib/visual-style-settings"
+import {
+  VISUAL_STYLE_STORAGE_VERSION,
+  normalizeVisualStyle,
+  resolveStoredVisualStyle,
+  type VisualStyle,
+} from "@/lib/visual-style-settings"
 import { normalizePath } from "@/lib/path-utils"
 import { readFile, writeFile, fileExists } from "@/commands/fs"
 
@@ -610,6 +615,7 @@ export async function loadRerankConfig(projectId?: string, projectPath?: string)
 
 const THEME_KEY = "theme"
 const VISUAL_STYLE_KEY = "visualStyle"
+const VISUAL_STYLE_VERSION_KEY = "visualStyleVersion"
 
 export async function saveTheme(theme: "light" | "dark" | "system"): Promise<void> {
   const store = await getStore()
@@ -625,13 +631,23 @@ export async function loadTheme(): Promise<"light" | "dark" | "system" | null> {
 export async function saveVisualStyle(style: VisualStyle): Promise<void> {
   const store = await getStore()
   await store.set(VISUAL_STYLE_KEY, normalizeVisualStyle(style))
+  await store.set(VISUAL_STYLE_VERSION_KEY, VISUAL_STYLE_STORAGE_VERSION)
   await store.save()
 }
 
 export async function loadVisualStyle(): Promise<VisualStyle | null> {
   const store = await getStore()
   const saved = await store.get<string>(VISUAL_STYLE_KEY)
-  return saved ? normalizeVisualStyle(saved) : null
+  if (!saved) return null
+  const savedVersion = await store.get<string>(VISUAL_STYLE_VERSION_KEY)
+  const normalized = normalizeVisualStyle(saved)
+  const resolved = resolveStoredVisualStyle(saved, savedVersion)
+  if (saved !== resolved || resolved !== normalized || savedVersion !== VISUAL_STYLE_STORAGE_VERSION) {
+    await store.set(VISUAL_STYLE_KEY, resolved)
+    await store.set(VISUAL_STYLE_VERSION_KEY, VISUAL_STYLE_STORAGE_VERSION)
+    await store.save()
+  }
+  return resolved
 }
 
 const UI_FONT_SIZE_SCALE_KEY = "uiFontSizeScale"

+ 4 - 4
src/lib/visual-style-settings-default.spec.ts

@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"
 import { DEFAULT_VISUAL_STYLE, normalizeVisualStyle } from "./visual-style-settings"
 
 describe("visual-style-settings default", () => {
-  it("uses cangzhu as the default visual style for new users", () => {
-    expect(DEFAULT_VISUAL_STYLE).toBe("cangzhu")
-    expect(normalizeVisualStyle("bad")).toBe("cangzhu")
-    expect(normalizeVisualStyle(null)).toBe("cangzhu")
+  it("uses fangzheng as the default visual style for new users", () => {
+    expect(DEFAULT_VISUAL_STYLE).toBe("fangzheng")
+    expect(normalizeVisualStyle("bad")).toBe("fangzheng")
+    expect(normalizeVisualStyle(null)).toBe("fangzheng")
   })
 })

+ 35 - 8
src/lib/visual-style-settings.spec.ts

@@ -1,20 +1,37 @@
-// @vitest-environment jsdom
-
-import { beforeEach, describe, expect, it } from "vitest"
-import { applyVisualStyle, normalizeVisualStyle } from "./visual-style-settings"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import {
+  VISUAL_STYLE_STORAGE_VERSION,
+  applyVisualStyle,
+  normalizeVisualStyle,
+  resolveStoredVisualStyle,
+} from "./visual-style-settings"
 
 describe("visual-style-settings", () => {
   beforeEach(() => {
-    document.documentElement.className = ""
+    const classes = new Set<string>()
+    vi.stubGlobal("document", {
+      documentElement: {
+        classList: {
+          add: (...names: string[]) => names.forEach((name) => classes.add(name)),
+          remove: (...names: string[]) => names.forEach((name) => classes.delete(name)),
+          contains: (name: string) => classes.has(name),
+        },
+      },
+    })
+  })
+
+  afterEach(() => {
+    vi.unstubAllGlobals()
   })
 
-  it("falls back to cangzhu for invalid values", () => {
-    expect(normalizeVisualStyle("bad")).toBe("cangzhu")
-    expect(normalizeVisualStyle(null)).toBe("cangzhu")
+  it("falls back to fangzheng for invalid values", () => {
+    expect(normalizeVisualStyle("bad")).toBe("fangzheng")
+    expect(normalizeVisualStyle(null)).toBe("fangzheng")
   })
 
   it("accepts all oriental visual styles", () => {
     expect(normalizeVisualStyle("classic")).toBe("classic")
+    expect(normalizeVisualStyle("fangzheng")).toBe("fangzheng")
     expect(normalizeVisualStyle("tianqing")).toBe("tianqing")
     expect(normalizeVisualStyle("qingci")).toBe("qingci")
     expect(normalizeVisualStyle("yunshan")).toBe("yunshan")
@@ -38,4 +55,14 @@ describe("visual-style-settings", () => {
     expect(document.documentElement.classList.contains("visual-tianqing")).toBe(false)
     expect(document.documentElement.classList.contains("visual-gumo")).toBe(true)
   })
+
+  it("migrates legacy saved styles to the compact square style once", () => {
+    expect(resolveStoredVisualStyle("yuebai", null)).toBe("fangzheng")
+    expect(resolveStoredVisualStyle("cangzhu", "old-version")).toBe("fangzheng")
+  })
+
+  it("keeps user-selected styles after the current visual style version is stored", () => {
+    expect(resolveStoredVisualStyle("yuebai", VISUAL_STYLE_STORAGE_VERSION)).toBe("yuebai")
+    expect(resolveStoredVisualStyle("classic", VISUAL_STYLE_STORAGE_VERSION)).toBe("classic")
+  })
 })

+ 16 - 1
src/lib/visual-style-settings.ts

@@ -1,5 +1,6 @@
 export type VisualStyle =
   | "classic"
+  | "fangzheng"
   | "tianqing"
   | "qingci"
   | "yunshan"
@@ -7,10 +8,13 @@ export type VisualStyle =
   | "yuebai"
   | "gumo"
 
-export const DEFAULT_VISUAL_STYLE: VisualStyle = "cangzhu"
+export const DEFAULT_VISUAL_STYLE: VisualStyle = "fangzheng"
 export const VISUAL_STYLE_STORAGE_KEY = "qmai-visual-style"
+export const VISUAL_STYLE_STORAGE_VERSION_KEY = "qmai-visual-style-version"
+export const VISUAL_STYLE_STORAGE_VERSION = "fangzheng-20260707"
 
 const VISUAL_STYLE_CLASSES: Record<Exclude<VisualStyle, "classic">, string> = {
+  fangzheng: "visual-fangzheng",
   tianqing: "visual-tianqing",
   qingci: "visual-qingci",
   yunshan: "visual-yunshan",
@@ -21,6 +25,7 @@ const VISUAL_STYLE_CLASSES: Record<Exclude<VisualStyle, "classic">, string> = {
 
 const VISUAL_STYLE_VALUES = new Set<VisualStyle>([
   "classic",
+  "fangzheng",
   "tianqing",
   "qingci",
   "yunshan",
@@ -35,6 +40,16 @@ export function normalizeVisualStyle(value: unknown): VisualStyle {
     : DEFAULT_VISUAL_STYLE
 }
 
+export function resolveStoredVisualStyle(
+  value: unknown,
+  storageVersion: unknown,
+): VisualStyle {
+  if (storageVersion !== VISUAL_STYLE_STORAGE_VERSION) {
+    return DEFAULT_VISUAL_STYLE
+  }
+  return normalizeVisualStyle(value)
+}
+
 export function applyVisualStyle(style: VisualStyle): void {
   if (typeof document === "undefined") return
   const html = document.documentElement

+ 12 - 1
src/stores/wiki-store.ts

@@ -23,7 +23,10 @@ import {
 import {
   DEFAULT_VISUAL_STYLE,
   VISUAL_STYLE_STORAGE_KEY,
+  VISUAL_STYLE_STORAGE_VERSION,
+  VISUAL_STYLE_STORAGE_VERSION_KEY,
   normalizeVisualStyle,
+  resolveStoredVisualStyle,
   type VisualStyle,
 } from "@/lib/visual-style-settings"
 import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
@@ -73,7 +76,14 @@ const readStoredUiFontFamily = (): UiFontFamily => {
 
 const readStoredVisualStyle = (): VisualStyle => {
   if (typeof localStorage === "undefined") return DEFAULT_VISUAL_STYLE
-  return normalizeVisualStyle(localStorage.getItem(VISUAL_STYLE_STORAGE_KEY))
+  const saved = localStorage.getItem(VISUAL_STYLE_STORAGE_KEY)
+  const version = localStorage.getItem(VISUAL_STYLE_STORAGE_VERSION_KEY)
+  const resolved = resolveStoredVisualStyle(saved, version)
+  if (saved !== resolved || version !== VISUAL_STYLE_STORAGE_VERSION) {
+    localStorage.setItem(VISUAL_STYLE_STORAGE_KEY, resolved)
+    localStorage.setItem(VISUAL_STYLE_STORAGE_VERSION_KEY, VISUAL_STYLE_STORAGE_VERSION)
+  }
+  return resolved
 }
 
 const readStoredSidebarNavConfig = (): SidebarNavConfig => {
@@ -972,6 +982,7 @@ export const useWikiStore = create<WikiState>((set) => ({
     const normalized = normalizeVisualStyle(visualStyle)
     if (typeof localStorage !== "undefined") {
       localStorage.setItem(VISUAL_STYLE_STORAGE_KEY, normalized)
+      localStorage.setItem(VISUAL_STYLE_STORAGE_VERSION_KEY, VISUAL_STYLE_STORAGE_VERSION)
     }
     set({ visualStyle: normalized })
   },