Skip to content

@tabula-css/merge

Tabula's runtime merge: cn(), resolve(), dyn(). Reads the registry only — no Tailwind in its import graph — and is the mathematical heart of the profile.

Install

@tabula-css/merge is a runtime dependency — part of the root README's quickstart:

bash
npm install @tabula-css/merge @tabula-css/preset

Overview

Given the registry @tabula-css/registry generates, cn() decides deterministically which of two competing utility classes wins — no heuristics, no consulting Tailwind at runtime. As Concepts § the merge: a total order, not a cascade simulation explains, the merge never simulates the browser's cascade (specificity, source order, !important); it runs a pure fold over each class's canonical slots, ordered by the registry's frozen rank. resolve() runs the same fold to produce a complete, inspectable styling model for one element — the algorithm an agent (or a test) can run to predict exactly what the browser will render. dyn() is the one sanctioned escape hatch for genuinely dynamic values (a progress bar's width) that the token pipeline could never have baked into a class.

Before any of these functions can be called, the runtime must be configured once with a generated registry (typically .tabula/registry.json, written by tabula build).

Exports

Configuration

ts
export function configure(input: string | RegistryFile): RegistryReader;
export function configureReader(reader: RegistryReader): RegistryReader;
export function getRegistry(): RegistryReader; // throws MissingRegistryError if unconfigured
export function isConfigured(): boolean;
export function reset(): void; // test isolation only

configure() accepts exactly what readRegistry() accepts — a parsed RegistryFile, a JSON string, or a filesystem path — and throws RegistryReadError (carrying a TAB-E302 diagnostic) if the registry's schema version doesn't match what this build of @tabula-css/merge understands. Call it once at application startup:

ts
import { configure } from '@tabula-css/merge';
import registry from '../.tabula/registry.json';

configure(registry);

Every other export in this package reads whatever registry was last configured; calling cn()/resolve()/dyn() before configure() throws MissingRegistryError (TAB-E303).

cn()

ts
export const MAX_CLASSES: number; // 512
export function cn(...inputs: ClassValue[]): string;
export function clearOpaqueWarnings(): void; // test isolation

The merge function. inputs accepts clsx-style values — strings, numbers, null/undefined/false (skipped), nested arrays, and objects whose truthy keys contribute their (whitespace-split) class names. cn() flattens every fragment, keeping each candidate's originating top-level fragment index; for every (pseudoElement, condition, slot) key, the class that owns it is the one with the later fragment, and only within one fragment does the registry's higher rank win. That is what makes cn(base, className) a sound override mechanism: the caller's className fragment wins regardless of its own internal rank.

ts
import { cn } from '@tabula-css/merge';

cn('p-md', 'pt-sm');   // → "p-md pt-sm"   (later fragment overrides just padding-top)
cn('pt-sm', 'p-md');   // → "p-md"         (p-md is later AND covers padding-top; pt-sm owns nothing)
cn('bg-surface', className); // → the caller's className fragment always wins its slots

A variant chain (hover:bg-accent-hover, sm:hover:p-md) counts as registered only when its canonical, ascending-rank chain prefix × the base utility's family is a declared variant product — or its exact class is a chain exception. cn() reads that from the registry through the reader's hasChain()/hasChainClass() accessors: a chain outside the registered set emits no CSS (the preset only emits rules for declared products), so cn() treats it exactly like any other unknown class rather than passing it silently — the TAB-E300 path below. Parametric variants (group-*/peer-*/aria-*/data-*) are out of scope of the closure and are not gated here.

cn() is total in production for every input: an unknown class (TAB-E300, including an undeclared variant chain) passes through opaque with a one-time console.error, and an oversized candidate list (TAB-E305, over MAX_CLASSES) merges anyway. In development, both cases throw instead — UnknownClassError or MaxClassesError — along with a merge-soundness self-check (T2): cn() independently recomputes each slot's winner by rank and throws MergeSoundnessError (TAB-E301) if the registry's declared order disagrees, meaning the registry itself violates its own invariants. A class parsing to a non-finite rank throws RankIntegrityError in both modes — there's no correct fallback for it, unlike an ordinary unknown class. clearOpaqueWarnings() resets the once-per-process warning bookkeeping (used by test suites, not application code).

resolve()

ts
export function resolve(classString: string, axes?: AxisState, vars?: DynamicVars): ResolvedStyle;
export function resolveAxisValue(value: unknown, axes: AxisState): string;

resolve() runs the same order-independent fold as cn(), but over a single class string, at a given axis state (e.g. { theme: 'dark' }), and returns a complete inspectable model rather than a merged string — no cascade simulation, no DOM. Unlike cn(), unknown classes are reported, not thrown: resolve() is meant to model whatever a class string actually contains, including drift between source and registry.

ts
export interface ResolvedStyle {
  readonly base: Readonly<Record<string, string>>;
  readonly conditions: readonly ResolvedCondition[]; // media / self / group / peer, by condition rank
  readonly ambient: Readonly<Record<string, { readonly value: string; readonly source: string }>>;
  readonly atomic: readonly ResolvedAtomic[];
  readonly dependencies: readonly ResolvedDependency[]; // declared group/peer/container dependencies
  readonly unknown: readonly string[];
  readonly warnings: readonly Diagnostic[]; // e.g. TAB-W401 for a registered exception
}
ts
import { resolve } from '@tabula-css/merge';

resolve('bg-surface hover:bg-surface-raised', { theme: 'dark' });
// → { base: { 'background-color': '#0b0b0c' },
//     conditions: [{ condition: 'hover', when: '&:where(:hover)', declarations: { 'background-color': '#1a1a1c' } }],
//     … }

The optional third argument, vars, models a component's live dyn() contribution — a DynamicVars record substituted at inline-style precedence (above class-set custom properties, above the registry's initialValue), exactly matching where an inline style sits in the cascade. It is validated by dyn() itself, so an invalid var throws the same errors dyn() would. Omitting it leaves every result byte-identical to calling resolve() without dynamic values. resolveAxisValue() is the lower-level helper that resolves one axis-mapped (or literal) value against an AxisState — exposed for callers building their own partial models.

dyn()

ts
export type DynamicVars = Readonly<Record<`--d-${string}`, string>>;
export const MAX_DYNAMIC_VALUE_LENGTH: number; // 256
export function dyn(vars: DynamicVars): Record<string, string>;

The sanctioned inline-style escape hatch (Draft A §4.4). dyn() validates every key against the registry's --d-* dynamic custom properties (an unregistered key throws UnregisteredDynamicPropertyError/TAB-E304 in development, is dropped with a one-time warning in production) and every value against a bounded shape filter: it must be a string, no longer than MAX_DYNAMIC_VALUE_LENGTH, containing none of ; { } " ' < > & \ @ or a control character — punctuation that could terminate a CSS declaration or escape a style="…" attribute. A rejected value throws InvalidDynamicValueError/TAB-E306 in development, or is dropped in production. The class string itself stays static and registered (e.g. w-progress); dyn() only supplies the custom-property values that class reads.

tsx
import { dyn, cn } from '@tabula-css/merge';

<div className={cn('w-progress')} style={dyn({ '--d-progress': `${percent}%` })} />

The value filter is deliberately a shape filter, not a CSS type checker — whether "73.4%" is a legal <percentage> for that specific property is what the @property syntax (declared in tabula.config.json and enforced at build time via TAB-E127) actually decides.

variants()

ts
export interface VariantsConfig<V extends VariantGroups> {
  readonly base?: string;
  readonly variants: V;
  readonly compoundVariants?: readonly CompoundVariant<V>[];
  readonly defaultVariants?: VariantProps<V>;
}
export type VariantsFn<V extends VariantGroups> = (props?: VariantCallProps<V>) => string;
export function variants<V extends VariantGroups>(config: VariantsConfig<V>): VariantsFn<V>;

A CVA-equivalent, statically-analyzable variant composer. Every class in the config is a plain literal string (so the ESLint plugin's sink-harvester reads it exactly like a cva/tv config), and composition happens through cn() — so the result carries the same soundness, canonicalization, and idempotence guarantees as any other cn() call.

ts
import { variants } from '@tabula-css/merge';

const button = variants({
  base: 'inline-flex rounded-md',
  variants: {
    intent: { primary: 'bg-brand ink-on-brand', ghost: 'ink-text' },
    size: { sm: 'px-sm', md: 'px-md' },
  },
  compoundVariants: [{ intent: 'primary', size: 'sm', class: 'gap-xs' }],
  defaultVariants: { intent: 'primary', size: 'md' },
});

button({ intent: 'ghost', className }); // caller's className is merged last, so it wins

An option naming a value a group doesn't define throws in development and is ignored (that group contributes nothing) in production, matching cn()'s fail-visible-in-dev / fail-open-in-prod stance.

Errors

ts
export class TabulaMergeError extends Error {
  readonly code: string;
  readonly diagnostics: readonly Diagnostic[];
}

Every thrown error extends TabulaMergeError and carries a stable TAB-Exxx code plus structured Diagnostics from @tabula-css/core:

ErrorCodeThrown byWhen
UnknownClassErrorTAB-E300cn()An unregistered class, in development (with Levenshtein-nearest suggestions).
MergeSoundnessErrorTAB-E301cn()The T2 dev self-check finds the registry's declared rank order disagrees with itself.
RankIntegrityErrorTAB-E301cn()A class resolves to a non-finite rank — fires in production too, since there's no safe fallback.
MissingRegistryErrorTAB-E303getRegistry()cn()/resolve()/dyn() called before configure().
MaxClassesErrorTAB-E305cn()Candidate count exceeds MAX_CLASSES, in development.
UnregisteredDynamicPropertyErrorTAB-E304dyn()A key is not a registered --d-* dynamic custom property, in development.
InvalidDynamicValueErrorTAB-E306dyn()A value fails the shape filter (wrong type, too long, unsafe punctuation), in development.

Types

ts
export type ClassName = string & { readonly __tabulaClassName?: never };
export type ClassValue = string | number | boolean | null | undefined | ClassDictionary | ClassValue[];
export interface ClassDictionary { readonly [className: string]: boolean | null | undefined; }
export type { RegistryReader, RegistryFile } from '@tabula-css/registry/read';

ClassName is a documentary brand (its marker is optional, so a plain string literal is still assignable) rather than a hard nominal type — it marks a className prop as a sanctioned pass-through the ESLint plugin's no-runtime-class-construction rule recognizes, without requiring callers to wrap every string.

Lower-level helpers

Exposed for tooling built on top of the merge (the ESLint plugin's sink analysis, custom class-string inspectors): parseToken() (parses one class token — variant chain plus utility — against a registry, never throwing), flatten() (the clsx-style input flattening cn() uses internally), levenshtein()/nearest() (edit-distance "did you mean?" suggestions), isDev() (the NODE_ENV-based dev/production check every guard in this package uses), and warnOnce()/clearWarnOnce() (the shared production degradation channel — one console.error per unique key, ever).

See also

Released under the MIT License.