Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/design-system-nested-chains.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@pandacss/config': minor
'@pandacss/compiler': minor
---

Compose design systems. A design system can now extend another by setting its own `designSystem` field, and you still
write one line in your config — the parent comes along for free.

Set `designSystem: '@acme/marketing'`, and if `@acme/marketing` was built on `@acme/foundations`, Panda walks the chain
and merges both presets under your config: foundations first, then marketing, then you on top. Each parent resolves from
where its child is installed, so it works in monorepos and Docker builds where the consumer only depends on the leaf.
Each design system's pre-extracted styles hydrate under its own name. A cycle (`@a` → `@b` → `@a`) or a parent that
isn't installed stops the build with a message that names the package and what to do.
7 changes: 7 additions & 0 deletions .changeset/smart-include.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@pandacss/config': minor
---

Name a package in `include` and Panda scans it for you. A library that uses Panda but isn't a design system — say a charts package built on `@acme/ds` — can go straight in `include` as a bare specifier (or via `--include`); Panda resolves it and globs its source so its styles land in your CSS. No hand-written `./node_modules/...` glob, and the package's own dependencies are skipped.

If you point `include` at an actual design system (a package shipping `panda.lib.json`), Panda stops and tells you to move it to `designSystem` — and reports every one at once, not one per run. Plain globs and local folder names work exactly as before.
30 changes: 25 additions & 5 deletions design-notes/design-system-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,11 +387,31 @@ them**.
components are never re-extracted. Diagnostics: not-found, version-mismatch, peer-range. Manifest/preset/build-info
registered as build deps. _Deferred to later phases:_ build-info tree-shaking to app imports (optimization) and the
stale-build-info `files` re-extract fallback (Phase 5).
3. **Nested chains.** `resolveChain` over manifest values: ordered plan, resolve-against-manifest-dir, cycle guard. Host
reads the parent chain and feeds values in. Diagnostics: cycle, parent-not-found. Tested as in-memory arrays (depth-N,
diamond, cycle).
4. **Smart `include`.** Bare specifiers resolve via Node resolution: manifest present → redirect; no manifest → auto-glob +
extract. Diagnostic: in-include (batched).
3. **Nested chains. ✅ Complete.** A manifest's own `designSystem` field links to its parent. The host
(`packages/config` `loadDesignSystemChain`) walks that chain, resolving each parent against the **previous manifest's
directory** (not the consumer cwd, so transitive parents work in workspaces and Docker layers), guards cycles with a
visited set, and merges presets root → leaf (ancestors lowest, leaf and app override). The node driver
(`packages/compiler` `hydrateDesignSystem`) hydrates each level under its own name. Diagnostics: cycle,
parent-not-found. The engine `resolveChain` primitive (root-first order, diamond dedup, cycle path) lands with single
level and is tested as in-memory arrays (depth-N, diamond, cycle); the host walk is tested end-to-end (depth-2 merge
order, dual importMap roots, resolve-against-manifest-dir, cycle, parent-not-found). _Deferred:_ the runtime path does
**not** feed manifests through `resolveChain`. With a single `designSystem` parent the reachable chain is a strict line,
so the host orders it by reversing the walk and catches cycles by path — `resolveChain`'s topo-sort, diamond dedup, and
second cycle pass would all be no-ops on linear input. The runtime wiring earns its place only with [plural
parents](#why-singular), where a node gains two parents, the walk becomes a DAG, and diamonds can actually form.
4. **Smart `include`. ✅ Complete.** Bare specifiers in `include` resolve via Node resolution (`packages/config`
`expandSmartInclude`, run during `resolveAuthoredPresets`). An entry is treated as a package only if it matches the npm
package-name grammar — globs (incl. extglob), relative/absolute paths, and multi-segment paths pass through untouched.
The package directory resolves exports-safely (`<spec>/package.json`, else the package entry walked up to its
`package.json`); a `panda.lib.json` is then detected **on disk** (immune to `exports`): present → it's a design system,
batched `design_system_in_include` error redirecting to `designSystem`; absent → auto-glob the package source
(`<pkg>/**/*.{…}` over `SMART_INCLUDE_EXTENSIONS`, relative under cwd / absolute when hoisted, POSIX-separated), emit a
matching `<pkg>/**/node_modules/**` exclude so the consumed package's own deps aren't scanned (pnpm symlink farms), and
register its `package.json` as a config dep. The CLI `--include` override flows through the same resolution
(`resolveSmartInclude`), so a bare specifier works there too. _Deferred:_ cross-package **source** watch (open item #4)
— re-running cssgen on edits inside the consumed package, beyond the config-dep invalidation wired here. The extension
list is a hand-maintained mirror of the engine's parseable set (config is binding-free); a future engine-side extension
gate would make it the single source of truth.
5. **`panda lib` + propagation.** The command (+ `--watch`): glob `src/` → `create` → write manifest/buildinfo/preset → sync
exports. Register resolved paths as build deps; drift receipt + persisted state; stale-buildinfo fallback; token-conflict
warning. Remove `ship`/`emit-pkg` with a migration note.
Expand Down
11 changes: 7 additions & 4 deletions packages/compiler/src/design-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { type BuildInfoArtifact, type Compiler } from '@pandacss/compiler-shared
import { readPandaVersion, type LoadConfigResult } from '@pandacss/config'
import { readFileSync } from 'node:fs'

type ResolvedDesignSystem = NonNullable<NonNullable<LoadConfigResult['metadata']>['designSystem']>

export function hydrateDesignSystem(compiler: Compiler, ds: ResolvedDesignSystem | undefined): void {
if (!ds) return
type ResolvedDesignSystem = NonNullable<NonNullable<LoadConfigResult['metadata']>['designSystem']>[number]

export function hydrateDesignSystem(compiler: Compiler, chain: ResolvedDesignSystem[] | undefined): void {
if (!chain || chain.length === 0) return
const pandaVersion = readPandaVersion()
for (const ds of chain) hydrateLevel(compiler, ds, pandaVersion)
}

function hydrateLevel(compiler: Compiler, ds: ResolvedDesignSystem, pandaVersion: string | undefined): void {
const compat = compiler.designSystem.validate(ds.manifest, { pandaVersion })
if (!compat.ok) {
throw incompatibleManifestError(compiler, ds, compat.reason, pandaVersion)
Expand Down
24 changes: 21 additions & 3 deletions packages/compiler/src/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ import {
type Driver,
type SourceChange,
} from '@pandacss/compiler-shared'
import { type HostHooks, type LoadConfigResult, diffConfig, loadConfig } from '@pandacss/config'
import {
type HostHooks,
type LoadConfigResult,
diffConfig,
loadConfig,
mergeExcludes,
resolveSmartInclude,
} from '@pandacss/config'
import { hydrateDesignSystem } from './design-system'
import { createCompilerFromSnapshot } from './index'

Expand All @@ -28,10 +35,21 @@ type CodegenPrepareHooks = NonNullable<HostHooks['codegen:prepare']>
*/
export async function createNodeDriver(options: NodeDriverOptions): Promise<Driver> {
const loaded = await loadConfig({ cwd: options.cwd, file: options.configPath })
if (options.include?.length) loaded.config.include = options.include
if (options.include?.length) applyIncludeOverride(loaded, options.cwd, options.include)
return new NodeDriver(options, loaded)
}

function applyIncludeOverride(loaded: LoadConfigResult, cwd: string, include: string[]): void {
const deps = new Set(loaded.dependencies)
const resolved = resolveSmartInclude(include, cwd, deps)
loaded.config.include = resolved.include
if (resolved.excludes.length > 0) {
const existing = Array.isArray(loaded.config.exclude) ? loaded.config.exclude : undefined
loaded.config.exclude = mergeExcludes(existing, resolved.excludes)
}
loaded.dependencies = Array.from(deps)
}

class NodeDriver extends BaseDriver {
#options: NodeDriverOptions
#loaded: LoadConfigResult
Expand All @@ -57,7 +75,7 @@ class NodeDriver extends BaseDriver {
async reload(): Promise<DiffConfigResult> {
const next = await loadConfig({ cwd: this.#options.cwd, file: this.#options.configPath })
// Re-apply before diffing so the override isn't seen as a config change.
if (this.#options.include?.length) next.config.include = this.#options.include
if (this.#options.include?.length) applyIncludeOverride(next, this.#options.cwd, this.#options.include)
const diff = diffConfig(this.#loaded, next)
if (diff.hasChanged) {
this.#loaded = next
Expand Down
156 changes: 133 additions & 23 deletions packages/config/__tests__/design-system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ describe('resolveAuthoredPresets / designSystem', () => {
},
'styled-system',
])
expect(metadata?.designSystem?.name).toBe('@acme/ds')
expect(metadata?.designSystem?.manifest.name).toBe('@acme/ds')
expect(metadata?.designSystem?.buildInfoPath).toMatch(/panda\.buildinfo\.json$/)
expect(metadata?.designSystem?.files).toEqual(['./dist/**/*.mjs'])
expect(metadata?.designSystem?.map((ds) => ds.name)).toEqual(['@acme/ds'])
expect(metadata?.designSystem?.[0]?.manifest.name).toBe('@acme/ds')
expect(metadata?.designSystem?.[0]?.buildInfoPath).toMatch(/panda\.buildinfo\.json$/)
expect(metadata?.designSystem?.[0]?.files).toEqual(['./dist/**/*.mjs'])
})

test('respects a custom outdir basename in the wired importMap', async () => {
Expand Down Expand Up @@ -111,7 +111,7 @@ describe('resolveAuthoredPresets / designSystem', () => {
writeFileSync(join(pkg, 'p.mjs'), 'export default {}')

const { metadata } = await resolveAuthoredPresets({ designSystem: '@acme/future' } as any, cwd)
expect(metadata?.designSystem?.buildInfoPath).toMatch(/b\.json$/)
expect(metadata?.designSystem?.[0]?.buildInfoPath).toMatch(/b\.json$/)
})

test('rejects a manifest missing a buildInfo entry', async () => {
Expand All @@ -128,30 +128,140 @@ describe('resolveAuthoredPresets / designSystem', () => {
)
})

test('rejects parent design systems in the single-level loader', async () => {
const pkg = join(cwd, 'node_modules', '@acme', 'child')
test('errors with guidance when the package does not resolve', async () => {
await expect(resolveAuthoredPresets({ designSystem: '@acme/missing' } as any, cwd)).rejects.toThrow(
/designSystem "@acme\/missing" could not be resolved/,
)
})

test('throws when manifest resolution fails for an unexpected reason', async () => {
const pkg = join(cwd, 'node_modules', '@acme', 'broken-resolve')
mkdirSync(pkg, { recursive: true })
writeFileSync(join(pkg, 'package.json'), JSON.stringify({ name: '@acme/child' }))
writeFileSync(
join(pkg, 'panda.lib.json'),
JSON.stringify({
schemaVersion: 1,
name: '@acme/child',
panda: '^2.0.0',
preset: './p.mjs',
buildInfo: './b.json',
designSystem: '@acme/base',
}),
join(pkg, 'package.json'),
JSON.stringify({ name: '@acme/broken-resolve', exports: { './panda.lib.json': 42 } }),
)
writeFileSync(join(pkg, 'p.mjs'), 'export default {}')
await expect(resolveAuthoredPresets({ designSystem: '@acme/child' } as any, cwd)).rejects.toThrow(
/nested design systems are not supported yet/,

await expect(resolveAuthoredPresets({ designSystem: '@acme/broken-resolve' } as any, cwd)).rejects.toThrow(
/Failed to resolve designSystem "@acme\/broken-resolve"/,
)
})
})

test('errors with guidance when the package does not resolve', async () => {
await expect(resolveAuthoredPresets({ designSystem: '@acme/missing' } as any, cwd)).rejects.toThrow(
/designSystem "@acme\/missing" could not be resolved/,
describe('resolveAuthoredPresets / designSystem nested chains', () => {
let cwd: string

const writeManifest = (dir: string, manifest: Record<string, unknown>, preset: string) => {
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: manifest.name }))
writeFileSync(join(dir, 'panda.lib.json'), JSON.stringify({ schemaVersion: 1, panda: '^2.0.0', ...manifest }))
writeFileSync(join(dir, 'preset.mjs'), preset)
}

beforeAll(() => {
cwd = mkdtempSync(join(tmpdir(), 'panda-ds-chain-'))
const mods = join(cwd, 'node_modules')

writeManifest(
join(mods, '@acme', 'marketing'),
{
name: '@acme/marketing',
preset: './preset.mjs',
buildInfo: './bi.json',
designSystem: '@acme/foundations',
importMap: { css: '@acme/marketing/css' },
},
`export default { name: '@acme/marketing', theme: { tokens: { colors: { brand: { value: 'mk' }, mkOnly: { value: 'mk' } } } } }`,
)
writeManifest(
join(mods, '@acme', 'marketing', 'node_modules', '@acme', 'foundations'),
{ name: '@acme/foundations', preset: './preset.mjs', buildInfo: './bi.json' },
`export default { name: '@acme/foundations', theme: { tokens: { colors: { brand: { value: 'fd' }, fdOnly: { value: 'fd' } } } } }`,
)

writeManifest(
join(mods, '@acme', 'loop-a'),
{ name: '@acme/loop-a', preset: './preset.mjs', buildInfo: './bi.json', designSystem: '@acme/loop-b' },
`export default { name: '@acme/loop-a' }`,
)
writeManifest(
join(mods, '@acme', 'loop-b'),
{ name: '@acme/loop-b', preset: './preset.mjs', buildInfo: './bi.json', designSystem: '@acme/loop-a' },
`export default { name: '@acme/loop-b' }`,
)

writeManifest(
join(mods, '@acme', 'orphan'),
{ name: '@acme/orphan', preset: './preset.mjs', buildInfo: './bi.json', designSystem: '@acme/ghost' },
`export default { name: '@acme/orphan' }`,
)

writeManifest(
join(mods, '@acme', 'skinned'),
{ name: '@acme/skinned', preset: './preset.mjs', buildInfo: './bi.json', designSystem: '@acme/raw' },
`export default { name: '@acme/skinned', theme: { tokens: { colors: { brand: { value: 'skin' }, skinOnly: { value: 'skin' } } } } }`,
)
writeManifest(
join(mods, '@acme', 'raw'),
{ name: '@acme/raw-identity', preset: './preset.mjs', buildInfo: './bi.json' },
`export default { name: '@acme/raw-identity', theme: { tokens: { colors: { brand: { value: 'raw' }, rawOnly: { value: 'raw' } } } } }`,
)
})

afterAll(() => rmSync(cwd, { recursive: true, force: true }))

test('merges ancestors root-first so the leaf and the app override the root', async () => {
const { config } = await resolveAuthoredPresets(
{ designSystem: '@acme/marketing', theme: { tokens: { colors: { brand: { value: 'app' } } } } } as any,
cwd,
)
const colors = config.theme?.tokens?.colors
if (!colors) throw new Error('expected resolved color tokens')
expect(colors.brand.value).toBe('app')
expect(colors.mkOnly.value).toBe('mk')
expect(colors.fdOnly.value).toBe('fd')
})

test('records the resolved chain root-first in metadata', async () => {
const { metadata } = await resolveAuthoredPresets({ designSystem: '@acme/marketing' } as any, cwd)
expect(metadata?.designSystem?.map((ds) => ds.name)).toEqual(['@acme/foundations', '@acme/marketing'])
})

test('wires one importMap root per design system, root-first, then the local outdir', async () => {
const { config } = await resolveAuthoredPresets({ designSystem: '@acme/marketing' } as any, cwd)
expect(config.importMap).toEqual([
'@acme/foundations',
{
css: '@acme/marketing/css',
recipes: '@acme/marketing/recipes',
patterns: '@acme/marketing/patterns',
jsx: '@acme/marketing/jsx',
tokens: '@acme/marketing/tokens',
},
'styled-system',
])
})

test('reports a cycle in the parent chain', async () => {
await expect(resolveAuthoredPresets({ designSystem: '@acme/loop-a' } as any, cwd)).rejects.toThrow(
/Design-system cycle: @acme\/loop-a → @acme\/loop-b → @acme\/loop-a/,
)
})

test('reports a parent that is not installed alongside its declaring library', async () => {
await expect(resolveAuthoredPresets({ designSystem: '@acme/orphan' } as any, cwd)).rejects.toThrow(
/designSystem "@acme\/orphan" extends "@acme\/ghost"/,
)
})

test('links the chain by specifier, so a parent whose manifest name differs still merges as the root', async () => {
const { config, metadata } = await resolveAuthoredPresets({ designSystem: '@acme/skinned' } as any, cwd)
const colors = config.theme?.tokens?.colors
if (!colors) throw new Error('expected resolved color tokens')
expect(colors.brand.value).toBe('skin')
expect(colors.rawOnly.value).toBe('raw')
expect(colors.skinOnly.value).toBe('skin')
expect(metadata?.designSystem?.map((ds) => ds.name)).toEqual(['@acme/raw-identity', '@acme/skinned'])
expect(config.importMap).toEqual(['@acme/raw', '@acme/skinned', 'styled-system'])
})
})
Loading
Loading