Skip to content

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:

  1. Only classes in vocabulary.txt exist; anything else emits no CSS, silently. A class built at runtime (`p-${n}`) never works — Tailwind scans source text.
  2. Reverse-look up a class by intent, not memory (MCP find_class_for).
  3. Never use arbitrary values (w-[347px]); tabula except add mints a named class instead.
  4. Never write dark: — themes are token axes; bg-surface already covers every theme.
  5. Higher rank wins. That is the entire cascade — attribute order changes nothing.
  6. Two classes writing one property in one static string is a lint error, not a merge.
  7. Compose with cn(base, …, className): the caller's className goes last and wins.
  8. Every text-bearing element carries one type-* and one ink-*, or says type-inherit.
  9. No parent styles its children: space-*, divide-*, *:, **:, [&>*]:, in-* don't exist. Use gap-*, or put the utility on the child.
  10. Cross-element dependencies must be named (group/card, never a bare group).

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):

json
"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-level slots map resolves an id back to a property name (e.g. 9 → background-color); slotAliases maps 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 — only rank decides.
  • localityL0 for a class that only ever styles the element it's on. The build fails if any utility classifies L1/L2 (a selector that reaches a sibling or descendant) outside a documented quarantine.
  • composed (on a declaration, not shown above) — true when the declaration's value is a var(--tb-*) reference rather than a literal. A class whose only declarations are composed owns no slots for them (SPEC J1 amendment) — this is how shadow-md and ring-2 can compose without conflicting: they write different custom properties, not the same box-shadow slot.

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:

  1. Split every class string on whitespace; parse each token into (variants, utility).
  2. For each declaration of each class, compute a slot key: (pseudoElement, condition, slot).
  3. The highest-rank declaration wins each key. Order of iteration is irrelevant.
  4. cn() additionally orders by fragment first: a later fragment beats an earlier one, and only within one fragment does rank decide. This is what makes cn(base, className) a sound override mechanism — the caller's fragment wins regardless of its own internal rank.
  5. 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):

js
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):

  • base registers the ordinary text-* / font-* / leading-* / tracking-* families, contained three ways: every inheritable profile-owned CSS property gets exactly one :root default (checked — TAB-E220 if one is missing); an inheritable-property utility is only legal on a text-leaf tag or an element carrying a scope-text marker (tabula/inherited-property-boundary, autofixable); and the set of values that can inherit is closed to registered tokens.
  • strict replaces partial typography with type-* — an 8-property bundle (font-family, size, weight, style, line-height, letter-spacing, text-transform, font-variant-numeric) applied as one class — plus a separate ink-* for color. Partial classes (text-sm alone) are not registered at all: a text element states its complete typographic identity in one declaration, or says type-inherit explicitly.

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:

json
"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:

jsonc
"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 sourceFix-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.

MechanismWhy it's bannedUse instead
space-x-* / space-y-*Emits & > * + * — a parent reaching in to style its childrengap-* on the flex/grid parent
divide-x-* / divide-y-*Same shape as space-*, for bordersA border utility on each child
*: (child variant)Styles every direct child from the parentPut the utility on each child
**: (descendant variant)Unbounded descendant reachPut the utility on each element that needs it
[&>*]: / [&~*]: / [&+*]:An arbitrary variant carrying a combinator — the same reach, spelled differentlyStyle the target element directly
in-*Matches an ancestor's state; the element depends on a parent that never declared the relationshipA named group/card + group-hover/card:
rtl: / ltr:Direction is a token axis, not a variantLogical utilities: ps-*/pe-*, ms-*/me-*, start-*/end-*
pl-* pr-* ml-* mr-* left-* right-* border-l-* border-r-* text-left text-rightPhysical inline-axis utilities are not registered — they emit no CSS at allps-* 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 valueJust the token utility (bg-surface); switch by setting the axis attribute at :root
@theme inline in project CSSInlines a token's value at its use site, so the root axis block can no longer re-point itDeclare 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 anywaytabula except add
Bare group / peer / @container"Which ancestor is this?" is unanswerable without reading the whole treeName it: group/card + group-hover/card:
A class name built at runtimeTailwind scans source text; a name assembled at runtime is never scannedA 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.

CodeForbiddenRule
TAB-E221@applyComposes a class list into a hand-written selector — the indirection the profile removes
TAB-E222Hand-authored rules outside the sanctioned entry stylesheetStyling by selector rather than by class
TAB-E223--tb-* / --d-* defined anywhere but :root/htmlT17, the single most important check in the system
TAB-E224@theme inlineT20 — compiles every conditional token away; the shadcn-ecosystem trap
TAB-E225!importantPuts a declaration outside the rank model

So all three of these now fail, where every one of them used to pass:

css
@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.

Released under the MIT License.