弹出(Eject)——单向冻结
实验性
tabula eject(无论是这次单向冻结,还是 --report 反向映射分析)在 v0.3.0 中都以实验性形态发布: 命令本身可用、有测试覆盖,但它的表面——标志、报告格式、层级表——在未来的次要版本中可能会发生变化。 欢迎通过 GitHub issues 提供反馈。
tabula eject 把一份已验证的 .tabula/ 复制进一个项目自有的目录,并从此停止设计令牌流水线向它输送内容。 研究(以及记录在 .orchestrator/plan.md 中的探测)已经确认,.tabula/source.css 在原生的 @tailwindcss/cli 上编译,class 零改动——所以弹出并不是一次重写,而是一次冻结:你保留了你已经上线的那份确切 CSS,保留了 cn(),而放弃的是生成器。
它存在的意义,是为了你想要在不做迁移的情况下脱离该配置档案的那一天:一个进入维护期的项目、一次交接给一个 不会采用 Tabula 的团队,或者一份多年后必须仅靠 Tailwind 就能构建的存档。
这是一扇单向门。 弹出之后不再有重新构建,不再有
tabula build --check的漂移门禁,也不再有对被冻结拷贝的tabula scan封闭性门禁。tokens/中的设计令牌改动不会再抵达它。要回头,你需要删除这份被冻结的目录、从源头 重新构建——没有任何东西会反向流动。弹出默认是空跑,正是为了让穿过这扇门成为一个刻意的第二步 (--yes),而绝不是一次意外。
命令
tabula eject [--to <dir>] [--yes] [--force] [--write-imports]--to <dir>—— 冻结目标。默认是项目根目录下的tabula-frozen/。--yes—— 执行。不加此标志,eject 就是一次空跑: 它会打印完整的计划——它将要复制的每一个文件、 权限变更、它发现的@import重写、到期警告、单向门横幅,以及cn()策略提示——并且不触碰任何东西。--force—— 允许一个已经包含文件的目标目录(否则一个非空目标会被拒绝)。--write-imports—— 重写指向.tabula/source.css的项目 CSS@import行,使其指向被冻结的拷贝。 不加此标志时,eject 只会打印它找到的文件以及确切的新 import 行,由你自己去应用这次改动。
前置条件(按顺序失败关闭)
- 一个可加载的项目——根目录下要有
tabula.config.json加tokens/*.tokens.json(否则TAB-E901)。 请从项目根目录运行 eject。 - 一份存在、已验证的
.tabula/——清单必须存在,输入必须仍然与它的哈希一致(未过期),每一个产物的sha256都必须与清单匹配,并且该目录中不得隐藏任何清单未覆盖的内容。这与tabula doctor所使用的同一个 基于清单哈希的漂移门禁,并且复用同样的代码:TAB-E303(缺失/过期)与TAB-E601(手工编辑过或未被 覆盖)。弹出只冻结一个已验证的状态——一份漂移或过期的.tabula/会被拒绝,因为冻结它就等于冻结了一个 已经不再匹配你设计令牌的东西。 - 一个可用的目标——目标目录必须不存在,或者为空,除非给出了
--force(TAB-E240)。一个它无法写入的 目标同样是TAB-E240。
四种风险,以及弹出如何处理它们
弹出很容易在细微之处出错。有四个陷阱被提前钉死,每一个都被显式处理,而不是听天由命。
1. cn() 仍然需要 @tabula-css/merge——不要换成 tailwind-merge
被冻结的 CSS 是标准的 Tailwind,但运行时不是。cn() 依据你的 registry.json 解析 class 冲突; tailwind-merge 并不知道你的词汇表,因此替换 cn() 会改变渲染出的输出。反例来自 concepts.md:
cn("pt-sm", "p-md") // Tabula → "p-md" (p-md is later and covers padding-top)
cn("pt-sm", "p-md") // tailwind-merge → "pt-sm p-md" (keeps pt-sm — different CSS)所以被弹出的目录保留了 registry.json,EJECTED.md 会以书面形式告诉你要继续使用 @tabula-css/merge。 没有任何地方建议悄悄替换掉这个合并运行时。
2. 被弹出的拷贝是你自己的(0644),不是只读产物(0444)
.tabula/ 下的产物以**只读(0444)**方式写入,并由清单加以门禁,因此一个编辑器不能悄悄地覆盖保存一个生成 文件。被弹出的拷贝则正相反:它们现在是你自己的文件。eject 会在一个项目自有的目录里,以 0644 写入每一份 拷贝,因此你可以编辑它们,而不必与那个权限位或一个已经不再运行的漂移检查作对。
3. 过期的例外会发出警告,而不会阻塞
通常,一个过期的例外是一个硬性构建错误(TAB-E141)——正是这个机制阻止了一份封闭词汇表悄悄变得开放。 但弹出之后不再有重新构建,所以那个错误再也不可能触发了。把这次冻结阻塞在它上面,等于是阻塞在一个 弹出已经取消了的未来上。取而代之,eject 会为每一条已过期或将在 90 天内到期的例外发出警告 (TAB-W402),并且 EJECTED.md 会列出每一条例外及其状态,因此冻结那笔债务是一个你能看见的选择, 而不是一个你会继承的意外。
4. 单向门不会被漏看
每一次运行——无论是空跑还是执行——都会打印一条横幅,说明设计令牌改动将停止流动,且不再有重新构建。空跑 默认的存在,意味着在任何东西被写入之前,你总能先看到完整的计划。
保持工作的部分,以及停止工作的部分
保持工作:
- 运行时的
cn()——配合@tabula-css/merge与被复制的registry.json(风险 1)。 types.d.ts——class 名称的类型与 CSS 一同被冻结;你编辑器的自动补全与对 class 字符串的类型检查不受影响。llms.txt/llms-full.txt/AGENTS.md.snippet——智能体接口也一并被复制,因此一个读取被冻结目录的 智能体仍然能获得词汇表与规则。- 在原生 Tailwind 上编译——
source.css直接用@tailwindcss/cli就能构建。
停止工作:
- 重新构建——
tabula build不再以被冻结的目录为目标;设计令牌的改动不会抵达它。 - 设计令牌改动的流动——流水线被切断了;这次冻结是一个时间点上的快照。
- 扫描与漂移门禁——
tabula scan的封闭性与build --check的漂移检查不再管辖这份被冻结的拷贝。它现在 只是普通的项目 CSS。
弹出之后
被写入被冻结目录中的 EJECTED.md,记录了出处信息(配置档案 id、版本、输入哈希)、单向门声明、cn() 策略 提示、完整的例外清单,以及验证命令。要确认这次冻结能在原生 Tailwind 上编译:
npx @tailwindcss/cli -i tabula-frozen/source.css -o out.css如果你使用了 --write-imports,你的应用样式表的 @import 已经指向了 tabula-frozen/source.css;否则 eject 已经打印出了那行确切的语句,供你自己粘贴。
反向映射报告(--report)
弹出会冻结 Tabula 的 CSS,并让渲染结果保持精确不变。另一个问题是:我的 class 用法中,有多少能够改用 原生(vanilla) Tailwind v4?tabula eject --report 会诚实地回答这个问题。这是一种分析模式—— 不做冻结,不向 .tabula/ 写入任何内容,且总是以退出码 0 结束——它会依据一份冻结的反向映射表,把你扫描到 的源码中实际使用的 class 分类到四个层级中:
| 层级 | 含义 | --write? |
|---|---|---|
| A | 原生 Tailwind 中存在相同的拼写,且生成的声明等价——切换工具链不会改变渲染结果。 | 无需 |
| B | 一次可证明的 1:1、保持渲染不变的重命名(例如 opacity-disabled → opacity-50)。 | 是 |
| C | 存在映射,但渲染或语义会发生变化——仅供报告,并附带确切的注意事项。 | 从不 |
| D | 原生 Tailwind 中没有对应物(type-* 排版组合包)——保留被冻结的 CSS,或手动重新设计。 | 从不 |
一条变体链依据其最薄弱的部分分类:Tabula 用 :where() 降低特异性的每一个自身状态变体(hover、 focus、active 等)都是层级 C——它的特异性是 (0,1,0),而原生的是 (0,2,0),并且 hover 还会额外 失去原生的 @media (hover:hover) 门禁——所以无论其基础部分的可移植性如何,每一条真实的链都会落在 C。
tabula eject --report [--theme-port] [--format=json] [--write]--report—— 在项目旁边写入tabula-eject-report.md(并将其回显到 stdout):头部判定结果、按 层级的计数、按文件的工作清单(file:line、class、层级、目标或注意事项)、层级 D 的清单,以及主要的 注意事项。--format=json会以 JSON 文档的形式输出同样的数据。--theme-port—— 报告 with-theme-port 场景,并附加命名空间移植说明。参见下面的两种场景。--write—— 仅通过与tabula migrate相同的代码转换器(codemod)引擎,应用层级 B 的重命名 (一次真正的统一 diff,仅限 class 汇)。其余一切在结构上都仅供报告。当没有可应用的内容时,--write会打印0 rewrites并以退出码 0 结束。
两种场景
Tabula 的 theme.css 只定义 --tb-* 自定义属性,其 source.css 运行在原生的默认主题之上。所以一个 具名的令牌工具类,例如 bg-accent 或 p-lg,是一个合法的原生工具类形态,但原生读取的是 --color-accent / --spacing-lg,而 Tabula 从不填充它们:
- as-is —— 你弹出,保留拼写,且不移植任何主题变量。令牌工具类不会输出任何内容(颜色/间距/尺寸), 或者解析为原生的默认值(
rounded-md会变成0.375rem,而不是 Tabula 的0.5rem)。这些系列在 as-is 场景下是层级 C。 - with-theme-port(
--theme-port)—— 你把 Tabula 的令牌值复制进原生命名空间(--tb-color-*→--color-*、--tb-spacing-*→--spacing-*、--tb-radius-*→--radius-*)。同样的系列会变成 层级 A。
移植不会挽救每一个系列。物理轴与逻辑轴的间距系列(px、mx、inset-x、scroll-px、 border-x——在 Tabula 中是逻辑行内方向,在原生中是物理左右方向,所以在 LTR 下相同,但在 RTL 下会镜像) 以及 ring-* / shadow-* 颜色工具类(Tabula 中不生效的 --tb-ring-color,在原生中会变成生效的 --tw-ring-color——一个原本不渲染 ring 的元素可能突然渲染出一个)在两种场景下都仍然是层级 C。
实测数据(reference-ui)
在 examples/reference-ui 项目上测得(478 个基础 class + 1131 条变体链 = 1609 个 token,已针对 注册表用 jq 校验——.orchestrator/R2.4-reverse-map.md):
| 场景 | A | B | C | D |
|---|---|---|---|---|
| as-is | 83 | 11 | 1511 | 4 |
| with-theme-port | 405 | 11 | 1189 | 4 |
只有11 个 token(0.7%)——标量令牌重命名 opacity-disabled、duration-fast、ease-standard、 border-{t,b,y,s,e}-thin、align-start、align-end、not-prose——对 --write 是安全的,且这个 集合在两种场景下是相同的(主题移植并不会让它变大)。每一条变体链在两种场景下都是 C。这个命令所体现的 结论是刻意的:完整的原生迁移不是产品——诚实的报告才是,而 tabula eject(冻结 CSS)依然是保持 渲染精确的方式。
弹出 vs. 恢复
弹出冻结的是产物;从 .tabula/ 恢复一份配置档案重建的是 源头。它们是相反的方向:当你想停止使用生成器、保留这份 CSS 时选择弹出;当你想把设计令牌找回来、以便 继续生成时选择恢复。