跳转至

迁移 — 逐组件 .css → Tailwind v4 工具类

状态: 计划(尚未执行) · 起草日期: 2026-06-30 · 类型: 增量式样式迁移,无预期视觉变化 负责人立场: 倾向于完整迁移,但重视不破坏 UI · 本计划的建议: 有界迁移(在布局/间距/排版处普遍使用工具类;保留 CSS 处理棘手部分)。参见 §8。

为什么

frontend/src 包含 74 个 .css 文件 / 16,615 行的全局、类 BEM CSS(dub-col、models-row__role、readiness-checklist__title、……)。Tailwind v4 已经接入 —— src/index.css 导入了 tailwindcss/theme.css + tailwindcss/utilities.css,包含一个 @theme 块,并且 vite.config.js 运行 @tailwindcss/vite。因此工具类的运行时成本已经支付;我们只是没有使用它们。如今编辑布局意味着要在 989 行的文件和 JSX 的 className 中寻找一个类。工具类把布局放在阅读它的地方 —— JSX 中 —— 并将逐组件 CSS 缩减到只有工具类无法表达的内容。

这不是重新设计。每一步都必须渲染出像素级一致的效果。诚实的障碍在于目前没有任何视觉回归测试 —— 之前的页面重构(见 docs/maintenance-pages-modularization.md)通过对比 className 字符串来验证“没有变化”,而那个技巧在这里毫无用处,因为重点恰恰是类名会改变。弥合这一差距是第一个真正的任务(§4),而不是事后补做。

当前状态(测量于 2026-06-30)

指标 值
.css 文件 74
CSS 总行数 16,615
整个 CSS 中的 var(--…) token 引用 ~3,200
使用 display:flex 的文件 64
使用 display:grid / grid-template 的文件 27
使用 transition: 的文件 46
使用 box-shadow 的文件 43
使用 @media 的文件 25
使用 linear/radial-gradient 的文件 22
使用 @keyframes 的文件(共 73 个块) 30
使用 animation: 的文件 34
使用 backdrop-filter/玻璃模糊的文件 11
使用 ::before/::after 的文件 11
使用 :has() 的文件 3
使用 !important 的文件 14

最大的文件(按布局密度而非原始大小排序的转换 ROI): index.css 2532 · FirstRunSetup.css 1020 · DubTab.css 989 · VoiceGallery.css 541 · StoriesEditor.css 525 · LogsFooter.css 507 · Settings.css 469 · CloneDesignTab.css 458 · settings/primitives/primitives.css 368.

token 系统(不要重新设计它):

  • src/ui/tokens.css(157 行,约 82 个自定义属性):被声明为“唯一事实来源” —— 颜色、4px 间距刻度(--space-0..9)、圆角、字体、字号刻度、字重、阴影、动效、z-index、焦点环、玻璃模糊。通过 src/ui/index.js 导入。
  • src/ui/themes.css(188 行):语义化颜色 token 的逐主题覆盖,以 <html> 上的 [data-theme="midnight|nord|solarized|…"] 为键。默认(无属性)= Gruvbox Dark。
  • src/index.css @theme { … }:将 token 子集映射到 Tailwind 的主题命名空间(--color-*、--radius-*、--font-*),从而存在 bg-bg、text-fg、rounded-lg、font-mono 等工具类。它硬编码了与 tokens.css 重复的十六进制字面量 —— 这就是已知的漂移缺陷(见 §2)。

当前的加载顺序:index.css(@theme → theme 层,优先级最低)在 App.jsx 中导入;tokens.css + themes.css 是未分层的 :root / [data-theme] 规则,通过 ui/index.js 导入。由于未分层的 CSS 优先级高于 @layer theme,tokens.css 已经赢得了默认值,且主题化已经生效 —— @theme 中的十六进制字面量实际上是一个失效的重复,其存在仅仅是为了让 Tailwind 知道工具类名称。这正是它们悄然漂移的原因:运行时没有任何东西读取它们,因此过期的值永远不会显现。

策略(整体形态)

  1. 增量式、逐组件 —— 绝不一次性大爆炸。 每个 PR 处理一个组件(或一个小集群)。每个 PR 都可独立发布且 CI 通过。半个迁移完成的组件没问题;半个迁移完成的代码库是未来几个月的常态,这是可接受的。
  2. 机械性 80% 采用工具类优先: flexbox、grid、gap、padding/margin、width/height、text-*/font-*、rounded-*、border、简单的 bg-*/text-* 颜色、hidden、truncate、基本的 hover:/focus: 颜色状态。这些与工具类一一对应,也是行数缩减收益所在。
  3. 硬核 20% 保留 .css: 玻璃拟态(分层渐变 + backdrop-filter)、::before/::after、@keyframes、:has() 及其他复杂组合器、[data-theme] 特定规则,以及任何用 !important 对抗特异性的东西。工具类无法干净地表达这些,强行转换(任意值大杂烩、[&::before]:…)只会把可读的 CSS 换成不可读的 JSX。
  4. 通过 token 桥(§2)实现单一事实来源: 工具类引用与剩余 .css 读取相同的 CSS 变量,因此一个值恰好存在于一个地方,data-theme 切换对两者都继续有效。
  5. 没有文件算“完成”,除非它被删除或被证明已最小化。 成功以移除的 CSS 行数和删除的 .css 文件数来衡量,而不是以“触碰过”的文件数。

2. Token 桥前置条件(P0 —— 门控一切)

只有当同一组件中的工具类和遗留 CSS 将某个 token 解析为相同的值(包括主题切换之后)时,迁移才是安全的。目前 @theme 字面量重复了 tokens.css;一旦组件开始混合 bg-bg(工具类)与 background: var(--color-bg)(CSS),任何漂移都会变成可见的、依赖主题的缺陷。在转换任何内容之前修复事实来源。

推荐修复 —— 方案 A(改动最小,不重命名): 让 @theme 成为已经重叠的组的唯一声明位置 —— 仅颜色、圆角、字体 —— 并从 tokens.css 中删除这些默认声明(留下一行指向性注释)。tokens.css 中的其他所有内容(间距、字号刻度、字重、阴影、动效、z-index、焦点环、玻璃模糊)保持不变。

为什么这样既正确又安全:

  • Tailwind 需要 @theme 中存在的键来生成工具类名称 (--color-fg → text-fg/bg-fg;--radius-lg → rounded-lg;--font-mono → font-mono)。保留这些键是绝不可妥协的。
  • @theme 会把 :root { --color-fg: … } 输出到低优先级的 theme 层。 themes.css 中的 [data-theme] 规则未分层,优先级仍然更高,因此 主题切换行为不变 —— 编辑后请快速手动遍历所有主题验证一下。
  • 从 tokens.css 删除重复的 :root 颜色/圆角/字体行后,每个值恰好只剩一个字面量。 现有约 3,200 处 var(--…) 引用仍能继续解析(该变量仍存在于 :root 上, 只不过现在来源于 @theme)。

防止复发(按“修复整个类”的规则,这是必须做的): 新增 frontend/src/__tests__/theme-token-parity.test.js(vitest,无浏览器环境),解析 index.css 的 @theme + tokens.css + themes.css,并断言: (a) 没有任何 token 键同时在 @theme 和 tokens.css 中以字面量声明 (可捕获重新引入的重复);(b) themes.css 中的每个 [data-theme] 块都覆盖了 @theme 中的每个颜色键(可捕获某个主题漏掉颜色)。这个测试才能让去重结果 保持 去重。

已否决的替代方案 — 方案 B(纯粹主义): 将源 token 重命名为私有命名空间 (--ov-color-fg),再通过 @theme inline { --color-fg: var(--ov-color-fg) } 桥接。这确实字面意义上落实了“tokens.css 是源”,也是教科书式的 Tailwind 模式, 但它迫使你一次性重命名 74 个文件中约 3,200 处 var(--color-*) 引用—— 这是一个庞大且高风险的 diff,违反“低风险、增量式”原则。不值得。 (@theme inline 引用同一个名称会造成循环,不是一个可选方案。)

可选的后续事项: 向 @theme 添加 --spacing,使 p-*/gap-*/m-* 映射到现有的 4px 刻度(--space-1 = 2px … --space-9 = 44px)。Tailwind 的默认 spacing 是 0.25rem 的倍数;VoiceStudio 的刻度是自定义的,所以如果不做这件事, gap-3 ≠ var(--space-3)。有两个选择,请在 P0 决定: - 映射到刻度: 设置 --spacing: 2px 无法重现非线性的步进;应在 @theme 中显式定义与 --space-1..9 对应的 --spacing-1..9,然后使用 gap-2/p-5 等。这对读者最清晰,但工具类数字不会与 Tailwind 默认值一致——请记录下来。 - 使用桥接到变量的任意值: gap-[var(--space-3)]、 p-[var(--space-5)]。零歧义,JSX 略为冗长,但保证像素完全一致。 推荐用于 P1–P2(对于“不改变视觉效果”最安全);等信心充足后再回头考虑命名间距。

3. 什么能干净地转换为工具类,什么继续留在 CSS 里

能干净地转换为工具类(具体例子,来自真实文件):

  • ReadinessChecklist.css .readiness-checklist { display:flex; flex-direction:column; gap:var(--space-3); padding:var(--space-5); border:1px solid var(--color-border); border-radius:var(--radius-lg); font-size:var(--text-sm); } → className="flex flex-col gap-[var(--space-3)] p-[var(--space-5)] border border-border rounded-lg text-sm"(如果字体刻度已桥接,也可以映射为 text-sm)。 同一选择器上的 backdrop-filter 那一行继续留在 CSS 中(见下文)。
  • .readiness-checklist__title { font-weight:var(--weight-semibold); color:var(--color-fg); display:flex; align-items:center; gap:var(--space-3); } → font-semibold text-fg flex items-center gap-[var(--space-3)]。
  • 通用布局行列(dub-col、models-row)——flex/grid/gap/padding → 工具类。

继续留在 .css 中的内容(标准 + 真实示例):

  • 玻璃拟态 / 分层背景。 Panel.css 的 .ui-panel--glass 叠加了两个 radial-gradient、一个 linear-gradient 以及 backdrop-filter: var(--glass-blur-md)。完全保留在 CSS 中。 (11 个文件使用了玻璃模糊。)
  • 伪元素。 Panel.css 的 .ui-panel--glass::before(顶部发丝线渐变); DubTab.css 的 .dub-stepper__step::before(连接线)。涉及 11 个文件。继续保留。
  • 关键帧 + 动画。 30 个文件中共有 73 个 @keyframes 块 (index.css 中的 @keyframes mesh/spin/pulse/shimmer;DubTab.css 中的 dub-pulse、dub-stepper-spin、dub-skel-shimmer)。将 @keyframes 和 animation: 简写保留在 CSS 中;只有在 @theme 中注册动画后, className="animate-…" 才有意义,而为一次性效果这样做并不值得。
  • :has() 和复杂组合器(3 个文件)、[data-theme] 专用规则 (整个 themes.css + 零散的覆盖规则)、!important 块(14 个文件, 例如 DubTab.css 的 .dub-footer-panel::before { display:none !important; })。
  • 媒体查询(25 个文件):只有在断点与 Tailwind 一致时才能转换为 sm:/md:/lg:;VoiceStudio 的断点是自定义的,因此除非先将组件的断点加入 @theme,否则请将响应式块保留在 CSS 中。优先级较低。

给评审者的一条经验法则:如果一条声明读取单个 token 并设置一个盒模型/文本/flex 属性,它就是工具类;如果它组合多个值、作用于伪元素/状态组合器或包含动画, 则应保留在 CSS 中。

4. 风险缓解——无视觉测试的缺口(关卡风险)

这是成败攸关的一项。说实话:没有视觉基线,“没有变化”就无法验证, 而且 className 差异对比(页面重构所依赖的方法)在类名本身就是被改变的对象时 是行不通的。有两个层面,两者都要做:

(a) 在动组件之前建立截图基线(属于 P0)。 为正在迁移的界面添加 Playwright 组件/页面截图。该仓库在文档栈中已经引用了 Playwright 工具;接好一个最小的 tests/visual/,启动 Vite 应用(或不依赖 Storybook 直接渲染路由),在固定视口下为默认主题 + 一个深色主题 + 一个浅色主题 捕获每个组件的 PNG(专门捕获 token 桥接回归)。提交基线。 每个迁移 PR 运行 playwright test --update-snapshots=none,并且只要像素差异 超过一个极小阈值就失败。这把“它变了吗?”从人工猜测变成了 CI 关卡。 先在 main 上捕获基线,以便它们反映迁移前的真实情况。

  • 现实地规划范围:一开始就把全部 74 个界面都快照出来本身就是一个项目。应按阶段、即时快照——在 P1 叶子组件工作之前,先为叶子组件建立基线;在 P3 之前,为大型页面建立基线。组件的基线放在同一个准备迁移该组件的 PR 中(与转换 PR 分开,以便基线 diff 可以独立审查)。

(b) 每个组件的人工核对清单(双保险,也是难以确定性截图的界面的备用方案——任何包含动画、canvas/波形或实时后端数据的界面): 1. 默认主题:在同一视口下并排对比前后效果。 2. 遍历每个 [data-theme]——确认颜色仍能切换(token 桥接检查)。 3. 交互元素的 hover/focus/active/disabled 状态。 4. 组件的 @keyframes/动画仍然运行。 5. prefers-reduced-motion 路径不受影响(例如 #root 启动动画)。 6. 无控制台警告;bun run build + bun run lint 干净通过。

如果某个界面既没有 (a) 也没有 (b) 覆盖,不要迁移它——将其归入“保留为 CSS”的类别,而不是盲目进行。

5. 分阶段实施

每个阶段包含一个或多个可独立发布、CI 全绿的 PR。按叶子优先、由内向外排序,让影响范围只随信心增长而扩大。

P0 — Token 桥接 + 工具链 + 视觉基线(不转换组件)

  • 去重 @theme ↔ tokens.css(§2 方案 A)并加入一致性测试。
  • 确定并记录间距方案(推荐使用任意值桥接)。
  • 添加 prettier-plugin-tailwindcss(或确认 oxlint/oxfmt 的 class 排序),并接入 class 排序(§6)。
  • 更新 CONTRIBUTING.md(§6——目前写的是 “原生 CSS……不使用 Tailwind”,这已与现实矛盾,根据文档同步规则必须在同一 PR 中修改)。
  • 建立 tests/visual/ Playwright 测试框架(暂不包含逐组件基线——只有运行器和主题矩阵)。
  • 工作量:约 1–2 天。成功标准:一致性测试通过;所有主题下的主题切换均验证正常;CI 新增 class 排序检查;像素零变化(本 PR 不包含任何组件编辑)。

P1 — 叶子 / 展示型组件(风险最低)

目标:小型 ui/ 原语和无状态组件,其 CSS 主要是 flex/grid/间距/字体——例如 Badge、UpdateStatusChip、NetworkToggle、ReadinessChecklist、ReadinessChecklist、DemoPresetGrid、KeyboardCheatsheet、MultiLangPicker。暂时跳过大量使用毛玻璃效果的组件。 - 每个组件:先出基线截图 PR,再出转换 PR。将布局/间距/字体转换为工具类;任何毛玻璃/::before/动画相关行保留在现在很小的 .css 中;如果没有任何残留,就彻底删除该 .css 并移除其 import。 - 工作量:约 3–5 天,覆盖约 10–15 个组件。成功标准:约 10 个 .css 文件被删除或缩减 >70%;视觉 diff 干净;验证了一套可复用的逐组件转换方法。

P2 — 面板与中型组件

目标:settings/*Panel.css、Sidebar、NotificationPanel、CastingView、ExportModal、EngineCompatibilityMatrix、donate/Postcard 等。状态更多,有些毛玻璃效果——转换布局骨架,保留毛玻璃/伪元素/动画。 - 工作量:约 1–1.5 周。成功标准:设置面板变成轻盈的工具类 JSX + 一个共享的 primitives.css 负责毛玻璃/控件外观;CSS 代码量显著下降。

P3 — 大型页面

按 ROI 排序的目标:DubTab(989)、VoiceGallery(541)、StoriesEditor(525)、LogsFooter(507)、Settings(469)、CloneDesignTab(458)、FirstRunSetup(1020)。这些与已规划的页面模块化(docs/maintenance-pages-modularization.md)天然契合——先进行模块化拆分,再迁移拆出来的较小组件(P3 对于各个片段来说相当于“再来一次 P1”)。转换布局/间距;流水线步骤条、遮罩层、渐变和关键帧仍然保留为 CSS。 - 工作量:约 2–3 周。成功标准:每个页面的 .css 缩减到只剩毛玻璃/动画/伪元素残留;最大的单点代码量削减在此处实现。

P4 — 最后淘汰 index.css 全局样式

index.css(2532 行)是基础:@theme、@keyframes、::selection、根渲染、基础重置以及共享全局类。只把组件复用的全局工具类转换为真正的工具类或组件作用域 CSS;保留 @theme、关键帧、重置和 ::selection。最后再做这件事,因为一切都依赖于它。 - 工作量:约 1 周。成功标准:index.css 缩减到只剩基础内容;没有孤儿全局类。

6. 工具链

  • class 排序 / 格式化。 仓库使用 oxlint 进行 lint(bun run lint,属于门禁),并配有用于 hooks 的参考性 ESLint。对于 Tailwind class 排序,添加 prettier-plugin-tailwindcss(权威方案,能理解 @theme)并接入到 *.jsx 上,或者采用 oxfmt 的 Tailwind class 排序(如果团队偏好单一格式化工具)。无论哪种方式,目标都是让 class 顺序确定化,使 diff 保持可读且合并干净。
  • 回归预防。 添加一个 oxlint/约定守卫,避免新组件重新引入臃肿的 CSS:先加一条软性规则(仅警告,遵循“保持主分支绿色”的原则),对超过较小行数预算、本应工具类优先的新 .css 文件给出提示;同时将 §2 的一致性测试作为 Token 漂移的硬性门禁。
  • CONTRIBUTING 更新(必需)。 目前 CONTRIBUTING.md 写着 “CSS:组件级文件使用原生 CSS,不使用 Tailwind。” 这已经过时。请改为工具类优先的标准:布局/间距/排版/简单颜色通过 Tailwind 工具类实现;组件的 .css 只用于毛玻璃、伪元素、关键帧、:has()、[data-theme] 规则和 !important 覆盖;token 位于 tokens.css/@theme 中,绝不硬编码。 根据文档同步硬性规则,这必须在与 P0 同一个 PR 中完成。
  • 不新增构建基础设施——@tailwindcss/vite 已经完成所有工作;不需要 PostCSS 配置,也不需要 Tailwind 配置文件(v4 通过 @theme 实现 CSS-first)。

7. 非目标 / 何时停止

  • 不以 100% 转换为目标。 约 20% 的 CSS(11 个毛玻璃文件、30 个关键帧文件、11 个伪元素文件、3 个 :has() 文件、14 个 !important 文件、自定义断点媒体查询)作为 CSS 确实更好,应该保留。把它们硬塞进任意值工具类只会让 JSX 难以阅读,且毫无收益。
  • 不重新设计 token 体系。 tokens.css/themes.css 和 data-theme 模型保持现状(只做 §2 的去重)。
  • 不做视觉重设计。 像素级一致是约定;重新样式化是另一项任务。
  • 不进行 .jsx → .tsx,不涉及引擎/后端/Tauri/Python 层面,不升级版本,除仅用于开发的格式化插件 + Playwright 外不改变依赖(仅前端)。
  • 单个文件的停止条件:如果抽出布局/间距后,剩余 CSS 全是毛玻璃/动画/伪元素,那就完成了——不要去追求最后那 10%。
  • 不要动 BootstrapSplash.css、WaveformPlayer.css/SegmentTrack.css(与 canvas 相邻)、以及其他以动画/::before 为主的文件,除非存在明确的布局收益。

8. 工作量与建议

总工作量估算: 在下方限定范围内,P0–P4 大约需要 5–7 个专注周,分摊在许多小型 PR 中(它可以干净地并行和暂停——不必是一次大推进)。

建议 —— 有边界的迁移,而非 100%。 负责人倾向于全量迁移,并很看重不破坏现有功能;这两个目标部分冲突,因此坦率的判断是:

  • 做:在所有地方转换布局/间距/排版/简单颜色——这是真正的可维护性收益,约 80% 的 16.6k 行代码都在这里,而且这是低风险部分。
  • 保留约 15–25% 为 CSS(玻璃拟态、关键帧、伪元素、:has()、[data-theme]、!important、自定义断点媒体查询)。转换这些会换来难以阅读的 JSX,并增加视觉回归风险——恰恰是在那些差异最难验证的组件上。
  • 以视觉基线(§4)为门禁。 这是唯一最重要的决定:如果 Playwright 截图测试框架没有随 P0 交付,就不要开始 P1——没有它,"不破坏 UI"这一要求在结构上就无法满足。token-bridge 去重(§2)是另一个硬性前置条件;两者成本都很低,且都属于 P0。

现实的最终状态:约 60 个 .css 文件被删除或缩减 >70%,16.6k 行 CSS 中可能移除约 10–12k 行,其余则是工具类无法表达的效果残留,属刻意保留且记录在文档中。这能以一小部分回归风险,带来几乎全部"全量"迁移的可维护性收益。

已遵守的约束

  • 保持主分支绿色 —— 每个阶段都是独立 CI 绿色的 PR;lint/format 与一致性测试守卫在会造成大量变更的位置以警告优先(而非报错)。
  • 文档同步 —— CONTRIBUTING.md 重写与 P0 在同一 PR 中落地。
  • 不影响版本管理/Docker/Tauri/Python —— 仅前端;工具新增仅限 devDependency;不升级 package.json 的 version(根据 Docker 绿色规则,新增 devDependency 仍需要重新生成根 bun.lock,并确认 bun install --frozen-lockfile 通过)。
  • 本地优先 / 跨平台一致性 —— 纯样式;无行为变更,无平台差异。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 om/VoiceStudio