快速上手
本文将带你在 Tabula 之上构建一小组组件,从一个空项目一路做到通过 CI 检查的构建。这里出现的每一条命令与每一段代码都是真实的——它们要么取自 examples/reference-ui,要么已经针对本仓库测试套件中的实际 CLI 运行过。
1. 安装
npm install -D @tabula-css/cli @tabula-css/eslint-plugin
npm install @tabula-css/merge @tabula-css/preset
# optional: paved-path primitives (<Text>, <Separator>, <Prose>)
npm install @tabula-css/react需要 Node 20+ 与 Tailwind CSS v4(@tabula-css/preset 专门面向 v4 引擎)。
2. 编写你的设计令牌
Tabula 会读取你项目根目录下的每一个 tokens/*.tokens.json 文件(可以有多个——但一个命名空间必须只存在于其中一个文件里, 否则构建会报错),并将它们合并。设计令牌(design token)是一份扁平的、DTCG 格式的配置档案(profile):只有两层深度, 每个值要么是字面量,要么是一份完整声明的轴(axis)映射——绝不是 $ref,也绝不是别名链。
tokens/base.tokens.json:
{
"color": {
"surface": {
"$type": "color",
"$description": "Default page background. The bottom-most layer of the UI.",
"$value": { "$axis": "theme", "light": "#ffffff", "dark": "#0b0b0c" }
}
},
"spacing": {
"md": {
"$type": "dimension",
"$description": "Default spacing unit. Card padding and gaps use this.",
"$value": { "value": 1, "unit": "rem" }
}
},
"radius": {
"md": {
"$type": "dimension",
"$description": "Default corner radius for cards and raised surfaces.",
"$value": { "value": 0.5, "unit": "rem" }
}
}
}每个设计令牌都需要一个至少 20 个字符的 $description(TAB-E109)——这正是反向查找(find_class_for)得以实现的基础, 因此含糊的描述是实实在在的缺陷,而不是走个形式。$axis 的取值必须是完全的(total):每一个声明的轴成员都需要有一个值, 不允许有回退(fallback)(TAB-E113)——一个在深色模式下悄悄沿用浅色模式取值的设计令牌,正是轴模型的存在意义所要杜绝的那种隐形 bug。
3. 声明你的轴
tabula.config.json:
{
"profileId": "my-app@1",
"profileLevel": "base",
"axes": {
"theme": {
"values": ["light", "dark"],
"default": "light",
"attribute": "data-theme",
"media": { "dark": "(prefers-color-scheme: dark)" }
}
},
"groups": []
}profileLevel 决定排版的严格程度(见 concepts.md);base——当该字段被省略时的默认值——会让标准的 text-*/font-* 工具类保持在约束规则之下。axes.theme.attribute 是用于切换主题的 DOM 属性(data-theme="dark"); media.dark 则是在任何属性被设置之前、用于首次渲染的 prefers-color-scheme 回退。
4. 构建
npx tabula build✓ wrote 12 artifacts to .tabula/ (inputsHash 67a91fcf6338)
AGENTS.md.snippet
llms-full.txt
llms.txt
manifest.json
profile.css
registry.json
source.css
tabula.config.json
theme.css
tokens.resolved.json
types.d.ts
vocabulary.txtbuild 是原子化的,并且失败即关闭(fails closed):只要存在任何设计令牌或配置错误,就不会写出任何文件(退出码 1, 并打印出违规的诊断信息)。每个产物都带有 @generated <hash> 头部,并以只读方式(0444)写入——绝不要手动编辑 .tabula/ 下的任何文件;tabula doctor 会捕捉到这一点(TAB-E601),并在下一次 tabula build 时将该编辑视为已丢弃。
每个产物是什么
| 文件 | 用途 |
|---|---|
source.css | Tailwind v4 的入口:source(none) + 主题 + 每个已注册 class 对应的一条 @utility + 精确命名封闭词汇表的 @source inline(...)。这就是你的构建工具所导入的文件。 |
theme.css | 仅包含 @theme 代码块与根作用域的轴代码块,也被内嵌进了 source.css。 |
profile.css | 每一个已注册工具类的 CSS,按 rank 升序重新输出(SPEC J2)——用于查看词汇表本身的输出。它并不代表最终交付的一切:见下文。 |
registry.json | 唯一真相来源。 class → 声明、槽位(slot)、rank、自定义属性、例外、封禁。其他一切都由此派生。 |
tokens.resolved.json | 每个设计令牌在各轴组合下的字面值——在手动选择一个值之前先读这个文件。 |
vocabulary.txt | 每一个合法的 class 及其声明与描述——在写一个 class 之前先读这个文件。 |
types.d.ts | 每一个已注册 class 名称组成的 TypeScript 联合类型:既是编辑器自动补全,也是一道编译期的封闭性门禁。 |
llms.txt / llms-full.txt / AGENTS.md.snippet | 面向智能体的接口——见 agents.md。 |
manifest.json | 完整性根:inputsHash、profileVersion、每个产物的 sha256。这是 doctor 与 build --check 用来比对的基准。 |
tabula.config.json | 构建出该目录所用配置的规范化(canonical)拷贝——让被冻结的 .tabula/ 说明自己是由什么构建的。 |
profile.css 不会展示什么
profile.css 是从 registry.json 重新输出的(emitProfileCss,packages/registry/src/generate/assemble.ts), 而不是从编译产物中捕获的。它的全部内容都是一个单独的 @layer utilities { … } 代码块。所以它展示的恰好就是已注册的词汇表, 别无其他——尤其是它不展示 Tailwind 的 base 层。
但那一层依然会被交付。@import "tailwindcss" source(none) 关闭的是源码扫描,而不是移除 base。因此导入 .tabula/source.css 同样会带来 preflight——box-sizing: border-box、margin: 0、border: 0 solid、 替换元素上的 display: block、表单控件重置——这些会应用到每一个元素上,却既不出现在注册表里,也不出现在 vocabulary.txt 里。grep box-sizing .tabula/profile.css 什么都搜不到;但浏览器依然会拿到它。
实践上来说:如果一个元素的计算样式里出现了某个属性、而它自身的任何 class 都没有设置它,preflight 就是首先该去查看的地方, profile.css 帮不了你找到它。完整清单请阅读 node_modules/tailwindcss/preflight.css。
5. 将它接入你的应用
把生成的样式表作为你的 Tailwind 入口导入(把路径调整为你自己的 CSS 入口文件):
@import "../.tabula/source.css";工具链在你自己的 CSS 里检查了什么——以及仍然没有检查什么
以前这条工具链里没有任何东西会去读项目自身的样式表。现在会读了——通过 tabula check:css(tabula build --check 会运行它,除非你传入 --no-css),以及你编辑器里的 @tabula-css/stylelint-plugin。项目里的每一个 .css 文件都适用五条封禁: @apply(TAB-E221)、入口文件之外的手写规则(TAB-E222)、在 :root/html 之外定义的 --tb-*/--d-*(TAB-E223)、 @theme inline(TAB-E224),以及 !important(TAB-E225)。你刚刚加上 @import 的这个文件是受核准的入口 (sanctioned entry):它可以承载根级别的应用 CSS,并且只在 TAB-E222 这一条上被豁免,其余一条都不豁免。完整全貌, 包括如何用 --css-entry 命名一个非常规入口,见 CSS 治理。
有一件事仍然需要你自己盯着:封闭性只有一层深。source(none) 封闭的是 .tabula/source.css 这一层词汇表。它对你样式表里 其余的部分只字未提,两道门禁都不会标记以下任何一种写法,因为 @source 和 @import 都是项目 CSS 被允许包含的 at 规则:
@import "../.tabula/source.css";
@source "./src"; /* ← scanning back on: every Tailwind class now emits */
@import "tailwindcss"; /* ← same, without source(none) */让那些 class 得以工作的主题命名空间无论如何都还在被定义。在 profileLevel: "base" 下,预设只重置了与配置档案自身族群相冲突的排版 标度(--text-*、--leading-*、--font-weight-*、--font-*、--tracking-*,见 packages/preset/src/index.ts); 在 strict 下则完全不发出任何重置。--color-*、--spacing、--radius-* 和 --shadow-* 在两种模式下都保持存活, 所以 bg-red-500 与 p-4 依然完全可被构造出来——它们之所以处于惰性状态,只是因为暂时没有任何东西告诉 Tailwind 去扫描它们。 封闭性是生成的入口文件的属性,而不是你项目的属性。
所以:让 .tabula/source.css 的这一条 @import 成为你项目里唯一的 Tailwind 入口,不要添加任何 @source 行, 并且在发布之前对其余部分做一次 grep 检查——
grep -rn '@source\|@import "tailwindcss"' src/**/*.css然后,直接照着 vocabulary.txt 里的内容来写 class:
import { cn, type ClassName } from '@tabula-css/merge';
export function Card({ className }: { className?: ClassName }) {
return <div className={cn('bg-surface p-md rounded-md', className)} />;
}className 的类型是 ClassName——一个由 @tabula-css/merge 导出的品牌化(branded)字符串类型——它把该值标记为一个受核准的 透传(pass-through),因此 tabula/no-runtime-class-construction 会接受把它原样解构进 cn()。调用方的覆盖值总是排在最后, 从而以确定的方式赢得它所触及的槽位:
<Card className="p-lg" /> // → "bg-surface rounded-md p-lg" (p-md fully shadowed)Variants
对于一个带有具名变体的组件,variants()(@tabula-css/merge 提供的、等价于 CVA 的方案)接受一份静态的字面量配置——其中的每一个 字符串都会像普通 class 字符串一样被检查,因为这份映射和源码文本一样是完全可扫描的。(这个例子复用了 examples/reference-ui 中更丰富的设计令牌集合里的 class 名称,而不是上文引入的那三个设计令牌。)
import { variants, type ClassName } from '@tabula-css/merge';
const button = variants({
base: 'rounded-md gap-sm inline-flex items-center justify-center',
variants: {
variant: {
solid: 'bg-accent',
outline: 'border-border border-thin',
},
size: {
sm: 'px-sm h-control-sm',
md: 'px-md h-control-md',
},
},
defaultVariants: { variant: 'solid', size: 'md' },
});
<button className={button({ variant: 'outline', size: 'sm', className })} />完整的、通过 lint 检查的版本(焦点环、禁用状态、过渡效果)请见 examples/reference-ui/src/button.tsx。
6. 配置 ESLint
eslint.config.js:
import tabula from '@tabula-css/eslint-plugin';
export default [
{
...tabula.configs.strict,
files: ['**/*.{ts,tsx}'],
settings: {
tabula: { registry: '.tabula/registry.json' },
},
},
];strict 会把全部 15 条 tabula/* 规则都变成错误——未知 class、被禁用的机制(space-*、divide-*、dark:、任意值、裸的 group)、class 顺序、同字符串内的槽位冲突、className 排在最后,等等。要接入一个已有代码库?改用 tabula.configs.migration:封禁类规则(no-runtime-class-construction、no-unregistered-arbitrary-value、 no-theme-variant、no-important)依然是硬性错误,其余则放宽为警告,让你可以逐步落地这次切换。见 migration.md。
7. 例外工作流
有时候,一个已注册的设计令牌确实无法覆盖你所需要的某个值。tabula except add 会对照真实的设计令牌文档校验一项提案, 并打印出补丁——除非你传入 --apply,否则它什么都不会写:
npx tabula except add \
--name hero-legacy-width --type dimension --value 347px \
--families w --reason "Legacy marketing hero matches a fixed CMS image at exactly 347px; no rem token this specific exists and none should." \
--owner @growth-team --expires 2027-01-15 --allowed-in "src/marketing/hero.tsx"✓ valid. Add this to tokens/exceptions.tokens.json:
{
"exception": {
"hero-legacy-width": { "$type": "dimension", "$value": { "value": 347, "unit": "px" }, ... }
}
}
✓ will mint class: w-hero-legacy-width
Nothing was written. Apply the patch, then run `tabula build`.reason 必须至少 40 个字符(一道反敷衍检查——写“需要它”是过不了的);expires 对具名例外最多能设到 12 个月之后, 对 --literal 泄压阀通道则最多 90 天。一个与现有设计令牌足够接近(约 5% 以内)的近似匹配会触发一条警告, 而不是任由你悄悄再造一个。传入 --apply 可以直接写入补丁——此后 .tabula/ 就会处于过期状态,直到你再次运行 tabula build,而 doctor 也会这样提示你。
8. 在 CI 中检查它
两条命令构成了 CI 门禁:
npx tabula build --check # exit 1 if the committed .tabula/ doesn't byte-match a fresh build — then runs the scan gate
npx tabula doctor # staleness, hand-edited artifacts, expiring exceptions, version skew, budgetsbuild --check 本身就已经包含了 scan 门禁,所以这两条命令就是完整的门禁。当你只想要它、而不想要漂移检查时——比如在一个 pre-commit 钩子里,或者一个更快的按 PR 运行的任务——可以单独运行 scan:
npx tabula scan --strict # exit 1 on any class outside the vocabulary, in any source file typetabula scan——读取你源码的那道门禁
ESLint 只能看到 .ts/.tsx。scan 运行的是Tailwind 自身的扫描器(@tailwindcss/oxide),对你的源码 glob 执行与生成 CSS 时相同的那种提取,并把找到的候选项与注册表、你的例外表以及 foreignClasses 做差异比对。 发现的问题会以 TAB-E201 的形式报告,附带 file:line:col,并以退出码 1 结束。
默认的 glob(tabula.config.json 里的 scan.sources 可以覆盖它们;@tabula-css/core 里的 DEFAULT_SCAN_SOURCES) 会递归覆盖 src/、app/、pages/ 和 components/,针对以下扩展名:
js jsx mjs cjs ts tsx mts cts md mdx html vue svelte astronode_modules/、dist/ 和 .tabula/ 永远不会被扫描;可以通过 scan.ignore 添加更多排除项。
Tailwind 的扫描器故意设计得很宽松——它会提取文件里每一段形似单词的片段——所以如果报告每一个未注册的候选项, 就会把普通的行文与标识符也一并标记出来。因此 scan 只有在一个候选项既未注册又具有工具类的外形时才会报告它, 判断依据是以下四种独立信号之一:命中封禁列表(这与注册表无关——即便完全没有 .tabula/ 也会触发)、 任意值或任意属性的 […] 语法、一条每个变体都能解析的变体链(md:whatever),或者一个已注册族群前缀搭配了未注册的值 (比如已存在 p-md 时出现的 p-7)。这使得 scan 成为一道针对意图的强门禁,而不是 linter 的一个字面超集: linter 知道某个字符串位于 className 里,而一个不带上下文的扫描器做不到这一点。
--strict 会加上抑制预算(TAB-W900):它统计命名了某条 tabula/ 规则的 eslint-disable 注释数量——外加那些不带规则列表的 一揽子禁用(它们会连带把 Tabula 的规则也一起噤声)——并在计数超过你配置中的 budgets.maxSuppressions 时失败。 该预算默认是 0,所以在 --strict 下,第一次出现的抑制就会导致失败。这个计数在每次运行时都会被打印出来, 无论预算是否被强制执行。
build --check 会在字节级差异比对通过之后、以 --strict 模式自行运行 scan 门禁——先检查漂移, 这样一个 scan 发现的问题就始终意味着“这段源码有问题”,而绝不会意味着“注册表过期了”。传入 --no-scan 可以跳过它、只检查漂移(适用于一个已经扫描过的发布任务)。
--check 从不写入任何文件——它只是在内存中把新生成的字节与磁盘上的内容做差异比对。doctor 会依次运行: 过期性检查(设计令牌/配置的哈希与清单的对比)、手动编辑检查(每个产物的 sha256 与清单摘要的对比—— 如果发生了漂移则报 TAB-E601)、例外检查(已过期的报错;30 天内即将过期的发出警告)、版本偏差检查 (混用了不同版本的 @tabula-css/*,或者安装的 Tailwind 版本与构建 .tabula/ 时所用的版本不一致),以及 预算检查(对照 budgets.maxEscapes 的字面逃逸计数,默认值来自 @tabula-css/core)。
所有命令的退出码含义都是一致的:0 表示正常,1 表示存在契约违规,2 表示工具自身出了问题(输入格式错误、内部错误)。 给任意命令加上 --format=json,就能得到机器可读的信封结构 { tabula, ok, inputsHash, diagnostics }——这才是 一个智能体或 CI 步骤应当解析的对象,而不是面向人类的文本输出。
Next
- concepts.md,了解合并背后的理论与被禁用机制的清单。
- agents.md,如果你正在为这个项目接入一个 AI 编码智能体。
- migration.md,如果你正在转换一个既有的 Tailwind 或 shadcn/ui 代码库。