From 61c020ff65682dbacd39d4a104a415d083b7a887 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 1 Sep 2025 19:20:15 +0100 Subject: [PATCH 1/2] feat: implement --- .changeset/color-palette-handling.md | 38 ++++ packages/core/src/context.ts | 1 + .../__tests__/color-palette.test.ts | 167 ++++++++++++++++++ packages/token-dictionary/src/dictionary.ts | 14 +- packages/token-dictionary/src/middleware.ts | 6 + packages/token-dictionary/src/transform.ts | 14 ++ packages/types/src/theme.ts | 26 +++ website/pages/docs/concepts/virtual-color.md | 78 ++++++++ 8 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 .changeset/color-palette-handling.md diff --git a/.changeset/color-palette-handling.md b/.changeset/color-palette-handling.md new file mode 100644 index 0000000000..20095d4a3e --- /dev/null +++ b/.changeset/color-palette-handling.md @@ -0,0 +1,38 @@ +--- +'@pandacss/types': minor +'@pandacss/token-dictionary': minor +'@pandacss/core': minor +'@pandacss/config': minor +'@pandacss/generator': minor +--- + +Add support for controlling the color palette generation via `theme.colorPalette` property. + +```ts +// Disable color palette generation completely +export default defineConfig({ + theme: { + colorPalette: { + enabled: false, + }, + }, +}) + +// Include only specific colors +export default defineConfig({ + theme: { + colorPalette: { + include: ['gray', 'blue', 'red'], + }, + }, +}) + +// Exclude specific colors +export default defineConfig({ + theme: { + colorPalette: { + exclude: ['yellow', 'orange'], + }, + }, +}) +``` diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index 429c954d48..36dc6fb602 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -258,6 +258,7 @@ export class Context { themes: themeVariants, prefix: this.prefix.tokens, hash: this.hash.tokens, + colorPalette: theme.colorPalette, }) } diff --git a/packages/token-dictionary/__tests__/color-palette.test.ts b/packages/token-dictionary/__tests__/color-palette.test.ts index e8c08d585f..83213f70bd 100644 --- a/packages/token-dictionary/__tests__/color-palette.test.ts +++ b/packages/token-dictionary/__tests__/color-palette.test.ts @@ -3,6 +3,15 @@ import { addVirtualPalette } from '../src/middleware' import { transforms } from '../src/transform' import { TokenDictionary } from '../src/dictionary' +const createDictionary = (options: any = {}) => { + const dictionary = new TokenDictionary(options) + return dictionary + .registerTokens() + .registerTransform(...transforms) + .registerMiddleware(addVirtualPalette) + .build() +} + const dasherize = (token: string) => token .toString() @@ -1634,3 +1643,161 @@ test('should generate virtual palette with DEFAULT value', () => { } `) }) + +test('should disable color palette when enabled is false', () => { + const dictionary = createDictionary({ + tokens: { + colors: { + red: { + 500: { value: '#red500' }, + 700: { value: '#red700' }, + }, + blue: { + 500: { value: '#blue500' }, + 700: { value: '#blue700' }, + }, + }, + }, + colorPalette: { + enabled: false, + }, + }) + + const getVar = dictionary.view.get + expect(getVar('colors.colorPalette.500')).toBeUndefined() + expect(getVar('colors.colorPalette.700')).toBeUndefined() + + expect(Array.from(dictionary.view.colorPalettes.keys())).toHaveLength(0) +}) + +test('should include only specified colors in color palette', () => { + const dictionary = createDictionary({ + tokens: { + colors: { + red: { + 500: { value: '#red500' }, + 700: { value: '#red700' }, + }, + blue: { + 500: { value: '#blue500' }, + 700: { value: '#blue700' }, + }, + green: { + 500: { value: '#green500' }, + 700: { value: '#green700' }, + }, + }, + }, + colorPalette: { + include: ['red', 'blue'], + }, + }) + + const getVar = dictionary.view.get + expect(getVar('colors.colorPalette.500')).toBe('var(--colors-color-palette-500)') + expect(getVar('colors.colorPalette.700')).toBe('var(--colors-color-palette-700)') + + expect(Array.from(dictionary.view.colorPalettes.keys())).toMatchInlineSnapshot(` + [ + "red", + "blue", + ] + `) +}) + +test('should exclude specified colors from color palette', () => { + const dictionary = createDictionary({ + tokens: { + colors: { + red: { + 500: { value: '#red500' }, + 700: { value: '#red700' }, + }, + blue: { + 500: { value: '#blue500' }, + 700: { value: '#blue700' }, + }, + green: { + 500: { value: '#green500' }, + 700: { value: '#green700' }, + }, + }, + }, + colorPalette: { + exclude: ['red'], + }, + }) + + const getVar = dictionary.view.get + expect(getVar('colors.colorPalette.500')).toBe('var(--colors-color-palette-500)') + expect(getVar('colors.colorPalette.700')).toBe('var(--colors-color-palette-700)') + + expect(Array.from(dictionary.view.colorPalettes.keys())).toMatchInlineSnapshot(` + [ + "blue", + "green", + ] + `) +}) + +test('should handle semantic tokens with colorPalette configuration', () => { + const dictionary = createDictionary({ + semanticTokens: { + colors: { + primary: { + value: '{colors.blue.500}', + }, + secondary: { + value: '{colors.red.500}', + }, + accent: { + value: '{colors.green.500}', + }, + }, + }, + tokens: { + colors: { + blue: { + 500: { value: '#blue500' }, + }, + red: { + 500: { value: '#red500' }, + }, + green: { + 500: { value: '#green500' }, + }, + }, + }, + colorPalette: { + include: ['primary'], + }, + }) + + expect(Array.from(dictionary.view.colorPalettes.keys())).toMatchInlineSnapshot(` + [ + "primary", + ] + `) +}) + +test('should enable color palette by default when no configuration is provided', () => { + const dictionary = createDictionary({ + tokens: { + colors: { + red: { + 500: { value: '#red500' }, + }, + }, + }, + // No colorPalette config provided - should default to enabled: true + }) + + const getVar = dictionary.view.get + expect(getVar('colors.colorPalette.500')).toBe('var(--colors-color-palette-500)') + + expect(Array.from(dictionary.view.colorPalettes.keys())).toMatchInlineSnapshot(` + [ + "red", + ] + `) +}) diff --git a/packages/token-dictionary/src/dictionary.ts b/packages/token-dictionary/src/dictionary.ts index 2235cabdc0..c60ed93376 100644 --- a/packages/token-dictionary/src/dictionary.ts +++ b/packages/token-dictionary/src/dictionary.ts @@ -10,7 +10,14 @@ import { type CssVar, type CssVarOptions, } from '@pandacss/shared' -import type { Recursive, SemanticTokens, ThemeVariantsMap, TokenCategory, Tokens } from '@pandacss/types' +import type { + Recursive, + SemanticTokens, + ThemeVariantsMap, + TokenCategory, + Tokens, + ColorPaletteOptions, +} from '@pandacss/types' import { isMatching, match } from 'ts-pattern' import { isCompositeTokenValue } from './is-composite' import { middlewares } from './middleware' @@ -36,6 +43,7 @@ export interface TokenDictionaryOptions { themes?: ThemeVariantsMap | undefined prefix?: string hash?: boolean + colorPalette?: ColorPaletteOptions } export interface TokenMiddleware { @@ -80,6 +88,10 @@ export class TokenDictionary { return this.options.hash } + get colorPalette() { + return this.options.colorPalette + } + getByName = (path: string) => { return this.byName.get(path) } diff --git a/packages/token-dictionary/src/middleware.ts b/packages/token-dictionary/src/middleware.ts index 5ed9ca05df..f767feec4b 100644 --- a/packages/token-dictionary/src/middleware.ts +++ b/packages/token-dictionary/src/middleware.ts @@ -64,6 +64,12 @@ export const addPixelUnit: TokenMiddleware = { export const addVirtualPalette: TokenMiddleware = { enforce: 'post', transform(dictionary: TokenDictionary) { + const colorPaletteConfig = dictionary.colorPalette + const enabled = colorPaletteConfig?.enabled ?? true + + // If disabled, skip generating color palettes + if (!enabled) return + const tokens = dictionary.filter({ extensions: { category: 'colors' } }) const keys = new Map() diff --git a/packages/token-dictionary/src/transform.ts b/packages/token-dictionary/src/transform.ts index 6e1930dad7..574f7307ca 100644 --- a/packages/token-dictionary/src/transform.ts +++ b/packages/token-dictionary/src/transform.ts @@ -229,6 +229,15 @@ export const addColorPalette: TokenTransformer = { return token.extensions.category === 'colors' && !token.extensions.isVirtual }, transform(token, dict) { + // Check colorPalette configuration + const colorPaletteConfig = dict.colorPalette + const enabled = colorPaletteConfig?.enabled ?? true + const include = colorPaletteConfig?.include + const exclude = colorPaletteConfig?.exclude + + // If disabled, don't add colorPalette extensions + if (!enabled) return {} + let tokenPathClone = [...token.path] tokenPathClone.pop() tokenPathClone.shift() @@ -243,6 +252,11 @@ export const addColorPalette: TokenTransformer = { return {} } + // Check include/exclude filters + const colorName = token.path[1] // e.g., 'blue' from ['colors', 'blue', '500'] + if (include && !include.includes(colorName)) return {} + if (exclude && exclude.includes(colorName)) return {} + /** * If this is the nested color palette: * ```json diff --git a/packages/types/src/theme.ts b/packages/types/src/theme.ts index 3b8375bf32..97e1ceef77 100644 --- a/packages/types/src/theme.ts +++ b/packages/types/src/theme.ts @@ -3,6 +3,24 @@ import type { RecipeConfig, SlotRecipeConfig } from './recipe' import type { CssKeyframes } from './system-types' import type { SemanticTokens, Tokens } from './tokens' +export interface ColorPaletteOptions { + /** + * Whether to enable color palette generation. + * @default true + */ + enabled?: boolean + /** + * List of color names to include in color palette generation. + * When specified, only these colors will be used for color palettes. + */ + include?: string[] + /** + * List of color names to exclude from color palette generation. + * When specified, these colors will not be used for color palettes. + */ + exclude?: string[] +} + export interface Theme { /** * The breakpoints for your project. @@ -49,6 +67,10 @@ export interface Theme { * The predefined container sizes for your project. */ containerSizes?: Record + /** + * The color palette configuration for your project. + */ + colorPalette?: ColorPaletteOptions } interface PartialTheme extends Omit { @@ -61,6 +83,10 @@ interface PartialTheme extends Omit { * Multi-variant style definitions for component slots. */ slotRecipes?: Record> + /** + * The color palette configuration for your project. + */ + colorPalette?: Partial } export interface ExtendableTheme extends Theme { diff --git a/website/pages/docs/concepts/virtual-color.md b/website/pages/docs/concepts/virtual-color.md index 12ef3d9d0e..243ea59705 100644 --- a/website/pages/docs/concepts/virtual-color.md +++ b/website/pages/docs/concepts/virtual-color.md @@ -224,3 +224,81 @@ function ButtonShowcase() { ) } ``` + +## Configuration + +By default, color palette generation is enabled and includes all colors defined in your theme. + +You can control which colors are used to generate color palettes by configuring the `colorPalette` property in your theme. + +### Disable Color Palette + +To completely disable color palette generation, set `enabled` to `false`: + +```ts filename="panda.config.ts" +import { defineConfig } from '@pandacss/dev' + +export default defineConfig({ + theme: { + colorPalette: { + enabled: false + } + } +}) +``` + +### Include Specific Colors + +To generate color palettes for only specific colors, use the `include` option: + +```ts filename="panda.config.ts" +import { defineConfig } from '@pandacss/dev' + +export default defineConfig({ + theme: { + colorPalette: { + include: ['gray', 'blue', 'red'] + } + } +}) +``` + +This will only generate color palettes for `gray`, `blue`, and `red` colors, even if you have other colors defined in your theme. + +### Exclude Specific Colors + +To exclude certain colors from color palette generation, use the `exclude` option: + +```ts filename="panda.config.ts" +import { defineConfig } from '@pandacss/dev' + +export default defineConfig({ + theme: { + colorPalette: { + exclude: ['yellow', 'orange'] + } + } +}) +``` + +This will generate color palettes for all colors except `yellow` and `orange`. + +### Combination of Options + +You can combine the `enabled`, `include`, and `exclude` options as needed: + +```ts filename="panda.config.ts" +import { defineConfig } from '@pandacss/dev' + +export default defineConfig({ + theme: { + colorPalette: { + enabled: true, + include: ['gray', 'blue', 'red', 'green'], + exclude: ['red'] // This will override the include for 'red' + } + } +}) +``` + +In this example, color palettes will be generated for `gray`, `blue`, and `green`, but not for `red` (since it's excluded). From 323213f8eff1394c68e5624c121629f6bc12ffcb Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Thu, 6 Nov 2025 23:45:12 +0000 Subject: [PATCH 2/2] refactor: handle nested notation in include/exclude --- packages/token-dictionary/package.json | 4 + packages/token-dictionary/src/transform.ts | 106 ++++++++++----------- pnpm-lock.yaml | 7 ++ 3 files changed, 63 insertions(+), 54 deletions(-) diff --git a/packages/token-dictionary/package.json b/packages/token-dictionary/package.json index a04a9d49ac..40c5944550 100644 --- a/packages/token-dictionary/package.json +++ b/packages/token-dictionary/package.json @@ -41,6 +41,10 @@ "@pandacss/logger": "workspace:^", "@pandacss/shared": "workspace:*", "@pandacss/types": "workspace:*", + "picomatch": "^4.0.0", "ts-pattern": "5.8.0" + }, + "devDependencies": { + "@types/picomatch": "4.0.2" } } diff --git a/packages/token-dictionary/src/transform.ts b/packages/token-dictionary/src/transform.ts index be6fd2ba09..9b7c7511d7 100644 --- a/packages/token-dictionary/src/transform.ts +++ b/packages/token-dictionary/src/transform.ts @@ -1,5 +1,6 @@ import { isCssUnit, isString, PandaError } from '@pandacss/shared' import type { TokenDataTypes } from '@pandacss/types' +import picomatch from 'picomatch' import { P, match } from 'ts-pattern' import type { TokenTransformer } from './dictionary' import { isCompositeBorder, isCompositeGradient, isCompositeShadow } from './is-composite' @@ -238,58 +239,56 @@ export const addColorPalette: TokenTransformer = { // If disabled, don't add colorPalette extensions if (!enabled) return {} - let tokenPathClone = [...token.path] - tokenPathClone.pop() - tokenPathClone.shift() - - if (tokenPathClone.length === 0) { - const newPath = [...token.path] - newPath.shift() - tokenPathClone = newPath + // Extract color path (remove 'colors' prefix and last segment) + // ['colors', 'blue', '500'] -> ['blue'] + // ['colors', 'button', 'light', 'accent', 'secondary'] -> ['button', 'light', 'accent'] + // ['colors', 'primary'] -> ['primary'] (handle flat tokens) + let colorPath = token.path.slice(1, -1) + + // If no nested segments, use the path without the 'colors' prefix + if (colorPath.length === 0) { + colorPath = token.path.slice(1) + if (colorPath.length === 0) { + return {} + } } - if (tokenPathClone.length === 0) { - return {} + // Convert path segments to dot-notation string for pattern matching + const colorPathString = colorPath.join('.') + + // Check include/exclude filters using picomatch (supports glob patterns) + // Exclude takes precedence over include + if (exclude?.length) { + const excludeMatchers = exclude.map((pattern) => picomatch(pattern)) + if (excludeMatchers.some((matcher) => matcher(colorPathString))) { + return {} + } } - // Check include/exclude filters - const colorName = token.path[1] // e.g., 'blue' from ['colors', 'blue', '500'] - if (include && !include.includes(colorName)) return {} - if (exclude && exclude.includes(colorName)) return {} + if (include?.length) { + const includeMatchers = include.map((pattern) => picomatch(pattern)) + if (!includeMatchers.some((matcher) => matcher(colorPathString))) { + return {} + } + } /** - * If this is the nested color palette: - * ```json - * { - * "colors": { - * "button": { - * "light": { - * "accent": { - * "secondary": { - * value: 'blue', - * }, - * }, - * }, - * }, - * }, - * }, - * ``` + * Generate all possible color palette roots from the color path. + * + * For ['button', 'light', 'accent']: + * - ['button'] + * - ['button', 'light'] + * - ['button', 'light', 'accent'] * - * The `colorPaletteRoots` will be `['button', 'button.light', 'button.light.accent']`. - * It holds all the possible values you can pass to the css `colorPalette` property. - * It's used by the `addVirtualPalette` middleware to build the virtual `colorPalette` token for each color pattern root. + * These represent all possible values you can pass to the css `colorPalette` property. */ - const colorPaletteRoots = tokenPathClone.reduce( - (acc, _, i, arr) => { - const next = arr.slice(0, i + 1) - acc.push(next) - return acc - }, - [] as Array, - ) - - const colorPaletteRoot = tokenPathClone[0] - const colorPalette = dict.formatTokenName(tokenPathClone) + const colorPaletteRoots: string[][] = [] + for (let i = 0; i < colorPath.length; i++) { + colorPaletteRoots.push(colorPath.slice(0, i + 1)) + } + + const colorPaletteRoot = colorPath[0] + const colorPalette = dict.formatTokenName(colorPath) /** * If this is the nested color palette: @@ -343,16 +342,15 @@ export const addColorPalette: TokenTransformer = { * })} * /> */ - const colorPaletteTokenKeys = token.path - // Remove everything before colorPalette root and the root itself - .slice(token.path.indexOf(colorPaletteRoot) + 1) - .reduce( - (acc, _, i, arr) => { - acc.push(arr.slice(i)) - return acc - }, - [] as Array, - ) + // Remove everything before colorPalette root and the root itself + const startIndex = token.path.indexOf(colorPaletteRoot) + 1 + const remainingPath = token.path.slice(startIndex) + const colorPaletteTokenKeys: string[][] = [] + + // Generate all suffixes of the remaining path + for (let i = 0; i < remainingPath.length; i++) { + colorPaletteTokenKeys.push(remainingPath.slice(i)) + } // https://github.com/chakra-ui/panda/issues/1421 if (colorPaletteTokenKeys.length === 0) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a4fa4f512..f6d902bc9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -733,9 +733,16 @@ importers: '@pandacss/types': specifier: workspace:* version: link:../types + picomatch: + specifier: ^4.0.0 + version: 4.0.3 ts-pattern: specifier: 5.8.0 version: 5.8.0 + devDependencies: + '@types/picomatch': + specifier: 4.0.2 + version: 4.0.2 packages/types: devDependencies: