Plumeria 18.2

refirst11
refirst11Core team members

Plumeria v18.2 lifts a small build error that turned out to be holding back a lot.

css.marker and css.extended take a pseudo-class as their second argument. Until now, passing a functional one — :has(), :is(), :not(), :nth-child() — failed the build. The generated variable name carried the argument along with it, so :nth-child(2n) produced --card-nth-child(2n), which is not a valid custom property. The parser stopped there, and the error it printed was a CSS syntax error with no trace of the API that caused it.

The name is now derived from a hash of the id and the pseudo, so the argument never reaches it:

/* before — invalid */
--card-nth-child(2n): 1;

/* 18.2 */
--xzh6qdtt-card-nth-child: 1;

The hash also settles two things that were left to chance. An id holding a character that is not identifier-safe no longer breaks the name, and two markers that share an id and differ only inside the parentheses no longer collide.

Generated variable names change in this release. The stylesheet is regenerated in full on every build, so nothing has to be updated on your side — but if you have been reading --card-hover in devtools, it now reads --xxxxxxxx-card-hover.


What it opens up

:has() is the interesting one. A marker normally travels downward: a state on an ancestor reaches its descendants. With :has() the state belongs to a descendant, the marker lands on the element containing it, and the styles land anywhere below that element.

const styles = css.create({
  wrap: {
    ...css.marker('wrap', ':has(a:hover)'),
  },
  sibling: {
    color: 'gray',
    [css.extended('wrap', ':has(a:hover)')]: { color: 'red' },
  },
});

export const Row = () => (
  <div classStyle={styles.wrap}>
    <a href="#">hover me</a>
    <span classStyle={styles.sibling}>reacts</span>
  </div>
);

Hovering the link changes the span. In CSS you would write .wrap:has(a:hover) .sibling, which spells out the path between the two elements. The marker spells out nothing: it is linked to extended by an id, so the styles reach every descendant at any depth, and nesting the span one level deeper does not break the pair.

:defined is the quiet one. It matches every standard element, so the marker is permanently on, and what remains is the plain descendant relation with no state involved.


Every combinator, expressed

With functional pseudo-classes available in both places — as the marker's argument, and as a selector key wrapping a combinator — the relations combinators used to carry are all expressible.

CSSPlumeria
.a .b — descendant, on a statecss.marker(id, ':hover') + css.extended
.a .b — descendant, no statecss.marker(id, ':defined') + css.extended
.a > .b — childcss.marker(id, ':defined') + css.extended on the child
.a + .b, .a ~ .b — siblingcss.marker(id, ':defined') + css.extended on the sibling
.a:hover ~ .b — sibling reacting to a statecss.marker(id, ':has(a:hover)') + css.extended
.a:has(.b) .c — a descendant's state, back up and down againcss.marker(id, ':has(input:focus)') + css.extended

Every row on the left names a path. None of the marker rows do — the pair is linked by an id, so the scope is every descendant at any depth, and rearranging the tree between them changes nothing.


Which one to write

There is a second route the table does not show. A combinator is legal inside a functional pseudo-class — no-combinator permits it there — so a relation can also be written as a selector key, anchored on something you write yourself:

const styles = css.create({
  cell: { ':is([data-row] > *)': { color: 'red' } },
});

The two routes overlap almost entirely, so the question is which to reach for by default. We measured rather than guessed.

Stylesheet bytes, from a production vite build of the same markup:

Targets under one relationSelector keymarker / extended
194 B147 B
5281 B43 B

One target, and the marker pair costs more — it emits the marker rule and the container query where a selector key emits one rule. Five, and it is a fifth of the size: every extended sharing an id lands in the same @container block, while a selector key repeats its selector for every declaration.

Style recalculation, over a class toggle in headless Chromium, median of 30 runs:

ElementsPlain CSSSelector keymarker / extended
5000.009 ms0.006 ms0.012 ms
2,0000.008 ms0.007 ms0.012 ms
8,0000.005 ms0.005 ms0.012 ms

One element toggled per run, which is what a hover or a focus is. The marker column is flat across a sixteen-fold increase in page size, and the gap to plain CSS is 0.007 ms — four hundredths of one frame.

The number that is not flat is the one nobody hits: toggling every anchor at once costs 26 ms at 8,000 elements against 7 ms for a selector key. Style queries are re-evaluated per container, so a broad invalidation pays per container. No interface toggles eight thousand components in the same frame, but that is where the difference lives.

So: reach for marker / extended first. It is smaller from the second target onward, its recalculation cost does not grow with the page, and — the reason it exists — it names no path, so nesting the target one level deeper leaves it working. The selector key is the escape hatch, for markup you do not write and therefore cannot attach a style to.

Neither route reintroduces a dependency on DOM shape into the style objects themselves, which is what the combinator ban was protecting in the first place.


Migrating from CSS Modules

The relations above are what made a CSS Modules migration mechanical, so 18.2 ships one:

npx @plumeria/codemod migrate --from css-modules

It reads every *.module.css, writes a *.styles.ts beside it, and points the consumers at it — the import, the className prop, the class names, and composes, which becomes an array at the call site.

/* Card.module.css */
.base { font-size: 12px }
.card { composes: base; padding: 16px }
.card:hover { background: teal }
.card .card-title { color: red }
/* Card.styles.ts */
import * as css from '@plumeria/core';

export const styles = css.create({
  base: { fontSize: 12 },
  card: {
    ...css.marker('card', ':defined'),
    padding: 16,
    ':hover': { background: 'teal' },
  },
  cardTitle: {
    [css.extended('card', ':defined')]: { color: 'red' },
  },
});

The descendant rule is the part that could not be written before this release. .card .card-title has no combinator to translate into, so it becomes a marker on the parent and an extended key on the child — and because both class names appear in the same selector, the transform reads which is which from the stylesheet alone. No markup is inspected.

What it will not convert, it leaves in place and names:

src/Card.module.css
  10:1  sibling-combinator  .item + .item
        A marker carries no order. Write the relation as a selector key.

Sibling combinators, :global, and a composes that reaches into another file are reported rather than guessed at. The exit code is 1 while anything remains, so the command composes with a script.

Moving back: exporting to CSS Modules

Adopting a styling engine should not be a one-way door. The codemod runs in reverse just as mechanically. On this documentation site — 25 files using themes, keyframes, marker / extended, aliased imports and several css.create calls per file — it exports 15 stylesheets and reports nothing.

npx @plumeria/codemod migrate --from plumeria

It exports css.create definitions to standard *.module.css files, points consumers at className, and handles function style keys by passing them through CSS variables:

/* Card.tsx */
- import * as css from '@plumeria/core';
+ import styles from './Card.module.css';
+ export { styles };

- export const styles = css.create({
-   card: { padding: 16 },
-   size: (width: number) => ({ width }),
- });

  export const Card = ({ width }: { width: number }) => (
-   <div classStyle={[styles.card, styles.size(width)]} />
+   <div
+     className={[styles.card, styles.size].join(' ')}
+     style={{ ['--styles-size-width' as string]: typeof width === 'number' ? `${width}px` : width }}
+   />
  );

Global constructs like css.createTheme, css.keyframes, and css.viewTransition are extracted and appended to src/styles/global.css (or styles/global.css) as CSS custom properties and standard @keyframes rules.

Definitions do not have to sit in the file that uses them. Imports are followed through tsconfig.json paths, directory index files, and filenames carrying a dot, so css.createStatic values reached under an alias are inlined into the CSS rather than reported as dynamic. A file that only defines styles is left where it is, exactly as the forward migration leaves the original stylesheet in place, and its consumers are pointed at the generated module instead.

css.marker and css.extended come across as the custom property and the @container style() rule they compile to, and a file holding several css.create calls is written to one stylesheet.

Run eslint --fix with the recommended config first, then start with --dry-run to inspect what would change. Anything with dynamic semantics that cannot be cleanly converted is reported and left untouched.

Plumeria merges composed styles by property name before it emits anything. classStyle={[styles.a, styles.b]} resolves color once — rightmost wins — and a single declaration reaches the element. Not every pair is the array's to settle: where one property covers another, or sits under a condition, rank decides instead. A longhand carries one step more than the shorthand covering it, a declaration under an at-rule one more again, and the array never enters into it.

The export writes one class per css.create key, so a stylesheet has to reach those same answers with the two things it has. It orders the rules so that every call site's array order holds at once, writes a shorthand before the longhand narrowing it, and puts a narrower condition after the one it implies. Where two call sites compose the same pair in opposite orders, no order satisfies both: the one left over carries an extra class holding only the disputed declarations. A composition whose members come from separate modules is folded into a single class, because no declaration order reaches across two stylesheets.

Which is a lot of claiming for something a reader cannot see. So CI exports this site and the end-to-end app, exports them a second time and requires nothing to move, adopts them back, and then opens both the Plumeria build and the exported one in a browser to compare what the two pages actually paint.

Each direction is a fixed point after the first run:

f(f(x))=f(x)f(f(x)) = f(x)

The round trip is not the identity, and CI does not ask it to be. A project that leaves and comes back has its constants folded and its merged classes named, so exporting it again reaches further than the first export did. What has to hold is that nothing new is reported and nothing new fails to compile.


18.2.34 (Aug 25, 2026)

  • Fix: a style function with a destructured parameter, called with no argument at all, aborted the build under @plumeria/unplugin with an internal TypeError rather than compiling. ({ tone = 'navy' }) => ({ backgroundColor: tone }) reached for as styles.box() is the same call as styles.box({}), and now resolves to the same class; a parameter left without a default is reported the way a named call with a missing key already was. The call already worked under @plumeria/turbopack-loader, so this only ever showed up outside Next.

18.2.33 (Aug 25, 2026)

  • Feat: @plumeria/headlessui adds Label, Separator, Avatar (Image, Fallback), Progress (Indicator) and VisuallyHidden, bringing the exported set to twenty-five component groups. All five are the small primitives a form or a profile row is built from, and until now they were the ones you had to reach past the package for. Separator is the odd one: it already existed as a part inside Select, DropdownMenu, ContextMenu and Menubar, but not on its own.

18.2.32 (Aug 25, 2026)

  • Fix: @plumeria/headlessui loads under Node's ESM resolver. The package is "type": "module", but its entry re-exported the components without file extensions — which a bundler accepts and Node does not — so import('@plumeria/headlessui') failed with ERR_MODULE_NOT_FOUND anywhere it was not being bundled.
  • Update: the six Arrow components drop the classStyle declaration they carried. classStyle already reaches them through the SVGAttributes augmentation in @plumeria/core/class-style, the same way it reaches every other component and every SVG element, so the declaration was restating what was in scope. Nothing changes at the call site and the compiled JavaScript is unchanged.
  • Update: @plumeria/inspector no longer depends on @plumeria/headlessui. It never imported it, so installing the inspector pulled the whole Radix tree in for nothing.

18.2.31 (Aug 24, 2026)

  • Fix: allow any number as a linear() easing stop in validate-values, so generated spring and bounce curves that overshoot past 1 or dip below 0 are no longer reported. Leading-dot numbers such as .5 are now reported in linear() and cubic-bezier(), as they already were everywhere else.

18.2.30 (Aug 24, 2026)

  • Fix: accept CSS variables in individual cubic-bezier() and linear() arguments, and stop text-decoration-line from accepting arbitrary tokens in validate-values.

18.2.29 (Aug 23, 2026)

  • Fix: support nested CSS variables while rejecting malformed variables and invalid function values in validate-values.

18.2.28 (Aug 22, 2026)

  • Update: the package now says on npm what it does. migrate --from css-modules is the reason most people reach for it, and neither the description nor the keywords named CSS Modules at all.

18.2.27 (Aug 22, 2026)

  • Update: migrate --from css-modules converts several selector shapes it used to report. A compound class rides on the class written last, a chain of three or more classes becomes one marker per level with each gated by the one above it, a bare tag under a class takes a key of its own that the consumer rewrite attaches to the markup it can see — a child combinator taking a different key from a descendant one, and reaching one level rather than any — and an attribute selector or a :global ancestor becomes an :is() selector key.
  • Update: the styling prop is written in stylesheet order rather than in the order the class list carried, a composes expansion included, because a class list has no order of its own in CSS. Reads of two stylesheets keep the slots they were given, since which of them the bundler puts first is not a fact one file holds.
  • Fix: the migration no longer writes a call site it cannot answer for. A class a rule was refused for, and a pair whose order Plumeria's rank settles against the one CSS gives it — a declaration under an at-rule, or further down the shorthand graph, outranks one written after it — are reported and the stylesheet is held whole: nothing written and no consumer moved, so a later run converts it once the rule named in the report is dealt with.

18.2.26 (Aug 21, 2026)

  • Fix: two components declared in one file that receive a style through the same prop name shared one lookup table, so the second rendered with the first one's keys and reached the element with no class at all. Each component now reads the table it owns, in the bundler plugins and in the generated stylesheet alike.
  • Fix: a style function's parameter default was read as neither a default nor a name, so (c = 'red') => ({ color: c }) produced an empty class even when an argument was passed. A default now compiles to the CSS variable's fallback, so box() and box('blue') share one class and only the second writes an inline style.
  • Fix: a style function that takes no parameter resolved to nothing when it was called, so () => ({ color: 'red' }) left the styling prop off the element entirely. It now compiles to the class its static equivalent would.
  • Fix: a style key that is only digits was dropped without a word. css.create({ 1: { color: 'red' } }) is now reported, the way @plumeria/no-invalid-selector already reported it; a quoted '1' and a name carrying digits are unaffected.
  • Fix: a style key holding a quote or a backslash was written into the run-time lookup table unescaped, which made the compiled module invalid JavaScript.
  • Fix: css.use() with no arguments, and css.use() on a style that cannot be resolved, survived into the output after the @plumeria/core import had already been removed, leaving a module that throws on load. The first compiles to an empty class and the second is reported.
  • Fix: css.keyframes() with no argument aborted the transform with an internal error instead of being skipped, the way create() and viewTransition() already are.
  • Update: the style prop table is read by the owning component's key rather than scanned, so a project with many components generates its stylesheet faster — 44ms against 83ms on a 400-component benchmark.

18.2.25 (Aug 19, 2026)

Update dependencies

18.2.24 (Aug 18, 2026)

  • Fix: migrate --from css-modules overwrote a *.styles.ts that was already there without reporting it. A stylesheet whose target exists is now reported as target-exists and left alone, the way the export already treats an existing *.module.css, and its consumers keep the import they had rather than being pointed at a file that was never written.
  • Fix: migrate --from css-modules left a style read with a run-time key spelling the stylesheet import it had just renamed, so s[name] survived as an unbound identifier. The read now carries the generated binding.
  • Fix: migrate --from plumeria folded a style key named by a constant only inside a composition array, so classStyle={styles[VARIANT]} kept the constant while the import that carried it was retired. A lone call site folds the same way.

18.2.23 (Aug 18, 2026)

  • Fix: migrate --from css-modules drops the pixel guard the export put around a function style argument, so styles.box(width) comes back as it was written rather than carrying a typeof test the custom property no longer needs. Left in, it was wrapped in another guard the next time the project was exported and the file stopped compiling, so a project with a function style in it could be adopted back but never exported again.
  • The round trip closes: a project exports, adopts back, and exports again, with each direction reaching a fixed point on its second pass.

18.2.22 (Aug 18, 2026)

  • Fix: migrate --from plumeria reproduces the rank a declaration is given in Plumeria — one step per shorthand covering the property, one more under an at-rule — which settles the pair rather than the order it was written in. It orders the rules and the declarations inside them, so a base longhand still outranks a shorthand set under a media query.
  • Fix: migrate --from plumeria recognises two properties that only partly overlap — borderColor against borderTop — as sharing ground, so the order the call site composes them in is carried into the stylesheet instead of being read as unrelated. A pair that both covers and partly overlaps keeps both answers, through a class that carries only the crossing declarations.
  • Fix: migrate --from plumeria writes a narrower condition after the one it implies, so a @media (min-width: 900px) rule still wins where a @media (min-width: 600px) rule also matches.
  • Fix: migrate --from plumeria folds a composition whose members come from separate modules into a single class in the first of them. Two stylesheets have no order that reaches both, and a fold puts the members back under one that does.
  • Fix: migrate --from plumeria carries the unit Plumeria would have added into an argument reaching a function style through a custom property, so styles.box(100) is still 100px after the export.
  • Fix: migrate --from css-modules finds a module the same way wherever it looks, so a style read as a value and a function style call are both restored in a file whose stylesheet sits beside it.

18.2.21 (Aug 18, 2026)

  • Feat: migrate --from plumeria resolves a style read with a key named by a constant — styles[size] where size is a const holding a string, in the same file or imported from another — to the class it names instead of reporting it, and removes a constant left naming nothing with it.
  • Fix: migrate --from css-modules writes a restored read with the name the import carries, so a composed array, a css.use call, and a local holding a style survive a file whose module was renamed on the way back.

18.2.20 (Aug 18, 2026)

  • Fix: migrate --from plumeria reproduces the order a classStyle array composes in, by ordering the generated rules to satisfy every call site, collapsing an unconditional array into one class that composes its members, and giving a call site that still disagrees an override class carrying only the disputed declarations.
  • Fix: migrate --from plumeria leaves a file the plan cannot export in Plumeria together with every file it reads definitions from, so a partial migration no longer leaves source that does not compile.
  • Fix: migrate --from plumeria converts negative values, css.use, a token read outside a style, a style arriving through a prop, and a classStyle on an element that already carries className, instead of reporting them or rewriting them by halves.
  • Fix: migrate --from plumeria writes the generated import with the other imports rather than where the css.create stood, and replaces the block appended to global.css on a re-run instead of appending it again.
  • Fix: migrate --from css-modules points a consumer at where the module actually landed, restores a composed array, a css.use call, and a function style call, and keeps one name per module when a file reads several.
  • Fix: migrate --from plumeria reports a style read with a computed key rather than exporting it, because the class it names is not known until it runs.

18.2.19 (Aug 17, 2026)

  • Feat: npx @plumeria/codemod migrate --from plumeria exports css.create definitions to CSS Modules, writes global styles (createTheme, keyframes, viewTransition) to src/styles/global.css, and rewrites consumers to use className.
  • Fix: import specifiers are resolved through tsconfig.json paths, directory index files, and filenames that carry a dot, so a definition reached under an alias is found instead of reported as dynamic.
  • Feat: css.createStatic values imported from another file are inlined into the generated CSS, and the import is removed only when nothing outside the exported styles still reads it.
  • Feat: css.marker and css.extended are expanded into the custom property and the @container style() rule they compile to.
  • Feat: several css.create calls in one file are written to one stylesheet, renaming a key an earlier call already claimed and repointing its usages.
  • Feat: a file that only defines styles is left untouched and its consumers are pointed at the generated *.module.css, matching how --from css-modules leaves the original stylesheet in place.

18.2.18 (Aug 16, 2026)

  • Update: groundwork for npx @plumeria/codemod migrate --from plumeria. Note: the command is usable from 18.2.19.

18.2.17 (Aug 16, 2026)

  • Update: README.md and documentation describe using --dry-run to preview a CSS Modules migration without writing files.

18.2.16 (Aug 16, 2026)

  • Update: README.md separates the rules enabled by the recommended config from optional property-policy and border-expansion rules.

18.2.15 (Aug 16, 2026)

  • Feat: two states that can hold at once, such as :hover and :focus, now settle their intersection by composition order. The atom from the style written further right in classStyle receives one more :not(#\#) and becomes a class of its own, so reversing the array reverses the winner and the module the bundler reached first no longer decides. Only a pair that is otherwise indistinguishable is weighted; a differing at-rule, shorthand depth or selector specificity is left alone, and an explicit compound such as :hover:focus stays above the weighting.
  • Feat: no-order-dependent-overlap also reports two states written in one style that can match at the same time and set the same property, including two states on one pseudo-element such as :hover::before and :focus::before. Mutually exclusive states, differing pseudo-elements and pairs already ranked by specificity are left alone. Report only, no suggestion.

18.2.14 (Aug 15, 2026)

  • Fix: ESLint rules now check properties inside function style keys, including unused keys, validation, sorting, formatting, and shorthand expansion.

18.2.13 (Aug 15, 2026)

  • Fix: a function key on a style imported from another file is compiled instead of silently dropped, in the transform and in the generated CSS alike.
  • Fix: an imported style resolves the constants, createStatic and createTheme values of the file that declares it; both function keys and static keys lost those declarations before.

18.2.12 (Aug 14, 2026)

  • Fix: expand-border-shorthands expands a var() or a template-literal expression that stands as one token, assigning it to the component the other tokens leave open; such a value was reported as unsplittable.
  • Fix: the expansion writes currentColor for an omitted color instead of currentcolor, which validate-values rejected, and validate-values now lists currentColor as a borderColor keyword.

18.2.11 (Aug 13, 2026)

  • Fix: no-order-dependent-overlap also reports two conditions in one style where one provably matches a subset of the other and the narrower one is written first. Only a single numeric min- or max- range on one axis is compared; the swap is offered as a suggestion.
  • Fix: @plumeria/utils places a conditional at-rule after every condition that contains it while optimizing, so the narrower condition wins everywhere both apply.
  • Fix: [email protected] reads a condition as the interval it matches, so implication is recognized across min-/max- prefixes, comparison and two-sided forms, and and chains, on width, height, inline-size and block-size, for @container alongside @media. Note: two conditions that overlap without either containing the other, such as 600px900px against 700px1000px, are still decided by source order.

18.2.10 (Aug 13, 2026)

  • Feat: @plumeria/eslint-plugin adds expand-border-shorthands, a fixable rule that expands the eleven border bundles (border, borderTop, borderBlock, …) into their width, style and color declarations, writing each component's initial value where one was omitted. A value that cannot be split, such as var(--edge) or inherit, is reported without a fix. Not part of recommended.

18.2.9 (Aug 13, 2026)

  • Fix: @plumeria/compiler sorts the file list it globs, so the stylesheet is emitted in the same order on every machine. The scan pass is sorted as well.
  • Feat: @plumeria/eslint-plugin adds no-physical-properties and no-logical-properties, which keep a project on one spelling of the properties that carry both a logical and a physical name. { sizes }, false by default, extends them to width, height, their min/max forms, overflow-x and overflow-y. Neither is part of recommended.

18.2.8 (Aug 13, 2026)

  • Feat: @plumeria/eslint-plugin adds no-order-dependent-overlap, which reports two properties written in one style that overlap while neither one outranks the other: one property under its logical and its physical name, or two shorthands that cross, such as borderTop beside borderBlockWidth. Suggestions only, no autofix. warn in recommended.
  • Fix: [email protected] registers corner-shape and the eight corner longhands it sets, which carried no depth at all, and exports the shorthand graph the rule ranks pairs from. Note: the corner longhands now receive one :not(#\#) where they received none. Class names are unaffected.

18.2.7 (Aug 12, 2026)

  • Fix: @plumeria/core exposes overflowInline instead of overflowBlockX — the logical counterpart of overflow-x is overflow-inline, and CSS has no overflow-block-x.
  • Fix: [email protected] aligns the shorthand graph with the logical property groups: the physical edges now sit below the axis shorthand that sets them across margin, padding, inset, scroll-margin, scroll-padding and the border axes, overflow and overscroll-behavior gain their missing logical longhands, and containIntrinsicSize, positionTry, marker, cue, pause and rest are registered as shorthands. Note: these depths decide how many :not(#\#) selectors an atomic class receives, so the generated stylesheet changes for the properties listed above. Class names are unaffected.

18.2.6 (Aug 12, 2026)

  • Fix: @plumeria/utils gives a nested selector inside a conditional rule both the nested and the condition depth, so @media { ':hover': ... } outranks a bare ':hover' regardless of stylesheet order. Custom properties stay exempt and receive no depth.
  • Fix: @plumeria/utils moves conditional at-rules (@media, @container, @supports, @layer, @scope) after base rules while optimizing, so a conditional declaration wins over a base declaration of equal specificity. @keyframes is left where it is.

18.2.5 (Aug 12, 2026)

  • Fix: @plumeria/utils preserves shorthand and longhand declarations as independent atoms, and merges identical selectors and at-rules without reordering them during optimization.
  • Fix: @plumeria/turbopack-loader optimizes its accumulated development virtual stylesheet, so duplicate selectors and at-rules are merged consistently.
  • Fix: [email protected] removes the override-longhand behavior: a longhand written above a shorthand is no longer crushed by it, and declarations merge the same way regardless of order. No effect under the ESLint recommended rules.
  • Fix: logical longhand properties receive specificity from their depth in the shorthand graph — padding-block-start gets two :not(#\#) selectors in base styles and three inside conditional rules.
  • Update: base and conditional priority is controlled by specificity instead of moving media queries to the end of the stylesheet, so development and production behave the same.

18.2.4 (Aug 11, 2026)

  • Fix: drop the asterisk wildcard from style prop attribution. A prop now counts as applied only inside the component that received it, so a component that merely forwards a same-named prop fails the never-applied check instead of slipping past it.

18.2.3 (Aug 11, 2026)

  • Fix: a style prop that is never applied fails the build instead of silently dropping the style. classStyle={[styles.base, cond && styleArray]} and classStyle={cond ? styleArray : styles.base} now compile: the styles a prop's call sites pass are carried through the condition, and a closed gate leaves the surrounding styles in place.

18.2.2 (Aug 9, 2026)

  • Update: dependencies
  • Update: README.md

18.2.1 (Aug 7, 2026)

  • Feat: migrate a CSS Modules stylesheet with npx @plumeria/codemod migrate --from css-modules, which converts the stylesheet, rewrites its consumers, and reports the rules it did not convert

18.2.0 (Aug 7, 2026)

  • Feat: accept functional pseudo-classes in css.marker / css.extended
  • The generated marker variable now carries a hash, so its name changes
  • Note: the marker's css variable is derived from a hash of the id and the pseudo, so the variable name changes in this release
  • Note: an extended style is keyed by the container query that wraps it, so its class name changes with the variable name

Thanks for building with Plumeria! If you have any feedback or questions, please let us know on GitHub Discussions or GitHub Issues.