Skip to content

概念

局部性(locality)与十条法则

Tabula 的核心主张是:一个元素的样式就是它自身的 class 字符串——不存在从祖先元素、兄弟元素或样式表顺序继承或级联而来的样式, 除非元素自身的 class 字符串显式声明了一种非局部性(group/nametype-inheritprose)。需要去读一个父元素才能知道 子元素如何渲染,被视为一类 bug,而不是一种可选的风格取向。

这一主张被编码为十条规则,逐字打印(并被强制执行)在每一份生成的 .tabula/llms.txt 中:

  1. 只有 vocabulary.txt 中的 class 才存在;其他任何写法都会静默地不产生任何 CSS。在运行时拼出来的 class (如 `p-${n}`)永远不会生效——Tailwind 扫描的是源码文本
  2. 按意图而非记忆去反向查找一个 class(MCP 的 find_class_for)。
  3. 永远不要使用任意值(w-[347px]);用 tabula except add 铸造一个具名的 class 来代替。
  4. 永远不要写 dark:——主题是设计令牌的轴(axis);bg-surface 已经覆盖了每一种主题。
  5. 更高的 rank 获胜。这就是全部的级联规则——属性书写顺序不改变任何结果。
  6. 两个 class 在同一个静态字符串里写同一个属性,是一个 lint 错误,而不是一次合并。
  7. cn(base, …, className) 来组合:调用方的 className 排在最后并获胜。
  8. 每一个承载文本的元素都携带一个 type-* 与一个 ink-*,或者声明 type-inherit
  9. 没有父元素可以样式化它的子元素:space-*divide-**:**:[&>*]:in-* 都不存在。 用 gap-*,或者把工具类放在子元素自己身上。
  10. 跨元素的依赖关系必须是具名的group/card,绝不是裸的 group)。

注册表是唯一真相来源

registry.json 并不是配置档案(profile)的文档说明——它本身就是这份配置档案。其他一切产物(vocabulary.txtllms.txt、ESLint 插件、@tabula-css/merge)都是从它派生出来的视图,一旦两个产物出现分歧,注册表被定义为正确的一方。 它由 @tabula-css/registry 生成,该包解析 Tailwind 自身编译出的 CSS 输出(通过两套相互独立的后端之一——design-system API,或者一次 PostCSS 探针样式表遍历——并在 CI 中相互交叉校验),而不是重新实现一遍 Tailwind 的工具类语法。

tokens.resolved.json 是面向取值的配套视图:每一个设计令牌在每一根轴上的完全解析字面量,以及——为了让正向构建 保持信息无损——源设计令牌的 $deprecated$extensions(包括 $extensions.tabula.contrastWith 无障碍契约以及 外部厂商命名空间),还有一个记录顶层别名引用的 $alias 标记。一份字节级相同的 tabula.config.json 会被复制到它 旁边,因此一份被冻结的 .tabula/ 能准确说明是什么构建出了它。

一条完整的 class 记录(examples/reference-ui 中的 bg-surface):

json
"bg-surface": {
  "family": "bg",
  "token": "color.surface",
  "declarations": [{ "property": "background-color", "slot": 9, "emitted": "var(--tb-color-surface)", "value": "#ffffff" }],
  "slots": [9],
  "rank": 30091300001,
  "spec": [0, 1, 0],
  "locality": "L0",
  "inherits": false
}
  • slots —— 一个 class 拥有的规范化 longhand CSS 属性(以及配置档案内部的自定义属性)的数字 id。 注册表顶层的 slots 映射会把一个 id 解析回属性名(例如 9 → background-color);slotAliases 会在两个逻辑属性 等价时把它们映射到同一个物理槽位(padding-block-start ≡ padding-top)。
  • rank —— 覆盖每一个已注册 class 的单一整数全序(见下文)。
  • spec —— 该选择器的 CSS 特异性,对每一个普通工具类都被归一化为 (0, 1, 0)(伪元素则为 (0, 1, 1)), 这样特异性就不携带任何信息——只有 rank 才能决定胜负。
  • locality —— 对于一个只会样式化其自身所在元素的 class,取值为 L0。如果任何工具类在归类为 L1/L2 (一个能够触及兄弟或后代元素的选择器)时不在一处已记录的隔离区(quarantine)内,构建就会失败。
  • composed(属于某条声明,而非上面展示的整条记录)—— 当该声明的值是一个 var(--tb-*) 引用而非字面量时为 true。一个全部声明都是 composed 的 class 不会为它们占用任何槽位(SPEC J1 修订)——这正是 shadow-mdring-2 能够无冲突地组合的原因:它们写的是不同的自定义属性,而不是同一个 box-shadow 槽位。

合并:一个全序,而不是一次级联模拟

每一个已注册的 class 都拥有一个唯一的 ranklex(−breadth, familyIndex, valueIndex)——大致来说:更窄的工具类排在更宽的之前, 然后是族群,然后是取值)。cn()resolve() 从不模拟浏览器的级联规则(选择器特异性、源码顺序、!important); 它们运行的是一次纯粹的折叠(fold):

  1. 把每一个 class 字符串按空白拆分;把每一个 token 解析为 (variants, utility)
  2. 对每一个 class 的每一条声明,计算出一个槽位键:(pseudoElement, condition, slot)
  3. rank 最高的那条声明赢得每一个键位。 迭代顺序无关紧要。
  4. cn() 还会额外先按*片段(fragment)*排序:较晚的片段胜过较早的片段,只有在同一个片段内部才由 rank 决定胜负。 这正是 cn(base, className) 成为一个可靠覆盖机制的原因——调用方所在的那个片段获胜,无论它自身内部的 rank 是什么。
  5. 幸存者按 rank 升序排序输出,因此打印出来的 class 字符串从左到右读起来就是“后者获胜”(阅读规则), 而原子(atomic)/未知的 class 则按作者书写顺序追加在最后。

实际例子(已对照 packages/merge/testexamples/reference-ui/.tabula/llms-full.txt 验证):

js
cn("p-md", "pt-sm")   // → "p-md pt-sm"
//   padding-top ← pt-sm (later fragment); the other 3 sides stay owned by p-md.
cn("pt-sm", "p-md")   // → "p-md"
//   p-md is later AND covers padding-top → pt-sm owns nothing → dropped entirely.
//   (tailwind-merge cannot express this: it keeps pt-sm, and stylesheet order then
//    silently makes it win — the opposite of the stated composition order.)

cn("bg-surface", "bg-surface-raised")        // → "bg-surface-raised"  (same slot, later wins)
cn("bg-surface", "hover:bg-surface-raised")  // → both survive (different condition ⇒ different slot key)
cn("shadow-md", "shadow-brand")              // → both survive (composed utilities write different custom properties)

在开发环境下,cn() 会在每次调用时断言自身的可靠性(T2):它会独立地按 rank 重新计算每一个槽位的赢家, 如果注册表所声明的 rank 顺序与它刚刚选出的赢家不一致,就抛出 TAB-E301——一份损坏或被手动编辑过的注册表会大声地失败, 而不是悄悄地渲染错误。

配置档案的严格级别:basestrict

排版是唯一拥有两种出厂严格级别的领域,通过 tabula.config.json 中的 profileLevel 设置(默认:base):

  • base 注册普通的 text-* / font-* / leading-* / tracking-* 族群,并通过三种方式加以约束: 每一个可继承、且归属于配置档案的 CSS 属性都恰好拥有一个 :root 默认值(会被检查——缺失时报 TAB-E220); 一个可继承属性的工具类只在文本叶子标签上,或者在一个携带 scope-text 标记的元素上才合法 (tabula/inherited-property-boundary,可自动修复);并且能够被继承的取值集合被封闭在已注册的设计令牌范围内。
  • stricttype-* 取代局部排版——一个 8 属性的组合包(font-family、size、weight、style、line-height、 letter-spacing、text-transform、font-variant-numeric)作为单个 class 应用,外加一个独立的 ink-* 负责颜色。 局部性的 class(单独的 text-sm)完全不会被注册:一个文本元素要么用一条声明陈述自己完整的排版身份, 要么明确地声明 type-inherit

examples/reference-ui 使用的是 strict(见其 tabula.config.json);docs/benchmark.md 描述了那项旨在衡量 究竟哪个级别对智能体更有帮助的研究。

主题化:轴,而非 dark:

一个主题不是一个 CSS 变体——它是在 tabula.config.json 中声明一次、并在构建时被解析进每一个设计令牌取值的轴(axis)

json
"color.surface": { "$value": { "$axis": "theme", "light": "#ffffff", "dark": "#0b0b0c" } }

这必须是完全的(total):每一个已声明的轴成员(lightdark、……)都需要一个字面量值,不允许有回退 (TAB-E113)——一个在深色模式下悄悄沿用其浅色模式取值的设计令牌,正是这条完全性规则的存在意义所要杜绝的那种典型隐形 bug。 构建会为每一个自定义属性发出一条 @propertyinherits: false,因此其值永远不能被祖先元素重新定义), 外加根作用域的轴代码块(:root[data-theme="dark"] { --tb-color-surface: #0b0b0c; },并带有 prefers-color-scheme 的媒体查询回退用于首次渲染)。像 bg-surface 这样的 class 已经同时承载了每一种主题—— 写 dark:bg-surface-dark 会重新引入正是轴模型的存在意义所要移除的那种选择器条件分支,这就是为什么 dark: (以及任何 [data-theme=…]: 变体)是一种被禁用的机制,也是为什么 @theme inline 在项目 CSS 中被禁用 (它会在使用处内联一个值,导致根部的轴代码块再也无法重新指向它)。

变体(Variants)

一条变体链(hover:bg-accent-hoversm:p-mdsm:hover:bg-surface-raised)是在一个基础工具类前面加上一个或 多个变体前缀。由于该预设是用 @import "tailwindcss" source(none) 加一份显式的 @source inline 列表来编译的, 一条链只有在被注册的情况下才会产生 CSS——和一个基础 class 完全一样。封闭性(T3)不变;被注册的集合只是被扩展, 纳入了已声明的变体产物(declared variant products)——就像任意值只有通过一条已注册的例外才会合法一样。CSS 始终是配置档案(即注册表)的纯函数,永远不是被扫描源码的函数——因此一次新的链用法会引发一个响亮的 tabula scan 错误,而绝不会是一条静默缺失的规则。

已声明的产物

一个产物(product)是一个链前缀与它可以修饰的*族群(family)*的配对,在 tabula.config.json 中声明:

jsonc
"variants": {
  "products": {
    "hover": ["bg", "border", "ink", "(static)"],  // family names as in the registry
    "sm":    "*",                                   // "*" = every non-atomic family
    "sm:hover": ["bg"]                              // a length-2 product, in canonical order
  },
  "preset": "interaction"                           // "interaction" | "all-len1" | "none"
}

构建会把每一个已声明的产物具体化为确切的链 class 列表,并把它写入注册表(variantProducts——一份紧凑的 前缀 → 族群 映射——外加 chainCount)、source.css 中被追加的链层(一个字面量的 @layer utilities 代码块, 包含按 effectiveRank 排序的注册表模板规则——链故意不被列入 @source inline,那里始终只保留基础 class)、 types.d.ts,以及 llms-full.txt。一条不值得为它专门声明一整个产物的一次性链,可以搭上既有的例外机制: tabula except add --chain hover:bg-accent-hover … 会用与铸造一个取值例外相同的理由/归属者/到期日流程, 注册这一条单独的链。

预设

variants 小节缺失时,默认使用的是 interaction 预设——之所以这样选择,是为了让一个项目开箱即可为它的 交互状态设置样式:

  • 每一个自身状态变体 {hover, focus, focus-visible, focus-within, active, disabled} × 族群 {bg, border, ink, outline, ring, shadow, decoration, accent, caret, fill, stroke, opacity, (static)}
  • 外加 {placeholder} × {ink, caret, accent}

first/last/odd/even 以及媒体变体包含在这份预设里——需要显式声明它们。all-len1 是每一个已启用的 具名变体 × 每一个非原子族群(体量很大,并会做预算检查——见下文);none 什么都不声明,只有显式的 products 才会生效。

规范顺序

一条链只以恰好一种拼写方式被注册:变体按严格升序的 rank 排列,最多一个媒体变体,然后才是工具类本身 (sm:hover:bg-surface,绝不是 hover:sm:bg-surface)。cn() 已经会输出规范化的字符串;运行时解析器对顺序是 容忍的,会在检查成员资格之前先做规范化,但源码被要求保持规范拼写,这样 diff 才能保持最小,而且"后者获胜"读起来 才是从左到右的。

失败模式

tabula scan 用与运行时相同的语法解析源码中的每一个 class token,并将不在已注册集合内的链拒绝为 TAB-E230,为每种情况给出对应的修复建议:

源码中修复方式
顺序不规范(hover:sm:x给出规范化的重写形式(sm:hover:x
未声明的产物(默认预设下的 hover:p-mddeclare product hover × pin tabula.configvariants.products, then rebuild
一个参数化变体(aria-*data-*group-*peer-*v0.1 尚不支持参数化变体
在一个原子 class 上使用变体(hover:not-prose原子 class 不接受任何变体

还有两个代码用来守卫配置与构建:在一个没有 breakpoint的配置档案上声明一个媒体产物,是一个配置错误 (TAB-E170);一个格式不正确的产物声明——一个参数化/未知的变体或族群,一个不规范或过长的前缀——是 TAB-E171

预算

完全的封闭性无法扩展:即便链长度 ≤ 2,朴素的全量产物也会是数兆字节的 CSS。所以被发出的集合是已声明的产物, 而它们的展开本身又被 variants.maxChainCandidates(默认 20,000)设了上限。一个产物展开超过该上限的配置 档案,构建会以 TAB-E172 失败,并给出数量、五个最大的产物,以及那个可调参数——超过大致这个规模之后, 产物本身就不再具备可交付性。

运行时与残余缺口

在运行时,cn() 对待一条未声明的链,方式与对待任何未知 token 完全一样:在开发环境下抛出(附带一个 Levenshtein"你是不是想写"提示),在生产环境下不透明地原样放行——一条不会渲染出任何 CSS 的链,再也不能悄悄从 cn() 身边溜过去而不被察觉。成员资格是按规范形式检查的,因此书写顺序在运行时从来都无关紧要。

有一条通道依设计仍然敞开着。tabula scan 读取的是你的源码 glob;一个从未出现在被扫描文件里的 class 字符串—— 在 glob 之外的一个生成文件里被拼装出来,或者在运行时构造——对它来说是不可见的,会一路抵达 cn(),并在那里于 开发环境下抛出。上游的第一道防线是 ESLint 的 no-runtime-class-construction(你写的是字面量 class 字符串, 绝不是 `hover:${x}`),扫描 glob 是第二道。两者都不是封闭性本身;它们的作用是阻止一个字符串逃过那道真正 执行封闭性的门禁。这与基础词汇表本身存在的残余缺口是同一个缺口——变体并不会让它变得更大。

被禁用的机制

以下每一种机制都会编译成一个能够触及到穿戴该 class 的元素之外的选择器。构建会在工具类层里对任何这类规则报错 (即上文的 locality: "L0" 检查);这张表存在的意义是让智能体理解原因,而不只是知道它被拒绝了—— 目的是让理解原因本身,阻止这种机制以另一副伪装被重新发明出来。

机制为什么被禁用改用
space-x-* / space-y-*会产生 & > * + *——一个父元素伸手去样式化它的子元素在 flex/grid 父元素上使用 gap-*
divide-x-* / divide-y-*space-* 同样的形态,只是用在边框上在每个子元素上使用一个边框工具类
*:(子元素变体)从父元素样式化每一个直接子元素把工具类放在每个子元素上
**:(后代变体)无边界的后代触达范围把工具类放在每一个需要它的元素上
[&>*]: / [&~*]: / [&+*]:一个携带组合符的任意变体——同样的触达范围,只是换了一种写法直接样式化目标元素
in-*匹配的是祖先的状态;该元素依赖于一个从未声明过这层关系的父元素一个具名的 group/card + group-hover/card:
rtl: / ltr:方向是一个设计令牌的轴,而不是一个变体逻辑工具类:ps-*/pe-*ms-*/me-*start-*/end-*
pl-* pr-* ml-* mr-* left-* right-* border-l-* border-r-* text-left text-right物理方向的行内轴工具类不会被注册——它们完全不产生任何 CSSps-* pe-* ms-* me-* start-* end-* border-s-* border-e-* align-start align-end
dark:(或任何主题 / [data-theme=…] 变体)主题是构建时解析的设计令牌轴;该工具类已经携带了每一种主题的取值直接用设计令牌工具类(bg-surface);在 :root 上设置该轴属性来切换
项目 CSS 中的 @theme inline会在使用处内联一个设计令牌的取值,导致根部的轴代码块再也无法重新指向它在设计令牌文件中声明该设计令牌;让构建去生成 @theme
任意值(w-[347px]没有名字、没有归属者、没有到期日——而且 source(none) 意味着它反正也不会产生 CSStabula except add
裸的 group / peer / @container“这是哪一个祖先?”这个问题在不读遍整棵树的情况下是无法回答的给它取名:group/card + group-hover/card:
在运行时拼出来的 class 名Tailwind 扫描的是源码文本;一个在运行时拼装出的名字永远不会被扫描到一个位于 *.classmap.ts 文件中的字面量查找表

其中有两条被作为一道与注册表无关的底线强制执行,无论构建状态如何(packages/eslint-plugin/src/banlist.tsspace-*divide-**:**:、任意组合符、in-*rtl:/ltr:),因此即便没有生成的注册表,它们也会触发; 其余的则都是词汇表本身根本不注册它们所带来的封闭性后果。

以上每一项都会在源文件中被检查:由 ESLint(.ts/.tsx)检查,由 tabula scan(它还覆盖 .js.mdx.html.vue.svelte.astro 等)检查,或者仅仅因为词汇表根本不会输出它——并且,自从有了 CSS 治理层之后, 也会在你的样式表里被检查。见下文。

项目 CSS 同样受到治理

上面那张表说的是配置档案禁止什么。这一节说的是它在 .css 文件里检查什么,因为在治理层出现之前,答案是什么都不检查: 这套系统里没有任何命令会去打开你写的样式表,所以上面的每一条机制封禁都只对 .tsx 生效,对其他一切都不生效。 一个导入的样式表里出现一条 .card .title { color: red },就足以在每一道门禁都亮绿灯的情况下打破局部性。

现在有五条规则会遍历每一个项目 .css 文件、以及每一份生成出的样式表——在你的编辑器里通过 @tabula-css/stylelint-plugin,在 CI 里通过 tabula check:csstabula build --check 会运行它, 除非你传入 --no-css)。两者使用的是同一个规则内核,因此它们不可能产生分歧。

代码禁止规则
TAB-E221@apply把一份 class 列表编织进一个手写的选择器——正是这份配置档案要移除的那种间接层
TAB-E222受核准入口之外的手写规则按选择器而非按 class 来样式化
TAB-E223:root/html 之外定义的 --tb-* / --d-*T17,本系统中最重要的一条检查
TAB-E224@theme inlineT20——把每一个有条件的设计令牌都编译没了;这是 shadcn 生态圈的陷阱
TAB-E225!important把一条声明置于 rank 模型之外

于是,以下这三种写法现在全都会失败,而它们曾经全都能通过:

css
@theme inline { --tb-color-accent: red; }   /* TAB-E224 */
.card p { color: red; }                     /* TAB-E222 */
.panel { --tb-color-accent: red; }          /* TAB-E223 */

这个领域里还有一条 SPEC 规则尚未实现:T18,非根元素上的轴载体属性(data-theme)。在 CSS 里,一个 .panel[data-theme="dark"] 选择器会被当作一条手写规则捕捉到,而不是当作一次轴违规——这个结果本身是对的, 只是名字挂错了——而且生成出的选择器都是 :root[data-theme=…],所以这样的一个属性从一开始就不会有任何效果。

tabula/no-theme-variant 覆盖了 JS/TSX 中的 dark:(T19)。

完整细节,包括这两道门禁目前仍未覆盖到什么、以及两者可能在哪里出现分歧: CSS 治理

Released under the MIT License.