feat(desktop): 亮/暗主题切换 —— 默认亮色,一处令牌驱动全 app 换肤

把高频硬编码色(ink 表面 / line 边框 / slate 文字)重定义为 CSS 变量(RGB 三元组,
保留 /透明度 修饰符),:root(亮) 与 .dark(暗) 两套值切换——改 tailwind.config + index.css
两处即让全 app 换肤,无需逐文件迁移。

- 亮色走 shadcn 中性灰(zinc:zinc-50 底/白卡/zinc-200 边/zinc-900 字);
  暗色精炼为中性 zinc 暗(替代原偏蓝 ink),顺带提质感。
- lib/theme.ts:useTheme + applyInitialTheme(main 启动前设类,避免首屏闪烁)+ localStorage 持久化,默认亮色。
- TopBar 加 Sun/Moon 切换钮;滚动条/输入控件 color-scheme 随主题。
- 修亮色下会失效的 bg-white/5 → bg-ink-800(Tabs/Badge/ExecTrace 的中性微底);
  模态遮罩 bg-black/55 两模式皆宜,保留。

验证:tsc + vitest 48 过 + 生产构建通过(var 色全解析)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-25 10:39:36 +08:00
parent aea04e2e8d
commit ca7ce4c26f
10 changed files with 122 additions and 221 deletions
@@ -78,7 +78,7 @@ export function ExecTrace({ events, phase, compact }: { events: ExecEvent[]; pha
<div className="flex items-center gap-2">
<Icon className={cn("h-3.5 w-3.5 shrink-0", m.cls)} strokeWidth={2} />
<span className="text-xs font-medium text-slate-200">{n.label}</span>
<span className={cn("rounded bg-white/5 px-1.5 py-0.5 text-[9px]", m.cls)}>{m.name}</span>
<span className={cn("rounded bg-ink-800 px-1.5 py-0.5 text-[9px]", m.cls)}>{m.name}</span>
{n.ms != null && n.ms > 0 && <span className="ml-auto font-mono text-[10px] text-slate-500">{n.ms} ms</span>}
{n.status === "running" && <span className="ml-auto text-[10px] text-accent-400"></span>}
</div>
+50 -11
View File
@@ -2,6 +2,50 @@
@tailwind components;
@tailwind utilities;
/* 主题令牌::root=亮色,.dark=暗色。值为 RGB 三元组,供 tailwind 的 rgb(var() / α) 取用。
亮色走 shadcn 中性灰(zinc),暗色为精炼中性暗(zinc 系,替代原偏蓝的 ink)。 */
:root {
--ink-950: 249 249 250; /* 页面底 */
--ink-900: 255 255 255; /* 顶栏/面板 */
--ink-850: 255 255 255; /* 卡片 */
--ink-800: 244 244 245; /* hover/抬升 */
--ink-700: 228 228 231;
--ink-600: 212 212 216;
--line: 228 228 231; /* 边框 zinc-200 */
--slate-100: 24 24 27; /* 最强文字 */
--slate-200: 24 24 27; /* 主文字 */
--slate-300: 63 63 70;
--slate-400: 82 82 91;
--slate-500: 113 113 122; /* 次要 */
--slate-600: 161 161 170; /* 弱 */
--slate-700: 212 212 216;
--sb-thumb: 212 212 216;
--sb-thumb-hover: 161 161 170;
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.06);
color-scheme: light;
}
.dark {
--ink-950: 10 10 12;
--ink-900: 20 20 24;
--ink-850: 24 24 27;
--ink-800: 31 31 35;
--ink-700: 38 38 43;
--ink-600: 50 50 56;
--line: 39 39 42; /* zinc-800 */
--slate-100: 244 244 245;
--slate-200: 228 228 231;
--slate-300: 212 212 216;
--slate-400: 161 161 170;
--slate-500: 138 138 147;
--slate-600: 113 113 122;
--slate-700: 82 82 91;
--sb-thumb: 39 39 42;
--sb-thumb-hover: 63 63 70;
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.25);
color-scheme: dark;
}
html,
body,
#root {
@@ -10,25 +54,25 @@ body,
}
body {
background: #0b0d12;
color: #cbd2de;
background: rgb(var(--ink-950));
color: rgb(var(--slate-300));
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* 深色滚动条 */
/* 滚动条随主题 */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: #242b3c;
background: rgb(var(--sb-thumb));
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: #323a4f;
background: rgb(var(--sb-thumb-hover));
background-clip: padding-box;
}
::-webkit-scrollbar-track {
@@ -44,9 +88,4 @@ body {
color: transparent;
}
/* 输入控件深色统一 */
input,
textarea,
select {
color-scheme: dark;
}
/* 输入控件随主题(color-scheme 由 :root/.dark 设定,控件自动继承) */
@@ -0,0 +1,35 @@
// 主题(亮/暗)管理:持久化到 localStorage,切换在 <html> 上加/去 .dark 类。
// 默认亮色(暗色为可选)。初始化在 applyInitialThememain 启动时调,先于渲染避免闪烁)。
import { useCallback, useEffect, useState } from "react";
export type Theme = "light" | "dark";
const KEY = "sdx-theme";
export function getStoredTheme(): Theme {
const t = localStorage.getItem(KEY);
return t === "dark" ? "dark" : "light"; // 默认亮色
}
function apply(theme: Theme): void {
document.documentElement.classList.toggle("dark", theme === "dark");
}
// applyInitialTheme 在渲染前调用,依据存储值设好 .dark 类(避免首屏闪烁)。
export function applyInitialTheme(): void {
apply(getStoredTheme());
}
// useTheme 暴露当前主题与切换函数(写 localStorage + 切 .dark 类)。
export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Theme) => void } {
const [theme, setThemeState] = useState<Theme>(getStoredTheme);
useEffect(() => {
apply(theme);
localStorage.setItem(KEY, theme);
}, [theme]);
const setTheme = useCallback((t: Theme) => setThemeState(t), []);
const toggle = useCallback(() => setThemeState((t) => (t === "dark" ? "light" : "dark")), []);
return { theme, toggle, setTheme };
}
+3
View File
@@ -2,6 +2,9 @@ import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { applyInitialTheme } from "./lib/theme";
applyInitialTheme(); // 先于渲染设好主题类,避免首屏闪烁
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
+10 -1
View File
@@ -1,8 +1,9 @@
import type { CSSProperties } from "react";
import { User, ChevronDown, Search as SearchIcon, LogOut } from "lucide-react";
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon } from "lucide-react";
import type { AuthUser } from "../lib/api";
import { useHealth } from "../lib/health";
import { isMacDesktop } from "../lib/desktop";
import { useTheme } from "../lib/theme";
import { cn } from "../ui";
// Wails 拖拽区/控件 CSS 变量(浏览器忽略,桌面端生效)。
@@ -22,6 +23,7 @@ function Light({ on, label }: { on: boolean; label: string }) {
// 顶栏:品牌 · 垂直切换 · 健康灯 · 登录用户 + 登出(深色 + 毛玻璃)。
export function TopBar({ user, onLogout, onCommand }: { user: AuthUser; onLogout: () => void; onCommand?: () => void }) {
const h = useHealth();
const { theme, toggle } = useTheme();
return (
<header
style={DRAG}
@@ -62,6 +64,13 @@ export function TopBar({ user, onLogout, onCommand }: { user: AuthUser; onLogout
<Light on={h.neo4j} label="Neo4j" />
</div>
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
<button
onClick={toggle}
className="flex items-center rounded-md border border-line bg-ink-800 px-2 py-1 text-slate-400 transition hover:border-ink-600 hover:text-slate-200"
title={theme === "dark" ? "切换到亮色" : "切换到暗色"}
>
{theme === "dark" ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
</button>
<span className="flex items-center gap-1.5 rounded-md border border-line bg-ink-800 px-2.5 py-1 text-xs text-slate-300" title={user.email}>
<User className="h-3.5 w-3.5 text-slate-500" />
{user.name || user.email}
+1 -1
View File
@@ -4,7 +4,7 @@ import { cn } from "./cn";
type Tone = "neutral" | "brand" | "accent" | "success" | "warn" | "danger";
const tones: Record<Tone, string> = {
neutral: "bg-white/5 text-slate-400",
neutral: "bg-ink-800 text-slate-400",
brand: "bg-brand/15 text-brand-400",
accent: "bg-accent/15 text-accent-400",
success: "bg-success/15 text-success",
+1 -1
View File
@@ -33,7 +33,7 @@ export function Tabs<T extends string>({
>
{t.label}
{t.count != null && t.count > 0 && (
<span className="ml-1 rounded bg-white/5 px-1 text-[9px] text-slate-400">{t.count}</span>
<span className="ml-1 rounded bg-ink-800 px-1 text-[9px] text-slate-400">{t.count}</span>
)}
{active && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded bg-brand" />}
</button>
+21 -11
View File
@@ -1,20 +1,31 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
// 深色 AI 控制台分层表面
// 分层表面 / 边框 / 文字均由 CSS 变量驱动(RGB 三元组,保留 /透明度 修饰符)。
// 变量在 index.css 的 :root(亮) 与 .dark(暗) 两套值间切换 —— 一处定义,全 app 换肤。
ink: {
950: "#0b0d12",
900: "#0f121a",
850: "#141824",
800: "#1a1f2d",
700: "#232a3b",
600: "#323a4f",
950: "rgb(var(--ink-950) / <alpha-value>)",
900: "rgb(var(--ink-900) / <alpha-value>)",
850: "rgb(var(--ink-850) / <alpha-value>)",
800: "rgb(var(--ink-800) / <alpha-value>)",
700: "rgb(var(--ink-700) / <alpha-value>)",
600: "rgb(var(--ink-600) / <alpha-value>)",
},
line: "#242b3c",
// 语义令牌 —— 强调色统一从这里取,便于整体换肤(紫=brand,青=accent)。
line: "rgb(var(--line) / <alpha-value>)",
slate: {
100: "rgb(var(--slate-100) / <alpha-value>)",
200: "rgb(var(--slate-200) / <alpha-value>)",
300: "rgb(var(--slate-300) / <alpha-value>)",
400: "rgb(var(--slate-400) / <alpha-value>)",
500: "rgb(var(--slate-500) / <alpha-value>)",
600: "rgb(var(--slate-600) / <alpha-value>)",
700: "rgb(var(--slate-700) / <alpha-value>)",
},
// 语义强调色 —— 两套主题通用(紫=brand,青=accent)。
brand: { DEFAULT: "#7c5cf6", 400: "#a78bfa", 500: "#8b5cf6", 600: "#6d28d9" },
accent: { DEFAULT: "#22d3ee", 400: "#22d3ee", 500: "#06b6d4" },
success: { DEFAULT: "#34d399", 500: "#10b981" },
@@ -22,14 +33,13 @@ export default {
danger: { DEFAULT: "#fb7185", 500: "#f43f5e" },
},
borderRadius: {
// 统一圆角档位
lg: "0.625rem", // 10px —— 卡片/面板
md: "0.5rem", // 8px —— 控件
},
boxShadow: {
glow: "0 0 0 1px rgba(139,92,246,0.45), 0 0 18px rgba(139,92,246,0.28)",
"glow-cyan": "0 0 0 1px rgba(34,211,238,0.4), 0 0 16px rgba(34,211,238,0.22)",
card: "0 1px 2px rgba(0,0,0,0.4), 0 8px 24px rgba(0,0,0,0.25)",
card: "var(--shadow-card)",
},
},
},