はじめに
本ページでは、空のプロジェクトから CI でチェック済みのビルドに至るまで、Tabula 上でごく小さなコンポーネント一式を構築する手順を説明します。ここに登場するコマンドやコードブロックはすべて実物です――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/reactNode 20 以上と Tailwind CSS v4 が必要です(@tabula-css/preset は v4 エンジンを専用にターゲットとしています)。
2. トークンを記述する
Tabula はプロジェクトルート内のすべての tokens/*.tokens.json ファイルを読み込み(ファイルは複数あってもかまいません――ただし一つの名前空間は必ず一つのファイルにのみ存在しなければならず、そうでなければビルドはエラーになります)、それらをマージします。トークン(デザイントークン)はフラットで DTCG 形式のプロファイルです。深さは2段までで、すべての値はリテラルか、総体として宣言された軸マップのいずれかであり、$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) でなければなりません。宣言された軸メンバーはすべて値を持つ必要があり、フォールバックは許されません(TAB-E113)――ライトモードの値をダークモードでも黙って引き継いでしまうトークンこそ、まさに軸モデルが排除しようとしている目に見えないバグです。
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 はアトミックであり、失敗時には閉じます(fail closed)。トークンや設定にエラーが一つでもあれば、何も書き出されません(終了コード 1、違反の診断メッセージが表示されます)。すべての成果物には @generated <hash> ヘッダーが付き、読み取り専用(0444)で書き出されます――.tabula/ 配下のものを手で編集することは決してしないでください。tabula doctor がそれを検知し(TAB-E601)、次回の tabula build でその編集は破棄されたものとして扱われます。
各成果物の役割
| File | What it's for |
|---|---|
source.css | Tailwind v4 のエントリポイント。source(none) + テーマ + 登録済みクラスごとに1つの @utility + 閉じた語彙をそのまま列挙する @source inline(...) から成ります。バンドラーがインポートするのはこれです。 |
theme.css | @theme ブロックとルートスコープの軸ブロックだけを含み、source.css にも埋め込まれます。 |
profile.css | 登録済みのすべてのユーティリティの CSS を、rank の昇順で再出力したもの(SPEC J2)――語彙の出力を検査するためのものです。実際に出荷されるものすべてではありません。詳しくは下記を参照してください。 |
registry.json | 唯一の正(ground truth)。 クラス → 宣言、スロット、rank、カスタムプロパティ、例外、禁止事項のマッピング。他のすべてはここから派生します。 |
tokens.resolved.json | 軸の組み合わせごとの、すべてのトークンのリテラル値。手で値を選ぶ前に読むべきものです。 |
vocabulary.txt | 合法なクラスすべてと、その宣言および説明。クラスを書く前に読むべきものです。 |
types.d.ts | 登録済みのクラス名すべてから成る 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 は何もヒットしませんが、ブラウザには確かに適用されています。
実務上のポイント:ある要素の computed style に、その要素自身のクラスがどれも設定していないプロパティが現れた場合、まず疑うべきは preflight であり、profile.css を見ても見つかりません。全リストは node_modules/tailwindcss/preflight.css を参照してください。
5. アプリに配線する
生成されたスタイルシートを、あなたの Tailwind エントリポイントとしてインポートします(CSS エントリファイルへのパスは環境に合わせて調整してください):
@import "../.tabula/source.css";ツールチェインが自分の CSS の中で何をチェックし、何をまだチェックしないか
以前は、このツールチェインの中でプロジェクトのスタイルシートを読むものは何もありませんでした。今では tabula check:css(--no-css を渡さない限り tabula build --check が実行します)と、エディタ内の @tabula-css/stylelint-plugin がそれを読みます。プロジェクト内のすべての .css ファイルに、次の5つの禁止事項が適用されます:@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 governance を参照してください。
一つだけ、あなた自身が見張らなければならないことがあります:閉包性はたった1行分の深さしかありません。source(none) が閉じるのは .tabula/source.css の語彙だけです。スタイルシートの残りの部分については何も言っておらず、どちらのゲートも次の2つを検出しません。なぜなら @source と @import は、プロジェクト CSS が含むことを許されている at-rule だからです:
@import "../.tabula/source.css";
@source "./src"; /* ← scanning back on: every Tailwind class now emits */
@import "tailwindcss"; /* ← same, without source(none) */そうしたクラスを機能させるテーマ名前空間は、いずれの場合も定義されたままです。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 からそのままクラスを書いていきます:
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 がエクスポートする、ブランド付きの文字列型です――これによって承認済みのパススルーであるとマークされ、tabula/no-runtime-class-construction がそれをそのまま分割代入して cn() に渡すことを許可します。呼び出し側のオーバーライドは常に最後に置かれるため、それが触れるスロットを決定論的に勝ち取ります:
<Card className="p-lg" /> // → "bg-surface rounded-md p-lg" (p-md fully shadowed)バリアント
名前付きバリアントを持つコンポーネントには、variants()(@tabula-css/merge が提供する CVA 相当の機能)を使います。これは静的でリテラルな設定を受け取ります――そのマップ内のすべての文字列は、普通のクラス文字列とまったく同じようにチェックされます。マップもソーステキストと同じようにスキャン可能だからです。(この例は、上で紹介した3つのトークンではなく、examples/reference-ui のより豊富なトークンセットからクラス名を再利用しています。)
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 })} />フォーカスリング、無効状態、トランジションを含む完全でリントもクリーンなバージョンは 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/* ルールのすべてをエラーに変えます――未知のクラス、禁止されたメカニズム(space-*、divide-*、dark:、任意の値、裸の group など)、クラスの順序、同一文字列内でのスロット衝突、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 build を再実行するまで .tabula/ は古くなり(stale)、doctor がそれを知らせます。
8. CI でチェックする
CI ゲートは次の2つのコマンドから成ります:
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 ゲートを含んでいるため、この2つでゲートの全体になります。ドリフトチェックなしで scan だけを実行したい場合――pre-commit フックや、PR ごとの高速なジョブなど――は、単独で実行してください:
npx tabula scan --strict # exit 1 on any class outside the vocabulary, in any source file typetabula scan ――ソースを読み取るゲート
ESLint が見るのは .ts/.tsx だけです。scan は、あなたのソースの glob パターンに対して Tailwind 自身のスキャナー(@tailwindcss/oxide)を実行します――これは CSS を生成するのと同じ抽出処理です――そして見つかった候補を、レジストリ・あなたの例外・foreignClasses と突き合わせます。検出結果は file:line:col 付きの TAB-E201 として報告され、終了コード 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 は、未登録かつユーティリティらしい形をしている候補だけを、次の4つの独立したシグナルのいずれかによって報告します:禁止リストへのヒット(レジストリに依存しない――.tabula/ が全く存在しなくても発火します)、任意の値または任意のプロパティを表す […] 構文、すべてのバリアントが解決できるバリアントチェーン(md:whatever)、あるいは登録済みのファミリー接頭辞に未登録の値が続く場合(p-md は存在するが p-7 はない、など)。これによって scan は意図に対する強力なゲートとなりますが、リンタの単純な上位互換ではありません。リンタは文字列が className の中にあることを知っていますが、文脈を持たないスキャナーにはそれができません。
--strict は抑制バジェット(TAB-W900)を追加します。これは、tabula/ ルールを名指しする eslint-disable コメントの数――加えて、ルールリストを指定しない全面的な無効化(これは他のすべてのルールと一緒に Tabula のルールも黙らせます)――を数え、設定の budgets.maxSuppressions を超えると失敗します。このバジェットのデフォルトは 0 なので、--strict の下では最初の抑制がそのまま失敗になります。バジェットが強制されているかどうかにかかわらず、この件数は毎回の実行で表示されます。
build --check は、バイト単位の差分チェックが通った後で、--strict モードで scan ゲート自体を実行します――ドリフトのチェックが先に行われるため、scan の検出結果は常に「このソースが間違っている」ことを意味し、「レジストリが古かった」ことを意味することは決してありません。すでに scan 済みのリリースジョブなど、ドリフトのチェックだけをしたい場合は --no-scan を渡してスキップしてください。
--check は決して書き込みを行いません――新しく生成したバイト列を、メモリ上でディスクの内容と比較するだけです。doctor は次の順序で実行されます:staleness(古さ)(トークン/設定のハッシュとマニフェストの比較)、hand-edits(手編集)(各成果物の sha256 とマニフェストの比較――ずれていれば TAB-E601)、exceptions(例外)(期限切れはエラー、30 日以内に期限切れになるものは警告)、version skew(バージョンのずれ)(@tabula-css/* のバージョンが混在している、またはインストールされている Tailwind が .tabula/ のビルド時のものと一致しない)、そして budgets(バジェット)(budgets.maxEscapes に対するリテラルエスケープの件数。デフォルト値は @tabula-css/core に由来します)。
終了コードは、どのコマンドでも同じ形をしています:0 は正常、1 は契約違反、2 はツール自体が壊れている(不正な入力、内部エラー)ことを意味します。どのコマンドにも --format=json を付けると、{ tabula, ok, inputsHash, diagnostics } というマシン向けのエンベロープが得られます――エージェントや CI のステップがパースすべきなのはこちらであり、人間向けのテキストではありません。
次に読むもの
- マージの背後にある理論と、禁止メカニズムの一覧については concepts.md を参照してください。
- AI コーディングエージェントをこのプロジェクトに組み込む場合は agents.md を参照してください。
- 既存の Tailwind や shadcn/ui のコードベースを移行する場合は migration.md を参照してください。