Tolaria ADR-0081:基于语义 CSS 变量契约的应用自持明暗主题运行时
2026/9/13 4:08:27 网站建设 项目流程

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 主题运行时被刻意压缩到比通用主题系统小得多的规模,共五条边界约束:

  1. 主题由应用定义,而不是由 vault 笔记定义;
  2. CSS 自定义属性是公开的运行时契约,服务于产品组件、Tailwind v4 与 shadcn/ui;
  3. 带类型的 TypeScript 辅助函数为无法直接读取 CSS 变量的消费方(典型如 CodeMirror 扩展)派生取值;
  4. 既有 CSS 变量保留为兼容性别名,供 UI 逐步向语义命名迁移;
  5. 首批持久化选择只有lightdark;跟随系统、高对比变体、自定义主题、按 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-backgroundtext-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观察documentElementclass/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 minimalindex.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-themedarkclass 打到<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处理命名、themeCssValueline-height/font-weight等无单位数值特判),useEditorTheme()useMemo缓存并输出cssVarsstyleString(第 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询