Compare commits

..

1 Commits

Author SHA1 Message Date
shadcn
93cb808f3a chore: debug edit 2024-04-21 00:50:59 +04:00
10870 changed files with 108569 additions and 874440 deletions

View File

@@ -7,5 +7,5 @@
"access": "public", "access": "public",
"baseBranch": "main", "baseBranch": "main",
"updateInternalDependencies": "patch", "updateInternalDependencies": "patch",
"ignore": ["v4", "tests"] "ignore": ["www", "**-template"]
} }

View File

@@ -1,5 +0,0 @@
---
"@shadcn/react": patch
---
Fix MessageScroller abandoning a newly appended scrollAnchor placement when autoScroll is enabled, so the anchored turn holds at the reading line while the reply streams.

View File

@@ -1,5 +0,0 @@
---
"@shadcn/react": patch
---
Fix MessageScroller firing "ResizeObserver loop completed with undelivered notifications" during streamed content growth by coalescing ResizeObserver callbacks into requestAnimationFrame.

View File

@@ -0,0 +1,5 @@
---
"shadcn-ui": patch
---
update zod imports

View File

@@ -1,14 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Bash(npm run typecheck:*)",
"Bash(ls:*)",
"Bash(cat:*)",
"WebSearch",
"WebFetch(domain:github.com)"
],
"deny": []
},
"outputStyle": "Explanatory"
}

View File

@@ -1,41 +0,0 @@
{
"name": "shadcn",
"displayName": "shadcn/ui",
"version": "1.0.0",
"description": "UI component and design system framework. Search registries, install components as source code, and audit your project.",
"author": {
"name": "shadcn"
},
"homepage": "https://ui.shadcn.com",
"repository": "https://github.com/shadcn-ui/ui",
"license": "MIT",
"logo": "skills/shadcn/assets/shadcn.png",
"keywords": [
"shadcn",
"shadcn-ui",
"ui",
"components",
"tailwind",
"tailwindcss",
"radix",
"react",
"design-system",
"registry",
"mcp"
],
"category": "developer-tools",
"tags": [
"ui",
"components",
"design-system",
"react",
"tailwind"
],
"skills": "./skills/",
"mcpServers": {
"shadcn": {
"command": "npx",
"args": ["shadcn@latest", "mcp"]
}
}
}

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).

View File

@@ -1,8 +0,0 @@
node_modules/
target/
.next/
build/
dist/
/templates/
/fixtures/

View File

@@ -8,7 +8,6 @@
"plugin:tailwindcss/recommended" "plugin:tailwindcss/recommended"
], ],
"plugins": ["tailwindcss"], "plugins": ["tailwindcss"],
"ignorePatterns": ["**/fixtures/**"],
"rules": { "rules": {
"@next/next/no-html-link-for-pages": "off", "@next/next/no-html-link-for-pages": "off",
"tailwindcss/no-custom-classname": "off", "tailwindcss/no-custom-classname": "off",

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [shadcn]

View File

@@ -1,12 +1,12 @@
// ORIGINALLY FROM CLOUDFLARE WRANGLER: // ORIGINALLY FROM CLOUDFLARE WRANGLER:
// https://github.com/cloudflare/wrangler2/blob/main/.github/changeset-version.js // https://github.com/cloudflare/wrangler2/blob/main/.github/changeset-version.js
import { execSync } from "child_process" import { exec } from "child_process"
// This script is used by the `release.yml` workflow to update the version of the packages being released. // This script is used by the `release.yml` workflow to update the version of the packages being released.
// The standard step is only to run `changeset version` but this does not update the pnpm-lock.yaml file. // The standard step is only to run `changeset version` but this does not update the package-lock.json file.
// So we also run `pnpm install`, which does this update. // So we also run `npm install`, which does this update.
// This is a workaround until this is handled automatically by `changeset version`. // This is a workaround until this is handled automatically by `changeset version`.
// See https://github.com/changesets/changesets/issues/421. // See https://github.com/changesets/changesets/issues/421.
execSync("npx changeset version", { stdio: "inherit" }) exec("npx changeset version")
execSync("pnpm install --lockfile-only", { stdio: "inherit" }) exec("npm install")

View File

@@ -1,35 +0,0 @@
// Collect the packages that a snapshot prerelease just published, so the
// prerelease comment workflow can render an install line per package.
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
const [, , prNumber, channel] = process.argv
const packagesDir = join(process.cwd(), "packages")
const published = []
for (const dir of readdirSync(packagesDir)) {
const pkgPath = join(packagesDir, dir, "package.json")
if (!existsSync(pkgPath)) {
continue
}
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
// Snapshot versions are stamped `0.0.0-<channel>-<timestamp>`, so the channel
// marker is how we tell which packages this run actually versioned.
if (
!pkg.private &&
typeof pkg.version === "string" &&
pkg.version.includes(`-${channel}`)
) {
published.push({ name: pkg.name, version: pkg.version })
}
}
writeFileSync(
"prerelease-info.json",
JSON.stringify({ pr: prNumber, channel, packages: published }, null, 2)
)
console.log(`Collected ${published.length} prerelease package(s).`)

View File

@@ -1,46 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/astro-app"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/astro-monorepo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/next-app"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/next-monorepo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/react-router-app"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/react-router-monorepo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/start-app"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/start-monorepo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/vite-app"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/vite-monorepo"
schedule:
interval: "weekly"

21
.github/version-script-beta.js vendored Normal file
View File

@@ -0,0 +1,21 @@
// ORIGINALLY FROM CLOUDFLARE WRANGLER:
// https://github.com/cloudflare/wrangler2/blob/main/.github/version-script.js
import { exec } from "child_process"
import fs from "fs"
const pkgJsonPath = "packages/cli/package.json"
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath))
exec("git rev-parse --short HEAD", (err, stdout) => {
if (err) {
console.log(err)
process.exit(1)
}
pkg.version = "0.0.0-beta." + stdout.trim()
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, "\t") + "\n")
})
} catch (error) {
console.error(error)
process.exit(1)
}

View File

@@ -4,7 +4,7 @@
import { exec } from "child_process" import { exec } from "child_process"
import fs from "fs" import fs from "fs"
const pkgJsonPath = "packages/shadcn/package.json" const pkgJsonPath = "packages/cli/package.json"
try { try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath)) const pkg = JSON.parse(fs.readFileSync(pkgJsonPath))
exec("git rev-parse --short HEAD", (err, stdout) => { exec("git rev-parse --short HEAD", (err, stdout) => {

View File

@@ -1,56 +0,0 @@
name: Browser tests
on:
pull_request:
branches: ["*"]
paths:
- "packages/react/**"
- ".github/workflows/browser-tests.yml"
jobs:
browser:
runs-on: ubuntu-latest
name: pnpm test:browser
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- uses: pnpm/action-setup@v4
name: Install pnpm
id: pnpm-install
with:
version: 10.33.4
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Cache Playwright browsers
uses: actions/cache@v3
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install Playwright Chromium
run: pnpm --filter=@shadcn/react exec playwright install --with-deps chromium
- run: pnpm --filter=@shadcn/react test:browser

View File

@@ -16,13 +16,13 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 18
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v2.2.4
name: Install pnpm name: Install pnpm
id: pnpm-install id: pnpm-install
with: with:
version: 10.33.4 version: 8.6.1
run_install: false run_install: false
- name: Get pnpm store directory - name: Get pnpm store directory
@@ -52,13 +52,13 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 18
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v2.2.4
name: Install pnpm name: Install pnpm
id: pnpm-install id: pnpm-install
with: with:
version: 10.33.4 version: 8.6.1
run_install: false run_install: false
- name: Get pnpm store directory - name: Get pnpm store directory
@@ -77,9 +77,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install
- name: Build packages
run: pnpm build:packages
- run: pnpm format:check - run: pnpm format:check
tsc: tsc:
@@ -93,13 +90,13 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 18
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v2.2.4
name: Install pnpm name: Install pnpm
id: pnpm-install id: pnpm-install
with: with:
version: 10.33.4 version: 8.6.1
run_install: false run_install: false
- name: Get pnpm store directory - name: Get pnpm store directory
@@ -116,7 +113,4 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install
- name: Build packages
run: pnpm build:packages
- run: pnpm typecheck - run: pnpm typecheck

View File

@@ -1,5 +1,5 @@
# Adapted from vercel/next.js # Adapted from vercel/next.js
name: "Stale issue handler" name: Issue Stale
on: on:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
@@ -11,35 +11,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository_owner == 'shadcn-ui' if: github.repository_owner == 'shadcn-ui'
steps: steps:
- uses: actions/stale@v9 - uses: actions/stale@v4
id: issue-stale id: stale-no-repro
name: "Mark stale issues, close stale issues" name: "Close stale issues with no reproduction"
with: with:
repo-token: ${{ secrets.STALE_TOKEN }} repo-token: ${{ secrets.STALE_TOKEN }}
ascending: true close-issue-message: "This issue has been automatically closed because it received no activity for a while. If you think it was closed by accident, please leave a comment. Thank you."
days-before-issue-close: 7 days-before-issue-close: 7
days-before-issue-stale: 365 days-before-issue-stale: 15
days-before-pr-stale: -1
days-before-pr-close: -1
remove-issue-stale-when-updated: true
stale-issue-label: "stale?"
exempt-issue-labels: "roadmap,next"
stale-issue-message: "This issue has been automatically marked as stale due to one year of inactivity. It will be closed in 7 days unless theres further input. If you believe this issue is still relevant, please leave a comment or provide updated details. Thank you. (This is an automated message)"
close-issue-message: "This issue has been automatically closed due to one year of inactivity. If youre still experiencing a similar problem or have additional details to share, please open a new issue following our current issue template. Your updated report helps us investigate and address concerns more efficiently. Thank you for your understanding! (This is an automated message)"
operations-per-run: 300
- uses: actions/stale@v9
id: pr-state
name: "Mark stale PRs, close stale PRs"
with:
repo-token: ${{ secrets.STALE_TOKEN }}
ascending: true
days-before-issue-close: -1
days-before-issue-stale: -1
days-before-pr-close: 7
days-before-pr-stale: 365
remove-pr-stale-when-updated: true
exempt-pr-labels: "roadmap,next,bug"
stale-pr-label: "stale?" stale-pr-label: "stale?"
stale-pr-message: "This PR has been automatically marked as stale due to one year of inactivity. It will be closed in 7 days unless theres further input. If you believe this PR is still relevant, please leave a comment or provide updated details. Thank you. (This is an automated message)" days-before-pr-close: -1
close-pr-message: "This PR has been automatically closed due to one year of inactivity. Thank you for your understanding! (This is an automated message)" days-before-pr-stale: -1
operations-per-run: 300 exempt-issue-labels: "roadmap,next,bug"
operations-per-run: 300 # 1 operation per 100 issues, the rest is to label/comment/close

View File

@@ -1,9 +1,9 @@
# Adapted from create-t3-app. # Adapted from create-t3-app.
name: Write Prerelease comment name: Write Beta Release comment
on: on:
workflow_run: workflow_run:
workflows: ["Release"] workflows: ["Release - Beta"]
types: types:
- completed - completed
@@ -11,84 +11,55 @@ jobs:
comment: comment:
if: | if: |
github.repository_owner == 'shadcn-ui' && github.repository_owner == 'shadcn-ui' &&
github.event.workflow_run.event == 'pull_request' && ${{ github.event.workflow_run.conclusion == 'success' }}
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Write comment to the PR name: Write comment to the PR
steps: steps:
# Stable pushes and no-changeset runs upload no artifact, so a missing - name: "Comment on PR"
# download is expected — gate the rest of the job on it succeeding. uses: actions/github-script@v6
- name: Download prerelease info
id: download
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: prerelease-info
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build comment
id: info
if: steps.download.outcome == 'success'
uses: actions/github-script@v7
with:
script: |
const fs = require("fs");
const info = JSON.parse(fs.readFileSync("prerelease-info.json", "utf8"));
if (!info.packages || info.packages.length === 0) {
core.info("No prerelease packages to comment.");
return;
}
const installs = info.packages
.map((p) => `pnpm dlx ${p.name}@${p.version}`)
.join("\n");
const links = info.packages
.map(
(p) =>
`- [${p.name}@${p.version}](https://www.npmjs.com/package/${p.name}/v/${p.version})`
)
.join("\n");
const body = [
`A new ${info.channel} prerelease is available for testing:`,
"",
"```sh",
installs,
"```",
"",
links,
].join("\n");
core.setOutput("pr", info.pr);
core.setOutput("channel", info.channel);
core.setOutput("body", body);
- name: Comment on PR
if: steps.info.outputs.body
uses: marocchino/sticky-pull-request-comment@v2
with:
number: ${{ steps.info.outputs.pr }}
message: ${{ steps.info.outputs.body }}
- name: Remove the prerelease label once published
if: steps.info.outputs.pr
uses: actions/github-script@v7
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
try { const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
await github.rest.issues.removeLabel({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: Number("${{ steps.info.outputs.pr }}"), run_id: context.payload.workflow_run.id,
name: `release: ${{ steps.info.outputs.channel }}`,
}); });
} catch (error) {
if (error.status !== 404) { for (const artifact of allArtifacts.data.artifacts) {
throw error; // Extract the PR number and package version from the artifact name
const match = /^npm-package-shadcn-ui@(.*?)-pr-(\d+)/.exec(artifact.name);
if (match) {
require("fs").appendFileSync(
process.env.GITHUB_ENV,
`\nBETA_PACKAGE_VERSION=${match[1]}` +
`\nWORKFLOW_RUN_PR=${match[2]}` +
`\nWORKFLOW_RUN_ID=${context.payload.workflow_run.id}`
);
break;
}
} }
core.info("The prerelease label was already removed."); - name: "Comment on PR with Link"
} uses: marocchino/sticky-pull-request-comment@v2
with:
number: ${{ env.WORKFLOW_RUN_PR }}
message: |
A new prerelease is available for testing:
```sh
npx shadcn-ui@${{ env.BETA_PACKAGE_VERSION }}
```
- name: "Remove the autorelease label once published"
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: '${{ env.WORKFLOW_RUN_PR }}',
name: '🚀 autorelease',
});

60
.github/workflows/prerelease.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
# Adapted from create-t3-app.
name: Release - Beta
on:
pull_request:
types: [labeled]
branches:
- main
jobs:
prerelease:
if: |
github.repository_owner == 'shadcn-ui' &&
contains(github.event.pull_request.labels.*.name, '🚀 autorelease')
name: Build & Publish a beta release to NPM
runs-on: ubuntu-latest
environment: Preview
steps:
- name: Checkout Repo
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Use PNPM
uses: pnpm/action-setup@v2.2.4
with:
version: 8.6.1
- name: Use Node.js 18
uses: actions/setup-node@v3
with:
node-version: 18
cache: "pnpm"
- name: Install NPM Dependencies
run: pnpm install
- name: Modify package.json version
run: node .github/version-script-beta.js
- name: Authenticate to NPM
run: echo "//registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN" >> packages/cli/.npmrc
env:
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
- name: Publish Beta to NPM
run: pnpm pub:beta
- name: get-npm-version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: packages/cli
- name: Upload packaged artifact
uses: actions/upload-artifact@v2
with:
name: npm-package-shadcn-ui@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: packages/cli/dist/index.js

View File

@@ -2,176 +2,52 @@
name: Release name: Release
run-name: ${{ github.event_name == 'pull_request' && format('Release Prerelease - PR {0}', github.event.number) || 'Release Stable' }}
on: on:
pull_request:
types: [labeled]
branches:
- main
push: push:
branches: branches:
- main - main
jobs: jobs:
prerelease:
if: "${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && (contains(github.event.pull_request.labels.*.name, 'release: beta') || contains(github.event.pull_request.labels.*.name, 'release: rc')) }}"
name: Publish Prerelease to NPM
runs-on: ubuntu-latest
environment: Preview
permissions:
id-token: write
contents: read
steps:
- name: Select prerelease channel
id: prerelease
uses: actions/github-script@v7
with:
script: |
const prereleaseLabels = [
{ name: "release: beta", channel: "beta" },
{ name: "release: rc", channel: "rc" },
];
const labels = context.payload.pull_request.labels.map((label) => label.name);
const selectedLabels = prereleaseLabels.filter((label) =>
labels.includes(label.name)
);
if (selectedLabels.length !== 1) {
throw new Error(
`Expected exactly one prerelease label, found: ${
selectedLabels.map((label) => label.name).join(", ") || "none"
}.`
);
}
core.setOutput("channel", selectedLabels[0].channel);
core.setOutput("label", selectedLabels[0].name);
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
version: 10.33.4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
# A snapshot prerelease needs changesets to compute versions. The
# Changesets version PR consumes them, so a label on that PR is a no-op.
- name: Check for changesets
id: changesets
run: |
shopt -s nullglob
present=false
for file in .changeset/*.md; do
if [ "$(basename "$file")" != "README.md" ]; then
present=true
break
fi
done
echo "present=$present" >> "$GITHUB_OUTPUT"
- name: No changesets to prerelease
if: steps.changesets.outputs.present == 'false'
run: echo "::notice::No changesets found on this branch; nothing to prerelease."
# Snapshot versions are stamped per run (timestamped), so each publish is
# unique and can never collide with a real release on the latest tag.
- name: Version snapshot
if: steps.changesets.outputs.present == 'true'
run: pnpm exec changeset version --snapshot ${{ steps.prerelease.outputs.channel }}
- name: Build packages
if: steps.changesets.outputs.present == 'true'
run: pnpm build:packages
- name: Publish snapshot to NPM
if: steps.changesets.outputs.present == 'true'
run: pnpm exec changeset publish --tag ${{ steps.prerelease.outputs.channel }} --no-git-tag
- name: Collect prerelease info
if: steps.changesets.outputs.present == 'true'
run: node .github/collect-prerelease-info.js "${{ github.event.number }}" "${{ steps.prerelease.outputs.channel }}"
- name: Upload prerelease info
if: steps.changesets.outputs.present == 'true'
uses: actions/upload-artifact@v4
with:
name: prerelease-info
path: prerelease-info.json
release: release:
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }} if: ${{ github.repository_owner == 'shadcn-ui' }}
name: Create Version PR or Publish Stable Release name: Create a PR for release workflow
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
pull-requests: write
steps: steps:
- name: Checkout Repo - name: Checkout Repo
uses: actions/checkout@v4 uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Use PNPM - name: Use PNPM
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v2.2.4
with: with:
version: 10.33.4 version: 8.6.1
- name: Use Node.js 20 - name: Use Node.js 18
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: 20 version: 8.6.1
registry-url: "https://registry.npmjs.org" node-version: 18
cache: "pnpm" cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies - name: Install NPM Dependencies
run: pnpm install run: pnpm install
# Builds every publishable package under packages/* (shadcn, @shadcn/react), # - name: Check for errors
# never apps/v4, so each dist is fresh before changeset publish. # run: pnpm check
- name: Build the packages
run: pnpm build:packages
- name: Import GPG key - name: Build the package
uses: crazy-max/ghaction-import-gpg@v6 run: pnpm build:cli
with:
gpg_private_key: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
git_user_signingkey: true
git_commit_gpgsign: true
git_tag_gpgsign: true
- name: Create Version PR or Publish to NPM - name: Create Version PR or Publish to NPM
id: changesets id: changesets
uses: changesets/action@v1 uses: changesets/action@v1.4.1
with: with:
setupGitUser: false
commit: "chore(release): version packages" commit: "chore(release): version packages"
title: "chore(release): version packages" title: "chore(release): version packages"
version: node .github/changeset-version.js version: node .github/changeset-version.js
publish: npx changeset publish publish: npx changeset publish
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
NODE_ENV: "production" NODE_ENV: "production"

View File

@@ -1,75 +0,0 @@
name: Signed commits
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- ready_for_review
permissions:
pull-requests: write
jobs:
signed-commits:
if: github.repository_owner == 'shadcn-ui'
runs-on: ubuntu-latest
name: Signed commits
steps:
- name: Check PR commits
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const body = "Can you sign the commits please? See https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits. Thank you."
const { owner, repo } = context.repo
const pullNumber = context.payload.pull_request.number
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number: pullNumber,
per_page: 100,
})
const unsignedCommits = commits.filter((commit) => {
return commit.commit.verification?.reason === "unsigned"
})
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pullNumber,
per_page: 100,
})
const existingComments = comments.filter((comment) => {
return comment.user.type === "Bot" && comment.body.trim() === body
})
if (unsignedCommits.length > 0) {
core.info(`Found ${unsignedCommits.length} unsigned commits.`)
if (existingComments.length === 0) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: pullNumber,
body,
})
}
return
}
core.info("All commits are signed.")
for (const comment of existingComments) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id,
})
}

View File

@@ -1,314 +0,0 @@
name: Templates
on:
pull_request:
branches: ["*"]
paths:
- ".github/workflows/templates.yml"
- "apps/v4/registry/**"
- "package.json"
- "packages/shadcn/src/commands/add.ts"
- "packages/shadcn/src/commands/init.ts"
- "packages/shadcn/src/templates/**"
- "packages/shadcn/src/utils/create-project.ts"
- "packages/shadcn/src/utils/get-monorepo-info.ts"
- "packages/shadcn/src/utils/get-package-manager.ts"
- "pnpm-lock.yaml"
- "templates/**"
jobs:
validate:
runs-on: ubuntu-latest
name: ${{ matrix.package-manager == 'pnpm' && format('pnpm {0}', matrix.pnpm-version) || matrix.package-manager }} ${{ matrix.template }}
permissions:
contents: read
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
template: [next, vite, astro, start, react-router]
package-manager: [pnpm, bun, npm, yarn]
pnpm-version: [10.33.4, 11]
exclude:
- package-manager: bun
pnpm-version: 11
- package-manager: npm
pnpm-version: 11
- package-manager: yarn
pnpm-version: 11
env:
NEXT_PUBLIC_APP_URL: http://localhost:4000
NEXT_PUBLIC_V0_URL: https://v0.dev
REGISTRY_URL: http://localhost:4000/r
ROOT_PNPM_VERSION: 10.33.4
TEMPLATE_PNPM_VERSION: ${{ matrix.pnpm-version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
id: pnpm-install
with:
version: ${{ env.ROOT_PNPM_VERSION }}
run_install: false
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install Yarn
if: matrix.package-manager == 'yarn'
run: |
corepack enable
COREPACK_ENABLE_PROJECT_SPEC=0 corepack prepare yarn@4.12.0 --activate
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "pnpm_cache_dir=$(pnpm store path)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Build packages
run: |
pnpm --filter=shadcn build
pnpm --filter=v4 registry:build
- name: Validate templates
env:
TEMPLATE: ${{ matrix.template }}
TEMPLATE_PACKAGE_MANAGER: ${{ matrix.package-manager }}
SHADCN_TEMPLATE_DIR: ${{ github.workspace }}/templates
run: |
set -euo pipefail
root_pnpm="$(command -v pnpm)"
validation_script="$RUNNER_TEMP/validate-templates.sh"
cat > "$validation_script" <<'BASH'
set -euo pipefail
bin_dir="$RUNNER_TEMP/template-pnpm-bin"
mkdir -p "$bin_dir"
cat > "$bin_dir/pnpm" <<'PNPM'
#!/usr/bin/env bash
exec npx -y "pnpm@${TEMPLATE_PNPM_VERSION}" "$@"
PNPM
chmod +x "$bin_dir/pnpm"
export PATH="$bin_dir:$PATH"
echo "Using template pnpm $(pnpm --version)"
cli="$GITHUB_WORKSPACE/packages/shadcn/dist/index.js"
template_root="$RUNNER_TEMP/generated-template-${TEMPLATE_PACKAGE_MANAGER}-${TEMPLATE}"
rm -rf "$template_root"
mkdir -p "$template_root"
modes=(app monorepo)
has_script() {
node -e "const pkg = require('./package.json'); process.exit(pkg.scripts && pkg.scripts[process.argv[1]] ? 0 : 1)" "$1"
}
run_script_if_present() {
local script="$1"
if has_script "$script"; then
pnpm run "$script"
else
echo "No $script script found; skipping."
fi
}
validate_non_pnpm_project() {
local package_manager="$1"
local project_path="$2"
local check_workspace_protocol="$3"
local is_monorepo="$4"
cd "$project_path"
test ! -f pnpm-workspace.yaml
test ! -f pnpm-lock.yaml
EXPECTED_PACKAGE_MANAGER="$package_manager" \
CHECK_WORKSPACE_PROTOCOL="$check_workspace_protocol" \
IS_MONOREPO="$is_monorepo" \
node <<'NODE'
const fs = require("node:fs")
const path = require("node:path")
const expectedPackageManager = process.env.EXPECTED_PACKAGE_MANAGER
const checkWorkspaceProtocol =
process.env.CHECK_WORKSPACE_PROTOCOL === "true"
const isMonorepo = process.env.IS_MONOREPO === "true"
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
if (isMonorepo) {
const workspaces = pkg.workspaces ?? []
if (!Array.isArray(workspaces)) {
throw new Error("Expected package.json workspaces to be an array.")
}
if (workspaces.length === 0) {
throw new Error("Expected package.json workspaces to have entries.")
}
for (const workspace of ["sharp", "unrs-resolver", "esbuild"]) {
if (workspaces.includes(workspace)) {
throw new Error(`Unexpected workspace entry: ${workspace}`)
}
}
if (!pkg.packageManager?.startsWith(`${expectedPackageManager}@`)) {
throw new Error(
`Expected packageManager to use ${expectedPackageManager}, got ${pkg.packageManager}`
)
}
} else {
if (pkg.workspaces !== undefined) {
throw new Error("Did not expect package.json workspaces for app template.")
}
if (pkg.packageManager !== undefined) {
throw new Error(
`Did not expect packageManager for app template, got ${pkg.packageManager}`
)
}
}
if (checkWorkspaceProtocol) {
const packageJsonFiles = []
function collectPackageJsonFiles(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules") {
continue
}
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
collectPackageJsonFiles(fullPath)
} else if (entry.name === "package.json") {
packageJsonFiles.push(fullPath)
}
}
}
collectPackageJsonFiles(process.cwd())
for (const file of packageJsonFiles) {
const json = fs.readFileSync(file, "utf8")
if (json.includes("workspace:")) {
throw new Error(`Unexpected workspace: protocol in ${file}`)
}
}
}
NODE
}
for mode in "${modes[@]}"; do
project="test-${TEMPLATE}-${mode}-${TEMPLATE_PACKAGE_MANAGER}"
project_path="$template_root/$project"
echo "::group::${TEMPLATE} ${mode} ${TEMPLATE_PACKAGE_MANAGER}"
args=(
init
--defaults
--name "$project"
--template "$TEMPLATE"
--cwd "$template_root"
--silent
)
if [ "$mode" = "monorepo" ]; then
args+=(--monorepo)
is_monorepo="true"
else
args+=(--no-monorepo)
is_monorepo="false"
fi
case "$TEMPLATE_PACKAGE_MANAGER" in
pnpm)
SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \
REGISTRY_URL="$REGISTRY_URL" \
npm_config_user_agent="pnpm/${TEMPLATE_PNPM_VERSION}" \
node "$cli" "${args[@]}"
cd "$project_path"
pnpm install --frozen-lockfile
run_script_if_present typecheck
run_script_if_present build
;;
bun)
(
cd "$template_root"
SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \
REGISTRY_URL="$REGISTRY_URL" \
npm_config_user_agent="bun/$(bun --version)" \
bunx --bun --package "$GITHUB_WORKSPACE/packages/shadcn" \
shadcn "${args[@]}"
)
validate_non_pnpm_project \
"bun" \
"$project_path" \
"false" \
"$is_monorepo"
;;
npm)
(
cd "$template_root"
SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \
REGISTRY_URL="$REGISTRY_URL" \
npm_config_user_agent="npm/$(npm --version)" \
npx --yes --package "$GITHUB_WORKSPACE/packages/shadcn" \
shadcn "${args[@]}"
)
validate_non_pnpm_project \
"npm" \
"$project_path" \
"true" \
"$is_monorepo"
;;
yarn)
(
cd "$template_root"
SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \
REGISTRY_URL="$REGISTRY_URL" \
COREPACK_ENABLE_PROJECT_SPEC=0 \
npm_config_user_agent="yarn/$(COREPACK_ENABLE_PROJECT_SPEC=0 yarn --version)" \
yarn dlx --package "$GITHUB_WORKSPACE/packages/shadcn" \
shadcn "${args[@]}"
)
validate_non_pnpm_project \
"yarn" \
"$project_path" \
"false" \
"$is_monorepo"
;;
esac
echo "::endgroup::"
done
BASH
"$root_pnpm" exec start-server-and-test \
"$root_pnpm v4:dev" \
http://localhost:4000 \
"bash $validation_script"

View File

@@ -8,9 +8,6 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: pnpm test name: pnpm test
env:
NEXT_PUBLIC_APP_URL: http://localhost:4000
NEXT_PUBLIC_V0_URL: https://v0.dev
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
@@ -19,13 +16,13 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 22 node-version: 18
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v2.2.4
name: Install pnpm name: Install pnpm
id: pnpm-install id: pnpm-install
with: with:
version: 10.33.4 version: 8.6.1
run_install: false run_install: false
- name: Get pnpm store directory - name: Get pnpm store directory
@@ -39,46 +36,7 @@ jobs:
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install
- run: pnpm test - run: pnpm test
react:
runs-on: ubuntu-latest
name: pnpm test (@shadcn/react)
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
id: pnpm-install
with:
version: 10.33.4
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- run: pnpm --filter=@shadcn/react test

View File

@@ -1,84 +0,0 @@
name: Validate Registries
on:
pull_request:
paths:
- "apps/v4/registry/directory.json"
push:
branches:
- main
paths:
- "apps/v4/registry/directory.json"
jobs:
validate:
runs-on: ubuntu-latest
name: pnpm validate:registries
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v3
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 file = "apps/v4/registry/directory.json"
const reservedNamespaces = new Set(
process.env.RESERVED_NAMESPACES.split(",").filter(Boolean)
)
function readNames() {
return JSON.parse(fs.readFileSync(file, "utf8")).map(
(entry) => entry.name
)
}
const violations = readNames()
.filter((name) => reservedNamespaces.has(name))
.map((name) => `${file}: ${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
with:
version: 10.33.4
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Validate registries
run: pnpm --filter=v4 validate:registries

19
.gitignore vendored
View File

@@ -15,7 +15,6 @@ build
# misc # misc
.DS_Store .DS_Store
.eslintcache
*.pem *.pem
# debug # debug
@@ -35,21 +34,3 @@ yarn-error.log*
.contentlayer .contentlayer
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
# ide
.idea
.fleet
.vscode
.notes
.playwright-mcp
.playwright-cli
shadcn-workspace
# vitest browser mode writes these only on test failure.
__screenshots__
.codex-artifacts
.tmp*
CONTEXT.md
docs/adr

4
.husky/commit-msg Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx commitlint --edit $1

1
.npmrc
View File

@@ -1,2 +1 @@
auto-install-peers=true auto-install-peers=true
link-workspace-packages=true

View File

@@ -3,6 +3,4 @@ node_modules
.next .next
build build
.contentlayer .contentlayer
**/fixtures apps/www/pages/api/registry.json
deprecated
apps/v4/registry/styles/**/*.css

19
.vscode/settings.json vendored
View File

@@ -3,19 +3,8 @@
{ "pattern": "apps/*/" }, { "pattern": "apps/*/" },
{ "pattern": "packages/*/" } { "pattern": "packages/*/" }
], ],
"tailwindCSS.classFunctions": ["cva", "cn"], "tailwindCSS.experimental.classRegex": [
"vitest.debugExclude": [ ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
"<node_internals>/**", ["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
"**/node_modules/**", ]
"**/fixtures/**"
],
"files.exclude": {
"deprecated": true
},
"search.exclude": {
"apps/v4/registry/radix-*": true,
"apps/v4/public/r/*": true,
"packages/shadcn/test/fixtures/*": true,
"apps/v4/styles/*": true
}
} }

View File

@@ -20,25 +20,28 @@ This repository is structured as follows:
``` ```
apps apps
└── v4 └── www
├── app ├── app
├── components ├── components
├── content ├── content
└── registry └── registry
── new-york-v4 ── default
│ ├── example
│ └── ui
└── new-york
├── example ├── example
└── ui └── ui
packages packages
└── shadcn └── cli
``` ```
| Path | Description | | Path | Description |
| -------------------- | ---------------------------------------- | | --------------------- | ---------------------------------------- |
| `apps/v4/app` | The Next.js application for the website. | | `apps/www/app` | The Next.js application for the website. |
| `apps/v4/components` | The React components for the website. | | `apps/www/components` | The React components for the website. |
| `apps/v4/content` | The content for the website. | | `apps/www/content` | The content for the website. |
| `apps/v4/registry` | The registry for the components. | | `apps/www/registry` | The registry for the components. |
| `packages/shadcn` | The `shadcn` package. | | `packages/cli` | The `shadcn-ui` package. |
## Development ## Development
@@ -79,58 +82,37 @@ You can use the `pnpm --filter=[WORKSPACE]` command to start the development pro
1. To run the `ui.shadcn.com` website: 1. To run the `ui.shadcn.com` website:
```bash ```bash
pnpm --filter=v4 dev pnpm --filter=www dev
``` ```
2. To run the `shadcn` package: 2. To run the `shadcn-ui` package:
```bash ```bash
pnpm --filter=shadcn dev pnpm --filter=shadcn-ui dev
``` ```
## Running the CLI Locally
To run the CLI locally, you can follow the workflow:
1. Start by running the dev server:
```bash
pnpm dev
```
2. In another terminal tab, test the CLI by running:
```bash
pnpm shadcn
```
To test the CLI in a specific app, use a command like:
```bash
pnpm shadcn <init | add | ...> -c ~/Desktop/my-app
```
This workflow ensures that you are running the most recent version of the registry and testing the CLI properly in your local environment.
## Documentation ## Documentation
The documentation for this project is located in the `v4` workspace. You can run the documentation locally by running the following command: The documentation for this project is located in the `www` workspace. You can run the documentation locally by running the following command:
```bash ```bash
pnpm --filter=v4 dev pnpm --filter=www dev
``` ```
Documentation is written using [MDX](https://mdxjs.com). You can find the documentation files in the `apps/v4/content/docs` directory. Documentation is written using [MDX](https://mdxjs.com). You can find the documentation files in the `apps/www/content/docs` directory.
## Components ## Components
We use a registry system for developing components. You can find the source code for the components under `apps/v4/registry`. The components are organized by styles. We use a registry system for developing components. You can find the source code for the components under `apps/www/registry`. The components are organized by styles.
```bash ```bash
apps apps
└── v4 └── www
└── registry └── registry
── new-york-v4 ── default
│ ├── example
│ └── ui
└── new-york
├── example ├── example
└── ui └── ui
``` ```
@@ -139,12 +121,7 @@ When adding or modifying components, please ensure that:
1. You make the changes for every style. 1. You make the changes for every style.
2. You update the documentation. 2. You update the documentation.
3. You run `pnpm registry:build` to update the registry. 3. You run `pnpm build:registry` to update the registry.
See [`apps/v4/registry/README.md`](apps/v4/registry/README.md) for how the
registry pipeline is structured and for the faster targeted build modes
(`--style`, `--registry`, `--examples`, `--indexes`) you can use while
iterating locally. Always run the full `pnpm registry:build` before committing.
## Commit Convention ## Commit Convention
@@ -183,9 +160,9 @@ If you have a request for a new component, please open a discussion on GitHub. W
## CLI ## CLI
The `shadcn` package is a CLI for adding components to your project. You can find the documentation for the CLI [here](https://ui.shadcn.com/docs/cli). The `shadcn-ui` package is a CLI for adding components to your project. You can find the documentation for the CLI [here](https://ui.shadcn.com/docs/cli).
Any changes to the CLI should be made in the `packages/shadcn` directory. If you can, it would be great if you could add tests for your changes. Any changes to the CLI should be made in the `packages/cli` directory. If you can, it would be great if you could add tests for your changes.
## Testing ## Testing

View File

@@ -1,12 +1,12 @@
# shadcn/ui # shadcn/ui
A set of beautifully designed components that you can customize, extend, and build on. Start here then make it your own. Open Source. Open Code. **Use this to build your own component library**. Accessible and customizable components that you can copy and paste into your apps. Free. Open Source. **Use this to build your own component library**.
![hero](apps/v4/public/opengraph-image.png) ![hero](apps/www/public/og.jpg)
## Documentation ## Documentation
Visit https://ui.shadcn.com/docs to view the documentation. Visit http://ui.shadcn.com/docs to view the documentation.
## Contributing ## Contributing
@@ -14,4 +14,4 @@ Please read the [contributing guide](/CONTRIBUTING.md).
## License ## License
Licensed under the [MIT license](./LICENSE.md). Licensed under the [MIT license](https://github.com/shadcn/ui/blob/main/LICENSE.md).

View File

@@ -1,59 +0,0 @@
# Releasing
This monorepo publishes two packages independently with [Changesets](https://github.com/changesets/changesets):
- **`shadcn`** — the CLI and tooling.
- **`@shadcn/react`** — headless React primitives.
They version on their own lines. A change to one never bumps the other unless a changeset says so.
## 1. Add a changeset
Every change that should publish needs a changeset. Run:
```sh
pnpm changeset
```
Select the affected package(s) and bump level. One PR can carry separate changesets for `shadcn` and `@shadcn/react` at different levels. A PR with no changeset publishes nothing.
## 2. Stable release
Stable releases are automated by `.github/workflows/release.yml` (the `release` job, on push to `main`):
1. Merged changesets accumulate on `main`.
2. The Changesets action opens/updates a **"Version Packages"** PR that bumps versions and writes changelogs.
3. Merging that PR triggers `changeset publish`, which builds all packages (`pnpm build:packages`) and publishes any whose version is ahead of npm — each to the `latest` tag.
`pnpm build:packages` (`turbo run build --filter=./packages/*`) builds `shadcn` and `@shadcn/react` but never `apps/v4`.
## 3. Prereleases (per-PR snapshots)
Add the **`release: beta`** or **`release: rc`** label to a PR. The `prerelease` job in `release.yml`:
1. Verifies the branch has changesets (a label on the version PR is a no-op, since it consumed them).
2. Runs `changeset version --snapshot <channel>` — stamps a unique `0.0.0-<channel>-<timestamp>` on each changeset'd package.
3. Builds and runs `changeset publish --tag <channel> --no-git-tag`.
4. Uploads the published package list; `prerelease-comment.yml` posts a `pnpm dlx` install line per package and removes the label.
The label selects the **dist-tag/channel**; the **changesets on the branch** select which packages publish. Snapshots are timestamped, so they never touch `latest` and never collide.
```sh
# Install a snapshot from the PR comment, e.g.:
pnpm dlx @shadcn/react@0.0.0-beta-20260624120000
```
## 4. Prerelease trains (sustained `-beta.N` / `-rc.N`)
For a baking release line (e.g. `1.0.0-rc.0`, `-rc.1`, …) rather than throwaway snapshots, use Changesets pre mode:
```sh
pnpm changeset pre enter rc # writes .changeset/pre.json
# ...normal changeset + Version PR cycle now produces -rc.N versions on the rc tag...
pnpm changeset pre exit # back to stable; next Version PR ships X.Y.Z on latest
```
## Notes
- `pnpm-workspace.yaml` sets `minimumReleaseAge: 2880` (48h), so freshly published stable/beta versions take time to resolve in normal installs. Use `pnpm dlx <pkg>@<exact-snapshot-version>` to test immediately.
- Publishing uses npm OIDC/provenance (`id-token: write` + `npm@latest`); no `NPM_TOKEN` secret is needed.

View File

@@ -1,9 +0,0 @@
# Security Policy
If you believe you have found a security vulnerability, we encourage you to let us know right away.
We will investigate all legitimate reports and do our best to quickly fix the problem.
Our preference is that you make use of GitHub's private vulnerability reporting feature to disclose potential security vulnerabilities in our Open Source Software.
To do this, please visit the security tab of the repository and click the [Report a vulnerability](https://github.com/shadcn-ui/ui/security/advisories/new) button.

View File

@@ -1,2 +0,0 @@
NEXT_PUBLIC_V0_URL=https://v0.dev
NEXT_PUBLIC_APP_URL=http://localhost:4000

49
apps/v4/.gitignore vendored
View File

@@ -1,49 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# generated content
.contentlayer
.content-collections
.source
.devtools

View File

@@ -1,8 +0,0 @@
dist
node_modules
.next
build
.contentlayer
registry/__index__.tsx
content/docs/components/calendar.mdx
registry/styles/**/*.css

View File

@@ -1 +0,0 @@
This is a wip registry for the `shadcn` canary version. It has React 19 and Tailwind v4 components.

View File

@@ -1,104 +0,0 @@
"use client"
import { MENU_ACCENTS, type MenuAccentValue } from "@/registry/config"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function MenuAccentPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const currentAccent = MENU_ACCENTS.find(
(accent) => accent.value === params.menuAccent
)
return (
<div className="group/picker relative pr-3 md:pr-0">
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Menu Accent</div>
<div className="text-sm font-medium text-foreground">
{currentAccent?.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">
<svg
xmlns="http://www.w3.org/2000/svg"
width="128"
height="128"
viewBox="0 0 24 24"
fill="none"
className="size-4 text-foreground"
>
<path
d="M19 12.1294L12.9388 18.207C11.1557 19.9949 10.2641 20.8889 9.16993 20.9877C8.98904 21.0041 8.80705 21.0041 8.62616 20.9877C7.53195 20.8889 6.64039 19.9949 4.85726 18.207L2.83687 16.1811C1.72104 15.0622 1.72104 13.2482 2.83687 12.1294M19 12.1294L10.9184 4.02587M19 12.1294H2.83687M10.9184 4.02587L2.83687 12.1294M10.9184 4.02587L8.89805 2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
data-accent={currentAccent?.value}
className="fill-muted-foreground/30 data-[accent=bold]:fill-foreground"
></path>
<path
d="M22 20C22 21.1046 21.1046 22 20 22C18.8954 22 18 21.1046 18 20C18 18.8954 20 17 20 17C20 17 22 18.8954 22 20Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
data-accent={currentAccent?.value}
className="fill-muted-foreground/30 data-[accent=bold]:fill-foreground"
></path>
</svg>
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
>
<PickerRadioGroup
value={currentAccent?.value}
onValueChange={(value) => {
setParams({ menuAccent: value as MenuAccentValue })
}}
>
<PickerGroup>
{MENU_ACCENTS.map((accent) => (
<PickerRadioItem
key={accent.value}
value={accent.value}
closeOnClick={isMobile}
disabled={
accent.value === "bold" &&
(params.menuColor === "default-translucent" ||
params.menuColor === "inverted-translucent")
}
>
{accent.label}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="menuAccent"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -1,88 +0,0 @@
"use client"
import Script from "next/script"
import { type RegistryItem } from "shadcn/schema"
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/styles/base-nova/ui/command"
import { useActionMenu } from "@/app/(app)/(create)/hooks/use-action-menu"
export const CMD_K_FORWARD_TYPE = "cmd-k-forward"
export function ActionMenu({
itemsByBase,
}: {
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
}) {
const {
activeRegistryName,
getCommandValue,
groups,
handleSelect,
open,
setOpen,
} = useActionMenu(itemsByBase)
return (
<CommandDialog open={open} onOpenChange={setOpen} className="animate-none!">
<Command loop>
<CommandInput placeholder="Search" />
<CommandList>
<CommandEmpty>No items found.</CommandEmpty>
<CommandGroup>
{groups.map((group) =>
group.items.map((item) => (
<CommandItem
key={item.id}
value={getCommandValue(item)}
data-checked={activeRegistryName === item.registryName}
className="px-2"
onSelect={() => {
handleSelect(item.registryName)
}}
>
{item.label}
</CommandItem>
))
)}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
)
}
export function ActionMenuScript() {
return (
<Script
id="design-system-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward Cmd/Ctrl + K (and P) to parent
document.addEventListener('keydown', function(e) {
if ((e.key === 'k' || e.key === 'p') && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: '${CMD_K_FORWARD_TYPE}',
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -1,86 +0,0 @@
"use client"
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 {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function BaseColorPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const mounted = useMounted()
const [params, setParams] = useDesignSystemSearchParams()
const currentBaseColor = React.useMemo(
() => BASE_COLORS.find((baseColor) => baseColor.name === params.baseColor),
[params.baseColor]
)
return (
<div className="group/picker relative">
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Base Color</div>
<div className="text-sm font-medium text-foreground">
{currentBaseColor?.title}
</div>
</div>
{mounted && (
<div
style={
{
"--color":
currentBaseColor?.cssVars?.dark?.["muted-foreground"],
} 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"}
>
<PickerRadioGroup
value={currentBaseColor?.name}
onValueChange={(value) => {
setParams({ baseColor: value as BaseColorName })
}}
>
<PickerGroup>
{BASE_COLORS.map((baseColor) => (
<PickerRadioItem
key={baseColor.name}
value={baseColor.name}
closeOnClick={isMobile}
>
{baseColor.title}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="baseColor"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -1,86 +0,0 @@
"use client"
import * as React from "react"
import { BASES } from "@/registry/config"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function BasePicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const currentBase = React.useMemo(
() => BASES.find((base) => base.name === params.base),
[params.base]
)
const handleValueChange = React.useCallback(
(value: string) => {
const newBase = BASES.find((base) => base.name === value)
if (!newBase) {
return
}
setParams({ base: newBase.name })
},
[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">Base</div>
<div className="text-sm font-medium text-foreground">
{currentBase?.title}
</div>
</div>
{currentBase?.meta?.logo && (
<div
className="pointer-events-none absolute top-1/2 right-4 size-4 -translate-y-1/2 text-foreground select-none md:right-2.5 *:[svg]:size-4 *:[svg]:text-foreground!"
dangerouslySetInnerHTML={{
__html: currentBase.meta.logo,
}}
/>
)}
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
>
<PickerRadioGroup
value={currentBase?.name}
onValueChange={handleValueChange}
>
<PickerGroup>
{BASES.map((base) => (
<PickerRadioItem
key={base.name}
value={base.name}
closeOnClick={isMobile}
>
{base.title}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
</div>
)
}

View File

@@ -1,130 +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]
)
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,45 +0,0 @@
"use client"
import * as React from "react"
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"
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) {
const timer = setTimeout(() => setHasCopied(false), 2000)
return () => clearTimeout(timer)
}
}, [hasCopied])
const handleCopy = React.useCallback(() => {
copyToClipboardWithMeta(`--preset ${presetCode}`, {
name: "copy_preset_command",
properties: {
preset: presetCode,
},
})
setHasCopied(true)
}, [presetCode])
return (
<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>
</Button>
)
}

View File

@@ -1,72 +0,0 @@
"use client"
import * as React from "react"
import { BASES } from "@/registry/config"
import { Button } from "@/registry/new-york-v4/ui/button"
import { getDocsPathForItem } from "@/app/(app)/(create)/lib/devtools"
import {
serializeDesignSystemSearchParams,
useDesignSystemSearchParams,
} from "@/app/(app)/(create)/lib/search-params"
export function CreateDevtools() {
const [params, setParams] = useDesignSystemSearchParams()
const previewUrl = React.useMemo(
() =>
serializeDesignSystemSearchParams(
`/preview/${params.base}/${params.item}`,
params
),
[params]
)
const docsUrl = React.useMemo(
() => getDocsPathForItem(params.base, params.item),
[params.base, params.item]
)
if (process.env.NODE_ENV === "production") {
return null
}
return (
<div className="dark absolute bottom-3 left-1/2 z-20 flex -translate-x-1/2 items-center gap-0.5 rounded-xl bg-card/90 p-1 shadow-xl backdrop-blur-xl">
{BASES.map((base) => (
<Button
key={base.name}
variant="ghost"
size="sm"
data-active={params.base === base.name}
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({ base: base.name })}
>
{base.name === "radix" ? "Radix" : "Base"}
</Button>
))}
<div className="mx-0.5 h-4 w-px bg-border/80" />
<Button
asChild
variant="ghost"
size="sm"
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<a href={previewUrl} target="_blank" rel="noreferrer">
Open in New Tab
</a>
</Button>
<div className="mx-0.5 h-4 w-px bg-border/80" />
<Button
asChild
variant="ghost"
size="sm"
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<a href={docsUrl} target="_blank" rel="noreferrer">
Open Docs
</a>
</Button>
</div>
)
}

View File

@@ -1,15 +0,0 @@
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
export function CreateSkeleton() {
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"
>
<Skeleton className="flex-1 rounded-2xl" />
<Skeleton className="min-h-[151px] w-full self-start rounded-2xl md:h-full md:max-h-full md:min-h-0 md:w-(--customizer-width)" />
</div>
</div>
)
}

View File

@@ -1,131 +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 { Button } from "@/styles/base-nova/ui/button"
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". Rendered client-only to
// avoid a useId hydration mismatch on the Base UI dialog trigger. The loading
// placeholder mirrors the trigger button exactly so there is no layout shift.
const ProjectForm = dynamic(
() =>
import("@/app/(app)/(create)/components/project-form").then(
(m) => m.ProjectForm
),
{
ssr: false,
loading: () => (
<Button disabled aria-hidden>
Get Code
</Button>
),
}
)
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 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,323 +0,0 @@
"use client"
import * as React from "react"
import {
buildRegistryTheme,
DEFAULT_CONFIG,
POINTER_CURSOR_SELECTOR,
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
const POINTER_CURSOR_CSS = `@layer base {
${POINTER_CURSOR_SELECTOR} {
cursor: pointer;
}
}
`
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, pointer: boolean) {
return [
buildCssRule(":root", {
...(cssVars.theme ?? {}),
...(cssVars.light ?? {}),
}),
buildCssRule(".dark", cssVars.dark),
pointer ? POINTER_CURSOR_CSS : "",
]
.filter(Boolean)
.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,
pointer,
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" || (style === "sera" && radius !== "none")) {
setSearchParams({ radius: "none" })
return
}
if (style === "rhea" && radius === "large") {
setSearchParams({ radius: "default" })
}
}, [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, pointer)
}, [registryTheme, pointer])
// 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,78 +0,0 @@
"use client"
import Script from "next/script"
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"
export const UNDO_FORWARD_TYPE = "undo-forward"
export const REDO_FORWARD_TYPE = "redo-forward"
export function HistoryButtons() {
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
title="Undo"
disabled={!canGoBack}
onClick={goBack}
>
<HugeiconsIcon icon={Undo02Icon} />
<span className="sr-only">Undo</span>
</Button>
<Button
variant="ghost"
size="icon"
title="Redo"
disabled={!canGoForward}
onClick={goForward}
>
<HugeiconsIcon icon={Redo02Icon} />
<span className="sr-only">Redo</span>
</Button>
</div>
)
}
export function HistoryScript() {
return (
<Script
id="history-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
document.addEventListener('keydown', function(e) {
if (!e.metaKey && !e.ctrlKey) return;
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
var key = e.key.toLowerCase();
if ((key === 'z' && e.shiftKey) || (key === 'y' && e.ctrlKey)) {
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: '${REDO_FORWARD_TYPE}' }, '*');
}
} else if (key === 'z') {
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: '${UNDO_FORWARD_TYPE}' }, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -1,171 +0,0 @@
"use client"
import * as React from "react"
import { iconLibraries, type IconLibraryName } from "@/registry/config"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
const logos = {
lucide: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
d="M14 12a4 4 0 0 0-8 0 8 8 0 1 0 16 0 11.97 11.97 0 0 0-4-8.944"
/>
<path
stroke="currentColor"
d="M10 12a4 4 0 0 0 8 0 8 8 0 1 0-16 0 11.97 11.97 0 0 0 4.063 9"
/>
</svg>
),
tabler: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="none"
viewBox="0 0 32 32"
>
<path
fill="currentColor"
d="M31.288 7.107A8.83 8.83 0 0 0 24.893.712a55.9 55.9 0 0 0-17.786 0A8.83 8.83 0 0 0 .712 7.107a55.9 55.9 0 0 0 0 17.786 8.83 8.83 0 0 0 6.395 6.395c5.895.95 11.89.95 17.786 0a8.83 8.83 0 0 0 6.395-6.395c.95-5.895.95-11.89 0-17.786"
/>
<path
fill="#fff"
d="m17.884 9.076 1.5-2.488 6.97 6.977-2.492 1.494zm-7.96 3.127 7.814-.909 3.91 3.66-.974 7.287-9.582 2.159a3.06 3.06 0 0 1-2.17-.329l5.244-4.897c.91.407 2.003.142 2.587-.626.584-.77.488-1.818-.226-2.484s-1.84-.755-2.664-.21c-.823.543-1.107 1.562-.67 2.412l-5.245 4.89a2.53 2.53 0 0 1-.339-2.017z"
/>
</svg>
),
hugeicons: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="128"
height="128"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M2 9.5H22" stroke="currentColor"></path>
<path
d="M20.5 9.5H3.5L4.23353 15.3682C4.59849 18.2879 4.78097 19.7477 5.77343 20.6239C6.76589 21.5 8.23708 21.5 11.1795 21.5H12.8205C15.7629 21.5 17.2341 21.5 18.2266 20.6239C19.219 19.7477 19.4015 18.2879 19.7665 15.3682L20.5 9.5Z"
stroke="currentColor"
></path>
<path
d="M5 9C5 5.41015 8.13401 2.5 12 2.5C15.866 2.5 19 5.41015 19 9"
stroke="currentColor"
></path>
</svg>
),
phosphor: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
width="32"
height="32"
>
<path fill="none" d="M0 0h32v32H0z" />
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5h9v16H9zm9 16v9a9 9 0 0 1-9-9M9 5l9 16m0 0h1a8 8 0 0 0 0-16h-1"
/>
</svg>
),
remixicon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
>
<path d="M12 2C17.5228 2 22 6.47715 22 12C22 15.3137 19.3137 18 16 18C12.6863 18 10 15.3137 10 12C10 11.4477 9.55228 11 9 11C8.44772 11 8 11.4477 8 12C8 16.4183 11.5817 20 16 20C16.8708 20 17.7084 19.8588 18.4932 19.6016C16.7458 21.0956 14.4792 22 12 22C6.6689 22 2.3127 17.8283 2.0166 12.5713C2.23647 9.45772 4.83048 7 8 7C11.3137 7 14 9.68629 14 13C14 13.5523 14.4477 14 15 14C15.5523 14 16 13.5523 16 13C16 8.58172 12.4183 5 8 5C6.50513 5 5.1062 5.41032 3.90918 6.12402C5.72712 3.62515 8.67334 2 12 2Z" />
</svg>
),
}
export function IconLibraryPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const currentIconLibrary = React.useMemo(
() => iconLibraries[params.iconLibrary as keyof typeof iconLibraries],
[params.iconLibrary]
)
return (
<div className="group/picker relative">
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Icon Library</div>
<div className="text-sm font-medium text-foreground">
{currentIconLibrary?.title}
</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 *:[svg]:text-foreground!">
{logos[currentIconLibrary?.name as keyof typeof logos]}
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
>
<PickerRadioGroup
value={currentIconLibrary?.name}
onValueChange={(value) => {
setParams({ iconLibrary: value as IconLibraryName })
}}
>
<PickerGroup>
{Object.values(iconLibraries).map((iconLibrary) => (
<PickerRadioItem
key={iconLibrary.name}
value={iconLibrary.name}
closeOnClick={isMobile}
>
{iconLibrary.title}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="iconLibrary"
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,108 +0,0 @@
"use client"
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"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
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"
const cachedGroupedItems = React.cache(
(items: Pick<RegistryItem, "name" | "title" | "type">[]) => {
return groupItemsByType(items)
}
)
export function ItemExplorer({
base,
items,
}: {
base: Base["name"]
items: Pick<RegistryItem, "name" | "title" | "type">[]
}) {
const [params, setParams] = useDesignSystemSearchParams()
const groupedItems = React.useMemo(() => cachedGroupedItems(items), [items])
const currentItem = React.useMemo(
() => items.find((item) => item.name === params.item) ?? null,
[items, params.item]
)
return (
<Sidebar
className="sticky z-30 hidden h-full overscroll-none bg-transparent xl:flex"
collapsible="none"
>
<SidebarContent className="-mx-1 no-scrollbar overflow-x-hidden">
{groupedItems.map((group) => (
<Collapsible
key={group.type}
defaultOpen
className="group/collapsible"
>
<SidebarGroup className="px-1 py-0">
<CollapsibleTrigger className="flex w-full items-center gap-1 py-1.5 text-[0.8rem] font-medium [&[data-state=open]>svg]:rotate-90">
<ChevronRightIcon className="size-3.5 text-muted-foreground transition-transform" />
<span>{group.title}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu className="relative ml-1.5 border-l border-border/50 pl-2">
{group.items.map((item, index) => (
<SidebarMenuItem key={item.name} className="relative">
<div
className={cn(
"absolute top-1/2 -left-2 h-px w-2 border-t border-border/50",
index === group.items.length - 1 && "bg-sidebar"
)}
/>
{index === group.items.length - 1 && (
<div className="absolute top-1/2 -bottom-1 -left-2.5 w-1 bg-sidebar" />
)}
<SidebarMenuButton
onClick={() => setParams({ item: item.name })}
className="relative h-[26px] w-fit cursor-pointer overflow-visible border border-transparent text-[0.8rem] font-normal after:absolute after:inset-x-0 after:-inset-y-1 after:z-0 after:rounded-md data-[active=true]:border-accent data-[active=true]:bg-accent 3xl:fixed:w-full 3xl:fixed:max-w-48"
data-active={item.name === currentItem?.name}
isActive={item.name === currentItem?.name}
>
{item.title}
<span className="absolute inset-0 flex w-(--sidebar-width) bg-transparent" />
</SidebarMenuButton>
<Link
href={`/preview/${base}/${item.name}`}
prefetch
className="sr-only"
tabIndex={-1}
>
{item.title}
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
))}
</SidebarContent>
</Sidebar>
)
}

View File

@@ -1,44 +0,0 @@
"use client"
import {
SquareLock01Icon,
SquareUnlock01Icon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@/lib/utils"
import {
useLocks,
type LockableParam,
} from "@/app/(app)/(create)/hooks/use-locks"
export function LockButton({
param,
className,
}: {
param: LockableParam
className?: string
}) {
const { isLocked, toggleLock } = useLocks()
const locked = isLocked(param)
return (
<button
type="button"
title={locked ? "Unlock" : "Lock"}
aria-label={locked ? "Unlock" : "Lock"}
onClick={() => toggleLock(param)}
data-locked={locked}
className={cn(
"flex size-4 cursor-pointer items-center justify-center rounded opacity-0 ring-foreground/60 transition-opacity outline-none group-focus-within/picker:opacity-100 group-hover/picker:opacity-100 focus:opacity-100 focus-visible:ring-1 data-[locked=true]:opacity-100 pointer-coarse:hidden",
className
)}
>
<HugeiconsIcon
icon={locked ? SquareLock01Icon : SquareUnlock01Icon}
strokeWidth={2}
className="size-5 text-foreground"
/>
</button>
)
}

View File

@@ -1,88 +0,0 @@
"use client"
import * as React from "react"
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,
PickerGroup,
PickerItem,
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"
const APPLE_PLATFORM_REGEX = /Mac|iPhone|iPad|iPod/
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()
React.useEffect(() => {
const platform = navigator.platform
const userAgent = navigator.userAgent
setIsMac(APPLE_PLATFORM_REGEX.test(platform || userAgent))
}, [])
return (
<React.Fragment>
<Picker>
<PickerTrigger
className={cn(
"flex items-center justify-between gap-2 rounded-lg px-1.75 ring-1 ring-foreground/10 focus-visible:ring-1",
className
)}
>
<span className="font-medium">Menu</span>
<HugeiconsIcon icon={Menu09Icon} strokeWidth={2} className="size-5" />
</PickerTrigger>
<PickerContent side="right" align="start" alignOffset={-8}>
<PickerGroup>
<PickerItem onClick={openActionMenu}>
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>
<PickerItem onClick={toggleTheme}>
Light/Dark <PickerShortcut>D</PickerShortcut>
</PickerItem>
</PickerGroup>
<PickerSeparator />
<PickerGroup>
<PickerItem onClick={goBack} disabled={!canGoBack}>
Undo <PickerShortcut>{isMac ? "⌘Z" : "Ctrl+Z"}</PickerShortcut>
</PickerItem>
<PickerItem onClick={goForward} disabled={!canGoForward}>
Redo{" "}
<PickerShortcut>{isMac ? "⇧⌘Z" : "Ctrl+Shift+Z"}</PickerShortcut>
</PickerItem>
<PickerSeparator />
<PickerItem onClick={() => setShowResetDialog(true)}>
Reset <PickerShortcut>R</PickerShortcut>
</PickerItem>
</PickerGroup>
</PickerContent>
</Picker>
</React.Fragment>
)
}

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,203 +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
render={<Button variant="outline" className={triggerClassName} />}
>
{label}
</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
render={
<Button variant="outline" type="button" className="h-10" />
}
>
Cancel
</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,293 +0,0 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/registry/bases/base/lib/utils"
import { IconPlaceholder } from "@/app/(app)/(create)/components/icon-placeholder"
function Picker({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function PickerPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function PickerTrigger({ className, ...props }: MenuPrimitive.Trigger.Props) {
return (
<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",
className
)}
{...props}
/>
)
}
function PickerContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 20,
anchor,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "anchor"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
anchor={anchor}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"cn-menu-target z-50 no-scrollbar max-h-(--available-height) w-[calc(var(--available-width)-(--spacing(6)))] min-w-32 origin-(--transform-origin) translate-y-2 overflow-x-hidden overflow-y-auto rounded-xl border-0 bg-neutral-950/80 p-1.5 text-neutral-100 ring-1 ring-neutral-950/80 backdrop-blur-xl outline-none md:w-52 dark:bg-neutral-800/90 dark:ring-neutral-700/50 data-closed:overflow-hidden",
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
<div className="absolute inset-y-0 right-0 left-62 z-40 bg-transparent" />
</MenuPrimitive.Portal>
)
}
function PickerGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function PickerLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-xs font-medium text-neutral-400 data-inset:pl-8",
className
)}
{...props}
/>
)
}
function PickerItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium outline-hidden select-none **:text-neutral-100 focus:bg-neutral-600 focus:text-neutral-100 focus:**:text-neutral-100 data-inset:pl-8 dark:focus:bg-neutral-700/80 pointer-coarse:gap-3 pointer-coarse:py-2.5 pointer-coarse:pl-3 pointer-coarse:text-base data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function PickerSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function PickerSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent/95 focus:text-accent-foreground focus:ring-1 focus:ring-foreground/20 not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-open:bg-accent/95 data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<IconPlaceholder
lucide="ChevronRightIcon"
tabler="IconChevronRight"
hugeicons="ArrowRight01Icon"
phosphor="CaretRightIcon"
remixicon="RiArrowRightSLine"
className="ml-auto"
/>
</MenuPrimitive.SubmenuTrigger>
)
}
function PickerSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof PickerContent>) {
return (
<PickerContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-md bg-popover/90 p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 backdrop-blur-xs duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function PickerCheckboxItem({
className,
children,
checked,
...props
}: MenuPrimitive.CheckboxItem.Props) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent/95 focus:text-accent-foreground focus:ring-1 focus:ring-foreground/20 focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2 flex items-center justify-center">
<MenuPrimitive.CheckboxItemIndicator>
<IconPlaceholder
lucide="CheckIcon"
tabler="IconCheck"
hugeicons="Tick02Icon"
phosphor="CheckIcon"
remixicon="RiCheckLine"
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function PickerRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function PickerRadioItem({
className,
children,
...props
}: MenuPrimitive.RadioItem.Props) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-lg py-1.5 pr-8 pl-2 text-sm font-medium outline-hidden select-none **:text-neutral-100 focus:bg-neutral-600 focus:text-neutral-100 focus:**:text-neutral-100 data-inset:pl-8 dark:focus:bg-neutral-700/80 pointer-coarse:gap-3 pointer-coarse:py-2.5 pointer-coarse:pl-3 pointer-coarse:text-base data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<IconPlaceholder
lucide="CheckIcon"
tabler="IconCheck"
hugeicons="Tick02Icon"
phosphor="CheckIcon"
remixicon="RiCheckLine"
className="size-4 pointer-coarse:size-5"
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function PickerSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn(
"-mx-1.5 my-1.5 h-px bg-neutral-600 dark:bg-neutral-700",
className
)}
{...props}
/>
)
}
function PickerShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-neutral-400! group-focus/dropdown-menu-item:text-neutral-100",
className
)}
{...props}
/>
)
}
export {
Picker,
PickerPortal,
PickerTrigger,
PickerContent,
PickerGroup,
PickerLabel,
PickerItem,
PickerCheckboxItem,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerShortcut,
PickerSub,
PickerSubTrigger,
PickerSubContent,
}

View File

@@ -1,38 +0,0 @@
"use client"
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"
export function PresetHandler() {
const router = useRouter()
const [params, setParams] = useDesignSystemSearchParams()
const hasConverted = React.useRef(false)
React.useEffect(() => {
if (params.preset === "random") {
router.replace(`/create?preset=${generateRandomPreset()}`)
}
}, [params.preset, router])
React.useEffect(() => {
if (hasConverted.current) {
return
}
hasConverted.current = true
if (!params.preset || params.preset === "random") {
return
}
if (isPresetCode(params.preset)) {
return
}
setParams({ base: params.base })
}, [params.preset, params.base, setParams])
return null
}

View File

@@ -1,125 +0,0 @@
"use client"
import * as React from "react"
import { STYLES, type Preset } from "@/registry/config"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function PresetPicker({
presets,
isMobile,
anchorRef,
}: {
presets: readonly Preset[]
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const currentPreset = React.useMemo(() => {
return presets.find(
(preset) =>
preset.base === params.base &&
preset.style === params.style &&
preset.baseColor === params.baseColor &&
preset.theme === params.theme &&
preset.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
)
}, [
presets,
params.base,
params.style,
params.baseColor,
params.theme,
params.chartColor,
params.iconLibrary,
params.font,
params.fontHeading,
params.menuAccent,
params.menuColor,
params.radius,
])
// Filter presets for current base only
const currentBasePresets = React.useMemo(() => {
return presets.filter((preset) => preset.base === params.base)
}, [presets, params.base])
const handlePresetChange = (value: string) => {
const preset = presets.find((p) => p.title === value)
if (!preset) {
return
}
// Update all params including base.
setParams({
base: preset.base,
style: preset.style,
baseColor: preset.baseColor,
theme: preset.theme,
chartColor: preset.chartColor,
iconLibrary: preset.iconLibrary,
font: preset.font,
fontHeading: preset.fontHeading,
menuAccent: preset.menuAccent,
menuColor: preset.menuColor,
radius: preset.radius,
custom: false,
})
}
return (
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Preset</div>
<div className="line-clamp-1 text-sm font-medium text-foreground">
{currentPreset?.description ?? "Custom"}
</div>
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
className="md:w-72"
>
<PickerRadioGroup
value={currentPreset?.title ?? ""}
onValueChange={handlePresetChange}
>
<PickerGroup>
{currentBasePresets.map((preset) => {
const style = STYLES.find((s) => s.name === preset.style)
return (
<PickerRadioItem
key={preset.title}
value={preset.title}
closeOnClick={isMobile}
>
<div className="flex items-center gap-2">
{preset.description}
</div>
</PickerRadioItem>
)
})}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
)
}

View File

@@ -1,16 +0,0 @@
"use client"
export function PreviewStyle() {
return (
<style jsx global>{`
html {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
`}</style>
)
}

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,168 +0,0 @@
"use client"
import * as React from "react"
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/(create)/components/action-menu"
import { CreateDevtools } from "@/app/(app)/(create)/components/create-devtools"
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>
<CreateDevtools />
<PreviewSwitcher />
</div>
)
}

View File

@@ -1,726 +0,0 @@
"use client"
import * as React from "react"
import {
Copy01Icon,
Globe02Icon,
HandPointingRight04Icon,
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,
buildThemeForPreset,
DEFAULT_CONFIG,
type BaseName,
type DesignSystemConfig,
} from "@/registry/config"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "@/styles/base-nova/ui/dialog"
import {
Field,
FieldContent,
FieldDescription,
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 {
ToggleGroup,
ToggleGroupItem,
} from "@/styles/base-nova/ui/toggle-group"
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]
const APPLY_MODES = [
{
value: "full",
title: "Full preset",
description:
"Everything from the preset, including components, theme, and fonts.",
flag: null,
label: "full preset",
},
{
value: "theme",
title: "Theme only",
description:
"Theme tokens only, like colors, radii, and shadows. Components stay as they are.",
flag: "--only theme",
label: "--only theme",
},
{
value: "font",
title: "Fonts only",
description:
"Only preset fonts for body and headings. Components stay as they are.",
flag: "--only font",
label: "--only font",
},
] as const
type ApplyMode = (typeof APPLY_MODES)[number]["value"]
type ProjectFormTab = "new-project" | "existing-project" | "theme"
type CopyTarget = "command" | "apply" | "theme"
type ThemeCssVars = NonNullable<
ReturnType<typeof buildThemeForPreset>["cssVars"]
>
function formatCssVarsRule(selector: string, cssVars?: Record<string, string>) {
const declarations = Object.entries(cssVars ?? {})
.map(([key, value]) => ` --${key}: ${value};`)
.join("\n")
return `${selector} {\n${declarations}\n}`
}
function formatThemeCss(cssVars: ThemeCssVars) {
return [
formatCssVarsRule(":root", cssVars.light),
formatCssVarsRule(".dark", cssVars.dark),
].join("\n\n")
}
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 [copiedTarget, setCopiedTarget] = React.useState<CopyTarget | null>(
null
)
const [applyMode, setApplyMode] = React.useState<ApplyMode>("full")
const [activeTab, setActiveTab] =
React.useState<ProjectFormTab>("new-project")
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 !== DEFAULT_CONFIG.base ? ` --base ${params.base}` : ""
const templateFlag = ` --template ${framework}`
const monorepoFlag = isMonorepo ? " --monorepo" : ""
const rtlFlag = params.rtl ? " --rtl" : ""
const pointerFlag = params.pointer ? " --pointer" : ""
const flags = `${presetFlag}${baseFlag}${templateFlag}${monorepoFlag}${rtlFlag}${pointerFlag}`
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.pointer,
params.rtl,
presetCode,
])
const command = commands[packageManager]
const applyCommands = React.useMemo(() => {
const presetFlag = ` --preset ${presetCode}`
const onlyFlag =
applyMode === "theme"
? " --only theme"
: applyMode === "font"
? " --only font"
: ""
const flags = `${presetFlag}${onlyFlag}`
return IS_LOCAL_DEV
? {
pnpm: `shadcn apply${flags}`,
npm: `shadcn apply${flags}`,
yarn: `shadcn apply${flags}`,
bun: `shadcn apply${flags}`,
}
: {
pnpm: `pnpm dlx shadcn${SHADCN_VERSION} apply${flags}`,
npm: `npx shadcn${SHADCN_VERSION} apply${flags}`,
yarn: `yarn dlx shadcn${SHADCN_VERSION} apply${flags}`,
bun: `bunx --bun shadcn${SHADCN_VERSION} apply${flags}`,
}
}, [applyMode, presetCode])
const applyCommand = applyCommands[packageManager]
const themeConfig = React.useMemo<DesignSystemConfig>(() => {
const isRadiusLocked = params.style === "lyra" || params.style === "sera"
return {
...DEFAULT_CONFIG,
base: params.base,
style: params.style,
baseColor: params.baseColor,
theme: params.theme,
chartColor: params.chartColor,
iconLibrary: params.iconLibrary,
font: params.font,
fontHeading: params.fontHeading,
menuAccent: params.menuAccent,
menuColor: params.menuColor,
radius: isRadiusLocked ? "none" : params.radius,
template: params.template,
rtl: params.rtl,
pointer: params.pointer,
}
}, [
params.base,
params.baseColor,
params.chartColor,
params.font,
params.fontHeading,
params.iconLibrary,
params.menuAccent,
params.menuColor,
params.pointer,
params.radius,
params.rtl,
params.style,
params.template,
params.theme,
])
const themeCss = React.useMemo(() => {
const theme = buildThemeForPreset(themeConfig)
if (!theme.cssVars) {
return ""
}
return formatThemeCss(theme.cssVars)
}, [themeConfig])
React.useEffect(() => {
if (copiedTarget) {
const timer = setTimeout(() => setCopiedTarget(null), 2000)
return () => clearTimeout(timer)
}
}, [copiedTarget])
const handleCopy = React.useCallback(() => {
const properties: Record<string, string> = {
command,
}
if (params.template) {
properties.template = params.template
}
copyToClipboardWithMeta(command, {
name: "copy_npm_command",
properties,
})
setCopiedTarget("command")
}, [command, params.template])
const handleCopyApply = React.useCallback(() => {
copyToClipboardWithMeta(applyCommand, {
name: "copy_apply_command",
properties: {
command: applyCommand,
applyMode,
},
})
setCopiedTarget("apply")
}, [applyCommand, applyMode])
const handleCopyTheme = React.useCallback(() => {
copyToClipboardWithMeta(themeCss, {
name: "copy_theme_code",
properties: {
preset: presetCode,
baseColor: params.baseColor,
theme: params.theme,
format: "css",
},
})
setCopiedTarget("theme")
}, [params.baseColor, params.theme, presetCode, themeCss])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button className={cn(className)} />}>
Get Code
</DialogTrigger>
<DialogContent className="dark top-[64px] no-scrollbar flex max-h-[calc(100svh-2rem)] translate-y-0 flex-col rounded-2xl p-0 shadow-xl **:data-[slot=dialog-close]:top-4.5 **:data-[slot=dialog-close]:right-4 **:data-[slot=field-separator]:h-2 sm:max-w-md">
<div className="flex min-w-0 flex-1 flex-col gap-0 overflow-hidden rounded-2xl">
<DialogHeader className="border-b px-6 py-5">
<ToggleGroup
value={[activeTab]}
onValueChange={(values) =>
setActiveTab((values[0] as typeof activeTab) ?? "new-project")
}
aria-label="Project type"
spacing={2}
className="**:data-[slot=toggle-group-item]:data-pressed:bg-neutral-700/70"
>
<ToggleGroupItem value="new-project">New Project</ToggleGroupItem>
<ToggleGroupItem value="existing-project">
Existing Project
</ToggleGroupItem>
<ToggleGroupItem value="theme">Theme</ToggleGroupItem>
</ToggleGroup>
</DialogHeader>
{activeTab === "new-project" && (
<div className="no-scrollbar overflow-y-auto">
<FieldGroup className="px-6 py-4">
<Field className="gap-3">
<FieldLabel>Template</FieldLabel>
<TemplateGrid
template={params.template}
setParams={setParams}
/>
</Field>
<FieldSeparator className="-mx-6" />
<Field>
<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">
<FieldLabel htmlFor="pointer">
<HugeiconsIcon
icon={HandPointingRight04Icon}
className="size-4 -rotate-90"
/>
Use pointer on buttons
</FieldLabel>
<Switch
id="pointer"
checked={params.pointer}
onCheckedChange={(checked) =>
setParams({ pointer: checked === true })
}
/>
</Field>
<FieldSeparator className="-mx-6" />
<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>
<DialogFooter className="m-0 min-w-0 p-6">
<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}
>
{copiedTarget === "command" ? (
<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">
{copiedTarget === "command" ? "Copied" : "Copy Command"}
</Button>
</div>
</DialogFooter>
</div>
)}
{activeTab === "existing-project" && (
<div className="no-scrollbar overflow-y-auto">
<FieldGroup className="px-6 py-4">
<FieldSet className="gap-3">
<FieldLegend variant="label">Apply Preset</FieldLegend>
<FieldDescription>
Pick which parts of the preset to apply.
</FieldDescription>
<ApplyModeGrid mode={applyMode} setMode={setApplyMode} />
</FieldSet>
</FieldGroup>
<DialogFooter className="m-0 min-w-0 p-6">
<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={handleCopyApply}
>
{copiedTarget === "apply" ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
{Object.entries(applyCommands).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={handleCopyApply} className="h-9 w-full">
{copiedTarget === "apply" ? "Copied" : "Copy Command"}
</Button>
</div>
</DialogFooter>
</div>
)}
{activeTab === "theme" && (
<div className="no-scrollbar overflow-y-auto">
<FieldGroup className="min-w-0 px-6 py-4">
<FieldSet className="min-w-0 gap-3">
<FieldLegend variant="label">Theme Tokens</FieldLegend>
<FieldDescription>
Copy the CSS variables for this preset.
</FieldDescription>
<div className="w-full min-w-0 overflow-hidden rounded-xl border-0 ring-1 ring-border">
<div className="flex items-center gap-2 py-1 pr-1.5 pl-3">
<div className="min-w-0 truncate font-mono text-sm text-muted-foreground">
globals.css
</div>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopyTheme}
>
{copiedTarget === "theme" ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy theme</span>
</Button>
</div>
<div className="relative no-scrollbar max-h-[45svh] overflow-auto border-t bg-popover p-3">
<pre className="min-w-max font-mono leading-normal whitespace-pre">
<code>{themeCss}</code>
</pre>
</div>
</div>
</FieldSet>
</FieldGroup>
<DialogFooter className="m-0 min-w-0 p-6">
<Button onClick={handleCopyTheme} className="h-9 w-full">
{copiedTarget === "theme" ? "Copied" : "Copy Theme"}
</Button>
</DialogFooter>
</div>
)}
</div>
</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 ApplyModeGrid = React.memo(function ApplyModeGrid({
mode,
setMode,
}: {
mode: ApplyMode
setMode: (mode: ApplyMode) => void
}) {
return (
<RadioGroup
value={mode}
onValueChange={(value) => setMode(value as ApplyMode)}
aria-label="Apply"
>
{APPLY_MODES.map((option) => (
<FieldLabel key={option.value} htmlFor={`apply-${option.value}`}>
<Field orientation="horizontal">
<RadioGroupItem value={option.value} id={`apply-${option.value}`} />
<FieldContent>
<FieldTitle>{option.title}</FieldTitle>
<FieldDescription>{option.description}</FieldDescription>
</FieldContent>
</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,111 +0,0 @@
"use client"
import * as React from "react"
import { RADII, type RadiusValue } 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 RadiusPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const isRadiusLocked = params.style === "lyra" || params.style === "sera"
const selectedRadiusName = isRadiusLocked ? "none" : params.radius
const currentRadius = RADII.find(
(radius) => radius.name === selectedRadiusName
)
const defaultRadius = RADII.find((radius) => radius.name === "default")
const otherRadii = RADII.filter((radius) => radius.name !== "default")
return (
<div className="group/picker relative">
<Picker>
<PickerTrigger disabled={isRadiusLocked}>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Radius</div>
<div className="text-sm font-medium text-foreground">
{currentRadius?.label}
</div>
</div>
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 rotate-90 items-center justify-center text-base text-foreground select-none md:right-2.5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
className="text-foreground"
>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 20v-5C4 8.925 8.925 4 15 4h5"
/>
</svg>
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
>
<PickerRadioGroup
value={currentRadius?.name}
onValueChange={(value) => {
if (isRadiusLocked) {
return
}
setParams({ radius: value as RadiusValue })
}}
>
<PickerGroup>
{defaultRadius && (
<PickerRadioItem
key={defaultRadius.name}
value={defaultRadius.name}
closeOnClick={isMobile}
>
{defaultRadius.label}
</PickerRadioItem>
)}
</PickerGroup>
<PickerSeparator />
<PickerGroup>
{otherRadii.map((radius) => (
<PickerRadioItem
key={radius.name}
value={radius.name}
closeOnClick={isMobile}
disabled={params.style === "rhea" && radius.name === "large"}
>
{radius.label}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="radius"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -1,73 +0,0 @@
"use client"
import Script from "next/script"
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"
export const RANDOMIZE_FORWARD_TYPE = "randomize-forward"
export function RandomButton({
variant = "outline",
className,
...props
}: React.ComponentProps<typeof Button>) {
const { randomize } = useRandom()
return (
<Button
variant={variant}
onClick={randomize}
className={cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)}
{...props}
>
<span className="w-full truncate text-center font-medium">Shuffle</span>
</Button>
)
}
export function RandomizeScript() {
return (
<Script
id="randomize-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward r key (shuffle) and Shift+R (reset).
document.addEventListener('keydown', function(e) {
if ((e.key === 'r' || e.key === 'R') && !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 (window.parent && window.parent !== window) {
var type = e.shiftKey
? '${RESET_FORWARD_TYPE}'
: '${RANDOMIZE_FORWARD_TYPE}';
window.parent.postMessage({
type: type,
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -1,34 +0,0 @@
"use client"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/styles/base-nova/ui/alert-dialog"
import { useReset } from "@/app/(app)/(create)/hooks/use-reset"
export function ResetDialog() {
const { showResetDialog, setShowResetDialog, confirmReset } = useReset()
return (
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Reset to defaults?</AlertDialogTitle>
<AlertDialogDescription>
This will reset all customization options to their default values.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmReset}>Reset</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -1,66 +0,0 @@
"use client"
import * as React from "react"
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"
export function ShareButton() {
const [params] = useDesignSystemSearchParams()
const presetCode = usePresetCode()
const [hasCopied, setHasCopied] = React.useState(false)
const shareUrl = React.useMemo(() => {
const origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
const searchParams = new URLSearchParams({
preset: presetCode,
item: params.item,
})
if (params.pointer) {
searchParams.set("pointer", "true")
}
return `${origin}/create?${searchParams.toString()}`
}, [params.item, params.pointer, presetCode])
React.useEffect(() => {
if (hasCopied) {
const timer = setTimeout(() => setHasCopied(false), 2000)
return () => clearTimeout(timer)
}
}, [hasCopied])
const handleCopy = React.useCallback(() => {
copyToClipboardWithMeta(shareUrl, {
name: "copy_create_share_url",
properties: {
url: shareUrl,
},
})
setHasCopied(true)
}, [shareUrl])
return (
<Button variant="outline" className="hidden md:flex" onClick={handleCopy}>
{hasCopied ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
data-icon="inline-start"
/>
) : (
<HugeiconsIcon
icon={Share03Icon}
strokeWidth={2}
data-icon="inline-start"
/>
)}
Share
</Button>
)
}

View File

@@ -1,79 +0,0 @@
"use client"
import * as React from "react"
import { type Style, type StyleName } from "@/registry/config"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function StylePicker({
styles,
isMobile,
anchorRef,
}: {
styles: readonly Style[]
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const currentStyle = styles.find((style) => style.name === params.style)
return (
<div className="group/picker relative">
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Style</div>
<div className="text-sm font-medium text-foreground">
{currentStyle?.title}
</div>
</div>
{currentStyle?.icon && (
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center select-none md:right-2.5">
{React.cloneElement(currentStyle.icon, {
className: "size-4",
})}
</div>
)}
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
>
<PickerRadioGroup
value={currentStyle?.name}
onValueChange={(value) => {
setParams({ style: value as StyleName })
}}
>
<PickerGroup>
{styles.map((style) => (
<PickerRadioItem
value={style.name}
key={style.name}
closeOnClick={isMobile}
>
{style.title}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="style"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -1,124 +0,0 @@
"use client"
import * as React from "react"
import { useMounted } from "@/hooks/use-mounted"
import { BASE_COLORS, type Theme, type ThemeName } 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 ThemePicker({
themes,
isMobile,
anchorRef,
}: {
themes: readonly Theme[]
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const mounted = useMounted()
const [params, setParams] = useDesignSystemSearchParams()
const currentTheme = React.useMemo(
() => themes.find((theme) => theme.name === params.theme),
[themes, params.theme]
)
const currentThemeIsBaseColor = React.useMemo(
() => BASE_COLORS.find((baseColor) => baseColor.name === params.theme),
[params.theme]
)
return (
<div className="group/picker relative">
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Theme</div>
<div className="text-sm font-medium text-foreground">
{currentTheme?.title}
</div>
</div>
{mounted && (
<div
style={
{
"--color":
currentTheme?.cssVars?.dark?.[
currentThemeIsBaseColor ? "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={currentTheme?.name}
onValueChange={(value) => {
setParams({ theme: value as ThemeName })
}}
>
<PickerGroup>
{themes
.filter((theme) =>
BASE_COLORS.find((baseColor) => baseColor.name === theme.name)
)
.map((theme) => {
return (
<PickerRadioItem
key={theme.name}
value={theme.name}
closeOnClick={isMobile}
>
{theme.title}
</PickerRadioItem>
)
})}
</PickerGroup>
<PickerSeparator />
<PickerGroup>
{themes
.filter(
(theme) =>
!BASE_COLORS.find(
(baseColor) => baseColor.name === theme.name
)
)
.map((theme) => {
return (
<PickerRadioItem
key={theme.name}
value={theme.name}
closeOnClick={isMobile}
>
{theme.title}
</PickerRadioItem>
)
})}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="theme"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

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,68 +0,0 @@
"use client"
import * as React from "react"
import { Icons } from "@/components/icons"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/styles/base-nova/ui/dialog"
const STORAGE_KEY = "shadcn-create-welcome-dialog"
export function WelcomeDialog() {
const [isOpen, setIsOpen] = React.useState(false)
React.useEffect(() => {
const dismissed = localStorage.getItem(STORAGE_KEY)
if (!dismissed) {
setIsOpen(true)
}
}, [])
// Stable callback — avoids re-creation on every render. (rerender-functional-setstate)
const handleOpenChange = React.useCallback((open: boolean) => {
setIsOpen(open)
if (!open) {
localStorage.setItem(STORAGE_KEY, "true")
}
}, [])
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
showCloseButton={false}
className="dialog-ring max-w-92 min-w-0 gap-0 overflow-hidden rounded-xl p-0 sm:max-w-sm dark:bg-neutral-900"
>
<div className="flex aspect-[2/1.2] w-full items-center justify-center rounded-t-xl bg-neutral-950 text-center text-neutral-100 sm:aspect-2/1">
<div className="font-mono text-2xl font-bold">
<Icons.logo className="size-12" />
</div>
</div>
<DialogHeader className="gap-1 p-4">
<DialogTitle className="text-left text-base">
Build your own shadcn/ui
</DialogTitle>
<DialogDescription className="text-left leading-relaxed text-foreground">
Customize everything from the ground up. Pick your component
library, font, color scheme, and more.
</DialogDescription>
<DialogDescription className="mt-2 text-left leading-relaxed font-medium text-foreground">
Available for all major React frameworks.
</DialogDescription>
</DialogHeader>
<DialogFooter className="m-0">
<DialogClose render={<Button className="w-full" />}>
Get Started
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,22 +0,0 @@
import { Suspense } from "react"
import { CreateSkeleton } from "@/app/(app)/(create)/components/create-skeleton"
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>
{/* HistoryProvider reads useSearchParams(), which opts this subtree out
of prerendering up to this boundary. The fallback is what paints on
first load, so it must be the page placeholder, not empty. */}
<Suspense fallback={<CreateSkeleton />}>
<HistoryProvider>{children}</HistoryProvider>
</Suspense>
</LocksProvider>
)
}

View File

@@ -1,5 +0,0 @@
import { CreateSkeleton } from "@/app/(app)/(create)/components/create-skeleton"
export default function CreateLoading() {
return <CreateSkeleton />
}

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

@@ -1,142 +0,0 @@
"use client"
import * as React from "react"
import { type RegistryItem } from "shadcn/schema"
import useSWR from "swr"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
import { groupItemsByType } from "@/app/(app)/(create)/lib/utils"
const ACTION_MENU_OPEN_KEY = "create:action-menu-open"
type ActionMenuItem = {
id: string
type: string
label: string
registryName: string
}
type ActionMenuGroup = {
type: string
title: string
items: ActionMenuItem[]
}
type ActionMenuSourceItem = Pick<RegistryItem, "name" | "title" | "type">
const SEARCH_KEYWORDS: Record<string, string> = {
"registry:block": "block blocks component components",
"registry:item": "item items component components",
}
function sortRegistryGroups(groups: ReturnType<typeof groupItemsByType>) {
return [...groups].sort((a, b) => {
if (a.type === b.type) {
return a.title.localeCompare(b.title)
}
if (a.type === "registry:block") {
return -1
}
if (b.type === "registry:block") {
return 1
}
return a.title.localeCompare(b.title)
})
}
export function useActionMenu(
itemsByBase: Record<string, ActionMenuSourceItem[]>
) {
const [params, setParams] = useDesignSystemSearchParams()
const { data: open = false, mutate: setOpenData } = useSWR<boolean>(
ACTION_MENU_OPEN_KEY,
{
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
)
const groups = React.useMemo<ActionMenuGroup[]>(() => {
const currentBaseItems = itemsByBase?.[params.base] ?? []
const sortedRegistryGroups = sortRegistryGroups(
groupItemsByType(currentBaseItems)
)
return sortedRegistryGroups.map((group) => ({
type: group.type,
title: group.title,
items: group.items.map((item) => ({
id: `${group.type}:${item.name}`,
type: group.type,
label: item.title ?? item.name,
registryName: item.name,
})),
}))
}, [itemsByBase, params.base])
const activeRegistryName = params.item
const handleSelect = React.useCallback(
(registryName: string) => {
setParams({ item: registryName })
void setOpenData(false, { revalidate: false })
},
[setOpenData, setParams]
)
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
void setOpenData(nextOpen, { revalidate: false })
},
[setOpenData]
)
const getCommandValue = React.useCallback((item: ActionMenuItem) => {
const keywords = SEARCH_KEYWORDS[item.type] ?? item.type.replace(":", " ")
return `${item.label ?? ""} ${keywords}`.trim()
}, [])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "p" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
void setOpenData((currentOpen = false) => !currentOpen, {
revalidate: false,
})
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [setOpenData])
return {
activeRegistryName,
getCommandValue,
groups,
handleSelect,
open,
setOpen: handleOpenChange,
}
}
export function useActionMenuTrigger() {
const { mutate: setOpenData } = useSWR<boolean>(ACTION_MENU_OPEN_KEY, {
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
})
const openActionMenu = React.useCallback(() => {
void setOpenData(true, { revalidate: false })
}, [setOpenData])
return {
openActionMenu,
}
}

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,185 +0,0 @@
"use client"
import * as React from "react"
import { Suspense } from "react"
import { useSearchParams } from "next/navigation"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(app)/(create)/lib/search-params"
type HistoryContextValue = {
canGoBack: boolean
canGoForward: boolean
goBack: () => void
goForward: () => void
}
const HistoryContext = React.createContext<HistoryContextValue | null>(null)
// Reads useSearchParams() in its own Suspense boundary so the
// provider never blanks out children while search params resolve.
// useSearchParams reflects the *settled* preset, which coalesces the
// transient values nuqs emits mid-update — reading nuqs state directly
// here would record those transients as phantom history entries.
function PresetSync({
onPresetChange,
}: {
onPresetChange: (preset: string) => void
}) {
const searchParams = useSearchParams()
const preset = searchParams.get("preset") ?? ""
React.useEffect(() => {
onPresetChange(preset)
}, [preset, onPresetChange])
return null
}
export function HistoryProvider({ children }: { children: React.ReactNode }) {
// Write through the shared search-params hook (shallow) instead of
// router.replace. router.replace triggers a full server navigation that
// resets the preset on prod — the same failure the customizer avoids by
// going shallow (#11060).
const [, setParams] = useDesignSystemSearchParams()
const [preset, setPreset] = React.useState("")
const entriesRef = React.useRef<string[]>([preset])
const indexRef = React.useRef(0)
const maxIndexRef = React.useRef(0)
const isNavigatingRef = React.useRef(false)
const [index, setIndex] = React.useState(0)
const [maxIndex, setMaxIndex] = React.useState(0)
const onPresetChange = React.useCallback((nextPreset: string) => {
setPreset(nextPreset)
}, [])
React.useEffect(() => {
if (isNavigatingRef.current) {
isNavigatingRef.current = false
return
}
if (preset === entriesRef.current[indexRef.current]) {
return
}
const nextEntries = entriesRef.current.slice(0, indexRef.current + 1)
nextEntries.push(preset)
entriesRef.current = nextEntries
const nextIndex = nextEntries.length - 1
indexRef.current = nextIndex
maxIndexRef.current = nextIndex
setIndex(nextIndex)
setMaxIndex(nextIndex)
}, [preset])
const canGoBack = index > 0
const canGoForward = index < maxIndex
const goBack = React.useCallback(() => {
if (indexRef.current <= 0) {
return
}
isNavigatingRef.current = true
const nextIndex = indexRef.current - 1
indexRef.current = nextIndex
setIndex(nextIndex)
// The first history entry is "" (no preset in the URL); null clears the
// param to restore that state. nuqs accepts null to clear a key, which the
// wrapper's input type does not model.
setParams(
{
preset: entriesRef.current[nextIndex] || null,
} as Partial<DesignSystemSearchParams>,
{ history: "replace" }
)
}, [setParams])
const goForward = React.useCallback(() => {
if (indexRef.current >= maxIndexRef.current) {
return
}
isNavigatingRef.current = true
const nextIndex = indexRef.current + 1
indexRef.current = nextIndex
setIndex(nextIndex)
// The first history entry is "" (no preset in the URL); null clears the
// param to restore that state. nuqs accepts null to clear a key, which the
// wrapper's input type does not model.
setParams(
{
preset: entriesRef.current[nextIndex] || null,
} as Partial<DesignSystemSearchParams>,
{ history: "replace" }
)
}, [setParams])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (!e.metaKey && !e.ctrlKey) {
return
}
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return
}
const key = e.key.toLowerCase()
if ((key === "z" && e.shiftKey) || (key === "y" && e.ctrlKey)) {
e.preventDefault()
goForward()
return
}
if (key === "z") {
e.preventDefault()
goBack()
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [goBack, goForward])
const value = React.useMemo(
() => ({ canGoBack, canGoForward, goBack, goForward }),
[canGoBack, canGoForward, goBack, goForward]
)
return (
<HistoryContext value={value}>
<Suspense>
<PresetSync onPresetChange={onPresetChange} />
</Suspense>
{children}
</HistoryContext>
)
}
export function useHistory() {
const context = React.useContext(HistoryContext)
if (!context) {
throw new Error("useHistory must be used within HistoryProvider")
}
return context
}

View File

@@ -1,69 +0,0 @@
"use client"
import * as React from "react"
import type { DesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
type ParentToIframeMessage = {
type: "design-system-params"
data: DesignSystemSearchParams
}
export const isInIframe = () => {
if (typeof window === "undefined") {
return false
}
return window.self !== window.top
}
export function useIframeMessageListener<
Message extends ParentToIframeMessage,
MessageType extends Message["type"],
>(
messageType: MessageType,
onMessage: (data: Extract<Message, { type: MessageType }>["data"]) => void
) {
const onMessageRef = React.useRef(onMessage)
React.useEffect(() => {
onMessageRef.current = onMessage
}, [onMessage])
React.useEffect(() => {
if (!isInIframe()) {
return
}
const handleMessage = (event: MessageEvent) => {
if (event.data.type === messageType) {
onMessageRef.current(event.data.data)
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [messageType])
}
export function sendToIframe<
Message extends ParentToIframeMessage,
MessageType extends Message["type"],
>(
iframe: HTMLIFrameElement | null,
messageType: MessageType,
data: Extract<Message, { type: MessageType }>["data"]
) {
if (!iframe?.contentWindow) {
return
}
iframe.contentWindow.postMessage(
{
type: messageType,
data,
},
"*"
)
}

View File

@@ -1,59 +0,0 @@
"use client"
import * as React from "react"
export type LockableParam =
| "style"
| "baseColor"
| "theme"
| "chartColor"
| "iconLibrary"
| "font"
| "fontHeading"
| "menuAccent"
| "menuColor"
| "radius"
type LocksContextValue = {
locks: Set<LockableParam>
isLocked: (param: LockableParam) => boolean
toggleLock: (param: LockableParam) => void
}
const LocksContext = React.createContext<LocksContextValue | null>(null)
export function LocksProvider({ children }: { children: React.ReactNode }) {
const [locks, setLocks] = React.useState<Set<LockableParam>>(new Set())
const isLocked = React.useCallback(
(param: LockableParam) => locks.has(param),
[locks]
)
const toggleLock = React.useCallback((param: LockableParam) => {
setLocks((prev) => {
const next = new Set(prev)
if (next.has(param)) {
next.delete(param)
} else {
next.add(param)
}
return next
})
}, [])
const value = React.useMemo(
() => ({ locks, isLocked, toggleLock }),
[locks, isLocked, toggleLock]
)
return <LocksContext value={value}>{children}</LocksContext>
}
export function useLocks() {
const context = React.useContext(LocksContext)
if (!context) {
throw new Error("useLocks must be used within LocksProvider")
}
return context
}

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,249 +0,0 @@
"use client"
import * as React from "react"
import { decodePreset } from "shadcn/preset"
import {
BASE_COLORS,
getThemesForBaseColor,
iconLibraries,
MENU_ACCENTS,
MENU_COLORS,
RADII,
STYLES,
type BaseColorName,
type ChartColorName,
type FontHeadingValue,
type FontValue,
type IconLibraryName,
type MenuAccentValue,
type MenuColorValue,
type RadiusValue,
type StyleName,
type ThemeName,
} from "@/registry/config"
import { useLocks } from "@/app/(app)/(create)/hooks/use-locks"
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
import {
applyBias,
RANDOMIZE_BIASES,
type RandomizeContext,
} from "@/app/(app)/(create)/lib/randomize-biases"
import {
isTranslucentMenuColor,
useDesignSystemSearchParams,
} from "@/app/(app)/(create)/lib/search-params"
import { SHUFFLE_PRESETS } from "@/app/(app)/(create)/lib/shuffle-presets"
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])
// True-random implementation. Kept, but shuffle is now wired to the
// curated preset list below.
const randomizeTrueRandom = 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])
// Picks a random preset from the curated list instead of true random.
// Locked params are preserved over the decoded preset values.
const randomize = React.useCallback(() => {
// Avoid re-picking the preset that is currently applied.
const currentCode = getPresetCode(paramsRef.current)
const availableCodes = SHUFFLE_PRESETS.filter(
(code) => code !== currentCode
)
const decoded = decodePreset(
randomItem(availableCodes.length > 0 ? availableCodes : SHUFFLE_PRESETS)
)
if (!decoded) {
return
}
const current = paramsRef.current
const nextParams = {
style: locks.has("style") ? current.style : (decoded.style as StyleName),
baseColor: locks.has("baseColor")
? current.baseColor
: (decoded.baseColor as BaseColorName),
theme: locks.has("theme") ? current.theme : (decoded.theme as ThemeName),
chartColor: locks.has("chartColor")
? current.chartColor
: ((decoded.chartColor ?? decoded.theme) as ChartColorName),
iconLibrary: locks.has("iconLibrary")
? current.iconLibrary
: (decoded.iconLibrary as IconLibraryName),
font: locks.has("font") ? current.font : (decoded.font as FontValue),
fontHeading: locks.has("fontHeading")
? current.fontHeading
: (decoded.fontHeading as FontHeadingValue),
menuAccent: locks.has("menuAccent")
? current.menuAccent
: (decoded.menuAccent as MenuAccentValue),
menuColor: locks.has("menuColor")
? current.menuColor
: (decoded.menuColor as MenuColorValue),
radius: locks.has("radius")
? current.radius
: (decoded.radius as RadiusValue),
}
// 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, randomizeTrueRandom }
}

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,48 +0,0 @@
"use client"
import * as React from "react"
import { useTheme } from "next-themes"
import { useMetaColor } from "@/hooks/use-meta-color"
export function useThemeToggle() {
const { setTheme, resolvedTheme } = useTheme()
const { setMetaColor, metaColor } = useMetaColor()
React.useEffect(() => {
setMetaColor(metaColor)
}, [metaColor, setMetaColor])
const toggleTheme = React.useCallback(() => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}, [resolvedTheme, setTheme])
// Listen for the D key to toggle theme.
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
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()
toggleTheme()
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [toggleTheme])
return { toggleTheme }
}

View File

@@ -1,29 +0,0 @@
import { type NextRequest } from "next/server"
import { track } from "@vercel/analytics/server"
import { buildInstructions } from "@/app/(app)/(create)/lib/build-instructions"
import { parseDesignSystemConfig } from "@/app/(app)/(create)/lib/parse-config"
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const result = parseDesignSystemConfig(searchParams)
if (!result.success) {
return new Response(result.error, { status: 400 })
}
track("create_app_manual", result.data)
const markdown = buildInstructions(result.data)
return new Response(markdown, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
})
} catch (error) {
return new Response(
error instanceof Error ? error.message : "An unknown error occurred",
{ status: 500 }
)
}
}

View File

@@ -1,74 +0,0 @@
import { describe, expect, it } from "vitest"
import {
buildRegistryBase,
DEFAULT_CONFIG,
POINTER_CURSOR_SELECTOR,
} from "@/registry/config"
import { GET } from "./route"
function createRequest(search = "") {
const searchParams = new URLSearchParams(
Object.entries(DEFAULT_CONFIG).map(([key, value]) => [key, String(value)])
)
const url = new URL(`http://localhost:4000/init${search}`)
for (const [key, value] of url.searchParams) {
searchParams.set(key, value)
}
return {
nextUrl: new URL(`http://localhost:4000/init?${searchParams}`),
} as Parameters<typeof GET>[0]
}
describe("GET /init", () => {
it("returns the full registry base when only is omitted", async () => {
const response = await GET(createRequest())
const json = await response.json()
expect(response.status).toBe(200)
expect(json).toEqual(buildRegistryBase(DEFAULT_CONFIG))
expect(json.css["@layer base"][POINTER_CURSOR_SELECTOR]).toBeUndefined()
})
it("returns pointer cursor css when pointer is enabled", async () => {
const response = await GET(createRequest("?pointer=true"))
const json = await response.json()
expect(response.status).toBe(200)
expect(json.css["@layer base"][POINTER_CURSOR_SELECTOR]).toEqual({
cursor: "pointer",
})
})
it("returns a sparse registry base when only is provided", async () => {
const response = await GET(createRequest("?only=theme"))
const json = await response.json()
expect(response.status).toBe(200)
expect(json.type).toBe("registry:base")
expect(json.config).toEqual({
menuColor: "default",
menuAccent: "subtle",
tailwind: {
baseColor: "neutral",
},
})
expect(json.cssVars.light).toBeDefined()
expect(json.cssVars.light.radius).toBe("0.625rem")
expect(json.dependencies).toBeUndefined()
expect(json.registryDependencies).toBeUndefined()
})
it("rejects unsupported only values", async () => {
const response = await GET(createRequest("?only=icon"))
const json = await response.json()
expect(response.status).toBe(400)
expect(json.error).toBe(
"Invalid only value. Use one or more of: theme, font"
)
})
})

View File

@@ -1,66 +0,0 @@
import { NextResponse, type NextRequest } from "next/server"
import { track } from "@vercel/analytics/server"
import { isPresetCode } from "shadcn/preset"
import { registryItemSchema } from "shadcn/schema"
import {
buildPartialRegistryBase,
buildRegistryBase,
parseRegistryBaseParts,
} from "@/registry/config"
import { parseDesignSystemConfig } from "@/app/(app)/(create)/lib/parse-config"
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const result = parseDesignSystemConfig(searchParams)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
const onlyResult = parseRegistryBaseParts(searchParams.get("only"))
if (!onlyResult.success) {
return NextResponse.json({ error: onlyResult.error }, { status: 400 })
}
const rawPreset = searchParams.get("preset")
const presetCode =
rawPreset && isPresetCode(rawPreset)
? rawPreset
: getPresetCode(result.data)
const registryBase = onlyResult.parts
? buildPartialRegistryBase(result.data, onlyResult.parts)
: buildRegistryBase(result.data)
const parseResult = registryItemSchema.safeParse(registryBase)
if (!parseResult.success) {
return NextResponse.json(
{
error: "Invalid registry base item",
details: parseResult.error.format(),
},
{ status: 500 }
)
}
if (searchParams.get("track") === "1") {
track("create_app", {
...result.data,
preset: presetCode,
})
}
return NextResponse.json(parseResult.data)
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "An unknown error occurred",
},
{ status: 500 }
)
}
}

View File

@@ -1,44 +0,0 @@
import { after, NextResponse, type NextRequest } from "next/server"
import { track } from "@vercel/analytics/server"
import { isPresetCode } from "shadcn/preset"
import { parseDesignSystemConfig } from "@/app/(app)/(create)/lib/parse-config"
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
import { buildV0Payload } from "@/app/(app)/(create)/lib/v0"
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const result = parseDesignSystemConfig(searchParams)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
const rawPreset = searchParams.get("preset")
const presetCode =
rawPreset && isPresetCode(rawPreset)
? rawPreset
: getPresetCode(result.data)
// Defer analytics to after response is sent.
after(() => {
track("create_open_in_v0", {
...result.data,
preset: presetCode,
})
})
const payload = await buildV0Payload(result.data)
return NextResponse.json(payload)
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "An unknown error occurred",
},
{ status: 500 }
)
}
}

View File

@@ -1,73 +0,0 @@
import "server-only"
import { registryItemSchema } from "shadcn/schema"
import { BASES, getThemesForBaseColor, type BaseName } from "@/registry/config"
import {
ALLOWED_ITEM_TYPES,
EXCLUDED_ITEMS,
} from "@/app/(app)/(create)/lib/constants"
export async function getItemsForBase(base: BaseName) {
const { Index } = await import("@/registry/bases/__index__")
const index = Index[base]
if (!index) {
return []
}
return Object.values(index).filter(
(item) =>
ALLOWED_ITEM_TYPES.includes(item.type) &&
!EXCLUDED_ITEMS.includes(item.name)
)
}
export async function getBaseItem(name: string, base: BaseName) {
const { Index } = await import("@/registry/bases/__index__")
const index = Index[base]
if (!index?.[name] || EXCLUDED_ITEMS.includes(name)) {
return null
}
return registryItemSchema.parse(index[name])
}
export async function getBaseComponent(name: string, base: BaseName) {
const { Index } = await import("@/registry/bases/__index__")
const index = Index[base]
if (!index?.[name] || EXCLUDED_ITEMS.includes(name)) {
return null
}
const { Components } = await import("@/registry/bases/__components__")
return Components[base]?.[name] ?? null
}
export async function getAllItems() {
const entries = await Promise.all(
BASES.map(async (base) => {
const items = await getItemsForBase(base.name as BaseName)
const filtered: Pick<
NonNullable<(typeof items)[number]>,
"name" | "title" | "type"
>[] = []
for (const item of items) {
if (item !== null && !/\d+$/.test(item.name)) {
filtered.push({
name: item.name,
title: item.title,
type: item.type,
})
}
}
return [base.name, filtered] as const
})
)
return Object.fromEntries(entries)
}
// Re-export for server-side use.
export { getThemesForBaseColor }

View File

@@ -1,297 +0,0 @@
import dedent from "dedent"
import { UI_COMPONENTS } from "@/lib/components"
import {
buildRegistryBase,
getBodyFont,
getHeadingFont,
getInheritedHeadingFontValue,
type DesignSystemConfig,
} from "@/registry/config"
// Builds step-by-step markdown instructions for manually setting up a project.
export function buildInstructions(config: DesignSystemConfig) {
const registryBase = buildRegistryBase(config)
const normalizedFontHeading =
config.fontHeading === config.font ? "inherit" : config.fontHeading
const dependencies = [
...(registryBase.dependencies ?? []),
"clsx",
"tailwind-merge",
]
const lightVars = Object.entries(registryBase.cssVars?.light ?? {})
.map(([key, value]) => ` --${key}: ${value};`)
.join("\n")
const darkVars = Object.entries(registryBase.cssVars?.dark ?? {})
.map(([key, value]) => ` --${key}: ${value};`)
.join("\n")
const font = getBodyFont(config.font)
const headingFont =
normalizedFontHeading === "inherit"
? undefined
: getHeadingFont(normalizedFontHeading)
const sections = [
buildDependenciesSection(dependencies),
buildUtilsSection(),
buildCssSection(
lightVars,
darkVars,
normalizedFontHeading === "inherit"
? getInheritedHeadingFontValue(config.font)
: "var(--font-heading)"
),
buildFontSection(font, headingFont),
buildComponentsJsonSection(config),
buildAvailableComponentsSection(config),
config.rtl ? buildRtlSection(config) : null,
]
return sections.filter(Boolean).join("\n\n---\n\n")
}
function buildDependenciesSection(dependencies: string[]) {
const list = dependencies.map((dep) => `- ${dep}`).join("\n")
return dedent`
## Step 1: Dependencies
The following dependencies are required:
${list}
`
}
function buildUtilsSection() {
return dedent`
## Step 2: Create \`lib/utils.ts\`
\`\`\`ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
\`\`\`
`
}
function buildCssSection(
lightVars: string,
darkVars: string,
fontHeadingValue: string
) {
return dedent`
## Step 3: Set up CSS
Add the following to your global CSS file (e.g. \`app/globals.css\`):
\`\`\`css
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@theme inline {
--font-sans: var(--font-sans);
--font-heading: ${fontHeadingValue};
--font-mono: var(--font-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
${lightVars}
}
.dark {
${darkVars}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
\`\`\`
`
}
function buildFontSection(
font: ReturnType<typeof getBodyFont>,
headingFont: ReturnType<typeof getHeadingFont>
) {
if (!font) {
return null
}
const googleFontsUrl = `https://fonts.google.com/specimen/${font.font.import.replace(/_/g, "+")}`
const headingGoogleFontsUrl = headingFont
? `https://fonts.google.com/specimen/${headingFont.font.import.replace(/_/g, "+")}`
: null
const nextImports = headingFont
? `${font.font.import}, ${headingFont.font.import}`
: font.font.import
const nextDeclarations = [
`const fontSans = ${font.font.import}({`,
` subsets: ["latin"],`,
` variable: "${font.font.variable}",`,
`})`,
headingFont ? `const fontHeading = ${headingFont.font.import}({` : null,
headingFont ? ` subsets: ["latin"],` : null,
headingFont ? ` variable: "${headingFont.font.variable}",` : null,
headingFont ? `})` : null,
]
.filter(Boolean)
.join("\n")
const nextHtmlClassName = headingFont
? 'className={fontSans.variable + " " + fontHeading.variable}'
: `className={fontSans.variable}`
const otherFrameworkCss = [
":root {",
` ${font.font.variable}: ${font.font.family};`,
...(headingFont
? [` ${headingFont.font.variable}: ${headingFont.font.family};`]
: []),
"}",
].join("\n")
const headingSection = headingFont
? dedent`
This config also uses **${headingFont.title.replace(" (Heading)", "")}** for headings (\`${headingFont.font.variable}\`).
`
: ""
return dedent`
## Step 4: Set up the font
This config uses **${font.title}** (\`${font.font.variable}\`).
${headingSection}
### Next.js
\`\`\`tsx
import { ${nextImports} } from "next/font/google"
${nextDeclarations}
// Add the font variable classes to your <html> className.
// <html ${nextHtmlClassName}>
\`\`\`
### Other frameworks
Add the font${headingFont ? "s" : ""} from [Google Fonts](${googleFontsUrl})${headingGoogleFontsUrl ? ` and [Google Fonts](${headingGoogleFontsUrl})` : ""} and set the CSS variable${headingFont ? "s" : ""} to the font famil${headingFont ? "ies" : "y"}:
\`\`\`css
${otherFrameworkCss}
\`\`\`
`
}
function buildComponentsJsonSection(config: DesignSystemConfig) {
const componentsJson = {
$schema: "https://ui.shadcn.com/schema.json",
style: `${config.base}-${config.style}`,
tailwind: {
css: "app/globals.css",
baseColor: config.baseColor,
},
iconLibrary: config.iconLibrary,
aliases: {
components: "@/components",
utils: "@/lib/utils",
ui: "@/components/ui",
lib: "@/lib",
hooks: "@/hooks",
},
}
return dedent`
## Step 5: Create \`components.json\`
Add a \`components.json\` file to the root of your project:
\`\`\`json
${JSON.stringify(componentsJson, null, 2)}
\`\`\`
`
}
function buildAvailableComponentsSection(config: DesignSystemConfig) {
const list = UI_COMPONENTS.join(", ")
const style = `${config.base}-${config.style}`
return dedent`
## Available Components
${list}
To fetch the source for a component, use:
\`https://ui.shadcn.com/r/styles/${style}/<component>.json\`
For documentation and examples, visit:
\`https://ui.shadcn.com/docs/components/${config.base}/<component>\`
`
}
function buildRtlSection(config: DesignSystemConfig) {
const template =
config.template === "next-monorepo" ? "next" : (config.template ?? "next")
return dedent`
## RTL Support
Add \`dir="rtl"\` to your root \`<html>\` element:
\`\`\`html
<html dir="rtl">
\`\`\`
For full RTL setup including the \`DirectionProvider\`, see the [RTL documentation](https://ui.shadcn.com/docs/rtl/${template}).
`
}

View File

@@ -1,3 +0,0 @@
export const ALLOWED_ITEM_TYPES = ["registry:block", "registry:example"]
export const EXCLUDED_ITEMS = ["component-example"]

View File

@@ -1,10 +0,0 @@
import { type BaseName } from "@/registry/config"
export function getDocsPathForItem(base: BaseName, item: string) {
if (item.endsWith("-example")) {
const component = item.slice(0, -"-example".length)
return `/docs/components/${base}/${component}`
}
return "/docs/components"
}

View File

@@ -1,77 +0,0 @@
import { FONT_DEFINITIONS, type FontName } from "@/lib/font-definitions"
type CreateFont = {
family: string
import: string
previewVariable: string
style: {
fontFamily: string
}
variable: string
}
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: {
family: definition.family,
import: definition.import,
previewVariable: definition.previewVariable,
style: {
fontFamily: `var(${definition.previewVariable}), ${definition.family}`,
},
variable: definition.registryVariable,
} satisfies CreateFont,
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"),
createFontOption("eb-garamond"),
createFontOption("instrument-serif"),
] 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,32 +0,0 @@
import { registryItemSchema, type RegistryItem } from "shadcn/schema"
import { BASE_COLORS, THEMES } from "@/registry/config"
export function buildTheme(baseColorName: string, themeName: string) {
const baseColor = BASE_COLORS.find((c) => c.name === baseColorName)
const theme = THEMES.find((t) => t.name === themeName)
if (!baseColor || !theme) {
throw new Error(
`Base color "${baseColorName}" or theme "${themeName}" not found`
)
}
const mergedTheme: RegistryItem = {
name: `${baseColor.name}-${theme.name}`,
title: `${baseColor.title} ${theme.title}`,
type: "registry:theme",
cssVars: {
light: {
...baseColor.cssVars?.light,
...theme.cssVars?.light,
},
dark: {
...baseColor.cssVars?.dark,
...theme.cssVars?.dark,
},
},
}
return registryItemSchema.parse(mergedTheme)
}

View File

@@ -1,79 +0,0 @@
import { describe, expect, it } from "vitest"
import { parseDesignSystemConfig } from "./parse-config"
describe("parseDesignSystemConfig", () => {
it("defaults pointer to false when omitted", () => {
const result = parseDesignSystemConfig(
new URLSearchParams(
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
)
)
expect(result.success).toBe(true)
if (!result.success) {
throw new Error(result.error)
}
expect(result.data.pointer).toBe(false)
})
it("parses pointer=true", () => {
const result = parseDesignSystemConfig(
new URLSearchParams(
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&pointer=true&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
)
)
expect(result.success).toBe(true)
if (!result.success) {
throw new Error(result.error)
}
expect(result.data.pointer).toBe(true)
})
it("defaults missing chartColor from the selected theme", () => {
const result = parseDesignSystemConfig(
new URLSearchParams(
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
)
)
expect(result.success).toBe(true)
if (!result.success) {
throw new Error(result.error)
}
expect(result.data.chartColor).toBe("taupe")
})
it("honors explicit fontHeading and chartColor overrides when a preset is present", () => {
const result = parseDesignSystemConfig(
new URLSearchParams(
"preset=a0&fontHeading=playfair-display&chartColor=emerald"
)
)
expect(result.success).toBe(true)
if (!result.success) {
throw new Error(result.error)
}
expect(result.data.fontHeading).toBe("playfair-display")
expect(result.data.chartColor).toBe("emerald")
})
it("keeps pointer outside preset decoding", () => {
const result = parseDesignSystemConfig(
new URLSearchParams("preset=a0&pointer=true")
)
expect(result.success).toBe(true)
if (!result.success) {
throw new Error(result.error)
}
expect(result.data.pointer).toBe(true)
})
})

View File

@@ -1,54 +0,0 @@
import { decodePreset, isPresetCode } from "shadcn/preset"
import {
designSystemConfigSchema,
type DesignSystemConfig,
} from "@/registry/config"
import { resolvePresetOverrides } from "@/app/(app)/(create)/lib/preset-query"
// Parses design system config from URL search params.
export function parseDesignSystemConfig(searchParams: URLSearchParams) {
let configInput: Record<string, unknown>
const presetParam = searchParams.get("preset")
if (presetParam && isPresetCode(presetParam)) {
const decoded = decodePreset(presetParam)
if (!decoded) {
return { success: false as const, error: "Invalid preset code" }
}
const presetOverrides = resolvePresetOverrides(searchParams, decoded)
configInput = {
...decoded,
...presetOverrides,
base: searchParams.get("base") ?? "base",
template: searchParams.get("template") ?? undefined,
rtl: searchParams.get("rtl") === "true",
pointer: searchParams.get("pointer") === "true",
}
} else {
configInput = {
base: searchParams.get("base"),
style: searchParams.get("style"),
iconLibrary: searchParams.get("iconLibrary"),
baseColor: searchParams.get("baseColor"),
theme: searchParams.get("theme"),
chartColor: searchParams.get("chartColor") ?? undefined,
font: searchParams.get("font"),
fontHeading: searchParams.get("fontHeading") ?? undefined,
menuAccent: searchParams.get("menuAccent"),
menuColor: searchParams.get("menuColor"),
radius: searchParams.get("radius"),
template: searchParams.get("template") ?? undefined,
rtl: searchParams.get("rtl") === "true",
pointer: searchParams.get("pointer") === "true",
}
}
const result = designSystemConfigSchema.safeParse(configInput)
if (!result.success) {
return { success: false as const, error: result.error.issues[0].message }
}
return { success: true as const, data: result.data as DesignSystemConfig }
}

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)
}

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