コンセプト
局所性と十戒
Tabula の中心的な主張はこうです:要素のスタイリングはそれ自身のクラス文字列である――祖先要素、兄弟要素、スタイルシートの順序から継承したりカスケードしたりするものは何もない。ただし、要素自身のクラス文字列が非局所性を宣言している場合(group/name、type-inherit、prose)は例外である。子要素がどう描画されるかを知るために親を読む必要がある、という状態はスタイルの選択ではなく、バグの一種として扱われます。
この主張は10のルールとして符号化され、生成されるすべての .tabula/llms.txt にそのままの文言で(かつ強制力を持って)出力されます:
vocabulary.txtにあるクラスだけが存在する。それ以外のものは静かにCSS を一切生成しない。実行時に組み立てられたクラス(`p-${n}`)は決して機能しない――Tailwind はソースのテキストをスキャンするからだ。- クラスは記憶からではなく、意図から逆引きする(MCP の
find_class_for)。 - 任意の値(
w-[347px])は決して使わない。代わりにtabula except addが名前付きのクラスを新たに発行する。 dark:は決して書かない――テーマはトークンの軸であり、bg-surfaceはすでにすべてのテーマをカバーしている。- より高い
rankが勝つ。それがカスケードのすべてであり、属性の順序は何も変えない。 - 一つの静的な文字列の中で2つのクラスが同じプロパティを書くのはマージではなく、リントエラーである。
cn(base, …, className)で合成する:呼び出し側のclassNameは最後に置かれ、勝つ。- テキストを持つすべての要素は、1つの
type-*と1つのink-*を持つか、あるいはtype-inheritを宣言する。 - 親が子をスタイリングすることはない:
space-*、divide-*、*:、**:、[&>*]:、in-*は存在しない。gap-*を使うか、ユーティリティを子要素の側に置く。 - 要素をまたぐ依存関係は必ず名前を付ける(
group/card。裸のgroupは決して使わない)。
レジストリは唯一の正である
registry.json はプロファイルのドキュメントではなく、プロファイルそのものです。他のすべての成果物(vocabulary.txt、llms.txt、ESLint プラグイン、@tabula-css/merge)はそこから派生したビューであり、2つの成果物が食い違う場合には、レジストリの方が正しいと定義されています。これは @tabula-css/registry によって生成されますが、これは Tailwind のユーティリティ文法を再実装するのではなく、Tailwind 自身がコンパイルした CSS 出力を解析します(2つの独立したバックエンド――デザインシステム API か、PostCSS によるプローブシートの走査――のいずれかを介し、CI では両者を相互チェックしています)。
tokens.resolved.json は値のための対になるビューです:各トークンの軸ごとに完全に解決されたリテラル値に加えて――フォワードビルドが情報を保持したままであるように――ソーストークンの $deprecated、$extensions($extensions.tabula.contrastWith のアクセシビリティ契約や外部ベンダーの名前空間を含む)、そしてトップレベルのエイリアス参照を記録する $alias マーカーです。バイト同一の tabula.config.json がその隣にコピーされるため、凍結された .tabula/ はそれを何がビルドしたのかを正確に記述します。
クラスのエントリを、丸ごと見てみましょう(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――そのクラスが所有する正規のロングハンド CSS プロパティ(およびプロファイル内部のカスタムプロパティ)を表す数値 ID。レジストリのトップレベルのslotsマップは、ID をプロパティ名に逆引きします(例:9 → background-color)。slotAliasesは、論理プロパティが同一の物理スロットと等価である場合に、それらを同じ物理スロットへとマッピングします(padding-block-start ≡ padding-top)。rank――登録済みのすべてのクラスに対する、単一の整数による全順序(詳細は後述)。spec――セレクタの CSS 詳細度。普通のユーティリティはすべて(0, 1, 0)に正規化されており(疑似要素は(0, 1, 1))、詳細度は何の情報も運びません――決定するのはrankだけです。locality――そのクラスが付与された要素だけを常にスタイリングする場合はL0です。文書化された隔離区画の外で、いずれかのユーティリティがL1/L2(兄弟要素や子孫要素に届くセレクタ)に分類された場合、ビルドは失敗します。composed(宣言の側に付くもので、上記の例には表示されていません)――宣言の値がリテラルではなくvar(--tb-*)への参照である場合にtrueになります。宣言がすべて composed であるクラスは、それらに対応するスロットを一切所有しません(SPEC J1 の修正)――これが、shadow-mdとring-2が衝突せずに合成できる理由です。両者は同じbox-shadowスロットではなく、それぞれ異なるカスタムプロパティを書き込んでいるのです。
マージ:カスケードのシミュレーションではなく、全順序
登録済みのすべてのクラスは、一意な rank を持ちます(lex(−breadth, familyIndex, valueIndex) ――おおまかに言えば、より狭いユーティリティがより広いものより先に来て、その次にファミリー、その次に値で決まります)。cn() と resolve() は、ブラウザのカスケード(セレクタの詳細度、ソースの順序、!important)を決してシミュレートしません。代わりに、純粋な畳み込み(fold)を実行します:
- すべてのクラス文字列を空白で分割し、各トークンを
(variants, utility)に解析する。 - 各クラスの各宣言について、スロットキー
(pseudoElement, condition, slot)を計算する。 - 各キーについて、最も rank の高い宣言が勝つ。 走査の順序は無関係である。
cn()はさらに、まずフラグメントによって順序付けを行う:後のフラグメントが前のフラグメントに勝ち、rank が決定に関与するのは同一フラグメント内だけである。これがcn(base, className)を健全なオーバーライドの仕組みにしている理由であり、呼び出し側のフラグメントは、その内部の rank にかかわらず勝つ。- 生き残った宣言は出力のために rank の昇順でソートされる。そのため出力されるクラス文字列は、左から右に読むと「後のものが勝つ」ように読める(読解ルール)。アトミック/未知のクラスは、その後に記述順で追加される。
具体例(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 で設定される、2つの厳格さレベルが用意されている唯一の領域です(デフォルト:base):
baseは、普通のtext-*/font-*/leading-*/tracking-*ファミリーを登録し、3つの方法で封じ込めます:継承可能でプロファイルが所有するすべての CSS プロパティは、ちょうど1つの: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)を1つのクラスとしてまとめたバンドルで、色については別途ink-*を使います。部分的なクラス(text-sm単体など)はそもそも登録されません:テキスト要素は、1つの宣言で完全なタイポグラフィの同一性を表明するか、明示的にtype-inheritを宣言するかのどちらかです。
examples/reference-ui は strict を使用しています(その tabula.config.json を参照)。どちらのレベルが実際にエージェントをより助けるかを測定するために設計された研究については docs/benchmark.md で説明しています。
テーマ:軸であり、決して dark: ではない
テーマは CSS のバリアントではありません――tabula.config.json で一度だけ宣言され、ビルド時にすべてのトークンの値へと解決される軸です:
"color.surface": { "$value": { "$axis": "theme", "light": "#ffffff", "dark": "#0b0b0c" } }これは**全域的(total)**でなければなりません:宣言された軸メンバー(light、dark、……)はすべてリテラル値を持つ必要があり、フォールバックはありません(TAB-E113)――ライトモードの値をダークモードでも黙って引き継いでしまうトークンこそ、総体性のルールが排除しようとしている典型的な、目に見えないバグです。ビルドはカスタムプロパティごとに1つの @property を出力し(inherits: false なので、値が祖先要素によって再定義されることは決してありません)、加えてルートスコープの軸ブロック(:root[data-theme="dark"] { --tb-color-surface: #0b0b0c; }、初回描画のための prefers-color-scheme メディアフォールバック付き)を出力します。bg-surface のようなクラスは、すでにすべてのテーマを同時に体現しています――dark:bg-surface-dark と書くことは、軸モデルが取り除こうとしているまさにそのセレクタ条件分岐を再導入することになります。これが dark:(および [data-theme=…]: 系のバリアント)が禁止されたメカニズムである理由であり、@theme inline がプロジェクト CSS で禁止されている理由です(それは値を使用箇所にインライン化してしまうため、ルートの軸ブロックがもはやそれを再度指し示すことができなくなります)。
バリアント
バリアントチェーン(hover:bg-accent-hover、sm:p-md、sm:hover:bg-surface-raised)は、1つ以上のバリアントをベースユーティリティの前に付加します。プリセットは @import "tailwindcss" source(none) と明示的な @source inline リストでコンパイルされるため、チェーンは登録されている場合にのみ CSS を生成します――ベースクラスとまったく同じです。閉包性(T3)は変わりません。登録済みの集合が単純に拡張され、宣言済みのバリアントプロダクトを含むようになるだけです。これは任意の値が登録済みの例外を通じてのみ合法になるのと同じ考え方です。CSS はプロファイル(レジストリ)の純粋な関数であり続け、スキャンされたソースの関数には決してなりません――そのため新しいチェーンの使用は、静かに欠落するルールではなく、大きな音を立てる tabula scan エラーになります。
宣言済みのプロダクト
プロダクトとは、チェーンプレフィックスと、それが前置してよいファミリーとを組み合わせたもので、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"
}ビルドは、宣言済みのすべてのプロダクトを厳密なチェーンクラスの一覧へと具体化し、それをレジストリ(variantProducts――コンパクトな prefix → families マップ――に加えて chainCount)、source.css に追加されるチェーンレイヤー(effectiveRank順に並んだ、レジストリテンプレートによる1つのリテラル @layer utilities ブロック――チェーンは意図的に @source inline には列挙されず、そちらはベースのみのままです)、types.d.ts、そして llms-full.txt に書き込みます。プロダクト全体を宣言するほどではない一回限りのチェーンは、既存の例外メカニズムに乗ります:tabula except add --chain hover:bg-accent-hover … は、値の例外と同じ reason/owner/expiry の書類とともに、その単一のチェーンを登録します。
プリセット
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 だけが適用されます。
正規の順序
チェーンは厳密に1つの綴りでのみ登録されます:バリアントを rank の厳密な昇順で並べ、メディアバリアントは最大1つ、その後にユーティリティを置きます(sm:hover:bg-surface、hover:sm:bg-surface は決して使いません)。cn() はすでに正規形の文字列を生成しています。ランタイムのパーサーは順序に寛容で、メンバーシップをチェックする前に正規化しますが、ソースは正規の綴りに保たれます――そうすることで差分が最小限に保たれ、「後のものが勝つ」が左から右に読めるようになります。
失敗モード
tabula scan は、ソース中のすべてのクラストークンをランタイムと同じ文法で解析し、登録済みの集合の外にあるチェーンを**TAB-E230**として拒否します。ケースごとの修正案は次のとおりです:
| In source | Fix-it |
|---|---|
非正規の順序(hover:sm:x) | 正規の書き換えを提示する(sm:hover:x) |
未宣言のプロダクト(デフォルトプリセットでの hover:p-md) | hover × p プロダクトを tabula.config の variants.products に宣言し、リビルドする |
パラメトリックバリアント(aria-*、data-*、group-*、peer-*) | パラメトリックバリアントは v0.1 では未サポート |
アトミッククラスへのバリアント(hover:not-prose) | アトミッククラスはバリアントを取らない |
設定とビルドを守るコードがさらに2つあります:breakpoint 軸を持たないプロファイルで宣言されたメディアプロダクトは設定エラー(TAB-E170)です。不正な形式のプロダクト宣言――パラメトリック/未知のバリアントやファミリー、非正規または長すぎるプレフィックス――は TAB-E171 です。
予算
完全な閉包はスケールしません:チェーン長が2以下でも、素朴な積は数メガバイトの CSS になります。そのため生成される集合は宣言されたプロダクトに限られ、その展開自体も variants.maxChainCandidates(デフォルト 20,000)で上限が設定されます。プロダクトの展開がこの上限を超えるプロファイルは、件数、最大の5つのプロダクト、そして調整用のノブを示す TAB-E172 でビルドが失敗します――おおよそその規模を超えると、成果物は出荷可能ではなくなります。
ランタイムと残存ギャップ
ランタイムでは、cn() は未宣言のチェーンを他の未知のトークンとまったく同様に扱います:開発時には(レーベンシュタイン距離による「もしかして」の提案とともに)例外を投げ、本番では不透明なまま素通りします――CSS を一切生成しないチェーンが cn() を気づかれずにすり抜けることは、もはやありません。メンバーシップは正規形でチェックされるため、記述時の順序はランタイムでは一切問題になりません。
構造上、1つのチャネルだけが開いたままです。tabula scan はソースの glob を読みますが、スキャン対象のファイルに一度も現れないクラス文字列――globs の外にある生成ファイルの中で組み立てられたもの、あるいは実行時に構築されたもの――はそれには見えず、cn() に到達し、そこで開発時に例外を投げます。上流の防壁は ESLint の no-runtime-class-construction(リテラルなクラス文字列を書き、`hover:${x}` のような組み立てを決して行わない)であり、スキャンの glob が2つ目の防壁です。どちらも閉包性そのものではありません――それらは、閉包性を強制するゲートを文字列がすり抜けるのを防いでいるだけです。これはベースの語彙が持つのと同じ残存ギャップであり、バリアントはそれを広げません。
禁止されたメカニズム
これらはすべて、クラスを付けている要素を越えて届くセレクタにコンパイルされます。ユーティリティレイヤーでこのようなルールが1つでもあれば、ビルドは失敗します(上述の 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: (または任意の theme/[data-theme=…] バリアント) | テーマはビルド時に解決されるトークンの軸であり、ユーティリティはすでにすべてのテーマの値を保持している | トークンのユーティリティ(bg-surface)だけを使い、:root で軸属性を設定して切り替える |
@theme inline プロジェクト CSS 内 | トークンの値を使用箇所にインライン化してしまい、ルートの軸ブロックがもはやそれを再度指し示せなくなる | トークンファイルにトークンを宣言し、ビルドに @theme を出力させる |
任意値(Arbitrary values) (w-[347px]) | 名前も、所有者も、有効期限もない――そして source(none) の下ではどのみち CSS を生成しない | tabula except add |
裸の値(Bare) group / peer / @container | 「これはどの祖先のことか?」という問いに、ツリー全体を読まずには答えられない | 名前を付ける:group/card + group-hover/card: |
| 実行時に組み立てられるクラス名 | 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 にだけ通用し、それ以外には通用しませんでした。インポートされたスタイルシートの中のたった1つの .card .title { color: red } が、すべてのゲートが グリーン(green)のまま局所性を破っていたのです。
現在は、5つのルールがプロジェクトのすべての .css ファイルと、生成されたスタイルシートの両方に対して実行されます――エディタでは @tabula-css/stylelint-plugin を通じて、CI では tabula check:css(--no-css を渡さない限り tabula build --check が実行します)を通じてです。両方とも同じルールカーネルを使っているため、食い違うことはありません。
| Code | 禁止事項 | ルール |
|---|---|---|
TAB-E221 | @apply | クラスの一覧を手書きのセレクタへと合成してしまう――このプロファイルが取り除こうとしている間接参照そのもの |
TAB-E222 | 承認されたエントリスタイルシート以外での手書きルール | クラスではなくセレクタによるスタイリング |
TAB-E223 | :root/html 以外で定義された --tb-* / --d-* | T17、このシステムで最も重要なチェック |
TAB-E224 | @theme inline | T20 ――条件付きのトークンをすべてコンパイルで消し去ってしまう。shadcn エコシステムの罠 |
TAB-E225 | !important | 宣言を rank モデルの外に置いてしまう |
そのため、かつてはすべて通っていた次の3つが、今ではすべて失敗します:
@theme inline { --tb-color-accent: red; } /* TAB-E224 */
.card p { color: red; } /* TAB-E222 */
.panel { --tb-color-accent: red; } /* TAB-E223 */この領域における SPEC のルールのうち1つは、まだ未実装です:T18、ルート要素以外に置かれた軸の運び手となる属性(data-theme)です。CSS では、.panel[data-theme="dark"] セレクタは軸違反としてではなく、手書きルールとして捕捉されます――誤った名前の下での正しい結果です――そして生成されるセレクタは :root[data-theme=…] なので、そもそもそのような属性には何の効果もありません。
tabula/no-theme-variant は JS/TSX における dark:(T19)をカバーしています。
ゲートがまだカバーしていないことや、両者がどこでずれうるかを含めた詳細は CSSガバナンス を参照してください。