【免费下载链接】pomotroid
:tomato: Simple and visually-pleasing Pomodoro timer
Pomotroid 是一款基于 Tauri + Svelte 的番茄钟应用,其主题系统曾长期停留在"单个主题手动切换"的阶段:用户在浅色与深色环境之间移动时,必须手工更换主题。本文以仓库中openspec/changes/archive/2026-02-26-auto-light-and-dark-mode/下的设计文档(design.md)、方案文档(proposal.md)与规格文档(specs/theme-mode/spec.md)为主线,完整讲解该次迭代如何用三个设置字段取代单一theme字段、用window.matchMedia('(prefers-color-scheme: dark)')实现前端实时跟随操作系统深浅色、如何保证主窗口与设置窗口同步刷新,以及如何通过一次性 DB 迁移让老用户无感升级。读完本文,你将掌握这套"模式 + 双选择器 + 共享解析函数 + 幂等迁移"的完整实现思路,并能在自己的 Tauri 项目中直接复用。
背景:单一theme字段的局限性
在本次改动之前,Pomotroid 的主题机制非常朴素:
- 设置存储在一张扁平的 SQLite 键值表
settings中(迁移脚本定义了key TEXT PRIMARY KEY, value TEXT NOT NULL结构),当前激活主题就是表里的一个字符串键theme(主题名)。 - 前端在启动时读取该值,按主题名查找到对应的主题 JSON,再把其中的颜色键值写入
:root的 CSS 自定义属性。 - 唯一的应用入口是
applyTheme()(src/lib/stores/theme.ts),它把Theme.colors中的每一项(键已含--前缀,例如--color-background)逐个写入document.documentElement.style.setProperty(key, value)。主窗口与设置窗口共用这一个函数。
问题在于:这种模型无法感知操作系统当前的浅色/深色偏好。用户白天用浅色主题、晚上想切深色主题,只能手动进入设置更换,这与现代桌面应用"跟随系统"的预期相悖。从源码结构看,仓库内置了多达 38 个 JSON 主题(src-tauri/src/themes/mod.rs 通过include_str!在编译期嵌入),其中既有浅色主题(如pomotroid-light.json、github.json、solarized-light.json、rose-pine-dawn.json),也有大量深色主题,但应用并不区分它们的明暗属性——本次改动的范围也刻意排除了"在 JSON 中给主题标注 light/dark 分类"这一项(见 Non-Goals)。
新的设置模型:theme_mode+theme_light+theme_dark
设计文档给出的核心决策是Option B:彻底删除theme字段,而不是保留它作为缓存。三个新字段各司其职:
| 设置键 | 类型 | 取值 / 默认值 | 语义 |
|---|---|---|---|
theme_mode | string | "auto"/"light"/"dark",默认"auto" | 决定活动主题如何解析 |
theme_light | string | 主题名,仓库当前 Rust 默认值为"Pomotroid Light" | 浅色选择器选中的主题 |
theme_dark | string | 主题名,仓库当前 Rust 默认值为"Pomotroid" | 深色选择器选中的主题 |
需要说明一个细节:规格文档(specs/theme-mode/spec.md)在设计时要求两个默认值均为"Pomotroid",而当前仓库中 Rust 侧的 settings/defaults.rs 与Settings::default()(settings/mod.rs)实际落地的默认值是theme_light = "Pomotroid Light"、theme_dark = "Pomotroid"——这可以推断是后续"主题选择重设计"迭代调整的结果;前端 stores/settings.ts 的兜底默认值则仍是theme_light: 'Pomotroid'。阅读本文时请以当前仓库代码为准。
选择"推导而非缓存"的理由很关键:如果保留theme作为写透缓存,那么每次 OS 深浅色变化、每次选择器改动都必须同步更新它,还要跨主窗口与设置窗口维护一致性,存在漂移风险;而每次启动时用一次matchMedia.matches重新推导成本极低,从根源上消灭了"第二数据源"。该字段的移除是破坏性变更——任何直接读取设置 DB 的外部工具将看不到theme键,设计文档明确认为这是可接受的内部实现细节。
对应的类型层改动包括:RustSettings结构体把theme: String替换为三个新字段(settings/mod.rs);前端 types.ts 的Settings接口同步替换,并在注释中标注theme_mode合法值为'auto' | 'light' | 'dark'。
活动主题解析规则:一个共享函数搞定
活动主题永远在运行时推导,推导规则被抽成了唯一的共享函数resolveThemeName()(src/lib/utils/theme.ts):
import type { Settings } from '$lib/types'; export function resolveThemeName(settings: Settings, osDark: boolean): string { switch (settings.theme_mode) { case 'light': return settings.theme_light; case 'dark': return settings.theme_dark; default: // 'auto' return osDark ? settings.theme_dark : settings.theme_light; } }规则可以浓缩为一张决议矩阵:
theme_mode | OSprefers-color-scheme | 活动主题 |
|---|---|---|
auto | dark | theme_dark |
auto | light | theme_light |
light | 任意 | 恒为theme_light(忽略 OS) |
dark | 任意 | 恒为theme_dark(忽略 OS) |
设计文档特别强调:主窗口+page.svelte与设置窗口settings/+page.svelte必须共用这一函数,因为两个窗口需要完全一致的解析逻辑,复制粘贴两份实现必然导致漂移。这对应 tasks.md 中 3.1 的交付物。
前端-only 的 OS 信号检测:为什么不需要 Rust 参与
实现 OS 深浅色检测只用了浏览器原生 API,没有引入任何新依赖(proposal.md 的 Impact 一节明确写了 "No new dependencies"):
const osDark = window.matchMedia('(prefers-color-scheme: dark)').matches;设计文档对这一决策给出了两个层次的论证:
applyTheme()完全是前端行为。Rust 侧只需要持久化用户偏好(theme_mode/theme_light/theme_dark),永远不需要知道"当前解析出来的是哪个主题"。- 备选方案成本过高:Tauri 的
on_system_theme_changed窗口事件 → 发射自定义 IPC 事件 → 前端监听,要为同一个结果额外引入约 3 层管道。
另一个附带好处是无启动闪烁风险:matchMedia.matches是同步查询,不存在异步间隙,启动时可以在show()窗口之前完成主题应用(+page.svelte中applyTheme之后才调用getCurrentWebviewWindow().show())。这与仓库中"设置/统计窗口先隐藏创建、应用主题后再显示以避免白屏闪烁"的做法一脉相承。
双窗口集成:启动解析、实时监听、设置同步
主题逻辑在主窗口与设置窗口的实现是完全对称的,以主窗口 src/routes/+page.svelte 为例,完整生命周期包含三条路径:
① 启动解析:onMount中先getSettings()拿到完整设置,再getThemes()拿到全部主题,用resolveThemeName(s, osDark)找到活动主题并applyTheme();找不到时回退到themes[0]:
const osDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const active = themes.find((t) => t.name === resolveThemeName(s, osDark)) ?? themes[0]; if (active) applyTheme(active);② 实时 OS 监听:注册matchMedia('(prefers-color-scheme: dark)')的change事件,仅在theme_mode === 'auto'时重新解析并应用主题——这正是 spec 中"OS 变化在非 Auto 模式下被忽略"场景的实现:
const mqListener = async (e: MediaQueryListEvent) => { if ($settings.theme_mode !== 'auto') return; const allThemes = await getThemes(); const t = allThemes.find((th) => th.name === resolveThemeName($settings, e.matches)); if (t) applyTheme(t); }; mq.addEventListener('change', mqListener);③ 设置变更同步:监听 Rust 广播的settings:changed事件(由 commands.rs 的settings_set在每次保存后app.emit("settings:changed", ...)触发)。处理器先缓存旧值,再比较theme_mode、theme_light、theme_dark三者是否变化,任一变化即用新设置重新解析并应用——这样无论改动发生在哪个窗口,另一个窗口都会同步刷新。设置窗口 settings/+page.svelte 的逻辑完全相同。
此外还有一条兜底路径:自定义主题热重载(themes:changed事件)时也会按当前模式重新解析,确保主题文件被替换后界面立即刷新。
Appearance 界面:模式选择器 + 双折叠选择器 + 延迟预览
界面改造集中在 AppearanceSection.svelte,其状态模型是理解整个交互的关键:
let osDark = $state(window.matchMedia('(prefers-color-scheme: dark)').matches); let openPicker = $state<'light' | 'dark' | null>(null); // 手风琴:同时只展开一个 let lightIsActive = $derived( $settings.theme_mode === 'light' || ($settings.theme_mode === 'auto' && !osDark) ); let darkIsActive = $derived( $settings.theme_mode === 'dark' || ($settings.theme_mode === 'auto' && osDark) );模式选择器是三个按钮的分段控件(Auto / Light / Dark),点击时先按"假设新模式"解析出主题并立即应用,再持久化theme_mode:
async function setMode(mode: string) { const resolved = resolveThemeName({ ...$settings, theme_mode: mode }, osDark); const t = themes.find((th) => th.name === resolved); if (t) applyTheme(t); await setSetting('theme_mode', mode); }两个独立选择器各有一整套卡片列表(复用旧的单选择器卡片布局),每个卡片显示该主题自身的背景色、前景色、强调色以及三个 round 色块作为预览,选中项打勾,当前激活选择器中的选中项额外获得高亮边框。选择器的激活状态由lightIsActive/darkIsActive两个派生值决定:mode=light或mode=auto+OS 浅色时浅色选择器激活;mode=dark或mode=auto+OS 深色时深色选择器激活。
延迟预览(Deferred preview)是本次设计中一个反直觉但很正确的交互决策:点击非激活选择器中的主题,只调用setSetting保存,不调用applyTheme():
async function selectLight(theme: Theme) { if (lightIsActive) applyTheme(theme); // 仅激活时才应用 await setSetting('theme_light', theme.name); }理由在 design.md 的 Decision 4 中写得很清楚:用户此刻是在配置未来状态。设想 OS 处于深色、模式为 Auto 时,用户在浅色选择器里挑主题——若立即应用,界面会突然变成与当前 OS 相悖的浅色主题,非常令人困惑。备选方案"无论是否激活一律预览"被否决,因为 Auto 模式 + OS 深色时操作浅色选择器的场景太容易踩坑。规格文档为此专门保留了"Auto 模式 + OS 深色 + 选择浅色主题 → 仅保存、活动主题不变"的验收场景。另外,AppearanceSection.svelte自身也挂了matchMedia监听(onMount中mq.addEventListener('change', mqListener)更新osDark),确保设置窗口打开期间用户切换 OS 深浅色时,激活徽章与高亮状态能实时纠正——这正是 design.md Risks 一节中"设置窗口不同步"风险的解法。
Rust 侧还有一个与界面联动的细节:settings_set在theme_mode/theme_light/theme_dark任一变化时,会同步更新托盘图标的配色(commands.rs)——托盘图标跟随活动主题而非模式,这印证了"Rust 只关心偏好、不推导活动主题"的分工。
数据库迁移:让老用户的 Nord 不变成 Pomotroid
为什么必须写迁移?设计文档给出了一个很容易被忽略的陷阱:seed/defaults 机制只对缺失键插入默认值(INSERT OR IGNORE,见 settings/mod.rs 的seed_defaults)。如果仅靠 seed,老用户 DB 里只有theme="Nord"而没有theme_light/theme_dark,新增字段会被塞进默认的 "Pomotroid",用户的 Nord 偏好就静默丢失了。
因此设计文档要求新增一次性迁移,在启动时、加载设置之前执行:
- 新增迁移写入 db/migrations.rs(迁移版本号机制:
schema_version表 +run()中按version < N逐级执行,每步包在事务里,失败则整体回滚); - 若
theme_light键缺失:读取theme值,同时写入theme_light与theme_dark,并把theme_mode置为"auto"; - 删除(或保留为孤儿)旧的
theme键——设计文档明确表示两种做法在功能上无差异,tasks.md 选择的是删除; - 全新安装由
DEFAULTSseed 直接得到三个新键; - 无需回滚路径——迁移是纯增量的、对用户数据非破坏性的。
迁移的幂等性有测试保障:migration_is_idempotent对同一内存库连续执行两次run(),断言不报错且schema_version停在最新值(db/migrations.rs)。仓库中的既有迁移(如 MIGRATION_2 把time_*_mins转为time_*_secs)展示了同样的模式:INSERT OR IGNORE ... SELECT读取旧值写入新键,再DELETE旧键。规格文档为迁移定义了验收场景:老用户带自定义主题(如 Nord)升级后,两个选择器都显示 Nord 且模式为 Auto,界面无感知变化。
风险与权衡回顾
设计文档在 Risks / Trade-offs 一节做了三条明确评估,结合源码可逐一验证:
- 启动闪烁(无风险):
matchMedia是同步的,不存在异步间隙,两个窗口都在show()前完成主题应用(src/routes/+page.svelte、src/routes/settings/+page.svelte)。 - 设置窗口失同步(有明确对策):设置窗口打开期间 OS 切换深浅色,激活高亮会过期——
AppearanceSection自带的matchMedia监听实时更新osDark,派生值随之重算。 theme键移除是破坏性的(接受):直接读 DB 的外部工具会失去该键,但这是内部实现细节,应用自身通过迁移与推导完全自洽。
验证路径:从冒烟测试到类型检查
tasks.md 的 Cleanup & Verification 一节给出了完整的验收清单,可作为实现后的自检模板:
cargo test——确认移除 Rusttheme字段后无编译/测试破坏;npm run check——确认前端零类型错误(theme引用全部清除);- 冒烟测试:全新安装默认 Auto 模式、两个选择器各显示默认主题激活;
- 冒烟测试:切换模式与选择器,验证"正确应用或正确延迟";
- 冒烟测试:Auto 模式下实时切换 OS 深浅色,两个窗口主题同步变化。
规格文档还要求迁移场景"对用户无可见变化",即老用户升级后看到的是与旧版一致的主题外观,但背后已切换为三字段模型。
小结
本次迭代的核心方法论值得记住:把"用户偏好"与"解析结果"彻底分离——DB 只存三个偏好字段,活动主题永远由resolveThemeName()在运行时推导;用前端原生 API 取代跨进程事件管道,避免为同一个结果引入多余的 Tauri 命令与事件层;用一次幂等迁移保护老用户数据,而不是依赖只补缺省值的 seed 机制。这套模式 + 双选择器 + 共享解析 + 迁移的组合,稍加改造即可复用于任何需要"跟随系统深浅色"的桌面应用。
【免费下载链接】pomotroid
:tomato: Simple and visually-pleasing Pomodoro timer
相关推荐
自动切换网站主题:妙用prefers-color-scheme实现暗色模式检测
自动切换网站主题:妙用prefers color scheme实现暗色模式检测 你是否遇到过这样的情况:晚上浏览网站时,突然弹出的白色背景让眼睛刺痛不已?或者白
前端文档Bulma 深色模式(Dark Mode)实现指南:基于 prefers-color-scheme 与 CSS 变量的主题切换机制
Bulma 深色模式(Dark Mode)实现指南:基于 prefers color scheme 与 CSS 变量的主题切换机制 Bulma 的深色模式不是简
前端UI组件antd-mobile 深色模式(Dark Mode)接入指南:基于 `data-prefers-color-scheme` 属性与 CSS 变量的主题实现
antd mobile 深色模式(Dark Mode)接入指南:基于 data prefers color scheme 属性与 CSS 变量的主题实现 ant
UI组件前端移动开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考