Skip to content

弹出(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 行,由你自己去应用这次改动。

前置条件(按顺序失败关闭)

  1. 一个可加载的项目——根目录下要有 tabula.config.jsontokens/*.tokens.json(否则 TAB-E901)。 请从项目根目录运行 eject。
  2. 一份存在、已验证的 .tabula/——清单必须存在,输入必须仍然与它的哈希一致(未过期),每一个产物的 sha256 都必须与清单匹配,并且该目录中不得隐藏任何清单未覆盖的内容。这与 tabula doctor 所使用的同一个 基于清单哈希的漂移门禁,并且复用同样的代码:TAB-E303(缺失/过期)与 TAB-E601(手工编辑过或未被 覆盖)。弹出只冻结一个已验证的状态——一份漂移或过期的 .tabula/ 会被拒绝,因为冻结它就等于冻结了一个 已经不再匹配你设计令牌的东西。
  3. 一个可用的目标——目标目录必须不存在,或者为空,除非给出了 --forceTAB-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.jsonEJECTED.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-disabledopacity-50)。
C存在映射,但渲染或语义会发生变化——仅供报告,并附带确切的注意事项。从不
D原生 Tailwind 中没有对应物(type-* 排版组合包)——保留被冻结的 CSS,或手动重新设计。从不

一条变体链依据其最薄弱的部分分类:Tabula 用 :where() 降低特异性的每一个自身状态变体(hoverfocusactive 等)都是层级 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-accentp-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

移植不会挽救每一个系列。物理轴与逻辑轴的间距系列(pxmxinset-xscroll-pxborder-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):

场景ABCD
as-is831115114
with-theme-port4051111894

只有11 个 token(0.7%)——标量令牌重命名 opacity-disabledduration-fastease-standardborder-{t,b,y,s,e}-thinalign-startalign-endnot-prose——对 --write 是安全的,且这个 集合在两种场景下是相同的(主题移植并不会让它变大)。每一条变体链在两种场景下都是 C。这个命令所体现的 结论是刻意的:完整的原生迁移不是产品——诚实的报告才是,而 tabula eject(冻结 CSS)依然是保持 渲染精确的方式。

弹出 vs. 恢复

弹出冻结的是产物.tabula/ 恢复一份配置档案重建的是 源头。它们是相反的方向:当你想停止使用生成器、保留这份 CSS 时选择弹出;当你想把设计令牌找回来、以便 继续生成时选择恢复。

Released under the MIT License.