Skip to content

快速上手

本文将带你在 Tabula 之上构建一小组组件,从一个空项目一路做到通过 CI 检查的构建。这里出现的每一条命令与每一段代码都是真实的——它们要么取自 examples/reference-ui,要么已经针对本仓库测试套件中的实际 CLI 运行过。

1. 安装

bash
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

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

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. 构建

bash
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.txt

build 是原子化的,并且失败即关闭(fails closed):只要存在任何设计令牌或配置错误,就不会写出任何文件(退出码 1, 并打印出违规的诊断信息)。每个产物都带有 @generated <hash> 头部,并以只读方式(0444)写入——绝不要手动编辑 .tabula/ 下的任何文件;tabula doctor 会捕捉到这一点(TAB-E601),并在下一次 tabula build 时将该编辑视为已丢弃。

每个产物是什么

文件用途
source.cssTailwind 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完整性根:inputsHashprofileVersion、每个产物的 sha256。这是 doctorbuild --check 用来比对的基准。
tabula.config.json构建出该目录所用配置的规范化(canonical)拷贝——让被冻结的 .tabula/ 说明自己是由什么构建的。

profile.css 不会展示什么

profile.css 是从 registry.json 重新输出的(emitProfileCsspackages/registry/src/generate/assemble.ts), 而不是从编译产物中捕获的。它的全部内容都是一个单独的 @layer utilities { … } 代码块。所以它展示的恰好就是已注册的词汇表, 别无其他——尤其是它展示 Tailwind 的 base 层。

但那一层依然会被交付。@import "tailwindcss" source(none) 关闭的是源码扫描,而不是移除 base。因此导入 .tabula/source.css 同样会带来 preflight——box-sizing: border-boxmargin: 0border: 0 solid、 替换元素上的 display: block、表单控件重置——这些会应用到每一个元素上,却既不出现在注册表里,也不出现在 vocabulary.txt 里。grep box-sizing .tabula/profile.css 什么都搜不到;但浏览器依然会拿到它。

实践上来说:如果一个元素的计算样式里出现了某个属性、而它自身的任何 class 都没有设置它,preflight 就是首先该去查看的地方, profile.css 帮不了你找到它。完整清单请阅读 node_modules/tailwindcss/preflight.css

5. 将它接入你的应用

把生成的样式表作为你的 Tailwind 入口导入(把路径调整为你自己的 CSS 入口文件):

css
@import "../.tabula/source.css";

工具链在你自己的 CSS 里检查了什么——以及仍然没有检查什么

以前这条工具链里没有任何东西会去读项目自身的样式表。现在会读了——通过 tabula check:csstabula build --check 会运行它,除非你传入 --no-css),以及你编辑器里的 @tabula-css/stylelint-plugin。项目里的每一个 .css 文件都适用五条封禁: @applyTAB-E221)、入口文件之外的手写规则(TAB-E222)、在 :root/html 之外定义的 --tb-*/--d-*TAB-E223)、 @theme inlineTAB-E224),以及 !importantTAB-E225)。你刚刚加上 @import 的这个文件是受核准的入口 (sanctioned entry):它可以承载根级别的应用 CSS,并且只在 TAB-E222 这一条上被豁免,其余一条都不豁免。完整全貌, 包括如何用 --css-entry 命名一个非常规入口,见 CSS 治理

有一件事仍然需要你自己盯着:封闭性只有一层深source(none) 封闭的是 .tabula/source.css 这一层词汇表。它对你样式表里 其余的部分只字未提,两道门禁都不会标记以下任何一种写法,因为 @source@import 都是项目 CSS 被允许包含的 at 规则:

css
@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-500p-4 依然完全可被构造出来——它们之所以处于惰性状态,只是因为暂时没有任何东西告诉 Tailwind 去扫描它们。 封闭性是生成的入口文件的属性,而不是你项目的属性。

所以:让 .tabula/source.css 的这一条 @import 成为你项目里唯一的 Tailwind 入口,不要添加任何 @source 行, 并且在发布之前对其余部分做一次 grep 检查——

bash
grep -rn '@source\|@import "tailwindcss"' src/**/*.css

然后,直接照着 vocabulary.txt 里的内容来写 class:

tsx
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()。调用方的覆盖值总是排在最后, 从而以确定的方式赢得它所触及的槽位:

tsx
<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 名称,而不是上文引入的那三个设计令牌。)

tsx
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

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-constructionno-unregistered-arbitrary-valueno-theme-variantno-important)依然是硬性错误,其余则放宽为警告,让你可以逐步落地这次切换。见 migration.md

7. 例外工作流

有时候,一个已注册的设计令牌确实无法覆盖你所需要的某个值。tabula except add 会对照真实的设计令牌文档校验一项提案, 并打印出补丁——除非你传入 --apply,否则它什么都不会写

bash
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 门禁:

bash
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, budgets

build --check 本身就已经包含了 scan 门禁,所以这两条命令就是完整的门禁。当你只想要它、而不想要漂移检查时——比如在一个 pre-commit 钩子里,或者一个更快的按 PR 运行的任务——可以单独运行 scan

bash
npx tabula scan --strict   # exit 1 on any class outside the vocabulary, in any source file type

tabula scan——读取你源码的那道门禁

ESLint 只能看到 .ts/.tsxscan 运行的是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 astro

node_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 代码库。

Released under the MIT License.