AI.md
System instructions and mental models for AI assistants working with Plumeria.
This document is for AI assistants (Claude, Gemini, ChatGPT, and others) generating code with Plumeria. When writing Plumeria code, always follow the rules and patterns described here.
Critical Rules (Quick Reference)
The complete rule set, distilled. Each rule is explained with examples in the sections below.
MUST:
- Call
css.create()at module top level, never inside a component. (→ Forbidden Patterns) - Bind styles with the
classStyleprop, notclassName. (→ Mental Model) - Import
@plumeria/corein every file that uses Plumeria styles, including files that only consume imported styles. (→ Core Usage) - Start nested selector keys with
:(pseudo) or[(attribute). (→ Selector Rules) - Explicitly declare a compound selector when simultaneous pseudo-class states set the same property. (→ Selector Rules)
- Compose with arrays; the right side always wins. (→ Core Usage)
NEVER:
- Pass a raw object to
classStyle, e.g.classStyle={{ color: 'red' }}. (→ Forbidden Patterns) - Call
css.create()inside a component body. (→ Forbidden Patterns) - Use the
&self-reference character. (→ Selector Rules) - Use
:has(),:is(), or:where()— usecss.marker()/css.extended()instead. (→ Selector Rules, Advanced APIs) - Use child or descendant selectors (
.title,> div) — style child elements directly. (→ Selector Rules) - Nest a media/container query inside a pseudo-selector. The reverse (pseudo inside media) is allowed once. (→ Selector Rules)
- Mix
classNameandclassStyleon the same element. (→ Forbidden Patterns) - Merge
css.use()output with the inlinestyleprop. (→ Dynamic Styling) - Pass a received
Styleprop on to another component. Apply it on the element the component renders. (→ Styling Custom Components)
Mental Model
AI assistants often misapply runtime CSS-in-JS habits to Plumeria. Correct your mental model:
- Styles are type schemas, not runtime objects. Plumeria definitions are static schemas read by the compiler; they do not exist as JavaScript objects at runtime.
css.create()is a compiler directive, not an object factory. Everything inside it is statically analyzed — the compiler must be able to read all styling information without executing code.classStyleis NOTclassName. Plumeria binds styles through its ownclassStyleprop. Never reach forclassNamereflexively.- Zero-byte CSS in the JS bundle. All CSS is resolved at build time. The abstraction layer (your definitions) and the output layer (generated CSS) are completely separated, so Plumeria has no side effects and high referential transparency.
Core Usage
Define styles at module top level with css.create(), bind them with classStyle:
import * as css from '@plumeria/core';
const styles = css.create({
container: {
display: 'flex',
padding: '16px',
},
active: {
backgroundColor: 'blue',
},
});
export const MyComponent = ({ isActive }) => {
return (
<div classStyle={[styles.container, isActive && styles.active]}>
Hello Plumeria
</div>
);
};Right-wins composition. classStyle accepts arrays, ternaries, and conditional expressions. The right-most style always takes precedence: above, styles.active overrides styles.container when isActive is true. css.use() follows the same rule. Within a single style, do not rely on source order to resolve overlapping pseudo-class states; declare their compound selector explicitly (see Selector Rules).
Cross-file imports. Styles can be imported across files, but the consuming component file MUST contain import "@plumeria/core"; — the import is how the compiler finds the file.
Forbidden Patterns
Never generate these patterns. They break static analysis or cause runtime errors.
❌ Passing an object directly to classStyle:
<span classStyle={{ fontSize: '12px' }}>Small Text</span>❌ Creating styles inside a component body:
export const BadComponent = ({ dynamicColor }) => {
const dynamicStyles = css.create({
dynamic: { color: dynamicColor },
});
// ...
};❌ Mixing className and classStyle on the same element:
<span className="global-class" classStyle={styles.base}>Text</span>❌ Complex JS expressions in style values. Only simple ternary operators are allowed, and only when necessary. Never put complex logic or function calls inside style values.
Selector Rules
Plumeria supports nesting for pseudo-classes, pseudo-elements, and attribute selectors — under strict rules:
- NO
&. Plumeria does not use the&self-reference character. - Nested keys MUST start with
:or[. Pseudo-classes/elements start with:; attribute selectors start with[. - NO
:has(),:is(),:where(). These are strictly forbidden. Use the pairedcss.marker()/css.extended()APIs (see Advanced APIs) for context-aware, parent-state, or descendant styling — they cover every use case of these pseudo-classes without breaking CSS atomicity. - NO child selectors. Keys like
.titleor> divare not supported. Apply Plumeria styles directly to child elements instead. - Declare overlapping states explicitly. Pseudo-classes such as
:hover,:focus, and:activecan match simultaneously. When two states set the same property, declare their compound selector with the value the intersection should take. If they set different properties, no compound selector is needed. - Media/container query nesting is one-directional. A pseudo-selector may be nested inside a media/container query exactly once. The reverse — a media/container query inside a pseudo-selector — is forbidden and causes compiler/type errors.
const styles = css.create({
button: {
backgroundColor: 'white',
padding: '16px',
// ✅ Pseudo-classes and elements (starts with :)
':hover': {
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
},
':active': {
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
},
// ✅ :hover and :active can match together; the intersection gets its own value
':hover:active': {
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
},
'::before': {
content: '""',
display: 'block',
},
// ✅ Attribute selectors (starts with [)
'[data-active="true"]': {
border: '2px solid blue',
},
// ✅ Pseudo-selector nested inside a media query (allowed once)
'@media (max-width: 768px)': {
padding: '8px',
':hover': {
boxShadow: 'none',
},
},
},
});
// ❌ INVALID: media query nested inside a pseudo-selector
const badStyles = css.create({
button: {
':hover': {
'@media (max-width: 768px)': {
backgroundColor: 'blue',
},
},
},
});Dynamic Styling
Function keys
For dynamic values (which would normally require React's inline style prop), define a function key in css.create(). Function keys compile to CSS variables — so unlike inline styles, they fully support @media and @container queries:
const styles = css.create({
palette: (color: string) => ({
backgroundColor: color,
// Media/container queries work on dynamic styles
'@media (max-width: 600px)': {
backgroundColor: 'blue',
},
}),
});
// Usage: <div classStyle={styles.palette(color)} />Bracket notation (variants)
The css.variants() API has been removed in favor of standard bracket notation (styles[variant]). The compiler turns these expressions into static lookup tables at build time:
import * as css from '@plumeria/core';
// 1. Define variants in a dedicated, minimal css.create()
const sizeStyles = css.create({
small: { fontSize: '12px' },
medium: { fontSize: '16px' },
large: { fontSize: '20px' },
});
// 2. Type the variant (string literal union or keyof typeof)
type Size = keyof typeof sizeStyles;
interface ButtonProps {
size: Size;
children: React.ReactNode;
}
// 3. Resolve dynamically with bracket notation
export const Button = ({ size, children }: ButtonProps) => {
return <button classStyle={sizeStyles[size]}>{children}</button>;
};Best practices:
- ⚠️ Keep variant
css.create()calls minimal. Every key in acss.create()used with bracket notation is compiled into the generated lookup table (e.g.{"small":"...","medium":"..."}[size]). Separate variants into dedicated calls; don't mix them with unrelated static styles. - Local variable assignment is supported.
const currentStyle = sizeStyles[size]; <div classStyle={currentStyle} />works — the compiler traces local style variables and inlines them during JSX extraction.
css.use() returns a static string
css.use() compiles to a static class name string. It has no integration with the inline style prop — never attempt to merge or combine css.use() output with style.
Advanced APIs
css.marker() and css.extended() (paired descendant styling)
These paired APIs enable context-aware styling (e.g. styling a child when the parent is hovered) without DOM combinators or :has()/:is()/:where():
css.marker(id, pseudo)— sets a CSS variable marker on the parent when the pseudo state is active. MUST be spread into the parent style:...css.marker(...).css.extended(id, pseudo)— applies styles to descendants while the linked marker is active. Used as a computed key:[css.extended(...)].
import * as css from '@plumeria/core';
const styles = css.create({
parent: {
// 1. Set a marker with the unique id 'card' for the ':hover' state
...css.marker('card', ':hover'),
padding: '24px',
border: '1px solid #ccc',
},
child: {
transition: 'color 0.3s ease',
// 2. React to the 'card' marker being active
[css.extended('card', ':hover')]: {
color: 'blue',
// Further pseudo-classes can be nested here
':hover': {
color: 'darkblue',
},
},
},
});
export const Card = () => {
return (
<div classStyle={styles.parent}>
<span classStyle={styles.child}>Hover parent to make me blue!</span>
</div>
);
};At build time this compiles to an atomic container style query (@container style(--x1f9k2q1-card-hover: 1)) — zero runtime overhead, zero dependency on DOM hierarchy.
css.createStatic()
Defines static variables (such as media query strings) that are inlined at build time, typically used as computed keys in css.create():
import * as css from '@plumeria/core';
export const breakpoints = css.createStatic({
xs: '@media (max-width: 480px)',
sm: '@media (max-width: 640px)',
md: '@media (max-width: 768px)',
lg: '@media (max-width: 1024px)',
});
export const styles = css.create({
container: {
[breakpoints.sm]: {
padding: 16,
},
[breakpoints.lg]: {
padding: 32,
},
},
});css.createTheme()
Defines scoped CSS variables for themes. First argument: the selector that activates the theme. Second argument: an object of { default, theme } value pairs. Generated variable names are prefixed with a unique hash, so name collisions are impossible. Theme values are only compiled when actually used in a styling block.
import * as css from '@plumeria/core';
// Class-based theme
export const theme = css.createTheme('.dark', {
text: {
default: '#333',
theme: '#eaeaea',
},
background: {
default: 'white',
theme: 'black',
},
});
// Attribute selectors and media queries are also supported:
// css.createTheme('[data-theme="dark"]', { ... });
// css.createTheme('@media (prefers-color-scheme: dark)', { ... });css.keyframes()
Generates a unique @keyframes animation name (a hash inlined at build time) for use inside style rules:
import * as css from '@plumeria/core';
const fadeIn = css.keyframes({
from: { opacity: 0 },
to: { opacity: 1 },
});
const styles = css.create({
card: {
transition: 'transform 0.3s ease',
':hover': {
animationName: fadeIn,
animationDuration: '0.5s',
},
},
});css.viewTransition()
Generates a unique view-transition-name, accepting animation definitions (group, imagePair, new, old) to customize View Transitions:
import * as css from '@plumeria/core';
const fadeIn = css.keyframes({
from: { opacity: 0 },
to: { opacity: 1 },
});
const fadeOut = css.keyframes({
from: { opacity: 1 },
to: { opacity: 0 },
});
const longCrossFade = css.viewTransition({
old: {
animationName: fadeOut,
animationDuration: '1.2s',
},
new: {
animationName: fadeIn,
animationDuration: '1.2s',
},
});
export const transition = css.create({
name: {
viewTransitionName: longCrossFade,
},
});
// React usage:
// <ViewTransition name={css.use(transition.name)}>...</ViewTransition>Styling Custom Components
There are exactly 3 patterns for applying Plumeria styles to custom components. In all of them, compilation happens at the classStyle / css.use() call sites; the component itself just passes the compiled className / style through to the DOM.
Core principle: Custom props typed as
Styleare statically traced by the compiler, so styles pass seamlessly across component boundaries.
Pattern 1: Direct classStyle inside the component
The component imports its own styles and applies them internally. Simplest pattern — fully self-contained:
import React from 'react';
import * as css from '@plumeria/core';
const styles = css.create({
button: {
padding: '10px',
backgroundColor: 'navy',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
},
});
export const Button = ({ children }: { children: React.ReactNode }) => {
return (
// The compiler transforms classStyle → className here
<button classStyle={styles.button}>
{children}
</button>
);
};
// Usage
<Button>Click me</Button>;Pattern 2: Passing a Style prop
classStyle itself is compiled away at the call site, so it cannot be used as a prop name on a custom component. Instead, define a custom prop (e.g. styleArray) typed as css.Style. The compiler statically traces it across component boundaries and resolves it into the internal element's classStyle. Composing as [styles.text, styleArray] lets call-site styles override the component's base styles (right-wins — see Core Usage): below, the call site's fontSize: '24px' overrides the internal fontSize: '12px'.
// --- Button.tsx ---
import React from 'react';
import * as css from '@plumeria/core';
type ButtonProps = {
children: React.ReactNode;
styleArray?: css.Style;
};
// base style
const styles = css.create({
text: {
fontSize: '12px',
},
});
// Pass styleArray directly to the inner element's classStyle
export const Button = ({ children, styleArray }: ButtonProps) => {
return <button classStyle={[styles.text, styleArray]}>{children}</button>;
};
// --- Usage (call site) ---
import * as css from '@plumeria/core';
import { Button } from './Button';
const styles = css.create({
primary: {
padding: '10px',
backgroundColor: 'navy',
color: '#fff',
},
text: {
fontSize: '24px',
},
});
// The compiler traces styleArray and resolves it statically
<Button styleArray={[styles.primary, styles.text]}>Click me</Button>;Pattern 3: className bypass with css.use()
The component resolves the Style prop into a class name string with css.use() and passes it to className. The compiler traces the prop into the css.use() call the same way:
// --- Button.tsx ---
import React from 'react';
import * as css from '@plumeria/core';
type ButtonProps = {
children: React.ReactNode;
styleArray?: css.Style;
};
// base style
const styles = css.create({
text: {
fontSize: '12px',
},
});
// Pass styleArray into css.use() to generate the className
export const Button = ({ children, styleArray }: ButtonProps) => {
return (
<button className={css.use(styles.text, styleArray)}>
{children}
</button>
);
};
// --- Usage (call site) ---
import * as css from '@plumeria/core';
import { Button } from './Button';
const styles = css.create({
primary: {
padding: '10px',
backgroundColor: 'navy',
color: '#fff',
},
text: {
fontSize: '24px',
},
});
// The compiler traces styleArray inside css.use() and resolves it statically
<Button styleArray={[styles.primary, styles.text]}>Click me</Button>;Anti-pattern: relaying a Style prop
A component that receives a Style prop MUST apply it — to classStyle or through css.use() — on an element it renders. A style prop that is never applied is a build error, which is what happens when it is handed to another component instead.
What decides this is where the style ends up, not how many style props a component takes. Passing styles in is always fine:
// ✅ VALID: every value is a style, so any number of style props is fine
<Layout
headerStyle={styles.header}
bodyStyle={styles.body}
buttonStyle={styles.button}
/>Forwarding one onward instead of applying it is not:
// ❌ INVALID: headerStyle reaches no element of this component
export const Layout = ({ headerStyle }: { headerStyle?: css.Style }) => {
return <Header headerStyle={headerStyle} />;
};Plumeria: "headerStyle" is a style received through a prop but is never applied
to classStyle or css.use() here. Apply it on an element this component renders;
a style prop cannot be passed on to another component.Write one of these instead:
// ✅ Apply it on an element this component renders
export const Layout = ({ headerStyle }: { headerStyle?: css.Style }) => {
return <header classStyle={[styles.base, headerStyle]}>...</header>;
};
// ✅ Or drop the middle component and style the one that owns the element
<Header headerStyle={[styles.base, styles.primary]} />;Merging under a base style — [styles.base, headerStyle] — is the supported shape. What is rejected is the relay: a prop that is only forwarded, never applied.
So the valid call site above stays valid only while Layout applies all three props itself. If it routes them onward to a grandchild, each unapplied prop is an error — which is the signal that the styles belong on the components that own those elements.
Prop drilling is an anti-pattern in React on its own terms; here it is also unresolvable, because the compiler would have to trace a style across an arbitrary chain of wrappers to build the lookup table. Rejecting it keeps the boundary decidable and the generated CSS exact.
Summary
| Pattern | Compilation site | Component's role |
|---|---|---|
1. Direct classStyle | Inside the component | Self-contained styles |
2. Style prop | Traced and compiled | Receives and applies Style |
3. className bypass | Inside the component (css.use) | Resolves Style into className |
In every pattern the component applies the style it receives. None of them forward it.
Testing
NEVER stub @plumeria/core. It publishes types and no runtime. A moduleNameMapper entry or a hand-written mock makes the suite run, but every class name it produces is invented, so the test says nothing about the styles. If a runner cannot resolve the package, that is the signal to test a different layer, not to fake the package.
NEVER assert a generated class name in a component test. xvdv6o3r is one property-value pair, hashed. Adding a property to the style breaks every test that spelled the old list out. Assert what the component renders, holds, or branches on.
To test what a style compiles to, call the transform directly. It takes a source string and returns the rewritten code plus the stylesheet — no component, no DOM, no bundler. Write it for Jest; Vitest and node:test need their own imports, and node:test has no expect at all.
For a project on @plumeria/unplugin:
const { unpluginFactory } = require('@plumeria/unplugin/factory');
const plugin = unpluginFactory();
const { code } = await plugin.transform(source, id);
const cssId = code.match(/import "(.+\.zero\.css)"/)[1];
const css = plugin.load(cssId);For a project on @plumeria/next-plugin, call the loader its build runs, with a loader context of your own:
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 loader returns the rewritten code and writes its stylesheet only when NODE_ENV is development or production; a test run is neither, so the file is left alone. Read class names from the returned code and derive them; do not hard-code them.
To test component behaviour, use Vitest with the plugin in the config. This is the only layer that needs the transform in the module pipeline.
export default defineConfig({
plugins: [react(), plumeria.vite()],
test: { environment: 'jsdom' },
});jsdom applies no stylesheet. document.styleSheets is empty and getComputedStyle returns initial values. A test there can see which classes were attached, never what they do. Anything about the cascade — specificity, @media, marker with extended — belongs in an end-to-end test against a real browser.
Toolchain Notes
Compiler expectations. The SWC compiler statically extracts css.create() calls, which is why they MUST sit at module top level. Prefer direct, clearly defined references — indirect variable references may be unanalyzable.
ESLint guarantees. @plumeria/eslint-plugin strictly enforces Plumeria's rules; code that satisfies it is safe to ship. It guarantees:
- CSS property value validation — invalid values are caught at lint time.
- Property sort order — a specific ordering is enforced.
- Unused style detection — unused styles are flagged automatically.
Follow the plugin's rules as documented in its README.md.