Skip to content

Upgrading to 0.18

From 0.18, csszyx reads the CSS your project’s Tailwind compiles before it writes a class. Each class it emits carries the prefix your stylesheet sets. A class that a later class on the same element fully covers is removed. Every finding has a level that csszyx.config sets, and csszyx check fails only on error. The other changes rename sz keys or change an API shape, and the type checker, csszyx check or csszyx migrate --keys-only finds those for you.

  1. Install 0.18 for csszyx and every @csszyx/* package at once. The build stops on a runtime or an engine from another release, with runtime-version-skew or engine-version-skew, each naming the version to install.

  2. If csszyx init wrote a csszyx.config.ts or csszyx.config.js for you, replace it with the 0.18 template.

  3. Rewrite the renamed keys:

    Terminal window
    npx @csszyx/cli migrate --keys-only --dry-run # preview
    npx @csszyx/cli migrate --keys-only # apply
  4. Run tsc --noEmit. It flags szv rows that do not type-check, and calls to transform, csszyxTurbopack and the @csszyx/unplugin entry that use the 0.17 shape.

  5. Run npx @csszyx/cli check. It lists the keys --keys-only could not reach, class names cut off by a spread, and Tailwind entries that set different prefixes, and it exits 1 while any remain. It no longer fails on class-precedence or duplicate-sz; if your CI relied on that, opt back in.

  6. List the classes the build now removes, and decide whether each one should go:

    Terminal window
    npx @csszyx/cli check --rule merge-covered-key --rule merge-covered-class

    Its findings are info, so the audit does not fail the run.

  7. Build. If the build stops, its message names the stylesheet or the option to fix. A line ending in (dead-class) names an sz class that produces no CSS, and one ending in (unsigned-value) a sign your Tailwind has no negative for.

ChangeWhat you seeFound byFixed by
Tailwind prefixClasses gain tw:; the build can stop on your stylesheetsthe build, csszyx checkyou
A later key covers an earlier one{ pb: 2, p: 4 } emits p-4the merge audityou
sz covers a className classclassName="card pb-2" sz={{ p: 4 }} emits card p-4the merge audityou
A spread splits the class namesspread-split-class warning; Svelte attribute_duplicatethe build, csszyx checkyou
szcn merges on compiled CSSszcn keeps or drops different classesdev warningyou
font-variant-numeric keys[csszyx] removed-key: on tabularNumsthe build, csszyx checkmigrate --keys-only
touch-action keys, contain[csszyx] moved-value: on touch: pan-xthe build, csszyx checkmigrate --keys-only
szv() returns SzPropstype errors in szv rowstscyou
transform options[csszyx] options-object-required: throwntsc, runtime throwyou
csszyxTurbopack optionsoptions-object-required throwntsc, runtime throwyou
@csszyx/unplugin exportsan import that no longer resolvestsc, the bundleryou
@csszyx/compiler exportsan import that no longer resolvestsc, the bundleryou
Engine on its owntransformSource no longer exported from the root; Cannot find package '@csszyx/core'tsc, the bundler, the import of @csszyx/compiler/engineyou
Engine at the compiler’s versionengine-version-skewthe first engine callyou
Runtime at the plugin’s versionruntime-version-skewthe build startyou
Core in Node onlyverifyMangleMapIntegrity not exported; Could not resolve "@csszyx/core" in a browser buildtsc, the bundleryou
@csszyx/core/native resultsno diagnostics on a result; rootDir not acceptedtscyou
Thrown errors carry a codeerr.code is native-engine-unavailable where it was CSSZYX_NATIVE_UNAVAILABLE; messages start [csszyx] <code>:your catchyou
ES modules onlyTS1471 or TS1479 in a CommonJS TypeScript filetscyou
Adapters under requirerequire(...) returns an objectyour config fails to loadyou
Vue and Svelte take static values onlythis sz value is only known at runtimethe buildyou
parseObjectLiteral removedan import that no longer resolvestsc, the bundleryou
Framework parsersSvelte cannot parse this file on sz="{ p: 4 }"; Vue 3.5 and Svelte 5 requiredthe buildyou
mergeClassAttributes removedan import that no longer resolvestsc, the bundleryou
A template string without ${}{ p: `4` } emits p-4your snapshotsnothing
Build-log diagnostics[csszyx] unknown-key: src/Card.tsx:3:16 where 0.17 printed Unknown property "bgg"your log filtersyou
check --json kindskind is never other for an engine findingyour CI filteryou
check --json version 2"version": 2; message holds no help: lineyour CI parseryou
MCP csszyx_validate fieldseach warning is an object, not a stringyour MCP clientyou
csszyx.config`development` is not read from this file at every buildthe build, csszyx checkyou
check exit codecheck passes with a class-precedence or duplicate-sz findingyour CIyou
Build-time dead-classproduces no CSS under this project's Tailwind (dead-class)the buildyou
A sign Tailwind serves no negative forserves no negative … (unsigned-value)the build, csszyx checkyou
A variant Tailwind does not serve[csszyx] unserved-variant: on { hidden: { p: 4 } }the build, csszyx check, dev runtimeyou
A runtime value under a variant keykey `hover` holds a value only known at runtimethe buildyou
A slash opacity in a colour string[csszyx] slash-opacity-string:the build, csszyx checkyou
A slash opacity in a colour object[csszyx] color-slash-opacity:the build, csszyx checkyou
Spellings every v4 servesstart-4 where 0.17 wrote inset-s-4your snapshotsnothing
Logical keys need Tailwind 4.2(dead-class) on pbs-4the build, csszyx checkyou
migrate asks your Tailwindmore entries under Unrecognized classescsszyx migrateyou
Config edits apply without a restartfindings printed again at their new leveldev and watch lanesnothing

With @import "tailwindcss" prefix(tw), Tailwind serves tw:p-4 and nothing for p-4. 0.17 emitted p-4 and only warned, so every sz class in a prefixed project styled nothing. 0.18 writes the prefix before every class:

<div sz={{ p: 4 }} />
// 0.17 → className="p-4"
// 0.18 → className="tw:p-4"

Vue and Svelte templates get the same prefix through the csszyx bundler plugin. The adapters used on their own read no stylesheet; pass classPrefix: 'tw' to them.

You are affected when a stylesheet your build reads sets prefix(...), or when your project holds more than one stylesheet that imports Tailwind: a test fixture, a Storybook copy, an old file the app no longer loads.

What you see. In a prefixed project, classes in the DOM and in snapshots gain the prefix, so a test that selects .p-4 stops matching. When two Tailwind entries set different prefixes, the build stops before it transforms anything:

[csszyx] prefix-disagreement:
The Tailwind stylesheets of this project set different prefixes (`src/index.css` with `prefix(tw)`, `fixtures/old.css` with none), so no class csszyx emits is served by all of them.
help: give every Tailwind stylesheet the same `@import "tailwindcss"` line, or list the stylesheets this build loads with the csszyx `tailwindStylesheet` option.
note: no module was transformed, and nothing was written.
docs: https://csszyx.com/e/prefix-disagreement

A stylesheet that imports Tailwind and fails to compile also stops the build, since csszyx cannot read its prefix. See Tailwind entries set different prefixes for every variant of these messages.

The edit. Name the stylesheets your app loads, and csszyx reads the prefix from those alone:

csszyx({ tailwindStylesheet: "src/index.css" });

On Next.js with Turbopack, pass the same list to the loader and to the two commands, and run csszyx next prebuild before next build. Without it the build stops with next-prefix-unread:

next.config.mjs
turbopack: csszyxTurbopack({ tailwindStylesheet: "app/globals.css" }),
"build": "csszyx next prebuild 'app/**/*.tsx' --tailwind-stylesheet app/globals.css && next build --turbopack"

A watch build (webpack, rollup -w, vite build --watch, esbuild, Rspack) whose prefix changes mid-session fails each rebuild until you restart it. A Vite dev server recompiles every module and reloads the page instead.

Tooling. csszyx check reports two entries that disagree as a prefix-disagreement finding and exits 1. csszyx migrate changes nothing here.

A later sz key replaces an earlier one it covers

Section titled “A later sz key replaces an earlier one it covers”

In a static sz object or array, a later key now removes an earlier key whose CSS it fully covers. 0.17 emitted both classes, and because Tailwind writes the shorthand’s rule first, the earlier pb-2 won whatever order you wrote:

<div sz={{ pb: 2, p: 4 }} /> // 0.17 → "pb-2 p-4" 0.18 → "p-4"
<div sz={{ p: 4, pb: 2 }} /> // 0.17 → "p-4 pb-2" 0.18 → "p-4 pb-2"

You are affected when a static object writes a narrower key before a wider key of the same property: pb before p, mt before m, w before size.

What you see. A production build prints nothing, and the element renders the later key. A Vite dev server or a webpack development build prints one count at start:

[csszyx] {n} class(es) in {m} file(s) were removed: another class on the same element sets every property they set.
help: `csszyx check --rule merge-covered-key --rule merge-covered-class` lists them.
note: set `merge-covered-key` and `merge-covered-class` to `'off'` in `diagnostics.rules` of `{config}` to hide this line; `build.mergeCoveredClasses: false` keeps the classes.

The line names only the ids that removed a class. Under the atomic preset each removal prints on its own line with its file and line, in production too.

A jest snapshot of a class list changes too, because jest reads the table the build wrote.

The edit. Write the refining key last: { p: 4, pb: 2 }. The merge audit lists each removal at the line and key you wrote:

src/App.tsx:3: The build removes `px-2`, written as `px`, because a later key in the same sz object sets every property it sets.

Values the runtime resolves keep every class, and so do szv factories and the Svelte and Vue adapters. The full list is in A later key covers an earlier one.

If your stylesheet is read wrong and a class you need is removed, set build.mergeCoveredClasses: false (on Turbopack, csszyxTurbopack({ mergeCoveredClasses: false })). The build then keeps every class, as 0.17 did, and the setting also turns off the next rule.

Tooling. The merge audit finds every site. csszyx migrate does not reorder keys.

In 0.17 a static className and the sz beside it both reached the element, and the stylesheet order decided between their classes. Now a Tailwind class in a static className is dropped when a class the static sz beside it emits sets every property it sets:

<div className="card pb-2" sz={{ p: 4 }} />
// 0.17 → className="card pb-2 p-4"
// 0.18 → className="card p-4"

You are affected when a static className holds a Tailwind utility that a key in the sz beside it covers. Two classes inside one className are never compared with each other. A class from @utility, a plugin or plain CSS is never removed, and neither is a Tailwind class that one of your own CSS rules selects on.

What you see. The same dev-server count as the object rule, and this finding in the merge audit:

src/App.tsx:2: The build removes `pb-2` from `className`, because an `sz` class on the same element sets every property it sets.

The edit. Move the refinement into sz, after the key that covers it:

// before
<div className="card pb-2" sz={{ p: 4 }} />
// after → className="card p-4 pb-2"
<div className="card" sz={{ p: 4, pb: 2 }} />

build.mergeCoveredClasses: false turns this rule off together with the previous one. Details in sz over a static className.

Tooling. The merge audit. csszyx migrate does not move classes.

Class names on either side of a spread no longer merge

Section titled “Class names on either side of a spread no longer merge”

0.17 folded every class name and every sz on an element into the first class name, before a spread. A className the spread carried then replaced all of them. In 0.18 a spread is a wall: what is written on one side of it merges, and each side becomes one class name where its sz stood. In React, Preact, Solid and Qwik the last one replaces the others:

export const Card = (rest) => <div className="card pb-2" {...rest} sz={{ p: 4 }} />;
// 0.18 → <div className="card pb-2" {...rest} className="p-4" /> React renders class="p-4"

You are affected when a className or sz sits before a spread and another sz or class name sits after it. An element with no spread between them is unchanged.

What you see. The build prints this in every mode, production included:

[csszyx] spread-split-class: src/Card.tsx:1:36
`<div>` has class names or `sz` on more than one side of a spread; in React, Preact, Solid and Qwik the last class attribute replaces the others, so `card pb-2` will not apply there.
help: write them in one `sz` array after `{...rest}`, in the order that should win: `sz={['card pb-2', rest.className, { … }]}`.
note: in Vue JSX every class is merged, so every side applies there.
docs: https://csszyx.com/e/spread-split-class

The edit. Write everything in one sz array after the spread, in the order that should win:

// before
<div className="card" {...rest} sz={{ p: 4 }} />
// after
<div {...rest} sz={["card", rest.className, { p: 4 }]} />

In Solid the spread’s attribute is rest.class. A Svelte component with class and sz on either side of a spread fails to compile with attribute_duplicate. Put both after the spread and read the spread’s class inside the string:

<div {...rest} class="card {rest.class ?? ''}" sz={{ p: 4 }}></div>

Tooling. csszyx check lists the element under the kind spread-split-class and exits 1. csszyx migrate does not rewrite it.

szcn and _szcn used to decide which class a later one replaces from a hand-written table of prefixes. They now drop an earlier class only when the table the build generates from your compiled CSS shows that the later class sets everything it set. Some pairs come out differently:

Call0.18 result
szcn('flex', 'block')block
szcn('border-t-2', 'border-2')border-2
szcn('text-2xl', 'text-[0.8rem]')both kept: text-2xl also sets a line height
szcn('transition', 'transition-none')both kept
a pair that differs only in its ! flagboth kept

Only Tailwind’s own utilities merge. A class from @utility, a plugin or plain CSS, and a Tailwind class one of your CSS rules selects on, is never removed and never removes another, so szcn('z-10', 'z-modal') with a hand-written .z-modal keeps both.

You are affected when a component relies on szcn to drop a class, when you call registerSzcnGroups or setSzcnGroups to make a custom class merge, or when you build with esbuild, Rspack, Rsbuild, Rolldown, Farm or Bun. Those lanes ship no table, and neither does jest before a build or csszyx next prebuild has run, nor the runtime used on its own. Without a table, szcn removes exact repeats only.

What you see. Once per run, in development, a lane with no table prints:

[csszyx] merge-table-missing: `szcn` has no merge table in this run, so it removes only exact repeats: `szcn('p-2', 'p-4')` keeps both classes.
help: use the csszyx plugin for Vite, Rollup, webpack or Next.js, which registers one; for jest, run the build or `csszyx next prebuild` first.
note: esbuild, Rspack, Rsbuild, Rolldown, Farm and Bun ship no table.
docs: https://csszyx.com/e/merge-table-missing

A call to registerSzcnGroups prints, once in development, that it no longer changes what szcn merges. It still names the property classify and splitBox read.

The edit. For a custom class that should merge, declare it as a @theme token and delete the rule you wrote for it, then remove the registerSzcnGroups call:

/* before */
.text-brand { color: #0b5fff; }
/* after */
@theme {
--color-brand: #0b5fff;
}

For a lane with no table, drop the earlier class yourself, or run jest after the build. Where the table comes from lists every lane.

Tooling. No command finds these calls. Search your code for szcn( and registerSzcnGroups.

font-variant-numeric takes one key per group

Section titled “font-variant-numeric takes one key per group”

CSS allows one keyword per group of font-variant-numeric (figure, spacing, fraction, ordinal, slashed zero) and combines the groups. The eight boolean flags let one object hold both keywords of a group, and on the page the stylesheet order, not the object, then decided which one applied. Each group is now one key, so a later value in the same group replaces the earlier one and different groups combine:

Before (replaced)AfterGroup
{ liningNums: true } / { oldstyleNums: true }{ numFigure: 'lining' } / { numFigure: 'oldstyle' }figure
{ proportionalNums: true } / { tabularNums: true }{ numSpacing: 'proportional' } / { numSpacing: 'tabular' }spacing
{ diagonalFractions: true } / { stackedFractions: true }{ numFraction: 'diagonal' } / { numFraction: 'stacked' }fraction
{ ordinal: true }{ numOrdinal: true }ordinal
{ slashedZero: true }{ numSlashedZero: true }slashed zero
{ fontVariant: 'normal-nums' }{ nums: 'normal' }resets every group

What you see. The old keys emit nothing, and the build names the replacement, in production too:

[csszyx] removed-key: src/Table.tsx:4:17
`tabularNums` was replaced, and nothing is emitted for it.
help: write `{ numSpacing: 'tabular' }` instead, or run `csszyx migrate`.
docs: https://csszyx.com/e/removed-key

The edit. csszyx migrate --keys-only rewrites them, including a ternary of literals (tabularNums: c ? true : false becomes numSpacing: c ? 'tabular' : undefined). Two old keys of one group in one object become one key holding the value that rendered before. Both classes used to ship, and Tailwind’s stylesheet order picked one, so { tabularNums: true, proportionalNums: true } becomes { numSpacing: 'tabular' } whichever was written last. The same goes for oldstyle over lining and stacked over diagonal. A stand-alone fontVariant: 'normal-nums' sorted after every group and rendered, so migrate keeps { nums: 'normal' } and drops the old group keys beside it.

Tooling. csszyx check lists what --keys-only left, under the kind removed-key.

touch-action takes one key per group, contain gains keys

Section titled “touch-action takes one key per group, contain gains keys”

touch-action has the same shape as font-variant-numeric: groups that combine (horizontal pan, vertical pan, pinch zoom) and keywords that stand alone. The groups moved to their own keys, and touch keeps the stand-alone keywords. contain had no key; it now follows the same shape.

BeforeAfter
{ touch: 'pan-x' } / 'pan-left' / 'pan-right'{ touchPanX: 'x' } / 'left' / 'right'
{ touch: 'pan-y' } / 'pan-up' / 'pan-down'{ touchPanY: 'y' } / 'up' / 'down'
{ touch: 'pinch-zoom' }{ touchPinchZoom: true }
{ touch: 'auto' } / 'none' / 'manipulation'unchanged
none{ contain: 'none' | 'strict' | 'content' }, { containSize: 'size' | 'inline-size' }, containLayout / containPaint / containStyle: true

What you see. A moved value emits nothing. The build reports it with its replacement, in a ternary and inside any variant as well as at the top of the object:

[csszyx] moved-value: src/Map.tsx:3:16
`touch: pan-x` moved to `touchPanX`, and nothing is emitted for it.
help: write `{ touchPanX: 'x' }`, or run `csszyx migrate`.
docs: https://csszyx.com/e/moved-value

The edit. csszyx migrate --keys-only rewrites it, also under the old alias (touchAction: 'pan-x' becomes touchPanX: 'x' in one run) and in a ternary of literals. When every branch lands on one group the ternary keeps its test (touch: c ? 'pan-x' : 'pan-left' becomes touchPanX: c ? 'x' : 'left'). Branches on two groups split into one key per group, each branch of the other group reading undefined.

In one object the later key wins for all three properties (nums, touch, contain against their groups): the stand-alone keyword replaces the groups written before it, and groups written after it replace it. A touch keyword beside an old group key in one object ({ touch: 'none', touchAction: 'pan-x' }) rendered the keyword before, so migrate keeps touch: 'none' and drops the group key.

Tooling. csszyx check lists what --keys-only left, under the kind moved-value. The shapes --keys-only skips (objects inside szv(…) or an sz array, HTML files, spreads, runtime ternaries) are listed in What --keys-only leaves for the build.

A szv() factory returned SzObject, which a typed sz prop does not accept, and its variant rows were never type-checked: a typo compiled and lowered to a class with no CSS. Factories now return SzProps, and every row is checked the way sz={...} is, nested variants included. Runtime behaviour is unchanged.

What you see. tsc errors inside szv configs:

szv({ variants: { size: { sm: { p: 2, bgg: "red-500" } } } }); // ❌ `bgg` is not an sz key
szv({ variants: { size: { sm: { hover: { bgg: "red-500" } } } } }); // ❌ nested too
szv({ variants: { layout: { row: { display: "flexx" } } } }); // ❌ not a display value

An unknown key is reported once, on that key, and the error names it: Type '"red-500"' is not assignable to type '"red-500" & NotAnSzKey<"bgg">'. The keys beside it in the row stay clean.

The edit. Fix the key or value the error names. A row shared between configs needs as const or the type SzProps when it sets a key with a closed value type such as display, as it already did at sz={row}: without as const, { display: "flex" } widens to { display: string }. A row typed SzObject no longer compiles, and neither does a variable that receives a factory’s result as SzObject:

// before
const pill: SzObject = { rounded: "full", px: 3 };
const o: SzObject = badgeSz({ shape: "pill" });
// after
const pill = { rounded: "full", px: 3 } as const;
const o: SzProps = badgeSz({ shape: "pill" });

A row table built at runtime as Record<string, SzProps> still compiles.

Tooling. tsc --noEmit. Neither csszyx check nor csszyx migrate changes types.

transform from @csszyx/compiler took the variant prefix and the mangle map as positional arguments. Both are now keys of one options object:

// before
transform(sz, "hover:");
transform(sz, "", map);
// after
transform(sz, { prefix: "hover:" });
transform(sz, { mangleMap: map });

What you see. tsc rejects the old call. Untyped code throws:

[csszyx] options-object-required: `transform` takes its options in one object, so a second argument that is not an object, or a third argument, is refused.
help: pass the prefix and the mangle map as keys: `transform(sz, { prefix: 'hover:', mangleMap })`.
docs: https://csszyx.com/e/options-object-required

Tooling. tsc --noEmit. csszyx migrate does not rewrite calls.

csszyxTurbopack took the Turbopack config to extend first and loader options second. Everything is now one object, with the config under turbopack:

// before
csszyxTurbopack({}, { tailwindStylesheet: "app/globals.css" });
csszyxTurbopack(existingConfig, { mergeCoveredClasses: false });
// after
csszyxTurbopack({ tailwindStylesheet: "app/globals.css" });
csszyxTurbopack({ turbopack: existingConfig, mergeCoveredClasses: false });

For a config alone, write csszyxTurbopack({ turbopack: existingConfig }). rules and resolveAlias belong inside turbopack.

What you see. A second argument, or a top-level key that is not a loader option, throws when Next loads the config:

[csszyx] options-object-required:
`csszyxTurbopack` has no option(s) `rules`, so the call is refused.
help: use `config`, `glob`, `importedStaticSz`, `mergeCoveredClasses`, `parserMode`, `safelistOutputFile`, `tailwindStylesheet` or `turbopack`; a Turbopack setting such as `rules` goes under `turbopack`.
docs: https://csszyx.com/e/options-object-required

Tooling. tsc on a typed next.config.ts; the throw otherwise.

The main entry of @csszyx/unplugin re-exported whole internal files. It now exports unplugin (also the default export), vitePlugin, webpackPlugin, rollupPlugin, esbuildPlugin, parseThemeBlocks and hasTokens. The other names are internal, and each lane’s own entry exports what that lane offers:

// before
import { mangleCSS } from "@csszyx/unplugin";
// after
import { mangleCSS } from "@csszyx/unplugin/css-mangler";

The lane entries are @csszyx/unplugin/css-mangler, /next, /next-prebuild, /jest and /postcss.

What you see. tsc reports that the module has no such exported member, and an ES module import fails when the bundler or Node loads it.

@csszyx/compiler renames one error and drops nine exports

Section titled “@csszyx/compiler renames one error and drops nine exports”

The error transformRust throws when the native engine cannot load is now RustTransformUnavailableError, the counterpart of WasmTransformUnavailableError. It was OxcRustNotImplementedError, a name left from an engine that no longer exists. ASTBudgetExceededError is gone: nothing threw it. A file over the AST budget is left as written and reported as an ast-budget issue on the result.

// before
import { OxcRustNotImplementedError, ASTBudgetExceededError } from "@csszyx/compiler";
if (err instanceof OxcRustNotImplementedError) { /* … */ }
// after
import { RustTransformUnavailableError } from "@csszyx/compiler/engine";
if (err instanceof RustTransformUnavailableError) { /* … */ }
const overBudget = result.issues?.some(issue => issue.code === "ast-budget");

parseStaticObjectLiteral, explainStaticObjectLiteral and their result type StaticObjectResult are gone too. They read an sz object with a second parser that disagreed with the build on { p: +4 }, { p: [4] } and template strings. Ask the build’s engine instead: transformSource lowers the object the way the build does, and csszyx explain prints the class it gets.

// before
import { parseStaticObjectLiteral } from "@csszyx/compiler";
const object = parseStaticObjectLiteral("{ p: 4 }");
// after
import { transformSource } from "@csszyx/compiler/engine";
const { code } = transformSource("export const A = () => <div sz={{ p: 4 }} />;", "a.tsx");
// code holds className="p-4"

The text classifiers szDiagnosticKindOf, isAdvisorySzDiagnostic, SZ_DIAGNOSTIC_KIND_IDS and their type SzDiagnosticKindId are gone. They guessed a diagnostic’s kind from its English. Every build diagnostic now carries its code on the result, and csszyx check --json writes it on each finding, so read the code.

// before
import { szDiagnosticKindOf } from "@csszyx/compiler";
const kind = szDiagnosticKindOf(result.diagnostics[0]);
// after
const kind = result.issues?.[0]?.code;

szKeySuggestionFor is gone for the same reason: it read the misspelt key out of an Unknown property sentence. An unknown-key issue carries the key it most likely misspells in args.suggestion, absent when no key is near.

// before
import { szKeySuggestionFor } from "@csszyx/compiler";
const key = szKeySuggestionFor(result.diagnostics[0]);
// after
import type { SzIssue } from "@csszyx/compiler";
const issue = result.issues?.[0] as SzIssue | undefined;
const key = issue?.code === "unknown-key" ? issue.args.suggestion : undefined;

CsszyxCompiler, the tenth removed name, is covered under the engine move.

What you see. tsc reports that the module has no such exported member, and an ES module import fails when the bundler or Node loads it. Code that compared err.name with "OxcRustNotImplementedError" no longer matches.

The engine moves to @csszyx/compiler/engine and is installed on its own

Section titled “The engine moves to @csszyx/compiler/engine and is installed on its own”

Installing @csszyx/runtime, @csszyx/dynamic or @csszyx/types used to install the whole engine through @csszyx/compiler: a native binary for your platform and three WebAssembly builds, none of which runs in a browser. A project that installs only @csszyx/runtime drops from about 7.5 MB to 1 MB. The compiler’s root no longer loads the engine, and @csszyx/core is an optional peer of the compiler instead of a dependency.

Every function that calls the engine moved to @csszyx/compiler/engine, and the root no longer exports them: transformSource, transformRust, transformRustBatch, transformWasm, transformWasmBatch, scanModuleLinks, scanModuleLinksRust, scanModuleLinksWasm, scanGlobalVarUsages, scanGlobalVarUsagesRust, scanGlobalVarUsagesWasm, extractCrossModuleRegistryEntries, extractCrossModuleForwards, ensureRustTransformAvailable, isRustTransformAvailable, isWasmTransformAvailable, RustTransformUnavailableError and WasmTransformUnavailableError. Their types stay importable from the root.

// before
import { transformSource } from "@csszyx/compiler";
// after
import { transformSource } from "@csszyx/compiler/engine";

You are affected when your own code calls those functions, or @csszyx/compiler/migrate, with only @csszyx/compiler installed. Add the engine at the compiler’s version:

Terminal window
npm install @csszyx/core

@csszyx/unplugin, @csszyx/cli, @csszyx/mcp-server, both adapters and csszyx already depend on it, so a build that goes through any of them changes nothing.

CsszyxCompiler is gone. Its init() loaded a WebAssembly build nothing called, and its transform() and generateRecoveryToken() were the root’s transform and generateRecoveryToken, which stay.

What you see. An import of a moved name fails in tsc and in the bundler. Without @csszyx/core installed, importing @csszyx/compiler/engine or @csszyx/compiler/migrate fails before any of its exports can run:

Cannot find package '@csszyx/core' imported from …/@csszyx/compiler/dist/transform-rust-….mjs

An engine from another release stops the first engine call with engine-version-skew, which names both versions and what to install. A lockfile can keep an older @csszyx/core-<platform> package beside a current @csszyx/core; the message names that package too.

The build stops when @csszyx/runtime is from another release

Section titled “The build stops when @csszyx/runtime is from another release”

@csszyx/unplugin used to accept any @csszyx/runtime of the same major. The build and the runtime each carry the rules that lower an sz object: the build lowers the static ones, the runtime lowers szr, szv and objects built at run time, and merges classes with the table the build wrote. A runtime from another release wrote other classes for the same object, and nothing said so.

The plugin now requires @csszyx/runtime at exactly its own version, and stops when the build starts (on Turbopack, in csszyx next prebuild and in the loader) if your project resolves another one:

[csszyx] runtime-version-skew: @csszyx/unplugin 0.18.0 found @csszyx/runtime 0.17.2 at /app/node_modules/@csszyx/runtime
help: install @csszyx/[email protected]; the build and the runtime lower sz objects with rules from the same release

You are affected when your lockfile holds @csszyx/runtime at another version than @csszyx/unplugin, which installing both at once in step 1 avoids. A package that depends on its own copy of the runtime shows up with pnpm why @csszyx/runtime or npm ls @csszyx/runtime.

@csszyx/core runs in Node only, and verifyMangleMapIntegrity is gone

Section titled “@csszyx/core runs in Node only, and verifyMangleMapIntegrity is gone”

@csszyx/core used to ship a second WebAssembly build for bundlers and browsers. Its one browser use was a global you wired up yourself, window.verify_mangle_checksum, so that the synchronous verifyMangleMapIntegrity() could recompute the mangle checksum, at the price of 'wasm-unsafe-eval' in your Content-Security-Policy. Without that global the function accepted the page after a shape check and said so only in development.

That build and the function are removed. @csszyx/core resolves in Node only, and verifyMangleMapIntegrity is no longer exported from @csszyx/runtime or csszyx. verifyMangleChecksumAsync recomputes the checksum with Web Crypto, which plain script-src 'self' allows:

// before
import { verifyMangleMapIntegrity } from "@csszyx/runtime";
if (!verifyMangleMapIntegrity()) abort();
// after
import { readChecksumAttribute, verifyMangleChecksumAsync } from "@csszyx/runtime";
const checksum = readChecksumAttribute(document.documentElement);
if (checksum && !(await verifyMangleChecksumAsync(checksum))) abort();

It finds the map on its own: the census in the HTML, or, with hydrationCensus: false on a build that mangles classes, the map the bundle installed, or, when nothing is mangled, the empty map. Each verifies without a word. When it answers false it prints why — mangle-checksum-mismatch, web-crypto-unavailable, mangle-census-unreadable or mangle-map-unavailable — see the runtime reference. Remove any window.verify_mangle_checksum wiring and the 'wasm-unsafe-eval' you added for it.

You are affected when your code imports verifyMangleMapIntegrity, or imports @csszyx/core in code that runs in a browser or an edge runtime.

What you see. tsc: Module '"@csszyx/runtime"' has no exported member 'verifyMangleMapIntegrity'. A browser build that imports the engine fails to resolve it:

✘ [ERROR] Could not resolve "@csszyx/core" (esbuild)
"." is not exported under the conditions ["module", "browser", "production", "import"] from package …/@csszyx/core (Vite)

@csszyx/core/native returns codes instead of sentences

Section titled “@csszyx/core/native returns codes instead of sentences”

This concerns code that calls the native binding @csszyx/core/native directly; the csszyx packages are already updated. A transformBatch result no longer carries diagnostics, the rendered sentences. Its new spans field is one text per file holding each diagnostic’s code, its byte offset in the source and the values it names; its format is internal and can change between releases. @csszyx/compiler decodes it and renders the sentence. The rootDir option, which only shortened the file path inside a sentence, is no longer accepted.

The edit. Call transformRust or transformSource from @csszyx/compiler/engine instead. Their result holds the rendered diagnostics and an issues entry with code, args, line and column for each.

An error the browser packages throw has a code, the id its page is named by, and args, the values its message names. Its message starts [csszyx] <code>:; a production build throws the code, its args as JSON and the page instead of the sentence. The class you caught before still matches: SzDepthError is still an SzDepthError, and splitBoxSz and the CDN’s attribute parser still throw a TypeError and a SyntaxError.

CsszyxNativeUnavailableError from @csszyx/core/native changes its code from CSSZYX_NATIVE_UNAVAILABLE to native-engine-unavailable, and its message starts [csszyx] native-engine-unavailable: instead of csszyx native engine unavailable:. Its detail, help and helpIsExplicit fields are gone: args names what is missing (form, and platform, packageName or exportName), and the constructor takes those args. RustMigrateUnavailableError from @csszyx/compiler/migrate takes the same args in place of its detail string.

The edit. Match on the code, and recognise the error with isCsszyxError, from csszyx or @csszyx/runtime. Two copies of csszyx in one app have two error classes, so instanceof can answer false where the guard does not:

import { isCsszyxError } from 'csszyx';
try {
szr(input);
} catch (err) {
if (isCsszyxError(err) && err.code === 'sz-object-depth') return '';
throw err;
}

@csszyx/unplugin and @csszyx/compiler ship ES modules only

Section titled “@csszyx/unplugin and @csszyx/compiler ship ES modules only”

Both packages shipped every entry twice, as an ES module and as CommonJS, and instanceof disagreed between the two copies of an error class. They now ship ES modules only, which takes 0.79 MB off an install. Node 22.18 and later load an ES module with require() too, and for every entry require() returns the same exports the CommonJS build did, default included, so these keep working as they are:

const csszyxWebpack = require("@csszyx/unplugin/webpack").default;
const { csszyxTurbopack } = require("@csszyx/unplugin/next");
const csszyx = require("@csszyx/unplugin/jest").createTransformer();

A PostCSS config that names @csszyx/unplugin/postcss still gets the plugin function. The compiler entries that @csszyx/runtime loads keep their CommonJS build, so a Jest suite that renders your components needs no change.

You are affected when a TypeScript file compiled as CommonJS — a .cts file, or a .ts file in a package without "type": "module" — imports either package under "module": "node16", or with TypeScript older than 5.8. A file that only imports @csszyx/runtime or @csszyx/types is not affected.

What you see. TS1471 for import x = require(…), TS1479 for an import statement:

error TS1471: Module '@csszyx/unplugin/webpack' cannot be imported using this construct. The specifier only resolves to an ES module, which cannot be imported with 'require'. Use an ECMAScript import instead.
error TS1479: The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("@csszyx/unplugin")' call instead.

The edit. Set "module" to "nodenext" (TypeScript 5.8 or later) or "node20" (TypeScript 5.9 or later); both type-check require() of an ES module.

require of the Vue and Svelte adapters returns the exports

Section titled “require of the Vue and Svelte adapters returns the exports”

Under require, @csszyx/vue-adapter and @csszyx/svelte-adapter returned a bare function. They now return the same named exports as import, default included:

// before
const csszyx = require("@csszyx/vue-adapter");
// after
const { vitePlugin } = require("@csszyx/vue-adapter");
// Svelte: const { preprocessor } = require("@csszyx/svelte-adapter");

An ES module import is unchanged. The CommonJS entry loads the compiler’s ES modules with require(), so it needs Node 22.18, or 24.11 and later, which both packages’ engines field now states.

What you see. A CommonJS config that calls the result as a function fails with a TypeError when it loads.

Vue and Svelte templates take static sz values only

Section titled “Vue and Svelte templates take static sz values only”

A Vue or Svelte template has no csszyx runtime: nothing in it runs _sz(), sets a --_sz-* style variable or picks a conditional’s branch, the way the React build does. The adapters now lower sz with the build’s engine, on the native or the wasm artifact build.parser picks, and write a class only for a value the engine resolves completely. In 0.17 they read the value with a second, narrower parser, left a value it refused as written without a word, and wrote class="" for an array value.

These values are static and get their class:

  • strings, numbers (-2 and +4 included) and booleans;
  • nested variant objects, { hover: { bg: 'blue-700' } };
  • template strings without a ${} substitution, { p: `4` };
  • spreads of literal objects, { ...{ p: 4 }, m: 2 };
  • arrays of such objects, [{ p: 4 }, { m: 2 }];
  • null and undefined, which leave the key unset.

Every other value is an error, and the build stops on it: a name, even a const from <script setup> or the component’s <script>, a prop, a conditional, a call, an array value such as { p: [4] } and a template string with ${}. The message names the file and the position in it:

[csszyx] template-runtime-value: src/Card.vue:3:15
This `sz` value is only known at runtime, and a template has no csszyx runtime to resolve it, so it gets no class.
help: write the value as a literal, such as `{ p: 4 }`, or bind `class` or `style` yourself for a value that changes at runtime.
note: the attribute is left as written.
docs: https://csszyx.com/e/template-runtime-value

The other sz attributes of the file still get their classes, and the class the adapter writes is escaped for the attribute.

The edit. Write the value as a literal, or bind class or style yourself for a value that changes at runtime:

<!-- before -->
<div :sz="{ p: big ? 8 : 4, w: size }" />
<!-- after -->
<div :class="big ? 'p-8' : 'p-4'" :style="{ width: `${size}px` }" />

In Svelte, the same edit is class={big ? 'p-8' : 'p-4'} style:width="{size}px".

On a component, sz becomes a class attribute, as on an HTML tag, the way React turns it into a className prop. Vue passes that class to the component’s root element; a Svelte component takes it as its class prop and puts it on an element. The same static-only rule applies there.

You are affected when a component from a library has a prop of its own named sz, a size say. 0.17 left <Avatar :sz="size" /> as written; 0.18 reads it as csszyx sz and stops the build. Pass the prop in a way the adapters do not read:

<!-- Vue -->
<Avatar v-bind="{ sz: size }" />
<!-- Svelte -->
<Avatar {...{ sz: size }} />

parseObjectLiteral is no longer exported from @csszyx/vue-adapter or @csszyx/svelte-adapter. preprocess() from either adapter returns the errors and warnings in result.diagnostics, each with its line, column and full message; pass filename in the options to put the file name in the message.

The adapters read templates with Vue’s and Svelte’s parsers

Section titled “The adapters read templates with Vue’s and Svelte’s parsers”

@csszyx/vue-adapter now reads a .vue file with vue/compiler-sfc, and @csszyx/svelte-adapter reads a .svelte file with svelte/compiler. Both read the file the way your build does, so sz= written in page text, in a comment, in an interpolation, in an {@html} string or under v-pre stays text, and sz = "{ p: 4 }" with spaces around = is read. The adapters need the framework installed: Vue 3.5 or later, Svelte 5 or later. Svelte 4 is no longer read.

The class joins the element’s own class instead of standing beside it, which Vue and Svelte both reject as a duplicate attribute:

<div class="card" :sz="{ p: 4 }" />
<!-- 0.17 → class="card" class="p-4" -->
<!-- 0.18 → class="card p-4" -->

In Vue a bound :class is left alone and Vue joins it to the static class at runtime. In Svelte the class joins a quoted class on the same side of the element’s spreads; class={expr} stays a separate attribute.

Svelte’s quoted single-brace form is gone. sz="{ p: 4 }" is not Svelte, and Svelte’s parser rejects the file. Write the value as an expression:

<!-- before -->
<div sz="{ p: 4 }"></div>
<!-- after -->
<div sz={{ p: 4 }}></div>

A file the framework cannot parse is one error. The build stops on it with the parser’s message and position, and no sz in that file is lowered. In Svelte, run csszyx after the preprocessors that turn the file into Svelte (pug, TypeScript without lang="ts"). A Vue <template lang="pug"> holding sz is an error too: the adapter reads HTML templates only.

mergeClassAttributes is no longer exported from either adapter; preprocess() joins the class itself. preprocess() now returns a source map in result.map.

A template string without a substitution is static

Section titled “A template string without a substitution is static”

sz={{ p: `4` }} holds the string '4' before any code runs, and the build now lowers it like sz={{ p: '4' }}. It used to set the value through a style variable:

<div sz={{ p: `4` }} />
// 0.17 → className="p-(--_sz-p)" style={{ "--_sz-p": __szSpacingVar(`4`, "p") }}
// 0.18 → className="p-4"

The same holds in an szs slot, an szv config, and an object another module imports. A template string with a ${} substitution is unchanged.

Every sz diagnostic in the build log changes shape

Section titled “Every sz diagnostic in the build log changes shape”

Each diagnostic the engine reports for an sz prop now prints a header with its code and position, then the sentence, help:, note: and docs: lines indented by two spaces. Most sentences are reworded:

0.17
[csszyx] Unknown property "bgg" in sz prop at src/Card.tsx:3. The class is still emitted, so it styles nothing unless Tailwind serves that utility. Check for typos. If the class is intentional, define it with Tailwind's @utility.
# 0.18
[csszyx] unknown-key: src/Card.tsx:3:16
`bgg` is not an sz property.
help: write `bg` if you meant it; for a custom utility, define it with Tailwind's `@utility`.
note: the class is still emitted, and it styles nothing unless Tailwind serves that utility.
docs: https://csszyx.com/e/unknown-key

You are affected when a CI step, a log filter or a test greps the build output for 0.17 wording, such as Unknown property or sz fallback at. Those strings no longer appear.

The edit. Match the code in the header, for example [csszyx] unknown-key:. The codes are the ids csszyx check --rule takes, and they stay fixed when a sentence is reworded. csszyx check --json gives the same code as kind, with the position in line and column.

check —json no longer reports other for engine diagnostics

Section titled “check —json no longer reports other for engine diagnostics”

csszyx check --json read each diagnostic’s kind from its wording, so families added since then came out as other. The kind now comes from the engine’s code, and an engine diagnostic is never other. The new kinds are moved-value, global-before-group, runtime-family-conflict, merge-classifier-unavailable, parse-error, nesting-depth, unsupported-dynamic-sz and ast-budget. The kind of an sz-diagnostic finding is the engine’s code. The TypeScript type SzDiagnosticCode from @csszyx/compiler names every code, and SZ_DIAGNOSTIC_CODES lists them, in place of SzDiagnosticKindId and SZ_DIAGNOSTIC_KIND_IDS, which are removed. A finding of another pass has its rule as its kind, as before.

The edit. A CI filter, --rule or --ignore-rule that selected other must name the new ids:

Terminal window
# before
csszyx check --ignore-rule other
# after
csszyx check --ignore-rule parse-error --ignore-rule ast-budget

--rule other, --ignore-rule other and rules: { other: … } in csszyx.config are still accepted, with a removed-rule-id warning: no finding is of that kind any more, so they select and set nothing. A line that carries no code, which other used to name, is set by sz-diagnostic. The full list is under Running it in CI.

check —json writes version 2, with help and note as fields

Section titled “check —json writes version 2, with help and note as fields”

In version 1 an sz-diagnostic finding’s message held the sentence followed by the terminal’s layout: "\n help: …" and "\n note: …" lines, indented for a terminal. A consumer that wanted the advice had to cut the string apart. Version 2 hands over the parts:

FieldVersion 1Version 2
version12
$schemaabsenthttps://csszyx.com/schemas/check-v2.json
messagethe sentence, then help: and note: linesthe main sentence alone
helpinside messageits own field, without the help: label; absent when there is none
noteinside messageits own field, without the note: label; absent when there is none
linethe first :<number> found in message, or absentthe engine’s position of the finding; absent for a diagnostic about the whole file
columnabsent1-based, in UTF-16 code units, beside line
argsabsentthe values the sentence names, by name, such as { "key": "bgg", "suggestion": "bg" }
urlabsentthe page that explains the kind, https://csszyx.com/e/<kind>
levelabsentinfo, warn or error, from csszyx.config

rule, kind, file and suggestion keep their meaning. A finding of any other pass never had help, note, column, args or url, so only its version and level change. The JSON Schema is published at /schemas/check-v2.json.

With --json, stdout carries only the JSON document. Warnings about the run itself, such as removed-rule-id for --rule other, and errors such as an unknown rule id, go to stderr. A step that pipes stdout into a JSON parser keeps working when the run warns.

The human report changed too. Each finding prints as its line and column, its level when it is not error, its kind and the sentence, with help: and note: lines under it:

src/Card.tsx
1:48 (info) class-precedence: `sz` wins over the runtime `className` on this element in any attribute order, so a caller's `className` cannot override these styles.
help: state the order in one `sz` array: `sz={[{ … }, className]}` for the caller to win, `sz={[className, { … }]}` for these styles to win.

The edit. Read help and note where you split message, and accept version: 2:

// before
const [sentence, ...rest] = finding.message.split('\n');
// after
const { message: sentence, help, note } = finding;

The MCP server’s csszyx_validate tool changed the same way. Each entry of warnings was a rendered message string; it is now an object with code, message, help, note and url. An entry of errors that the engine explains carries the same fields beside key, one entry per diagnostic, where it used to join the rendered messages into message.

csszyx.config sets how loudly each finding is reported

Section titled “csszyx.config sets how loudly each finding is reported”

0.17 did not read csszyx.config.ts: csszyx init wrote one, and no build or command loaded it. In 0.18 every bundler plugin, csszyx check, csszyx next prebuild and csszyx next watch load csszyx.config.ts (or .mts, .js, .mjs) from the project root. They read one section from it, diagnostics, which sets the level of each kind of finding: off, info, warn or error.

csszyx.config.ts
import { defineConfig } from "csszyx";
export default defineConfig({
diagnostics: {
preset: "recommended",
rules: { "class-precedence": "error" },
},
});

You are affected when the project holds a config file that 0.17’s csszyx init wrote, or any csszyx.config file that sets plugin options.

What you see. The 0.17 template set development.debug, a plugin option. Every build start and every csszyx check run now prints:

[csszyx] config-invalid: csszyx.config.ts
This file has 1 problem(s); each one is ignored, and the rest of the file applies.
- (warning) development: `development` is not read from this file; plugin options belong in the bundler config.
help: https://csszyx.com/docs/reference/config/#csszyxconfig describes every field; a dev server, a watch build and `csszyx next watch` read it again when it or a module it imports is saved.
docs: https://csszyx.com/e/config-invalid

csszyx check still passes. In a project without TypeScript, init wrote the same TypeScript text into csszyx.config.js, which does not load. While that file keeps the template’s shape (a formatter changing its quotes, spacing or semicolons, or a comment, does not count), the build and csszyx check print one warning that it could not be loaded and is ignored, and csszyx check still passes. Any other config that fails to load, or has no default export, is an error, and csszyx check exits 1, so levels you meant are never dropped without a word.

The edit. Replace the file with the one 0.18’s init writes. It loads under either name, since it holds no type annotation:

import { defineConfig } from "csszyx";
export default defineConfig({
diagnostics: {
preset: "recommended",
},
});

In a package without "type": "module", 0.18’s init names the file csszyx.config.mts (or .mjs without TypeScript), which Node always reads as an ES module; a .ts or .js name keeps working there too. Running 0.18’s init again does the replacement for you: it deletes the 0.17 template and writes the new file, and keeps any other csszyx.config it finds.

Deleting the file works too: without one, every lane uses recommended. Plugin options such as development.debug stay in the bundler config.

Two type changes come with it. CsszyxConfig is deprecated: it requires every section, so a hand-written object fails tsc --strict. Type plugin options with PartialCsszyxConfig and the config file with defineConfig. development.strictMode is removed. Nothing ever read it, so it never failed a build; tsc now rejects an object literal that sets it. Delete the key, and gate on csszyx check.

A Vite dev server, every watch build (webpack, rollup -w, vite build --watch, an esbuild context) and csszyx next watch read the file again when it, or a module it imports by a relative path, is saved, and print each finding again at its new level. Run csszyx next prebuild again before next build: the Turbopack loader and jest read the levels those commands write to .csszyx/diagnostic-policy.json. That file lists the config and every module it imports by a relative path, and once one of them changed since it was written, the loader and jest load the config themselves. Every preset, id and lane is in csszyx.config.

Tooling. The build and csszyx check print each problem in the file, and name the id a misspelt one most likely means. csszyx migrate does not rewrite the file.

csszyx check 0.17 exited 1 on every finding it reported. In 0.18 each finding carries a level from csszyx.config, and the run exits 1 when a finding is at error, or when the config itself has an error such as an unknown id. info and warn findings are still listed, and the run passes.

The default recommended preset keeps the 0.17 verdict for every finding except two notes about a choice the code made: class-precedence (sz wins over a runtime className) and duplicate-sz (two sz attributes merged in order). Both are info now:

export const Card = (props) => <div className={props.className} sz={{ p: 4 }} />;
src/Card.tsx
1:48 (info) class-precedence: `sz` wins over the runtime `className` on this element in any attribute order, so a caller's `className` cannot override these styles.
help: state the order in one `sz` array: `sz={[{ … }, className]}` for the caller to win, `sz={[className, { … }]}` for these styles to win.
! 1 sz issue(s) in 1 file(s); 1 below --fail-on error, which do not fail the run.

You are affected when CI runs csszyx check and relied on it failing for either note, or when a CI step reads --json and treats every finding as a failure.

The edit. To fail on everything the run reports, as 0.17 did, pass --fail-on info:

Terminal window
npx @csszyx/cli check --fail-on info

To fail on one note only, raise it in csszyx.config (.ts, .mts, .js or .mjs):

export default defineConfig({
diagnostics: { rules: { "class-precedence": "error", "duplicate-sz": "error" } },
});

--rule and --ignore-rule still only choose what is reported: --rule class-precedence lists the note and passes. Each --json finding gains a level field; a step that fails on the document should read level, or the exit code. A finding set to off is not in the document.

Tooling. No command changes your CI. See Running it in CI.

The build names sz classes that produce no CSS

Section titled “The build names sz classes that produce no CSS”

An sz value your Tailwind has no utility for lowered to a class with no CSS, and only csszyx check said so. { break: "bogus" } is a real key with a value Tailwind does not serve, so no key warning fired and break-bogus reached the DOM styling nothing. A Vite, Rollup or webpack build, and csszyx next prebuild or csszyx next watch under Turbopack, now ask the project’s Tailwind about every class an sz prop emitted, and print each one it serves no CSS for:

[csszyx] src/Card.tsx:3:22: `break-bogus` (sz key `break`) is emitted by an sz prop and produces no CSS under this project's Tailwind (dead-class).
help: fix the sz key or value, or define the class with Tailwind's @utility; `csszyx check --rule dead-class` lists every one.

You are affected when csszyx check already reported dead-class findings for your project. The build now prints the same classes, in production too. It does not fail: csszyx check is still the gate, and exits 1 on them. A class written in className is never reported here.

The edit. Fix the key or value the line names. For a class you serve on purpose outside Tailwind, list it in the config, which also stops csszyx check reporting it:

export default defineConfig({
diagnostics: { allow: { classes: ["my-legacy-class"] } },
});

diagnostics.rules: { "dead-class": "off" } hides every such line, in the build and in csszyx check. quiet: "nudges" does not hide it, since the finding is an error. quiet: true does, together with every other plugin warning, including the reports of other classes that have no CSS. The full entry is An sz class produces no CSS.

Tooling. csszyx check --rule dead-class lists every class with the file that emits it.

A negative your Tailwind does not serve is reported

Section titled “A negative your Tailwind does not serve is reported”

The minus of a negative number goes on the utility, the way Tailwind spells a negative. Stock Tailwind has no negative padding or width, and no negative fraction on a margin, so those classes style nothing. 0.17 wrote them without a word, and its two lanes disagreed: { p: -2 } gave p--2 at runtime and -p-2 at build. Both lanes now write -p-2, and the build asks the project’s Tailwind whether it serves the class:

[csszyx] src/Card.tsx:3:22: `-p-2` (sz key `p`) is emitted by an sz prop and produces no CSS: this project's Tailwind serves no negative `p-2`; drop the sign, or define it with @utility (unsigned-value).
help: `csszyx check --rule unsigned-value` lists every one.

You are affected when an sz object gives a negative number to a key whose utility has no negative: { p: -2 }, { w: -4 }, { mt: '-1/2' }, and the same under a variant ({ hover: { p: -2 } } → hover:-p-2).

What you see. The line above, where the build prints dead-class, in production too. It is a kind of dead-class, at error under both presets: the build carries on, and csszyx check lists the class under Negative values this project's Tailwind does not serve: and exits 1. A class that is dead without its sign as well ({ p: '-medium' }) is a plain dead-class. A length keeps its sign inside the bracket ({ mt: '-10px' } → mt-[-10px]) and is not reported.

The edit. Drop the sign, or write a length where the property takes a negative one. A project that serves the negative on purpose defines it with @utility -p-* and hears nothing. rules['dead-class'] sets the level of both findings, and rules['unsigned-value'] of this one alone.

Tooling. csszyx check --rule unsigned-value. csszyx migrate does not rewrite it.

A variant your Tailwind does not serve is reported

Section titled “A variant your Tailwind does not serve is reported”

A key holding an object is a variant, so { hidden: { p: 4 } } writes hidden:p-4. Tailwind serves a bare variant for some states (checked:, disabled:, open:) and not for others (hidden:, readonly:, expanded:), and the class it does not serve styles nothing. 0.17’s build wrote it without a word, and csszyx check listed it as a plain dead-class. Both now name the variant:

[csszyx] unserved-variant: src/Card.tsx:3:22
`hidden:p-4` produces no CSS: Tailwind serves no `hidden` variant.
help: write `{ '[hidden]': { … } }` to match the HTML attribute, or `aria: { hidden: { … } }` for the ARIA state.
note: the class is still emitted, and it styles nothing unless the project defines `hidden` with `@custom-variant`.
docs: https://csszyx.com/e/unserved-variant

You are affected when an sz object puts an HTML attribute, an ARIA state or a misspelt variant name alone as a key: { hidden: { … } }, { expanded: { … } }, { hovr: { … } }.

What you see. The line above, where the build prints dead-class, and in csszyx check under Variants this project's Tailwind does not serve:. It is a kind of dead-class, at error under both presets. Both ask your Tailwind: the line comes only when the class with that one variant dropped is served. In development the runtime prints it too, on Node and SSR, for an ARIA state the Tailwind csszyx is built with does not serve, and never for another name.

The edit. Write the attribute in brackets ({ '[hidden]': { p: 4 } } → [hidden]:p-4) or the ARIA state under aria ({ aria: { expanded: { p: 4 } } } → aria-expanded:p-4). A project that means its own variant defines it with @custom-variant and hears nothing. rules['dead-class'] sets the level of every dead-class finding, and rules['unserved-variant'] of this one alone.

Tooling. csszyx check --rule unserved-variant. csszyx migrate does not rewrite it.

A runtime value under a variant key is reported

Section titled “A runtime value under a variant key is reported”

0.17 lowered sz={{ m: 1, hover: x }} into a CSS variable on a class Tailwind never serves, and said nothing. The static keys still lower at build. The variant key now goes to the runtime part of the class list, where x is styled:

<div sz={{ m: 1, hover: x }} />
// 0.18 → className={_szcn("m-1", _szPart({ hover: x }))}

No file tells Tailwind to generate the classes x lowers to, so the build reports the key under the kind fallback-missing-css, an error, printed in production too:

[csszyx] fallback-missing-css: src/Card.tsx:3:22
`sz` falls back to the runtime: key `hover` holds a value only known at runtime and no sz property claims it, so its classes were never collected.
help: if the key is a variant, give it a static sz object literal or a two-branch `c ? { … } : { … }`; if it is an sz property, check its spelling; for runtime values use `dynamic()`.
docs: https://csszyx.com/e/fallback-missing-css

You are affected when an sz object holds a runtime value under a variant key, at any depth (md.hover), in an sz array element or a conditional branch, or under a namespace import. A key no sz table claims takes the same path, since it may be a variant your project declares, so a misspelt key such as tablt: x is reported here too. A two-branch c ? { … } : { … } with literal branches is unchanged and compiles.

The edit. Write a static object under the variant, a two-branch ternary of literals, an szv() factory, or a dynamic() value for a true runtime value. Fix the spelling of a key that was meant as a property. The full matrix is in sz — unresolved expressions.

Tooling. The build only. csszyx check does not list runtime fallbacks.

A colour string with a slash opacity is reported

Section titled “A colour string with a slash opacity is reported”

A colour key takes its opacity in the object form, so { bg: 'red-500/50' } has always emitted nothing. 0.17 said so only in the browser, in development. The build now names it under the kind slash-opacity-string, an error under recommended:

[csszyx] slash-opacity-string: src/Card.tsx:3:16
`bg: 'red-500/50'` emits nothing: a string value cannot carry a slash opacity.
help: use the object form: `{ color: 'red-500', op: 50 }`.
docs: https://csszyx.com/e/slash-opacity-string

The runtime prints the same words once per value, and its code and args in production:

[csszyx] slash-opacity-string: `bg: 'red-500/50'` emits nothing: a string value cannot carry a slash opacity.
help: use the object form: `{ color: 'red-500', op: 50 }`.
docs: https://csszyx.com/e/slash-opacity-string
[csszyx] slash-opacity-string: {"key":"bg","color":"red-500","opacity":"50"} https://csszyx.com/e/slash-opacity-string

The edit. Write the object form the line names:

// before
<div sz={{ bg: "red-500/50" }} />
// after
<div sz={{ bg: { color: "red-500", op: 50 } }} />

Tooling. csszyx check lists it under the kind slash-opacity-string and exits 1.

The object form is the one spelling for an opacity, so a slash inside it is reported too, under the kind color-slash-opacity, an error under recommended: csszyx check exits 1 on a { color: "red-500/50" } that 0.17 passed. Its class is still written. When both are given, op is the opacity: { color: "red-500/50", op: 20 } wrote bg-red-500/50/50, which no Tailwind serves, and now writes bg-red-500/20. The important modifier now ends the class, so { color: "red-500!", op: 20 } writes bg-red-500/20! instead of the dead bg-red-500!/20.

// before
<div sz={{ bg: { color: "red-500/50" } }} />
// after
<div sz={{ bg: { color: "red-500", op: 50 } }} />

Some classes take the spelling every Tailwind v4 serves

Section titled “Some classes take the spelling every Tailwind v4 serves”

One csszyx release serves every Tailwind from 4.0, so a key now writes the spelling all of them serve. The CSS is the same; class names in the DOM and in snapshots change:

sz0.170.18
{ start: 4 }, { insetS: 4 }inset-s-4 (Tailwind 4.2+)start-4
{ end: 4 }, { insetE: 4 }inset-e-4 (Tailwind 4.2+)end-4
{ bgSize: 'auto 100px' }bg-size-[auto_100px] (Tailwind 4.1+)bg-[length:auto_100px]
{ bgSize: '--x' }bg-size-(--x) (Tailwind 4.1+)bg-(length:--x)
{ bgPos: '--x' }bg-(--x), which Tailwind reads as a colourbg-(position:--x)
{ shadowColor: x }, x known at runtimeshadow-(--…), which Tailwind reads as the shadowshadow-(color:--…)

You are affected when a test or a selector matches one of the 0.17 names. On Tailwind older than 4.2, start and end now style the element, and so does bgSize on 4.0.

The edit. Update the snapshots and selectors. Nothing in your sz code changes.

Logical block and inline keys need Tailwind 4.2

Section titled “Logical block and inline keys need Tailwind 4.2”

The block-direction keys lower to utilities Tailwind added in 4.2: pbs, pbe, mbs, mbe, scrollMbs, scrollMbe, scrollPbs, scrollPbe, insetBs, insetBe, borderBs, borderBe, blockSize, minBlockSize, maxBlockSize, inlineSize, minInlineSize and maxInlineSize. On an older Tailwind their classes style nothing. 0.17 wrote them without a word; the build now reports each one as dead-class:

[csszyx] src/Card.tsx:3:22: `pbs-4` (sz key `pbs`) is emitted by an sz prop and produces no CSS under this project's Tailwind (dead-class).

The edit. Upgrade Tailwind to 4.2 or later, or write the physical key (pt for pbs in a horizontal writing mode).

Tooling. csszyx check --rule dead-class lists every one.

migrate leaves classes your Tailwind does not serve

Section titled “migrate leaves classes your Tailwind does not serve”

0.17’s csszyx migrate turned some classes into sz that styles something the original never did: -z-auto became { z: 'auto' }, -m-1/2 became { m: -1 }, and a shorter prefix could take a longer one’s class. A sign on a keyword, a fraction on a key that takes none, and an opacity modifier on a value that is not a colour (m-2/50) now stay in className and are listed under Unrecognized classes. The longest matching prefix decides which key a class belongs to.

Where csszyx’s own tables would decide whether a class is served, migrate asks the stylesheets of your project with the Tailwind it installs, as csszyx check does, and the migration log says how many classes it asked about. Without a Tailwind v4 stylesheet to ask, the tables decide, and the log says why.

Two classes migrate now reads by their type hint: bg-[length:auto_100px] becomes { bgSize: 'auto 100px' } and bg-[position:10px_20px] becomes { bgPos: '10px 20px' }, also in the bg-(length:--x) form. 0.17 read both as the colour key bg. The 4.1 spellings bg-size-[…] still read as bgSize.

You are affected when you run csszyx migrate over classes like these.

The edit. Resolve the listed classes by hand or through csszyx migrate --audit, as for any unrecognized class.