Plumeria 18.3
Plumeria v18.3 establishes a clear way to test styles.
Each bundler package now stands on its own. Call @plumeria/unplugin or @plumeria/turbopack-loader directly with a source string, and it compiles that source without a bundler or DOM. The rewritten code and stylesheet come back as ordinary values that any test runner can assert on.
This avoids importing @plumeria/core at run time, so tests no longer need a stub that invents class names.
The transform is a function
The compiler resolves css.create, and the compiler is an ordinary function. Give it a source string and it returns the rewritten code alongside the stylesheet. No component, DOM, or bundler is required:
const { unpluginFactory } = require('@plumeria/unplugin/factory');
const compile = async (source) => {
const plugin = unpluginFactory(undefined, { framework: 'vite' });
const { code } = await plugin.transform.call(
{ addWatchFile() {} },
source,
`${__dirname}/fixture.tsx`,
);
const cssId = code.match(/import "(.+\.zero\.css)"/)[1];
return { code, css: plugin.load(plugin.resolveId(cssId)) };
};
test('two properties become two atoms', async () => {
const { code, css } = await compile(`
import * as css from '@plumeria/core';
const styles = css.create({ box: { padding: 16, color: 'red' } });
export const A = () => <div classStyle={styles.box} />;
`);
expect(code).toMatch(/className=\{"\S+ \S+"\}/);
expect(css).toContain('padding: 16px');
expect(css).toContain('color: red');
});@plumeria/unplugin/factory is new in this release. It exposes the factory directly so CommonJS runners such as Jest can require it.
Only the boilerplate in this test is specific to Jest. For Vitest, import test and expect from vitest and use ESM instead of require and __dirname. For node:test, import test from node:test and use node:assert. All three runners call the same compiler.
Next.js tests the loader its own build runs
@plumeria/turbopack-loader is a webpack loader. Calling it directly requires four properties on this:
const loader = require('@plumeria/turbopack-loader');
const fn = loader.default ?? loader;
const compile = (source) =>
new Promise((resolve, reject) => {
fn.call(
{
resourcePath: `${__dirname}/fixture.tsx`,
async: () => (err, content) => (err ? reject(err) : resolve(content)),
addDependency: () => {},
clearDependencies: () => {},
},
source,
);
});The test calls the same loader that withPlumeria installs, and it produces the same class names as the plugin:
export const A = () => <div className={"xqqbxt1d xq96bg3w"} />;The loader writes its stylesheet only when NODE_ENV is development or production. Set NODE_ENV=development when a test also needs to inspect the stylesheet.
Add @plumeria/turbopack-loader to your dev dependencies when testing it directly.
What each layer can see
Style tests answer three different questions. Each belongs at a different layer.
What did it compile to? Call the transform directly, as shown above. This works in any test runner.
Does the component behave correctly? Use Vitest with the plugin in its config. This layer needs the transform in the module pipeline. By the time the markup reaches the DOM, the styling prop is gone:
<h1 class="xvdv6o3r xggb8uiu xvazecna">Vite + React</h1>Does the page look right? Use an end-to-end test in a real browser. jsdom can see attached classes, but it cannot verify how the styles render.
One rule applies to all three layers: do not hard-code generated class names in a component test. Each atomic class name is a hash of one property–value pair. Adding a property therefore changes the list and breaks any test that spells it out. When class names matter, derive them with a compile test instead.
The full write-up is in Testing.
What landed in 18.2.x
Plumeria 18.2 received thirty-four patches. Together, they expanded which relationships styles can express, made CSS Modules migration work in both directions, and made the generated cascade more predictable.
Functional pseudo-classes for marker and extended
css.marker and css.extended now accept functional pseudo-classes such as :has(), :is(), :not(), and :nth-child(). Marker variable names are derived from a hash, so arguments and identifier-unsafe characters no longer produce invalid custom properties or collisions.
With :has(), a descendant can activate styles elsewhere below the same marker. With :defined, the same APIs express an always-on descendant relationship. Together with selector keys, this makes descendant, child, and sibling relationships expressible without placing DOM paths in style objects.
Generated marker variable names changed in 18.2.0, but builds regenerate the stylesheet automatically.
CSS Modules migration in both directions
The codemod can adopt a CSS Modules project:
npx @plumeria/codemod migrate --from css-modulesIt creates *.styles.ts files, rewrites imports and className, restores compositions, and translates descendant relationships to marker and extended. Unsupported or ambiguous selectors are reported and left unchanged.
It can also export Plumeria styles back to CSS Modules:
npx @plumeria/codemod migrate --from plumeriaThe reverse migration handles function styles through CSS variables, writes themes and animations to global.css, follows aliased and indexed imports, and expands marker and extended to the CSS they represent. Use --dry-run before writing files.
Fixes from 18.2.20 through 18.2.27 closed the round trip. Exporting, adopting, and exporting again now reaches a fixed point on the second pass in each direction. See the codemod documentation for supported conversions and reports.
Normalized merging and specificity
18.2.5 stopped merging a shorthand and longhand by source order. They remain independent atoms, and the more specific property wins through its position in the shorthand graph. For example, paddingBlockStart outranks paddingBlock, which outranks padding.
Conditional and nested declarations receive additional specificity. Identical selectors and at-rules are merged recursively without reordering their declarations, and the development stylesheet now goes through the same optimization. This keeps development and production behavior aligned.
Conditions are ordered when one provably contains another. A narrower @media or @container interval is placed after the broader one. States such as :hover and :focus that can hold at the same time are settled by classStyle composition order when neither otherwise outranks the other.
The implementation moved through zss-engine 2.4.1, 2.4.2, 2.4.3, and 2.5.0 before reaching 2.6.0. These releases normalized shorthand merging, corrected the logical-property graph, added missing shorthands, and made comparable media and container conditions understandable as intervals.
The complete cascade model is documented in Specificity.
New ESLint rules for ambiguous property pairs
18.2 added four rules:
no-order-dependent-overlapreports overlapping properties or states when neither side has a stable priority. It is enabled as a warning inrecommended.expand-border-shorthandsexpands border bundles into width, style, and color declarations.no-physical-propertieskeeps a project on logical property names.no-logical-propertieskeeps a project on physical property names.
The last three are optional. Used together in the appropriate direction, border expansion and a property-naming rule remove the property pairs that specificity alone cannot rank. See Optional rules for configuration and examples.
ESLint checks also reach properties inside function style keys, and validate-values gained fixes for CSS variables, easing functions, and border values.
More reliable style resolution
The compiler now reports a style prop that is never applied instead of silently dropping it. Conditional style arrays, imported function styles, imported constants, createStatic, and createTheme values also resolve across files.
In 18.2.26, each component began reading its own style prop table. This fixed components that shared a prop name and reduced a 400-component stylesheet benchmark from 83ms to 44ms. Style function defaults now become CSS variable fallbacks, and parameterless style functions compile like their static equivalents.
File scanning is sorted for reproducible stylesheet output. The dependency update in 18.2.25 also included the @rust-gear/glob 1.1.1 patch.
Five new Headless UI component groups
18.2.33 added:
LabelSeparatorAvatarwithImageandFallbackProgresswithIndicatorVisuallyHidden
@plumeria/headlessui now exports twenty-five component groups. The package also loads directly through Node's ESM resolver.
Testing
Learn more about testing styles that are resolved at build time
unplugin
Learn more about @plumeria/unplugin
Codemod
Migrate between Plumeria and CSS Modules
Specificity
Understand property, condition, and composition priority
Optional ESLint rules
Remove property pairs that depend on source order
18.3.16 (Sep 4, 2026)
- Update: [email protected]
- Fix: a custom property written in camelCase, such as
--fooBar, was emitted kebab-cased as--foo-barwhilevar(--fooBar)in a value was left as written, so the variable never resolved. Custom property names now keep their case. - Fix: an at-rule nested inside another at-rule dropped the outer condition, so
@mediawrapping@supportscompiled to the@supportsblock alone. Both conditions are now kept, and@supports,@layerand@scopeare accepted alongside a nested query in the types. - Fix: a hex code inside
url()or a quoted value was replaced with its color name, sourl(#fff)becameurl(white)andcontent: '#fff'becamecontent: 'white'. Those values are now left as written. - Update:
navy,springgreen,powderblueandlavenderblushare normalized like the other named colors.
18.3.15 (Sep 3, 2026)
- Fix: an error thrown while scanning the file a style is declared in was reported under the name of the file that uses the style. It is now reported under the name of the file it came from, including when the style is reached through a re-export.
18.3.14 (Sep 1, 2026)
- Fix: a component held by a call —
memo,forwardRefor any other wrapper — was not read as a component, so a style reached through its non-destructured parameter threwDynamic or unresolvable style object, and the styles handed to it were found by scanning the file rather than by name. The function a call wraps is now read as the component it is. - Change: a wrapped component is now held to the same rule as every other one, so a style prop it never applies to an element is reported where it is received. Passing that style on to another component already failed the build, but the error was raised in the component that received it next.
18.3.13 (Sep 1, 2026)
- Fix: a component exported as the default of its file was keyed by
defaultinstead of by the name it is declared with, so a named component sharing that file and that prop name could be found in its place and its styles bound to the wrong element. A default export now resolves to the local binding it names. - Fix: a style prop on a component the file does not declare at the top level — one wrapped in
memo,forwardRefor any other call — was resolved by taking the first component in the file that takes a prop of that name, which depends on registration order and could bind another component's style or none at all. Every candidate in the file is now collected, and the key the caller passed selects among them at runtime.
18.3.12 (Sep 1, 2026)
- Fix: a style handed to a component through a prop threw
Dynamic or unresolvable style objectwhen that component was written as a function declaration or exported as the default. Every form a component can take is now read, andcompileCSSfalls back to the file's other components when the owner holds no entry, as the bundler plugins already did. - Fix: a constant read through more than one property, such as
theme.colors.primary, resolved to nothing, so the declaration was dropped from the sheet without an error and an interpolation of it was left empty. The whole path is now walked.
18.3.11 (Aug 31, 2026)
- Fix:
compileCSSread each globbed file at the path the glob returned, which is relative tocwd. Passing acwdother than the process directory threwENOENT. The path is now resolved againstcwdbefore it is read. - Perf: a value that holds none of
kf-,vt-orcr-skips the reference marker scan, which the on-demand walk had been running over every string it visits. Every match begins with one of the three, so the set of references found is unchanged.
18.3.10 (Aug 30, 2026)
- Perf: a value that holds no
var(skips the custom property scan, which the on-demand walk had been running over every string it visits. The gate is implied by the pattern it guards, so the set of variables found is unchanged.
18.3.9 (Aug 30, 2026)
- Update README.md
18.3.8 (Aug 29, 2026)
- Update README.md
18.3.7 (Aug 29, 2026)
- Fix: a theme variable written with a fallback, or nested inside another variable's fallback, was not recognised as used, so its declaration never reached the sheet and the value fell back for want of anything to read. The name is now read up to where the fallback begins, rather than up to a closing parenthesis that may belong to an inner
var().
18.3.6 (Aug 29, 2026)
- Fix: a
css.keyframesorcss.viewTransitionbinding read inside a template literal or a concatenation lost thekf-/vt-prefix its own name carries, so the value named a rule that was never emitted and ananimationshorthand built that way animated nothing. The binding now reads the same wherever it is read. - Fix: a reference sitting in the middle of a value, as an
animationshorthand holds one, is now found when collecting the rules a sheet needs, instead of only when it is the whole value.
18.3.5 (Aug 29, 2026)
- Fix: a
css.createThemeselector written as anything other than a string literal — a template literal, or a name the file declares — was read as an empty selector, which dropped the theme's whole rule and left the styles pointing at custom properties nothing declared. The selector is now resolved the same way a style value is, so a name or acreateStaticentry works, and two themes that differ only by selector stay apart. - Fix: a selector that still cannot be read at build time is now reported where it is written, instead of compiling to no CSS at all.
18.3.4 (Aug 29, 2026)
- Fix: editing only the selector passed to
css.createThemeleft the rule under the old selector setting the same custom properties, because their names were hashed from the value alone and never from the selector. The selector is now part of the hash, so the rule left behind declares variables of its own and stops competing with the new one. - Fix:
@plumeria/utilshashes acreateThemedeclaration the same way in the scan pass as in the transform, which had left the selector out of the scan pass alone.
18.3.3 (Aug 28, 2026)
- Fix:
sort-propertiesplacesanimationFillMode,animationComposition,animationTimeline,animationRange,animationRangeStart,animationRangeEnd,borderBlockColor,borderBlockStyle,borderBlockWidth,borderInlineColor,borderInlineStyle,borderInlineWidth, andwhiteSpaceCollapsein the group they belong to. The order table did not carry them, so--fixsorted each one past every property it knows. - Fix:
validate-valuesacceptsanchor()only in the inset properties andanchor-size()only in the accepted@position-tryproperties. Both were accepted wherever a length is, and both are invalid outside those properties. - Fix:
validate-valuescompares keywords, global values, and units without regard to case, sodisplay: 'BLOCK'andwidth: '10PX'no longer report. CSS keywords are ASCII case-insensitive. - Feat:
validate-valueschecks the value ofanimationRange,animationRangeStart, andanimationRangeEndagainst the named timeline ranges, and reports a numericanimationTimeline. None of the four were checked at all.
18.3.2 (Aug 27, 2026)
- Fix:
migrate --from plumeriawrites acss.keyframesinto the CSS Module that reads it rather than into the global stylesheet. A CSS Module renames the value ofanimation-namethe way it renames a class, so a global@keyframesnever matched the renamed reference. One acss.viewTransitionnames is written globally as well, since a::view-transition-*rule is not scoped. - Fix: the global stylesheet is looked for at
app/global.cssandsrc/app/global.css, under either theglobal.cssorglobals.cssspelling, before a newstyles/global.cssis created. An App Router project imports the former from its root layout, and nothing imported the file the migration used to write. - Fix: only the definitions an exported style reaches are written out. A
css.keyframes,css.viewTransition, orcss.createThemethat nothing reads, or that only a file the migration held back reads, stays in the TypeScript file that declares it. - Feat:
migrate --from css-modulesreads a stylesheet's@keyframesback into acss.keyframesbinding, and splits ananimationshorthand naming one into its longhands. A keyframe step no longer reports as an unsupported selector.
18.3.1 (Aug 27, 2026)
- Update: README.md
Thanks for building with Plumeria! If you have any feedback or questions, please let us know on GitHub Discussions or GitHub Issues.