Concepts
Locality and the Ten Laws
Tabula's central claim: an element's styling is its own class string — nothing inherits or cascades from an ancestor, a sibling, or stylesheet order, except where the element's own class string declares a non-locality (group/name, type-inherit, prose). Needing to read a parent to know how a child renders is treated as a bug class, not a style choice.
That claim is encoded as ten rules, printed verbatim (and enforced) in every generated .tabula/llms.txt:
- Only classes in
vocabulary.txtexist; anything else emits no CSS, silently. A class built at runtime (`p-${n}`) never works — Tailwind scans source text. - Reverse-look up a class by intent, not memory (MCP
find_class_for). - Never use arbitrary values (
w-[347px]);tabula except addmints a named class instead. - Never write
dark:— themes are token axes;bg-surfacealready covers every theme. - Higher
rankwins. That is the entire cascade — attribute order changes nothing. - Two classes writing one property in one static string is a lint error, not a merge.
- Compose with
cn(base, …, className): the caller'sclassNamegoes last and wins. - Every text-bearing element carries one
type-*and oneink-*, or saystype-inherit. - No parent styles its children:
space-*,divide-*,*:,**:,[&>*]:,in-*don't exist. Usegap-*, or put the utility on the child. - Cross-element dependencies must be named (
group/card, never a baregroup).
The registry is ground truth
registry.json is not documentation of the profile — it is the profile. Every other artifact (vocabulary.txt, llms.txt, the ESLint plugin, @tabula-css/merge) is a view derived from it, and where two artifacts disagree, the registry is defined to be right. It is produced by @tabula-css/registry, which parses Tailwind's own compiled CSS output (via one of two independent backends — the design-system API, or a PostCSS probe-sheet walk — cross-checked against each other in CI) rather than re-implementing Tailwind's utility grammar.
tokens.resolved.json is the companion view for values: every token's fully-resolved literal per axis, and — so the forward build is information-preserving — the source token's $deprecated, $extensions (including $extensions.tabula.contrastWith accessibility contracts and foreign vendor namespaces), and a $alias marker recording a top-level alias reference. A byte-identical tabula.config.json is copied beside it, so a frozen .tabula/ states exactly what built it.
A class entry, in full (bg-surface in examples/reference-ui):
"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— the numeric ids of the canonical longhand CSS properties (and profile-internal custom properties) a class owns. The registry's top-levelslotsmap resolves an id back to a property name (e.g.9 → background-color);slotAliasesmaps logical properties onto the same physical slot where the two are equivalent (padding-block-start ≡ padding-top).rank— a single integer total order over every registered class (see below).spec— the selector's CSS specificity, normalized to(0, 1, 0)for every plain utility ((0, 1, 1)for pseudo-elements) so specificity carries no information — onlyrankdecides.locality—L0for a class that only ever styles the element it's on. The build fails if any utility classifiesL1/L2(a selector that reaches a sibling or descendant) outside a documented quarantine.composed(on a declaration, not shown above) —truewhen the declaration's value is avar(--tb-*)reference rather than a literal. A class whose only declarations are composed owns no slots for them (SPEC J1 amendment) — this is howshadow-mdandring-2can compose without conflicting: they write different custom properties, not the samebox-shadowslot.
The merge: a total order, not a cascade simulation
Every registered class has a unique rank (lex(−breadth, familyIndex, valueIndex) — roughly: narrower utilities before wider ones, then family, then value). cn() and resolve() never simulate the browser's cascade (selector specificity, source order, !important); they run a pure fold:
- Split every class string on whitespace; parse each token into
(variants, utility). - For each declaration of each class, compute a slot key:
(pseudoElement, condition, slot). - The highest-rank declaration wins each key. Order of iteration is irrelevant.
cn()additionally orders by fragment first: a later fragment beats an earlier one, and only within one fragment does rank decide. This is what makescn(base, className)a sound override mechanism — the caller's fragment wins regardless of its own internal rank.- Survivors are sorted by ascending rank for output, so the printed class string reads left-to-right as "later wins" (the Reading Rule), and atomic/unknown classes are appended afterward in author order.
Worked examples (verified against packages/merge/test and 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)In development, cn() asserts its own soundness on every call (T2): it independently recomputes each slot's winner by rank and throws TAB-E301 if the registry's declared rank order disagrees with the winner it just picked — a corrupt or hand-edited registry fails loudly rather than silently misrendering.
Profile levels: base vs strict
Typography is the one area with two shipped strictness levels, set via profileLevel in tabula.config.json (default: base):
baseregisters the ordinarytext-*/font-*/leading-*/tracking-*families, contained three ways: every inheritable profile-owned CSS property gets exactly one:rootdefault (checked —TAB-E220if one is missing); an inheritable-property utility is only legal on a text-leaf tag or an element carrying ascope-textmarker (tabula/inherited-property-boundary, autofixable); and the set of values that can inherit is closed to registered tokens.strictreplaces partial typography withtype-*— an 8-property bundle (font-family, size, weight, style, line-height, letter-spacing, text-transform, font-variant-numeric) applied as one class — plus a separateink-*for color. Partial classes (text-smalone) are not registered at all: a text element states its complete typographic identity in one declaration, or saystype-inheritexplicitly.
examples/reference-ui uses strict (see its tabula.config.json); docs/benchmark.md describes the study designed to measure which level actually helps an agent more.
Theming: axes, never dark:
A theme is not a CSS variant — it's an axis declared once in tabula.config.json and resolved into every token's value at build time:
"color.surface": { "$value": { "$axis": "theme", "light": "#ffffff", "dark": "#0b0b0c" } }This must be total: every declared axis member (light, dark, …) needs a literal, with no fallback (TAB-E113) — a token that silently keeps its light value in dark mode is the archetypal invisible bug the totality rule exists to rule out. The build emits one @property per custom property (inherits: false, so a value can never be redefined by an ancestor) plus root-scoped axis blocks (:root[data-theme="dark"] { --tb-color-surface: #0b0b0c; }, with a prefers-color-scheme media fallback for first paint). A class like bg-surface is already every theme at once — writing dark:bg-surface-dark would reintroduce exactly the selector-conditional branch the axis model exists to remove, which is why dark: (and any [data-theme=…]: variant) is a banned mechanism, and why @theme inline is banned in project CSS (it inlines a value at its use site, so the root axis block can no longer re-point it).
Variants
A variant chain (hover:bg-accent-hover, sm:p-md, sm:hover:bg-surface-raised) prefixes a base utility with one or more variants. Because the preset compiles with @import "tailwindcss" source(none) and an explicit @source inline list, a chain emits CSS only if it is registered — exactly like a base class. Closure (T3) is unchanged; the registered set is simply widened to include declared variant products, the same way arbitrary values become legal only through a registered exception. CSS stays a pure function of the profile (the registry), never of scanned source — so a new chain usage is a loud tabula scan error, never a silently missing rule.
Declared products
A product is a chain prefix paired with the families it may prefix, declared in 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"
}The build materialises every declared product into the exact chain-class list and writes it to the registry (variantProducts — a compact prefix → families map — plus chainCount), source.css's appended chain layer (one literal @layer utilities block of registry-template rules, ordered by effectiveRank — chains are deliberately NOT listed in @source inline, which stays base-only), types.d.ts, and llms-full.txt. A one-off chain that does not warrant a whole product rides the existing exception mechanism: tabula except add --chain hover:bg-accent-hover … registers that single chain with the same reason/owner/expiry paperwork as a value exception.
Presets
When the variants section is absent the interaction preset is the default — chosen so a project styles its interactive states out of the box:
- every self-state variant
{hover, focus, focus-visible, focus-within, active, disabled}× families{bg, border, ink, outline, ring, shadow, decoration, accent, caret, fill, stroke, opacity, (static)}; - plus
{placeholder}×{ink, caret, accent}.
first/last/odd/even and the media variants are not in the preset — declare them explicitly. all-len1 is every enabled named variant × every non-atomic family (large, and budget-checked — see below); none declares nothing, and only explicit products apply.
Canonical order
A chain is registered in exactly one spelling: variants in strictly ascending rank order, at most one media variant, then the utility (sm:hover:bg-surface, never hover:sm:bg-surface). cn() already emits canonical strings; the runtime parser is order-tolerant and canonicalises before it checks membership, but source is held to the canonical spelling so diffs stay minimal and "last wins" reads left-to-right.
Failure modes
tabula scan parses every class token in source with the same grammar as the runtime and rejects a chain outside the registered set as TAB-E230, with a fix-it per case:
| In source | Fix-it |
|---|---|
Non-canonical order (hover:sm:x) | names the canonical rewrite (sm:hover:x) |
An undeclared product (hover:p-md under the default preset) | declare product hover × pin tabula.configvariants.products, then rebuild |
A parametric variant (aria-*, data-*, group-*, peer-*) | parametric variants are not supported in v0.1 |
A variant on an atomic class (hover:not-prose) | atomic classes take no variants |
Two more codes guard the config and the build: a media product declared on a profile with no breakpoint axis is a config error (TAB-E170); a malformed product declaration — a parametric/unknown variant or family, a non-canonical or over-long prefix — is TAB-E171.
Budget
Full closure does not scale: at chain length ≤ 2 the naïve product is megabytes of CSS. So the emitted set is the declared products, and their expansion is itself capped by variants.maxChainCandidates (default 20,000). A profile whose products expand past the cap fails the build with TAB-E172, stating the count, the five largest products, and the knob — beyond roughly that size the artifact stops being shippable.
Runtime and the residual gap
At runtime cn() treats an undeclared chain the same as any unknown token: it throws in development (with a Levenshtein "did you mean") and passes through opaquely in production — a chain that renders no CSS can no longer slip through cn() unnoticed. Membership is checked on the canonical form, so authored order never matters at runtime.
One channel remains open, by construction. tabula scan reads your source globs; a class string that never appears in a scanned file — assembled in a generated file outside the globs, or built at runtime — is invisible to it, reaches cn(), and dev-throws there. The upstream fence is ESLint's no-runtime-class-construction (you write literal class strings, never `hover:${x}`), and the scan globs are the second. Neither is closure itself; they are what keep a string from evading the gate that enforces it. This is the same residual gap the base vocabulary has — variants do not widen it.
Banned mechanisms
Every one of these compiles to a selector that reaches past the element wearing the class. The build fails on any such rule in the utilities layer (the locality: "L0" check above); the table exists so an agent understands why, not just that it's rejected — the goal is that knowing the reason stops the mechanism from being reinvented in a different disguise.
| Mechanism | Why it's banned | Use instead |
|---|---|---|
space-x-* / space-y-* | Emits & > * + * — a parent reaching in to style its children | gap-* on the flex/grid parent |
divide-x-* / divide-y-* | Same shape as space-*, for borders | A border utility on each child |
*: (child variant) | Styles every direct child from the parent | Put the utility on each child |
**: (descendant variant) | Unbounded descendant reach | Put the utility on each element that needs it |
[&>*]: / [&~*]: / [&+*]: | An arbitrary variant carrying a combinator — the same reach, spelled differently | Style the target element directly |
in-* | Matches an ancestor's state; the element depends on a parent that never declared the relationship | A named group/card + group-hover/card: |
rtl: / ltr: | Direction is a token axis, not a variant | Logical utilities: ps-*/pe-*, ms-*/me-*, start-*/end-* |
pl-* pr-* ml-* mr-* left-* right-* border-l-* border-r-* text-left text-right | Physical inline-axis utilities are not registered — they emit no CSS at all | ps-* pe-* ms-* me-* start-* end-* border-s-* border-e-* align-start align-end |
dark: (or any theme/[data-theme=…] variant) | Theme is a token axis resolved at build; the utility already carries every theme's value | Just the token utility (bg-surface); switch by setting the axis attribute at :root |
@theme inline in project CSS | Inlines a token's value at its use site, so the root axis block can no longer re-point it | Declare the token in the token file; let the build emit @theme |
Arbitrary values (w-[347px]) | No name, no owner, no expiry — and source(none) means it wouldn't emit CSS anyway | tabula except add |
Bare group / peer / @container | "Which ancestor is this?" is unanswerable without reading the whole tree | Name it: group/card + group-hover/card: |
| A class name built at runtime | Tailwind scans source text; a name assembled at runtime is never scanned | A literal lookup map in a *.classmap.ts file |
Two of these are enforced as a registry-independent floor regardless of build state (packages/eslint-plugin/src/banlist.ts: space-*, divide-*, *:, **:, arbitrary combinators, in-*, rtl:/ltr:) so they fire even without a generated registry; the rest are closure consequences of the vocabulary simply not registering them.
Every entry above is checked in source files by ESLint (.ts/.tsx), by tabula scan (which adds .js, .mdx, .html, .vue, .svelte, .astro and more), or by the vocabulary simply not emitting — and, since the CSS governance layer, in your stylesheets too. See below.
Project CSS is governed too
The table above says what the profile forbids. This section says what it checks in .css files, because until the governance layer existed the answer was nothing: no command in the system opened a stylesheet you wrote, so every mechanism ban above held for .tsx and for nothing else. A single .card .title { color: red } in an imported stylesheet broke locality with every gate green.
Five rules now run over every project .css file and over the emitted stylesheets — in your editor through @tabula-css/stylelint-plugin, and in CI through tabula check:css (which tabula build --check runs unless you pass --no-css). Both use the same rule kernel, so they cannot disagree.
| Code | Forbidden | Rule |
|---|---|---|
TAB-E221 | @apply | Composes a class list into a hand-written selector — the indirection the profile removes |
TAB-E222 | Hand-authored rules outside the sanctioned entry stylesheet | Styling by selector rather than by class |
TAB-E223 | --tb-* / --d-* defined anywhere but :root/html | T17, the single most important check in the system |
TAB-E224 | @theme inline | T20 — compiles every conditional token away; the shadcn-ecosystem trap |
TAB-E225 | !important | Puts a declaration outside the rank model |
So all three of these now fail, where every one of them used to pass:
@theme inline { --tb-color-accent: red; } /* TAB-E224 */
.card p { color: red; } /* TAB-E222 */
.panel { --tb-color-accent: red; } /* TAB-E223 */One SPEC rule in this area is still unimplemented: T18, the axis carrier attribute (data-theme) on a non-root element. In CSS a .panel[data-theme="dark"] selector is caught as a hand-authored rule rather than as an axis violation — the right outcome under the wrong name — and the emitted selectors are :root[data-theme=…], so such an attribute has no effect in the first place.
tabula/no-theme-variant covers dark: in JS/TSX (T19).
Full detail, including what the gates still do not cover and where the two can drift: CSS governance.