Compare commits

..

1 Commits

Author SHA1 Message Date
shadcn
86479e0a30 fix: init e2e tests 2026-03-10 17:21:19 +04:00
7200 changed files with 301032 additions and 179838 deletions

View File

@@ -1,22 +0,0 @@
---
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).

78
.github/workflows/deprecated.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: Deprecated
on:
pull_request_target:
types: [opened, synchronize]
permissions:
issues: write
contents: read
pull-requests: write
jobs:
deprecated:
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v46
with:
files: |
apps/www/**
files_ignore: |
apps/www/public/r/**
base_sha: ${{ github.event.pull_request.base.sha }}
sha: ${{ github.event.pull_request.head.sha }}
- name: Comment on PR if www files changed
if: steps.changed-files.outputs.any_changed == 'true'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const changedFiles = `${{ steps.changed-files.outputs.all_changed_files }}`.split(' ');
const wwwFiles = changedFiles.filter(file =>
file.startsWith('apps/www/') &&
!file.startsWith('apps/www/public/r/') &&
file !== 'apps/www/package.json'
);
if (wwwFiles.length > 0) {
const comment = `Looks like this PR modifies files in \`apps/www\`, which is deprecated.
Consider applying the change to \`apps/v4\` if relevant.`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
// Add deprecated label
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['deprecated']
});
} else {
// Remove deprecated label if no www files are changed
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: 'deprecated'
});
} catch (error) {
// Label doesn't exist, which is fine
console.log('Deprecated label not found, skipping removal');
}
}

View File

@@ -16,7 +16,7 @@ jobs:
check-registry-sync:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
name: check-registry-sync
name: Check registry sync
permissions:
contents: read
pull-requests: write
@@ -66,44 +66,6 @@ jobs:
with:
node-version: 20
- name: Block reserved registry namespaces
env:
RESERVED_NAMESPACES: "@shadcn,@ui,@blocks,@components,@block,@component,@util,@utils,@registry,@lib,@hook,@hooks,@theme,@themes,@chart,@charts"
run: |
node <<'EOF'
const fs = require("node:fs")
const files = [
"apps/v4/public/r/registries.json",
"apps/v4/registry/directory.json",
]
const reservedNamespaces = new Set(
process.env.RESERVED_NAMESPACES.split(",").filter(Boolean)
)
function readNames(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8")).map(
(entry) => entry.name
)
}
const violations = files.flatMap((filePath) => {
return readNames(filePath)
.filter((name) => reservedNamespaces.has(name))
.map((name) => `${filePath}: ${name}`)
})
if (violations.length > 0) {
console.error("Reserved registry namespaces are not allowed:")
for (const violation of violations) {
console.error(`- ${violation}`)
}
process.exit(1)
}
EOF
- uses: pnpm/action-setup@v4
name: Install pnpm
id: pnpm-install

2
.gitignore vendored
View File

@@ -15,7 +15,6 @@ build
# misc
.DS_Store
.eslintcache
*.pem
# debug
@@ -44,4 +43,3 @@ tsconfig.tsbuildinfo
.notes
.playwright-mcp
shadcn-workspace
.codex-artifacts

View File

@@ -5,4 +5,3 @@ build
.contentlayer
**/fixtures
deprecated
apps/v4/registry/styles/**/*.css

View File

@@ -5,4 +5,3 @@ build
.contentlayer
registry/__index__.tsx
content/docs/components/calendar.mdx
registry/styles/**/*.css

View File

@@ -1,10 +1,8 @@
"use client"
import * as React from "react"
import { IconMinus, IconPlus } from "@tabler/icons-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { Button } from "@/examples/radix/ui/button"
import { ButtonGroup } from "@/examples/radix/ui/button-group"
import {
Field,
FieldContent,
@@ -15,10 +13,11 @@ import {
FieldSeparator,
FieldSet,
FieldTitle,
} from "@/styles/radix-nova/ui/field"
import { Input } from "@/styles/radix-nova/ui/input"
import { RadioGroup, RadioGroupItem } from "@/styles/radix-nova/ui/radio-group"
import { Switch } from "@/styles/radix-nova/ui/switch"
} from "@/examples/radix/ui/field"
import { Input } from "@/examples/radix/ui/input"
import { RadioGroup, RadioGroupItem } from "@/examples/radix/ui/radio-group"
import { Switch } from "@/examples/radix/ui/switch"
import { IconMinus, IconPlus } from "@tabler/icons-react"
export function AppearanceSettings() {
const [gpuCount, setGpuCount] = React.useState(8)

View File

@@ -1,20 +1,8 @@
"use client"
import * as React from "react"
import {
ArchiveIcon,
ArrowLeftIcon,
CalendarPlusIcon,
ClockIcon,
ListFilterIcon,
MailCheckIcon,
MoreHorizontalIcon,
TagIcon,
Trash2Icon,
} from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { Button } from "@/examples/radix/ui/button"
import { ButtonGroup } from "@/examples/radix/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,7 +15,18 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/styles/radix-nova/ui/dropdown-menu"
} from "@/examples/radix/ui/dropdown-menu"
import {
ArchiveIcon,
ArrowLeftIcon,
CalendarPlusIcon,
ClockIcon,
ListFilterIcon,
MailCheckIcon,
MoreHorizontalIcon,
TagIcon,
Trash2Icon,
} from "lucide-react"
export function ButtonGroupDemo() {
const [label, setLabel] = React.useState("personal")

View File

@@ -1,21 +1,20 @@
"use client"
import * as React from "react"
import { AudioLinesIcon, PlusIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { Button } from "@/examples/radix/ui/button"
import { ButtonGroup } from "@/examples/radix/ui/button-group"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/styles/radix-nova/ui/input-group"
} from "@/examples/radix/ui/input-group"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/radix-nova/ui/tooltip"
} from "@/examples/radix/ui/tooltip"
import { AudioLinesIcon, PlusIcon } from "lucide-react"
export function ButtonGroupInputGroup() {
const [voiceEnabled, setVoiceEnabled] = React.useState(false)

View File

@@ -1,10 +1,9 @@
"use client"
import { Button } from "@/examples/radix/ui/button"
import { ButtonGroup } from "@/examples/radix/ui/button-group"
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
export function ButtonGroupNested() {
return (
<ButtonGroup>

View File

@@ -1,14 +1,13 @@
import { BotIcon, ChevronDownIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { Button } from "@/examples/radix/ui/button"
import { ButtonGroup } from "@/examples/radix/ui/button-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/radix-nova/ui/popover"
import { Separator } from "@/styles/radix-nova/ui/separator"
import { Textarea } from "@/styles/radix-nova/ui/textarea"
} from "@/examples/radix/ui/popover"
import { Separator } from "@/examples/radix/ui/separator"
import { Textarea } from "@/examples/radix/ui/textarea"
import { BotIcon, ChevronDownIcon } from "lucide-react"
export function ButtonGroupPopover() {
return (

View File

@@ -1,12 +1,10 @@
import { PlusIcon } from "lucide-react"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@/styles/radix-nova/ui/avatar"
import { Button } from "@/styles/radix-nova/ui/button"
} from "@/examples/radix/ui/avatar"
import { Button } from "@/examples/radix/ui/button"
import {
Empty,
EmptyContent,
@@ -14,7 +12,8 @@ import {
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/radix-nova/ui/empty"
} from "@/examples/radix/ui/empty"
import { PlusIcon } from "lucide-react"
export function EmptyAvatarGroup() {
return (

View File

@@ -1,5 +1,5 @@
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
import { Field, FieldLabel } from "@/styles/radix-nova/ui/field"
import { Checkbox } from "@/examples/radix/ui/checkbox"
import { Field, FieldLabel } from "@/examples/radix/ui/field"
export function FieldCheckbox() {
return (

View File

@@ -1,5 +1,5 @@
import { Button } from "@/styles/radix-nova/ui/button"
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
import { Button } from "@/examples/radix/ui/button"
import { Checkbox } from "@/examples/radix/ui/checkbox"
import {
Field,
FieldDescription,
@@ -8,8 +8,8 @@ import {
FieldLegend,
FieldSeparator,
FieldSet,
} from "@/styles/radix-nova/ui/field"
import { Input } from "@/styles/radix-nova/ui/input"
} from "@/examples/radix/ui/field"
import { Input } from "@/examples/radix/ui/input"
import {
Select,
SelectContent,
@@ -17,8 +17,8 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "@/styles/radix-nova/ui/select"
import { Textarea } from "@/styles/radix-nova/ui/textarea"
} from "@/examples/radix/ui/select"
import { Textarea } from "@/examples/radix/ui/textarea"
export function FieldDemo() {
return (

View File

@@ -1,5 +1,5 @@
import { Card, CardContent } from "@/styles/radix-nova/ui/card"
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
import { Card, CardContent } from "@/examples/radix/ui/card"
import { Checkbox } from "@/examples/radix/ui/checkbox"
import {
Field,
FieldDescription,
@@ -8,7 +8,7 @@ import {
FieldLegend,
FieldSet,
FieldTitle,
} from "@/styles/radix-nova/ui/field"
} from "@/examples/radix/ui/field"
const options = [
{

View File

@@ -1,13 +1,8 @@
"use client"
import { useState } from "react"
import {
Field,
FieldDescription,
FieldTitle,
} from "@/styles/radix-nova/ui/field"
import { Slider } from "@/styles/radix-nova/ui/slider"
import { Field, FieldDescription, FieldTitle } from "@/examples/radix/ui/field"
import { Slider } from "@/examples/radix/ui/slider"
export function FieldSlider() {
const [value, setValue] = useState([200, 800])

View File

@@ -1,4 +1,4 @@
import { FieldSeparator } from "@/styles/radix-nova/ui/field"
import { FieldSeparator } from "@/examples/radix/ui/field"
import { AppearanceSettings } from "./appearance-settings"
import { ButtonGroupDemo } from "./button-group-demo"

View File

@@ -1,20 +1,19 @@
"use client"
import * as React from "react"
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/styles/radix-nova/ui/input-group"
import { Label } from "@/styles/radix-nova/ui/label"
} from "@/examples/radix/ui/input-group"
import { Label } from "@/examples/radix/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/radix-nova/ui/popover"
} from "@/examples/radix/ui/popover"
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
export function InputGroupButtonExample() {
const [isFavorite, setIsFavorite] = React.useState(false)

View File

@@ -1,12 +1,9 @@
import { IconCheck, IconInfoCircle, IconPlus } from "@tabler/icons-react"
import { ArrowUpIcon, Search } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/radix-nova/ui/dropdown-menu"
} from "@/examples/radix/ui/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
@@ -14,13 +11,15 @@ import {
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@/styles/radix-nova/ui/input-group"
import { Separator } from "@/styles/radix-nova/ui/separator"
} from "@/examples/radix/ui/input-group"
import { Separator } from "@/examples/radix/ui/separator"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/radix-nova/ui/tooltip"
} from "@/examples/radix/ui/tooltip"
import { IconCheck, IconInfoCircle, IconPlus } from "@tabler/icons-react"
import { ArrowUpIcon, Search } from "lucide-react"
export function InputGroupDemo() {
return (
@@ -89,7 +88,7 @@ export function InputGroupDemo() {
<InputGroupInput placeholder="@shadcn" />
<InputGroupAddon align="inline-end">
<div className="flex size-4 items-center justify-center rounded-full bg-primary text-foreground">
<IconCheck className="size-3 text-background" />
<IconCheck className="size-3 text-white" />
</div>
</InputGroupAddon>
</InputGroup>

View File

@@ -1,6 +1,4 @@
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { Button } from "@/examples/radix/ui/button"
import {
Item,
ItemActions,
@@ -8,7 +6,8 @@ import {
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/styles/radix-nova/ui/item"
} from "@/examples/radix/ui/item"
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
export function ItemDemo() {
return (

View File

@@ -1,24 +1,8 @@
"use client"
import { useMemo, useState } from "react"
import {
IconApps,
IconArrowUp,
IconAt,
IconBook,
IconCircleDashedPlus,
IconPaperclip,
IconPlus,
IconWorld,
IconX,
} from "@tabler/icons-react"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/styles/radix-nova/ui/avatar"
import { Badge } from "@/styles/radix-nova/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/examples/radix/ui/avatar"
import { Badge } from "@/examples/radix/ui/badge"
import {
Command,
CommandEmpty,
@@ -26,7 +10,7 @@ import {
CommandInput,
CommandItem,
CommandList,
} from "@/styles/radix-nova/ui/command"
} from "@/examples/radix/ui/command"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -39,25 +23,36 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/styles/radix-nova/ui/dropdown-menu"
import { Field, FieldLabel } from "@/styles/radix-nova/ui/field"
} from "@/examples/radix/ui/dropdown-menu"
import { Field, FieldLabel } from "@/examples/radix/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/styles/radix-nova/ui/input-group"
} from "@/examples/radix/ui/input-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/radix-nova/ui/popover"
import { Switch } from "@/styles/radix-nova/ui/switch"
} from "@/examples/radix/ui/popover"
import { Switch } from "@/examples/radix/ui/switch"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/radix-nova/ui/tooltip"
} from "@/examples/radix/ui/tooltip"
import {
IconApps,
IconArrowUp,
IconAt,
IconBook,
IconCircleDashedPlus,
IconPaperclip,
IconPlus,
IconWorld,
IconX,
} from "@tabler/icons-react"
const SAMPLE_DATA = {
mentionable: [

View File

@@ -1,5 +1,5 @@
import { Badge } from "@/styles/radix-nova/ui/badge"
import { Spinner } from "@/styles/radix-nova/ui/spinner"
import { Badge } from "@/examples/radix/ui/badge"
import { Spinner } from "@/examples/radix/ui/spinner"
export function SpinnerBadge() {
return (

View File

@@ -1,4 +1,4 @@
import { Button } from "@/styles/radix-nova/ui/button"
import { Button } from "@/examples/radix/ui/button"
import {
Empty,
EmptyContent,
@@ -6,8 +6,8 @@ import {
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/radix-nova/ui/empty"
import { Spinner } from "@/styles/radix-nova/ui/spinner"
} from "@/examples/radix/ui/empty"
import { Spinner } from "@/examples/radix/ui/spinner"
export function SpinnerEmpty() {
return (

View File

@@ -63,7 +63,11 @@ export default function IndexPage() {
</Button>
</PageActions>
</PageHeader>
<div className="container-wrapper flex-1 pb-6">
<PageNav className="hidden md:flex">
<ExamplesNav className="flex-1 overflow-hidden [&>a:first-child]:text-primary" />
<ThemeSelector className="mr-4 hidden md:flex" />
</PageNav>
<div className="container-wrapper flex-1 section-soft pb-6">
<div className="container overflow-hidden">
<section className="-mx-4 w-[160vw] overflow-hidden rounded-lg border border-border/50 md:hidden md:w-[150vw]">
<Image

View File

@@ -56,7 +56,7 @@ export default function BlocksLayout({
<a href="#blocks">Browse Blocks</a>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/docs/components">View Components</Link>
<Link href="/docs/blocks">Add a block</Link>
</Button>
</PageActions>
</PageHeader>

View File

@@ -1,136 +0,0 @@
"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/(app)/create/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
export function ChartColorPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
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 (
<div className="group/picker relative">
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Chart Color</div>
<div className="text-sm font-medium text-foreground">
{currentChartColor?.title}
</div>
</div>
{mounted && (
<div
style={
{
"--color":
currentChartColor?.cssVars?.dark?.[
currentChartColorIsBaseColor
? "muted-foreground"
: "primary"
],
} as React.CSSProperties
}
className="pointer-events-none absolute top-1/2 right-4 size-4 -translate-y-1/2 rounded-full bg-(--color) select-none md:right-2.5"
/>
)}
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
className="max-h-92"
>
<PickerRadioGroup
value={currentChartColor?.name}
onValueChange={(value) => {
setParams({ chartColor: value as ChartColorName })
}}
>
<PickerGroup>
{availableChartColors
.filter((theme) =>
BASE_COLORS.find((baseColor) => baseColor.name === theme.name)
)
.map((theme) => (
<PickerRadioItem
key={theme.name}
value={theme.name}
closeOnClick={isMobile}
>
{theme.title}
</PickerRadioItem>
))}
</PickerGroup>
<PickerSeparator />
<PickerGroup>
{availableChartColors
.filter(
(theme) =>
!BASE_COLORS.find(
(baseColor) => baseColor.name === theme.name
)
)
.map((theme) => (
<PickerRadioItem
key={theme.name}
value={theme.name}
closeOnClick={isMobile}
>
{theme.title}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="chartColor"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -1,119 +0,0 @@
"use client"
import * as React from "react"
import dynamic from "next/dynamic"
import { type RegistryItem } from "shadcn/schema"
import { useIsMobile } from "@/hooks/use-mobile"
import { getThemesForBaseColor, STYLES } from "@/registry/config"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-nova/ui/card"
import { FieldGroup, FieldSeparator } from "@/styles/base-nova/ui/field"
import { MenuAccentPicker } from "@/app/(app)/create/components/accent-picker"
import { ActionMenu } from "@/app/(app)/create/components/action-menu"
import { BaseColorPicker } from "@/app/(app)/create/components/base-color-picker"
import { BasePicker } from "@/app/(app)/create/components/base-picker"
import { ChartColorPicker } from "@/app/(app)/create/components/chart-color-picker"
import { CopyPreset } from "@/app/(app)/create/components/copy-preset"
import { FontPicker } from "@/app/(app)/create/components/font-picker"
import { IconLibraryPicker } from "@/app/(app)/create/components/icon-library-picker"
import { MainMenu } from "@/app/(app)/create/components/main-menu"
import { MenuColorPicker } from "@/app/(app)/create/components/menu-picker"
import { OpenPreset } from "@/app/(app)/create/components/open-preset"
import { RadiusPicker } from "@/app/(app)/create/components/radius-picker"
import { RandomButton } from "@/app/(app)/create/components/random-button"
import { ResetDialog } from "@/app/(app)/create/components/reset-button"
import { StylePicker } from "@/app/(app)/create/components/style-picker"
import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
// Only visible when user clicks "Create Project".
const ProjectForm = dynamic(() =>
import("@/app/(app)/create/components/project-form").then(
(m) => m.ProjectForm
)
)
export function Customizer({
itemsByBase,
}: {
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
}) {
const [params] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const anchorRef = React.useRef<HTMLDivElement | null>(null)
const availableThemes = React.useMemo(
() => getThemesForBaseColor(params.baseColor),
[params.baseColor]
)
return (
<Card
className="dark top-24 right-12 isolate z-10 max-h-full min-h-0 w-full self-start rounded-2xl bg-card/90 shadow-xl backdrop-blur-xl md:w-(--customizer-width)"
ref={anchorRef}
size="sm"
>
<CardHeader className="hidden items-center justify-between gap-2 border-b group-data-reversed/layout:flex-row-reverse md:flex">
<MainMenu />
</CardHeader>
<CardContent className="no-scrollbar min-h-0 flex-1 overflow-x-auto overflow-y-hidden md:overflow-y-auto">
<FieldGroup className="flex-row gap-2.5 py-px **:data-[slot=field-separator]:-mx-4 **:data-[slot=field-separator]:w-auto md:flex-col md:gap-3.25">
<StylePicker
styles={STYLES}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<FieldSeparator className="hidden md:block" />
<BaseColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<ThemePicker
themes={availableThemes}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<ChartColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<FieldSeparator className="hidden md:block" />
<FontPicker
label="Heading"
param="fontHeading"
fonts={FONT_HEADING_OPTIONS}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<FontPicker
label="Font"
param="font"
fonts={FONTS}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<FieldSeparator className="hidden md:block" />
<IconLibraryPicker isMobile={isMobile} anchorRef={anchorRef} />
<RadiusPicker isMobile={isMobile} anchorRef={anchorRef} />
<FieldSeparator className="hidden md:block" />
<MenuColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<MenuAccentPicker isMobile={isMobile} anchorRef={anchorRef} />
{isMobile && <BasePicker isMobile={isMobile} anchorRef={anchorRef} />}
</FieldGroup>
</CardContent>
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:rounded-b-none md:**:[button,a]:w-full">
<CopyPreset className="min-w-0 flex-1 md:flex-none" />
<OpenPreset
className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none"
label={isMobile ? "Open" : "Open Preset"}
/>
<RandomButton className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none" />
<ActionMenu itemsByBase={itemsByBase} />
<ResetDialog />
</CardFooter>
<CardFooter className="-mt-3 hidden min-w-0 gap-2 md:flex md:flex-col md:**:[button,a]:w-full">
<ProjectForm />
</CardFooter>
</Card>
)
}

View File

@@ -1,307 +0,0 @@
"use client"
import * as React from "react"
import {
buildRegistryTheme,
DEFAULT_CONFIG,
type DesignSystemConfig,
} from "@/registry/config"
import { useIframeMessageListener } from "@/app/(app)/create/hooks/use-iframe-sync"
import { FONTS } from "@/app/(app)/create/lib/fonts"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(app)/create/lib/search-params"
const THEME_STYLE_ELEMENT_ID = "design-system-theme-vars"
const MANAGED_BODY_CLASS_PREFIXES = ["style-", "base-color-"] as const
type RegistryThemeCssVars = NonNullable<
ReturnType<typeof buildRegistryTheme>["cssVars"]
>
function removeManagedBodyClasses(body: Element) {
for (const className of Array.from(body.classList)) {
if (
MANAGED_BODY_CLASS_PREFIXES.some((prefix) => className.startsWith(prefix))
) {
body.classList.remove(className)
}
}
}
function buildCssRule(selector: string, cssVars?: Record<string, string>) {
const declarations = Object.entries(cssVars ?? {})
.filter(([, value]) => Boolean(value))
.map(([key, value]) => ` --${key}: ${value};`)
.join("\n")
if (!declarations) {
return `${selector} {}\n`
}
return `${selector} {\n${declarations}\n}\n`
}
function buildThemeCssText(cssVars: RegistryThemeCssVars) {
return [
buildCssRule(":root", {
...(cssVars.theme ?? {}),
...(cssVars.light ?? {}),
}),
buildCssRule(".dark", cssVars.dark),
].join("\n")
}
export function DesignSystemProvider({
children,
}: {
children: React.ReactNode
}) {
const [searchParams, setSearchParams] = useDesignSystemSearchParams({
shallow: true, // No need to go through the server…
history: "replace", // …or push updates into the iframe history.
})
const [isReady, setIsReady] = React.useState(false)
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<string | null>(null)
const initialFontHeadingRef = React.useRef<string | null>(null)
React.useEffect(() => {
initialFontSansRef.current =
document.documentElement.style.getPropertyValue("--font-sans")
initialFontHeadingRef.current =
document.documentElement.style.getPropertyValue("--font-heading")
return () => {
removeManagedBodyClasses(document.body)
document.getElementById(THEME_STYLE_ELEMENT_ID)?.remove()
if (initialFontSansRef.current) {
document.documentElement.style.setProperty(
"--font-sans",
initialFontSansRef.current
)
} else {
document.documentElement.style.removeProperty("--font-sans")
}
if (initialFontHeadingRef.current) {
document.documentElement.style.setProperty(
"--font-heading",
initialFontHeadingRef.current
)
} else {
document.documentElement.style.removeProperty("--font-heading")
}
}
}, [])
const handleDesignSystemMessage = React.useCallback(
(nextParams: DesignSystemSearchParams) => {
setSearchParams(nextParams)
},
[setSearchParams]
)
useIframeMessageListener("design-system-params", handleDesignSystemMessage)
React.useEffect(() => {
if (style === "lyra" && radius !== "none") {
setSearchParams({ radius: "none" })
}
}, [style, radius, setSearchParams])
// Use useLayoutEffect for synchronous style updates to prevent flash.
React.useLayoutEffect(() => {
if (!style || !theme || !font || !baseColor) {
return
}
const body = document.body
// Iterate over a snapshot so removals do not affect traversal.
removeManagedBodyClasses(body)
body.classList.add(`style-${style}`, `base-color-${baseColor}`)
// Update font.
// 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) {
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,
fontHeading,
baseColor,
selectedFont,
selectedHeadingFont,
])
const registryTheme = React.useMemo(() => {
if (!baseColor || !theme || !menuAccent || !effectiveRadius) {
return null
}
const config: DesignSystemConfig = {
...DEFAULT_CONFIG,
baseColor,
theme,
chartColor,
menuAccent,
radius: effectiveRadius,
}
return buildRegistryTheme(config)
}, [baseColor, theme, chartColor, menuAccent, effectiveRadius])
// Use useLayoutEffect for synchronous CSS var updates.
React.useLayoutEffect(() => {
if (!registryTheme || !registryTheme.cssVars) {
return
}
let styleElement = document.getElementById(
THEME_STYLE_ELEMENT_ID
) as HTMLStyleElement | null
if (!styleElement) {
styleElement = document.createElement("style")
styleElement.id = THEME_STYLE_ELEMENT_ID
document.head.appendChild(styleElement)
}
styleElement.textContent = buildThemeCssText(registryTheme.cssVars)
}, [registryTheme])
// Handle menu color inversion by adding/removing dark class to elements with cn-menu-target.
// useLayoutEffect to apply classes synchronously before paint, avoiding flash.
React.useLayoutEffect(() => {
if (!menuColor) {
return
}
const isInvertedMenu =
menuColor === "inverted" || menuColor === "inverted-translucent"
const isTranslucentMenu =
menuColor === "default-translucent" ||
menuColor === "inverted-translucent"
let frameId = 0
const updateMenuElements = () => {
const allElements = document.querySelectorAll<HTMLElement>(
".cn-menu-target, [data-menu-translucent]"
)
if (allElements.length === 0) {
return
}
// Disable transitions while toggling classes.
allElements.forEach((element) => {
element.style.transition = "none"
})
allElements.forEach((element) => {
if (element.classList.contains("cn-menu-target")) {
if (isInvertedMenu) {
element.classList.add("dark")
} else {
element.classList.remove("dark")
}
}
// When translucent is enabled, move from data-attr to class so styles apply.
// When disabled, move back to a data-attr so the element stays queryable
// for future toggles without losing its identity as a menu element.
if (isTranslucentMenu) {
element.classList.add("cn-menu-translucent")
element.removeAttribute("data-menu-translucent")
} else if (element.classList.contains("cn-menu-translucent")) {
element.classList.remove("cn-menu-translucent")
element.setAttribute("data-menu-translucent", "")
}
})
// Force a reflow, then re-enable transitions.
void document.body.offsetHeight
allElements.forEach((element) => {
element.style.transition = ""
})
}
const scheduleMenuUpdate = () => {
if (frameId) {
return
}
frameId = window.requestAnimationFrame(() => {
frameId = 0
updateMenuElements()
})
}
// Update existing menu elements.
updateMenuElements()
// Watch for new menu elements being added to the DOM.
const observer = new MutationObserver(() => {
scheduleMenuUpdate()
})
observer.observe(document.body, {
childList: true,
subtree: true,
})
return () => {
observer.disconnect()
if (frameId) {
window.cancelAnimationFrame(frameId)
}
}
}, [menuColor])
if (!isReady) {
return null
}
return <>{children}</>
}

View File

@@ -1,158 +0,0 @@
"use client"
import * as React from "react"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerLabel,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { FONTS } from "@/app/(app)/create/lib/fonts"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(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,
}: {
label: string
param: "font" | "fontHeading"
fonts: readonly FontPickerOption[]
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const currentValue = param === "font" ? params.font : params.fontHeading
const handleFontChange = React.useCallback(
(value: string) => {
setParams({
[param]: value,
} as Partial<DesignSystemSearchParams>)
},
[param, setParams]
)
const currentFont = React.useMemo(
() => 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 pickerFonts =
param === "fontHeading"
? fonts.filter((font) => font.value !== "inherit")
: fonts
const groups = new Map<string, FontPickerOption[]>()
for (const font of pickerFonts) {
const existing = groups.get(font.type)
if (existing) {
existing.push(font)
continue
}
groups.set(font.type, [font])
}
return Array.from(groups.entries()).map(([type, items]) => ({
type,
label: `${type.charAt(0).toUpperCase()}${type.slice(1)}`,
items,
}))
}, [fonts, param])
return (
<div className="group/picker relative">
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="line-clamp-1 max-w-[80%] truncate text-sm font-medium text-foreground">
{displayFontName}
</div>
</div>
<div
className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5"
style={{
fontFamily:
currentFont?.font?.style.fontFamily ??
currentBodyFont?.font.style.fontFamily,
}}
>
Aa
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
className="max-h-96"
>
<PickerRadioGroup
value={currentValue}
onValueChange={handleFontChange}
>
{param === "fontHeading" ? (
<>
<PickerGroup>
<PickerRadioItem value="inherit" closeOnClick={isMobile}>
{inheritFontLabel}
</PickerRadioItem>
</PickerGroup>
<PickerSeparator />
</>
) : null}
{groupedFonts.map((group) => (
<PickerGroup key={group.type}>
<PickerLabel>{group.label}</PickerLabel>
{group.items.map((font) => (
<PickerRadioItem
key={font.value}
value={font.value}
closeOnClick={isMobile}
>
{font.name}
</PickerRadioItem>
))}
</PickerGroup>
))}
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param={param}
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -1,75 +0,0 @@
"use client"
import { lazy, Suspense } from "react"
import { SquareIcon } from "lucide-react"
import type { IconLibraryName } from "shadcn/icons"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
const IconLucide = lazy(() =>
import("@/registry/icons/icon-lucide").then((mod) => ({
default: mod.IconLucide,
}))
)
const IconTabler = lazy(() =>
import("@/registry/icons/icon-tabler").then((mod) => ({
default: mod.IconTabler,
}))
)
const IconHugeicons = lazy(() =>
import("@/registry/icons/icon-hugeicons").then((mod) => ({
default: mod.IconHugeicons,
}))
)
const IconPhosphor = lazy(() =>
import("@/registry/icons/icon-phosphor").then((mod) => ({
default: mod.IconPhosphor,
}))
)
const IconRemixicon = lazy(() =>
import("@/registry/icons/icon-remixicon").then((mod) => ({
default: mod.IconRemixicon,
}))
)
// Preload all icon renderer modules so switching libraries is instant.
// These warm the browser module cache; React.lazy resolves immediately
// for modules that are already loaded.
void import("@/registry/icons/icon-lucide")
void import("@/registry/icons/icon-tabler")
void import("@/registry/icons/icon-hugeicons")
void import("@/registry/icons/icon-phosphor")
void import("@/registry/icons/icon-remixicon")
export function IconPlaceholder({
...props
}: {
[K in IconLibraryName]: string
} & React.ComponentProps<"svg">) {
const [{ iconLibrary }] = useDesignSystemSearchParams()
const iconName = props[iconLibrary]
if (!iconName) {
return null
}
return (
<Suspense fallback={<SquareIcon {...props} />}>
{iconLibrary === "lucide" && <IconLucide name={iconName} {...props} />}
{iconLibrary === "tabler" && <IconTabler name={iconName} {...props} />}
{iconLibrary === "hugeicons" && (
<IconHugeicons name={iconName} {...props} />
)}
{iconLibrary === "phosphor" && (
<IconPhosphor name={iconName} {...props} />
)}
{iconLibrary === "remixicon" && (
<IconRemixicon name={iconName} {...props} />
)}
</Suspense>
)
}

View File

@@ -1,169 +0,0 @@
"use client"
import * as React from "react"
import { Menu02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { useTheme } from "next-themes"
import { useMounted } from "@/hooks/use-mounted"
import { type MenuColorValue } from "@/registry/config"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerLabel,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import {
isTranslucentMenuColor,
useDesignSystemSearchParams,
} from "@/app/(app)/create/lib/search-params"
type ColorChoice = "default" | "inverted"
type SurfaceChoice = "solid" | "translucent"
function getMenuColorValue(
color: ColorChoice,
translucent: boolean
): MenuColorValue {
if (color === "default") {
return translucent ? "default-translucent" : "default"
}
return translucent ? "inverted-translucent" : "inverted"
}
const MENU_OPTIONS: { value: MenuColorValue; label: string }[] = [
{ value: "default", label: "Default / Solid" },
{ value: "default-translucent", label: "Default / Translucent" },
{ value: "inverted", label: "Inverted / Solid" },
{ value: "inverted-translucent", label: "Inverted / Translucent" },
]
export function MenuColorPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const { resolvedTheme } = useTheme()
const mounted = useMounted()
const lastSolidMenuAccentRef = React.useRef(params.menuAccent)
const isDark = mounted && resolvedTheme === "dark"
const currentMenu = MENU_OPTIONS.find(
(menu) => menu.value === params.menuColor
)
const colorChoice: ColorChoice =
params.menuColor === "inverted" ||
params.menuColor === "inverted-translucent"
? "inverted"
: "default"
const surfaceChoice: SurfaceChoice =
params.menuColor === "default-translucent" ||
params.menuColor === "inverted-translucent"
? "translucent"
: "solid"
React.useEffect(() => {
if (surfaceChoice === "solid") {
lastSolidMenuAccentRef.current = params.menuAccent
}
}, [params.menuAccent, surfaceChoice])
const setColor = (color: ColorChoice) => {
const nextMenuColor = getMenuColorValue(
color,
surfaceChoice === "translucent"
)
setParams({
menuColor: nextMenuColor,
...(isTranslucentMenuColor(nextMenuColor) && { menuAccent: "subtle" }),
})
}
const setSurface = (choice: SurfaceChoice) => {
const isTranslucent = choice === "translucent"
const nextMenuColor = getMenuColorValue(colorChoice, isTranslucent)
setParams({
menuColor: nextMenuColor,
menuAccent: isTranslucent ? "subtle" : lastSolidMenuAccentRef.current,
})
}
return (
<div className="group/picker relative">
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Menu</div>
<div className="line-clamp-1 max-w-[80%] truncate text-sm font-medium text-foreground">
{currentMenu?.label}
</div>
</div>
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5">
<HugeiconsIcon
icon={Menu02Icon}
strokeWidth={2}
className="size-4"
/>
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
>
<PickerGroup>
<PickerLabel>Color</PickerLabel>
<PickerRadioGroup
value={colorChoice}
onValueChange={(value) => {
setColor(value as ColorChoice)
}}
>
<PickerRadioItem value="default" closeOnClick={isMobile}>
Default
</PickerRadioItem>
<PickerRadioItem
value="inverted"
closeOnClick={isMobile}
disabled={isDark}
>
Inverted
</PickerRadioItem>
</PickerRadioGroup>
</PickerGroup>
<PickerSeparator />
<PickerGroup>
<PickerLabel>Appearance</PickerLabel>
<PickerRadioGroup
value={surfaceChoice}
onValueChange={(value) => {
setSurface(value as SurfaceChoice)
}}
>
<PickerRadioItem value="solid" closeOnClick={isMobile}>
Solid
</PickerRadioItem>
<PickerRadioItem value="translucent" closeOnClick={isMobile}>
Translucent
</PickerRadioItem>
</PickerRadioGroup>
</PickerGroup>
</PickerContent>
</Picker>
<LockButton
param="menuColor"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -1,87 +0,0 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { cn } from "@/lib/utils"
import { Button } from "@/styles/base-nova/ui/button"
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
export const DARK_MODE_FORWARD_TYPE = "dark-mode-forward"
export function ModeSwitcher({
variant = "ghost",
className,
}: {
variant?: React.ComponentProps<typeof Button>["variant"]
className?: React.ComponentProps<typeof Button>["className"]
}) {
const { toggleTheme } = useThemeToggle()
return (
<Button
variant={variant}
size="icon"
className={cn("group/toggle extend-touch-target", className)}
onClick={toggleTheme}
id="mode-switcher-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4.5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 3l0 18" />
<path d="M12 9l4.65 -4.65" />
<path d="M12 14.3l7.37 -7.37" />
<path d="M12 19.6l8.85 -8.85" />
</svg>
<span className="sr-only">Toggle theme</span>
</Button>
)
}
export function DarkModeScript() {
return (
<Script
id="dark-mode-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward D key
document.addEventListener('keydown', function(e) {
if ((e.key === 'd' || e.key === 'D') && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: '${DARK_MODE_FORWARD_TYPE}',
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -1,200 +0,0 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/styles/base-nova/ui/dialog"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-nova/ui/drawer"
import { Field, FieldContent, FieldLabel } from "@/styles/base-nova/ui/field"
import { Input } from "@/styles/base-nova/ui/input"
import {
OPEN_PRESET_FORWARD_TYPE,
useOpenPreset,
} from "@/app/(app)/create/hooks/use-open-preset"
import { parsePresetInput } from "@/app/(app)/create/lib/parse-preset-input"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
const PRESET_EXAMPLE = "b2D0wqNxT"
const PRESET_TITLE = "Open Preset"
const PRESET_DESCRIPTION = "Paste a preset code to load a saved configuration."
export function OpenPreset({
className,
label = "Open Preset",
}: React.ComponentProps<typeof Button> & {
label?: string
}) {
const [input, setInput] = React.useState("")
const [, setParams] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const { open, setOpen } = useOpenPreset()
const nextPreset = React.useMemo(() => parsePresetInput(input), [input])
const isInvalid = input.trim().length > 0 && nextPreset === null
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setInput("")
}
},
[setOpen]
)
const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!nextPreset) {
return
}
setParams({ preset: nextPreset })
handleOpenChange(false)
},
[handleOpenChange, nextPreset, setParams]
)
const triggerClassName = cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)
const desktopTrigger = (
<Button variant="outline" className={triggerClassName} />
)
const fields = (
<Field data-invalid={isInvalid || undefined}>
<FieldLabel htmlFor="preset-code" className="sr-only">
Preset code
</FieldLabel>
<FieldContent>
<Input
id="preset-code"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={`${PRESET_EXAMPLE} or --preset ${PRESET_EXAMPLE}`}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
aria-invalid={isInvalid}
className="h-10 md:h-8"
/>
</FieldContent>
</Field>
)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>
<Button variant="outline" className={triggerClassName}>
{label}
</Button>
</DrawerTrigger>
<DrawerContent className="dark rounded-t-2xl!">
<DrawerHeader>
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
<DrawerDescription>{PRESET_DESCRIPTION}</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit}>
<div className="px-4 py-2">{fields}</div>
<DrawerFooter>
<Button type="submit" className="h-10" disabled={!nextPreset}>
Open
</Button>
<DrawerClose asChild>
<Button variant="outline" type="button" className="h-10">
Cancel
</Button>
</DrawerClose>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={desktopTrigger}>{label}</DialogTrigger>
<DialogContent className="dark">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{PRESET_TITLE}</DialogTitle>
<DialogDescription>{PRESET_DESCRIPTION}</DialogDescription>
</DialogHeader>
<div className="py-4">{fields}</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" type="button" />}>
Cancel
</DialogClose>
<Button type="submit" disabled={!nextPreset}>
Open
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function OpenPresetScript() {
return (
<Script
id="open-preset-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward O key.
document.addEventListener('keydown', function(e) {
if (e.key === 'o' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: '${OPEN_PRESET_FORWARD_TYPE}',
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -1,37 +0,0 @@
"use client"
import { Button } from "@/registry/new-york-v4/ui/button"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
const PREVIEW_ITEMS = [
{ label: "01", value: "preview-02" },
{ label: "02", value: "preview" },
]
export function PreviewSwitcher() {
const [params, setParams] = useDesignSystemSearchParams()
const isPreview =
params.item === "preview" || params.item.startsWith("preview-0")
if (!isPreview) {
return null
}
return (
<div className="dark absolute right-3 bottom-3 z-20 flex items-center gap-1 rounded-xl bg-card/90 p-1 shadow-xl backdrop-blur-xl">
{PREVIEW_ITEMS.map((item) => (
<Button
key={item.value}
variant="ghost"
size="sm"
data-active={params.item === item.value}
className="h-7 min-w-8 cursor-pointer rounded-lg px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground"
onClick={() => setParams({ item: item.value })}
>
{item.label}
</Button>
))}
</div>
)
}

View File

@@ -1,166 +0,0 @@
"use client"
import * as React from "react"
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/create/components/action-menu"
import {
REDO_FORWARD_TYPE,
UNDO_FORWARD_TYPE,
} from "@/app/(app)/create/components/history-buttons"
import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/create/components/mode-switcher"
import { PreviewSwitcher } from "@/app/(app)/create/components/preview-switcher"
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(app)/create/components/random-button"
import { sendToIframe } from "@/app/(app)/create/hooks/use-iframe-sync"
import { OPEN_PRESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-open-preset"
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
import {
serializeDesignSystemSearchParams,
useDesignSystemSearchParams,
} from "@/app/(app)/create/lib/search-params"
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
export function Preview() {
const [params] = useDesignSystemSearchParams()
const iframeRef = React.useRef<HTMLIFrameElement>(null)
React.useEffect(() => {
const iframe = iframeRef.current
if (!iframe) {
return
}
const sendParams = () => {
sendToIframe(iframe, "design-system-params", params)
}
if (iframe.contentWindow) {
sendParams()
}
iframe.addEventListener("load", sendParams)
return () => {
iframe.removeEventListener("load", sendParams)
}
}, [params])
React.useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const iframeWindow = iframeRef.current?.contentWindow
if (
!iframeWindow ||
event.origin !== window.location.origin ||
event.source !== iframeWindow ||
!event.data ||
typeof event.data !== "object"
) {
return
}
const type = event.data.type
if (type === CMD_K_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "k",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RANDOMIZE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "r",
bubbles: true,
cancelable: true,
})
)
} else if (type === OPEN_PRESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "o",
bubbles: true,
cancelable: true,
})
)
} else if (type === UNDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === REDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
shiftKey: true,
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "R",
shiftKey: true,
bubbles: true,
cancelable: true,
})
)
} else if (type === DARK_MODE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "d",
bubbles: true,
cancelable: true,
})
)
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [])
const iframeSrc = React.useMemo(() => {
// The iframe src needs to include the serialized design system params
// for the initial load, but not be reactive to them as it would cause
// full-iframe reloads on every param change (flashes & loss of state).
// Further updates of the search params will be sent to the iframe
// via a postMessage channel, for it to sync its own history onto the host's.
return serializeDesignSystemSearchParams(
`/preview/${params.base}/${params.item}`,
params
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.base, params.item])
return (
<div className="relative flex flex-1 flex-col justify-center overflow-hidden rounded-2xl ring ring-foreground/10 md:ring-muted dark:ring-foreground/10">
<div className="relative z-0 mx-auto flex w-full flex-1 flex-col overflow-hidden">
<div className="absolute inset-0 bg-muted dark:bg-muted/30" />
<iframe
key={params.base + params.item}
ref={iframeRef}
src={iframeSrc}
className="z-10 size-full flex-1"
title="Preview"
/>
</div>
<PreviewSwitcher />
</div>
)
}

View File

@@ -1,375 +0,0 @@
"use client"
import * as React from "react"
import { Copy01Icon, Globe02Icon, Tick02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@/lib/utils"
import { useConfig } from "@/hooks/use-config"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { BASES, type BaseName } from "@/registry/config"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/styles/base-nova/ui/dialog"
import {
Field,
FieldContent,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
} from "@/styles/base-nova/ui/field"
import { RadioGroup, RadioGroupItem } from "@/styles/base-nova/ui/radio-group"
import { Switch } from "@/styles/base-nova/ui/switch"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/styles/base-nova/ui/tabs"
import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(app)/create/lib/search-params"
import {
getFramework,
getTemplateValue,
NO_MONOREPO_FRAMEWORKS,
TEMPLATES,
} from "@/app/(app)/create/lib/templates"
const TURBOREPO_LOGO =
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Turborepo</title><path d="M11.9906 4.1957c-4.2998 0-7.7981 3.501-7.7981 7.8043s3.4983 7.8043 7.7981 7.8043c4.2999 0 7.7982-3.501 7.7982-7.8043s-3.4983-7.8043-7.7982-7.8043m0 11.843c-2.229 0-4.0356-1.8079-4.0356-4.0387s1.8065-4.0387 4.0356-4.0387S16.0262 9.7692 16.0262 12s-1.8065 4.0388-4.0356 4.0388m.6534-13.1249V0C18.9726.3386 24 5.5822 24 12s-5.0274 11.66-11.356 12v-2.9139c4.7167-.3372 8.4516-4.2814 8.4516-9.0861s-3.735-8.749-8.4516-9.0861M5.113 17.9586c-1.2502-1.4446-2.0562-3.2845-2.2-5.3046H0c.151 2.8266 1.2808 5.3917 3.051 7.3668l2.0606-2.0622zM11.3372 24v-2.9139c-2.02-.1439-3.8584-.949-5.3019-2.2018l-2.0606 2.0623c1.975 1.773 4.538 2.9022 7.361 3.0534z"/></svg>'
const ORIGIN = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000"
const IS_LOCAL_DEV = ORIGIN.includes("localhost")
const SHADCN_VERSION = process.env.NEXT_PUBLIC_RC ? "@rc" : "@latest"
const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"] as const
type PackageManager = (typeof PACKAGE_MANAGERS)[number]
export function ProjectForm({
className,
}: React.ComponentProps<typeof Button>) {
const [open, setOpen] = React.useState(false)
const [params, setParams] = useDesignSystemSearchParams()
const presetCode = usePresetCode()
const [config, setConfig] = useConfig()
const [hasCopied, setHasCopied] = React.useState(false)
const packageManager = (config.packageManager || "pnpm") as PackageManager
const framework = React.useMemo(
() => getFramework(params.template ?? "next"),
[params.template]
)
const isMonorepo = React.useMemo(
() => params.template?.endsWith("-monorepo") ?? false,
[params.template]
)
const hasMonorepo = !NO_MONOREPO_FRAMEWORKS.includes(
framework as (typeof NO_MONOREPO_FRAMEWORKS)[number]
)
const commands = React.useMemo(() => {
const presetFlag = ` --preset ${presetCode}`
const baseFlag = params.base !== "radix" ? ` --base ${params.base}` : ""
const templateFlag = ` --template ${framework}`
const monorepoFlag = isMonorepo ? " --monorepo" : ""
const rtlFlag = params.rtl ? " --rtl" : ""
const flags = `${presetFlag}${baseFlag}${templateFlag}${monorepoFlag}${rtlFlag}`
return IS_LOCAL_DEV
? {
pnpm: `shadcn init${flags}`,
npm: `shadcn init${flags}`,
yarn: `shadcn init${flags}`,
bun: `shadcn init${flags}`,
}
: {
pnpm: `pnpm dlx shadcn${SHADCN_VERSION} init${flags}`,
npm: `npx shadcn${SHADCN_VERSION} init${flags}`,
yarn: `yarn dlx shadcn${SHADCN_VERSION} init${flags}`,
bun: `bunx --bun shadcn${SHADCN_VERSION} init${flags}`,
}
}, [framework, isMonorepo, params.base, params.rtl, presetCode])
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<string, string> = {
command,
}
if (params.template) {
properties.template = params.template
}
copyToClipboardWithMeta(command, {
name: "copy_npm_command",
properties,
})
setHasCopied(true)
}, [command, params.template])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button className={cn(className)} />}>
Create Project
</DialogTrigger>
<DialogContent className="dark no-scrollbar max-h-[calc(100svh-2rem)] overflow-y-auto rounded-2xl p-6 shadow-xl **:data-[slot=field-separator]:h-2 sm:max-w-sm">
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
<DialogDescription>
Pick a template and configure your project.
</DialogDescription>
</DialogHeader>
<div>
<FieldGroup>
<FieldSeparator className="-mx-6" />
<Field className="-mt-2 gap-3">
<FieldLabel>Template</FieldLabel>
<TemplateGrid template={params.template} setParams={setParams} />
</Field>
<FieldSeparator className="-mx-6" />
<Field className="-mt-2">
<FieldLabel>Base</FieldLabel>
<BaseGrid base={params.base} setParams={setParams} />
</Field>
<FieldSeparator className="-mx-6" />
<FieldSet>
<FieldLegend variant="label" className="sr-only">
Options
</FieldLegend>
<Field
orientation="horizontal"
data-disabled={hasMonorepo ? undefined : "true"}
>
<FieldLabel htmlFor="monorepo">
<span
className="size-4 text-neutral-100 [&_svg]:size-4 [&_svg]:fill-current"
dangerouslySetInnerHTML={{
__html: TURBOREPO_LOGO,
}}
/>
Create a monorepo
</FieldLabel>
<Switch
id="monorepo"
checked={params.template?.endsWith("-monorepo") ?? false}
disabled={!hasMonorepo}
onCheckedChange={(checked) => {
const framework = getFramework(params.template ?? "next")
setParams({
template: getTemplateValue(
framework,
checked === true
) as typeof params.template,
})
}}
/>
</Field>
<FieldSeparator className="-mx-6" />
<Field orientation="horizontal">
<FieldLabel htmlFor="rtl">
<HugeiconsIcon icon={Globe02Icon} className="size-4" />
Enable RTL support
</FieldLabel>
<Switch
id="rtl"
checked={params.rtl}
onCheckedChange={(checked) =>
setParams({ rtl: checked === true })
}
/>
</Field>
</FieldSet>
</FieldGroup>
</div>
<DialogFooter className="-mx-6 -mb-6 min-w-0">
<div className="flex w-full min-w-0 flex-col gap-3">
<Tabs
value={packageManager}
onValueChange={(value) => {
setConfig((prev) => ({
...prev,
packageManager: value as PackageManager,
}))
}}
className="min-w-0 gap-0 overflow-hidden rounded-xl border-0 ring-1 ring-border"
>
<div className="flex items-center gap-2 py-1 pr-1.5 pl-1">
<TabsList className="bg-transparent font-mono">
{PACKAGE_MANAGERS.map((manager) => {
return (
<TabsTrigger
key={manager}
value={manager}
className="py-0 leading-none data-[state=active]:shadow-none"
>
{manager}
</TabsTrigger>
)
})}
</TabsList>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
>
{hasCopied ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
{Object.entries(commands).map(([key, cmd]) => {
return (
<TabsContent key={key} value={key}>
<div className="relative overflow-hidden border-t bg-popover p-3">
<div className="no-scrollbar overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">
{cmd}
</code>
</div>
</div>
</TabsContent>
)
})}
</Tabs>
<Button onClick={handleCopy} className="h-9 w-full">
{hasCopied ? "Copied" : "Copy Command"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
const TemplateGrid = React.memo(function TemplateGrid({
template,
setParams,
}: {
template: DesignSystemSearchParams["template"]
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
}) {
const isMonorepo = template?.endsWith("-monorepo") ?? false
const framework = getFramework(template ?? "next")
const handleTemplateChange = React.useCallback(
(value: string) => {
setParams({
template: getTemplateValue(
value,
isMonorepo
) as DesignSystemSearchParams["template"],
})
},
[isMonorepo, setParams]
)
return (
<RadioGroup
value={framework}
onValueChange={handleTemplateChange}
className="grid grid-cols-2 gap-2"
>
{TEMPLATES.map((item) => (
<FieldLabel
key={item.value}
htmlFor={`template-${item.value}`}
className="block w-full"
>
<Field
orientation="horizontal"
className="w-full rounded-md transition-colors duration-150 hover:bg-neutral-700/45"
>
<FieldContent className="flex flex-row items-center gap-2 px-2.5 py-1.5">
<div
className="size-4 text-neutral-100 [&_svg]:size-4 *:[svg]:text-neutral-100!"
dangerouslySetInnerHTML={{
__html: item.logo,
}}
></div>
<FieldTitle>{item.title}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={item.value}
id={`template-${item.value}`}
className="sr-only absolute"
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
)
})
const BaseGrid = React.memo(function BaseGrid({
base,
setParams,
}: {
base: DesignSystemSearchParams["base"]
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
}) {
const handleBaseChange = React.useCallback(
(value: string) => {
setParams({ base: value as BaseName })
},
[setParams]
)
return (
<RadioGroup
value={base}
onValueChange={handleBaseChange}
aria-label="Base"
className="grid grid-cols-2 gap-2"
>
{BASES.map((item) => (
<FieldLabel
key={item.name}
htmlFor={`base-${item.name}`}
className="block w-full"
>
<Field
orientation="horizontal"
className="w-full rounded-md transition-colors duration-150 hover:bg-neutral-700/45"
>
<FieldContent className="flex flex-row items-center gap-2 px-2.5 py-1.5">
<div
className="size-4 text-neutral-100 [&_svg]:size-4 *:[svg]:text-neutral-100!"
dangerouslySetInnerHTML={{
__html: item.meta?.logo ?? "",
}}
/>
<FieldTitle>{item.title}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={item.name}
id={`base-${item.name}`}
className="sr-only absolute"
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
)
})

View File

@@ -1,57 +0,0 @@
"use client"
import * as React from "react"
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 "@/styles/base-nova/ui/button"
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
export function V0Button({ className }: { className?: string }) {
const [params] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const isMounted = useMounted()
const url = React.useMemo(() => {
const searchParams = new URLSearchParams()
if (params.preset) {
searchParams.set("preset", params.preset)
}
searchParams.set("base", params.base)
return `${process.env.NEXT_PUBLIC_APP_URL}/init/v0?${searchParams.toString()}`
}, [params.preset, params.base])
const title = React.useMemo(() => {
return params.base && params.style
? `New ${params.base}-${params.style} project`
: "New Project"
}, [params.base, params.style])
if (!isMounted) {
return <Skeleton className="h-8 w-24 rounded-lg" />
}
return (
<Button
nativeButton={false}
role="link"
variant={isMobile ? "default" : "outline"}
className={cn("h-[31px] gap-1 rounded-lg", className)}
render={
<a
href={`${process.env.NEXT_PUBLIC_V0_URL}/chat/api/open?url=${url}&title=${title}`}
target="_blank"
/>
}
>
<span>Open in</span>
<Icons.v0 className="size-5" data-icon="inline-end" />
</Button>
)
}

View File

@@ -1,11 +0,0 @@
"use client"
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
// Returns the canonical preset code derived from the current search params.
export function usePresetCode() {
const [params] = useDesignSystemSearchParams()
return getPresetCode(params)
}

View File

@@ -1,81 +0,0 @@
"use client"
import * as React from "react"
import useSWR from "swr"
const OPEN_PRESET_KEY = "create:open-preset-open"
export const OPEN_PRESET_FORWARD_TYPE = "open-preset-forward"
function isEditableTarget(target: EventTarget | null) {
return (
(target instanceof HTMLElement && target.isContentEditable) ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
)
}
export function useOpenPreset() {
const { data: open = false, mutate: setOpenData } = useSWR<boolean>(
OPEN_PRESET_KEY,
{
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
)
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
void setOpenData(nextOpen, { revalidate: false })
},
[setOpenData]
)
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (
e.key === "o" &&
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey
) {
if (isEditableTarget(e.target)) {
return
}
e.preventDefault()
void setOpenData(true, { revalidate: false })
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [setOpenData])
return {
open,
setOpen: handleOpenChange,
}
}
export function useOpenPresetTrigger() {
const { mutate: setOpenData } = useSWR<boolean>(OPEN_PRESET_KEY, {
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
})
const openPreset = React.useCallback(() => {
void setOpenData(true, { revalidate: false })
}, [setOpenData])
return {
openPreset,
}
}

View File

@@ -1,181 +0,0 @@
"use client"
import * as React from "react"
import {
BASE_COLORS,
getThemesForBaseColor,
iconLibraries,
MENU_ACCENTS,
MENU_COLORS,
RADII,
STYLES,
type FontHeadingValue,
} from "@/registry/config"
import { useLocks } from "@/app/(app)/create/hooks/use-locks"
import { FONTS } from "@/app/(app)/create/lib/fonts"
import {
applyBias,
RANDOMIZE_BIASES,
type RandomizeContext,
} from "@/app/(app)/create/lib/randomize-biases"
import {
isTranslucentMenuColor,
useDesignSystemSearchParams,
} from "@/app/(app)/create/lib/search-params"
function randomItem<T>(array: readonly T[]): T {
return array[Math.floor(Math.random() * array.length)]
}
export function useRandom() {
const { locks } = useLocks()
const [params, setParams] = useDesignSystemSearchParams()
const paramsRef = React.useRef(params)
React.useEffect(() => {
paramsRef.current = params
}, [params])
const randomize = React.useCallback(() => {
const selectedStyle = locks.has("style")
? paramsRef.current.style
: randomItem(STYLES).name
const context: RandomizeContext = {
style: selectedStyle,
}
const availableBaseColors = applyBias(
BASE_COLORS,
context,
RANDOMIZE_BIASES.baseColors
)
const baseColor = locks.has("baseColor")
? paramsRef.current.baseColor
: randomItem(availableBaseColors).name
context.baseColor = baseColor
const availableThemes = getThemesForBaseColor(baseColor)
const availableFonts = applyBias(FONTS, context, RANDOMIZE_BIASES.fonts)
const availableRadii = applyBias(RADII, context, RANDOMIZE_BIASES.radius)
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
const selectedIconLibrary = locks.has("iconLibrary")
? paramsRef.current.iconLibrary
: randomItem(Object.values(iconLibraries)).name
const lockedMenuAccent = locks.has("menuAccent")
? paramsRef.current.menuAccent
: undefined
const availableMenuColors =
!locks.has("menuColor") && lockedMenuAccent === "bold"
? MENU_COLORS.filter((menuColor) => {
return !isTranslucentMenuColor(menuColor.value)
})
: MENU_COLORS
const selectedMenuColor = locks.has("menuColor")
? paramsRef.current.menuColor
: randomItem(availableMenuColors).value
const selectedMenuAccent =
locks.has("menuAccent") || isTranslucentMenuColor(selectedMenuColor)
? paramsRef.current.menuAccent === "bold" &&
isTranslucentMenuColor(selectedMenuColor)
? "subtle"
: paramsRef.current.menuAccent
: randomItem(MENU_ACCENTS).value
context.radius = selectedRadius
const nextParams = {
style: selectedStyle,
baseColor,
theme: selectedTheme,
chartColor: selectedChartColor,
iconLibrary: selectedIconLibrary,
font: selectedFont,
fontHeading: selectedFontHeading,
menuAccent: selectedMenuAccent,
menuColor: selectedMenuColor,
radius: selectedRadius,
}
// Keep the ref in sync so rapid repeats use the latest randomized state
// even before the URL state finishes committing.
paramsRef.current = {
...paramsRef.current,
...nextParams,
}
setParams(nextParams)
}, [setParams, locks])
const randomizeRef = React.useRef(randomize)
React.useEffect(() => {
randomizeRef.current = randomize
}, [randomize])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "r" && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return
}
e.preventDefault()
randomizeRef.current()
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [])
return { randomize }
}

View File

@@ -1,103 +0,0 @@
"use client"
import * as React from "react"
import useSWR from "swr"
import { DEFAULT_CONFIG, PRESETS } from "@/registry/config"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
const RESET_DIALOG_KEY = "create:reset-dialog-open"
export const RESET_FORWARD_TYPE = "reset-forward"
export function useReset() {
const [params, setParams] = useDesignSystemSearchParams()
const { data: showResetDialog = false, mutate: setShowResetDialogData } =
useSWR<boolean>(RESET_DIALOG_KEY, {
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
})
const reset = React.useCallback(() => {
const preset =
PRESETS.find(
(preset) => preset.base === params.base && preset.style === params.style
) ?? DEFAULT_CONFIG
setParams({
base: params.base,
style: params.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,
template: DEFAULT_CONFIG.template,
item: params.item,
})
}, [setParams, params.base, params.style, params.item])
const handleShowResetDialogChange = React.useCallback(
(open: boolean) => {
void setShowResetDialogData(open, { revalidate: false })
},
[setShowResetDialogData]
)
const confirmReset = React.useCallback(() => {
reset()
void setShowResetDialogData(false, { revalidate: false })
}, [reset, setShowResetDialogData])
const showResetDialogRef = React.useRef(showResetDialog)
React.useEffect(() => {
showResetDialogRef.current = showResetDialog
}, [showResetDialog])
const confirmResetRef = React.useRef(confirmReset)
React.useEffect(() => {
confirmResetRef.current = confirmReset
}, [confirmReset])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "R" && e.shiftKey && !e.metaKey && !e.ctrlKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return
}
e.preventDefault()
// If the dialog is already open, confirm the reset.
if (showResetDialogRef.current) {
confirmResetRef.current()
return
}
handleShowResetDialogChange(true)
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [handleShowResetDialogChange])
return {
reset,
showResetDialog,
setShowResetDialog: handleShowResetDialogChange,
confirmReset,
}
}

View File

@@ -1,18 +0,0 @@
import { Suspense } from "react"
import { HistoryProvider } from "@/app/(app)/create/hooks/use-history"
import { LocksProvider } from "@/app/(app)/create/hooks/use-locks"
export default function CreateLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<LocksProvider>
<Suspense>
<HistoryProvider>{children}</HistoryProvider>
</Suspense>
</LocksProvider>
)
}

View File

@@ -1,233 +0,0 @@
import {
DM_Sans,
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<typeof Inter>
const geistSans = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
})
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 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",
})
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",
})
const robotoSlab = Roboto_Slab({
subsets: ["latin"],
variable: "--font-roboto-slab",
})
const merriweather = Merriweather({
subsets: ["latin"],
variable: "--font-merriweather",
})
const lora = Lora({
subsets: ["latin"],
variable: "--font-lora",
})
const playfairDisplay = Playfair_Display({
subsets: ["latin"],
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<FontName, PreviewFont>
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 = [
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]

View File

@@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest"
import { parsePresetInput } from "./parse-preset-input"
describe("parsePresetInput", () => {
it("accepts a raw preset code", () => {
expect(parsePresetInput("b0")).toBe("b0")
})
it("accepts a --preset flag", () => {
expect(parsePresetInput(" --preset b0 ")).toBe("b0")
})
it("rejects invalid preset input", () => {
expect(parsePresetInput("open sesame")).toBeNull()
})
})

View File

@@ -1,15 +0,0 @@
import { isPresetCode } from "shadcn/preset"
const PRESET_FLAG_PATTERN = /^--preset\b\s+(.+)$/i
export function parsePresetInput(value: string) {
const input = value.trim()
if (!input) {
return null
}
const preset = input.match(PRESET_FLAG_PATTERN)?.[1]?.trim() ?? input
return isPresetCode(preset) ? preset : null
}

View File

@@ -1,34 +0,0 @@
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<PresetConfig> = {
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)
}

View File

@@ -1,34 +0,0 @@
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",
})
})
})

View File

@@ -1,30 +0,0 @@
import { V1_CHART_COLOR_MAP, type PresetConfig } from "shadcn/preset"
import { type ChartColorName, type FontHeadingValue } from "@/registry/config"
type SearchParamsLike = Pick<URLSearchParams, "get" | "has">
export function resolvePresetOverrides(
searchParams: SearchParamsLike,
decoded: Pick<PresetConfig, "theme" | "chartColor" | "fontHeading">
) {
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,
}
}

View File

@@ -1,108 +0,0 @@
import type {
BaseColor,
BaseColorName,
Radius,
StyleName,
Theme,
ThemeName,
} from "@/registry/config"
import { type FONTS } from "./fonts"
export type RandomizeContext = {
style?: StyleName
baseColor?: BaseColorName
theme?: ThemeName
chartColor?: string
iconLibrary?: string
font?: string
menuAccent?: string
menuColor?: string
radius?: string
}
export type BiasFilter<T> = (
items: readonly T[],
context: RandomizeContext
) => readonly T[]
export type RandomizeBiases = {
baseColors?: BiasFilter<BaseColor>
chartColors?: BiasFilter<Theme>
fonts?: BiasFilter<(typeof FONTS)[number]>
radius?: BiasFilter<Radius>
}
// Theme → chart color pairings for randomization.
const CHART_COLOR_PAIRINGS: Record<string, string[]> = {
red: ["teal", "sky"],
orange: ["teal", "blue"],
amber: ["cyan", "indigo"],
yellow: ["sky", "violet"],
lime: ["indigo", "pink"],
green: ["purple", "rose"],
emerald: ["purple", "red"],
teal: ["fuchsia", "red"],
cyan: ["rose", "amber"],
sky: ["red", "yellow"],
blue: ["orange", "yellow"],
indigo: ["amber", "yellow"],
violet: ["yellow", "lime"],
purple: ["green", "lime"],
fuchsia: ["lime", "teal"],
pink: ["green", "cyan"],
rose: ["emerald", "sky"],
}
/**
* Configuration for randomization biases.
* Add biases here to influence random selection based on context.
*/
export const RANDOMIZE_BIASES: RandomizeBiases = {
baseColors: (baseColors) => {
return baseColors.filter((c) => c.name !== "gray")
},
fonts: (fonts, context) => {
// When style is lyra, only use mono fonts.
if (context.style === "lyra") {
return fonts.filter((font) => font.value === "jetbrains-mono")
}
return fonts
},
radius: (radii, context) => {
// When style is lyra, always use "none" radius.
if (context.style === "lyra") {
return radii.filter((radius) => radius.name === "none")
}
return radii
},
chartColors: (chartColors, context) => {
// When theme has a pairing, restrict chart colors to the paired values.
const pairing = context.theme ? CHART_COLOR_PAIRINGS[context.theme] : null
if (pairing) {
const filtered = chartColors.filter((c) => pairing.includes(c.name))
if (filtered.length > 0) {
return filtered
}
}
return chartColors
},
}
/**
* Applies biases to a list of items based on the current context.
*/
export function applyBias<T>(
items: readonly T[],
context: RandomizeContext,
biasFilter?: BiasFilter<T>
): readonly T[] {
if (!biasFilter) {
return items
}
return biasFilter(items, context)
}

View File

@@ -1,309 +0,0 @@
import * as React from "react"
import { useSearchParams } from "next/navigation"
import { useQueryStates } from "nuqs"
import {
createLoader,
createSerializer,
parseAsBoolean,
parseAsInteger,
parseAsString,
parseAsStringLiteral,
type inferParserType,
type Options,
} from "nuqs/server"
import { decodePreset, isPresetCode } from "shadcn/preset"
import {
BASE_COLORS,
BASES,
DEFAULT_CONFIG,
getThemesForBaseColor,
iconLibraries,
MENU_ACCENTS,
MENU_COLORS,
RADII,
STYLES,
THEMES,
type BaseColorName,
type BaseName,
type ChartColorName,
type FontHeadingValue,
type FontValue,
type IconLibraryName,
type MenuAccentValue,
type MenuColorValue,
type RadiusValue,
type StyleName,
type ThemeName,
} from "@/registry/config"
import { FONTS } from "@/app/(app)/create/lib/fonts"
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
import { resolvePresetOverrides } from "@/app/(app)/create/lib/preset-query"
const designSystemSearchParams = {
preset: parseAsString.withDefault("b0"),
base: parseAsStringLiteral<BaseName>(BASES.map((b) => b.name)).withDefault(
DEFAULT_CONFIG.base
),
item: parseAsString.withDefault("preview-02").withOptions({ shallow: true }),
iconLibrary: parseAsStringLiteral<IconLibraryName>(
Object.values(iconLibraries).map((i) => i.name)
).withDefault(DEFAULT_CONFIG.iconLibrary),
style: parseAsStringLiteral<StyleName>(STYLES.map((s) => s.name)).withDefault(
DEFAULT_CONFIG.style
),
theme: parseAsStringLiteral<ThemeName>(THEMES.map((t) => t.name)).withDefault(
DEFAULT_CONFIG.theme
),
chartColor: parseAsStringLiteral<ChartColorName>(
THEMES.map((t) => t.name)
).withDefault(DEFAULT_CONFIG.chartColor ?? "neutral"),
font: parseAsStringLiteral<FontValue>(FONTS.map((f) => f.value)).withDefault(
DEFAULT_CONFIG.font
),
fontHeading: parseAsStringLiteral<FontHeadingValue>([
"inherit",
...FONTS.map((f) => f.value),
]).withDefault(DEFAULT_CONFIG.fontHeading),
baseColor: parseAsStringLiteral<BaseColorName>(
BASE_COLORS.map((b) => b.name)
).withDefault(DEFAULT_CONFIG.baseColor),
menuAccent: parseAsStringLiteral<MenuAccentValue>(
MENU_ACCENTS.map((a) => a.value)
).withDefault(DEFAULT_CONFIG.menuAccent),
menuColor: parseAsStringLiteral<MenuColorValue>(
MENU_COLORS.map((m) => m.value)
).withDefault(DEFAULT_CONFIG.menuColor),
radius: parseAsStringLiteral<RadiusValue>(
RADII.map((r) => r.name)
).withDefault("default"),
template: parseAsStringLiteral([
"next",
"next-monorepo",
"start",
"start-monorepo",
"react-router",
"react-router-monorepo",
"vite",
"vite-monorepo",
"astro",
"astro-monorepo",
"laravel",
] as const).withDefault("next"),
rtl: parseAsBoolean.withDefault(false),
size: parseAsInteger.withDefault(100),
custom: parseAsBoolean.withDefault(false),
}
// Design system param keys that get encoded into the preset code.
const DESIGN_SYSTEM_KEYS = [
"style",
"baseColor",
"theme",
"chartColor",
"iconLibrary",
"font",
"fontHeading",
"radius",
"menuAccent",
"menuColor",
] as const
function normalizeFontHeading(
font: FontValue,
fontHeading: FontHeadingValue
): FontHeadingValue {
// Persist "same as body" as an explicit inherit sentinel so the body font
// can change later without freezing headings to a concrete previous value.
return fontHeading === font ? "inherit" : fontHeading
}
// Non-design-system keys that get passed through as-is.
// `base` is not encoded in preset codes — it's an architectural choice, not visual.
const NON_DESIGN_SYSTEM_KEYS = [
"base",
"item",
"preset",
"template",
"rtl",
"size",
"custom",
] as const
export const loadDesignSystemSearchParams = createLoader(
designSystemSearchParams
)
export const serializeDesignSystemSearchParams = createSerializer(
designSystemSearchParams
)
export type DesignSystemSearchParams = inferParserType<
typeof designSystemSearchParams
>
export function isTranslucentMenuColor(
menuColor?: MenuColorValue | null
): menuColor is "default-translucent" | "inverted-translucent" {
return (
menuColor === "default-translucent" || menuColor === "inverted-translucent"
)
}
function normalizePartialDesignSystemParams(
params: Partial<DesignSystemSearchParams>
): Partial<DesignSystemSearchParams> {
if (
params.menuAccent === "bold" &&
isTranslucentMenuColor(params.menuColor ?? undefined)
) {
return {
...params,
menuAccent: "subtle",
}
}
return params
}
function normalizeDesignSystemParams(
params: DesignSystemSearchParams
): DesignSystemSearchParams {
let result = {
...params,
fontHeading: normalizeFontHeading(params.font, params.fontHeading),
}
// Validate theme and chartColor against baseColor.
if (result.baseColor) {
const available = getThemesForBaseColor(result.baseColor)
const themeValid = available.some((t) => t.name === result.theme)
const chartColorValid = available.some((t) => t.name === result.chartColor)
if (!themeValid || !chartColorValid) {
const fallback = (available[0]?.name ?? result.baseColor) as ThemeName
result = {
...result,
...(!themeValid && { theme: fallback }),
...(!chartColorValid && { chartColor: fallback as ChartColorName }),
}
}
}
if (
result.menuAccent === "bold" &&
isTranslucentMenuColor(result.menuColor)
) {
return {
...result,
menuAccent: "subtle",
}
}
return result
}
// If preset param exists, decode it and overlay on raw params.
// V1 presets don't encode chartColor — fall back to the colored
// theme that base-color themes originally borrowed charts from.
type SearchParamsLike = Pick<URLSearchParams, "get" | "has">
function resolvePresetParams(
rawParams: DesignSystemSearchParams,
searchParams: SearchParamsLike
) {
if (rawParams.preset && isPresetCode(rawParams.preset)) {
const decoded = decodePreset(rawParams.preset)
if (decoded) {
const presetOverrides = resolvePresetOverrides(searchParams, decoded)
return normalizeDesignSystemParams({
...decoded,
...presetOverrides,
base: rawParams.base,
item: rawParams.item,
preset: rawParams.preset,
template: rawParams.template,
rtl: rawParams.rtl,
size: rawParams.size,
custom: rawParams.custom,
})
}
}
return normalizeDesignSystemParams(rawParams)
}
// Wraps nuqs useQueryStates with transparent preset encoding/decoding.
// - Reads: if ?preset=CODE is in the URL, decodes it and returns individual values.
// - Writes: when design system params are set, encodes them into a preset code.
export function useDesignSystemSearchParams(options: Options = {}) {
const searchParams = useSearchParams()
const [rawParams, rawSetParams] = useQueryStates(designSystemSearchParams, {
shallow: false,
history: "push",
...options,
})
const params = React.useMemo(
() => resolvePresetParams(rawParams, searchParams),
[rawParams, searchParams]
)
// Use ref so setParams callback stays stable across renders.
const paramsRef = React.useRef(params)
React.useEffect(() => {
paramsRef.current = params
}, [params])
type RawSetParamsInput = Parameters<typeof rawSetParams>[0]
const setParams = React.useCallback(
(
updates:
| Partial<DesignSystemSearchParams>
| ((
old: DesignSystemSearchParams
) => Partial<DesignSystemSearchParams>),
setOptions?: Options
) => {
const resolvedUpdates = normalizePartialDesignSystemParams(
typeof updates === "function" ? updates(paramsRef.current) : updates
)
const hasDesignSystemUpdate = DESIGN_SYSTEM_KEYS.some(
(key) => key in resolvedUpdates
)
if (!hasDesignSystemUpdate) {
// No design system change, pass through directly.
return rawSetParams(resolvedUpdates as RawSetParamsInput, setOptions)
}
// Merge current decoded values with updates.
const merged = normalizeDesignSystemParams({
...paramsRef.current,
...resolvedUpdates,
})
// Encode design system fields into a preset code.
// Cast needed: merged values may include null from nuqs resets,
// but encodePreset handles missing values by falling back to defaults.
const code = getPresetCode(merged)
// Build update: set preset, clear individual DS params from URL.
const rawUpdate: Record<string, unknown> = { preset: code }
for (const key of DESIGN_SYSTEM_KEYS) {
rawUpdate[key] = null
}
// Pass through non-DS params that were explicitly in the update.
for (const key of NON_DESIGN_SYSTEM_KEYS) {
if (key in resolvedUpdates) {
rawUpdate[key] = (resolvedUpdates as Record<string, unknown>)[key]
}
}
return rawSetParams(rawUpdate as RawSetParamsInput, setOptions)
},
[rawSetParams]
)
return [params, setParams] as const
}

View File

@@ -1,115 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { DEFAULT_CONFIG } from "@/registry/config"
import { buildV0Payload } from "@/app/(app)/create/lib/v0"
vi.mock("shadcn/schema", async () => {
return await vi.importActual("shadcn/schema")
})
vi.mock("shadcn/utils", async () => {
const actual = (await vi.importActual("shadcn/utils")) as {
transformFont: unknown
}
return {
transformFont: actual.transformFont,
transformIcons: async ({ sourceFile }: { sourceFile: unknown }) =>
sourceFile,
transformMenu: async ({ sourceFile }: { sourceFile: unknown }) =>
sourceFile,
transformRender: async ({ sourceFile }: { sourceFile: unknown }) =>
sourceFile,
}
})
vi.mock("@/registry/bases/__index__", () => ({
Index: {
base: {
card: {
name: "card",
type: "registry:ui",
},
},
radix: {
card: {
name: "card",
type: "registry:ui",
},
},
},
}))
describe("buildV0Payload", () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_APP_URL = "http://example.test"
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url
const name = url.split("/").pop()?.replace(".json", "") ?? "component"
return new Response(
JSON.stringify({
name,
type: "registry:ui",
files: [
{
path: `registry/base-nova/ui/${name}.tsx`,
type: "registry:ui",
content: `import * as React from "react"\n\nexport function Component() {\n return <div className="cn-font-heading text-xl" />\n}\n`,
},
],
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
}
)
})
)
})
afterEach(() => {
vi.unstubAllGlobals()
delete process.env.NEXT_PUBLIC_APP_URL
})
it("rewrites cn-font-heading to font-heading when heading inherits the body font", async () => {
const payload = await buildV0Payload({
...DEFAULT_CONFIG,
item: undefined,
fontHeading: "inherit",
})
const cardFile = payload.files?.find(
(file) => file.target === "components/ui/card.tsx"
)
expect(cardFile?.content).toContain("font-heading")
expect(cardFile?.content).not.toContain("cn-font-heading")
})
it("rewrites cn-font-heading to font-heading when a distinct heading font is selected", async () => {
const payload = await buildV0Payload({
...DEFAULT_CONFIG,
item: undefined,
fontHeading: "playfair-display",
})
const cardFile = payload.files?.find(
(file) => file.target === "components/ui/card.tsx"
)
expect(cardFile?.content).toContain("font-heading")
expect(cardFile?.content).not.toContain("cn-font-heading")
})
})

View File

@@ -1,74 +0,0 @@
import { Suspense } from "react"
import { type Metadata } from "next"
import dynamic from "next/dynamic"
import { siteConfig } from "@/lib/config"
import { absoluteUrl } from "@/lib/utils"
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
import { Customizer } from "@/app/(app)/create/components/customizer"
import { PresetHandler } from "@/app/(app)/create/components/preset-handler"
import { Preview } from "@/app/(app)/create/components/preview"
import { getAllItems } from "@/app/(app)/create/lib/api"
// Only shown on first visit (checks localStorage).
const WelcomeDialog = dynamic(() =>
import("@/app/(app)/create/components/welcome-dialog").then(
(m) => m.WelcomeDialog
)
)
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 function CreatePage() {
return (
<div className="relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden section-soft [--customizer-width:--spacing(48)] [--gap:--spacing(4)] md:[--gap:--spacing(6)] 2xl:[--customizer-width:--spacing(56)]">
<div
data-slot="designer"
className="flex min-h-0 flex-1 flex-col gap-(--gap) p-(--gap) pt-[calc(var(--gap)*0.25)] md:flex-row-reverse"
>
<Preview />
<Suspense
fallback={
<Skeleton className="isolate min-h-[151px] w-full self-start rounded-2xl md:h-full md:max-h-full md:min-h-0 md:w-(--customizer-width)" />
}
>
<CustomizerLoader />
</Suspense>
</div>
<PresetHandler />
<WelcomeDialog />
</div>
)
}
async function CustomizerLoader() {
const itemsByBase = await getAllItems()
return <Customizer itemsByBase={itemsByBase} />
}

View File

@@ -4,7 +4,6 @@ import { mdxComponents } from "@/mdx-components"
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react"
import { findNeighbour } from "fumadocs-core/page-tree"
import { replaceComponentsList } from "@/lib/llm"
import { source } from "@/lib/source"
import { absoluteUrl } from "@/lib/utils"
import { DocsBaseSwitcher } from "@/components/docs-base-switcher"
@@ -84,7 +83,7 @@ export default async function Page(props: {
const neighbours = isChangelog
? { previous: null, next: null }
: findNeighbour(source.pageTree, page.url)
const raw = replaceComponentsList(await page.data.getText("raw"))
const raw = await page.data.getText("raw")
return (
<div

View File

@@ -1,17 +1,15 @@
import Link from "next/link"
import { Button } from "@/examples/radix/ui/button"
import { mdxComponents } from "@/mdx-components"
import { IconRss } from "@tabler/icons-react"
import { getChangelogPages, type ChangelogPageData } from "@/lib/changelog"
import { absoluteUrl } from "@/lib/utils"
import { OpenInV0Cta } from "@/components/open-in-v0-cta"
import { Button } from "@/styles/radix-nova/ui/button"
export const revalidate = false
export const dynamic = "force-static"
const NUMBER_OF_LATEST_PAGES = 2
export function generateMetadata() {
return {
title: "Changelog",
@@ -36,8 +34,8 @@ export function generateMetadata() {
export default function ChangelogPage() {
const pages = getChangelogPages()
const latestPages = pages.slice(0, NUMBER_OF_LATEST_PAGES)
const olderPages = pages.slice(NUMBER_OF_LATEST_PAGES)
const latestPages = pages.slice(0, 5)
const olderPages = pages.slice(5)
return (
<div
@@ -46,7 +44,7 @@ export default function ChangelogPage() {
>
<div className="flex min-w-0 flex-1 flex-col">
<div className="h-(--top-spacing) shrink-0" />
<div className="mx-auto flex w-full max-w-160 min-w-0 flex-1 flex-col gap-6 px-4 py-6 text-neutral-800 md:px-0 lg:py-8 dark:text-neutral-300">
<div className="mx-auto flex w-full max-w-[40rem] min-w-0 flex-1 flex-col gap-6 px-4 py-6 text-neutral-800 md:px-0 lg:py-8 dark:text-neutral-300">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h1 className="scroll-m-24 text-4xl font-semibold tracking-tight sm:text-3xl">
@@ -81,7 +79,7 @@ export default function ChangelogPage() {
})}
{olderPages.length > 0 && (
<div id="more-updates" className="mb-24 scroll-mt-24">
<h2 className="mb-6 font-heading text-xl font-semibold tracking-tight">
<h2 className="font-heading mb-6 text-xl font-semibold tracking-tight">
More Updates
</h2>
<div className="grid auto-rows-fr gap-3 sm:grid-cols-2">

View File

@@ -1,11 +1,8 @@
"use client"
import * as React from "react"
import { IconMinus, IconPlus } from "@tabler/icons-react"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
import { Button } from "@/examples/base/ui-rtl/button"
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
import {
Field,
FieldContent,
@@ -16,13 +13,13 @@ import {
FieldSeparator,
FieldSet,
FieldTitle,
} from "@/styles/base-nova/ui-rtl/field"
import { Input } from "@/styles/base-nova/ui-rtl/input"
import {
RadioGroup,
RadioGroupItem,
} from "@/styles/base-nova/ui-rtl/radio-group"
import { Switch } from "@/styles/base-nova/ui-rtl/switch"
} from "@/examples/base/ui-rtl/field"
import { Input } from "@/examples/base/ui-rtl/input"
import { RadioGroup, RadioGroupItem } from "@/examples/base/ui-rtl/radio-group"
import { Switch } from "@/examples/base/ui-rtl/switch"
import { IconMinus, IconPlus } from "@tabler/icons-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,21 +1,8 @@
"use client"
import * as React from "react"
import {
ArchiveIcon,
ArrowLeftIcon,
CalendarPlusIcon,
ClockIcon,
ListFilterIcon,
MailCheckIcon,
MoreHorizontalIcon,
TagIcon,
Trash2Icon,
} from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
import { Button } from "@/examples/base/ui-rtl/button"
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
import {
DropdownMenu,
DropdownMenuContent,
@@ -29,7 +16,20 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/styles/base-nova/ui-rtl/dropdown-menu"
} from "@/examples/base/ui-rtl/dropdown-menu"
import {
ArchiveIcon,
ArrowLeftIcon,
CalendarPlusIcon,
ClockIcon,
ListFilterIcon,
MailCheckIcon,
MoreHorizontalIcon,
TagIcon,
Trash2Icon,
} from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,22 +1,22 @@
"use client"
import * as React from "react"
import { AudioLinesIcon, PlusIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
import { Button } from "@/examples/base/ui-rtl/button"
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/styles/base-nova/ui-rtl/input-group"
} from "@/examples/base/ui-rtl/input-group"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/base-nova/ui-rtl/tooltip"
} from "@/examples/base/ui-rtl/tooltip"
import { AudioLinesIcon, PlusIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,10 +1,10 @@
"use client"
import { Button } from "@/examples/base/ui-rtl/button"
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
const translations = {
ar: {

View File

@@ -1,17 +1,17 @@
"use client"
import { BotIcon, ChevronDownIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
import { Button } from "@/examples/base/ui-rtl/button"
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/base-nova/ui-rtl/popover"
import { Separator } from "@/styles/base-nova/ui-rtl/separator"
import { Textarea } from "@/styles/base-nova/ui-rtl/textarea"
} from "@/examples/base/ui-rtl/popover"
import { Separator } from "@/examples/base/ui-rtl/separator"
import { Textarea } from "@/examples/base/ui-rtl/textarea"
import { BotIcon, ChevronDownIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,15 +1,12 @@
"use client"
import { PlusIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@/styles/base-nova/ui-rtl/avatar"
import { Button } from "@/styles/base-nova/ui-rtl/button"
} from "@/examples/base/ui-rtl/avatar"
import { Button } from "@/examples/base/ui-rtl/button"
import {
Empty,
EmptyContent,
@@ -17,7 +14,10 @@ import {
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/base-nova/ui-rtl/empty"
} from "@/examples/base/ui-rtl/empty"
import { PlusIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,8 +1,9 @@
"use client"
import { Checkbox } from "@/examples/base/ui-rtl/checkbox"
import { Field, FieldLabel } from "@/examples/base/ui-rtl/field"
import { useLanguageContext } from "@/components/language-selector"
import { Checkbox } from "@/styles/base-nova/ui-rtl/checkbox"
import { Field, FieldLabel } from "@/styles/base-nova/ui-rtl/field"
const translations = {
ar: {

View File

@@ -1,8 +1,7 @@
"use client"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { Checkbox } from "@/styles/base-nova/ui-rtl/checkbox"
import { Button } from "@/examples/base/ui-rtl/button"
import { Checkbox } from "@/examples/base/ui-rtl/checkbox"
import {
Field,
FieldDescription,
@@ -11,8 +10,8 @@ import {
FieldLegend,
FieldSeparator,
FieldSet,
} from "@/styles/base-nova/ui-rtl/field"
import { Input } from "@/styles/base-nova/ui-rtl/input"
} from "@/examples/base/ui-rtl/field"
import { Input } from "@/examples/base/ui-rtl/input"
import {
Select,
SelectContent,
@@ -20,8 +19,10 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "@/styles/base-nova/ui-rtl/select"
import { Textarea } from "@/styles/base-nova/ui-rtl/textarea"
} from "@/examples/base/ui-rtl/select"
import { Textarea } from "@/examples/base/ui-rtl/textarea"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {
@@ -139,7 +140,7 @@ export function FieldDemo() {
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="rtl-exp-month">{t.month}</FieldLabel>
<Select defaultValue="">
<Select defaultValue="" items={months}>
<SelectTrigger id="rtl-exp-month">
<SelectValue placeholder="MM" />
</SelectTrigger>
@@ -156,7 +157,7 @@ export function FieldDemo() {
</Field>
<Field>
<FieldLabel htmlFor="rtl-exp-year">{t.year}</FieldLabel>
<Select defaultValue="">
<Select defaultValue="" items={years}>
<SelectTrigger id="rtl-exp-year">
<SelectValue placeholder="YYYY" />
</SelectTrigger>

View File

@@ -1,8 +1,7 @@
"use client"
import { useLanguageContext } from "@/components/language-selector"
import { Card, CardContent } from "@/styles/base-nova/ui-rtl/card"
import { Checkbox } from "@/styles/base-nova/ui-rtl/checkbox"
import { Card, CardContent } from "@/examples/base/ui-rtl/card"
import { Checkbox } from "@/examples/base/ui-rtl/checkbox"
import {
Field,
FieldDescription,
@@ -11,7 +10,9 @@ import {
FieldLegend,
FieldSet,
FieldTitle,
} from "@/styles/base-nova/ui-rtl/field"
} from "@/examples/base/ui-rtl/field"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,14 +1,14 @@
"use client"
import { useState } from "react"
import { useLanguageContext } from "@/components/language-selector"
import {
Field,
FieldDescription,
FieldTitle,
} from "@/styles/base-nova/ui-rtl/field"
import { Slider } from "@/styles/base-nova/ui-rtl/slider"
} from "@/examples/base/ui-rtl/field"
import { Slider } from "@/examples/base/ui-rtl/slider"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,12 +1,13 @@
"use client"
import { DirectionProvider } from "@/examples/base/ui-rtl/direction"
import { FieldSeparator } from "@/examples/base/ui-rtl/field"
import {
LanguageProvider,
LanguageSelector,
useLanguageContext,
} from "@/components/language-selector"
import { DirectionProvider } from "@/styles/base-nova/ui-rtl/direction"
import { FieldSeparator } from "@/styles/base-nova/ui-rtl/field"
import { AppearanceSettings } from "./appearance-settings"
import { ButtonGroupDemo } from "./button-group-demo"

View File

@@ -1,21 +1,21 @@
"use client"
import * as React from "react"
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
import { useLanguageContext } from "@/components/language-selector"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/styles/base-nova/ui-rtl/input-group"
import { Label } from "@/styles/base-nova/ui-rtl/label"
} from "@/examples/base/ui-rtl/input-group"
import { Label } from "@/examples/base/ui-rtl/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/base-nova/ui-rtl/popover"
} from "@/examples/base/ui-rtl/popover"
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,5 +1,25 @@
"use client"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/examples/base/ui-rtl/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@/examples/base/ui-rtl/input-group"
import { Separator } from "@/examples/base/ui-rtl/separator"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/examples/base/ui-rtl/tooltip"
import {
IconCheck,
IconChevronDown,
@@ -9,26 +29,6 @@ import {
import { ArrowUpIcon, Search } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/base-nova/ui-rtl/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@/styles/base-nova/ui-rtl/input-group"
import { Separator } from "@/styles/base-nova/ui-rtl/separator"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/base-nova/ui-rtl/tooltip"
const translations = {
ar: {

View File

@@ -1,9 +1,6 @@
"use client"
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { Button } from "@/examples/base/ui-rtl/button"
import {
Item,
ItemActions,
@@ -11,7 +8,10 @@ import {
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/styles/base-nova/ui-rtl/item"
} from "@/examples/base/ui-rtl/item"
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -1,6 +1,47 @@
"use client"
import { useMemo, useState } from "react"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/examples/base/ui-rtl/avatar"
import { Badge } from "@/examples/base/ui-rtl/badge"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/examples/base/ui-rtl/command"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/examples/base/ui-rtl/dropdown-menu"
import { Field, FieldLabel } from "@/examples/base/ui-rtl/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/examples/base/ui-rtl/input-group"
import { Popover, PopoverContent } from "@/examples/base/ui-rtl/popover"
import { Switch } from "@/examples/base/ui-rtl/switch"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/examples/base/ui-rtl/tooltip"
import {
IconApps,
IconArrowUp,
@@ -14,47 +55,6 @@ import {
} from "@tabler/icons-react"
import { useLanguageContext } from "@/components/language-selector"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/styles/base-nova/ui-rtl/avatar"
import { Badge } from "@/styles/base-nova/ui-rtl/badge"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/styles/base-nova/ui-rtl/command"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/styles/base-nova/ui-rtl/dropdown-menu"
import { Field, FieldLabel } from "@/styles/base-nova/ui-rtl/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/styles/base-nova/ui-rtl/input-group"
import { Popover, PopoverContent } from "@/styles/base-nova/ui-rtl/popover"
import { Switch } from "@/styles/base-nova/ui-rtl/switch"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/base-nova/ui-rtl/tooltip"
const translations = {
ar: {

View File

@@ -1,8 +1,9 @@
"use client"
import { Badge } from "@/examples/base/ui-rtl/badge"
import { Spinner } from "@/examples/base/ui-rtl/spinner"
import { useLanguageContext } from "@/components/language-selector"
import { Badge } from "@/styles/base-nova/ui-rtl/badge"
import { Spinner } from "@/styles/base-nova/ui-rtl/spinner"
const translations = {
ar: {

View File

@@ -1,7 +1,6 @@
"use client"
import { useLanguageContext } from "@/components/language-selector"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import { Button } from "@/examples/base/ui-rtl/button"
import {
Empty,
EmptyContent,
@@ -9,8 +8,10 @@ import {
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/base-nova/ui-rtl/empty"
import { Spinner } from "@/styles/base-nova/ui-rtl/spinner"
} from "@/examples/base/ui-rtl/empty"
import { Spinner } from "@/examples/base/ui-rtl/spinner"
import { useLanguageContext } from "@/components/language-selector"
const translations = {
ar: {

View File

@@ -5,10 +5,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div
data-slot="layout"
className="group/layout relative z-10 flex min-h-svh flex-col bg-background has-data-[slot=designer]:h-svh has-data-[slot=designer]:overflow-hidden"
className="relative z-10 flex min-h-svh flex-col bg-background"
>
<SiteHeader />
<main className="flex min-h-0 flex-1 flex-col">{children}</main>
<main className="flex flex-1 flex-col">{children}</main>
<SiteFooter />
</div>
)

View File

@@ -0,0 +1,64 @@
import { type Metadata } from "next"
import Link from "next/link"
import { Announcement } from "@/components/announcement"
import {
PageActions,
PageHeader,
PageHeaderDescription,
PageHeaderHeading,
} from "@/components/page-header"
import { Button } from "@/registry/new-york-v4/ui/button"
const title = "Pick a Color. Make it yours."
const description =
"Try our hand-picked themes. Copy and paste them into your project. New theme editor coming soon."
export const metadata: Metadata = {
title,
description,
openGraph: {
images: [
{
url: `/og?title=${encodeURIComponent(
title
)}&description=${encodeURIComponent(description)}`,
},
],
},
twitter: {
card: "summary_large_image",
images: [
{
url: `/og?title=${encodeURIComponent(
title
)}&description=${encodeURIComponent(description)}`,
},
],
},
}
export default function ThemesLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div>
<PageHeader>
<Announcement />
<PageHeaderHeading>{title}</PageHeaderHeading>
<PageHeaderDescription>{description}</PageHeaderDescription>
<PageActions>
<Button asChild size="sm">
<a href="#themes">Browse Themes</a>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/docs/theming">Documentation</Link>
</Button>
</PageActions>
</PageHeader>
{children}
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { CardsDemo } from "@/components/cards"
import { ThemeCustomizer } from "@/components/theme-customizer"
export const dynamic = "force-static"
export const revalidate = false
export default function ThemesPage() {
return (
<>
<div id="themes" className="container-wrapper scroll-mt-20">
<div className="container flex items-center justify-between gap-8 px-6 py-4 md:px-8">
<ThemeCustomizer />
</div>
</div>
<div className="container-wrapper flex flex-1 flex-col section-soft pb-6">
<div className="container flex flex-1 flex-col theme-container">
<CardsDemo />
</div>
</div>
</>
)
}

View File

@@ -1,7 +1,7 @@
"use client"
import { MENU_ACCENTS, type MenuAccentValue } from "@/registry/config"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import { LockButton } from "@/app/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -9,8 +9,8 @@ import {
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function MenuAccentPicker({
isMobile,
@@ -82,11 +82,6 @@ export function MenuAccentPicker({
key={accent.value}
value={accent.value}
closeOnClick={isMobile}
disabled={
accent.value === "bold" &&
(params.menuColor === "default-translucent" ||
params.menuColor === "inverted-translucent")
}
>
{accent.label}
</PickerRadioItem>

View File

@@ -1,8 +1,6 @@
"use client"
import Script from "next/script"
import { type RegistryItem } from "shadcn/schema"
import {
Command,
CommandDialog,
@@ -11,8 +9,10 @@ import {
CommandInput,
CommandItem,
CommandList,
} from "@/styles/base-nova/ui/command"
import { useActionMenu } from "@/app/(app)/create/hooks/use-action-menu"
} from "@/examples/base/ui/command"
import { type RegistryItem } from "shadcn/schema"
import { useActionMenu } from "@/app/(create)/hooks/use-action-menu"
export const CMD_K_FORWARD_TYPE = "cmd-k-forward"

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import { useMounted } from "@/hooks/use-mounted"
import { BASE_COLORS, type BaseColorName } from "@/registry/config"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import { LockButton } from "@/app/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -12,8 +12,8 @@ import {
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function BaseColorPicker({
isMobile,

View File

@@ -10,8 +10,8 @@ import {
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function BasePicker({
isMobile,

View File

@@ -1,16 +1,15 @@
"use client"
import * as React from "react"
import { Button } from "@/examples/base/ui/button"
import { cn } from "@/lib/utils"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/styles/base-nova/ui/button"
import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
import { usePresetCode } from "@/app/(create)/hooks/use-design-system"
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
const presetCode = usePresetCode()
const [hasCopied, setHasCopied] = React.useState(false)
const label = hasCopied ? "Copied" : `--preset ${presetCode}`
React.useEffect(() => {
if (hasCopied) {
@@ -33,13 +32,12 @@ export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
<Button
variant="outline"
onClick={handleCopy}
title={label}
className={cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)}
>
<span className="block min-w-0 truncate">{label}</span>
<span>{hasCopied ? "Copied" : `--preset ${presetCode}`}</span>
</Button>
)
}

View File

@@ -0,0 +1,88 @@
"use client"
import * as React from "react"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/examples/base/ui/card"
import { FieldGroup } from "@/examples/base/ui/field"
import { Separator } from "@/examples/base/ui/separator"
import { CardTitle } from "@/examples/radix/ui/card"
import { type RegistryItem } from "shadcn/schema"
import { useIsMobile } from "@/hooks/use-mobile"
import { getThemesForBaseColor, STYLES } from "@/registry/config"
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 { CopyPreset } from "@/app/(create)/components/copy-preset"
import { FontPicker } from "@/app/(create)/components/font-picker"
import { IconLibraryPicker } from "@/app/(create)/components/icon-library-picker"
import { MainMenu } from "@/app/(create)/components/main-menu"
import { MenuColorPicker } from "@/app/(create)/components/menu-picker"
import { ProjectForm } from "@/app/(create)/components/project-form"
import { RadiusPicker } from "@/app/(create)/components/radius-picker"
import { RandomButton } from "@/app/(create)/components/random-button"
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 { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function Customizer({
itemsByBase,
}: {
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
}) {
const [params] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const anchorRef = React.useRef<HTMLDivElement | null>(null)
const availableThemes = React.useMemo(
() => getThemesForBaseColor(params.baseColor),
[params.baseColor]
)
return (
<Card
className="dark top-24 right-12 isolate z-10 max-h-full min-h-0 w-full self-start rounded-2xl bg-card/90 shadow-xl backdrop-blur-xl md:w-(--customizer-width)"
ref={anchorRef}
size="sm"
>
<CardHeader className="hidden items-center justify-between gap-2 border-b group-data-reversed/layout:flex-row-reverse md:flex">
<MainMenu />
</CardHeader>
<CardContent className="no-scrollbar min-h-0 flex-1 overflow-x-auto overflow-y-hidden md:overflow-y-auto">
<FieldGroup className="flex-row gap-2.5 py-px md:flex-col md:gap-3.25">
<BasePicker isMobile={isMobile} anchorRef={anchorRef} />
<StylePicker
styles={STYLES}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<BaseColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<ThemePicker
themes={availableThemes}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<IconLibraryPicker isMobile={isMobile} anchorRef={anchorRef} />
<FontPicker fonts={FONTS} isMobile={isMobile} anchorRef={anchorRef} />
<RadiusPicker isMobile={isMobile} anchorRef={anchorRef} />
<MenuColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<MenuAccentPicker isMobile={isMobile} anchorRef={anchorRef} />
</FieldGroup>
</CardContent>
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:**:[button,a]:w-full">
<CopyPreset className="flex-1 md:flex-none" />
<RandomButton className="flex-1 md:flex-none" />
<ActionMenu itemsByBase={itemsByBase} />
<ResetDialog />
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,199 @@
"use client"
import * as React from "react"
import {
buildRegistryTheme,
DEFAULT_CONFIG,
type DesignSystemConfig,
type RadiusValue,
} from "@/registry/config"
import { useIframeMessageListener } from "@/app/(create)/hooks/use-iframe-sync"
import { FONTS } from "@/app/(create)/lib/fonts"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(create)/lib/search-params"
export function DesignSystemProvider({
children,
}: {
children: React.ReactNode
}) {
const [searchParams, setSearchParams] = useDesignSystemSearchParams({
shallow: true, // No need to go through the server…
history: "replace", // …or push updates into the iframe history.
})
const [liveSearchParams, setLiveSearchParams] = React.useState(searchParams)
const [isReady, setIsReady] = React.useState(false)
const { style, theme, font, baseColor, menuAccent, menuColor, radius } =
liveSearchParams
const effectiveRadius = style === "lyra" ? "none" : radius
React.useEffect(() => {
setLiveSearchParams(searchParams)
}, [searchParams])
const handleDesignSystemMessage = React.useCallback(
(nextParams: DesignSystemSearchParams) => {
setLiveSearchParams(nextParams)
setSearchParams(nextParams)
},
[setSearchParams]
)
useIframeMessageListener("design-system-params", handleDesignSystemMessage)
React.useEffect(() => {
if (style === "lyra" && radius !== "none") {
setLiveSearchParams((prev) => ({
...prev,
radius: "none",
}))
setSearchParams({ radius: "none" as RadiusValue })
}
}, [style, radius, setSearchParams])
// Use useLayoutEffect for synchronous style updates to prevent flash.
React.useLayoutEffect(() => {
if (!style || !theme || !font || !baseColor) {
return
}
const body = document.body
// Iterate over a snapshot so removals do not affect traversal.
Array.from(body.classList).forEach((className) => {
if (
className.startsWith("style-") ||
className.startsWith("base-color-")
) {
body.classList.remove(className)
}
})
body.classList.add(`style-${style}`, `base-color-${baseColor}`)
// Update font.
// 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.
const selectedFont = FONTS.find((f) => f.value === font)
if (selectedFont) {
const fontFamily = selectedFont.font.style.fontFamily
document.documentElement.style.setProperty("--font-sans", fontFamily)
}
setIsReady(true)
}, [style, theme, font, baseColor])
const registryTheme = React.useMemo(() => {
if (!baseColor || !theme || !menuAccent || !effectiveRadius) {
return null
}
const config: DesignSystemConfig = {
...DEFAULT_CONFIG,
baseColor,
theme,
menuAccent,
radius: effectiveRadius,
}
return buildRegistryTheme(config)
}, [baseColor, theme, menuAccent, effectiveRadius])
// Use useLayoutEffect for synchronous CSS var updates.
React.useLayoutEffect(() => {
if (!registryTheme || !registryTheme.cssVars) {
return
}
const styleId = "design-system-theme-vars"
let styleElement = document.getElementById(
styleId
) as HTMLStyleElement | null
if (!styleElement) {
styleElement = document.createElement("style")
styleElement.id = styleId
document.head.appendChild(styleElement)
}
const {
light: lightVars,
dark: darkVars,
theme: themeVars,
} = registryTheme.cssVars
let cssText = ":root {\n"
// Add theme vars (shared across light/dark).
if (themeVars) {
Object.entries(themeVars).forEach(([key, value]) => {
if (value) {
cssText += ` --${key}: ${value};\n`
}
})
}
// Add light mode vars.
if (lightVars) {
Object.entries(lightVars).forEach(([key, value]) => {
if (value) {
cssText += ` --${key}: ${value};\n`
}
})
}
cssText += "}\n\n"
cssText += ".dark {\n"
if (darkVars) {
Object.entries(darkVars).forEach(([key, value]) => {
if (value) {
cssText += ` --${key}: ${value};\n`
}
})
}
cssText += "}\n"
styleElement.textContent = cssText
}, [registryTheme])
// Handle menu color inversion by adding/removing dark class to elements with cn-menu-target.
React.useEffect(() => {
if (!menuColor) {
return
}
const updateMenuElements = () => {
const menuElements = document.querySelectorAll(".cn-menu-target")
menuElements.forEach((element) => {
if (menuColor === "inverted") {
element.classList.add("dark")
} else {
element.classList.remove("dark")
}
})
}
// Update existing menu elements.
updateMenuElements()
// Watch for new menu elements being added to the DOM.
const observer = new MutationObserver(() => {
updateMenuElements()
})
observer.observe(document.body, {
childList: true,
subtree: true,
})
return () => {
observer.disconnect()
}
}, [menuColor])
if (!isReady) {
return null
}
return <>{children}</>
}

View File

@@ -0,0 +1,113 @@
"use client"
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,
PickerContent,
PickerGroup,
PickerLabel,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(create)/components/picker"
import { type Font } from "@/app/(create)/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function FontPicker({
fonts,
isMobile,
anchorRef,
}: {
fonts: readonly Font[]
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const currentFont = React.useMemo(
() => fonts.find((font) => font.value === params.font),
[fonts, params.font]
)
const groupedFonts = React.useMemo(() => {
const groups = new Map<Font["type"], Font[]>()
for (const font of fonts) {
const existing = groups.get(font.type)
if (existing) {
existing.push(font)
continue
}
groups.set(font.type, [font])
}
return Array.from(groups.entries()).map(([type, items]) => ({
type,
label: `${type.charAt(0).toUpperCase()}${type.slice(1)}`,
items,
}))
}, [fonts])
return (
<div className="group/picker relative">
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Font</div>
<div className="text-sm font-medium text-foreground">
{currentFont?.name}
</div>
</div>
<div
className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5"
style={{ fontFamily: currentFont?.font.style.fontFamily }}
>
Aa
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
className="max-h-96"
>
<PickerRadioGroup
value={currentFont?.value}
onValueChange={(value) => {
setParams({ font: value as FontValue })
}}
>
{groupedFonts.map((group) => (
<PickerGroup key={group.type}>
<PickerLabel>{group.label}</PickerLabel>
{group.items.map((font) => (
<PickerRadioItem
key={font.value}
value={font.value}
closeOnClick={isMobile}
>
{font.name}
</PickerRadioItem>
))}
</PickerGroup>
))}
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="font"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -1,11 +1,11 @@
"use client"
import Script from "next/script"
import { Button } from "@/examples/base/ui/button"
import { Redo02Icon, Undo02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { Button } from "@/styles/base-nova/ui/button"
import { useHistory } from "@/app/(app)/create/hooks/use-history"
import { useHistory } from "@/app/(create)/hooks/use-history"
export const UNDO_FORWARD_TYPE = "undo-forward"
export const REDO_FORWARD_TYPE = "redo-forward"

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import { iconLibraries, type IconLibraryName } from "@/registry/config"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import { LockButton } from "@/app/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -11,8 +11,8 @@ import {
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
const logos = {
lucide: (

View File

@@ -1,3 +1,75 @@
"use client"
export { IconPlaceholder } from "@/app/(app)/create/components/icon-placeholder"
import { lazy, Suspense } from "react"
import { SquareIcon } from "lucide-react"
import type { IconLibraryName } from "shadcn/icons"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
const IconLucide = lazy(() =>
import("@/registry/icons/icon-lucide").then((mod) => ({
default: mod.IconLucide,
}))
)
const IconTabler = lazy(() =>
import("@/registry/icons/icon-tabler").then((mod) => ({
default: mod.IconTabler,
}))
)
const IconHugeicons = lazy(() =>
import("@/registry/icons/icon-hugeicons").then((mod) => ({
default: mod.IconHugeicons,
}))
)
const IconPhosphor = lazy(() =>
import("@/registry/icons/icon-phosphor").then((mod) => ({
default: mod.IconPhosphor,
}))
)
const IconRemixicon = lazy(() =>
import("@/registry/icons/icon-remixicon").then((mod) => ({
default: mod.IconRemixicon,
}))
)
// Preload all icon renderer modules so switching libraries is instant.
// These warm the browser module cache; React.lazy resolves immediately
// for modules that are already loaded.
void import("@/registry/icons/icon-lucide")
void import("@/registry/icons/icon-tabler")
void import("@/registry/icons/icon-hugeicons")
void import("@/registry/icons/icon-phosphor")
void import("@/registry/icons/icon-remixicon")
export function IconPlaceholder({
...props
}: {
[K in IconLibraryName]: string
} & React.ComponentProps<"svg">) {
const [{ iconLibrary }] = useDesignSystemSearchParams()
const iconName = props[iconLibrary]
if (!iconName) {
return null
}
return (
<Suspense fallback={<SquareIcon {...props} />}>
{iconLibrary === "lucide" && <IconLucide name={iconName} {...props} />}
{iconLibrary === "tabler" && <IconTabler name={iconName} {...props} />}
{iconLibrary === "hugeicons" && (
<IconHugeicons name={iconName} {...props} />
)}
{iconLibrary === "phosphor" && (
<IconPhosphor name={iconName} {...props} />
)}
{iconLibrary === "remixicon" && (
<IconRemixicon name={iconName} {...props} />
)}
</Suspense>
)
}

View File

@@ -2,16 +2,11 @@
import * as React from "react"
import Link from "next/link"
import { ChevronRightIcon } from "lucide-react"
import { type RegistryItem } from "shadcn/schema"
import { cn } from "@/lib/utils"
import { type Base } from "@/registry/bases"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/styles/base-nova/ui/collapsible"
} from "@/examples/base/ui/collapsible"
import {
Sidebar,
SidebarContent,
@@ -20,9 +15,14 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/styles/base-nova/ui/sidebar"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
import { groupItemsByType } from "@/app/(app)/create/lib/utils"
} from "@/examples/base/ui/sidebar"
import { ChevronRightIcon } from "lucide-react"
import { type RegistryItem } from "shadcn/schema"
import { cn } from "@/lib/utils"
import { type Base } from "@/registry/bases"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
import { groupItemsByType } from "@/app/(create)/lib/utils"
const cachedGroupedItems = React.cache(
(items: Pick<RegistryItem, "name" | "title" | "type">[]) => {

View File

@@ -7,10 +7,7 @@ import {
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@/lib/utils"
import {
useLocks,
type LockableParam,
} from "@/app/(app)/create/hooks/use-locks"
import { useLocks, type LockableParam } from "@/app/(create)/hooks/use-locks"
export function LockButton({
param,

View File

@@ -1,11 +1,11 @@
"use client"
import * as React from "react"
import { type Button } from "@/examples/base/ui/button"
import { Menu09Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@/lib/utils"
import { type Button } from "@/styles/base-nova/ui/button"
import {
Picker,
PickerContent,
@@ -14,13 +14,12 @@ import {
PickerSeparator,
PickerShortcut,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useActionMenuTrigger } from "@/app/(app)/create/hooks/use-action-menu"
import { useHistory } from "@/app/(app)/create/hooks/use-history"
import { useOpenPresetTrigger } from "@/app/(app)/create/hooks/use-open-preset"
import { useRandom } from "@/app/(app)/create/hooks/use-random"
import { useReset } from "@/app/(app)/create/hooks/use-reset"
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
} from "@/app/(create)/components/picker"
import { useActionMenuTrigger } from "@/app/(create)/hooks/use-action-menu"
import { useHistory } from "@/app/(create)/hooks/use-history"
import { useRandom } from "@/app/(create)/hooks/use-random"
import { useReset } from "@/app/(create)/hooks/use-reset"
import { useThemeToggle } from "@/app/(create)/hooks/use-theme-toggle"
const APPLE_PLATFORM_REGEX = /Mac|iPhone|iPad|iPod/
@@ -28,7 +27,6 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
const [isMac, setIsMac] = React.useState(false)
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
const { openActionMenu } = useActionMenuTrigger()
const { openPreset } = useOpenPresetTrigger()
const { randomize } = useRandom()
const { toggleTheme } = useThemeToggle()
const { setShowResetDialog } = useReset()
@@ -57,9 +55,6 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
Navigate...
<PickerShortcut>{isMac ? "⌘P" : "Ctrl+P"}</PickerShortcut>
</PickerItem>
<PickerItem onClick={openPreset}>
Open Preset... <PickerShortcut>O</PickerShortcut>
</PickerItem>
<PickerItem onClick={randomize}>
Shuffle <PickerShortcut>R</PickerShortcut>
</PickerItem>
@@ -78,7 +73,7 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
</PickerItem>
<PickerSeparator />
<PickerItem onClick={() => setShowResetDialog(true)}>
Reset <PickerShortcut>R</PickerShortcut>
Reset
</PickerItem>
</PickerGroup>
</PickerContent>

View File

@@ -0,0 +1,166 @@
"use client"
import * as React from "react"
import { useTheme } from "next-themes"
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 { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
const MENU_OPTIONS = [
{
value: "default" as const,
label: "Default",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="128"
height="128"
viewBox="0 0 24 24"
fill="none"
role="img"
stroke="currentColor"
className="size-4 text-foreground"
>
<path
d="M2 11.5C2 7.02166 2 4.78249 3.39124 3.39124C4.78249 2 7.02166 2 11.5 2C15.9783 2 18.2175 2 19.6088 3.39124C21 4.78249 21 7.02166 21 11.5C21 15.9783 21 18.2175 19.6088 19.6088C18.2175 21 15.9783 21 11.5 21C7.02166 21 4.78249 21 3.39124 19.6088C2 18.2175 2 15.9783 2 11.5Z"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M8.5 11.5L14.5001 11.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.5 15H13.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.5 8H15.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
{
value: "inverted" as const,
label: "Inverted",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="128"
height="128"
viewBox="0 0 24 24"
fill="none"
role="img"
className="size-4 fill-background"
>
<path
d="M2 11.5C2 7.02166 2 4.78249 3.39124 3.39124C4.78249 2 7.02166 2 11.5 2C15.9783 2 18.2175 2 19.6088 3.39124C21 4.78249 21 7.02166 21 11.5C21 15.9783 21 18.2175 19.6088 19.6088C18.2175 21 15.9783 21 11.5 21C7.02166 21 4.78249 21 3.39124 19.6088C2 18.2175 2 15.9783 2 11.5Z"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M8.5 11.5L14.5001 11.5"
stroke="var(--foreground)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.5 15H13.5"
stroke="var(--foreground)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.5 8H15.5"
stroke="var(--foreground)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
] as const
export function MenuColorPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const { resolvedTheme } = useTheme()
const mounted = useMounted()
const [params, setParams] = useDesignSystemSearchParams()
const currentMenu = MENU_OPTIONS.find(
(menu) => menu.value === params.menuColor
)
return (
<div className="group/picker relative">
<Picker>
<PickerTrigger disabled={mounted && resolvedTheme === "dark"}>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Menu Color</div>
<div className="text-sm font-medium text-foreground">
{currentMenu?.label}
</div>
</div>
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5">
{currentMenu?.icon}
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
>
<PickerRadioGroup
value={currentMenu?.value}
onValueChange={(value) => {
setParams({ menuColor: value as MenuColorValue })
}}
>
<PickerGroup>
{MENU_OPTIONS.map((menu) => (
<PickerRadioItem
key={menu.value}
value={menu.value}
closeOnClick={isMobile}
>
{menu.label}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="menuColor"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -0,0 +1,87 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { Button } from "@/examples/base/ui/button"
import { cn } from "@/lib/utils"
import { useThemeToggle } from "@/app/(create)/hooks/use-theme-toggle"
export const DARK_MODE_FORWARD_TYPE = "dark-mode-forward"
export function ModeSwitcher({
variant = "ghost",
className,
}: {
variant?: React.ComponentProps<typeof Button>["variant"]
className?: React.ComponentProps<typeof Button>["className"]
}) {
const { toggleTheme } = useThemeToggle()
return (
<Button
variant={variant}
size="icon"
className={cn("group/toggle extend-touch-target", className)}
onClick={toggleTheme}
id="mode-switcher-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4.5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 3l0 18" />
<path d="M12 9l4.65 -4.65" />
<path d="M12 14.3l7.37 -7.37" />
<path d="M12 19.6l8.85 -8.85" />
</svg>
<span className="sr-only">Toggle theme</span>
</Button>
)
}
export function DarkModeScript() {
return (
<Script
id="dark-mode-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward D key
document.addEventListener('keydown', function(e) {
if ((e.key === 'd' || e.key === 'D') && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: '${DARK_MODE_FORWARD_TYPE}',
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -4,7 +4,7 @@ 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/(app)/create/components/icon-placeholder"
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
function Picker({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
@@ -19,7 +19,7 @@ function PickerTrigger({ className, ...props }: MenuPrimitive.Trigger.Props) {
<MenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
className={cn(
"relative w-36 shrink-0 touch-manipulation rounded-xl p-3 ring-1 ring-foreground/10 select-none hover:bg-muted focus-visible:ring-foreground/50 focus-visible:outline-none disabled:opacity-50 data-popup-open:bg-muted md:w-full md:rounded-lg md:px-2.5 md:py-2",
"relative w-40 shrink-0 touch-manipulation rounded-xl p-3 ring-1 ring-foreground/10 select-none hover:bg-muted focus-visible:ring-foreground/50 focus-visible:outline-none disabled:opacity-50 data-popup-open:bg-muted md:w-full md:rounded-lg md:px-2.5 md:py-2",
className
)}
{...props}

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import { useRouter } from "next/navigation"
import { generateRandomPreset, isPresetCode } from "shadcn/preset"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function PresetHandler() {
const router = useRouter()

View File

@@ -10,8 +10,8 @@ import {
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function PresetPicker({
presets,
@@ -31,10 +31,8 @@ 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
@@ -45,10 +43,8 @@ export function PresetPicker({
params.style,
params.baseColor,
params.theme,
params.chartColor,
params.iconLibrary,
params.font,
params.fontHeading,
params.menuAccent,
params.menuColor,
params.radius,
@@ -71,10 +67,8 @@ 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,
@@ -112,6 +106,13 @@ export function PresetPicker({
closeOnClick={isMobile}
>
<div className="flex items-center gap-2">
{style?.icon && (
<div className="flex size-4 shrink-0 items-center justify-center">
{React.cloneElement(style.icon, {
className: "size-4",
})}
</div>
)}
{preset.description}
</div>
</PickerRadioItem>

View File

@@ -0,0 +1,142 @@
"use client"
import * as React from "react"
import { CMD_K_FORWARD_TYPE } from "@/app/(create)/components/action-menu"
import {
REDO_FORWARD_TYPE,
UNDO_FORWARD_TYPE,
} from "@/app/(create)/components/history-buttons"
import { DARK_MODE_FORWARD_TYPE } from "@/app/(create)/components/mode-switcher"
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(create)/components/random-button"
import { sendToIframe } from "@/app/(create)/hooks/use-iframe-sync"
import {
serializeDesignSystemSearchParams,
useDesignSystemSearchParams,
} from "@/app/(create)/lib/search-params"
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
// Hoisted — only uses module-level constants, no component state. (rendering-hoist-jsx)
function handleMessage(event: MessageEvent) {
if (
typeof window === "undefined" ||
event.origin !== window.location.origin
) {
return
}
const type = event.data.type
if (type === CMD_K_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "k",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RANDOMIZE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "r",
bubbles: true,
cancelable: true,
})
)
} else if (type === UNDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === REDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
shiftKey: true,
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === DARK_MODE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "d",
bubbles: true,
cancelable: true,
})
)
}
}
export function Preview() {
const [params] = useDesignSystemSearchParams()
const iframeRef = React.useRef<HTMLIFrameElement>(null)
React.useEffect(() => {
const iframe = iframeRef.current
if (!iframe) {
return
}
const sendParams = () => {
sendToIframe(iframe, "design-system-params", params)
}
if (iframe.contentWindow) {
sendParams()
}
iframe.addEventListener("load", sendParams)
return () => {
iframe.removeEventListener("load", sendParams)
}
}, [params])
React.useEffect(() => {
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [])
const iframeSrc = React.useMemo(() => {
// The iframe src needs to include the serialized design system params
// for the initial load, but not be reactive to them as it would cause
// full-iframe reloads on every param change (flashes & loss of state).
// Further updates of the search params will be sent to the iframe
// via a postMessage channel, for it to sync its own history onto the host's.
return serializeDesignSystemSearchParams(
`/preview/${params.base}/${params.item}`,
params
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.base, params.item])
return (
<div className="relative flex flex-1 flex-col justify-center overflow-hidden rounded-2xl ring ring-foreground/10 md:ring-muted dark:ring-foreground/10">
<div className="relative z-0 mx-auto flex w-full flex-1 flex-col overflow-hidden">
<div className="absolute inset-0 bg-muted dark:bg-muted/30" />
<iframe
key={params.base + params.item}
ref={iframeRef}
src={iframeSrc}
className="z-10 size-full flex-1"
title="Preview"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,312 @@
"use client"
import * as React from "react"
import { Button } from "@/examples/base/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/examples/base/ui/dialog"
import {
Field,
FieldContent,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
} from "@/examples/base/ui/field"
import { RadioGroup, RadioGroupItem } from "@/examples/base/ui/radio-group"
import { Switch } from "@/examples/base/ui/switch"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/examples/base/ui/tabs"
import { Copy01Icon, Globe02Icon, Tick02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@/lib/utils"
import { useConfig } from "@/hooks/use-config"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { usePresetCode } from "@/app/(create)/hooks/use-design-system"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(create)/lib/search-params"
import {
getFramework,
getTemplateValue,
NO_MONOREPO_FRAMEWORKS,
TEMPLATES,
} from "@/app/(create)/lib/templates"
const TURBOREPO_LOGO =
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Turborepo</title><path d="M11.9906 4.1957c-4.2998 0-7.7981 3.501-7.7981 7.8043s3.4983 7.8043 7.7981 7.8043c4.2999 0 7.7982-3.501 7.7982-7.8043s-3.4983-7.8043-7.7982-7.8043m0 11.843c-2.229 0-4.0356-1.8079-4.0356-4.0387s1.8065-4.0387 4.0356-4.0387S16.0262 9.7692 16.0262 12s-1.8065 4.0388-4.0356 4.0388m.6534-13.1249V0C18.9726.3386 24 5.5822 24 12s-5.0274 11.66-11.356 12v-2.9139c4.7167-.3372 8.4516-4.2814 8.4516-9.0861s-3.735-8.749-8.4516-9.0861M5.113 17.9586c-1.2502-1.4446-2.0562-3.2845-2.2-5.3046H0c.151 2.8266 1.2808 5.3917 3.051 7.3668l2.0606-2.0622zM11.3372 24v-2.9139c-2.02-.1439-3.8584-.949-5.3019-2.2018l-2.0606 2.0623c1.975 1.773 4.538 2.9022 7.361 3.0534z"/></svg>'
const ORIGIN = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000"
const IS_LOCAL_DEV = ORIGIN.includes("localhost")
const SHADCN_VERSION = process.env.NEXT_PUBLIC_RC ? "@rc" : "@latest"
const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"] as const
type PackageManager = (typeof PACKAGE_MANAGERS)[number]
export function ProjectForm({
className,
}: React.ComponentProps<typeof Button>) {
const [open, setOpen] = React.useState(false)
const [params, setParams] = useDesignSystemSearchParams()
const presetCode = usePresetCode()
const [config, setConfig] = useConfig()
const [hasCopied, setHasCopied] = React.useState(false)
const packageManager = (config.packageManager || "pnpm") as PackageManager
const framework = React.useMemo(
() => getFramework(params.template ?? "next"),
[params.template]
)
const isMonorepo = React.useMemo(
() => params.template?.endsWith("-monorepo") ?? false,
[params.template]
)
const hasMonorepo = !NO_MONOREPO_FRAMEWORKS.includes(
framework as (typeof NO_MONOREPO_FRAMEWORKS)[number]
)
const commands = React.useMemo(() => {
const presetFlag = ` --preset ${presetCode}`
const baseFlag = params.base !== "radix" ? ` --base ${params.base}` : ""
const templateFlag = ` --template ${framework}`
const monorepoFlag = isMonorepo ? " --monorepo" : ""
const rtlFlag = params.rtl ? " --rtl" : ""
const flags = `${presetFlag}${baseFlag}${templateFlag}${monorepoFlag}${rtlFlag}`
return IS_LOCAL_DEV && !process.env.NEXT_PUBLIC_RC
? {
pnpm: `shadcn init${flags}`,
npm: `shadcn init${flags}`,
yarn: `shadcn init${flags}`,
bun: `shadcn init${flags}`,
}
: {
pnpm: `pnpm dlx shadcn${SHADCN_VERSION} init${flags}`,
npm: `npx shadcn${SHADCN_VERSION} init${flags}`,
yarn: `yarn dlx shadcn${SHADCN_VERSION} init${flags}`,
bun: `bunx --bun shadcn${SHADCN_VERSION} init${flags}`,
}
}, [framework, isMonorepo, params.base, params.rtl, presetCode])
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<string, string> = {
command,
}
if (params.template) {
properties.template = params.template
}
copyToClipboardWithMeta(command, {
name: "copy_npm_command",
properties,
})
setHasCopied(true)
}, [command, params.template])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button className={cn(className)} />}>
Create Project
</DialogTrigger>
<DialogContent className="min-w-0 sm:max-w-sm">
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
<DialogDescription>
Pick a template and configure your project. Available for all major
React frameworks.
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel className="sr-only">Template</FieldLabel>
<TemplateGrid template={params.template} setParams={setParams} />
</Field>
<FieldSeparator />
<FieldSet>
<FieldLegend variant="label" className="sr-only">
Options
</FieldLegend>
<Field
orientation="horizontal"
data-disabled={hasMonorepo ? undefined : "true"}
>
<FieldLabel htmlFor="monorepo">
<span
className="size-4 text-foreground [&_svg]:size-4 [&_svg]:fill-current"
dangerouslySetInnerHTML={{
__html: TURBOREPO_LOGO,
}}
/>
Create a monorepo
</FieldLabel>
<Switch
id="monorepo"
checked={params.template?.endsWith("-monorepo") ?? false}
disabled={!hasMonorepo}
onCheckedChange={(checked) => {
const framework = getFramework(params.template ?? "next")
setParams({
template: getTemplateValue(
framework,
checked === true
) as typeof params.template,
})
}}
/>
</Field>
<FieldSeparator />
<Field orientation="horizontal">
<FieldLabel htmlFor="rtl">
<HugeiconsIcon icon={Globe02Icon} className="size-4" />
Enable RTL support
</FieldLabel>
<Switch
id="rtl"
checked={params.rtl}
onCheckedChange={(checked) =>
setParams({ rtl: checked === true })
}
/>
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter className="min-w-0">
<div className="flex w-full min-w-0 flex-col gap-3">
<Tabs
value={packageManager}
onValueChange={(value) => {
setConfig((prev) => ({
...prev,
packageManager: value as PackageManager,
}))
}}
className="min-w-0 gap-0 overflow-hidden rounded-lg border bg-surface"
>
<div className="flex items-center gap-2 px-1 py-1">
<TabsList className="font-mono">
{PACKAGE_MANAGERS.map((manager) => {
return (
<TabsTrigger
key={manager}
value={manager}
className="data-[state=active]:shadow-none"
>
{manager}
</TabsTrigger>
)
})}
</TabsList>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
>
{hasCopied ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
{Object.entries(commands).map(([key, cmd]) => {
return (
<TabsContent key={key} value={key}>
<div className="relative overflow-hidden border-t border-border/50 bg-surface px-3 py-3 text-surface-foreground">
<div className="no-scrollbar overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">
{cmd}
</code>
</div>
</div>
</TabsContent>
)
})}
</Tabs>
<Button onClick={handleCopy} className="h-9 w-full">
{hasCopied ? "Copied" : "Copy Command"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
const TemplateGrid = React.memo(function TemplateGrid({
template,
setParams,
}: {
template: DesignSystemSearchParams["template"]
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
}) {
const isMonorepo = template?.endsWith("-monorepo") ?? false
const framework = getFramework(template ?? "next")
const handleTemplateChange = React.useCallback(
(value: string) => {
setParams({
template: getTemplateValue(
value,
isMonorepo
) as DesignSystemSearchParams["template"],
})
},
[isMonorepo, setParams]
)
return (
<RadioGroup
value={framework}
onValueChange={handleTemplateChange}
className="grid grid-cols-3 gap-2"
>
{TEMPLATES.map((item) => (
<FieldLabel
key={item.value}
htmlFor={`template-${item.value}`}
className="py-1"
>
<Field className="gap-0" orientation="horizontal">
<FieldContent className="flex flex-col items-center justify-center gap-2">
<div
className="size-6 text-foreground [&_svg]:size-6 *:[svg]:text-foreground!"
dangerouslySetInnerHTML={{
__html: item.logo,
}}
></div>
<FieldTitle className="text-xs">{item.title}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={item.value}
id={`template-${item.value}`}
className="sr-only absolute"
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
)
})

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import { RADII, type RadiusValue } from "@/registry/config"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import { LockButton } from "@/app/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -12,8 +12,8 @@ import {
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function RadiusPicker({
isMobile,

View File

@@ -1,13 +1,12 @@
"use client"
import Script from "next/script"
import { Button } from "@/examples/base/ui/button"
import { DiceFaces05Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@/lib/utils"
import { Button } from "@/styles/base-nova/ui/button"
import { useRandom } from "@/app/(app)/create/hooks/use-random"
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
import { useRandom } from "@/app/(create)/hooks/use-random"
export const RANDOMIZE_FORWARD_TYPE = "randomize-forward"
@@ -28,7 +27,7 @@ export function RandomButton({
)}
{...props}
>
<span className="w-full truncate text-center font-medium">Shuffle</span>
<span className="w-full text-center font-medium">Shuffle</span>
</Button>
)
}
@@ -41,7 +40,7 @@ export function RandomizeScript() {
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward r key (shuffle) and Shift+R (reset).
// Forward R key
document.addEventListener('keydown', function(e) {
if ((e.key === 'r' || e.key === 'R') && !e.metaKey && !e.ctrlKey) {
if (
@@ -54,11 +53,8 @@ export function RandomizeScript() {
}
e.preventDefault();
if (window.parent && window.parent !== window) {
var type = e.shiftKey
? '${RESET_FORWARD_TYPE}'
: '${RANDOMIZE_FORWARD_TYPE}';
window.parent.postMessage({
type: type,
type: '${RANDOMIZE_FORWARD_TYPE}',
key: e.key
}, '*');
}

View File

@@ -9,8 +9,9 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/styles/base-nova/ui/alert-dialog"
import { useReset } from "@/app/(app)/create/hooks/use-reset"
} from "@/examples/base/ui/alert-dialog"
import { useReset } from "@/app/(create)/hooks/use-reset"
export function ResetDialog() {
const { showResetDialog, setShowResetDialog, confirmReset } = useReset()

View File

@@ -1,13 +1,13 @@
"use client"
import * as React from "react"
import { Button } from "@/examples/base/ui/button"
import { Share03Icon, Tick02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/styles/base-nova/ui/button"
import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
import { usePresetCode } from "@/app/(create)/hooks/use-design-system"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function ShareButton() {
const [params] = useDesignSystemSearchParams()

Some files were not shown because too many files have changed in this diff Show More