Pomotroid 自动浅色/深色模式实战:基于 prefers-color-scheme 的主题解析、双主题选择器与 SQLite 迁移

发布时间:2026/10/12 3:07:32
Pomotroid 自动浅色/深色模式实战:基于 prefers-color-scheme 的主题解析、双主题选择器与 SQLite 迁移 【免费下载链接】pomotroid:tomato: Simple and visually-pleasing Pomodoro timer项目地址https://gitcode.com/gh_mirrors/po/pomotroid点击查看免费下载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_modetheme_lighttheme_dark设计文档给出的核心决策是Option B彻底删除theme字段而不是保留它作为缓存。三个新字段各司其职设置键类型取值 / 默认值语义theme_modestringauto/light/dark默认auto决定活动主题如何解析theme_lightstring主题名仓库当前 Rust 默认值为Pomotroid Light浅色选择器选中的主题theme_darkstring主题名仓库当前 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.tsimport 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_modeOSprefers-color-scheme活动主题autodarktheme_darkautolighttheme_lightlight任意恒为theme_light忽略 OSdark任意恒为theme_dark忽略 OS设计文档特别强调主窗口page.svelte与设置窗口settings/page.svelte必须共用这一函数因为两个窗口需要完全一致的解析逻辑复制粘贴两份实现必然导致漂移。这对应 tasks.md 中 3.1 的交付物。前端-only 的 OS 信号检测为什么不需要 Rust 参与实现 OS 深浅色检测只用了浏览器原生 API没有引入任何新依赖proposal.md 的 Impact 一节明确写了 No new dependenciesconst 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 $statelight | 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_modeasync 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两个派生值决定modelight或modeautoOS 浅色时浅色选择器激活modedark或modeautoOS 深色时深色选择器激活。延迟预览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 里只有themeNord而没有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项目地址https://gitcode.com/gh_mirrors/po/pomotroid点击查看免费下载相关推荐自动切换网站主题妙用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 变量的主题实现 antUI组件前端移动开发上一篇Grasscutter 报错排查11 个高频错误码对着日志就能改下一篇texture-synthesis重复变换技术一次生成多次应用的强大功能创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询