@teacss/classes
v0.4.3
Published
TeaCSS class composition — typed recipes, nested class-value joining, and semantic conflict resolution.
Downloads
2,469
Readme
@teacss/classes
Typed class composition and semantic merging for TeaCSS.
Purpose
This package joins nested class-value inputs and removes earlier TeaCSS tokens that are overridden by later tokens under the same condition sequence. The algorithm is independent of CSS generation and ships with no runtime dependencies: callers provide vocabulary-specific conflict metadata and optional merger plugins.
It is the low-level package for custom integration and merger authors.
Applications and component libraries built on the official vocabulary use the
pre-bound cn and recipe exports from teacss. This package does not
generate CSS, validate whether a class value is supported, or own the Standard
and icon vocabularies.
Recipe composition
Most applications should use recipe from teacss. It is pre-bound to
the official standard-and-icon cn, so conflicts are resolved inside each
recipe output.
Use a primitive string for the top-level className when a recipe styles one
element. Variant choices and compound outputs are strings, and calling the
recipe returns the merged string directly. The call also accepts caller
className:
import { recipe, type ClassProp, type VariantProps } from "teacss";
const badge = recipe({
className: "d:inline-flex align-items:center rd:full p-x:2",
variants: {
tone: {
neutral: "bg-color:gray-100 text-color:gray-900",
accent: "bg-color:blue-500 text-color:white",
},
},
compoundVariants: [
{
tone: "accent",
className: "font-weight:700",
},
],
});
badge({
tone: "accent",
className: "p-x:3",
}); // "d:inline-flex align-items:center rd:full bg-color:blue-500 text-color:white font-weight:700 p-x:3"
type BadgeProps = VariantProps<typeof badge> & ClassProp;
function badgeClassName({ className, ...variants }: BadgeProps) {
return badge({ ...variants, className });
}Use a non-empty object for the top-level className when a recipe has multiple
elements. There is no distinguished or required root key: every declared key
is an ordinary named class and becomes a resolver in the result. Variant
choices target those names with maps; compounds support a targeted map or a
shared broadcast:
const button = recipe({
className: {
container: "d:inline-flex align-items:center p:2 p:4@md",
icon: "inline-size:4x",
label: "",
},
variants: {
size: {
small: {
container: "p:2",
icon: "inline-size:3x",
},
large: {
container: "p:4",
icon: "inline-size:5x",
},
},
disabled: {
true: {
container: "opacity:50 pointer-events:none",
},
},
},
defaultVariants: {
size: "small",
disabled: false,
},
compoundVariants: [
{
size: "large",
disabled: true,
className: {
container: "font-weight:700",
icon: "inline-size:6x",
},
},
{
disabled: true,
classKeys: ["container", "icon"],
className: "opacity:80",
},
],
});
const large = button({
size: "large",
});
large.container({
disabled: true,
className: "p:6",
});
large.icon();
large.label();
type ButtonProps = VariantProps<typeof button> & ClassProp;
function buttonClassName({ className, ...variants }: ButtonProps) {
const classes = button(variants);
return classes.container({ className });
}For an object-form recipe, the recipe call accepts only shared variant
selections. Each resolver accepts all recipe selections plus ClassProp; its
local selections and className apply only to that resolver invocation.
Compounds are re-evaluated against that local selection without mutating the
captured recipe result or sibling resolvers. A string-form recipe has no
resolver layer, so its recipe call accepts className and returns a string.
Multi-element variant choices are always class-target maps. Each compound uses
exactly one of two forms: a non-empty className target map, or a
primitive-string className paired with a non-empty classKeys array that
broadcasts the class to every listed effective named class. classKeys may
contain declared or inherited class names and must be dense, duplicate-free,
and free of unknown names. Matching entries contribute to the selected classes
in compound declaration order. The two forms cannot be combined. classKeys
is reserved as a variant name, while parts, styles, and styleKeys are
available as ordinary variant names.
The field name className is intentionally reused only at distinct
boundaries: top-level definition.className supplies base classes and selects
string or object mode; compoundVariants[].className supplies a compound
output; and caller className is accepted by a single-element recipe call or a
named resolver. A multi-element recipe call does not accept caller
className.
The one-sided disabled variant above is boolean-capable. true is
branch-backed and emits its fragment; boolean false is a distinct branchless
state that emits no variant fragment but remains available to defaults,
resolver-local selections, and compound matching. The undeclared string
"false" is not an alias for that branchless state.
Responsive and stateful behavior stays in static TeaCSS class values using
suffix conditions such as p:4@md and bg-color:blue-600@hover; recipe props
do not introduce responsive objects.
Recipes extend one parent created by the exact same reciper. An extending child
may omit className to inherit the parent's string or object mode, base
fragments, and existing named classes. If a child declares className, it must
preserve that mode; an object child may add new names:
const primaryButton = recipe({
extend: button,
variants: {
emphasis: {
strong: {
container: "font-weight:700",
},
},
},
});
const strongBadge = recipe({
extend: badge,
className: "font-weight:700",
});Typed definitions require one finite, statically stable schema. Optional,
union-valued, or open index-signature key sets for object-form className,
variants, or variant choices are rejected because they could promise
resolvers or selections absent from the runtime branch. A string | object
className value and a union-typed extend parent must be narrowed before
calling recipe(). Finite required named interfaces are accepted without an
artificial string index signature. __proto__ is reserved as a statically
unsafe schema name because ordinary object-literal syntax does not create a
predictable own property for it; an explicitly constructed null-prototype
runtime record can still carry that own name safely.
Every definition without extend requires its own className field. An
explicitly declared object className value must contain at least one key. The
retired top-level definition field styles and former nested compound selector
parts are not compatibility aliases; broadcast compounds use
compoundVariants[].classKeys.
Reciper creation
@teacss/classes exports the createReciper factory and no pre-bound
reciper. Each integration binds its own merger once and exports the
result under the name appropriate for that integration:
import { createReciper, createMerger } from "@teacss/classes";
export const merge = createMerger({ conflicts, plugins });
export const recipe = createReciper({ merge });
export const libraryButton = recipe({
className: "p:2",
});createReciper accepts the required merge field; there is no default merger
and no merger alias. Extension identity belongs to the exact reciper function,
not merely to the merger. Its return type is the type-only Reciper
export, so an integration can re-export its reciper without inlining the
complete generic recipe contract into generated declarations.
Libraries that export official recipes for downstream extension peer-depend on
and externalize teacss, which owns the official reciper. A custom integration
peer-depends on @teacss/classes and exports the exact reciper used to create
its parents. Consumers deduplicate that owning integration. Recreating a
reciper, loading another physical or versioned runtime, or loading the parent
and reciper from different provider-package instances breaks extension
identity even when declarations and merger behavior match. Deduplicating only
@teacss/classes does not repair a duplicated custom-reciper provider.
VariantProps extraction is type-only and needs no runtime identity; only
extend has this exact-identity requirement.
Class merging
bun add @teacss/classesimport { createMerger } from "@teacss/classes";
const cn = createMerger({
conflicts: {
keywords: {
flex: "display",
hidden: "display",
},
overlaps: {
p: ["padding-top", "padding-right", "padding-bottom", "padding-left"],
"p-x": ["padding-left", "padding-right"],
},
},
});
cn("p:4 flex", "p:8"); // "flex p:8"
cn("p:4", "p-x:8"); // "p:4 p-x:8"
cn("p:4", "p-x:8!"); // "p:4 p-x:8!"
cn('content:["hello world"]', 'content:["good bye"]'); // 'content:["good bye"]'Attached [] literal regions keep their internal whitespace in one token and
protect braces and semicolons from group expansion. Braces inside attached
() CSS functions are likewise not mistaken for groups. Top-level
{p:4;m:2}@hover group syntax still expands normally.
For the standard TeaCSS vocabulary, use the pre-bound merger instead:
import { cn } from "@teacss/preset-standard/merge";Merger API
createMerger(options?) returns a callable Merger that accepts strings,
mutable or readonly nested arrays and tuples, conditional class dictionaries,
falsy values, numbers, and tagged templates. It preserves the order of
surviving tokens; it does not sort them. Finite named interfaces are valid
class dictionaries and conflict tables; runtime protocol validation still
checks every supplied conflict atom.
Tagged-template literal segments and resolved expressions concatenate exactly. Put whitespace in the template wherever two class tokens need a boundary:
cn`p:${0}`; // "p:0"
cn`p:4@${"hover"}`; // "p:4@hover"
cn`${"p:4"} ${"m:2"}`; // "p:4 m:2"Options:
conflicts: keyword and shorthand/longhand conflict footprints.overlap:"precise"by default, or"symmetric"for last-intersection wins between equal-importance tokens. Differing importance still requires full winner coverage.plugins: ordered, uniquely named plugins with optional staticconflicts, token transforms, dynamic groups, drop-only finalizers, and pair resolvers.cache: one input LRU shared by normal and tagged-template calls, orfalseto disable caching.cache.maxdefaults to1500and must be a finite non-negative integer;0disables caching.
Each static conflict value is one atom or a readonly atom array. Static metadata is composed once from top-level options and then plugins. Identical atom sets deduplicate; a different set for the same keyword or declaration key is a protocol error. Keyword and declaration keys are separate namespaces. Shared CSS atoms use CSS property names; a third-party private atom must begin with that plugin's unique name.
import {
createMerger,
MERGE_OPAQUE,
type MergerPlugin,
} from "@teacss/classes";
const plugin: MergerPlugin = {
name: "example-icons",
conflicts: {
keywords: {},
overlaps: { icon: "example-icons:icon" },
},
group(token) {
if (token.keyword || token.prefix !== "icon") return null;
return token.value?.includes("-") ? null : MERGE_OPAQUE;
},
};
const cn = createMerger({ plugins: [plugin] });MERGE_OPAQUE is terminal classification. Different opaque raw strings survive;
an exact duplicate keeps only its last occurrence and never enters finalizers or
pair resolution. Import and return the named marker rather than raw false.
Token transforms receive a readonly token list and a run-local viewToken()
function. Their result must be an array of individual non-empty class tokens;
whitespace is valid only inside an attached [] literal region. Transform
output is not split or group-expanded again. Dynamic
group() hooks return atoms, MERGE_OPAQUE, or null. finalize() receives
current non-opaque occurrences with stable ids and may only return ids to drop;
null and [] both drop nothing. Every returned id must be an integer, unique,
and present in that exact input. Core validates the whole result before applying
any drop. resolve() may return "earlier", "later", "none", or null.
Its context reports real full coverage in both directions through covers
(later over earlier) and earlierCoversLater, independently of symmetric mode.
A resolver is global by default. A plugin may add resolveFamily(token) to
scope it: the family hook runs once per non-opaque occurrence, and resolve()
is considered only when both tokens return the same non-empty family. A
resolveFamily hook requires a resolve hook. Use this metadata whenever a
resolver owns a finite semantic family. A run made entirely of one unowned
static prefix, such as standard p:*, can then use the linear winner path;
dynamic groups and resolver-owned families still use pair resolution.
The package exports the MergerProtocolError class and the
MergerProtocolErrorCode type. Errors expose code plus optional plugin and
hook fields. Stable codes distinguish malformed options, cache bounds,
conflict metadata, plugin names and hooks, static conflicts, and invalid
transform, group, finalizer, resolver-family, or resolver results. Hook
exceptions are propagated unchanged. A failed run writes no input-LRU entry.
Only own option, cache, conflict-shape, plugin-name, and plugin-hook fields are
used; inherited prototype fields cannot alter a merger. Plugin arrays, own hook
references, static records, and atom arrays are snapshotted when
createMerger() runs. Array-shaped protocol results are validated from a fixed
length and own numeric indexes; custom iterators and inherited sparse entries
are not trusted. Every accepted array length must be an integer from 0 through
4294967294; malformed plugin, metadata, and hook arrays retain their
surface-specific protocol codes, while malformed class-value and tagged-template
arrays report INVALID_ARRAY_LENGTH. Hooks are called with
this === undefined and should be pure. If a hook closes over mutable state,
use cache: false.
Conflict Semantics
Only tokens with the same ordered condition sequence and intersecting atom
footprints are candidates. When importance differs, the important token removes
the non-important token only when its footprint fully covers the token being
removed; partial overlaps survive so unrelated declarations are not lost. With
equal importance, precise mode drops an earlier token only when the later
footprint covers it; symmetric mode treats any intersection as coverage.
Surviving tokens keep their original order. A custom resolve() hook remains an
explicit override of the default verdict.
Merge never validates TeaCSS or CSS values. A structurally parsed declaration uses its declared or fallback footprint even when the value is unsupported or empty:
cn("p:4", "p:invalid"); // "p:invalid"
cn("p:4", "p:"); // "p:"Run bun run bench:merger from the workspace root to measure the uncached
standard static-prefix p:* path at 500, 1,000, 2,000, and 4,000 tokens.
Status
Pre-1.0. Public recipe and merge APIs may change before the stable release.
