diff --git a/.changeset/short-colts-go.md b/.changeset/short-colts-go.md new file mode 100644 index 0000000000..095e08dd73 --- /dev/null +++ b/.changeset/short-colts-go.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add fontHeading to presets diff --git a/.changeset/spicy-walls-visit.md b/.changeset/spicy-walls-visit.md new file mode 100644 index 0000000000..69a81c5a53 --- /dev/null +++ b/.changeset/spicy-walls-visit.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add chartColor diff --git a/.cursor/rules/registry-bases-parity.mdc b/.cursor/rules/registry-bases-parity.mdc new file mode 100644 index 0000000000..08ebd58b94 --- /dev/null +++ b/.cursor/rules/registry-bases-parity.mdc @@ -0,0 +1,22 @@ +--- +description: Keep registry base and radix trees in sync when editing shared UI +globs: apps/v4/registry/bases/**/* +alwaysApply: false +--- + +# Registry bases: Base UI ↔ Radix parity + +`apps/v4/registry/bases/base` and `apps/v4/registry/bases/radix` are **parallel registries**. Anything that exists in both trees for the same purpose (preview blocks, mirrored examples, shared card layouts, etc.) **must stay in sync**. + +## When editing + +- If you change a file under **`bases/base/...`**, apply the **same behavioral and visual change** to the matching path under **`bases/radix/...`** (and the reverse). +- Only diverge where APIs differ (e.g. import paths like `@/registry/bases/base/ui/*` vs `@/registry/bases/radix/ui/*`, or Base UI vs Radix component props). +- Do **not** update only one side unless the user explicitly asks for a single-base change. + +## Typical mirrored paths + +- `blocks/preview/**` — preview cards and blocks +- Parallel `ui/*` components when both exist for the same component + +After edits, briefly confirm both trees were updated (or state why one side is intentionally unchanged). diff --git a/apps/v4/app/(app)/docs/changelog/page.tsx b/apps/v4/app/(app)/docs/changelog/page.tsx index 7d08c2be93..b9193beeb0 100644 --- a/apps/v4/app/(app)/docs/changelog/page.tsx +++ b/apps/v4/app/(app)/docs/changelog/page.tsx @@ -79,7 +79,7 @@ export default function ChangelogPage() { })} {olderPages.length > 0 && (
-

+

More Updates

diff --git a/apps/v4/app/(create)/components/chart-color-picker.tsx b/apps/v4/app/(create)/components/chart-color-picker.tsx new file mode 100644 index 0000000000..33b3a12a5e --- /dev/null +++ b/apps/v4/app/(create)/components/chart-color-picker.tsx @@ -0,0 +1,136 @@ +"use client" + +import * as React from "react" + +import { useMounted } from "@/hooks/use-mounted" +import { + BASE_COLORS, + getThemesForBaseColor, + type ChartColorName, +} from "@/registry/config" +import { LockButton } from "@/app/(create)/components/lock-button" +import { + Picker, + PickerContent, + PickerGroup, + PickerRadioGroup, + PickerRadioItem, + PickerSeparator, + PickerTrigger, +} from "@/app/(create)/components/picker" +import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params" + +export function ChartColorPicker({ + isMobile, + anchorRef, +}: { + isMobile: boolean + anchorRef: React.RefObject +}) { + const mounted = useMounted() + const [params, setParams] = useDesignSystemSearchParams() + + const availableChartColors = React.useMemo( + () => getThemesForBaseColor(params.baseColor), + [params.baseColor] + ) + + const currentChartColor = React.useMemo( + () => + availableChartColors.find((theme) => theme.name === params.chartColor), + [availableChartColors, params.chartColor] + ) + + const currentChartColorIsBaseColor = React.useMemo( + () => BASE_COLORS.find((baseColor) => baseColor.name === params.chartColor), + [params.chartColor] + ) + + React.useEffect(() => { + if (!currentChartColor && availableChartColors.length > 0) { + setParams({ chartColor: availableChartColors[0].name }) + } + }, [currentChartColor, availableChartColors, setParams]) + + return ( +
+ + +
+
Chart Color
+
+ {currentChartColor?.title} +
+
+ {mounted && ( +
+ )} + + + { + setParams({ chartColor: value as ChartColorName }) + }} + > + + {availableChartColors + .filter((theme) => + BASE_COLORS.find((baseColor) => baseColor.name === theme.name) + ) + .map((theme) => ( + + {theme.title} + + ))} + + + + {availableChartColors + .filter( + (theme) => + !BASE_COLORS.find( + (baseColor) => baseColor.name === theme.name + ) + ) + .map((theme) => ( + + {theme.title} + + ))} + + + + + +
+ ) +} diff --git a/apps/v4/app/(create)/components/customizer.tsx b/apps/v4/app/(create)/components/customizer.tsx index f1e57620b6..d42269307f 100644 --- a/apps/v4/app/(create)/components/customizer.tsx +++ b/apps/v4/app/(create)/components/customizer.tsx @@ -7,7 +7,7 @@ import { CardFooter, CardHeader, } from "@/examples/base/ui/card" -import { FieldGroup } from "@/examples/base/ui/field" +import { FieldGroup, FieldSeparator } from "@/examples/base/ui/field" import { type RegistryItem } from "shadcn/schema" import { useIsMobile } from "@/hooks/use-mobile" @@ -16,6 +16,7 @@ import { MenuAccentPicker } from "@/app/(create)/components/accent-picker" import { ActionMenu } from "@/app/(create)/components/action-menu" import { BaseColorPicker } from "@/app/(create)/components/base-color-picker" import { BasePicker } from "@/app/(create)/components/base-picker" +import { ChartColorPicker } from "@/app/(create)/components/chart-color-picker" import { CopyPreset } from "@/app/(create)/components/copy-preset" import { FontPicker } from "@/app/(create)/components/font-picker" import { IconLibraryPicker } from "@/app/(create)/components/icon-library-picker" @@ -27,7 +28,7 @@ import { ResetDialog } from "@/app/(create)/components/reset-button" import { StylePicker } from "@/app/(create)/components/style-picker" import { ThemePicker } from "@/app/(create)/components/theme-picker" import { V0Button } from "@/app/(create)/components/v0-button" -import { FONTS } from "@/app/(create)/lib/fonts" +import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(create)/lib/fonts" import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params" export function Customizer({ @@ -54,22 +55,40 @@ export function Customizer({ - + {isMobile && } + + + + + + - + diff --git a/apps/v4/app/(create)/components/design-system-provider.tsx b/apps/v4/app/(create)/components/design-system-provider.tsx index 31453c1051..c70d80872a 100644 --- a/apps/v4/app/(create)/components/design-system-provider.tsx +++ b/apps/v4/app/(create)/components/design-system-provider.tsx @@ -64,18 +64,37 @@ export function DesignSystemProvider({ history: "replace", // …or push updates into the iframe history. }) const [isReady, setIsReady] = React.useState(false) - const { style, theme, font, baseColor, menuAccent, menuColor, radius } = - searchParams + const { + style, + theme, + font, + fontHeading, + baseColor, + chartColor, + menuAccent, + menuColor, + radius, + } = searchParams const effectiveRadius = style === "lyra" ? "none" : radius const selectedFont = React.useMemo( () => FONTS.find((fontOption) => fontOption.value === font), [font] ) + const selectedHeadingFont = React.useMemo(() => { + if (fontHeading === "inherit" || fontHeading === font) { + return selectedFont + } + + return FONTS.find((fontOption) => fontOption.value === fontHeading) + }, [font, fontHeading, selectedFont]) const initialFontSansRef = React.useRef(null) + const initialFontHeadingRef = React.useRef(null) React.useEffect(() => { initialFontSansRef.current = document.documentElement.style.getPropertyValue("--font-sans") + initialFontHeadingRef.current = + document.documentElement.style.getPropertyValue("--font-heading") return () => { removeManagedBodyClasses(document.body) @@ -86,10 +105,18 @@ export function DesignSystemProvider({ "--font-sans", initialFontSansRef.current ) - return + } else { + document.documentElement.style.removeProperty("--font-sans") } - document.documentElement.style.removeProperty("--font-sans") + if (initialFontHeadingRef.current) { + document.documentElement.style.setProperty( + "--font-heading", + initialFontHeadingRef.current + ) + } else { + document.documentElement.style.removeProperty("--font-heading") + } } }, []) @@ -124,12 +151,29 @@ export function DesignSystemProvider({ // Always set --font-sans for the preview so the selected font is visible. // The font type (sans/serif/mono) is metadata for the CLI updater. if (selectedFont) { - const fontFamily = selectedFont.font.style.fontFamily - document.documentElement.style.setProperty("--font-sans", fontFamily) + document.documentElement.style.setProperty( + "--font-sans", + selectedFont.font.style.fontFamily + ) + } + + if (selectedHeadingFont) { + document.documentElement.style.setProperty( + "--font-heading", + selectedHeadingFont.font.style.fontFamily + ) } setIsReady(true) - }, [style, theme, font, baseColor, selectedFont]) + }, [ + style, + theme, + font, + fontHeading, + baseColor, + selectedFont, + selectedHeadingFont, + ]) const registryTheme = React.useMemo(() => { if (!baseColor || !theme || !menuAccent || !effectiveRadius) { @@ -140,12 +184,13 @@ export function DesignSystemProvider({ ...DEFAULT_CONFIG, baseColor, theme, + chartColor, menuAccent, radius: effectiveRadius, } return buildRegistryTheme(config) - }, [baseColor, theme, menuAccent, effectiveRadius]) + }, [baseColor, theme, chartColor, menuAccent, effectiveRadius]) // Use useLayoutEffect for synchronous CSS var updates. React.useLayoutEffect(() => { diff --git a/apps/v4/app/(create)/components/font-picker.tsx b/apps/v4/app/(create)/components/font-picker.tsx index 895b735429..db2f1c0bc1 100644 --- a/apps/v4/app/(create)/components/font-picker.tsx +++ b/apps/v4/app/(create)/components/font-picker.tsx @@ -2,13 +2,6 @@ import * as React from "react" -import { - Item, - ItemContent, - ItemDescription, - ItemTitle, -} from "@/registry/bases/radix/ui/item" -import { type FontValue } from "@/registry/config" import { LockButton } from "@/app/(create)/components/lock-button" import { Picker, @@ -20,28 +13,68 @@ import { PickerSeparator, PickerTrigger, } from "@/app/(create)/components/picker" -import { type Font } from "@/app/(create)/lib/fonts" -import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params" +import { FONTS } from "@/app/(create)/lib/fonts" +import { + useDesignSystemSearchParams, + type DesignSystemSearchParams, +} from "@/app/(create)/lib/search-params" + +type FontPickerOption = { + name: string + value: string + type: string + font: { + style: { + fontFamily: string + } + } | null +} export function FontPicker({ + label, + param, fonts, isMobile, anchorRef, }: { - fonts: readonly Font[] + label: string + param: "font" | "fontHeading" + fonts: readonly FontPickerOption[] isMobile: boolean anchorRef: React.RefObject }) { const [params, setParams] = useDesignSystemSearchParams() + const currentValue = param === "font" ? params.font : params.fontHeading + const handleFontChange = React.useCallback( + (value: string) => { + setParams({ + [param]: value, + } as Partial) + }, + [param, setParams] + ) const currentFont = React.useMemo( - () => fonts.find((font) => font.value === params.font), - [fonts, params.font] + () => fonts.find((font) => font.value === currentValue), + [fonts, currentValue] ) + const currentBodyFont = React.useMemo( + () => FONTS.find((font) => font.value === params.font), + [params.font] + ) + const inheritsBodyFont = param === "fontHeading" && currentValue === "inherit" + const displayFontName = inheritsBodyFont + ? currentBodyFont?.name + : currentFont?.name + const inheritFontLabel = currentBodyFont ? currentBodyFont.name : "Body font" const groupedFonts = React.useMemo(() => { - const groups = new Map() + const pickerFonts = + param === "fontHeading" + ? fonts.filter((font) => font.value !== "inherit") + : fonts + const groups = new Map() - for (const font of fonts) { + for (const font of pickerFonts) { const existing = groups.get(font.type) if (existing) { existing.push(font) @@ -56,21 +89,25 @@ export function FontPicker({ label: `${type.charAt(0).toUpperCase()}${type.slice(1)}`, items, })) - }, [fonts]) + }, [fonts, param]) return (
-
Font
+
{label}
- {currentFont?.name} + {displayFontName}
Aa
@@ -82,11 +119,19 @@ export function FontPicker({ className="max-h-96" > { - setParams({ font: value as FontValue }) - }} + value={currentValue} + onValueChange={handleFontChange} > + {param === "fontHeading" ? ( + <> + + + {inheritFontLabel} + + + + + ) : null} {groupedFonts.map((group) => ( {group.label} @@ -105,7 +150,7 @@ export function FontPicker({
diff --git a/apps/v4/app/(create)/components/preset-picker.tsx b/apps/v4/app/(create)/components/preset-picker.tsx index d30129acaa..40ae28a98e 100644 --- a/apps/v4/app/(create)/components/preset-picker.tsx +++ b/apps/v4/app/(create)/components/preset-picker.tsx @@ -31,8 +31,10 @@ export function PresetPicker({ preset.style === params.style && preset.baseColor === params.baseColor && preset.theme === params.theme && + preset.chartColor === params.chartColor && preset.iconLibrary === params.iconLibrary && preset.font === params.font && + preset.fontHeading === params.fontHeading && preset.menuAccent === params.menuAccent && preset.menuColor === params.menuColor && preset.radius === params.radius @@ -43,8 +45,10 @@ export function PresetPicker({ params.style, params.baseColor, params.theme, + params.chartColor, params.iconLibrary, params.font, + params.fontHeading, params.menuAccent, params.menuColor, params.radius, @@ -67,8 +71,10 @@ export function PresetPicker({ style: preset.style, baseColor: preset.baseColor, theme: preset.theme, + chartColor: preset.chartColor, iconLibrary: preset.iconLibrary, font: preset.font, + fontHeading: preset.fontHeading, menuAccent: preset.menuAccent, menuColor: preset.menuColor, radius: preset.radius, diff --git a/apps/v4/app/(create)/components/project-form.tsx b/apps/v4/app/(create)/components/project-form.tsx index 7cb6a6a30a..eeda464ac6 100644 --- a/apps/v4/app/(create)/components/project-form.tsx +++ b/apps/v4/app/(create)/components/project-form.tsx @@ -87,7 +87,7 @@ export function ProjectForm({ const rtlFlag = params.rtl ? " --rtl" : "" const flags = `${presetFlag}${baseFlag}${templateFlag}${monorepoFlag}${rtlFlag}` - return IS_LOCAL_DEV && !process.env.NEXT_PUBLIC_RC + return IS_LOCAL_DEV ? { pnpm: `shadcn init${flags}`, npm: `shadcn init${flags}`, @@ -130,7 +130,7 @@ export function ProjectForm({ }> Create Project - + Create Project @@ -139,16 +139,17 @@ export function ProjectForm({
- + + Template - + Base - +
Options @@ -159,7 +160,7 @@ export function ProjectForm({ > - + @@ -198,7 +199,7 @@ export function ProjectForm({
- +
-
- +
+ {PACKAGE_MANAGERS.map((manager) => { return ( {manager} @@ -241,7 +242,7 @@ export function ProjectForm({ {Object.entries(commands).map(([key, cmd]) => { return ( -
+
{cmd} @@ -288,23 +289,26 @@ const TemplateGrid = React.memo(function TemplateGrid({ {TEMPLATES.map((item) => ( - - + +
- {item.title} + {item.title}
- - + +
- {item.title} + {item.title} { - // If preset is already in the URL, return it. - if (params.preset && isPresetCode(params.preset)) { - return params.preset - } - - // Otherwise encode current params (e.g. on initial load before first interaction). - return encodePreset({ - style: params.style ?? undefined, - baseColor: params.baseColor ?? undefined, - theme: params.theme ?? undefined, - iconLibrary: params.iconLibrary ?? undefined, - font: params.font ?? undefined, - radius: params.radius ?? undefined, - menuAccent: params.menuAccent ?? undefined, - menuColor: params.menuColor ?? undefined, - } as Parameters[0]) - }, [ - params.preset, - params.style, - params.baseColor, - params.theme, - params.iconLibrary, - params.font, - params.radius, - params.menuAccent, - params.menuColor, - ]) + return getPresetCode(params) } diff --git a/apps/v4/app/(create)/hooks/use-locks.tsx b/apps/v4/app/(create)/hooks/use-locks.tsx index 457b12d606..3949df7324 100644 --- a/apps/v4/app/(create)/hooks/use-locks.tsx +++ b/apps/v4/app/(create)/hooks/use-locks.tsx @@ -6,8 +6,10 @@ export type LockableParam = | "style" | "baseColor" | "theme" + | "chartColor" | "iconLibrary" | "font" + | "fontHeading" | "menuAccent" | "menuColor" | "radius" diff --git a/apps/v4/app/(create)/hooks/use-random.tsx b/apps/v4/app/(create)/hooks/use-random.tsx index 950b773440..e549d33e3d 100644 --- a/apps/v4/app/(create)/hooks/use-random.tsx +++ b/apps/v4/app/(create)/hooks/use-random.tsx @@ -10,6 +10,7 @@ import { MENU_COLORS, RADII, STYLES, + type FontHeadingValue, } from "@/registry/config" import { useLocks } from "@/app/(create)/hooks/use-locks" import { FONTS } from "@/app/(create)/lib/fonts" @@ -62,9 +63,41 @@ export function useRandom() { const selectedTheme = locks.has("theme") ? paramsRef.current.theme : randomItem(availableThemes).name + context.theme = selectedTheme + + const availableChartColors = applyBias( + getThemesForBaseColor(baseColor), + context, + RANDOMIZE_BIASES.chartColors + ) + const selectedChartColor = locks.has("chartColor") + ? paramsRef.current.chartColor + : randomItem(availableChartColors).name + context.chartColor = selectedChartColor const selectedFont = locks.has("font") ? paramsRef.current.font : randomItem(availableFonts).value + context.font = selectedFont + + // Pick heading font: ~70% inherit, ~30% distinct with cross-category contrast. + let selectedFontHeading: FontHeadingValue + if (locks.has("fontHeading")) { + selectedFontHeading = paramsRef.current.fontHeading + } else if (Math.random() < 0.7) { + selectedFontHeading = "inherit" + } else { + const bodyType = availableFonts.find( + (f) => f.value === selectedFont + )?.type + const contrastFonts = availableFonts.filter( + (f) => f.type !== bodyType && f.value !== selectedFont + ) + selectedFontHeading = ( + contrastFonts.length > 0 + ? randomItem(contrastFonts) + : randomItem(availableFonts) + ).value as FontHeadingValue + } const selectedRadius = locks.has("radius") ? paramsRef.current.radius : randomItem(availableRadii).name @@ -91,16 +124,16 @@ export function useRandom() { : paramsRef.current.menuAccent : randomItem(MENU_ACCENTS).value - context.theme = selectedTheme - context.font = selectedFont context.radius = selectedRadius const nextParams = { style: selectedStyle, baseColor, theme: selectedTheme, + chartColor: selectedChartColor, iconLibrary: selectedIconLibrary, font: selectedFont, + fontHeading: selectedFontHeading, menuAccent: selectedMenuAccent, menuColor: selectedMenuColor, radius: selectedRadius, diff --git a/apps/v4/app/(create)/hooks/use-reset.tsx b/apps/v4/app/(create)/hooks/use-reset.tsx index f185eb9d36..a2498edafc 100644 --- a/apps/v4/app/(create)/hooks/use-reset.tsx +++ b/apps/v4/app/(create)/hooks/use-reset.tsx @@ -24,8 +24,10 @@ export function useReset() { style: DEFAULT_CONFIG.style, baseColor: DEFAULT_CONFIG.baseColor, theme: DEFAULT_CONFIG.theme, + chartColor: DEFAULT_CONFIG.chartColor, iconLibrary: DEFAULT_CONFIG.iconLibrary, font: DEFAULT_CONFIG.font, + fontHeading: DEFAULT_CONFIG.fontHeading, menuAccent: DEFAULT_CONFIG.menuAccent, menuColor: DEFAULT_CONFIG.menuColor, radius: DEFAULT_CONFIG.radius, diff --git a/apps/v4/app/(create)/init/md/build-instructions.ts b/apps/v4/app/(create)/init/md/build-instructions.ts index 45d870fa98..3e8dee1b2e 100644 --- a/apps/v4/app/(create)/init/md/build-instructions.ts +++ b/apps/v4/app/(create)/init/md/build-instructions.ts @@ -3,13 +3,17 @@ import dedent from "dedent" import { UI_COMPONENTS } from "@/lib/components" import { buildRegistryBase, - fonts, + getBodyFont, + getHeadingFont, + getInheritedHeadingFontValue, type DesignSystemConfig, } from "@/registry/config" // Builds step-by-step markdown instructions for manually setting up a project. export function buildInstructions(config: DesignSystemConfig) { const registryBase = buildRegistryBase(config) + const normalizedFontHeading = + config.fontHeading === config.font ? "inherit" : config.fontHeading const dependencies = [ ...(registryBase.dependencies ?? []), @@ -25,13 +29,23 @@ export function buildInstructions(config: DesignSystemConfig) { .map(([key, value]) => ` --${key}: ${value};`) .join("\n") - const font = fonts.find((f) => f.name === `font-${config.font}`) + const font = getBodyFont(config.font) + const headingFont = + normalizedFontHeading === "inherit" + ? undefined + : getHeadingFont(normalizedFontHeading) const sections = [ buildDependenciesSection(dependencies), buildUtilsSection(), - buildCssSection(lightVars, darkVars), - buildFontSection(font), + buildCssSection( + lightVars, + darkVars, + normalizedFontHeading === "inherit" + ? getInheritedHeadingFontValue(config.font) + : "var(--font-heading)" + ), + buildFontSection(font, headingFont), buildComponentsJsonSection(config), buildAvailableComponentsSection(config), config.rtl ? buildRtlSection(config) : null, @@ -67,7 +81,11 @@ function buildUtilsSection() { ` } -function buildCssSection(lightVars: string, darkVars: string) { +function buildCssSection( + lightVars: string, + darkVars: string, + fontHeadingValue: string +) { return dedent` ## Step 3: Set up CSS @@ -80,6 +98,7 @@ function buildCssSection(lightVars: string, darkVars: string) { @theme inline { --font-sans: var(--font-sans); + --font-heading: ${fontHeadingValue}; --font-mono: var(--font-mono); --color-background: var(--background); --color-foreground: var(--foreground); @@ -142,40 +161,74 @@ function buildCssSection(lightVars: string, darkVars: string) { ` } -function buildFontSection(font: (typeof fonts)[number] | undefined) { +function buildFontSection( + font: ReturnType, + headingFont: ReturnType +) { if (!font) { return null } const googleFontsUrl = `https://fonts.google.com/specimen/${font.font.import.replace(/_/g, "+")}` + const headingGoogleFontsUrl = headingFont + ? `https://fonts.google.com/specimen/${headingFont.font.import.replace(/_/g, "+")}` + : null + const nextImports = headingFont + ? `${font.font.import}, ${headingFont.font.import}` + : font.font.import + const nextDeclarations = [ + `const fontSans = ${font.font.import}({`, + ` subsets: ["latin"],`, + ` variable: "${font.font.variable}",`, + `})`, + headingFont ? `const fontHeading = ${headingFont.font.import}({` : null, + headingFont ? ` subsets: ["latin"],` : null, + headingFont ? ` variable: "${headingFont.font.variable}",` : null, + headingFont ? `})` : null, + ] + .filter(Boolean) + .join("\n") + const nextHtmlClassName = headingFont + ? 'className={fontSans.variable + " " + fontHeading.variable}' + : `className={fontSans.variable}` + const otherFrameworkCss = [ + ":root {", + ` ${font.font.variable}: ${font.font.family};`, + ...(headingFont + ? [` ${headingFont.font.variable}: ${headingFont.font.family};`] + : []), + "}", + ].join("\n") + const headingSection = headingFont + ? dedent` + + This config also uses **${headingFont.title.replace(" (Heading)", "")}** for headings (\`${headingFont.font.variable}\`). + ` + : "" return dedent` ## Step 4: Set up the font This config uses **${font.title}** (\`${font.font.variable}\`). + ${headingSection} ### Next.js \`\`\`tsx - import { ${font.font.import} } from "next/font/google" + import { ${nextImports} } from "next/font/google" - const fontSans = ${font.font.import}({ - subsets: ["latin"], - variable: "${font.font.variable}", - }) + ${nextDeclarations} - // Add fontSans.variable to your className. - // + // Add the font variable classes to your className. + // \`\`\` ### Other frameworks - Add the font from [Google Fonts](${googleFontsUrl}) and set the \`${font.font.variable}\` CSS variable to the font family: + Add the font${headingFont ? "s" : ""} from [Google Fonts](${googleFontsUrl})${headingGoogleFontsUrl ? ` and [Google Fonts](${headingGoogleFontsUrl})` : ""} and set the CSS variable${headingFont ? "s" : ""} to the font famil${headingFont ? "ies" : "y"}: \`\`\`css - :root { - ${font.font.variable}: ${font.font.family}; - } + ${otherFrameworkCss} \`\`\` ` } diff --git a/apps/v4/app/(create)/init/parse-config.test.ts b/apps/v4/app/(create)/init/parse-config.test.ts new file mode 100644 index 0000000000..245a7b6151 --- /dev/null +++ b/apps/v4/app/(create)/init/parse-config.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest" + +import { parseDesignSystemConfig } from "./parse-config" + +describe("parseDesignSystemConfig", () => { + it("honors explicit fontHeading and chartColor overrides when a preset is present", () => { + const result = parseDesignSystemConfig( + new URLSearchParams( + "preset=a0&fontHeading=playfair-display&chartColor=emerald" + ) + ) + + expect(result.success).toBe(true) + if (!result.success) { + throw new Error(result.error) + } + + expect(result.data.fontHeading).toBe("playfair-display") + expect(result.data.chartColor).toBe("emerald") + }) +}) diff --git a/apps/v4/app/(create)/init/parse-config.ts b/apps/v4/app/(create)/init/parse-config.ts index 173c613d0d..168bccc11a 100644 --- a/apps/v4/app/(create)/init/parse-config.ts +++ b/apps/v4/app/(create)/init/parse-config.ts @@ -4,6 +4,7 @@ import { designSystemConfigSchema, type DesignSystemConfig, } from "@/registry/config" +import { resolvePresetOverrides } from "@/app/(create)/lib/preset-query" // Parses design system config from URL search params. export function parseDesignSystemConfig(searchParams: URLSearchParams) { @@ -15,8 +16,10 @@ export function parseDesignSystemConfig(searchParams: URLSearchParams) { if (!decoded) { return { success: false as const, error: "Invalid preset code" } } + const presetOverrides = resolvePresetOverrides(searchParams, decoded) configInput = { ...decoded, + ...presetOverrides, base: searchParams.get("base") ?? "radix", template: searchParams.get("template") ?? undefined, rtl: searchParams.get("rtl") === "true", @@ -28,7 +31,9 @@ export function parseDesignSystemConfig(searchParams: URLSearchParams) { iconLibrary: searchParams.get("iconLibrary"), baseColor: searchParams.get("baseColor"), theme: searchParams.get("theme"), + chartColor: searchParams.get("chartColor") ?? undefined, font: searchParams.get("font"), + fontHeading: searchParams.get("fontHeading") ?? undefined, menuAccent: searchParams.get("menuAccent"), menuColor: searchParams.get("menuColor"), radius: searchParams.get("radius"), diff --git a/apps/v4/app/(create)/init/route.ts b/apps/v4/app/(create)/init/route.ts index 22c02af2f5..e573548f7d 100644 --- a/apps/v4/app/(create)/init/route.ts +++ b/apps/v4/app/(create)/init/route.ts @@ -1,10 +1,11 @@ import { NextResponse, type NextRequest } from "next/server" import { track } from "@vercel/analytics/server" -import { encodePreset, isPresetCode } from "shadcn/preset" +import { isPresetCode } from "shadcn/preset" import { registryItemSchema } from "shadcn/schema" import { buildRegistryBase } from "@/registry/config" import { parseDesignSystemConfig } from "@/app/(create)/init/parse-config" +import { getPresetCode } from "@/app/(create)/lib/preset-code" export async function GET(request: NextRequest) { try { @@ -15,21 +16,11 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: result.error }, { status: 400 }) } - // Use the preset code from the URL if provided, otherwise encode one. - const presetParam = searchParams.get("preset") + const rawPreset = searchParams.get("preset") const presetCode = - presetParam && isPresetCode(presetParam) - ? presetParam - : encodePreset({ - style: result.data.style, - baseColor: result.data.baseColor, - theme: result.data.theme, - iconLibrary: result.data.iconLibrary, - font: result.data.font, - radius: result.data.radius, - menuAccent: result.data.menuAccent, - menuColor: result.data.menuColor, - } as Parameters[0]) + rawPreset && isPresetCode(rawPreset) + ? rawPreset + : getPresetCode(result.data) const registryBase = buildRegistryBase(result.data) const parseResult = registryItemSchema.safeParse(registryBase) diff --git a/apps/v4/app/(create)/init/v0/route.ts b/apps/v4/app/(create)/init/v0/route.ts index d973eb3391..901909933a 100644 --- a/apps/v4/app/(create)/init/v0/route.ts +++ b/apps/v4/app/(create)/init/v0/route.ts @@ -1,7 +1,9 @@ import { after, NextResponse, type NextRequest } from "next/server" import { track } from "@vercel/analytics/server" +import { isPresetCode } from "shadcn/preset" import { parseDesignSystemConfig } from "@/app/(create)/init/parse-config" +import { getPresetCode } from "@/app/(create)/lib/preset-code" import { buildV0Payload } from "@/app/(create)/lib/v0" export async function GET(request: NextRequest) { @@ -13,11 +15,17 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: result.error }, { status: 400 }) } + const rawPreset = searchParams.get("preset") + const presetCode = + rawPreset && isPresetCode(rawPreset) + ? rawPreset + : getPresetCode(result.data) + // Defer analytics to after response is sent. after(() => { track("create_open_in_v0", { ...result.data, - preset: searchParams.get("preset") ?? "", + preset: presetCode, }) }) diff --git a/apps/v4/app/(create)/lib/fonts.ts b/apps/v4/app/(create)/lib/fonts.ts index 92bb3f8f2b..7deda364ff 100644 --- a/apps/v4/app/(create)/lib/fonts.ts +++ b/apps/v4/app/(create)/lib/fonts.ts @@ -3,21 +3,37 @@ import { Figtree, Geist, Geist_Mono, + IBM_Plex_Sans, + Instrument_Sans, Inter, JetBrains_Mono, Lora, + Manrope, Merriweather, + Montserrat, Noto_Sans, Noto_Serif, Nunito_Sans, Outfit, + Oxanium, Playfair_Display, Public_Sans, Raleway, Roboto, Roboto_Slab, + Source_Sans_3, + Space_Grotesk, } from "next/font/google" +import { FONT_DEFINITIONS, type FontName } from "@/lib/font-definitions" + +type PreviewFont = ReturnType + +const geistSans = Geist({ + subsets: ["latin"], + variable: "--font-geist-sans", +}) + const inter = Inter({ subsets: ["latin"], variable: "--font-inter", @@ -38,21 +54,6 @@ const figtree = Figtree({ variable: "--font-figtree", }) -const jetbrainsMono = JetBrains_Mono({ - subsets: ["latin"], - variable: "--font-jetbrains-mono", -}) - -const geistSans = Geist({ - subsets: ["latin"], - variable: "--font-geist-sans", -}) - -const geistMono = Geist_Mono({ - subsets: ["latin"], - variable: "--font-geist-mono", -}) - const roboto = Roboto({ subsets: ["latin"], variable: "--font-roboto", @@ -78,6 +79,51 @@ const outfit = Outfit({ variable: "--font-outfit", }) +const oxanium = Oxanium({ + subsets: ["latin"], + variable: "--font-oxanium", +}) + +const manrope = Manrope({ + subsets: ["latin"], + variable: "--font-manrope", +}) + +const spaceGrotesk = Space_Grotesk({ + subsets: ["latin"], + variable: "--font-space-grotesk", +}) + +const montserrat = Montserrat({ + subsets: ["latin"], + variable: "--font-montserrat", +}) + +const ibmPlexSans = IBM_Plex_Sans({ + subsets: ["latin"], + variable: "--font-ibm-plex-sans", +}) + +const sourceSans3 = Source_Sans_3({ + subsets: ["latin"], + variable: "--font-source-sans-3", +}) + +const instrumentSans = Instrument_Sans({ + subsets: ["latin"], + variable: "--font-instrument-sans", +}) + +const jetbrainsMono = JetBrains_Mono({ + subsets: ["latin"], + variable: "--font-jetbrains-mono", +}) + +const geistMono = Geist_Mono({ + subsets: ["latin"], + variable: "--font-geist-mono", +}) + const notoSerif = Noto_Serif({ subsets: ["latin"], variable: "--font-noto-serif", @@ -103,109 +149,85 @@ const playfairDisplay = Playfair_Display({ variable: "--font-playfair-display", }) +const PREVIEW_FONTS = { + geist: geistSans, + inter, + "noto-sans": notoSans, + "nunito-sans": nunitoSans, + figtree, + roboto, + raleway, + "dm-sans": dmSans, + "public-sans": publicSans, + outfit, + oxanium, + manrope, + "space-grotesk": spaceGrotesk, + montserrat, + "ibm-plex-sans": ibmPlexSans, + "source-sans-3": sourceSans3, + "instrument-sans": instrumentSans, + "jetbrains-mono": jetbrainsMono, + "geist-mono": geistMono, + "noto-serif": notoSerif, + "roboto-slab": robotoSlab, + merriweather, + lora, + "playfair-display": playfairDisplay, +} satisfies Record + +function createFontOption(name: FontName) { + const definition = FONT_DEFINITIONS.find((font) => font.name === name) + + if (!definition) { + throw new Error(`Unknown font definition: ${name}`) + } + + return { + name: definition.title, + value: definition.name, + font: PREVIEW_FONTS[name], + type: definition.type, + } as const +} + export const FONTS = [ - { - name: "Geist", - value: "geist", - font: geistSans, - type: "sans", - }, - { - name: "Inter", - value: "inter", - font: inter, - type: "sans", - }, - { - name: "Noto Sans", - value: "noto-sans", - font: notoSans, - type: "sans", - }, - { - name: "Nunito Sans", - value: "nunito-sans", - font: nunitoSans, - type: "sans", - }, - { - name: "Figtree", - value: "figtree", - font: figtree, - type: "sans", - }, - { - name: "Roboto", - value: "roboto", - font: roboto, - type: "sans", - }, - { - name: "Raleway", - value: "raleway", - font: raleway, - type: "sans", - }, - { - name: "DM Sans", - value: "dm-sans", - font: dmSans, - type: "sans", - }, - { - name: "Public Sans", - value: "public-sans", - font: publicSans, - type: "sans", - }, - { - name: "Outfit", - value: "outfit", - font: outfit, - type: "sans", - }, - { - name: "Geist Mono", - value: "geist-mono", - font: geistMono, - type: "mono", - }, - { - name: "JetBrains Mono", - value: "jetbrains-mono", - font: jetbrainsMono, - type: "mono", - }, - { - name: "Noto Serif", - value: "noto-serif", - font: notoSerif, - type: "serif", - }, - { - name: "Roboto Slab", - value: "roboto-slab", - font: robotoSlab, - type: "serif", - }, - { - name: "Merriweather", - value: "merriweather", - font: merriweather, - type: "serif", - }, - { - name: "Lora", - value: "lora", - font: lora, - type: "serif", - }, - { - name: "Playfair Display", - value: "playfair-display", - font: playfairDisplay, - type: "serif", - }, + createFontOption("geist"), + createFontOption("inter"), + createFontOption("noto-sans"), + createFontOption("nunito-sans"), + createFontOption("figtree"), + createFontOption("roboto"), + createFontOption("raleway"), + createFontOption("dm-sans"), + createFontOption("public-sans"), + createFontOption("outfit"), + createFontOption("oxanium"), + createFontOption("manrope"), + createFontOption("space-grotesk"), + createFontOption("montserrat"), + createFontOption("ibm-plex-sans"), + createFontOption("source-sans-3"), + createFontOption("instrument-sans"), + createFontOption("geist-mono"), + createFontOption("jetbrains-mono"), + createFontOption("noto-serif"), + createFontOption("roboto-slab"), + createFontOption("merriweather"), + createFontOption("lora"), + createFontOption("playfair-display"), ] as const export type Font = (typeof FONTS)[number] + +export const FONT_HEADING_OPTIONS = [ + { + name: "Inherit", + value: "inherit", + font: null, + type: "default", + }, + ...FONTS, +] as const + +export type FontHeadingOption = (typeof FONT_HEADING_OPTIONS)[number] diff --git a/apps/v4/app/(create)/lib/preset-code.ts b/apps/v4/app/(create)/lib/preset-code.ts new file mode 100644 index 0000000000..50d1c00727 --- /dev/null +++ b/apps/v4/app/(create)/lib/preset-code.ts @@ -0,0 +1,34 @@ +import { encodePreset, type PresetConfig } from "shadcn/preset" + +import { type DesignSystemConfig } from "@/registry/config" + +type PresetCodeConfig = Pick< + DesignSystemConfig, + | "style" + | "baseColor" + | "theme" + | "chartColor" + | "iconLibrary" + | "font" + | "fontHeading" + | "radius" + | "menuAccent" + | "menuColor" +> + +export function getPresetCode(config: PresetCodeConfig) { + const presetConfig: Partial = { + style: config.style as PresetConfig["style"], + baseColor: config.baseColor as PresetConfig["baseColor"], + theme: config.theme as PresetConfig["theme"], + chartColor: config.chartColor as PresetConfig["chartColor"], + iconLibrary: config.iconLibrary as PresetConfig["iconLibrary"], + font: config.font as PresetConfig["font"], + fontHeading: config.fontHeading as PresetConfig["fontHeading"], + radius: config.radius as PresetConfig["radius"], + menuAccent: config.menuAccent as PresetConfig["menuAccent"], + menuColor: config.menuColor as PresetConfig["menuColor"], + } + + return encodePreset(presetConfig) +} diff --git a/apps/v4/app/(create)/lib/preset-query.test.ts b/apps/v4/app/(create)/lib/preset-query.test.ts new file mode 100644 index 0000000000..93c4ce3b6f --- /dev/null +++ b/apps/v4/app/(create)/lib/preset-query.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest" + +import { resolvePresetOverrides } from "./preset-query" + +describe("resolvePresetOverrides", () => { + it("prefers explicit fontHeading and chartColor query params", () => { + const overrides = resolvePresetOverrides( + new URLSearchParams("fontHeading=playfair-display&chartColor=emerald"), + { + theme: "neutral", + chartColor: "blue", + fontHeading: "inherit", + } + ) + + expect(overrides).toEqual({ + fontHeading: "playfair-display", + chartColor: "emerald", + }) + }) + + it("falls back to decoded preset values when no overrides are present", () => { + const overrides = resolvePresetOverrides(new URLSearchParams(), { + theme: "neutral", + chartColor: "blue", + fontHeading: "inherit", + }) + + expect(overrides).toEqual({ + fontHeading: "inherit", + chartColor: "blue", + }) + }) +}) diff --git a/apps/v4/app/(create)/lib/preset-query.ts b/apps/v4/app/(create)/lib/preset-query.ts new file mode 100644 index 0000000000..f69f1cf9fc --- /dev/null +++ b/apps/v4/app/(create)/lib/preset-query.ts @@ -0,0 +1,30 @@ +import { V1_CHART_COLOR_MAP, type PresetConfig } from "shadcn/preset" + +import { type ChartColorName, type FontHeadingValue } from "@/registry/config" + +type SearchParamsLike = Pick + +export function resolvePresetOverrides( + searchParams: SearchParamsLike, + decoded: Pick +) { + const hasFontHeadingOverride = searchParams.has("fontHeading") + const hasChartColorOverride = searchParams.has("chartColor") + + const fontHeading = hasFontHeadingOverride + ? ((searchParams.get("fontHeading") ?? + decoded.fontHeading) as FontHeadingValue) + : decoded.fontHeading + + const chartColor = hasChartColorOverride + ? ((searchParams.get("chartColor") ?? + decoded.chartColor ?? + V1_CHART_COLOR_MAP[decoded.theme] ?? + decoded.theme) as ChartColorName) + : (decoded.chartColor ?? V1_CHART_COLOR_MAP[decoded.theme] ?? decoded.theme) + + return { + fontHeading, + chartColor, + } +} diff --git a/apps/v4/app/(create)/lib/randomize-biases.ts b/apps/v4/app/(create)/lib/randomize-biases.ts index de04164bfc..c73965143f 100644 --- a/apps/v4/app/(create)/lib/randomize-biases.ts +++ b/apps/v4/app/(create)/lib/randomize-biases.ts @@ -3,6 +3,7 @@ import type { BaseColorName, Radius, StyleName, + Theme, ThemeName, } from "@/registry/config" @@ -12,6 +13,7 @@ export type RandomizeContext = { style?: StyleName baseColor?: BaseColorName theme?: ThemeName + chartColor?: string iconLibrary?: string font?: string menuAccent?: string @@ -26,12 +28,30 @@ export type BiasFilter = ( export type RandomizeBiases = { baseColors?: BiasFilter + chartColors?: BiasFilter fonts?: BiasFilter<(typeof FONTS)[number]> radius?: BiasFilter - // Add more bias filters as needed: - // styles?: BiasFilter