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 && (
{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