Tolaria ADR-0081:基于语义 CSS 变量契约的应用自持明暗主题运行时
【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria
Tolaria 的 ADR-0081 描述了如何在一个曾经“纯浅色”的应用里重新引入深色模式:不复活旧的 vault 主题系统,而是建立一套由应用自持(app-owned)的明暗双主题运行时,以语义化 CSS 自定义属性作为核心契约,并把用户的主题偏好持久化为安装级别的应用设置。读完本文,你能理解这套主题的完整分层结构——从src/index.css的 token 契约、index.html的防闪烁预渲染脚本,到useThemeMode/useTheme等 React 层的桥接实现——以及它如何兼容 Tailwind v4、shadcn/ui 与 CodeMirror 等无法直接读取 CSS 变量的消费方。
背景:从 vault 主题到 light-only,再到 ADR-0081
要理解 ADR-0081 的决策边界,需要先回顾它取代的 ADR-0013。
ADR-0013 删除了早期的 vault-based 主题系统:主题原本是theme/目录下带type: Themefrontmatter 的 Markdown 笔记,每个属性桥接为一个 CSS 变量,配套ThemeManagerhook、主题属性编辑器、深色模式检测、保存即预览等一整套设施。该方案横跨 Rust 的 seed/create/defaults 模块、TypeScript hooks 和 CSS 变量桥接,维护负担重,而绝大多数用户从未超出默认值做定制。ADR-0013 的结论是彻底删除,应用退化为单主题浅色,并明确写下重估触发条件:“如果深色模式成为可访问性或用户诉求上的硬性要求”。
ADR-0081(2026-04-24,supersedes: "0013")正是在这个触发条件达成后作出的决策:深色模式已成为长时段写作与可访问性的产品需求。但它同时划定了红线——旧形态的主题系统不能复活:vault 笔记、用户实时编辑的主题、宽泛的运行时编辑能力都被明确排除,因为那是维护负担的来源。
核心决策与 v1 运行时的边界
ADR-0081 的决策原文是:
Tolaria 将通过一套语义 CSS 变量契约支持内部应用自持的明暗主题,用户选择的主题模式作为安装级别的应用设置持久化。
v1 主题运行时被刻意压缩到比通用主题系统小得多的规模,共五条边界约束:
- 主题由应用定义,而不是由 vault 笔记定义;
- CSS 自定义属性是公开的运行时契约,服务于产品组件、Tailwind v4 与 shadcn/ui;
- 带类型的 TypeScript 辅助函数为无法直接读取 CSS 变量的消费方(典型如 CodeMirror 扩展)派生取值;
- 既有 CSS 变量保留为兼容性别名,供 UI 逐步向语义命名迁移;
- 首批持久化选择只有
light和dark;跟随系统、高对比变体、自定义主题、按 vault 的主题均被推迟。
值得说明的一点是:从源码结构看,运行时后来实际把system也纳入了合法的持久化取值——src/lib/themeMode.ts 中THEME_MODES集合包含light/dark/system三个值,ThemeMode = ResolvedThemeMode | 'system'。这表明系统跟随模式已作为运行时能力存在,属于 v1 决策之后的演进;仓库中后续的 ADR-0112 即是对该模式的进一步决策化。
备选方案:四个选项与取舍
ADR-0081 评估了四个方向,只有第一个入选:
| 方案 | 评估结论 |
|---|---|
| 内部明暗运行时 + 语义 token(采纳) | 交付深色模式的同时,把产品持有的主题面保持得小、可测试,且兼容既有 CSS 变量用法 |
| 恢复 vault 笔记主题 | 灵活,但会重复 ADR-0013 已删除的复杂度,且让深色模式依赖用户可编辑数据 |
组件内 ad hoc.dark覆盖 | 初始最快,但会把颜色逻辑散落到全应用,使未来主题变体代价高昂 |
| 单一 TypeScript 主题对象作为 source of truth | 对校验有吸引力,但应用现状已依赖 CSS 变量服务于 Tailwind、shadcn/ui、BlockNote CSS 覆盖和大量产品组件 |
“单 TS 对象”方案被否的理由尤其有代表性:它不是技术上不可行,而是与现有架构(CSS 变量已经是 Tailwind v4 与 shadcn/ui 的取值通道)脱节——引入双份事实来源只会制造新的同步问题。
语义 CSS 变量契约:src/index.css的结构
ADR-0081 的后果条款第一条明确:src/index.css拥有应用外壳与共享状态(shared states)的稳定 CSS 自定义属性契约。仓库中的 src/index.css 完全按此落地,文件开头即标注 “Theme Variables — app-owned light/dark contract”。
双主题块与 token 分组
契约由两个选择器块构成::root, [data-theme="light"](第 32–184 行)与:root.dark, [data-theme="dark"](第 186–337 行)。两块使用完全对称的 token 命名,按角色分组:
:root, [data-theme="light"] { color-scheme: light; /* --- Semantic surfaces (light mode) --- */ --surface-app: #FFFFFF; --surface-sidebar: #F7F6F3; --surface-editor: #FFFFFF; /* ... */ /* --- Semantic text (light mode) --- */ --text-primary: #37352F; --text-heading: #37352F; /* ... */ /* --- Interaction states (light mode) --- */ --state-hover: #EBEBEA; --state-selected: #E8F4FE; --state-focus-ring: #155DFF; /* ... */ }dark 块则给出全套对应值,例如--surface-app: #1F1E1B、--text-primary: #E6E1D8、--accent-blue: #78A4FF(第 186–235 行)。token 的完整分组为:
- 语义表面(Surfaces):
--surface-app/sidebar/panel/card/popover/input/button/dialog/editor/overlay,覆盖应用外壳、侧栏、卡片、弹层到编辑区的每一层背景; - 语义文本(Text):
--text-primary/secondary/tertiary/muted/faint/heading/inverse,形成完整的文字灰阶; - 语义边框(Borders):
--border-default/subtle/strong/input/dialog/focus; - 交互状态(States):
--state-hover/selected/selected-strong/active/focus-ring/drag-target/disabled,以及编辑器的--editor-selection; - 强调色角色(Accents):
--accent-blue/green/orange/red/purple/yellow/teal/pink/gray,各带-light半透明背景变体; - 反馈角色(Feedback):
--feedback-info/success/warning/error-*,服务于徽章、警告、错误提示; - 语法与 diff 角色:
--syntax-highlight-*(关键词、字符串、注释、类型等)、--syntax-frontmatter-key/value、--diff-added/removed-text/bg、--diff-hunk-bg,直接支撑原始编辑器的语法高亮与 Git diff 视图——这正是 ADR Context 中提到的 “product-specific states such as selected rows, badges, warnings, and diff lines”。
shadcn/ui 别名与旧变量兼容层
契约的第二、三层在同一文件内完成。先是 shadcn 别名块(第 140–168 行,dark 块对称位于 第 294–321 行):
/* --- shadcn theme aliases (light mode) --- */ --background: var(--surface-app); --foreground: var(--text-primary); --primary: var(--accent-blue); --destructive: var(--accent-red); --sidebar: var(--surface-sidebar); /* ... */即 shadcn/ui 的标准 token(--background、--primary、--ring等)全部指向语义 token,而不是持有自己的色值。随后是兼容性别名(第 170–184 行):--bg-primary: var(--surface-app)、--border-primary: var(--border-default)等旧命名被保留,让既有产品代码在 UI 向语义命名迁移期间继续工作。这正对应 ADR 的第四条边界。
接入 Tailwind v4
在别名之后,文件用 Tailwind v4 的@theme inline块(第 340–385 行)把 shadcn 与核心语义 token 注册为 Tailwind 颜色:
@theme inline { --color-background: var(--background); --color-primary: var(--primary); --color-surface-app: var(--surface-app); --color-state-selected: var(--state-selected); --radius-lg: var(--radius); }inline模式意味着 Tailwind 生成的工具类在运行时解析var(--*)引用,因此bg-background、text-foreground这类工具类在data-theme切换时自动跟随两套值,无需任何 JS 介入。基础层(第 388–401 行)进一步把body绑到bg-background text-foreground,使整个外壳只依赖契约而非具体色值。
主题模式持久化:安装级别的应用设置
ADR-0081 的另一条关键后果是:“App settings, not vault frontmatter, store the selected theme mode because it is an installation-local comfort preference.”(主题模式是安装级的舒适偏好,存于应用设置而非 vault frontmatter。)
从源码看,这一决策落在两处:
- 设置层:src/hooks/useAppPreferences.ts 中调用
useThemeMode(settings.theme_mode, settingsLoaded),即 Tauri 持久化的应用设置里的theme_mode字段是 React 层的权威输入,settingsLoaded保证设置加载完成后才生效; - 镜像层:localStorage 中的
tolaria-theme键作为预 React 阶段的镜像。键名定义在 src/constants/appStorage.ts(APP_STORAGE_KEYS.theme: 'tolaria-theme'),并通过copyLegacyAppStorageKeys()从旧项目名遗留键laputa-theme自动迁移(第 34–67 行)。
运行时的归一化与解析逻辑集中在 src/lib/themeMode.ts,它导出的核心接口包括:
normalizeThemeMode(value):只接受light/dark/system,其余返回null(第 19–21 行);resolveThemeMode(value, matchMedia):system时按prefers-color-scheme: dark解析为light/dark,解析失败回退默认值light(第 45–49 行);applyThemeModeToDocument(document, mode):同时写data-theme属性并切换darkclass——双写是为了覆盖[data-theme="dark"]与:root.dark两组选择器(第 82–86 行);readStoredThemeMode(storage):读取tolaria-theme,命中失败再读 legacy 键并回写迁移(第 67–76 行)。
React 侧的入口是 src/hooks/useThemeMode.ts:在loaded为真时解析运行时模式、应用到 document、写回 localStorage 镜像,并同步应用图标主题;若用户选择system,还会订阅matchMedia('(prefers-color-scheme: dark)')的 change 事件,在系统外观切换时重新应用(第 35–56 行)。另有 src/hooks/useDocumentThemeMode.ts 用useSyncExternalStore+MutationObserver观察documentElement的class/data-theme属性变化,把“当前生效主题”变成可订阅的 React 状态,供编辑器等消费方响应。相关行为有测试覆盖,如 src/hooks/useThemeMode.test.ts(覆盖dark/system/未加载等场景)。
启动防闪烁:三层防 light-mode flash 机制
ADR-0081 的后果条款中有一条工程上很细节的要求:
Startup must avoid a light-mode flash when dark mode is selected, so the runtime needs a pre-React localStorage mirror and a minimal
index.htmlprepaint style in addition to persisted Tauri settings.
即除了 Tauri 持久化设置外,还需要React 之前的 localStorage 镜像与index.html的最小预渲染样式。仓库的 index.html 同时实现了这两点:
1. 内联预渲染样式(prepaint style)
<head>内联样式块(第 43–225 行)定义了独立的--startup-*变量::root提供浅色默认值(--startup-surface-app: #FFFFFF等),:root[data-theme="dark"]提供深色值(--startup-surface-app: #1F1E1B等)。#tolaria-boot-shell骨架屏(sidebar/list/editor 三栏占位结构,第 310–337 行)完全基于这些 startup 变量着色。注释写得很直白:“index.html owns this structure/style so the Tauri WebView can paint app chrome before the React chunk loads.”(index.html 拥有此结构/样式,使 Tauri WebView 能在 React chunk 加载前绘制应用外壳。)
2. 内联预渲染脚本(localStorage 镜像)
<body>内、React 入口加载之前,有一段 IIFE(第 229–265 行):
var key = 'tolaria-theme'; var legacyKey = 'laputa-theme'; var systemDarkQuery = '(prefers-color-scheme: dark)'; function resolveTheme(value) { var mode = normalizeTheme(value) || 'light'; return mode === 'system' ? (prefersDarkTheme() ? 'dark' : 'light') : mode; } function applyTheme(value) { var mode = resolveTheme(value); document.documentElement.setAttribute('data-theme', mode); document.documentElement.classList.toggle('dark', mode === 'dark'); } // 读 tolaria-theme,失败则读 laputa-theme 并回写,异常时回退 light它在首帧绘制前把data-theme与darkclass 打到<html>上,使 startup 骨架直接以正确主题着色;try/catch兜底保证存储不可用时回退light。这段脚本与src/lib/themeMode.ts的解析规则保持一致,形成“同一契约、两处执行”的镜像关系。
3. React 层接管
React 挂载后,useThemeMode依据 Tauri 持久化的settings.theme_mode重新应用权威选择——这是三层中唯一能读取 Tauri settings 的一层,localStorage 镜像只负责“比 React 更快”,Tauri 设置负责“比镜像更权威”。
useTheme:编辑器主题扁平化桥
ADR-0081 还规定:src/theme.json继续描述编辑器排版,但编辑器面向的颜色应经由与应用外壳相同的语义 CSS 变量解析;useTheme继续负责编辑器主题扁平化,并可以成长为主题模式与编辑器消费方之间的桥。
仓库实现印证了这一点。src/theme.json 定义了编辑器排版(15px 字号、1.5 行高、820px 最大宽度、h1–h4 层级等)与全部颜色字段,而颜色字段的值全部是var(--*)引用语义 token,例如"heading": { "color": "var(--text-heading)" }、"code": { "backgroundColor": "var(--bg-hover-subtle)", "color": "var(--text-secondary)" }、"colors": { "selection": "var(--editor-selection)" }——即编辑器的任何颜色在data-theme切换时自动跟随明暗两套值,theme.json本身不持有任何十六进制色值。
扁平化在 src/hooks/useTheme.ts 中完成:flattenTheme()把嵌套配置对象递归展平为 CSS 自定义属性映射(camelToKebab处理命名、themeCssValue对line-height/font-weight等无单位数值特判),useEditorTheme()用useMemo缓存并输出cssVars与styleString(第 23–60 行),编辑器组件据此把主题注入局部样式。这正对应 ADR 后果条款中 “useThemeremains responsible for editor theme flattening” 的表述。
后果清单与重估触发条件
汇总 ADR-0081 的后果条款,并对照当前仓库状态:
src/index.css拥有稳定的 CSS 自定义属性契约(已实现,见上文 token 分组);src/theme.json继续描述编辑器排版,编辑器颜色经由同一套语义变量解析(已实现,见theme.json的全var(--*)颜色);useTheme负责编辑器主题扁平化,是主题模式到编辑器消费方的桥(已实现,并可继续扩展);- 应用设置(而非 vault frontmatter)存储主题模式(已实现,
settings.theme_mode+ localStorage 镜像双通道); - 启动需要 pre-React localStorage 镜像 +
index.html预渲染样式以避免浅色闪烁(已实现,见防闪烁三层机制); - 领域 token(domain tokens)只在某个界面需要“通用语义 token 无法清晰表达”的角色时才引入——这是对 token 膨胀的约束:先复用
--surface-*/--state-*等通用语义层,不为单一表面随手加新 token; - 若未来把用户自定义主题、按 vault 主题或系统同步主题升格为一等产品需求,需要重新评估本 ADR。
从后续 ADR 演进看(如 ADR-0112 对系统主题模式的决策化),这套契约确实在“小、可测试、兼容既有 CSS 变量用法”的边界内保持了可扩展性:新主题能力的加入不需要推翻 CSS 变量这一公开契约,只需要在themeMode解析链与index.css的 token 块之间增补。
小结
ADR-0081 的核心价值不在于“加了深色模式”,而在于重新确立了一个克制的主题架构:语义 CSS 变量是应用外壳、Tailwind v4、shadcn/ui、编辑器与 diff/语法高亮视图之间的单一契约面;TypeScript 只负责解析模式、扁平化配置和桥接不能读 CSS 变量的消费方;持久化走安装级应用设置并以 localStorage 镜像加速首帧;index.html内联样式与脚本保证深色用户在 React 加载前就看到正确的深色骨架。四个备选方案的取舍过程(尤其是拒绝“单 TS 对象”与 ad hoc.dark覆盖)为同类桌面应用的主题系统选型提供了可直接参照的评估框架。
【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考