-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 添加 3 套主题切换系统提升学习效率 #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
82 changes: 82 additions & 0 deletions
82
docs/superpowers/plans/2026-06-23-theme-system-implementation.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
155
docs/superpowers/specs/2026-06-23-theme-system-design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 主题)。 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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()` 并在监听前加存在性判断。