Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions docs/superpowers/plans/2026-06-23-theme-system-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# 主题系统实现计划(Theme System Implementation Plan)

**日期**:2026-06-23
**关联 Spec**:`docs/superpowers/specs/2026-06-23-theme-system-design.md`

## 实施顺序

### Phase 1:CSS 变量层(基础设施)

**文件**:`src/index.css`

1. 在 `@layer base` 之前新增 `:root`、`[data-theme="parchment"]`、`[data-theme="daylight"]` 三个变量定义块,覆盖 spec 第 4 节全部 token。
2. 将 `body`、`::selection`、所有 `@layer components` 内的硬编码颜色替换为 `var(--token)`。
3. 噪点 overlay 的 `opacity` 改为 `var(--grain-opacity)`。

**文件**:`src/styles/app.css`

4. 将所有 `#0a0a0f`、`#12121a`、`#1a1a26`、`#d4a853`、`#e8c878`、`#8a6d2b`、`#2dd4bf`、`#f0ece4`、`#c9c4ba`、`#9a958c`、`#5c584f`、`rgba(212, 168, 83, ...)`、`rgba(45, 212, 191, ...)` 替换为对应 `var(--token)`。

### Phase 2:Tailwind 配置

**文件**:`tailwind.config.js`

5. `colors` 字段值改为 `'var(--bg-deep)'` 等字符串引用(保持键名不变,向后兼容)。

### Phase 3:状态层

**文件**:`src/store/settingsStore.ts`

6. 新增 `ThemeId` 类型、`theme` / `resolvedTheme` 状态、`setTheme` action。
7. `persist` 配置不变(`name: 'kf-settings'`),新增字段自动持久化。
8. 新增 `resolveSystemTheme()` 工具函数:`window.matchMedia('(prefers-color-scheme: dark)').matches ? 'obsidian' : 'daylight'`。

### Phase 4:主题应用

**文件**:`src/main.tsx`

9. 在 `createRoot().render()` 之前插入同步初始化脚本:从 `localStorage.getItem('kf-settings')` 解析 `theme`,若为 `'system'` 或不存在则调用 `resolveSystemTheme()`,将结果写入 `document.documentElement.dataset.theme`。

**文件**:`src/App.tsx`

10. 新增 `useThemeEffect()` hook:订阅 `useSettingsStore` 的 `theme`,在 `useEffect` 中计算 `resolvedTheme` 并写 `data-theme`;同时监听 `matchMedia('(prefers-color-scheme: dark)')` 的 `change` 事件,当 `theme === 'system'` 时重新解析。

### Phase 5:切换器组件

**文件**:`src/components/ThemeSwitcher.tsx`(新建)

11. 实现 4 选项 segmented control:系统 / Obsidian / Parchment / Daylight。
12. 样式使用现有 `.btn-ghost` 派生,紧凑型布局,适配导航栏。
13. 当前选中项高亮(`.active` 态)。

### Phase 6:导航栏集成

**文件**:`src/components/AppNav.tsx`、`src/components/Nav.tsx`

14. 在两个导航栏右侧加入 `<ThemeSwitcher />`。
15. 调整 `nav-bar` / `app-nav` 的 flex 布局,确保切换器与现有链接对齐。

### Phase 7:内联样式清理

**文件**:`src/pages/SettingsPage.tsx` 及其他含内联颜色的页面

16. 将 `style={{ color: '#f0ece4' }}` 等内联硬编码替换为 `var(--text-primary)` 等。
17. 扫描 `src/pages/*.tsx` 与 `src/components/*.tsx` 中的 `#` 开头颜色字面量,统一替换。

### Phase 8:验证

18. `npm run build` 通过。
19. `npm run quality:precommit` 通过(若可用)。
20. 启动 dev server 人工验证三套主题切换、刷新持久化、系统跟随。

## 风险与回滚

- **风险**:CSS 变量替换遗漏导致某元素颜色异常。
- **缓解**:Obsidian 主题的 token 值与原硬编码完全一致,替换后视觉应像素级不变;若有差异即为遗漏点。
- **回滚**:所有改动集中在 CSS 变量层,回滚只需还原 `index.css` / `app.css` 两个文件即可恢复原状。

## 不在本次范围

- 不修改 `animation` / `keyframes`。
- 不调整字体(Playfair Display + DM Sans 三套主题共用)。
- 不引入新的依赖包。
155 changes: 155 additions & 0 deletions docs/superpowers/specs/2026-06-23-theme-system-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# 主题系统设计(Theme System Design)

**日期**:2026-06-23
**状态**:Draft
**类型**:Design System Architecture Adjustment

## 1. 背景与动机

KnowledgeFlow 当前采用单一"黑金奢华编辑风"主题:深黑底 `#0a0a0f` + 金色 `#d4a853` + Playfair Display。所有颜色硬编码于 `src/index.css` 与 `src/styles/app.css`,无 CSS 变量、无主题切换能力。

**学习效率视角的三个问题**:

1. **长时间阅读疲劳**:学习场景下用户会停留 30 分钟以上盯着 Markdown 正文(StudyPage chat-content),高对比黑金适合品牌着陆页的"惊艳感",但加速视疲劳。
2. **环境光不适配**:学习者白天明亮教室与晚上昏暗宿舍共用同一界面。
3. **缺乏个性化归属**:无法调整主题会削弱"这是我的学习空间"的持续使用意愿。

## 2. 目标

- 提供 3 套精心设计的预设主题,覆盖夜间/阅读/白天三场景。
- 全站支持主题切换(含着陆页)。
- 首次访问跟随系统 `prefers-color-scheme`,用户手动切换后记住偏好。
- 保留黑金作为默认品牌主题,不稀释品牌识别。

## 3. 非目标

- 不做"调色板自由切换"(红绿蓝紫),避免稀释品牌与增加心智负担。
- 不做用户自定义颜色导入/导出。
- 不引入 CSS-in-JS 运行时主题引擎,保持纯 CSS 变量方案。

## 4. 主题定义

### 4.1 Obsidian(默认·现状保留)

夜间/品牌展示场景。完全保留当前黑金配色。

| Token | 值 | 用途 |
|-------|-----|------|
| `--bg-deep` | `#0a0a0f` | 页面底色 |
| `--bg-surface` | `#12121a` | 卡片底 |
| `--bg-elevated` | `#1a1a26` | 悬浮元素 |
| `--bg-input` | `#0a0a0f` | 输入框底 |
| `--bg-code` | `#08080d` | 代码块底 |
| `--gold-primary` | `#d4a853` | 主强调 |
| `--gold-light` | `#e8c878` | 次强调 |
| `--gold-dim` | `#8a6d2b` | 弱化强调 |
| `--teal-accent` | `#2dd4bf` | 用户消息/已掌握 |
| `--teal-deep` | `#0d9488` | — |
| `--text-primary` | `#f0ece4` | 正文 |
| `--text-body` | `#c9c4ba` | Markdown 正文 |
| `--text-secondary` | `#9a958c` | 次要文字 |
| `--text-muted` | `#5c584f` | 弱化文字 |
| `--border-gold` | `rgba(212, 168, 83, 0.12)` | 金色描边 |
| `--border-gold-strong` | `rgba(212, 168, 83, 0.25)` | 强描边 |
| `--grain-opacity` | `0.04` | 噪点强度 |

### 4.2 Parchment(米黄纸·阅读优化)

长时间阅读/白天柔和场景。模拟旧书纸质感。

| Token | 值 |
|-------|-----|
| `--bg-deep` | `#f5f0e6` |
| `--bg-surface` | `#ede5d3` |
| `--bg-elevated` | `#e3d9c2` |
| `--bg-input` | `#fbf7ee` |
| `--bg-code` | `#e8dfc9` |
| `--gold-primary` | `#8a5a1f` |
| `--gold-light` | `#a87328` |
| `--gold-dim` | `#6b4415` |
| `--teal-accent` | `#0d7d72` |
| `--teal-deep` | `#0a5e56` |
| `--text-primary` | `#3d2f1f` |
| `--text-body` | `#5a4a35` |
| `--text-secondary` | `#7a6a55` |
| `--text-muted` | `#9a8a75` |
| `--border-gold` | `rgba(138, 90, 31, 0.18)` |
| `--border-gold-strong` | `rgba(138, 90, 31, 0.35)` |
| `--grain-opacity` | `0.06` |

### 4.3 Daylight(清亮白·强光环境)

强光环境/偏好浅色场景。保留金色作品牌锚点。

| Token | 值 |
|-------|-----|
| `--bg-deep` | `#fafafa` |
| `--bg-surface` | `#ffffff` |
| `--bg-elevated` | `#f4f4f5` |
| `--bg-input` | `#ffffff` |
| `--bg-code` | `#f4f4f5` |
| `--gold-primary` | `#a87328` |
| `--gold-light` | `#c89548` |
| `--gold-dim` | `#8a5a1f` |
| `--teal-accent` | `#0d9488` |
| `--teal-deep` | `#0a766c` |
| `--text-primary` | `#1a1a1a` |
| `--text-body` | `#3a3a3a` |
| `--text-secondary` | `#6a6a6a` |
| `--text-muted` | `#9a9a9a` |
| `--border-gold` | `rgba(168, 115, 40, 0.18)` |
| `--border-gold-strong` | `rgba(168, 115, 40, 0.35)` |
| `--grain-opacity` | `0.03` |

## 5. 技术方案

### 5.1 CSS 变量架构

在 `src/index.css` 顶部定义 `:root`(默认 Obsidian)与 `[data-theme="parchment"]`、`[data-theme="daylight"]` 三个选择器块,每个块覆盖全部 token 变量。

所有现有硬编码颜色(`#0a0a0f`、`#d4a853`、`rgba(212, 168, 83, 0.12)` 等)替换为 `var(--token-name)`。

### 5.2 Tailwind 集成

`tailwind.config.js` 的 `colors` 字段从硬编码值改为 `rgb(var(--token-rgb) / <alpha-value>)` 形式,或直接引用 CSS 变量字符串。考虑到当前 Tailwind 类使用较少(项目以自定义 CSS 类为主),采用直接字符串引用 `'var(--bg-deep)'` 即可。

### 5.3 状态管理

`settingsStore` 增加:

```ts
type ThemeId = 'obsidian' | 'parchment' | 'daylight' | 'system'
theme: ThemeId // 'system' 表示跟随系统
resolvedTheme: ThemeId // 实际解析后的主题(不含 'system')
setTheme: (theme: ThemeId) => void
```

`'system'` 在 store 内部解析为 `obsidian` 或 `daylight`(基于 `prefers-color-scheme`)。持久化 `theme` 字段。

### 5.4 主题应用

在 `App.tsx` 中通过 `useEffect` 监听 `theme` 变化,将 `data-theme` 属性写到 `document.documentElement`。同时监听 `prefers-color-scheme` 变化(当 `theme === 'system'` 时重新解析)。

为避免首屏闪烁(FOUC),在 `main.tsx` 中插入一段同步脚本,在 React 渲染前从 localStorage 读取 `kf-settings` 并设置 `data-theme`。

### 5.5 切换器 UI

`ThemeSwitcher` 组件:3 段式 segmented control(Obsidian / Parchment / Daylight),含一个"跟随系统"选项。放置于:
- `AppNav`(应用内导航栏,右侧)
- `Nav`(着陆页导航栏,右侧)

样式遵循现有 `.btn-ghost` 风格,使用图标 + 文字。

## 6. 兼容性与迁移

- 现有所有页面无需改动业务逻辑,仅样式 token 替换。
- `SettingsPage` 等内联 `style={{ color: '#f0ece4' }}` 需替换为 `var(--text-primary)`。
- `tailwind.config.js` 中 `animation` / `keyframes` 不变。

## 7. 验收标准

1. 三套主题切换时无视觉断裂,所有页面颜色一致变化。
2. 刷新页面后主题保持,无 FOUC。
3. 首次访问(无 localStorage)时,深色系统→Obsidian,浅色系统→Daylight。
4. `prefers-color-scheme` 变化时(且 `theme === 'system'`),主题实时跟随。
5. 现有黑金视觉与切换前像素级一致(Obsidian 主题)。
21 changes: 21 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KnowledgeFlow — 让知识流入你的脑海</title>
<script>
// 主题防闪烁(FOUC prevention):
// 必须在 stylesheet 和应用脚本之前同步执行,否则首帧会以 :root 默认(obsidian)渲染后跳变。
// 与 main.tsx 的 resolveSystemTheme() 保持一致逻辑,但此处为内联副本(无法 import 模块)。
(function () {
try {
var raw = localStorage.getItem('kf-settings');
var stored = raw ? JSON.parse(raw).state.theme : null;
var theme = stored || 'system';
var resolved = theme;
if (theme === 'system') {
resolved = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'obsidian'
: 'daylight';
}
document.documentElement.setAttribute('data-theme', resolved);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'obsidian');
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
Expand Down
30 changes: 30 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,42 @@
import { useEffect } from 'react'
import { HashRouter, Routes, Route } from 'react-router-dom'
import LandingPage from './pages/LandingPage'
import StudyPage from './pages/StudyPage'
import PlansPage from './pages/PlansPage'
import PracticePage from './pages/PracticePage'
import ResourcesPage from './pages/ResourcesPage'
import SettingsPage from './pages/SettingsPage'
import { useSettingsStore, resolveTheme } from './store/settingsStore'

/**
* 主题副作用 hook:
* - 订阅 store 中的 theme(含 'system')
* - 将解析后的 ResolvedTheme 写入 <html data-theme>
* - 当 theme === 'system' 时,监听 prefers-color-scheme 变化实时跟随
*
* matchMedia 防护与 settingsStore.resolveSystemTheme() 保持一致:
* 不支持 matchMedia 的环境回退到 obsidian,不抛错。
*/
function useThemeEffect() {
const theme = useSettingsStore((s) => s.theme)

useEffect(() => {
const root = document.documentElement
const apply = () => {
root.setAttribute('data-theme', resolveTheme(theme))
}
apply()

if (theme !== 'system') return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

theme === 'system'分支没有检查window.matchMedia是否存在;这里会在不支持该 API 的环境里抛错,建议先复用resolveSystemTheme()` 并在监听前加存在性判断。

if (typeof window === 'undefined' || !window.matchMedia) return
const mql = window.matchMedia('(prefers-color-scheme: dark)')
mql.addEventListener('change', apply)
return () => mql.removeEventListener('change', apply)
}, [theme])
}

export default function App() {
useThemeEffect()
return (
<HashRouter>
<Routes>
Expand Down
4 changes: 4 additions & 0 deletions src/components/AppNav.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NavLink } from 'react-router-dom'
import { useSettingsStore } from '../store/settingsStore'
import ThemeSwitcher from './ThemeSwitcher'

const NAV_ITEMS = [
{ to: '/study', label: '智能学习' },
Expand Down Expand Up @@ -36,6 +37,9 @@ export default function AppNav() {
<span className="badge badge-demo">演示模式</span>
</li>
)}
<li>
<ThemeSwitcher />
</li>
</ul>
</nav>
)
Expand Down
4 changes: 2 additions & 2 deletions src/components/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function Hero({ onStart, onLearnMore }: { onStart?: () => void; o
style={{
inset: '40px',
animation: 'ringRotate 25s linear infinite reverse',
borderColor: 'rgba(45, 212, 191, 0.1)',
borderColor: 'var(--teal-ring-soft)',
}}
>
<div className="hero-ring-dot" />
Expand All @@ -45,7 +45,7 @@ export default function Hero({ onStart, onLearnMore }: { onStart?: () => void; o
style={{
inset: '120px',
animation: 'ringRotate 35s linear infinite reverse',
borderColor: 'rgba(45, 212, 191, 0.08)',
borderColor: 'var(--teal-ring-faint)',
}}
/>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/components/Nav.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom'
import ThemeSwitcher from './ThemeSwitcher'

export default function Nav() {
return (
Expand All @@ -13,6 +14,7 @@ export default function Nav() {
<li><Link className="nav-link" to="/practice">练习</Link></li>
<li><Link className="nav-link" to="/resources">资源</Link></li>
<li><Link className="nav-link" to="/settings">设置</Link></li>
<li><ThemeSwitcher /></li>
</ul>
</nav>
)
Expand Down
34 changes: 34 additions & 0 deletions src/components/ThemeSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { THEME_OPTIONS, useSettingsStore, type ThemeId } from '../store/settingsStore'

/**
* 主题切换器 — 4 段式 segmented control
* 系统 / 黑曜 / 羊皮纸 / 日光
*/
export default function ThemeSwitcher() {
const theme = useSettingsStore((s) => s.theme)
const setTheme = useSettingsStore((s) => s.setTheme)

return (
<div
className="theme-switcher"
role="radiogroup"
aria-label="主题切换"
title="切换主题"
>
{THEME_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
role="radio"
aria-checked={theme === opt.id}
aria-label={`${opt.label} — ${opt.hint}`}
title={`${opt.label} · ${opt.hint}`}
className={`theme-switcher-btn ${theme === opt.id ? 'active' : ''}`}
onClick={() => setTheme(opt.id as ThemeId)}
>
{opt.icon}
</button>
))}
</div>
)
}
Loading
Loading