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.
Upgrade in this order
Section titled “Upgrade in this order”-
Install 0.18 for
csszyxand every@csszyx/*package at once. The build stops on a runtime or an engine from another release, withruntime-version-skeworengine-version-skew, each naming the version to install. -
If
csszyx initwrote acsszyx.config.tsorcsszyx.config.jsfor you, replace it with the 0.18 template. -
Rewrite the renamed keys:
Terminal window npx @csszyx/cli migrate --keys-only --dry-run # previewnpx @csszyx/cli migrate --keys-only # apply -
Run
tsc --noEmit. It flagsszvrows that do not type-check, and calls totransform,csszyxTurbopackand the@csszyx/unpluginentry that use the 0.17 shape. -
Run
npx @csszyx/cli check. It lists the keys--keys-onlycould 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 onclass-precedenceorduplicate-sz; if your CI relied on that, opt back in. -
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-classIts findings are
info, so the audit does not fail the run. -
Build. If the build stops, its message names the stylesheet or the option to fix. A line ending in
(dead-class)names anszclass that produces no CSS, and one ending in(unsigned-value)a sign your Tailwind has no negative for.
| Change | What you see | Found by | Fixed by |
|---|---|---|---|
| Tailwind prefix | Classes gain tw:; the build can stop on your stylesheets | the build, csszyx check | you |
| A later key covers an earlier one | { pb: 2, p: 4 } emits p-4 | the merge audit | you |
sz covers a className class | className="card pb-2" sz={{ p: 4 }} emits card p-4 | the merge audit | you |
| A spread splits the class names | spread-split-class warning; Svelte attribute_duplicate | the build, csszyx check | you |
szcn merges on compiled CSS | szcn keeps or drops different classes | dev warning | you |
font-variant-numeric keys | [csszyx] removed-key: on tabularNums | the build, csszyx check | migrate --keys-only |
touch-action keys, contain | [csszyx] moved-value: on touch: pan-x | the build, csszyx check | migrate --keys-only |
szv() returns SzProps | type errors in szv rows | tsc | you |
transform options | [csszyx] options-object-required: thrown | tsc, runtime throw | you |
csszyxTurbopack options | options-object-required thrown | tsc, runtime throw | you |
@csszyx/unplugin exports | an import that no longer resolves | tsc, the bundler | you |
@csszyx/compiler exports | an import that no longer resolves | tsc, the bundler | you |
| Engine on its own | transformSource no longer exported from the root; Cannot find package '@csszyx/core' | tsc, the bundler, the import of @csszyx/compiler/engine | you |
| Engine at the compiler’s version | engine-version-skew | the first engine call | you |
| Runtime at the plugin’s version | runtime-version-skew | the build start | you |
| Core in Node only | verifyMangleMapIntegrity not exported; Could not resolve "@csszyx/core" in a browser build | tsc, the bundler | you |
@csszyx/core/native results | no diagnostics on a result; rootDir not accepted | tsc | you |
| Thrown errors carry a code | err.code is native-engine-unavailable where it was CSSZYX_NATIVE_UNAVAILABLE; messages start [csszyx] <code>: | your catch | you |
| ES modules only | TS1471 or TS1479 in a CommonJS TypeScript file | tsc | you |
Adapters under require | require(...) returns an object | your config fails to load | you |
| Vue and Svelte take static values only | this sz value is only known at runtime | the build | you |
parseObjectLiteral removed | an import that no longer resolves | tsc, the bundler | you |
| Framework parsers | Svelte cannot parse this file on sz="{ p: 4 }"; Vue 3.5 and Svelte 5 required | the build | you |
mergeClassAttributes removed | an import that no longer resolves | tsc, the bundler | you |
A template string without ${} | { p: `4` } emits p-4 | your snapshots | nothing |
| Build-log diagnostics | [csszyx] unknown-key: src/Card.tsx:3:16 where 0.17 printed Unknown property "bgg" | your log filters | you |
check --json kinds | kind is never other for an engine finding | your CI filter | you |
check --json version 2 | "version": 2; message holds no help: line | your CI parser | you |
MCP csszyx_validate fields | each warning is an object, not a string | your MCP client | you |
csszyx.config | `development` is not read from this file at every build | the build, csszyx check | you |
check exit code | check passes with a class-precedence or duplicate-sz finding | your CI | you |
Build-time dead-class | produces no CSS under this project's Tailwind (dead-class) | the build | you |
| A sign Tailwind serves no negative for | serves no negative … (unsigned-value) | the build, csszyx check | you |
| A variant Tailwind does not serve | [csszyx] unserved-variant: on { hidden: { p: 4 } } | the build, csszyx check, dev runtime | you |
| A runtime value under a variant key | key `hover` holds a value only known at runtime | the build | you |
| A slash opacity in a colour string | [csszyx] slash-opacity-string: | the build, csszyx check | you |
| A slash opacity in a colour object | [csszyx] color-slash-opacity: | the build, csszyx check | you |
| Spellings every v4 serves | start-4 where 0.17 wrote inset-s-4 | your snapshots | nothing |
| Logical keys need Tailwind 4.2 | (dead-class) on pbs-4 | the build, csszyx check | you |
migrate asks your Tailwind | more entries under Unrecognized classes | csszyx migrate | you |
| Config edits apply without a restart | findings printed again at their new level | dev and watch lanes | nothing |
Classes carry your Tailwind prefix
Section titled “Classes carry your Tailwind prefix”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-disagreementA 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:
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.
sz removes a className class it covers
Section titled “sz removes a className class it covers”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-classThe 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 merges only what compiled CSS proves
Section titled “szcn merges only what compiled CSS proves”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:
| Call | 0.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 ! flag | both 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-missingA 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) | After | Group |
|---|---|---|
{ 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-keyThe 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.
| Before | After |
|---|---|
{ 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-valueThe 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.
szv returns SzProps and checks every row
Section titled “szv returns SzProps and checks every row”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 keyszv({ variants: { size: { sm: { hover: { bgg: "red-500" } } } } }); // ❌ nested tooszv({ variants: { layout: { row: { display: "flexx" } } } }); // ❌ not a display valueAn 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:
// beforeconst pill: SzObject = { rounded: "full", px: 3 };const o: SzObject = badgeSz({ shape: "pill" });
// afterconst 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 takes an options object
Section titled “transform takes an options object”transform from @csszyx/compiler took the variant prefix and the mangle map as
positional arguments. Both are now keys of one options object:
// beforetransform(sz, "hover:");transform(sz, "", map);
// aftertransform(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-requiredTooling. tsc --noEmit. csszyx migrate does not rewrite calls.
csszyxTurbopack takes one options object
Section titled “csszyxTurbopack takes one options object”csszyxTurbopack took the Turbopack config to extend first and loader options
second. Everything is now one object, with the config under turbopack:
// beforecsszyxTurbopack({}, { tailwindStylesheet: "app/globals.css" });csszyxTurbopack(existingConfig, { mergeCoveredClasses: false });
// aftercsszyxTurbopack({ 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-requiredTooling. tsc on a typed next.config.ts; the throw otherwise.
@csszyx/unplugin exports seven names
Section titled “@csszyx/unplugin exports seven names”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:
// beforeimport { mangleCSS } from "@csszyx/unplugin";
// afterimport { 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.
// beforeimport { OxcRustNotImplementedError, ASTBudgetExceededError } from "@csszyx/compiler";if (err instanceof OxcRustNotImplementedError) { /* … */ }
// afterimport { 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.
// beforeimport { parseStaticObjectLiteral } from "@csszyx/compiler";const object = parseStaticObjectLiteral("{ p: 4 }");
// afterimport { 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.
// beforeimport { szDiagnosticKindOf } from "@csszyx/compiler";const kind = szDiagnosticKindOf(result.diagnostics[0]);
// afterconst 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.
// beforeimport { szKeySuggestionFor } from "@csszyx/compiler";const key = szKeySuggestionFor(result.diagnostics[0]);
// afterimport 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.
// beforeimport { transformSource } from "@csszyx/compiler";
// afterimport { 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:
npm install @csszyx/corepnpm add @csszyx/coreyarn add @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-….mjsAn 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/runtimehelp: install @csszyx/[email protected]; the build and the runtime lower sz objects with rules from the same releaseYou 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:
// beforeimport { verifyMangleMapIntegrity } from "@csszyx/runtime";if (!verifyMangleMapIntegrity()) abort();
// afterimport { 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.
Errors csszyx throws carry a code
Section titled “Errors csszyx throws carry a code”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:
// beforeconst csszyx = require("@csszyx/vue-adapter");
// afterconst { 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 (
-2and+4included) 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 }]; nullandundefined, 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-valueThe 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:
[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-keyYou 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:
# beforecsszyx check --ignore-rule other# aftercsszyx 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:
| Field | Version 1 | Version 2 |
|---|---|---|
version | 1 | 2 |
$schema | absent | https://csszyx.com/schemas/check-v2.json |
message | the sentence, then help: and note: lines | the main sentence alone |
help | inside message | its own field, without the help: label; absent when there is none |
note | inside message | its own field, without the note: label; absent when there is none |
line | the first :<number> found in message, or absent | the engine’s position of the finding; absent for a diagnostic about the whole file |
column | absent | 1-based, in UTF-16 code units, beside line |
args | absent | the values the sentence names, by name, such as { "key": "bgg", "suggestion": "bg" } |
url | absent | the page that explains the kind, https://csszyx.com/e/<kind> |
level | absent | info, 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:
// beforeconst [sentence, ...rest] = finding.message.split('\n');// afterconst { 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.
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-invalidcsszyx 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 fails only on error findings
Section titled “csszyx check fails only on error findings”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:
npx @csszyx/cli check --fail-on infoTo 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-variantYou 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-cssYou 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-stringThe 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-stringThe 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:
sz | 0.17 | 0.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 colour | bg-(position:--x) |
{ shadowColor: x }, x known at runtime | shadow-(--…), which Tailwind reads as the shadow | shadow-(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.