From b4f2023b1d5c733db67d4e90eb9485a95c5ed480 Mon Sep 17 00:00:00 2001 From: Ronny Badilla Date: Thu, 20 Aug 2026 02:34:02 -0600 Subject: [PATCH] feat(shadcn): add migrate base-color (#11248) * feat(shadcn): add migrate base-color Add a `base-color` migration that switches a project's base color and rewrites its theme CSS variables, mirroring `migrate icons`. Only tokens that still hold the source base color's value are replaced; customized tokens are left untouched and reported. `--from` defaults to the current `baseColor`. * chore: add changeset * chore: changeset --------- Co-authored-by: shadcn --- .changeset/migrate-base-color.md | 5 + apps/v4/content/docs/(root)/cli.mdx | 51 ++- packages/shadcn/src/commands/migrate.ts | 22 +- .../src/migrations/migrate-base-color.test.ts | 178 ++++++++++ .../src/migrations/migrate-base-color.ts | 318 ++++++++++++++++++ 5 files changed, 555 insertions(+), 19 deletions(-) create mode 100644 .changeset/migrate-base-color.md create mode 100644 packages/shadcn/src/migrations/migrate-base-color.test.ts create mode 100644 packages/shadcn/src/migrations/migrate-base-color.ts diff --git a/.changeset/migrate-base-color.md b/.changeset/migrate-base-color.md new file mode 100644 index 00000000000..5686af0ced1 --- /dev/null +++ b/.changeset/migrate-base-color.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add `npx shadcn migrate base-color` to switch a project's base color. diff --git a/apps/v4/content/docs/(root)/cli.mdx b/apps/v4/content/docs/(root)/cli.mdx index 347221b44ae..b6a7b91bb2c 100644 --- a/apps/v4/content/docs/(root)/cli.mdx +++ b/apps/v4/content/docs/(root)/cli.mdx @@ -408,11 +408,12 @@ npx shadcn@latest migrate [migration] **Available Migrations** -| Migration | Description | -| --------- | ------------------------------------------------------- | -| `icons` | Migrate your UI components to a different icon library. | -| `radix` | Migrate to radix-ui. | -| `rtl` | Migrate your components to support RTL (right-to-left). | +| Migration | Description | +| ------------ | ------------------------------------------------------- | +| `icons` | Migrate your UI components to a different icon library. | +| `base-color` | Migrate your theme to a different base color. | +| `radix` | Migrate to radix-ui. | +| `rtl` | Migrate your components to support RTL (right-to-left). | **Options** @@ -422,16 +423,16 @@ Usage: shadcn migrate [options] [migration] [path] run a migration. Arguments: - migration the migration to run. - path optional path or glob pattern to migrate. + migration the migration to run. + path optional path or glob pattern to migrate. Options: - -c, --cwd the working directory. defaults to the current directory. - -l, --list list all migrations. (default: false) - -y, --yes skip confirmation prompt. (default: false) - -f, --from the icon library to migrate from (icons migration only). - -t, --to the icon library to migrate to (icons migration only). - -h, --help display help for command + -c, --cwd the working directory. defaults to the current directory. + -l, --list list all migrations. (default: false) + -y, --yes skip confirmation prompt. (default: false) + -f, --from the base color or icon library to migrate from. + -t, --to the base color or icon library to migrate to. + -h, --help display help for command ``` --- @@ -472,6 +473,30 @@ Icons without an equivalent in the target library are left untouched and reporte --- +### migrate base-color + +The `base-color` migration switches your theme from one base color to another. + +```bash +npx shadcn@latest migrate base-color +``` + +This will prompt you for the source and target base colors, rewrite the theme CSS variables (the CSS file configured by `tailwind.css` in your `components.json`) and update `baseColor` so future `npx shadcn add` installs use the new base color. + +The following base colors are supported: `neutral`, `zinc`, `stone`, `mauve`, `olive`, `mist` and `taupe`. + +**Non-interactive** + +Use `--to` to migrate from the current `baseColor` in your `components.json`, or pass both `--from` and `--to` explicitly. + +```bash +npx shadcn@latest migrate base-color --to zinc --yes +``` + +Theme tokens that no longer match the source base color are left untouched and reported at the end of the migration. + +--- + ### migrate rtl The `rtl` migration transforms your components to support RTL (right-to-left) languages. diff --git a/packages/shadcn/src/commands/migrate.ts b/packages/shadcn/src/commands/migrate.ts index 30506b7ac8c..1c04a827dbc 100644 --- a/packages/shadcn/src/commands/migrate.ts +++ b/packages/shadcn/src/commands/migrate.ts @@ -1,4 +1,5 @@ import path from "path" +import { migrateBaseColor } from "@/src/migrations/migrate-base-color" import { migrateIcons } from "@/src/migrations/migrate-icons" import { migrateRadix } from "@/src/migrations/migrate-radix" import { migrateRtl } from "@/src/migrations/migrate-rtl" @@ -14,6 +15,10 @@ export const migrations = [ name: "icons", description: "migrate your ui components to a different icon library.", }, + { + name: "base-color", + description: "migrate your theme to a different base color.", + }, { name: "radix", description: "migrate to radix-ui.", @@ -57,13 +62,10 @@ export const migrate = new Command() .option("-l, --list", "list all migrations.", false) .option("-y, --yes", "skip confirmation prompt.", false) .option( - "-f, --from ", - "the icon library to migrate from (icons migration only)." - ) - .option( - "-t, --to ", - "the icon library to migrate to (icons migration only)." + "-f, --from ", + "the base color or icon library to migrate from." ) + .option("-t, --to ", "the base color or icon library to migrate to.") .action(async (migration, migratePath, opts) => { try { const options = migrateOptionsSchema.parse({ @@ -116,6 +118,14 @@ export const migrate = new Command() }) } + if (options.migration === "base-color") { + await migrateBaseColor(config, { + from: options.from, + to: options.to, + yes: options.yes, + }) + } + if (options.migration === "radix") { await migrateRadix(config, { yes: options.yes, path: options.path }) } diff --git a/packages/shadcn/src/migrations/migrate-base-color.test.ts b/packages/shadcn/src/migrations/migrate-base-color.test.ts new file mode 100644 index 00000000000..41ad8f56a8f --- /dev/null +++ b/packages/shadcn/src/migrations/migrate-base-color.test.ts @@ -0,0 +1,178 @@ +import { Config } from "@/src/utils/get-config" +import { transformCssVars } from "@/src/utils/updaters/update-css-vars" +import { describe, expect, it } from "vitest" + +import { getBaseColorMigration } from "./migrate-base-color" + +const SOURCE = { + light: { + background: "oklch(1 0 0)", + ring: "oklch(0.7 0.01 286)", + primary: "oklch(0.2 0 0)", + border: "oklch(0.9 0.01 286)", + }, + dark: { + background: "oklch(0.14 0 0)", + ring: "oklch(0.55 0.02 286)", + }, +} + +const TARGET = { + light: { + background: "oklch(1 0 0)", + ring: "oklch(0.708 0 0)", + primary: "oklch(0.205 0 0)", + border: "oklch(0.922 0 0)", + }, + dark: { + background: "oklch(0.14 0 0)", + ring: "oklch(0.556 0 0)", + }, +} + +describe("getBaseColorMigration", () => { + it("replaces tokens that still hold the source base color value", () => { + const css = `@import "tailwindcss"; + +:root { + --background: oklch(1 0 0); + --ring: oklch(0.7 0.01 286); + --primary: oklch(0.2 0 0); + --border: oklch(0.9 0.01 286); +} + +.dark { + --background: oklch(0.14 0 0); + --ring: oklch(0.55 0.02 286); +}` + + const { cssVars, skipped } = getBaseColorMigration( + css, + SOURCE, + TARGET, + "v4" + ) + + expect(cssVars.light).toEqual({ + ring: "oklch(0.708 0 0)", + primary: "oklch(0.205 0 0)", + border: "oklch(0.922 0 0)", + }) + expect(cssVars.dark).toEqual({ ring: "oklch(0.556 0 0)" }) + expect(skipped).toEqual([]) + }) + + it("keeps and reports tokens that no longer match the source", () => { + const css = `:root { + --ring: oklch(0.7 0.01 286); + --primary: oklch(0.55 0.22 260); +}` + + const { cssVars, skipped } = getBaseColorMigration( + css, + SOURCE, + TARGET, + "v4" + ) + + expect(cssVars.light.ring).toBe("oklch(0.708 0 0)") + expect(cssVars.light.primary).toBeUndefined() + expect(skipped).toContainEqual({ + token: "--primary", + reason: "does not match the source base color", + }) + }) + + it("does not touch or report tokens that are equal across base colors", () => { + const css = `:root { + --background: oklch(1 0 0); + --ring: oklch(0.7 0.01 286); +}` + + const { cssVars, skipped } = getBaseColorMigration( + css, + SOURCE, + TARGET, + "v4" + ) + + expect(cssVars.light.background).toBeUndefined() + expect(skipped.map((entry) => entry.token)).not.toContain("--background") + }) + + it("reports tokens that are missing from the CSS", () => { + const css = `:root { + --ring: oklch(0.7 0.01 286); +}` + + const { skipped } = getBaseColorMigration(css, SOURCE, TARGET, "v4") + + expect(skipped).toContainEqual({ + token: "--border", + reason: "not found in your CSS", + }) + }) + + it("wraps local hsl channels before comparing in v4", () => { + const source = { light: { border: "0 0% 90%" } } + const target = { light: { border: "0 0% 92%" } } + const css = `:root { + --border: hsl(0 0% 90%); +}` + + const { cssVars, skipped } = getBaseColorMigration( + css, + source, + target, + "v4" + ) + + expect(cssVars.light.border).toBe("0 0% 92%") + expect(skipped).toEqual([]) + }) +}) + +describe("getBaseColorMigration + transformCssVars", () => { + it("writes the computed subset into the theme CSS for v4", async () => { + const source = { + light: { + ring: "oklch(0.7 0.01 286)", + sidebar: "oklch(0.98 0.01 286)", + }, + dark: { ring: "oklch(0.55 0.02 286)" }, + } + const target = { + light: { + ring: "oklch(0.708 0 0)", + sidebar: "oklch(0.985 0 0)", + }, + dark: { ring: "oklch(0.556 0 0)" }, + } + const css = `@import "tailwindcss"; + +:root { + --ring: oklch(0.7 0.01 286); + --sidebar: oklch(0.98 0.01 286); + --primary: oklch(0.55 0.22 260); +} + +.dark { + --ring: oklch(0.55 0.02 286); +}` + + const { cssVars } = getBaseColorMigration(css, source, target, "v4") + const output = await transformCssVars(css, cssVars, {} as Config, { + tailwindVersion: "v4", + overwriteCssVars: true, + }) + + // Stock tokens are rewritten to the target base color in both modes. + expect(output).toContain("--ring: oklch(0.708 0 0)") + expect(output).toContain("--sidebar: oklch(0.985 0 0)") + expect(output).toContain("--ring: oklch(0.556 0 0)") + // Tokens outside the computed subset are left untouched. + expect(output).toContain("--primary: oklch(0.55 0.22 260)") + // The @theme inline color mappings are (re)generated. + expect(output).toContain("@theme inline") + }) +}) diff --git a/packages/shadcn/src/migrations/migrate-base-color.ts b/packages/shadcn/src/migrations/migrate-base-color.ts new file mode 100644 index 00000000000..41bed3fe019 --- /dev/null +++ b/packages/shadcn/src/migrations/migrate-base-color.ts @@ -0,0 +1,318 @@ +import { promises as fs } from "fs" +import path from "path" +import { getRegistryBaseColor } from "@/src/registry/api" +import { BASE_COLORS } from "@/src/registry/constants" +import { + registryBaseColorSchema, + registryItemCssVarsSchema, +} from "@/src/schema" +import { Config } from "@/src/utils/get-config" +import { getProjectInfo, TailwindVersion } from "@/src/utils/get-project-info" +import { highlighter } from "@/src/utils/highlighter" +import { logger } from "@/src/utils/logger" +import { spinner } from "@/src/utils/spinner" +import { + isLocalHSLValue, + updateCssVars, +} from "@/src/utils/updaters/update-css-vars" +import fsExtra from "fs-extra" +import postcss from "postcss" +import prompts from "prompts" +import { z } from "zod" + +type CssVars = z.infer + +export interface SkippedToken { + token: string + reason: string +} + +export async function migrateBaseColor( + config: Config, + options: { + from?: string + to?: string + yes?: boolean + } = {} +) { + if (!config.resolvedPaths.tailwindCss) { + throw new Error( + "We could not find a valid CSS file in your `components.json` file. Please ensure you have a valid `tailwind.css` path in your `components.json` file." + ) + } + + if (!config.tailwind.cssVariables) { + throw new Error( + "The `base-color` migration requires CSS variables. Your `components.json` has `cssVariables: false`, which uses inline Tailwind color classes instead of theme variables." + ) + } + + const baseColorChoices = BASE_COLORS.map((baseColor) => ({ + title: baseColor.label, + value: baseColor.name, + })) + const baseColorNames: string[] = BASE_COLORS.map( + (baseColor) => baseColor.name + ) + + // Only the target is validated. The source can be a legacy base color + // (e.g. slate) an existing project still uses. + if (options.to && !baseColorNames.includes(options.to)) { + throw new Error( + `Unknown base color: ${options.to}. Available base colors: ${baseColorNames.join( + ", " + )}.` + ) + } + + // Default the source to the project's current base color. + let sourceBaseColor = options.from || config.tailwind.baseColor + let targetBaseColor = options.to + + if (!sourceBaseColor || !targetBaseColor) { + const currentBaseColorIndex = baseColorChoices.findIndex( + (choice) => choice.value === config.tailwind.baseColor + ) + const migrateOptions = await prompts([ + { + type: sourceBaseColor ? null : "select", + name: "sourceBaseColor", + message: `Which base color would you like to ${highlighter.info( + "migrate from" + )}?`, + choices: baseColorChoices, + initial: currentBaseColorIndex === -1 ? 0 : currentBaseColorIndex, + }, + { + type: targetBaseColor ? null : "select", + name: "targetBaseColor", + message: `Which base color would you like to ${highlighter.info( + "migrate to" + )}?`, + choices: baseColorChoices, + }, + ]) + + sourceBaseColor = sourceBaseColor || migrateOptions.sourceBaseColor + targetBaseColor = targetBaseColor || migrateOptions.targetBaseColor + } + + if (!sourceBaseColor || !targetBaseColor) { + logger.info("Migration cancelled.") + process.exit(0) + } + + if (sourceBaseColor === targetBaseColor) { + throw new Error( + "You cannot migrate to the same base color. Please choose a different base color." + ) + } + + if (!options.yes) { + const relativePath = `./${path.relative( + config.resolvedPaths.cwd, + config.resolvedPaths.tailwindCss + )}` + const { confirm } = await prompts({ + type: "confirm", + name: "confirm", + initial: true, + message: `We will migrate ${highlighter.info( + relativePath + )} from ${highlighter.info(sourceBaseColor)} to ${highlighter.info( + targetBaseColor + )}. Continue?`, + }) + + if (!confirm) { + logger.info("Migration cancelled.") + process.exit(0) + } + } + + const sourceColor = await getRegistryBaseColor(sourceBaseColor) + const targetColor = await getRegistryBaseColor(targetBaseColor) + + if (!sourceColor) { + throw new Error(`Unknown base color: ${sourceBaseColor}.`) + } + + if (!targetColor) { + throw new Error("Something went wrong fetching the base colors.") + } + + const projectInfo = await getProjectInfo(config.resolvedPaths.cwd) + const tailwindVersion = projectInfo?.tailwindVersion ?? "v4" + + const sourceVars = getBaseColorCssVars(sourceColor, tailwindVersion) + const targetVars = getBaseColorCssVars(targetColor, tailwindVersion) + + const migrationSpinner = spinner(`Migrating base color...`)?.start() + + const raw = await fs.readFile(config.resolvedPaths.tailwindCss, "utf-8") + const { cssVars, skipped } = getBaseColorMigration( + raw, + sourceVars, + targetVars, + tailwindVersion + ) + + if ( + Object.keys(cssVars.light).length > 0 || + Object.keys(cssVars.dark).length > 0 + ) { + await updateCssVars(cssVars, config, { + overwriteCssVars: true, + tailwindVersion, + silent: true, + }) + } + + migrationSpinner.succeed("Migration complete.") + + // Keep components.json in sync so future `shadcn add` installs use the new + // base color. + await updateConfigBaseColor(config, targetBaseColor) + + const skippedTokens = new Map() + for (const { token, reason } of skipped) { + if (!skippedTokens.has(token)) { + skippedTokens.set(token, reason) + } + } + + if (skippedTokens.size > 0) { + logger.break() + logger.warn( + `Skipped ${skippedTokens.size} token${ + skippedTokens.size === 1 ? "" : "s" + }. These were left untouched:` + ) + for (const [token, reason] of Array.from(skippedTokens)) { + logger.warn(` - ${token}: ${reason}`) + } + } +} + +export function getBaseColorMigration( + css: string, + sourceVars: CssVars, + targetVars: CssVars, + tailwindVersion: TailwindVersion +): { + cssVars: { light: Record; dark: Record } + skipped: SkippedToken[] +} { + const currentVars = readCssVars(css) + + const light: Record = {} + const dark: Record = {} + const skipped: SkippedToken[] = [] + + const modes = [ + { mode: "light", selector: ":root", target: light }, + { mode: "dark", selector: ".dark", target: dark }, + ] as const + + for (const { mode, selector, target } of modes) { + for (const [name, sourceValue] of Object.entries(sourceVars[mode] ?? {})) { + const targetValue = targetVars[mode]?.[name] + + // Skip tokens that are identical in both base colors (e.g. radius). + if ( + targetValue === undefined || + normalizeValue(sourceValue) === normalizeValue(targetValue) + ) { + continue + } + + const prop = `--${name.replace(/^--/, "")}` + const currentValue = currentVars[selector][prop] + + if (currentValue === undefined) { + skipped.push({ token: prop, reason: "not found in your CSS" }) + continue + } + + // Only replace tokens that still hold the source base color value. + if ( + normalizeValue(currentValue) === + normalizeValue(getWrittenValue(sourceValue, tailwindVersion)) + ) { + target[name] = targetValue + } else { + skipped.push({ + token: prop, + reason: "does not match the source base color", + }) + } + } + } + + return { cssVars: { light, dark }, skipped } +} + +function getBaseColorCssVars( + baseColor: z.infer, + tailwindVersion: TailwindVersion +): CssVars { + if (tailwindVersion === "v4" && baseColor.cssVarsV4) { + return baseColor.cssVarsV4 + } + return baseColor.cssVars +} + +function readCssVars(css: string) { + const vars: Record> = { + ":root": {}, + ".dark": {}, + } + + postcss.parse(css).walkRules((rule) => { + if (rule.selector !== ":root" && rule.selector !== ".dark") { + return + } + + rule.walkDecls((declaration) => { + if (declaration.prop.startsWith("--")) { + vars[rule.selector][declaration.prop] = declaration.value + } + }) + }) + + return vars +} + +function getWrittenValue(value: string, tailwindVersion: TailwindVersion) { + // Local HSL channels are wrapped in hsl() when written to CSS in v4. + if (tailwindVersion === "v4" && isLocalHSLValue(value)) { + return `hsl(${value})` + } + return value +} + +function normalizeValue(value: string) { + return value.trim().replace(/\s+/g, " ") +} + +async function updateConfigBaseColor(config: Config, baseColor: string) { + const targetPath = path.resolve(config.resolvedPaths.cwd, "components.json") + + if (!fsExtra.existsSync(targetPath)) { + return + } + + const rawConfig = await fsExtra.readJson(targetPath) + + if (rawConfig.tailwind?.baseColor === baseColor) { + return + } + + rawConfig.tailwind = { ...rawConfig.tailwind, baseColor } + await fsExtra.writeJson(targetPath, rawConfig, { spaces: 2 }) + logger.info( + `Updated ${highlighter.info( + "baseColor" + )} in components.json to ${highlighter.info(baseColor)}.` + ) +}