概念
局部性(locality)与十条法则
Tabula 的核心主张是:一个元素的样式就是它自身的 class 字符串——不存在从祖先元素、兄弟元素或样式表顺序继承或级联而来的样式, 除非元素自身的 class 字符串显式声明了一种非局部性(group/name、type-inherit、prose)。需要去读一个父元素才能知道 子元素如何渲染,被视为一类 bug,而不是一种可选的风格取向。
这一主张被编码为十条规则,逐字打印(并被强制执行)在每一份生成的 .tabula/llms.txt 中:
- 只有
vocabulary.txt中的 class 才存在;其他任何写法都会静默地不产生任何 CSS。在运行时拼出来的 class (如`p-${n}`)永远不会生效——Tailwind 扫描的是源码文本。 - 按意图而非记忆去反向查找一个 class(MCP 的
find_class_for)。 - 永远不要使用任意值(
w-[347px]);用tabula except add铸造一个具名的 class 来代替。 - 永远不要写
dark:——主题是设计令牌的轴(axis);bg-surface已经覆盖了每一种主题。 - 更高的
rank获胜。这就是全部的级联规则——属性书写顺序不改变任何结果。 - 两个 class 在同一个静态字符串里写同一个属性,是一个 lint 错误,而不是一次合并。
- 用
cn(base, …, className)来组合:调用方的className排在最后并获胜。 - 每一个承载文本的元素都携带一个
type-*与一个ink-*,或者声明type-inherit。 - 没有父元素可以样式化它的子元素:
space-*、divide-*、*:、**:、[&>*]:、in-*都不存在。 用gap-*,或者把工具类放在子元素自己身上。 - 跨元素的依赖关系必须是具名的(
group/card,绝不是裸的group)。
注册表是唯一真相来源
registry.json 并不是配置档案(profile)的文档说明——它本身就是这份配置档案。其他一切产物(vocabulary.txt、 llms.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):
"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-md与ring-2能够无冲突地组合的原因:它们写的是不同的自定义属性,而不是同一个box-shadow槽位。
合并:一个全序,而不是一次级联模拟
每一个已注册的 class 都拥有一个唯一的 rank(lex(−breadth, familyIndex, valueIndex)——大致来说:更窄的工具类排在更宽的之前, 然后是族群,然后是取值)。cn() 和 resolve() 从不模拟浏览器的级联规则(选择器特异性、源码顺序、!important); 它们运行的是一次纯粹的折叠(fold):
- 把每一个 class 字符串按空白拆分;把每一个 token 解析为
(variants, utility)。 - 对每一个 class 的每一条声明,计算出一个槽位键:
(pseudoElement, condition, slot)。 - rank 最高的那条声明赢得每一个键位。 迭代顺序无关紧要。
cn()还会额外先按*片段(fragment)*排序:较晚的片段胜过较早的片段,只有在同一个片段内部才由 rank 决定胜负。 这正是cn(base, className)成为一个可靠覆盖机制的原因——调用方所在的那个片段获胜,无论它自身内部的 rank 是什么。- 幸存者按 rank 升序排序输出,因此打印出来的 class 字符串从左到右读起来就是“后者获胜”(阅读规则), 而原子(atomic)/未知的 class 则按作者书写顺序追加在最后。
实际例子(已对照 packages/merge/test 与 examples/reference-ui/.tabula/llms-full.txt 验证):
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——一份损坏或被手动编辑过的注册表会大声地失败, 而不是悄悄地渲染错误。
配置档案的严格级别:base 与 strict
排版是唯一拥有两种出厂严格级别的领域,通过 tabula.config.json 中的 profileLevel 设置(默认:base):
base注册普通的text-*/font-*/leading-*/tracking-*族群,并通过三种方式加以约束: 每一个可继承、且归属于配置档案的 CSS 属性都恰好拥有一个:root默认值(会被检查——缺失时报TAB-E220); 一个可继承属性的工具类只在文本叶子标签上,或者在一个携带scope-text标记的元素上才合法 (tabula/inherited-property-boundary,可自动修复);并且能够被继承的取值集合被封闭在已注册的设计令牌范围内。strict用type-*取代局部排版——一个 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):
"color.surface": { "$value": { "$axis": "theme", "light": "#ffffff", "dark": "#0b0b0c" } }这必须是完全的(total):每一个已声明的轴成员(light、dark、……)都需要一个字面量值,不允许有回退 (TAB-E113)——一个在深色模式下悄悄沿用其浅色模式取值的设计令牌,正是这条完全性规则的存在意义所要杜绝的那种典型隐形 bug。 构建会为每一个自定义属性发出一条 @property(inherits: 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-hover、sm:p-md、sm:hover:bg-surface-raised)是在一个基础工具类前面加上一个或 多个变体前缀。由于该预设是用 @import "tailwindcss" source(none) 加一份显式的 @source inline 列表来编译的, 一条链只有在被注册的情况下才会产生 CSS——和一个基础 class 完全一样。封闭性(T3)不变;被注册的集合只是被扩展, 纳入了已声明的变体产物(declared variant products)——就像任意值只有通过一条已注册的例外才会合法一样。CSS 始终是配置档案(即注册表)的纯函数,永远不是被扫描源码的函数——因此一次新的链用法会引发一个响亮的 tabula scan 错误,而绝不会是一条静默缺失的规则。
已声明的产物
一个产物(product)是一个链前缀与它可以修饰的*族群(family)*的配对,在 tabula.config.json 中声明:
"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-md) | declare 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 | 物理方向的行内轴工具类不会被注册——它们完全不产生任何 CSS | ps-* 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) 意味着它反正也不会产生 CSS | tabula except add |
裸的 group / peer / @container | “这是哪一个祖先?”这个问题在不读遍整棵树的情况下是无法回答的 | 给它取名:group/card + group-hover/card: |
| 在运行时拼出来的 class 名 | Tailwind 扫描的是源码文本;一个在运行时拼装出的名字永远不会被扫描到 | 一个位于 *.classmap.ts 文件中的字面量查找表 |
其中有两条被作为一道与注册表无关的底线强制执行,无论构建状态如何(packages/eslint-plugin/src/banlist.ts: space-*、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:css(tabula build --check 会运行它, 除非你传入 --no-css)。两者使用的是同一个规则内核,因此它们不可能产生分歧。
| 代码 | 禁止 | 规则 |
|---|---|---|
TAB-E221 | @apply | 把一份 class 列表编织进一个手写的选择器——正是这份配置档案要移除的那种间接层 |
TAB-E222 | 受核准入口之外的手写规则 | 按选择器而非按 class 来样式化 |
TAB-E223 | 在 :root/html 之外定义的 --tb-* / --d-* | T17,本系统中最重要的一条检查 |
TAB-E224 | @theme inline | T20——把每一个有条件的设计令牌都编译没了;这是 shadcn 生态圈的陷阱 |
TAB-E225 | !important | 把一条声明置于 rank 模型之外 |
于是,以下这三种写法现在全都会失败,而它们曾经全都能通过:
@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 治理。