Skip to content

Commit de6d700

Browse files
committed
feat(cli): add minimal cssgen output
1 parent 24b4e21 commit de6d700

29 files changed

Lines changed: 901 additions & 282 deletions

File tree

V2_MIGRATION.md

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,19 @@ means you can annotate an exported inline recipe with only its variant keys and
285285
import { cva } from 'styled-system/css'
286286
import type { RecipeRuntimeFn } from 'styled-system/types'
287287

288-
export const button: RecipeRuntimeFn<{ visual?: 'solid' | 'outline' }> =
289-
cva({ base: { px: '4' }, variants: { visual: { solid: { /* css */ }, outline: { /* css */ } } } })
288+
export const button: RecipeRuntimeFn<{ visual?: 'solid' | 'outline' }> = cva({
289+
base: { px: '4' },
290+
variants: {
291+
visual: {
292+
solid: {
293+
/* css */
294+
},
295+
outline: {
296+
/* css */
297+
},
298+
},
299+
},
300+
})
290301
```
291302

292303
The same works for `styled(tag, {...})` via `StyledComponent<Tag, Props>` and for `sva` via `SlotRecipeRuntimeFn`. This
@@ -413,6 +424,22 @@ Logging flags are consolidated: use `--log-level silent|error|warn|info|debug` i
413424

414425
---
415426

427+
## CSS output for monorepos
428+
429+
v2 keeps `panda cssgen --minimal` for packages that should emit usage CSS without duplicating foundation CSS. It writes
430+
recipes and utilities only; reset, base, and tokens should be emitted once by the app/root build.
431+
432+
Recommended monorepo workflow:
433+
434+
1. **App/root:** run a normal `panda build` or `panda cssgen` to emit the full stylesheet once.
435+
2. **Per package:** run `panda cssgen --minimal` to emit package-local usage CSS.
436+
3. **Published design systems:** ship `panda buildinfo` and hydrate in consumers (see `design-notes/build-info.md`).
437+
438+
The v1 positional layer names (`preflight`, `global`, `tokens`, …) and positional glob override are not part of the v2
439+
CLI surface yet.
440+
441+
---
442+
416443
## Still being finalized
417444

418445
Known gaps in the beta. Expect them to change before stable:
@@ -424,8 +451,6 @@ Known gaps in the beta. Expect them to change before stable:
424451
- **CSS minification.** `minify: true` works in the native emitter; full parity with the v1 LightningCSS path is still
425452
open.
426453
- **PostCSS plugin.** Experimental (above).
427-
- **CLI `[files]` override.** The v1 positional include override for `panda build` isn't wired yet. The build uses the
428-
config `include`.
429454

430455
---
431456

bench/__tests__/edgecases.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ test('emitter parity battery', () => {
106106
try {
107107
const compiler = createCompilerFromSnapshot(snapshot, { crossFile: true }) as any
108108
compiler.parseFileSource(path, src)
109-
v2 = fmt(compiler.layerCss(['recipes', 'utilities']) ?? '')
109+
v2 = fmt(compiler.getLayerCss({ layers: ['recipes', 'utilities'] }).css ?? '')
110110
} catch (e) {
111111
v2 = `V2_ERROR: ${(e as Error).message}`
112112
}

crates/pandacss_stylesheet/src/lib.rs

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ pub struct StylesheetOptions {
3636
pub include_static: bool,
3737
pub source_map: bool,
3838
pub emit_layer_declaration: bool,
39+
/// When set, `split_css` emits only the selected layers (and rebuilds the
40+
/// index `@layer` preamble + `@import` lines to match).
41+
pub layers: Option<Vec<StylesheetLayer>>,
3942
}
4043

4144
impl Default for StylesheetOptions {
@@ -45,6 +48,7 @@ impl Default for StylesheetOptions {
4548
include_static: false,
4649
source_map: false,
4750
emit_layer_declaration: true,
51+
layers: None,
4852
}
4953
}
5054
}
@@ -296,6 +300,10 @@ pub fn split_css(input: &StylesheetInput<'_>, options: &StylesheetOptions) -> Ve
296300
true,
297301
);
298302

303+
let selected = options.layers.as_deref();
304+
let layer_selected =
305+
|layer: StylesheetLayer| selected.is_none_or(|layers| layers.contains(&layer));
306+
299307
let mut files: Vec<SplitCssFile> = Vec::new();
300308
let mut imports: Vec<String> = Vec::new();
301309

@@ -305,6 +313,9 @@ pub fn split_css(input: &StylesheetInput<'_>, options: &StylesheetOptions) -> Ve
305313
(StylesheetLayer::Tokens, "tokens.css"),
306314
(StylesheetLayer::Utilities, "utilities.css"),
307315
] {
316+
if !layer_selected(layer) {
317+
continue;
318+
}
308319
let css = full
309320
.layer_ranges
310321
.get(layer)
@@ -319,38 +330,51 @@ pub fn split_css(input: &StylesheetInput<'_>, options: &StylesheetOptions) -> Ve
319330
}
320331
}
321332

322-
let recipe_files = emitter::emit_recipe_split(input.config, &utility, recipes, options.minify);
323-
if !recipe_files.is_empty() {
324-
let mut recipe_imports = Vec::with_capacity(recipe_files.len());
325-
for (name, css) in &recipe_files {
326-
recipe_imports.push(format!("@import './recipes/{name}.css';"));
333+
if layer_selected(StylesheetLayer::Recipes) {
334+
let recipe_files =
335+
emitter::emit_recipe_split(input.config, &utility, recipes, options.minify);
336+
if !recipe_files.is_empty() {
337+
let mut recipe_imports = Vec::with_capacity(recipe_files.len());
338+
for (name, css) in &recipe_files {
339+
recipe_imports.push(format!("@import './recipes/{name}.css';"));
340+
files.push(SplitCssFile {
341+
path: format!("styles/recipes/{name}.css"),
342+
code: ensure_trailing_newline(css),
343+
});
344+
}
327345
files.push(SplitCssFile {
328-
path: format!("styles/recipes/{name}.css"),
329-
code: ensure_trailing_newline(css),
346+
path: "styles/recipes.css".to_owned(),
347+
code: format!("{}\n", recipe_imports.join("\n")),
330348
});
349+
imports.push("@import './styles/recipes.css';".to_owned());
331350
}
332-
files.push(SplitCssFile {
333-
path: "styles/recipes.css".to_owned(),
334-
code: format!("{}\n", recipe_imports.join("\n")),
335-
});
336-
imports.push("@import './styles/recipes.css';".to_owned());
337351
}
338352

339-
for (theme_name, css) in
340-
theme_css_entries_from_dictionary(input.config, token_dictionary.as_deref(), options.minify)
341-
{
342-
if css.trim().is_empty() {
343-
continue;
353+
if layer_selected(StylesheetLayer::Tokens) {
354+
for (theme_name, css) in theme_css_entries_from_dictionary(
355+
input.config,
356+
token_dictionary.as_deref(),
357+
options.minify,
358+
) {
359+
if css.trim().is_empty() {
360+
continue;
361+
}
362+
files.push(SplitCssFile {
363+
path: format!("styles/themes/{}.css", file_stem(&theme_name)),
364+
code: ensure_trailing_newline(&css),
365+
});
344366
}
345-
files.push(SplitCssFile {
346-
path: format!("styles/themes/{}.css", file_stem(&theme_name)),
347-
code: ensure_trailing_newline(&css),
348-
});
349367
}
350368

351369
// `styles.css` entry: the @layer order declaration + the imports above.
352-
let mut index = layer_order_line(&input.config.layers);
353-
index.push('\n');
370+
let mut index = if options.emit_layer_declaration {
371+
layer_order_declaration(&input.config.layers, selected)
372+
} else {
373+
String::new()
374+
};
375+
if !index.is_empty() {
376+
index.push('\n');
377+
}
354378
if !imports.is_empty() {
355379
index.push_str(&imports.join("\n"));
356380
index.push('\n');
@@ -441,13 +465,37 @@ fn ensure_trailing_newline(css: &str) -> String {
441465
}
442466
}
443467

444-
fn layer_order_line(layers: &pandacss_config::CascadeLayers) -> String {
445-
let names = layers.declaration_names();
468+
/// Return the cascade layer order declaration for all configured layers or a
469+
/// selected subset.
470+
#[must_use]
471+
pub fn layer_order_declaration(
472+
layers: &pandacss_config::CascadeLayers,
473+
selected: Option<&[StylesheetLayer]>,
474+
) -> String {
475+
let names: Vec<String> = layers
476+
.ordered()
477+
.iter()
478+
.filter_map(|(semantic, name)| {
479+
let layer = StylesheetLayer::from_name(semantic)?;
480+
selected
481+
.is_none_or(|selected_layers| selected_layers.contains(&layer))
482+
.then(|| (*name).to_owned())
483+
})
484+
.collect();
485+
if names.is_empty() {
486+
return String::new();
487+
}
488+
if names.len() <= 3 {
489+
return format!("@layer {};", names.join(", "));
490+
}
491+
if names.len() == 4 {
492+
return format!("@layer {},\n {};", names[..3].join(", "), names[3]);
493+
}
446494
format!(
447495
"@layer {},\n {},\n {};",
448496
names[..3].join(", "),
449497
names[3..names.len() - 1].join(", "),
450-
names.last().expect("layer declaration names")
498+
names[names.len() - 1]
451499
)
452500
}
453501

crates/pandacss_stylesheet/tests/common/mod.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ pub fn compile_output(
5858
}
5959

6060
#[allow(dead_code)]
61-
pub fn split_output(config: &UserConfig, source: &str) -> Vec<pandacss_stylesheet::SplitCssFile> {
61+
pub fn split_output(
62+
config: &UserConfig,
63+
source: &str,
64+
options: StylesheetOptions,
65+
) -> Vec<pandacss_stylesheet::SplitCssFile> {
6266
let system = System::new(config.clone()).expect("valid project");
6367
let mut project = Project::new(system);
6468
project.parse_file("/style.ts", source);
@@ -76,7 +80,7 @@ pub fn split_output(config: &UserConfig, source: &str) -> Vec<pandacss_styleshee
7680
},
7781
&StylesheetOptions {
7882
include_static: true,
79-
..Default::default()
83+
..options
8084
},
8185
)
8286
}

crates/pandacss_stylesheet/tests/split.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use insta::assert_snapshot;
22

33
use crate::common::{config, split_output};
4+
use pandacss_stylesheet::{StylesheetLayer, StylesheetOptions};
45

56
/// Render the split file set as `=== path ===\n<code>` blocks for snapshotting.
67
fn render(files: &[pandacss_stylesheet::SplitCssFile]) -> String {
@@ -34,6 +35,7 @@ fn splits_layers_and_recipes_into_files_with_indexes() {
3435
let files = split_output(
3536
&config,
3637
"import { css } from '@panda/css'\nimport { button } from '@panda/recipes'\ncss({ color: 'red' })\nbutton({ size: 'sm' })",
38+
StylesheetOptions::default(),
3739
);
3840
assert_snapshot!(render(&files), @"
3941
=== styles.css ===
@@ -85,6 +87,41 @@ fn splits_layers_and_recipes_into_files_with_indexes() {
8587
");
8688
}
8789

90+
#[test]
91+
fn split_css_with_layer_filter_skips_unselected_files() {
92+
let config = config(serde_json::json!({
93+
"importMap": { "css": ["@panda/css"], "recipe": [], "pattern": [], "jsx": [], "tokens": ["@panda/tokens"] },
94+
"theme": {
95+
"tokens": { "colors": { "red": { "value": "#f00" } } }
96+
},
97+
"utilities": {
98+
"color": { "className": "c", "values": "colors" }
99+
}
100+
}));
101+
let source = "import { css } from '@panda/css'\ncss({ color: 'red' })";
102+
let files = split_output(
103+
&config,
104+
source,
105+
StylesheetOptions {
106+
layers: Some(vec![StylesheetLayer::Utilities]),
107+
emit_layer_declaration: false,
108+
..StylesheetOptions::default()
109+
},
110+
);
111+
112+
assert_snapshot!(render(&files), @"
113+
=== styles.css ===
114+
@import './styles/utilities.css';
115+
116+
=== styles/utilities.css ===
117+
@layer utilities {
118+
.c_red {
119+
color: var(--colors-red);
120+
}
121+
}
122+
");
123+
}
124+
88125
#[test]
89126
fn split_css_emits_theme_files() {
90127
let config = config(serde_json::json!({
@@ -105,7 +142,7 @@ fn split_css_emits_theme_files() {
105142
}
106143
}
107144
}));
108-
let files = split_output(&config, "");
145+
let files = split_output(&config, "", StylesheetOptions::default());
109146

110147
assert!(
111148
files

design-notes/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ Open gaps that stay outside the current CLI surface:
226226

227227
- `studio` and `analyze` are not ported.
228228
- `ship` / `emit-pkg` is not ported.
229-
- the v1 positional `[files]` override is not wired.
229+
- v1 positional layer type arguments are intentionally not ported. `cssgen --minimal` covers usage-only package CSS.
230230
- lightningcss minify parity is still an open follow-up.
231231

232232
Those gaps are intentional for now; the CLI prefers a smaller, reliable surface over a partial legacy clone.

design-notes/output-and-host-layer.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ Three principles fall out of this:
7575
2. **CSS gen and artifact gen are distinct operations on distinct cadences** — mirroring v1's `panda cssgen` vs
7676
`panda codegen`. Artifacts regenerate rarely (config change); CSS regenerates every build. The Driver exposes them as
7777
separate methods, never a combined "build everything." CSS itself has three forms: `compile()` (the merged
78-
stylesheet), `compiler.layerCss(layers)` (a merged subset string — `cssgen <type>` / `--minimal`), and
79-
`compiler.splitCss()` (the `{path,code}[]` file set — `cssgen --splitting`; written like artifacts). See
78+
stylesheet), `compiler.getLayerCss(options)` (a merged subset string — `cssgen --minimal`), and
79+
`compiler.getSplitCss()` (the `{path,code}[]` file set — `cssgen --splitting`; written like artifacts). See
8080
[stylesheet](./stylesheet.md).
8181
3. **Reads + artifact writes via the engine; CSS routing via the host.** Source discovery + reading run through the
8282
Rust `pandacss_fs` engine (`scan`/`glob`/`sources`). Artifact *writing* also goes through the engine fs

packages/cli/__tests__/buildinfo.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ describe('buildinfo command', () => {
120120
"ok": true,
121121
}
122122
`)
123-
expect(consumer.compiler.layerCss(['utilities'])).toMatchInlineSnapshot(`
123+
expect(consumer.compiler.getLayerCss({ layers: ['utilities'] }).css).toMatchInlineSnapshot(`
124124
"@layer utilities {
125125
.background_blue {
126126
background: blue;
@@ -143,7 +143,7 @@ describe('buildinfo command', () => {
143143

144144
expect(applied.modules).toEqual(['button.tsx'])
145145
// button's `color: red` hydrated; card's `background: blue` tree-shaken out.
146-
expect(consumer.compiler.layerCss(['utilities'])).toMatchInlineSnapshot(`
146+
expect(consumer.compiler.getLayerCss({ layers: ['utilities'] }).css).toMatchInlineSnapshot(`
147147
"@layer utilities {
148148
.color_red {
149149
color: red;

packages/cli/__tests__/cli-smoke.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,22 @@ describe('cli smoke', () => {
8585
expect(result.stdout).not.toContain('validate')
8686
})
8787

88-
it.each(['build', 'dev', 'check', 'info', 'doctor'])('prints help for panda %s', (command) => {
88+
it.each(['build', 'dev', 'check', 'info', 'doctor', 'cssgen'])('prints help for panda %s', (command) => {
8989
const result = runCli([command, '--help'])
9090

9191
expect(result.exitCode).toBe(0)
9292
expect(result.stdout).toContain(`panda ${command} v${version}`)
9393
})
9494

95+
it('documents cssgen --minimal', () => {
96+
const result = runCli(['cssgen', '--help'])
97+
98+
expect(result.exitCode).toBe(0)
99+
expect(result.stdout).toContain('--minimal')
100+
expect(result.stdout).toContain('--minify')
101+
expect(result.stdout).toContain('Emit usage CSS only')
102+
})
103+
95104
it.each(['inspect', 'validate', 'frobnicate'])('rejects unknown command %s', (command) => {
96105
const result = runCli([command])
97106

0 commit comments

Comments
 (0)