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