setOpen(false)}
+ />
+
+ )
+}
+
+export function ItemPickerScript() {
+ return (
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/lock-button.tsx b/apps/v4/app/(create)/components/lock-button.tsx
new file mode 100644
index 0000000000..773144a1cf
--- /dev/null
+++ b/apps/v4/app/(create)/components/lock-button.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import {
+ SquareLock01Icon,
+ SquareUnlock01Icon,
+} from "@hugeicons/core-free-icons"
+import { HugeiconsIcon } from "@hugeicons/react"
+
+import { cn } from "@/lib/utils"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/registry/new-york-v4/ui/tooltip"
+import { useLocks, type LockableParam } from "@/app/(create)/hooks/use-locks"
+
+export function LockButton({
+ param,
+ className,
+}: {
+ param: LockableParam
+ className?: string
+}) {
+ const { isLocked, toggleLock } = useLocks()
+ const locked = isLocked(param)
+
+ return (
+
+
+
+
+ {locked ? "Unlock" : "Lock"}
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/menu-picker.tsx b/apps/v4/app/(create)/components/menu-picker.tsx
new file mode 100644
index 0000000000..5539cf9ed2
--- /dev/null
+++ b/apps/v4/app/(create)/components/menu-picker.tsx
@@ -0,0 +1,167 @@
+"use client"
+
+import * as React from "react"
+import { useTheme } from "next-themes"
+import { useQueryStates } from "nuqs"
+
+import { useMounted } from "@/hooks/use-mounted"
+import { type MenuColorValue } from "@/registry/config"
+import { LockButton } from "@/app/(create)/components/lock-button"
+import {
+ Picker,
+ PickerContent,
+ PickerGroup,
+ PickerRadioGroup,
+ PickerRadioItem,
+ PickerTrigger,
+} from "@/app/(create)/components/picker"
+import { designSystemSearchParams } from "@/app/(create)/lib/search-params"
+
+const MENU_OPTIONS = [
+ {
+ value: "default" as const,
+ label: "Default",
+ icon: (
+
+ ),
+ },
+ {
+ value: "inverted" as const,
+ label: "Inverted",
+ icon: (
+
+ ),
+ },
+] as const
+
+export function MenuColorPicker({
+ isMobile,
+ anchorRef,
+}: {
+ isMobile: boolean
+ anchorRef: React.RefObject
+}) {
+ const { resolvedTheme } = useTheme()
+ const mounted = useMounted()
+ const [params, setParams] = useQueryStates(designSystemSearchParams, {
+ shallow: false,
+ history: "push",
+ })
+ const currentMenu = MENU_OPTIONS.find(
+ (menu) => menu.value === params.menuColor
+ )
+
+ return (
+
+
+
+
+
Menu Color
+
+ {currentMenu?.label}
+
+
+
+ {currentMenu?.icon}
+
+
+
+
+
+ {
+ setParams({ menuColor: value as MenuColorValue })
+ }}
+ >
+
+ {MENU_OPTIONS.map((menu) => (
+
+ {menu.icon}
+ {menu.label}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/picker.tsx b/apps/v4/app/(create)/components/picker.tsx
new file mode 100644
index 0000000000..d680388222
--- /dev/null
+++ b/apps/v4/app/(create)/components/picker.tsx
@@ -0,0 +1,284 @@
+"use client"
+
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/registry/bases/base/lib/utils"
+import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
+
+function Picker({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function PickerPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function PickerTrigger({ className, ...props }: MenuPrimitive.Trigger.Props) {
+ return (
+
+ )
+}
+
+function PickerContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ anchor,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "anchor"
+ >) {
+ return (
+
+
+
+
+
+
+ )
+}
+
+function PickerGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function PickerLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function PickerItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function PickerSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function PickerSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function PickerSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function PickerCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function PickerRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function PickerRadioItem({
+ className,
+ children,
+ ...props
+}: MenuPrimitive.RadioItem.Props) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function PickerSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function PickerShortcut({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ Picker,
+ PickerPortal,
+ PickerTrigger,
+ PickerContent,
+ PickerGroup,
+ PickerLabel,
+ PickerItem,
+ PickerCheckboxItem,
+ PickerRadioGroup,
+ PickerRadioItem,
+ PickerSeparator,
+ PickerShortcut,
+ PickerSub,
+ PickerSubTrigger,
+ PickerSubContent,
+}
diff --git a/apps/v4/app/(create)/components/preset-picker.tsx b/apps/v4/app/(create)/components/preset-picker.tsx
new file mode 100644
index 0000000000..c6cdef78d1
--- /dev/null
+++ b/apps/v4/app/(create)/components/preset-picker.tsx
@@ -0,0 +1,126 @@
+"use client"
+
+import * as React from "react"
+import { useQueryStates } from "nuqs"
+
+import { BASES, STYLES, type Preset } from "@/registry/config"
+import {
+ Picker,
+ PickerContent,
+ PickerGroup,
+ PickerRadioGroup,
+ PickerRadioItem,
+ PickerTrigger,
+} from "@/app/(create)/components/picker"
+import { designSystemSearchParams } from "@/app/(create)/lib/search-params"
+
+export function PresetPicker({
+ presets,
+ isMobile,
+ anchorRef,
+}: {
+ presets: readonly Preset[]
+ isMobile: boolean
+ anchorRef: React.RefObject
+}) {
+ const [params, setParams] = useQueryStates(designSystemSearchParams, {
+ shallow: false,
+ history: "push",
+ })
+
+ const currentPreset = React.useMemo(() => {
+ return presets.find(
+ (preset) =>
+ preset.base === params.base &&
+ preset.style === params.style &&
+ preset.baseColor === params.baseColor &&
+ preset.theme === params.theme &&
+ preset.iconLibrary === params.iconLibrary &&
+ preset.font === params.font &&
+ preset.menuAccent === params.menuAccent &&
+ preset.menuColor === params.menuColor &&
+ preset.radius === params.radius
+ )
+ }, [
+ presets,
+ params.base,
+ params.style,
+ params.baseColor,
+ params.theme,
+ params.iconLibrary,
+ params.font,
+ params.menuAccent,
+ params.menuColor,
+ params.radius,
+ ])
+
+ // Filter presets for current base only
+ const currentBasePresets = React.useMemo(() => {
+ return presets.filter((preset) => preset.base === params.base)
+ }, [presets, params.base])
+
+ const handlePresetChange = (value: string) => {
+ const preset = presets.find((p) => p.title === value)
+ if (!preset) {
+ return
+ }
+
+ // Update all params including base.
+ setParams({
+ base: preset.base,
+ style: preset.style,
+ baseColor: preset.baseColor,
+ theme: preset.theme,
+ iconLibrary: preset.iconLibrary,
+ font: preset.font,
+ menuAccent: preset.menuAccent,
+ menuColor: preset.menuColor,
+ radius: preset.radius,
+ custom: false,
+ })
+ }
+
+ return (
+
+
+
+
Preset
+
+ {currentPreset?.description ?? "Custom"}
+
+
+
+
+
+
+ {currentBasePresets.map((preset) => {
+ const style = STYLES.find((s) => s.name === preset.style)
+ return (
+
+
+ {style?.icon && (
+
+ {React.cloneElement(style.icon, {
+ className: "size-4",
+ })}
+
+ )}
+ {preset.description}
+
+
+ )
+ })}
+
+
+
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/preview-controls.tsx b/apps/v4/app/(create)/components/preview-controls.tsx
new file mode 100644
index 0000000000..d6bdbd9b0e
--- /dev/null
+++ b/apps/v4/app/(create)/components/preview-controls.tsx
@@ -0,0 +1,41 @@
+"use client"
+
+import { Monitor, Smartphone, Tablet } from "lucide-react"
+import { useQueryStates } from "nuqs"
+
+import {
+ ToggleGroup,
+ ToggleGroupItem,
+} from "@/registry/new-york-v4/ui/toggle-group"
+import { designSystemSearchParams } from "@/app/(create)/lib/search-params"
+
+export function PreviewControls() {
+ const [urlParams, setUrlParams] = useQueryStates(designSystemSearchParams, {
+ shallow: false,
+ })
+
+ return (
+
+
{
+ if (newValue) {
+ setUrlParams({ size: parseInt(newValue) })
+ }
+ }}
+ className="gap-1 *:data-[slot=toggle-group-item]:!size-6 *:data-[slot=toggle-group-item]:!rounded-sm"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/preview-style.tsx b/apps/v4/app/(create)/components/preview-style.tsx
new file mode 100644
index 0000000000..c1b830a5a1
--- /dev/null
+++ b/apps/v4/app/(create)/components/preview-style.tsx
@@ -0,0 +1,16 @@
+"use client"
+
+export function PreviewStyle() {
+ return (
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/preview.tsx b/apps/v4/app/(create)/components/preview.tsx
new file mode 100644
index 0000000000..7c292db1f7
--- /dev/null
+++ b/apps/v4/app/(create)/components/preview.tsx
@@ -0,0 +1,125 @@
+"use client"
+
+import * as React from "react"
+import { type ImperativePanelHandle } from "react-resizable-panels"
+
+import { DARK_MODE_FORWARD_TYPE } from "@/components/mode-switcher"
+import { Badge } from "@/registry/new-york-v4/ui/badge"
+import { RANDOMIZE_FORWARD_TYPE } from "@/app/(create)/components/customizer-controls"
+import { CMD_K_FORWARD_TYPE } from "@/app/(create)/components/item-picker"
+import { useDesignSystemSync } from "@/app/(create)/hooks/use-design-system"
+
+const MESSAGE_TYPE = "design-system-params"
+
+export function Preview() {
+ const params = useDesignSystemSync()
+ const iframeRef = React.useRef(null)
+ const resizablePanelRef = React.useRef(null)
+ const [initialParams] = React.useState(params)
+ const [iframeKey, setIframeKey] = React.useState(0)
+
+ // Sync resizable panel with URL param changes.
+ React.useEffect(() => {
+ if (resizablePanelRef.current && params.size) {
+ resizablePanelRef.current.resize(params.size)
+ }
+ }, [params.size])
+
+ React.useEffect(() => {
+ const iframe = iframeRef.current
+ if (!iframe) {
+ return
+ }
+
+ const sendParams = () => {
+ iframe.contentWindow?.postMessage(
+ {
+ type: MESSAGE_TYPE,
+ params,
+ },
+ "*"
+ )
+ }
+
+ if (iframe.contentWindow) {
+ sendParams()
+ }
+
+ iframe.addEventListener("load", sendParams)
+ return () => {
+ iframe.removeEventListener("load", sendParams)
+ }
+ }, [params])
+
+ const handleMessage = (event: MessageEvent) => {
+ if (event.data.type === CMD_K_FORWARD_TYPE) {
+ const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
+ const key = event.data.key || "k"
+
+ const syntheticEvent = new KeyboardEvent("keydown", {
+ key,
+ metaKey: isMac,
+ ctrlKey: !isMac,
+ bubbles: true,
+ cancelable: true,
+ })
+ document.dispatchEvent(syntheticEvent)
+ }
+
+ if (event.data.type === RANDOMIZE_FORWARD_TYPE) {
+ const key = event.data.key || "r"
+
+ const syntheticEvent = new KeyboardEvent("keydown", {
+ key,
+ bubbles: true,
+ cancelable: true,
+ })
+ document.dispatchEvent(syntheticEvent)
+ }
+
+ if (event.data.type === DARK_MODE_FORWARD_TYPE) {
+ const key = event.data.key || "d"
+
+ const syntheticEvent = new KeyboardEvent("keydown", {
+ key,
+ bubbles: true,
+ cancelable: true,
+ })
+ document.dispatchEvent(syntheticEvent)
+ }
+ }
+
+ React.useEffect(() => {
+ window.addEventListener("message", handleMessage)
+ return () => {
+ window.removeEventListener("message", handleMessage)
+ }
+ }, [])
+
+ if (!params.item || !params.base) {
+ return null
+ }
+
+ const iframeSrc = `/preview/${params.base}/${params.item}?theme=${initialParams.theme ?? "neutral"}&iconLibrary=${initialParams.iconLibrary ?? "lucide"}&style=${initialParams.style ?? "vega"}&font=${initialParams.font ?? "inter"}&baseColor=${initialParams.baseColor ?? "neutral"}`
+
+ return (
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/radius-picker.tsx b/apps/v4/app/(create)/components/radius-picker.tsx
new file mode 100644
index 0000000000..90dd19ccd7
--- /dev/null
+++ b/apps/v4/app/(create)/components/radius-picker.tsx
@@ -0,0 +1,106 @@
+"use client"
+
+import { useQueryStates } from "nuqs"
+
+import { RADII, type RadiusValue } 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 { designSystemSearchParams } from "@/app/(create)/lib/search-params"
+
+export function RadiusPicker({
+ isMobile,
+ anchorRef,
+}: {
+ isMobile: boolean
+ anchorRef: React.RefObject
+}) {
+ const [params, setParams] = useQueryStates(designSystemSearchParams, {
+ shallow: false,
+ history: "push",
+ })
+
+ const currentRadius = RADII.find((radius) => radius.name === params.radius)
+ const defaultRadius = RADII.find((radius) => radius.name === "default")
+ const otherRadii = RADII.filter((radius) => radius.name !== "default")
+
+ return (
+
+
+
+
+
Radius
+
+ {currentRadius?.label}
+
+
+
+
+
+
+
+ {
+ setParams({ radius: value as RadiusValue })
+ }}
+ >
+
+ {defaultRadius && (
+
+
+
{defaultRadius.label}
+
+ Use radius from style
+
+
+
+ )}
+
+
+
+ {otherRadii.map((radius) => (
+
+ {radius.label}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/share-button.tsx b/apps/v4/app/(create)/components/share-button.tsx
new file mode 100644
index 0000000000..b6d9afd0a2
--- /dev/null
+++ b/apps/v4/app/(create)/components/share-button.tsx
@@ -0,0 +1,76 @@
+"use client"
+
+import * as React from "react"
+import { Share03Icon, Tick02Icon } from "@hugeicons/core-free-icons"
+import { HugeiconsIcon } from "@hugeicons/react"
+import { useQueryStates } from "nuqs"
+
+import { copyToClipboardWithMeta } from "@/components/copy-button"
+import { Button } from "@/registry/new-york-v4/ui/button"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/registry/new-york-v4/ui/tooltip"
+import { designSystemSearchParams } from "@/app/(create)/lib/search-params"
+
+export function ShareButton() {
+ const [params] = useQueryStates(designSystemSearchParams, {
+ shallow: false,
+ })
+ const [hasCopied, setHasCopied] = React.useState(false)
+
+ const shareUrl = React.useMemo(() => {
+ const origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
+ return `${origin}/create?base=${params.base}&style=${params.style}&baseColor=${params.baseColor}&theme=${params.theme}&iconLibrary=${params.iconLibrary}&font=${params.font}&menuAccent=${params.menuAccent}&menuColor=${params.menuColor}&radius=${params.radius}&item=${params.item}`
+ }, [
+ params.base,
+ params.style,
+ params.baseColor,
+ params.theme,
+ params.iconLibrary,
+ params.font,
+ params.menuAccent,
+ params.menuColor,
+ params.radius,
+ params.item,
+ ])
+
+ React.useEffect(() => {
+ if (hasCopied) {
+ const timer = setTimeout(() => setHasCopied(false), 2000)
+ return () => clearTimeout(timer)
+ }
+ }, [hasCopied])
+
+ const handleCopy = React.useCallback(() => {
+ copyToClipboardWithMeta(shareUrl, {
+ name: "copy_create_share_url",
+ properties: {
+ url: shareUrl,
+ },
+ })
+ setHasCopied(true)
+ }, [shareUrl])
+
+ return (
+
+
+
+
+ Copy Link
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/style-picker.tsx b/apps/v4/app/(create)/components/style-picker.tsx
new file mode 100644
index 0000000000..29170d3886
--- /dev/null
+++ b/apps/v4/app/(create)/components/style-picker.tsx
@@ -0,0 +1,100 @@
+"use client"
+
+import * as React from "react"
+import { useQueryStates } from "nuqs"
+
+import { type Style, type StyleName } 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 { designSystemSearchParams } from "@/app/(create)/lib/search-params"
+
+export function StylePicker({
+ styles,
+ isMobile,
+ anchorRef,
+}: {
+ styles: readonly Style[]
+ isMobile: boolean
+ anchorRef: React.RefObject
+}) {
+ const [params, setParams] = useQueryStates(designSystemSearchParams, {
+ shallow: false,
+ history: "push",
+ })
+
+ const currentStyle = styles.find((style) => style.name === params.style)
+
+ return (
+
+
+
+
+
Style
+
+ {currentStyle?.title}
+
+
+ {currentStyle?.icon && (
+
+ {React.cloneElement(currentStyle.icon, {
+ className: "size-4",
+ })}
+
+ )}
+
+
+
+
+ {
+ setParams({ style: value as StyleName })
+ }}
+ >
+
+ {styles.map((style, index) => (
+
+
+
+ {style.icon && (
+
+ {React.cloneElement(style.icon, {
+ className: "size-4",
+ })}
+
+ )}
+
+
{style.title}
+
+ {style.description}
+
+
+
+
+ {index < styles.length - 1 && (
+
+ )}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/template-picker.tsx b/apps/v4/app/(create)/components/template-picker.tsx
new file mode 100644
index 0000000000..9ba6e46068
--- /dev/null
+++ b/apps/v4/app/(create)/components/template-picker.tsx
@@ -0,0 +1,99 @@
+"use client"
+
+import { useQueryStates } from "nuqs"
+
+import {
+ Picker,
+ PickerContent,
+ PickerGroup,
+ PickerRadioGroup,
+ PickerRadioItem,
+ PickerTrigger,
+} from "@/app/(create)/components/picker"
+import { designSystemSearchParams } from "@/app/(create)/lib/search-params"
+
+const TEMPLATES = [
+ {
+ value: "next",
+ title: "Next.js",
+ logo: '',
+ },
+ {
+ value: "start",
+ title: "TanStack Start",
+ logo: '',
+ },
+ {
+ value: "vite",
+ title: "Vite",
+ logo: '',
+ },
+] as const
+
+export function TemplatePicker({
+ isMobile,
+ anchorRef,
+}: {
+ isMobile: boolean
+ anchorRef: React.RefObject
+}) {
+ const [params, setParams] = useQueryStates(designSystemSearchParams, {
+ shallow: false,
+ history: "push",
+ })
+
+ const currentTemplate = TEMPLATES.find(
+ (template) => template.value === params.template
+ )
+
+ return (
+
+
+
+
Template
+
+ {currentTemplate?.title}
+
+
+ {currentTemplate?.logo && (
+
+ )}
+
+
+ {
+ setParams({
+ template: value as "next" | "start" | "vite",
+ })
+ }}
+ >
+
+ {TEMPLATES.map((template) => (
+
+ {template.logo && (
+
+ )}
+ {template.title}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/theme-picker.tsx b/apps/v4/app/(create)/components/theme-picker.tsx
new file mode 100644
index 0000000000..20cb6c6e1b
--- /dev/null
+++ b/apps/v4/app/(create)/components/theme-picker.tsx
@@ -0,0 +1,171 @@
+"use client"
+
+import * as React from "react"
+import { useTheme } from "next-themes"
+import { useQueryStates } from "nuqs"
+
+import { useMounted } from "@/hooks/use-mounted"
+import { BASE_COLORS, type Theme, type ThemeName } from "@/registry/config"
+import { LockButton } from "@/app/(create)/components/lock-button"
+import {
+ Picker,
+ PickerContent,
+ PickerGroup,
+ PickerLabel,
+ PickerRadioGroup,
+ PickerRadioItem,
+ PickerSeparator,
+ PickerTrigger,
+} from "@/app/(create)/components/picker"
+import { designSystemSearchParams } from "@/app/(create)/lib/search-params"
+
+export function ThemePicker({
+ themes,
+ isMobile,
+ anchorRef,
+}: {
+ themes: readonly Theme[]
+ isMobile: boolean
+ anchorRef: React.RefObject
+}) {
+ const { resolvedTheme } = useTheme()
+ const mounted = useMounted()
+ const [params, setParams] = useQueryStates(designSystemSearchParams, {
+ shallow: false,
+ history: "push",
+ })
+
+ const currentTheme = React.useMemo(
+ () => themes.find((theme) => theme.name === params.theme),
+ [themes, params.theme]
+ )
+
+ const currentThemeIsBaseColor = React.useMemo(
+ () => BASE_COLORS.find((baseColor) => baseColor.name === params.theme),
+ [params.theme]
+ )
+
+ React.useEffect(() => {
+ if (!currentTheme && themes.length > 0) {
+ setParams({ theme: themes[0].name })
+ }
+ }, [currentTheme, themes, setParams])
+
+ return (
+
+
+
+
+
Theme
+
+ {currentTheme?.title}
+
+
+ {mounted && resolvedTheme && (
+
+ )}
+
+
+
+
+ {
+ setParams({ theme: value as ThemeName })
+ }}
+ >
+
+ {themes
+ .filter((theme) =>
+ BASE_COLORS.find((baseColor) => baseColor.name === theme.name)
+ )
+ .map((theme) => {
+ const isBaseColor = BASE_COLORS.find(
+ (baseColor) => baseColor.name === theme.name
+ )
+ return (
+
+
+ {mounted && resolvedTheme && (
+
+ )}
+
+
{theme.title}
+
+ Match base color
+
+
+
+
+ )
+ })}
+
+
+
+ {themes
+ .filter(
+ (theme) =>
+ !BASE_COLORS.find(
+ (baseColor) => baseColor.name === theme.name
+ )
+ )
+ .map((theme) => {
+ return (
+
+
+ {mounted && resolvedTheme && (
+
+ )}
+ {theme.title}
+
+
+ )
+ })}
+
+
+
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/toolbar-controls.tsx b/apps/v4/app/(create)/components/toolbar-controls.tsx
new file mode 100644
index 0000000000..5330257039
--- /dev/null
+++ b/apps/v4/app/(create)/components/toolbar-controls.tsx
@@ -0,0 +1,277 @@
+"use client"
+
+import * as React from "react"
+import {
+ ComputerTerminal01Icon,
+ Copy01Icon,
+ Tick02Icon,
+} from "@hugeicons/core-free-icons"
+import { HugeiconsIcon } from "@hugeicons/react"
+import { useQueryStates } from "nuqs"
+import { toast } from "sonner"
+
+import { useConfig } from "@/hooks/use-config"
+import { copyToClipboardWithMeta } from "@/components/copy-button"
+import { Button } from "@/registry/new-york-v4/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/registry/new-york-v4/ui/dialog"
+import {
+ Field,
+ FieldGroup,
+ FieldLabel,
+ FieldTitle,
+} from "@/registry/new-york-v4/ui/field"
+import {
+ RadioGroup,
+ RadioGroupItem,
+} from "@/registry/new-york-v4/ui/radio-group"
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/registry/new-york-v4/ui/tabs"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/registry/new-york-v4/ui/tooltip"
+import { designSystemSearchParams } from "@/app/(create)/lib/search-params"
+
+const TEMPLATES = [
+ {
+ value: "next",
+ title: "Next.js",
+ logo: '',
+ },
+ {
+ value: "start",
+ title: "TanStack Start",
+ logo: '',
+ },
+ {
+ value: "vite",
+ title: "Vite",
+ logo: '',
+ },
+] as const
+
+export function ToolbarControls() {
+ const [open, setOpen] = React.useState(false)
+ const [params, setParams] = useQueryStates(designSystemSearchParams, {
+ shallow: false,
+ history: "push",
+ })
+ const [config, setConfig] = useConfig()
+ const [hasCopied, setHasCopied] = React.useState(false)
+
+ const packageManager = config.packageManager || "pnpm"
+
+ const commands = React.useMemo(() => {
+ const origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
+ const url = `${origin}/init?base=${params.base}&style=${params.style}&baseColor=${params.baseColor}&theme=${params.theme}&iconLibrary=${params.iconLibrary}&font=${params.font}&menuAccent=${params.menuAccent}&menuColor=${params.menuColor}&radius=${params.radius}&template=${params.template}`
+ const templateFlag = params.template ? ` --template ${params.template}` : ""
+ return {
+ pnpm: `pnpm dlx shadcn@latest create --preset ${url}${templateFlag}`,
+ npm: `npx shadcn@latest create --preset ${url}${templateFlag}`,
+ yarn: `yarn dlx shadcn@latest create --preset ${url}${templateFlag}`,
+ bun: `bunx --bun shadcn@latest create --preset ${url}${templateFlag}`,
+ }
+ }, [
+ params.base,
+ params.style,
+ params.baseColor,
+ params.theme,
+ params.iconLibrary,
+ params.font,
+ params.menuAccent,
+ params.menuColor,
+ params.radius,
+ params.template,
+ ])
+
+ const command = commands[packageManager]
+
+ React.useEffect(() => {
+ if (hasCopied) {
+ const timer = setTimeout(() => setHasCopied(false), 2000)
+ return () => clearTimeout(timer)
+ }
+ }, [hasCopied])
+
+ const handleCopy = React.useCallback(() => {
+ const properties: Record = {
+ command,
+ }
+ if (params.template) {
+ properties.template = params.template
+ }
+ copyToClipboardWithMeta(command, {
+ name: "copy_npm_command",
+ properties,
+ })
+ setOpen(false)
+ setHasCopied(true)
+ toast("Command copied to clipboard.", {
+ description:
+ "Paste and run the command in your terminal to create a new shadcn/ui project.",
+ position: "bottom-center",
+ classNames: {
+ content: "rounded-xl",
+ toast: "rounded-xl!",
+ description: "text-sm/leading-normal!",
+ },
+ })
+ }, [command, params.template, setOpen])
+
+ const handleCopyFromTabs = React.useCallback(() => {
+ const properties: Record = {
+ command,
+ }
+ if (params.template) {
+ properties.template = params.template
+ }
+ copyToClipboardWithMeta(command, {
+ name: "copy_npm_command",
+ properties,
+ })
+ setHasCopied(true)
+ }, [command, params.template])
+
+ const selectedTemplate = TEMPLATES.find(
+ (template) => template.value === params.template
+ )
+
+ return (
+
+ )
+}
diff --git a/apps/v4/app/(create)/components/v0-button.tsx b/apps/v4/app/(create)/components/v0-button.tsx
new file mode 100644
index 0000000000..dbded709af
--- /dev/null
+++ b/apps/v4/app/(create)/components/v0-button.tsx
@@ -0,0 +1,61 @@
+"use client"
+
+import { useQueryStates } from "nuqs"
+
+import { cn } from "@/lib/utils"
+import { useIsMobile } from "@/hooks/use-mobile"
+import { useMounted } from "@/hooks/use-mounted"
+import { Icons } from "@/components/icons"
+import { Button } from "@/registry/new-york-v4/ui/button"
+import { Skeleton } from "@/registry/new-york-v4/ui/skeleton"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/registry/new-york-v4/ui/tooltip"
+import { designSystemSearchParams } from "@/app/(create)/lib/search-params"
+
+export function V0Button({ className }: { className?: string }) {
+ const [params] = useQueryStates(designSystemSearchParams, {
+ shallow: false,
+ history: "push",
+ })
+ const isMobile = useIsMobile()
+ const isMounted = useMounted()
+
+ const url = `${process.env.NEXT_PUBLIC_APP_URL}/create/v0?base=${params.base}&style=${params.style}&baseColor=${params.baseColor}&theme=${params.theme}&iconLibrary=${params.iconLibrary}&font=${params.font}&menuAccent=${params.menuAccent}&menuColor=${params.menuColor}&radius=${params.radius}&item=${params.item}`
+
+ console.log(url)
+
+ if (!isMounted) {
+ return
+ }
+
+ return (
+ <>
+
+
+
+
+
+ Open current design in v0
+
+
+ >
+ )
+}
diff --git a/apps/v4/app/(create)/components/welcome-dialog.tsx b/apps/v4/app/(create)/components/welcome-dialog.tsx
new file mode 100644
index 0000000000..589dec5c3b
--- /dev/null
+++ b/apps/v4/app/(create)/components/welcome-dialog.tsx
@@ -0,0 +1,69 @@
+"use client"
+
+import * as React from "react"
+
+import { Icons } from "@/components/icons"
+import { Button } from "@/registry/new-york-v4/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/registry/new-york-v4/ui/dialog"
+
+const STORAGE_KEY = "shadcn-create-welcome-dialog"
+
+export function WelcomeDialog() {
+ const [isOpen, setIsOpen] = React.useState(false)
+
+ React.useEffect(() => {
+ const dismissed = localStorage.getItem(STORAGE_KEY)
+ if (!dismissed) {
+ setIsOpen(true)
+ }
+ }, [])
+
+ const handleOpenChange = (open: boolean) => {
+ setIsOpen(open)
+ if (!open) {
+ localStorage.setItem(STORAGE_KEY, "true")
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/v4/app/(create)/create/layout.tsx b/apps/v4/app/(create)/create/layout.tsx
new file mode 100644
index 0000000000..201653ada0
--- /dev/null
+++ b/apps/v4/app/(create)/create/layout.tsx
@@ -0,0 +1,9 @@
+import { LocksProvider } from "@/app/(create)/hooks/use-locks"
+
+export default function CreateLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return {children}
+}
diff --git a/apps/v4/app/(create)/create/page.tsx b/apps/v4/app/(create)/create/page.tsx
new file mode 100644
index 0000000000..dbe7d7a5aa
--- /dev/null
+++ b/apps/v4/app/(create)/create/page.tsx
@@ -0,0 +1,142 @@
+import { type Metadata } from "next"
+import Link from "next/link"
+import { ArrowLeftIcon } from "lucide-react"
+import type { SearchParams } from "nuqs/server"
+
+import { siteConfig } from "@/lib/config"
+import { absoluteUrl } from "@/lib/utils"
+import { ModeSwitcher } from "@/components/mode-switcher"
+import { SiteConfig } from "@/components/site-config"
+import { BASES } from "@/registry/config"
+import { Button } from "@/registry/new-york-v4/ui/button"
+import { Separator } from "@/registry/new-york-v4/ui/separator"
+import { SidebarProvider } from "@/registry/new-york-v4/ui/sidebar"
+import { Customizer } from "@/app/(create)/components/customizer"
+import { CustomizerControls } from "@/app/(create)/components/customizer-controls"
+import { ItemExplorer } from "@/app/(create)/components/item-explorer"
+import { ItemPicker } from "@/app/(create)/components/item-picker"
+import { Preview } from "@/app/(create)/components/preview"
+import { ShareButton } from "@/app/(create)/components/share-button"
+import { ToolbarControls } from "@/app/(create)/components/toolbar-controls"
+import { V0Button } from "@/app/(create)/components/v0-button"
+import { WelcomeDialog } from "@/app/(create)/components/welcome-dialog"
+import { getItemsForBase } from "@/app/(create)/lib/api"
+import { designSystemSearchParamsCache } from "@/app/(create)/lib/search-params"
+
+export const revalidate = false
+export const dynamic = "force-static"
+
+export const metadata: Metadata = {
+ title: "New Project",
+ description:
+ "Customize everything. Pick your component library, icons, base color, theme, fonts and create your own version of shadcn/ui.",
+ openGraph: {
+ title: "New Project",
+ description:
+ "Customize everything. Pick your component library, icons, base color, theme, fonts and create your own version of shadcn/ui.",
+ type: "website",
+ url: absoluteUrl("/create"),
+ images: [
+ {
+ url: siteConfig.ogImage,
+ width: 1200,
+ height: 630,
+ alt: siteConfig.name,
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "New Project",
+ description:
+ "Customize everything. Pick your component library, icons, base color, theme, fonts and create your own version of shadcn/ui.",
+ images: [siteConfig.ogImage],
+ creator: "@shadcn",
+ },
+}
+
+export default async function CreatePage({
+ searchParams,
+}: {
+ searchParams: Promise
+}) {
+ const params = await designSystemSearchParamsCache.parse(searchParams)
+ const base = BASES.find((b) => b.name === params.base) ?? BASES[0]
+
+ const items = await getItemsForBase(base.name)
+
+ const filteredItems = items
+ .filter((item) => item !== null)
+ .map((item) => ({
+ name: item.name,
+ title: item.title,
+ type: item.type,
+ }))
+
+ return (
+
+
+
+
+
+
+
+
+ New Project
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/v4/app/(create)/create/v0/route.ts b/apps/v4/app/(create)/create/v0/route.ts
new file mode 100644
index 0000000000..9b7e2672fe
--- /dev/null
+++ b/apps/v4/app/(create)/create/v0/route.ts
@@ -0,0 +1,391 @@
+import { NextResponse, type NextRequest } from "next/server"
+import { track } from "@vercel/analytics/server"
+import dedent from "dedent"
+import {
+ registryItemFileSchema,
+ registryItemSchema,
+ type configSchema,
+ type RegistryItem,
+} from "shadcn/schema"
+import { transformIcons, transformMenu, transformRender } from "shadcn/utils"
+import { Project, ScriptKind } from "ts-morph"
+import { z } from "zod"
+
+import {
+ buildRegistryBase,
+ designSystemConfigSchema,
+ fonts,
+ type DesignSystemConfig,
+} from "@/registry/config"
+
+const { Index } = await import("@/registry/bases/__index__")
+
+export async function GET(request: NextRequest) {
+ try {
+ const searchParams = request.nextUrl.searchParams
+
+ const parseResult = designSystemConfigSchema.safeParse({
+ base: searchParams.get("base"),
+ style: searchParams.get("style"),
+ iconLibrary: searchParams.get("iconLibrary"),
+ baseColor: searchParams.get("baseColor"),
+ theme: searchParams.get("theme"),
+ font: searchParams.get("font"),
+ item: searchParams.get("item"),
+ menuAccent: searchParams.get("menuAccent"),
+ menuColor: searchParams.get("menuColor"),
+ radius: searchParams.get("radius"),
+ })
+
+ if (!parseResult.success) {
+ return NextResponse.json(
+ { error: parseResult.error.issues[0].message },
+ { status: 400 }
+ )
+ }
+
+ const designSystemConfig = parseResult.data
+ const registryBase = buildRegistryBase(designSystemConfig)
+ const validateResult = registryItemSchema.safeParse(registryBase)
+
+ if (!validateResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid registry base item",
+ details: validateResult.error.format(),
+ },
+ { status: 500 }
+ )
+ }
+
+ track("create_open_in_v0", designSystemConfig)
+
+ const payload = await buildV0Payload(designSystemConfig)
+
+ return NextResponse.json(payload)
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "An unknown error occurred",
+ },
+ { status: 500 }
+ )
+ }
+}
+
+async function buildV0Payload(designSystemConfig: DesignSystemConfig) {
+ const files: z.infer[] = []
+
+ // Build globals.css file.
+ files.push(buildGlobalsCss(designSystemConfig))
+
+ // Build layout.tsx file.
+ files.push(buildLayoutFile(designSystemConfig))
+
+ // Build component files.
+ const componentFiles = await buildComponentFiles(designSystemConfig)
+ files.push(...componentFiles)
+
+ return registryItemSchema.parse({
+ name: designSystemConfig.item ?? "Item",
+ type: "registry:item",
+ files,
+ })
+}
+
+function buildGlobalsCss(designSystemConfig: DesignSystemConfig) {
+ const registryBase = buildRegistryBase(designSystemConfig)
+
+ const lightVars = Object.entries(registryBase.cssVars?.light ?? {})
+ .map(([key, value]) => ` --${key}: ${value};`)
+ .join("\n")
+
+ const darkVars = Object.entries(registryBase.cssVars?.dark ?? {})
+ .map(([key, value]) => ` --${key}: ${value};`)
+ .join("\n")
+
+ const content = dedent`@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --font-sans: var(--font-sans);
+ --font-mono: var(--font-mono);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+ --radius-3xl: calc(var(--radius) + 12px);
+ --radius-4xl: calc(var(--radius) + 16px);
+}
+
+:root {
+ ${lightVars}
+}
+
+.dark {
+ ${darkVars}
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+ `
+
+ return registryItemFileSchema.parse({
+ path: "app/globals.css",
+ type: "registry:file",
+ target: "app/globals.css",
+ content,
+ })
+}
+
+function buildLayoutFile(designSystemConfig: DesignSystemConfig) {
+ const font = fonts.find(
+ (font) => font.name === `font-${designSystemConfig.font}`
+ )
+ if (!font) {
+ throw new Error(`Font "${designSystemConfig.font}" not found`)
+ }
+
+ const content = dedent`
+ import type { Metadata } from "next";
+ import { ${font.font.import} } from "next/font/google";
+ import "./globals.css";
+
+ const fontSans = ${font.font.import}({subsets:['latin'],variable:'--font-sans'});
+
+ export const metadata: Metadata = {
+ title: "Create Next App",
+ description: "Generated by create next app",
+ };
+
+ export default function RootLayout({
+ children,
+ }: Readonly<{
+ children: React.ReactNode;
+ }>) {
+ return (
+
+
+ {children}
+
+
+ );
+ }
+ `
+
+ return registryItemFileSchema.parse({
+ path: "app/layout.tsx",
+ type: "registry:page",
+ target: "app/layout.tsx",
+ content,
+ })
+}
+
+async function buildComponentFiles(designSystemConfig: DesignSystemConfig) {
+ const files = []
+ const allItemsForBase = Object.values(Index[designSystemConfig.base])
+ .filter(
+ (item: RegistryItem) =>
+ item.type === "registry:ui" || item.name === "example"
+ )
+ .map((item) => item.name)
+
+ const registryItemFiles = await Promise.all(
+ allItemsForBase.map(async (name) => {
+ const file = await getRegistryItemFile(name, designSystemConfig)
+ return file
+ })
+ )
+ files.push(...registryItemFiles)
+
+ const pageFile = {
+ path: "app/page.tsx",
+ type: "registry:page",
+ target: "app/page.tsx",
+ content: dedent`
+ import { Button } from "@/components/ui/button";
+ export default function Page() {
+ return
+ }
+ `,
+ }
+
+ // Build the actual item component.
+ if (designSystemConfig.item) {
+ const itemComponentFile = await getRegistryItemFile(
+ designSystemConfig.item,
+ designSystemConfig
+ )
+ if (itemComponentFile) {
+ // Find the export default function from the component file.
+ const exportDefault = itemComponentFile.content.match(
+ /export default function (\w+)/
+ )
+ if (exportDefault) {
+ const functionName = exportDefault[1]
+
+ // Replace the export default function with a named export.
+ itemComponentFile.content = itemComponentFile.content.replace(
+ /export default function (\w+)/,
+ `export function ${functionName}`
+ )
+
+ // Import and render the item on the page.
+ pageFile.content = dedent`import { ${functionName} } from "@/components/${designSystemConfig.item}";
+
+ export default function Page() {
+ return <${functionName} />
+ }`
+ }
+
+ files.push({
+ ...itemComponentFile,
+ target: `components/${designSystemConfig.item}.tsx`,
+ type: "registry:component",
+ })
+ }
+ }
+
+ files.push(pageFile)
+
+ return z.array(registryItemFileSchema).parse(files)
+}
+
+async function getRegistryItemFile(
+ name: string,
+ designSystemConfig: DesignSystemConfig
+) {
+ const response = await fetch(
+ `${process.env.NEXT_PUBLIC_APP_URL}/r/styles/${designSystemConfig.base}-${designSystemConfig.style}/${name}.json`
+ )
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch registry item: ${response.statusText}`)
+ }
+
+ const json = await response.json()
+ const item = registryItemSchema.parse(json)
+
+ // Build a v0 config i.e components.json
+ const config = {
+ $schema: "https://ui.shadcn.com/schema.json",
+ style: `${designSystemConfig.base}-${designSystemConfig.style}`,
+ rsc: true,
+ tsx: true,
+ tailwind: {
+ config: "",
+ css: "app/globals.css",
+ baseColor: designSystemConfig.baseColor,
+ cssVariables: true,
+ prefix: "",
+ },
+ iconLibrary: designSystemConfig.iconLibrary,
+ aliases: {
+ components: "@/components",
+ utils: "@/lib/utils",
+ ui: "@/components/ui",
+ lib: "@/lib",
+ hooks: "@/hooks",
+ },
+ menuAccent: designSystemConfig.menuAccent,
+ menuColor: designSystemConfig.menuColor,
+ resolvedPaths: {
+ cwd: "/",
+ tailwindConfig: "./tailwind.config.js",
+ tailwindCss: "./globals.css",
+ utils: "./lib/utils",
+ components: "./components",
+ lib: "./lib",
+ hooks: "./hooks",
+ ui: "./components/ui",
+ },
+ } satisfies z.infer
+
+ const file = item.files?.[0]
+ if (!file?.content) {
+ return null
+ }
+
+ const content = await transformFileContent(file.content, config)
+
+ return {
+ ...file,
+ target:
+ name === "example"
+ ? "components/example.tsx"
+ : `components/ui/${name}.tsx`,
+ type: name === "example" ? "registry:component" : "registry:ui",
+ content,
+ }
+}
+
+const transformers = [transformIcons, transformMenu, transformRender]
+
+async function transformFileContent(
+ content: string,
+ config: z.infer
+) {
+ const project = new Project({
+ compilerOptions: {},
+ })
+
+ const sourceFile = project.createSourceFile("component.tsx", content, {
+ scriptKind: ScriptKind.TSX,
+ })
+
+ for (const transformer of transformers) {
+ await transformer({
+ filename: "component.tsx",
+ raw: content,
+ sourceFile,
+ config,
+ })
+ }
+
+ return sourceFile.getText()
+}
diff --git a/apps/v4/app/(create)/hooks/use-canva.tsx b/apps/v4/app/(create)/hooks/use-canva.tsx
new file mode 100644
index 0000000000..c963b6aa7a
--- /dev/null
+++ b/apps/v4/app/(create)/hooks/use-canva.tsx
@@ -0,0 +1,41 @@
+"use client"
+
+import * as React from "react"
+
+import {
+ sendToIframe,
+ sendToParent,
+ useParentMessageListener,
+} from "@/app/(create)/hooks/use-iframe-sync"
+
+const MESSAGE_TYPE = "canva-zoom"
+
+export type ZoomCommand =
+ | { type: "ZOOM_IN" }
+ | { type: "ZOOM_OUT" }
+ | { type: "ZOOM_SET"; value: number }
+ | { type: "ZOOM_FIT" }
+ | { type: "RESET" }
+
+export function sendCanvaZoomCommand(
+ iframe: HTMLIFrameElement | null,
+ command: ZoomCommand
+) {
+ sendToIframe(iframe, MESSAGE_TYPE, { command })
+}
+
+export function sendCanvaZoomUpdate(zoom: number) {
+ sendToParent(MESSAGE_TYPE, { zoom })
+}
+
+export function useCanvaZoomSync() {
+ const [zoom, setZoom] = React.useState(1)
+
+ useParentMessageListener<{ zoom: number }>(MESSAGE_TYPE, (data) => {
+ if (typeof data.zoom === "number") {
+ setZoom(data.zoom)
+ }
+ })
+
+ return zoom
+}
diff --git a/apps/v4/app/(create)/hooks/use-design-system.tsx b/apps/v4/app/(create)/hooks/use-design-system.tsx
new file mode 100644
index 0000000000..7add96b46e
--- /dev/null
+++ b/apps/v4/app/(create)/hooks/use-design-system.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import { useQueryStates } from "nuqs"
+
+import {
+ createIframeSyncStore,
+ useIframeSyncAll,
+ useIframeSyncValue,
+} from "@/app/(create)/hooks/use-iframe-sync"
+import {
+ designSystemSearchParams,
+ type DesignSystemSearchParams,
+} from "@/app/(create)/lib/search-params"
+
+const MESSAGE_TYPE = "design-system-params"
+
+const getInitialValues = (): DesignSystemSearchParams => {
+ if (typeof window === "undefined") {
+ return {
+ base: "radix",
+ iconLibrary: "lucide",
+ theme: "neutral",
+ style: "vega",
+ font: "inter",
+ item: "cover-example",
+ baseColor: "neutral",
+ menuAccent: "subtle",
+ menuColor: "default",
+ radius: "default",
+ size: 100,
+ custom: false,
+ template: "next",
+ }
+ }
+
+ const searchParams = new URLSearchParams(window.location.search)
+ return {
+ base: (searchParams.get("base") ||
+ "radix") as DesignSystemSearchParams["base"],
+ iconLibrary: (searchParams.get("iconLibrary") ||
+ "lucide") as DesignSystemSearchParams["iconLibrary"],
+ theme: (searchParams.get("theme") ||
+ "neutral") as DesignSystemSearchParams["theme"],
+ style: (searchParams.get("style") ||
+ "vega") as DesignSystemSearchParams["style"],
+ font: (searchParams.get("font") ||
+ "inter") as DesignSystemSearchParams["font"],
+ item: searchParams.get("item") || "cover-example",
+ baseColor: (searchParams.get("baseColor") ||
+ "neutral") as DesignSystemSearchParams["baseColor"],
+ menuAccent: (searchParams.get("menuAccent") ||
+ "subtle") as DesignSystemSearchParams["menuAccent"],
+ menuColor: (searchParams.get("menuColor") ||
+ "default") as DesignSystemSearchParams["menuColor"],
+ radius: (searchParams.get("radius") ||
+ "default") as DesignSystemSearchParams["radius"],
+ size: parseInt(searchParams.get("size") || "100"),
+ custom: (searchParams.get("custom") || "false") === "true",
+ template: (searchParams.get("template") ||
+ "next") as DesignSystemSearchParams["template"],
+ }
+}
+
+const designSystemStore = createIframeSyncStore(
+ MESSAGE_TYPE,
+ getInitialValues()
+)
+
+export function useDesignSystemSync() {
+ const [urlParams] = useQueryStates(designSystemSearchParams, {
+ shallow: false,
+ })
+
+ const keys = Object.keys(
+ designSystemSearchParams
+ ) as (keyof DesignSystemSearchParams)[]
+
+ return useIframeSyncAll(designSystemStore, keys, urlParams)
+}
+
+export function useDesignSystemParam(
+ key: K
+) {
+ const [urlParams] = useQueryStates(designSystemSearchParams, {
+ shallow: false,
+ })
+
+ return useIframeSyncValue(designSystemStore, key, urlParams[key])
+}
diff --git a/apps/v4/app/(create)/hooks/use-iframe-sync.tsx b/apps/v4/app/(create)/hooks/use-iframe-sync.tsx
new file mode 100644
index 0000000000..2ff7eb852d
--- /dev/null
+++ b/apps/v4/app/(create)/hooks/use-iframe-sync.tsx
@@ -0,0 +1,204 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+"use client"
+
+import * as React from "react"
+
+export const isInIframe = () => {
+ if (typeof window === "undefined") {
+ return false
+ }
+ return window.self !== window.top
+}
+
+export function createIframeSyncStore>(
+ messageType: string,
+ defaultValues: T
+) {
+ const store = new Map()
+ const listeners = new Map void>>()
+
+ if (typeof window !== "undefined") {
+ Object.entries(defaultValues).forEach(([key, value]) => {
+ store.set(key as keyof T, value)
+ })
+ }
+
+ if (typeof window !== "undefined" && isInIframe()) {
+ window.addEventListener("message", (event: MessageEvent) => {
+ if (event.data.type === messageType && event.data.params) {
+ Object.keys(event.data.params).forEach((key) => {
+ const newValue = event.data.params[key]
+ const oldValue = store.get(key as keyof T)
+
+ if (newValue !== oldValue) {
+ store.set(key as keyof T, newValue)
+
+ const keyListeners = listeners.get(key as keyof T)
+ if (keyListeners) {
+ keyListeners.forEach((listener) => listener())
+ }
+ }
+ })
+ }
+ })
+ }
+
+ return {
+ store,
+ listeners,
+ subscribe(key: keyof T, callback: () => void) {
+ if (!isInIframe()) return () => {}
+
+ if (!listeners.has(key)) {
+ listeners.set(key, new Set())
+ }
+ const keyListeners = listeners.get(key)!
+ keyListeners.add(callback)
+
+ return () => {
+ keyListeners.delete(callback)
+ if (keyListeners.size === 0) {
+ listeners.delete(key)
+ }
+ }
+ },
+ subscribeAll(keys: (keyof T)[], callback: () => void) {
+ if (!isInIframe()) return () => {}
+
+ keys.forEach((key) => {
+ if (!listeners.has(key)) {
+ listeners.set(key, new Set())
+ }
+ listeners.get(key)!.add(callback)
+ })
+
+ return () => {
+ keys.forEach((key) => {
+ const keyListeners = listeners.get(key)
+ if (keyListeners) {
+ keyListeners.delete(callback)
+ if (keyListeners.size === 0) {
+ listeners.delete(key)
+ }
+ }
+ })
+ }
+ },
+ get(key: keyof T) {
+ return store.get(key) as T[keyof T]
+ },
+ getAll() {
+ const result = {} as T
+ store.forEach((value, key) => {
+ result[key as keyof T] = value
+ })
+ return result
+ },
+ }
+}
+
+export function useIframeSyncValue(
+ store: ReturnType>,
+ key: string,
+ urlValue: T
+) {
+ const subscribe = React.useCallback(
+ (callback: () => void) => {
+ return store.subscribe(key, callback)
+ },
+ [store, key]
+ )
+
+ const getSnapshot = React.useCallback(() => {
+ if (!isInIframe()) {
+ return urlValue
+ }
+ return store.get(key) as T
+ }, [store, key, urlValue])
+
+ const getServerSnapshot = React.useCallback(() => {
+ return urlValue
+ }, [urlValue])
+
+ return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
+}
+
+export function useIframeSyncAll>(
+ store: ReturnType>,
+ keys: (keyof T)[],
+ urlValues: T
+) {
+ const subscribe = React.useCallback(
+ (callback: () => void) => {
+ return store.subscribeAll(keys, callback)
+ },
+ [store, keys]
+ )
+
+ const getSnapshot = React.useCallback(() => {
+ if (!isInIframe()) {
+ return urlValues
+ }
+ return store.getAll()
+ }, [store, urlValues])
+
+ const getServerSnapshot = React.useCallback(() => {
+ return urlValues
+ }, [urlValues])
+
+ return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
+}
+
+export function useParentMessageListener(
+ messageType: string,
+ onMessage: (data: T) => void
+) {
+ React.useEffect(() => {
+ if (isInIframe()) {
+ return
+ }
+
+ const handleMessage = (event: MessageEvent) => {
+ if (event.data.type === messageType) {
+ onMessage(event.data)
+ }
+ }
+
+ window.addEventListener("message", handleMessage)
+ return () => {
+ window.removeEventListener("message", handleMessage)
+ }
+ }, [messageType, onMessage])
+}
+
+export function sendToIframe(
+ iframe: HTMLIFrameElement | null,
+ messageType: string,
+ data: any
+) {
+ if (!iframe || !iframe.contentWindow) {
+ return
+ }
+
+ iframe.contentWindow.postMessage(
+ {
+ type: messageType,
+ ...data,
+ },
+ "*"
+ )
+}
+
+export function sendToParent(messageType: string, data: any) {
+ if (!isInIframe()) {
+ return
+ }
+
+ window.parent.postMessage(
+ {
+ type: messageType,
+ ...data,
+ },
+ "*"
+ )
+}
diff --git a/apps/v4/app/(create)/hooks/use-locks.tsx b/apps/v4/app/(create)/hooks/use-locks.tsx
new file mode 100644
index 0000000000..457b12d606
--- /dev/null
+++ b/apps/v4/app/(create)/hooks/use-locks.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import * as React from "react"
+
+export type LockableParam =
+ | "style"
+ | "baseColor"
+ | "theme"
+ | "iconLibrary"
+ | "font"
+ | "menuAccent"
+ | "menuColor"
+ | "radius"
+
+type LocksContextValue = {
+ locks: Set
+ isLocked: (param: LockableParam) => boolean
+ toggleLock: (param: LockableParam) => void
+}
+
+const LocksContext = React.createContext(null)
+
+export function LocksProvider({ children }: { children: React.ReactNode }) {
+ const [locks, setLocks] = React.useState>(new Set())
+
+ const isLocked = React.useCallback(
+ (param: LockableParam) => locks.has(param),
+ [locks]
+ )
+
+ const toggleLock = React.useCallback((param: LockableParam) => {
+ setLocks((prev) => {
+ const next = new Set(prev)
+ if (next.has(param)) {
+ next.delete(param)
+ } else {
+ next.add(param)
+ }
+ return next
+ })
+ }, [])
+
+ const value = React.useMemo(
+ () => ({ locks, isLocked, toggleLock }),
+ [locks, isLocked, toggleLock]
+ )
+
+ return {children}
+}
+
+export function useLocks() {
+ const context = React.useContext(LocksContext)
+ if (!context) {
+ throw new Error("useLocks must be used within LocksProvider")
+ }
+ return context
+}
diff --git a/apps/v4/app/(create)/init/route.ts b/apps/v4/app/(create)/init/route.ts
new file mode 100644
index 0000000000..850197566e
--- /dev/null
+++ b/apps/v4/app/(create)/init/route.ts
@@ -0,0 +1,56 @@
+import { NextResponse, type NextRequest } from "next/server"
+import { track } from "@vercel/analytics/server"
+import { registryItemSchema } from "shadcn/schema"
+
+import { buildRegistryBase, designSystemConfigSchema } from "@/registry/config"
+
+export async function GET(request: NextRequest) {
+ try {
+ const searchParams = request.nextUrl.searchParams
+
+ const result = designSystemConfigSchema.safeParse({
+ base: searchParams.get("base"),
+ style: searchParams.get("style"),
+ iconLibrary: searchParams.get("iconLibrary"),
+ baseColor: searchParams.get("baseColor"),
+ theme: searchParams.get("theme"),
+ font: searchParams.get("font"),
+ menuAccent: searchParams.get("menuAccent"),
+ menuColor: searchParams.get("menuColor"),
+ radius: searchParams.get("radius"),
+ template: searchParams.get("template"),
+ })
+
+ if (!result.success) {
+ return NextResponse.json(
+ { error: result.error.issues[0].message },
+ { status: 400 }
+ )
+ }
+
+ const registryBase = buildRegistryBase(result.data)
+ const parseResult = registryItemSchema.safeParse(registryBase)
+
+ if (!parseResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid registry base item",
+ details: parseResult.error.format(),
+ },
+ { status: 500 }
+ )
+ }
+
+ track("create_app", result.data)
+
+ return NextResponse.json(parseResult.data)
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "An unknown error occurred",
+ },
+ { status: 500 }
+ )
+ }
+}
diff --git a/apps/v4/app/(create)/lib/api.ts b/apps/v4/app/(create)/lib/api.ts
new file mode 100644
index 0000000000..e1819af4bb
--- /dev/null
+++ b/apps/v4/app/(create)/lib/api.ts
@@ -0,0 +1,44 @@
+import "server-only"
+
+import { registryItemSchema } from "shadcn/schema"
+
+import { getThemesForBaseColor, type BaseName } from "@/registry/config"
+import { ALLOWED_ITEM_TYPES } from "@/app/(create)/lib/constants"
+
+export async function getItemsForBase(base: BaseName) {
+ const { Index } = await import("@/registry/bases/__index__")
+ const index = Index[base]
+
+ if (!index) {
+ return []
+ }
+
+ return Object.values(index).filter((item) =>
+ ALLOWED_ITEM_TYPES.includes(item.type)
+ )
+}
+
+export async function getBaseItem(name: string, base: BaseName) {
+ const { Index } = await import("@/registry/bases/__index__")
+ const index = Index[base]
+
+ if (!index?.[name]) {
+ return null
+ }
+
+ return registryItemSchema.parse(index[name])
+}
+
+export async function getBaseComponent(name: string, base: BaseName) {
+ const { Index } = await import("@/registry/bases/__index__")
+ const index = Index[base]
+
+ if (!index?.[name]) {
+ return null
+ }
+
+ return index[name].component
+}
+
+// Re-export for server-side use.
+export { getThemesForBaseColor }
diff --git a/apps/v4/app/(create)/lib/constants.ts b/apps/v4/app/(create)/lib/constants.ts
new file mode 100644
index 0000000000..f42ae9b1d1
--- /dev/null
+++ b/apps/v4/app/(create)/lib/constants.ts
@@ -0,0 +1 @@
+export const ALLOWED_ITEM_TYPES = ["registry:block", "registry:example"]
diff --git a/apps/v4/app/(create)/lib/fonts.ts b/apps/v4/app/(create)/lib/fonts.ts
new file mode 100644
index 0000000000..e522be4321
--- /dev/null
+++ b/apps/v4/app/(create)/lib/fonts.ts
@@ -0,0 +1,151 @@
+import {
+ DM_Sans,
+ Figtree,
+ Geist,
+ Geist_Mono,
+ Inter,
+ JetBrains_Mono,
+ Noto_Sans,
+ Nunito_Sans,
+ Outfit,
+ Public_Sans,
+ Raleway,
+ Roboto,
+} from "next/font/google"
+
+const inter = Inter({
+ subsets: ["latin"],
+ variable: "--font-inter",
+})
+
+const notoSans = Noto_Sans({
+ subsets: ["latin"],
+ variable: "--font-noto-sans",
+})
+
+const nunitoSans = Nunito_Sans({
+ subsets: ["latin"],
+ variable: "--font-nunito-sans",
+})
+
+const figtree = Figtree({
+ subsets: ["latin"],
+ 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",
+})
+
+const raleway = Raleway({
+ subsets: ["latin"],
+ variable: "--font-raleway",
+})
+
+const dmSans = DM_Sans({
+ subsets: ["latin"],
+ variable: "--font-dm-sans",
+})
+
+const publicSans = Public_Sans({
+ subsets: ["latin"],
+ variable: "--font-public-sans",
+})
+
+const outfit = Outfit({
+ subsets: ["latin"],
+ variable: "--font-outfit",
+})
+
+export const FONTS = [
+ // {
+ // name: "Geist Sans",
+ // 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: "JetBrains Mono",
+ value: "jetbrains-mono",
+ font: jetbrainsMono,
+ type: "mono",
+ },
+ // {
+ // name: "Geist Mono",
+ // value: "geist-mono",
+ // font: geistMono,
+ // type: "mono",
+ // },
+] as const
+
+export type Font = (typeof FONTS)[number]
diff --git a/apps/v4/app/(create)/lib/merge-theme.ts b/apps/v4/app/(create)/lib/merge-theme.ts
new file mode 100644
index 0000000000..83f1d8030b
--- /dev/null
+++ b/apps/v4/app/(create)/lib/merge-theme.ts
@@ -0,0 +1,32 @@
+import { registryItemSchema, type RegistryItem } from "shadcn/schema"
+
+import { BASE_COLORS, THEMES } from "@/registry/config"
+
+export function buildTheme(baseColorName: string, themeName: string) {
+ const baseColor = BASE_COLORS.find((c) => c.name === baseColorName)
+ const theme = THEMES.find((t) => t.name === themeName)
+
+ if (!baseColor || !theme) {
+ throw new Error(
+ `Base color "${baseColorName}" or theme "${themeName}" not found`
+ )
+ }
+
+ const mergedTheme: RegistryItem = {
+ name: `${baseColor.name}-${theme.name}`,
+ title: `${baseColor.title} ${theme.title}`,
+ type: "registry:theme",
+ cssVars: {
+ light: {
+ ...baseColor.cssVars?.light,
+ ...theme.cssVars?.light,
+ },
+ dark: {
+ ...baseColor.cssVars?.dark,
+ ...theme.cssVars?.dark,
+ },
+ },
+ }
+
+ return registryItemSchema.parse(mergedTheme)
+}
diff --git a/apps/v4/app/(create)/lib/randomize-biases.ts b/apps/v4/app/(create)/lib/randomize-biases.ts
new file mode 100644
index 0000000000..c6505b73b7
--- /dev/null
+++ b/apps/v4/app/(create)/lib/randomize-biases.ts
@@ -0,0 +1,80 @@
+import type {
+ BaseColorName,
+ Radius,
+ StyleName,
+ ThemeName,
+} from "@/registry/config"
+
+import { type FONTS } from "./fonts"
+
+export type RandomizeContext = {
+ style?: StyleName
+ baseColor?: BaseColorName
+ theme?: ThemeName
+ iconLibrary?: string
+ font?: string
+ menuAccent?: string
+ menuColor?: string
+ radius?: string
+}
+
+export type BiasFilter = (
+ items: readonly T[],
+ context: RandomizeContext
+) => readonly T[]
+
+export type RandomizeBiases = {
+ fonts?: BiasFilter<(typeof FONTS)[number]>
+ radius?: BiasFilter
+ // Add more bias filters as needed:
+ // styles?: BiasFilter