Compare commits

...

297 Commits

Author SHA1 Message Date
shadcn
dcc95f4983 chore(release): version packages 2026-07-07 12:00:12 +00:00
shadcn
68e1f171aa refactor(create): consolidate create code into a single route group (#11102)
* refactor(create): consolidate create code into a single route group

Move all create code from app/(app)/create and app/(create) into the
nested route group app/(app)/(create), and the preview iframe pages
into app/(view). URLs are unchanged: /create keeps the (app) chrome
(and the header now persists across / <-> /create), /init keeps its
root-level path for the CLI contract, and /preview stays chrome-less
for the customizer iframe.

Registry sources and published payloads are untouched: two temporary
tsconfig path aliases map @/app/(create)/* and the legacy
@/app/(app)/create/* onto the new location. App code imports the real
path.

Also inline parse-config and build-instructions into the create lib so
the init routes are thin HTTP handlers, and remove the icon-placeholder
and lib re-export shims.

* fix
2026-07-07 15:59:14 +04:00
shadcn
dd6ce77cf1 fix(react): hold newly appended scroll anchors while autoScroll is enabled (#11100)
The tail spacer makes a freshly anchored turn read as "at the end", so
reconcileFollowMode re-armed following-bottom one frame after the anchor
placement and the first streamed chunk yanked the reader off the anchor.
Exclude the anchored-to-message hold from arming, matching the existing
settling-jump exclusion.


Claude-Session: https://claude.ai/code/session_0186fcQpHo1aLQC4wRpwKFFu

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:04:10 +04:00
Erick
628e427231 fix(message-scroller): avoid ResizeObserver loop error by coalescing resize handling into rAF (#11085)
Co-authored-by: shadcn <m@shadcn.com>
2026-07-07 09:37:08 +04:00
shadcn
d8ace420ba feat(create): show a loading placeholder for the customizer and preview (#11093)
* feat(create): show a loading placeholder for the customizer and preview

* fix(create): render the placeholder as the suspense fallback so it prerenders
2026-07-06 13:13:00 +04:00
shadcn
4163c1dbea feat(create): pick shuffle presets from a curated list (#11092)
* feat(create): pick shuffle presets from a curated list

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: format

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:32:29 +04:00
Pulkit
468674b1f0 docs(registry): update @pulkitxm homepage to pulkit.page (#11016)
* docs(registry): update @pulkitxm homepage to pulkit.page

* Update directory.json
2026-07-06 11:51:43 +04:00
Yuta Nakano
b13050763e feat(registry): add @pulld to directory (#10997) 2026-07-06 11:50:27 +04:00
sezabut
c4b22943fe feat(registry): add @mozaika to the registry directory (#11090)
Co-authored-by: Sam C <sam@mozaika.design>
2026-07-06 11:49:51 +04:00
Eric Tsai
34f0dabe48 feat(registry): add @ericts registry (#11087) 2026-07-06 11:49:35 +04:00
heykirman
5b5db1f8ea feat(registry): add @nusaiba registry (#11076)
Add Nusaiba motion-first shadcn marketing blocks to the public registry index.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 11:49:20 +04:00
shadcn
d0fae52822 fix 2026-07-03 16:27:55 +04:00
github-actions[bot]
2ef6388ca1 chore(release): version packages (#11083)
* chore(release): version packages

* fix

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-07-03 16:20:38 +04:00
shadcn
f3e7de1175 feat: base (#11082)
* feat(create): default design system config to base ui

* feat(www): default docs and init to base ui

* feat(www): default preview styles to base-nova

* feat(shadcn): default init and prompts to base ui

* docs: default to base ui in guides and examples

* docs: add changelog

* chore: changeset

* fix: address base ui default review findings

* fix: init handling

* feat: add migrate-radix-to-base skills

* fix: typo
2026-07-03 16:14:53 +04:00
jasleenkaur-qed42
a409271270 fix(create): prevent customizer reset when template query param is set (#11074)
* fix(create): prevent customizer reset when template query param is set

Use shallow nuqs updates and remove racing picker effects so selections
persist on /create?template=start. Sync preset to URL once across hook
instances and add regression tests for preset URL encoding.

Fixes #11060

* fix(create): apply undo/redo via shallow preset update

Undo/redo went through router.replace, which triggers a full server
navigation that resets the preset on prod (#11060). Route it through the
shared shallow search-params update instead, and keep the useSearchParams
reader so the transient values nuqs emits mid-update are not recorded as
phantom history entries.

* fix(create): satisfy typecheck and prettier in search-params test

Add the required `template` field to the first buildPresetUrlUpdate
fixture so it matches DesignSystemSearchParams, and apply prettier
formatting to search-params.ts and its test (both were failing
format:check).

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-07-02 19:43:25 +04:00
Utkarsh Singhal
70afde8358 feat(registry): add @vue-bits and @svelte-bits to the registry directory (#11066) 2026-07-02 15:58:38 +04:00
shadcn
574e85e22d Merge branch 'main' of github.com:shadcn-ui/ui 2026-07-02 14:50:44 +04:00
shadcn
3310c45dfb chore: back to neutral 2026-07-02 14:46:25 +04:00
coi
34223a842d refactor(base-drawer): migrate base drawer to @base-ui/react/drawer (#10430)
* refactor(base-drawer): rewrite base drawer wrapper for @base-ui/react/drawer

Replace vaul with @base-ui/react/drawer as the primitive behind the
base drawer wrapper.

Keep the public API aligned with the radix drawer shape while
rewriting DrawerContent to compose Backdrop, Viewport, Popup, and
Content internally.

Keep the registry dependency change in the same commit so the wrapper
rewrite lands as one source-level migration step.

* refactor(drawer): move shared drawer direction rules from stylesheets to primitives

Shared drawer styles previously owned direction-specific layout in CSS.
With base and radix now exposing different direction attributes,
keeping that logic in shared tokens would duplicate primitive-specific
branching in the stylesheets.

Move direction-specific layout into the base/radix drawer wrappers and
leave shared CSS responsible only for visual surface styling.

Also move handle visibility and header alignment into TSX, and unify
the shared drawer token names to cn-drawer-*.

* fix(base-drawer): migrate examples, blocks, and app consumers to render and swipeDirection

Update in-repo base drawer consumers to the new wrapper API.

Replace asChild usage with render, switch direction to swipeDirection,
and align examples, blocks, app consumers, and docs with the new
base drawer usage pattern.

* chore: update registries

* fix(drawer): update base marker example usage

* fix(drawer): clean up base style selectors

* docs: add migration docs

* wip

* fix: nested

* feat: drawer

* fix

* fix

* fix

* fix: pointer events

* fix: clean up radix drawers

* fix: position

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-07-02 14:16:37 +04:00
shadcn
dbf9c5ebc4 chore(deps): bump base-ui to 1.6.0 (#11059) 2026-06-30 10:33:51 +04:00
shadcn
897e9add14 feat(command-menu): add styles group linking to shadcn/create (#11052)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:30:46 +04:00
shadcn
8d6553a7f5 fix: orientation props on attachment (#11053) 2026-06-29 21:29:49 +04:00
shadcn
683073f102 feat(registry): add ai-elements components to new-york-v4 (#11051)
* feat(registry): add ai-elements components to new-york-v4

Adds attachment, bubble, marker, message, and message-scroller (registry:ui) to the new-york-v4 style, ported from radix-vega (also Radix-based, so non-breaking). Regenerates the runtime indexes (__index__.tsx, __components__.tsx) and the built catalog so they stay in sync.

* fix(registry): use resolved orientation for attachment variants

data-orientation used the normalized resolvedOrientation while the variant classes used the raw orientation prop, desyncing them when orientation is null. Use resolvedOrientation for both. Addresses Copilot review on #11051.
2026-06-29 20:56:50 +04:00
shadcn
02e398ab73 feat(skill): add chat component guidance to shadcn skill (#11048)
Add MessageScroller, Message, Bubble, Attachment, and Marker guidance,
driven by a baseline gap analysis: fresh agents hand-rolled all five
primitives, so this adds a rules/chat.md, discovery hooks in SKILL.md,
two evals, and supporting composition/styling rules.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:18:46 +04:00
shadcn
af79276f7e fix(v4): build @shadcn/react before registry build (#11049)
The v4 app imports @shadcn/react/message-scroller, which resolves via the
package's built dist. CI is a fresh clone and registry:build only built
shadcn, so the import was unresolved and the dev server never compiled,
timing out the test job. Build @shadcn/react first.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:54:41 +04:00
Arik Chakma
5a3ad36a5e fix(message-scroller): keep scrollbar gutter stable during autoscroll (#11032) 2026-06-29 16:32:21 +04:00
shrivi-maker
a63e8359ec feat(registry): add @channel3 to the registry directory (#11026)
Co-authored-by: Shrivathsan Sakthisundaram <285776207+shrivi-maker@users.noreply.github.com>
Co-authored-by: WOZCODE <contact@withwoz.com>
2026-06-29 15:24:17 +04:00
Tommaso
a72491cb9b Add evex to registry directory (#11023) 2026-06-27 13:14:17 +04:00
rds_agi
67aec7dcc5 fix(base): wrap dropdown label/items in group in message-scroller commands example (#11025)
DropdownMenuLabel renders Base UI's Menu.GroupLabel, which throws
"MenuGroupContext is missing" (Base UI error #31) unless rendered inside
a Menu.Group. Wrap the label and items in DropdownMenuGroup so the
"Jump to..." menu opens without crashing.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:13:58 +04:00
shadcn
40c7064532 docs: changelog 2026-06-26 22:18:01 +04:00
shadcn
5b3369f6ee fix: sidebar link 2026-06-26 21:49:48 +04:00
shadcn
b31e6d63b0 Merge branch 'main' of github.com:shadcn-ui/ui 2026-06-26 21:43:58 +04:00
shadcn
cf5b227565 docs: remove react page 2026-06-26 21:43:41 +04:00
github-actions[bot]
8055a12f46 chore(release): version packages (#11024)
Co-authored-by: shadcn <m@shadcn.com>
2026-06-26 21:25:48 +04:00
shadcn
18fcf0f766 feat: @shadcn/react (#11022)
* feat(@shadcn/react): add message-scroller package

Add the @shadcn/react headless primitives package with MessageScroller
scroll anchoring, streaming follow, history prepend, and jump-to-message
behavior. Includes geometry helpers, use-render utility, and unit,
browser, and perf tests.

* feat(registry): add chat components

Add MessageScroller, Message, Bubble, Attachment, and Marker registry
sources for base and radix, style variants, preview-03 chat blocks,
and registry index wiring.

* feat(v4): integrate chat components into docs site

Wire chat components into the v4 app with docs routes, example preview
pages, message part renderers, markdown support, registry build updates,
and supporting lib utilities.

* feat(examples): add chat component demos

Add base and radix example demos for MessageScroller, Message, Bubble,
Attachment, Marker, scroll-fade, and shimmer.

* docs: add chat component documentation

Add component and utility docs for the chat component set, update docs
navigation, and add the June 2026 chat components changelog entry.

* chore: regenerate registry JSON output

Rebuild public registry artifacts for all style variants with the new
chat components.

* chore(release): add @shadcn/react publish and CI pipeline

Add Changesets prerelease workflow, browser test job, RELEASING docs,
and monorepo wiring for publishing @shadcn/react independently from
the shadcn CLI.

* docs: fix display of component preview on mobile

* fix

* fix

* docs: add message scroller docs

* style: format

* fix
2026-06-26 21:19:31 +04:00
github-actions[bot]
c520191cd4 chore(release): version packages (#10949)
Co-authored-by: shadcn <m@shadcn.com>
2026-06-26 11:15:28 +04:00
Chai Pin Zheng
35983528c2 docs(registry): add @payload-components to directory (#11006)
Summary:
- Add the @payload-components namespace to the public registry directory.
- Include the live registry URL, homepage, description, and logo.

Validation:
- pnpm validate:registries
- curl checks for /r/registry.json and /r/hero-basic.json
2026-06-24 23:55:45 +04:00
shadcn
8692cd4cc1 fix: hookform zod errors (#11007) 2026-06-24 21:30:16 +04:00
shadcn
d3727d8c45 feat: add a /examples/[base]/[name] route 2026-06-24 20:49:27 +04:00
shadcn
e3f98d49e4 chore: update turborepo (#11005) 2026-06-24 20:30:09 +04:00
shadcn
549852bffc chore: update fumadocs (#11004) 2026-06-24 17:52:21 +04:00
Mohammad Shehadeh
4139cff3a0 feat(registry): add hirael to directory (#10965) 2026-06-24 17:35:08 +04:00
Denish Navadiya
95471a0fb9 feat: add @paceui in trusted registries (#10972) 2026-06-24 12:14:35 +04:00
Ray
b59f68ecc5 Add '@uiable' UI component library details and registry (#10976)
* Add '@uiable' UI component library details

Added new entry for '@uiable' with homepage, URL, description, and logo.

* Added @Uiable registry
2026-06-23 10:40:19 +04:00
Oliver Lewandowski
6956a099b3 feat(registry): add @7ovr to registry directory (#10989) 2026-06-23 10:39:46 +04:00
Liamandrew
2417242b12 feat(registry): add @tetra-ui (#10991) 2026-06-23 10:39:20 +04:00
Aniket Pawar
70a7a0c2a8 Update URL format and logo in directory.json (#10988) 2026-06-21 17:46:10 +04:00
Aniket Pawar
5602b81d83 Add new agent '@agentcn' to directory.json (#10985) 2026-06-21 15:49:35 +04:00
Raashish Aggarwal
3ffd3e1c7c fix(registry): update calendar month grid (#10644) 2026-06-19 11:38:39 +04:00
dependabot[bot]
e03df56fcf chore(deps): bump vitest from 2.1.9 to 3.2.6 (#10898)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 2.1.9 to 3.2.6.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v3.2.6/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 3.2.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: shadcn <m@shadcn.com>
2026-06-18 22:22:32 +04:00
dependabot[bot]
38fb1b6f41 chore(deps): bump astro from 6.3.8 to 6.4.6 in /templates/astro-monorepo (#10960)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 6.3.8 to 6.4.6.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@6.4.6/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 6.4.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 22:18:47 +04:00
shadcn
13eb6a81d1 fix: active style for carousel 2026-06-18 22:16:29 +04:00
Neon
5cc8a2af42 feat(registry): add @uui to registry directory (#10946) 2026-06-18 21:53:38 +04:00
shadcn
365d53b590 fix(shadcn): preserve existing dependency specifiers in package.json (#10967)
Fixes #10525
2026-06-18 21:46:26 +04:00
Saurabh
c2ddedf5d2 chore(registry): update beUI domain (#10961) 2026-06-18 11:16:18 +04:00
wrappixelTeam
c879483c96 Added Shadcn Space UI Kit to figma docs (#10839)
docs: Added Shadcn Space UI Kit to Figma paid section
2026-06-17 10:53:39 +04:00
jack-at-shadcn-ui-blocks
4885a9a7ad feat(registry): add shadcn-ui-blocks (#10950)
A growing library of shadcn/ui blocks, component variants, and templates —
free to start, with a Pro tier.

Co-authored-by: jack-at-shadcn-ui-blocks <293869725+jack-at-shadcn-ui-blocks@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:53:22 +04:00
dependabot[bot]
2f5929269a chore(deps-dev): bump vite in /templates/start-monorepo (#10959)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.14 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 10:52:59 +04:00
dependabot[bot]
2fdf1bb0d4 chore(deps-dev): bump vite in /templates/vite-monorepo (#10958)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.14 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 10:52:49 +04:00
dependabot[bot]
951750bdbe chore(deps-dev): bump vite in /templates/react-router-monorepo (#10957)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.14 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 10:52:38 +04:00
dependabot[bot]
6c5d5d6374 chore(deps-dev): bump vite in /templates/react-router-app (#10956)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.14 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 10:52:29 +04:00
shadcn
6ec117715f feat: add data-slot to spinner 2026-06-16 13:15:44 +04:00
shadcn
e583c773fe fix: dev server perf (#10954)
* fix: dev server perf

* fix
2026-06-16 11:20:07 +04:00
ChingRu
0f154171a7 feat(registry): add @heatmap to the directory (#10803) 2026-06-16 07:37:15 +04:00
shadcn
9197676b3d chore: replace node-fetch (#10905)
* chore: replace node-fetch

* fix(shadcn): surface network failure reason from fetch errors

* fix(shadcn): avoid Error.cause typings for older TS lib targets

* fix(shadcn): avoid stricter undici engine

* fix(shadcn): use undici 7 proxy agent
2026-06-15 14:08:41 +04:00
Ali Hussein
82dce7e945 fix(registry): remove duplicate entries from directory.json (#10912)
219 entries contained 10 duplicate registry names (209 unique).

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: shadcn <m@shadcn.com>
2026-06-15 10:22:53 +04:00
Saurabh
35bc9934bf fix(registry): add beUI logo (#10928) 2026-06-15 10:14:26 +04:00
khanhanh
1fd75c9d7e feat(kaui): add kaui component library (#10929)
* feat(kaui):  add kaui component library

* chore: remove unrelated formatting changes from registry directory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: shadcn <m@shadcn.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:06:21 +04:00
agonist
2ea31d8070 add contentbit registry (#10920) 2026-06-15 09:57:06 +04:00
rajtripathi99
ea9d371a2d Add @untld registry to the directory (#10903)
Registry URL: https://ui.untldlabs.com/r/{name}.json
Homepage: https://ui.untldlabs.com
2026-06-11 20:46:21 +04:00
JOE MON
d15f17d717 feat(registry): add braaile loader registry (#10923) 2026-06-11 20:44:29 +04:00
Saurabh
46ca8c5d4b feat(registry): add beui (#10887) 2026-06-11 20:41:01 +04:00
q32757468
a5eb279650 feat(registry): add gpt-vis registry (#10794) 2026-06-11 19:03:21 +04:00
shadcn
1994caba0b perf: dev server (#10904)
* perf: dev server

* fix
2026-06-10 11:10:01 +04:00
Koishore Roy
1450bea8d6 Add @delego to the registry directory (#10901) 2026-06-10 10:12:19 +04:00
Aniket Pawar
ced2a5beb5 Add new entry for @ogimagecn in directory.json (#10896) 2026-06-09 17:03:32 +04:00
Terra
10f1717a3e Add Saaskit component to directory.json (#10494)
* Add Saaskit component to directory.json

Added Saaskit component with description, URL, author, and logo.

* Update Saaskit entry in directory.json

* Add new registry entry for @saaskit

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:00:24 +04:00
Benedict Umeozor
deba036d50 fix(docs): disable ligatures in code blocks (#10809) 2026-06-08 23:51:25 +04:00
shadcn
5bd81beebf docs: add API reference for shadcn 2026-06-08 20:17:12 +04:00
github-actions[bot]
3f2ff18157 chore(release): version packages (#10873)
Co-authored-by: shadcn <m@shadcn.com>
2026-06-08 17:48:39 +04:00
shadcn
05eb2b968b feat(cli): improve search command (#10886)
- Search across multiple registries and make the registry argument
  optional: omit it to search every registry configured in components.json
  (builtins like @shadcn excluded). Without a components.json or configured
  registries, a clear usage error is printed.
- Add a --type filter (accepts "ui" or "registry:ui", comma-separated)
  with validation against the known item types.
- Fetch registries concurrently with a capped worker pool, preserving
  result order.
- Tolerate per-registry failures when searching all configured registries
  (reported in a structured `errors` field); exit non-zero when every
  registry fails. Usage errors print directly instead of routing through
  handleError.
- MCP parity: optional registries (search-all), a `types` filter, and type
  validation across the search/list/examples tools.
- Keep the public registry surface to `searchRegistries` and make it
  self-contained (clears its own context, useCache defaults to false).
- Consolidate search formatting into registry/search, add the `errors`
  field to searchResultsSchema, and update the skill docs.
2026-06-08 17:46:00 +04:00
Truong Giang
a721cc08e5 feat: add @soralabs registry (#10884)
* feat(registry): add @sora-ui to community registry directory

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(registry): rename @sora-ui to @soralabs in community registry directory

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 12:08:49 +04:00
shadcn
8da4592308 feat: update registry build commands (#10880)
* feat: update registry build commands

* fix
2026-06-06 23:19:46 +04:00
Sadman Sakib
f47d48f316 feat(registry): update diceui registry url to support style (#10881) 2026-06-06 23:19:34 +04:00
Franco Zeta
e6d9d6023b feat(registry): update @stepper logo (#10875) 2026-06-06 22:52:49 +04:00
Harshitha Sompura
7dfd933102 fix(cli): move msw to devDependencies (#10851)
* fix: move msw to devDependencies

* chore: update lockfile after moving msw to devDependencies

* chore: add changeset for msw devDependency fix

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-06-05 21:01:37 +04:00
Andrew Luo
9c6a5ee1b1 add extend to registry directory (#10850) 2026-06-05 19:49:46 +04:00
Anish K Srinivasan
c87897b2a5 feat(registry): add @gamekitui to community registry directory (#10864)
Drop-in, themeable browser games for shadcn (Snake, 2048, Minesweeper, and
more) — each a single self-contained file with zero dependencies.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:46:49 +04:00
joncoronel
c61197f627 Add new UI component libraries to directory.json (#10858) 2026-06-05 19:45:13 +04:00
shadcn
a1fb619cef feat(card): add spacing and edge-to-edge variants (#10872) 2026-06-05 19:32:28 +04:00
Subhadip Jana
d84c4a8ca5 feat(registry): add @grootstudio to community registry directory (#10698) 2026-06-04 10:24:24 +04:00
Ajay Patel
cd54e0927f registry: updated shadcnstudio registry url with style support (#10847) 2026-06-01 20:22:30 +04:00
github-actions[bot]
adac7cae1f chore(release): version packages (#10845)
Co-authored-by: shadcn <m@shadcn.com>
2026-06-01 14:58:03 +04:00
shadcn
7c63c46736 feat(registry): add GitHub registry support (#10842)
* feat: add github scheme

* fix

* fix: validate and search

* docs: update docs for GitHub registries

* docs: add changelog

* fix

* chore: update announcement

* docs(skills): update GitHub registry guidance

* fix(registry): reject option-like GitHub refs

* fix(registry): limit search registry discovery

* fix(registry): bound GitHub validation concurrency

* fix(registry): reject whitespace in GitHub refs

* fix(registry): track URL dependency sources

* test(registry): cover local dependency sources
2026-06-01 14:53:34 +04:00
shadcn
916c012132 Sort registry directory entries alphabetically by name. (#10836) 2026-06-01 11:44:56 +04:00
github-actions[bot]
460ad60d84 chore(release): version packages (#10835)
Co-authored-by: shadcn <m@shadcn.com>
2026-05-31 16:18:29 +04:00
shadcn
8e2d2d1439 feat: add shadcn eject (#10834) 2026-05-31 16:11:01 +04:00
shadcn
67cef8fcb9 fix(v4): update homepage mobile demo fallback images (#10810)
* fix(v4): update homepage mobile demo fallback images

Replace registry dashboard screenshots with compressed CardsDemo captures and narrow the mobile bleed width to 140vw.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(v4): remove container-wrapper padding on homepage

Use p-0 on the demo section wrapper so the mobile preview image aligns flush without extra horizontal inset.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(v4): drop redundant md:p-0 on homepage wrapper

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:21:35 +04:00
shadcn
4ff43ba694 fix(styles): use color-mix for secondary button hover (#10808)
Align secondary button hover with Rhea: mix 5% foreground into
secondary in OKLCH instead of opacity. Updates registry CSS,
shipped button sources, and public registry JSON. Badges unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:37:28 +04:00
shadcn
efdec3ca45 fix(styles): restore primary button hover for Nova and Lyra (#10807)
The default button variant used [a]:hover, which only applies to anchor
elements. Buttons render as <button>, so hover had no effect on create
and in installed projects. Use hover:bg-primary/80 to match other styles.

Fixes #10798

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:13:58 +04:00
shadcn
5c849297d0 feat(release): add beta and rc prerelease labels (#10806) 2026-05-29 15:13:21 +04:00
github-actions[bot]
2baa86081d chore(release): version packages (#10791)
Co-authored-by: shadcn <m@shadcn.com>
2026-05-29 12:10:45 +04:00
shadcn
980f288149 ci(templates): test pnpm 11 (#10790) 2026-05-29 11:15:40 +04:00
Raashish Aggarwal
07900769d9 fix(cli): update template handling for pnpm 11 (#10659)
* fix(cli): allow esbuild builds in Vite templates

* fix(cli): extend pnpm 11 build-script allowlists across app templates

- Add packages: [] to single-app pnpm-workspace.yaml so pnpm 9 does
  not reject the file with "packages field missing or empty".
- Add astro-app, react-router-app, start-app, next-app workspace
  yamls with the build-script allowlist each template needs
  (esbuild, sharp, unrs-resolver as applicable).
- Set msw: false across all app allowlists so the registry component
  install runs cleanly under pnpm 11 without executing msw's
  service-worker postinstall.
- Add a scaffold test pinning the packages:[] + allowBuilds shape
  so the parser keeps treating it as single-app.

* chore: changeset

* fix(templates): allow monorepo pnpm builds

* ci(templates): validate app workspace conversion

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-05-29 08:24:31 +04:00
Artyom Konoplyov
360e8a19c3 fix(transform-rtl): preserve quotes in transformed className literals (#10495)
* fix(transform-rtl): preserve string literal escapes

* chore(changeset): add rtl quote preservation note

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-05-27 22:29:44 +04:00
github-actions[bot]
e2fa0101e3 chore(release): version packages (#10789)
Co-authored-by: shadcn <m@shadcn.com>
2026-05-27 21:09:59 +04:00
shadcn
55ea86f252 chore: update templates (#10786)
* chore: update templates

* fix(cli): parse pnpm workspace packages

* chore(changeset): add shadcn patch

* chore(changeset): update description

* ci(templates): validate bun and npx init

* ci(templates): expand package manager validation

* ci(templates): parallelize validation

* ci(templates): allow yarn template lockfiles

* fix(cli): allow yarn template installs in ci
2026-05-27 21:08:26 +04:00
shadcn
f584f05489 fix: padding 2026-05-27 19:43:38 +04:00
shadcn
a06ba18dcc Revert "chore: update templates (#10784)" (#10785)
This reverts commit f3e16e7db7.
2026-05-27 19:06:53 +04:00
shadcn
f3e16e7db7 chore: update templates (#10784)
* chore: update templates

* ci(templates): validate generated starters

* fix

* fix(templates): support pnpm 9 workspace config

* ci(templates): test supported pnpm version
2026-05-27 19:04:17 +04:00
shadcn
64afddefd9 chore(v4): update base-ui to 1.5.0 (#10783) 2026-05-27 15:27:43 +04:00
shadcn
c873713992 fix(v4): serve registries from directory (#10781)
* fix(v4): serve registries from directory

* fix(v4): statically cache registries route

* fix
2026-05-27 09:16:59 +04:00
shadcn
3751fdfa4c fix(create): update lock state during render (#10782) 2026-05-26 23:56:45 +04:00
github-actions[bot]
c824d6b78d chore(release): version packages (#10780)
Co-authored-by: shadcn <m@shadcn.com>
2026-05-26 22:58:26 +04:00
shadcn
df1752dfe0 feat: rhea (#10779)
* feat: add rhea

* fix: blocks

* feat: build chat example

* fix

* fix: sidebar

* fix

* feat: update home

* fix

* fix

* fix

* feat: optimizine fonts

* feat

* fix

* fix

* fix

* fix

* fix

* fix

* fix: font in preview

* fix
2026-05-26 22:54:07 +04:00
Dominik K.
e826e543f2 fix(registry): restore missing @blockus object in registry JSON (#10778)
The dominik-ui registry entry accidentally dropped the opening brace for @blockus, leaving invalid JSON in both registry files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 22:11:01 +04:00
Dominik K.
f7eecafb45 chore(registry): add dominik-ui (#10776)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 21:44:26 +04:00
LN
6e6cf9ee96 Feat/add registry directory blockus (#10711)
* feat: add new registry entry for blockus with logo and description

* chore: rebuild registries.json with blockus entry
2026-05-26 21:18:51 +04:00
Antunes
5b628e23e3 feat(registry): add @toc-cn directory entry (#10759) 2026-05-25 21:38:57 +04:00
shadcn
4a4dc8eb0f Update pnpm release age settings (#10719)
* chore: update pnpm release age settings

* fix: declare pnpm 10 tool dependencies

* fix: align template pnpm versions
2026-05-22 20:10:00 +04:00
Shuta Kumano
a33becad35 feat(registry): add @gymnopedies registry (#10728)
* feat(registry): add @gymnopedies to directory

* chore(registry): rebuild registries.json
2026-05-22 16:25:16 +04:00
KapishDima
d60e8b6ce3 fix: wrap DirectoryList with React.Suspense (#10727) 2026-05-22 15:56:12 +04:00
github-actions[bot]
072c27fcd5 chore(release): version packages (#10568)
Co-authored-by: shadcn <m@shadcn.com>
2026-05-21 17:57:06 +04:00
shadcn
194dcc4571 docs: update changelog 2026-05-21 17:48:36 +04:00
shadcn
51e3cfaf32 feat(registry): add validate command (#10715)
* feat: implement registry include

* feat: updates

* fix

* refactor: implementation

* fix(registry): correct directory registry json

* fix(registry): stop warning for external registry dependencies

* feat(registry): add validate command
2026-05-21 17:32:34 +04:00
shadcn
c8ab3801ec feat: add include to registry.json (#10708)
* feat: implement registry include

* feat: updates

* fix

* refactor: implementation

* fix(registry): correct directory registry json

* fix(registry): stop warning for external registry dependencies

* fix(registry): address include review feedback
2026-05-21 17:13:04 +04:00
shadcn
731e6dd8a2 fix 2026-05-20 17:27:42 +04:00
Tushar Jolly
d7066f4a2d feat: add @next-ui registry (#10693)
* feat: add @next-ui registry

## New Registry: @next-ui

- **Homepage:** https://nexus-ui.com
- **Registry URL:** https://nexus-ui.com/r/{name}.json
- **Registry index:** https://nexus-ui.com/registry.json
- **Components:** 57 motion-native components for Next.js

### Checklist
- [x] registry.json live at https://nexus-ui.com/registry.json
- [x] Component files live at https://nexus-ui.com/r/{name}.json
- [x] Open source
- [x] Works with shadcn CLI

* Add Nexus Labs registry entry to registries.json
2026-05-20 16:49:50 +04:00
Matt
5274de83d6 feat(registry): add @bklit registry (#10707)
Add Bklit UI to the open source registry index (directory.json and
registries.json) for shadcn add and shadcn search.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 16:44:20 +04:00
Fabian Hiller
7e4dac7f31 feat: add Formisch (form library) examples to docs (#10342)
* feat: add Formisch (form library) examples to docs

* docs: update Formisch docs with additional form methods and examples

* chore: update valibot dependency to version 1.4.0

* style(docs): format formisch imports

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-05-20 15:18:41 +04:00
Charlie Hopkins-Brinicombe
28122dba18 feat: add @trophy-ui registry (#10700) 2026-05-20 09:44:13 +04:00
Parth Sharma
93cde61946 feat(registry): add Wensity (#10676) 2026-05-18 22:13:41 +04:00
lcorrigan704
c2dc06a99c add ui.corr.sh registry (#10675) 2026-05-18 18:57:42 +04:00
shadcn
c9930b7fda fix(toggle-group): update default spacing (#10681) 2026-05-17 12:52:42 +04:00
shadcn
d1149454a8 fix(create): preserve settings when changing style (#10680) 2026-05-17 12:31:37 +04:00
Michael McGovern
36139f6200 feat(registry): add @paddle registry (#10643)
* Add Paddle UI to directory

* Update description for @ncdai #10591
2026-05-14 15:45:15 +04:00
shadcn
15ac1be92b feat: shadcn/create for existing projects (#10622)
* feat: update project form

* feat: switch to toggle

* fix

* fix: color
2026-05-12 15:15:07 +04:00
Sergey Kuznetsov
8ca30ed32c feat(registry): add @turbopills-ui registry (#10579) 2026-05-10 20:34:04 +04:00
Alex Kostyniuk
e2605bc7c2 Update glasscn registry URL (#10597) 2026-05-10 20:33:38 +04:00
Chánh Đại
b8608d0976 Update description for @ncdai (#10591) 2026-05-09 16:50:22 +04:00
shadcn
fc1ca40af4 fix(v4): revert next dependency bump (#10584) 2026-05-08 10:28:15 +04:00
Yasuhiro Yamada
f454f6e4d1 fix(sidebar): use oklch sidebar tokens in outline shadows (#10577)
Co-authored-by: Yasuhiro Yamada <7986171+galoi@users.noreply.github.com>
2026-05-08 05:27:41 +04:00
Aniket Pawar
8cc7073aec feat(registry): add @framecn (#10558)
* Add new registry entry for @framecn

* Add new entry for @framecn with video components

* Fix JSON formatting in registries.json

* revert @text-ui in registries.json
2026-05-08 05:27:02 +04:00
shadcn
031387a471 deps: update next (#10581)
* deps: update next

* deps
2026-05-08 05:26:02 +04:00
Alexandre Schrammel
dd3567c39d feat(registry): add @amplo registry (#10573) 2026-05-07 10:27:07 +04:00
joe
ad2b8891a5 adds text-ui to the registry (#10571) 2026-05-06 23:23:40 +04:00
shadcn
f6e18c65cf perf: isr preview and llm (#10566)
* perf(create): limit preview prerender params

* perf(llm): generate markdown routes on demand
2026-05-06 14:57:42 +04:00
shadcn
1c4a53a37a test(shadcn): derive previous minor assertion (#10567)
* test(shadcn): derive previous minor assertion

* chore(changeset): add previous minor assertion

* chore: update changeset
2026-05-06 14:29:02 +04:00
shadcn
bc2db187aa feat(docs): add clickable mdx heading anchors 2026-05-06 13:27:03 +04:00
Krishna Agarwal
92b4927a80 feat(registry): add @indiacn registry (#10548) 2026-05-06 13:10:45 +04:00
Matheus Lima
3cbabe012e feat(registry): add @cognicatch to the ecosystem directory (#10217)
* feat(registry): add @cognicatch to directory

* feat(registry): add @cognicatch to directory

* build: update registries.json

* build: update registries.json

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-05-06 13:09:20 +04:00
github-actions[bot]
1137b24a97 chore(release): version packages (#10556)
Co-authored-by: shadcn <m@shadcn.com>
2026-05-05 16:56:17 +04:00
shadcn
bb251e2ab6 docs: add changelog 2026-05-05 16:52:06 +04:00
shadcn
28b3e5f360 fix(cli): suggest previous minor on errors (#10559) 2026-05-05 16:15:58 +04:00
shadcn
309d95017f feat(shadcn): alias placeholders in target (#10528)
* feat: add support for package imports

* fix

* test(cli): surface add command failures

* test(cli): remove stale pnpm pin from fixture

* fix(cli): reject invalid package import targets

* fix(cli): address package import review feedback

* feat(shadcn): alias placeholders in target

* docs: update docs for alias and examples

* chore: remove lockfile drift

* chore: clean up

* fix(shadcn): route target aliases by workspace

* docs(registry): document target subdirectories
2026-05-05 14:55:47 +04:00
shadcn
eb42ae25fd feat: add support for package imports (#10519)
* feat: add support for package imports

* fix

* test(cli): surface add command failures

* test(cli): remove stale pnpm pin from fixture

* fix(cli): reject invalid package import targets

* fix(cli): address package import review feedback

* test: expand coverage

* docs: add package imports docs
2026-05-05 12:24:21 +04:00
Franco Zeta
3977fb9ace feat(registry): add @stepper registry (#10552) 2026-05-04 19:04:54 +04:00
Jay Sharma
7865621397 feat: add @evilbuttons registry with animated button components (#10536)
* feat: add @evilbuttons registry with animated button components

* feat(registry): add @delta registry (#10476)

Co-authored-by: shadcn <m@shadcn.com>

* Added @nordaun registry (#10537)

* feat: add @evilbuttons registry with animated button components

* feat(fix): fix mismatched dis...

---------

Co-authored-by: Patrick Prunty <58374462+pprunty@users.noreply.github.com>
Co-authored-by: shadcn <m@shadcn.com>
Co-authored-by: Nordaun <admin@nordaun.com>
2026-04-30 17:02:03 +04:00
Nordaun
b07070cd07 Added @nordaun registry (#10537) 2026-04-29 22:30:23 +04:00
Patrick Prunty
ad68a44717 feat(registry): add @delta registry (#10476)
Co-authored-by: shadcn <m@shadcn.com>
2026-04-29 21:09:28 +04:00
Justin Levine
56161142f1 feat: add @shieldcn registry (#10487) 2026-04-28 21:30:30 +04:00
github-actions[bot]
c2e1a5793f chore(release): version packages (#10517)
Co-authored-by: shadcn <m@shadcn.com>
2026-04-28 20:45:27 +04:00
shadcn
ea6086cbcc feat: add preset commands (#10530)
* feat(cli): add preset commands

* docs(skill): update preset command guidance

* docs(cli): document preset commands

* chore: changeset

* fix(cli): refine preset command output

* fix(cli): align preset decode output

* fix(cli): update preset output fields

* docs(changelog): add preset commands entry

* docs(changelog): show preset command output

* docs(changelog): clarify preset resolve examples

* docs(changelog): refine preset examples

* docs(changelog): add preset command sections

* docs(changelog): show preset resolve output

* docs(changelog): clarify preset open example

* docs(changelog): update preset resolve example

* docs: update preset announcement

* docs: link preset announcement to changelog

* test: increase next init timeout
2026-04-28 20:43:16 +04:00
shadcn
68a69d81f7 chore: add devl registry (#10529) 2026-04-28 12:28:07 +04:00
shadcn
55fd4dc71b feat(shadcn): add code redirect (#10526) 2026-04-28 10:32:29 +04:00
shadcn
6dea65ebcb fix(shadcn): apply for monorepo (#10524)
* fix(shadcn): apply for monorepo

* fix
2026-04-28 09:52:10 +04:00
Gurbinder
ba10089b8d feat(registry): Add @evilcharts registry (#10502) 2026-04-27 15:41:47 +04:00
shadcn
8a814f926b ci: fix signed commits permissions (#10518) 2026-04-27 15:13:07 +04:00
shadcn
c236d0c009 feat: add preset code to shadcn info (#10516)
* feat: shadcn info preset code

* chore: changeset

* refactor(shadcn): simplify color catalog

* refactor(shadcn): clean up preset resolver

* chore: format
2026-04-27 12:17:33 +04:00
Shawn.
fd0e0c369b chore: add @dotmatrix to registry directory (#10504) 2026-04-27 10:54:52 +04:00
shadcn
07d14abde1 ci: check signed commits (#10506) 2026-04-27 10:54:04 +04:00
shadcn
8dd51c49f8 fix(registry): use https for xcn homepage 2026-04-26 19:05:42 +04:00
Alex Kostyniuk
c20e0cc596 feat(registry): add @glasscn registry (#10491)
* feat(registry): add @glasscn registry

* chore(registry): remove unrelated @xcn diff
2026-04-26 19:02:23 +04:00
github-actions[bot]
0126502236 chore(release): version packages (#10489)
Co-authored-by: shadcn <m@shadcn.com>
2026-04-25 14:39:56 +04:00
shadcn
94074e4bb2 ci: release 2026-04-25 14:36:50 +04:00
shadcn
eb6e783fb3 feat: add --pointer option (#10488) 2026-04-25 14:24:25 +04:00
Wolfr
f785bfab44 docs(figma): replace Obra Studio entry with Pro edition (#10474)
- Remove free Obra shadcn/ui entry from Free section
- Add Obra shadcn/ui Pro under Paid
2026-04-25 11:26:31 +04:00
shadcn
cc20c8a794 chore: changelog 2026-04-23 10:15:13 +04:00
Mason James
05948dce8e Update shadcnblocks registry description (#10463) 2026-04-22 15:38:03 +04:00
Ray
5d23df4e35 fix(templates): add notFoundComponent to start root route (#10369) 2026-04-22 10:56:23 +04:00
akazwz
abbdd32953 fix(v4): add missing sera styles to public schema (#10452)
* Add Sera styles to v4 public schema

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(v4): tighten public schema style assertion

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: shadcn <m@shadcn.com>
2026-04-22 09:50:30 +04:00
Jay Sharma
3f14ffa632 Update URL for @xcn entry in directory.json 2026-04-21 19:27:34 +04:00
ericzakariasson
5927f6de80 chore: update cursor plugin description
Made-with: Cursor
2026-04-21 17:47:35 +04:00
shadcn
39eb34104b Merge pull request #10444 from shadcn-ui/changeset-release/main
chore(release): version packages
2026-04-21 16:28:15 +04:00
github-actions[bot]
7cbc7e8d53 chore(release): version packages 2026-04-21 12:26:54 +00:00
shadcn
d0ac558ce2 Merge pull request #10396 from ramonclaudio/docs/dark-mode-tanstack-start
docs(dark-mode): add tanstack start guide
2026-04-21 16:25:48 +04:00
shadcn
bc0c46a93c Merge pull request #10445 from ericzakariasson/add-cursor-plugin-manifest
feat: add Cursor plugin manifest
2026-04-21 16:23:46 +04:00
shadcn
a64575d8a4 Merge pull request #10454 from Bartek532/feat/loading-ui-registry
feat: add loading-ui registry to index
2026-04-21 16:22:16 +04:00
shadcn
5d0cd7819b Merge pull request #10449 from radiumcoders/main
Add @xcn registry entry
2026-04-21 16:21:23 +04:00
shadcn
13478b26b6 Merge pull request #10451 from shadcn-ui/shadcn/apply-only
feat: add apply --only
2026-04-21 16:20:14 +04:00
Jay Sharma
aee8a71679 Merge branch 'main' into main 2026-04-21 17:18:34 +05:30
Bartek532
4507f1c794 chore: refine loading-ui registry description
Update the Loading UI directory description copy and regenerate the public registries index to keep generated metadata in sync.
2026-04-21 13:22:24 +02:00
Bartosz Zagrodzki
81cd2266aa Merge branch 'main' into feat/loading-ui-registry 2026-04-21 13:20:14 +02:00
Bartek532
cf756b1b55 feat: add loading-ui registry to index
Add the Loading UI registry to the curated directory and regenerate registries.json so the new source appears in the public registry index.
2026-04-21 13:19:18 +02:00
shadcn
5e61f9c4a4 test: ensure --radius is coming through 2026-04-21 13:03:40 +04:00
shadcn
c4def9305f docs: update 2026-04-21 13:03:25 +04:00
shadcn
e456fed9d3 feat: add apply --only 2026-04-21 12:57:56 +04:00
Ray
b95cd29508 Merge branch 'main' into docs/dark-mode-tanstack-start 2026-04-21 03:46:24 -04:00
shadcn
11cbc32840 refactor: caching for build registry 2026-04-21 11:25:56 +04:00
shadcn
01539fb4d7 refactor: add getThemeScript 2026-04-21 10:35:34 +04:00
Radiumcoders
e47ee89dcf Add @xcn registry entry 2026-04-20 20:16:26 +05:30
Ray
2f5c32c0b1 Merge branch 'main' into docs/dark-mode-tanstack-start 2026-04-20 10:22:34 -04:00
ericzakariasson
fbfe9f34bb feat: add Cursor plugin manifest
Adds .cursor-plugin/plugin.json so this repo installs as a Cursor
plugin via /add-plugin shadcn-ui/ui.

- Loads the existing skills/shadcn/SKILL.md skill (auto-discovered
  via the manifest's skills field).
- Registers the shadcn MCP server (npx shadcn@latest mcp) inline so
  users get the same MCP config already documented for every other
  client without hand-editing .cursor/mcp.json.
- Reuses skills/shadcn/assets/shadcn.png as the plugin logo.

No skill content or MCP changes — purely manifest wiring.

Made-with: Cursor
2026-04-20 10:40:32 +02:00
shadcn
d55e059fda Merge pull request #10440 from uiNerd16/add-aicanvas-registry
feat: add @aicanvas registry
2026-04-20 12:37:59 +04:00
shadcn
9c572ab778 fix: chartColor in presets 2026-04-20 12:29:55 +04:00
shadcn
91403eeb63 Merge pull request #10439 from shadcn-ui/changeset-release/main
chore(release): version packages
2026-04-20 12:18:33 +04:00
github-actions[bot]
3411d53856 chore(release): version packages 2026-04-20 08:16:18 +00:00
shadcn
efa2b38d07 Merge pull request #10179 from EthanThatOneKid/fix/accept-header-issue-10164
fix(cli): add Accept: application/json header to registry fetch
2026-04-20 12:15:20 +04:00
shadcn
d00605c5fb chore: changeset 2026-04-20 11:55:18 +04:00
shadcn
4bdeea4c63 docs: update docs 2026-04-20 11:55:13 +04:00
shadcn
f632f5d798 feat: rename header 2026-04-20 11:55:06 +04:00
Ethan Davidson
7d6d489f83 Merge branch 'main' into fix/accept-header-issue-10164 2026-04-19 15:59:29 -07:00
uiNerd16
e8b1be1f22 feat: add @aicanvas registry 2026-04-19 22:10:56 +02:00
shadcn
d987955893 Merge pull request #10399 from ysds/registry-exabase
chore(registry): add @exabase registry
2026-04-19 20:54:40 +04:00
shadcn
7b5435ac0b Merge pull request #10436 from shadcn-ui/shadcn/fix-init-git-new-project-only
fix: ensure git init runs for new projects only
2026-04-19 20:49:03 +04:00
shadcn
f289497e35 Merge branch 'main' into shadcn/fix-init-git-new-project-only 2026-04-19 15:06:58 +04:00
shadcn
0d266984e6 Merge pull request #10438 from shadcn-ui/shadcn/release-workflows
Consolidate release workflows and clarify run names
2026-04-19 15:06:49 +04:00
shadcn
cf92d4f8f2 Consolidate release workflows and beta comment handling 2026-04-19 14:59:14 +04:00
shadcn
b7cfc364ac chore: changeset 2026-04-19 13:11:24 +04:00
shadcn
de385d04fc fix: ensure git init runs for new projects only 2026-04-19 12:55:07 +04:00
shadcn
b9f78c8a35 Merge pull request #10418 from shadcn-ui/changeset-release/main
chore(release): version packages
2026-04-16 20:56:50 +04:00
github-actions[bot]
97b9e7b0ae chore(release): version packages 2026-04-16 16:56:01 +00:00
shadcn
e4b25981bf Merge pull request #10416 from shadcn-ui/shadcn/style-sera
feat: sera
2026-04-16 20:54:32 +04:00
shadcn
1017410468 chore: changeset 2026-04-16 20:51:21 +04:00
shadcn
fa71bb8624 fix 2026-04-16 15:52:36 +04:00
shadcn
d99839ec2a fix 2026-04-16 15:15:30 +04:00
shadcn
70b6bfd687 fix 2026-04-16 14:32:05 +04:00
shadcn
541c08f112 fix 2026-04-16 14:22:28 +04:00
shadcn
420433ae6f Merge branch 'main' into shadcn/style-sera 2026-04-16 14:17:19 +04:00
shadcn
a7d77e0cf7 fix 2026-04-16 13:38:49 +04:00
shadcn
7ec2acc87d fix 2026-04-15 21:04:08 +04:00
ysds
eeb5d22fe5 chore(registry): add @exabase registry 2026-04-15 12:08:19 +09:00
Ray
a757e80242 docs(dark-mode): add tanstack start guide 2026-04-14 15:31:40 -04:00
shadcn
84d1d476b1 Merge pull request #9728 from htmujahid/main
Update URL for @shadcn-editor in registries.json
2026-04-14 21:03:02 +04:00
shadcn
a52a606fb5 Merge branch 'main' into shadcn/style-sera 2026-04-14 00:04:39 +04:00
shadcn
6ba39bb720 fix 2026-04-14 00:03:30 +04:00
shadcn
dd4b5c287c fix 2026-04-13 17:15:11 +04:00
shadcn
aa534e5875 fix 2026-04-13 12:59:38 +04:00
shadcn
2be9640c88 feat: build registry 2026-04-10 11:35:48 +04:00
shadcn
56567ae21a fix 2026-04-10 05:37:42 +04:00
shadcn
429e258322 fix 2026-04-09 16:12:35 +04:00
shadcn
2f57100061 feat: init 2026-04-09 13:49:02 +04:00
shadcn
fc62d5781d Merge pull request #10337 from ramonclaudio/docs/llms-txt-drift
docs(llms.txt): fix 404 and backfill missing routes
2026-04-09 05:13:18 +04:00
shadcn
d86c5e5939 Merge pull request #9484 from ramonclaudio/fix/docs-copy-page-components-list
fix(docs): replace <ComponentsList /> in copy-page and markdown output
2026-04-08 22:02:29 +04:00
shadcn
8006dd1c93 Merge branch 'main' into fix/docs-copy-page-components-list 2026-04-08 21:43:08 +04:00
Ray
1dcbb4c88a docs(llms.txt): fix 404 and backfill missing routes
llms.txt was added in #8460 and hasn't kept up with the docs tree.
Audited every URL against apps/v4/content/docs and fixed the drift
in one pass.

Removed:
- About (/docs/about): returns 404, no about.mdx exists
- Form (/docs/components/form): points at a phantom. No radix/form.mdx
  exists post-#9304. URL only resolves because of a redirect in
  next.config.mjs, which lands at /docs/forms. That page is already
  listed as 'Forms Overview' in the ## Forms section, and the real
  form library docs (React Hook Form, TanStack Form, Next.js) are
  listed there too. The Form component entry is a stale duplicate.

Added to Overview:
- Skills (/docs/skills)
- Directory (/docs/directory)

Added whole RTL section (new since #8460):
- RTL (/docs/rtl)
- RTL - Next.js
- RTL - Vite
- RTL - TanStack Start

Added to Components:
- Direction (Misc)
- Native Select (Form & Input, after Select)
- Sonner (Feedback & Status, after Toast, since Sonner has its own
  docs page even though Toast already uses it under the hood)

Added to Registry:
- Namespaces
- Add a Registry (open source registry index)
- Open in v0 integration
- registry.json schema docs
- registry-item.json spec docs

Descriptions match the short curated style of the rest of the file.
Noticed while working on #9484.
2026-04-08 12:36:44 -04:00
shadcn
4f4ffde4aa chore: update registries 2026-04-08 20:01:13 +04:00
Ray
6d7a0ed93b fix(docs): replace <ComponentsList /> in copy-page and markdown output
The <ComponentsList /> tag on /docs/components was emitted as-is by
the Copy Page button and the /llm/[slug] markdown endpoint because
getComponentsList() walked components.children for pages directly.
After #9304 restructured the folder into components/radix/ and
components/base/ subfolders, the filter always returned [] and the
tag was replaced with an empty string (or, in the copy-page case,
never replaced at all).

- Reuse getPagesFromFolder() from lib/page-tree so the walker stays
  in sync with the on-screen ComponentsList React component.
- Match the existing llms.txt format: flat absolute URLs (the
  /docs/components/:name -> /docs/components/radix/:name redirect in
  next.config.mjs is the canonical form) plus the frontmatter
  description pulled via source.getPage() on each page.
- Export replaceComponentsList() and call it from
  docs/[[...slug]]/page.tsx so the Copy Page button goes through the
  same replacement path as processMdxForLLMs.
2026-04-08 11:50:07 -04:00
shadcn
b909b0363f Merge pull request #10324 from wrappixelTeam/feat/added-shadcn-dashboard
feat(registry): added new registry ( @shadcn-dashboard )
2026-04-08 19:15:16 +04:00
shadcn
a6fa6893eb Merge pull request #10333 from kapishdima/feat/remocn
feat: added @remocn to directory.json
2026-04-08 19:08:48 +04:00
KapishDima
561586bd98 Merge branch 'main' into feat/remocn 2026-04-08 16:41:56 +03:00
kapishdima
7ddb30aade feat: added @remocn to directory.json 2026-04-08 16:38:33 +03:00
shadcn
024425d45a fix: directory pager 2026-04-08 17:05:50 +04:00
Mihir Koshti
4bdaf48f9b Merge branch 'main' into feat/added-shadcn-dashboard 2026-04-08 18:15:55 +05:30
shadcn
e9546e87ff Merge pull request #10332 from shadcn-ui/shadcn/open-preset
feat: add open preset
2026-04-08 16:43:39 +04:00
shadcn
0b34d581f9 feat: add open preset 2026-04-08 16:33:32 +04:00
shadcn
5c2ed5e90e Merge branch 'main' of github.com:shadcn-ui/ui 2026-04-08 14:42:57 +04:00
shadcn
e9443ccd4a docs: add apply changelog 2026-04-08 14:42:52 +04:00
shadcn
1fe0fe65e8 Merge pull request #10331 from shadcn-ui/shadcn/directory-refactor
refactor: directory
2026-04-08 12:31:58 +04:00
shadcn
6823bad998 refactor: directory 2026-04-08 12:23:34 +04:00
shadcn
398e6c3406 fix: formatting in registries.json 2026-04-08 11:52:03 +04:00
shadcn
710cc27de7 Merge pull request #10330 from Aniket-508/feat/termcn-directory
feat(registry): add @termcn
2026-04-08 10:42:26 +04:00
Aniket Pawar
08212a478d feat(registry): add @termcn 2026-04-08 02:47:14 +00:00
shadcn
d718a8045f Merge pull request #10328 from shadcn-ui/changeset-release/main
chore(release): version packages
2026-04-07 22:13:37 +04:00
github-actions[bot]
2c4678c8c8 chore(release): version packages 2026-04-07 17:48:58 +00:00
shadcn
2466a300f4 Merge pull request #10313 from shadcn-ui/shadcn/apply-preset
feat: add apply
2026-04-07 21:47:54 +04:00
shadcn
66fcf1e853 Merge branch 'shadcn/apply-preset' of github.com:shadcn-ui/ui into shadcn/apply-preset 2026-04-07 21:36:15 +04:00
shadcn
5ebd54198d fix 2026-04-07 21:36:09 +04:00
shadcn
3a2d812510 fix
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-07 21:24:14 +04:00
shadcn
7811557088 fix
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-07 21:23:59 +04:00
shadcn
575f1602a1 fix 2026-04-07 21:05:08 +04:00
htmujahid
50dc9b506b fix: update URL for @shadcn-editor to point to raw GitHub content 2026-04-07 19:10:12 +05:00
shadcn
ae70ecc2f3 Merge branch 'shadcn/apply-preset' of github.com:shadcn-ui/ui into shadcn/apply-preset 2026-04-07 18:02:20 +04:00
shadcn
42284f4e64 test: update rtl 2026-04-07 18:02:05 +04:00
Mihir Koshti
6b5aa16668 updated name to @shadcndashboard 2026-04-07 18:31:07 +05:30
Mihir Koshti
706806a207 feat(registry): added new registry ( @shadcn-dashboard ) 2026-04-07 18:07:26 +05:30
Talha Mujahid
8a7502d7fa Merge branch 'shadcn-ui:main' into main 2026-04-07 17:34:11 +05:00
shadcn
abc65a4871 Merge branch 'main' into shadcn/apply-preset 2026-04-07 16:27:29 +04:00
shadcn
7d5af61468 style: fix code block 2026-04-07 16:11:26 +04:00
shadcn
2badcdc31f Merge pull request #10323 from shadcn-ui/docs/component-composition-sections
docs: add composition section
2026-04-07 15:57:44 +04:00
shadcn
64b8263450 docs: add changelog 2026-04-07 15:49:54 +04:00
shadcn
13b4593f37 fix 2026-04-07 15:49:26 +04:00
shadcn
7dc65da6b2 fix 2026-04-07 15:28:19 +04:00
shadcn
98e56b773c Merge branch 'shadcn/apply-preset' of github.com:shadcn-ui/ui into shadcn/apply-preset 2026-04-07 15:27:56 +04:00
shadcn
7ff9778ff0 fix 2026-04-07 15:27:33 +04:00
shadcn
4af7bbf4ba Merge branch 'main' into docs/component-composition-sections 2026-04-07 15:25:02 +04:00
shadcn
f00a94d9e5 docs: add composition section 2026-04-07 15:23:27 +04:00
shadcn
e3d654fd26 fix
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-07 14:01:23 +04:00
shadcn
7cb3b13a33 fix 2026-04-06 23:32:48 +04:00
shadcn
e3d2b14911 fix 2026-04-06 23:27:15 +04:00
shadcn
58c9dc2a7e fix 2026-04-06 23:27:09 +04:00
shadcn
3bdf60340d fix 2026-04-06 23:19:37 +04:00
shadcn
c1e29824cd feat: add apply command 2026-04-06 23:14:57 +04:00
Ethan Davidson
945298ed2d Merge branch 'main' into fix/accept-header-issue-10164 2026-03-26 00:29:45 -07:00
Ethan Davidson
f9b216af77 docs(registry): document content negotiation with Express example 2026-03-26 00:24:48 -07:00
Ethan Davidson
6525227036 fix(cli): add Accept and User-Agent headers to support content negotiation (fixes #10164) 2026-03-26 00:24:48 -07:00
Talha Mujahid
b57e192965 Update URL for @shadcn-editor in registries.json 2026-02-25 08:01:05 +05:00
3503 changed files with 234914 additions and 52848 deletions

View File

@@ -9,5 +9,6 @@
"WebFetch(domain:github.com)"
],
"deny": []
}
},
"outputStyle": "Explanatory"
}

View File

@@ -0,0 +1,41 @@
{
"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"]
}
}
}

35
.github/collect-prerelease-info.js vendored Normal file
View File

@@ -0,0 +1,35 @@
// 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,21 +0,0 @@
// 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/shadcn/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)
}

56
.github/workflows/browser-tests.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
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

@@ -22,7 +22,7 @@ jobs:
name: Install pnpm
id: pnpm-install
with:
version: 9.0.6
version: 10.33.4
run_install: false
- name: Get pnpm store directory
@@ -58,7 +58,7 @@ jobs:
name: Install pnpm
id: pnpm-install
with:
version: 9.0.6
version: 10.33.4
run_install: false
- name: Get pnpm store directory
@@ -78,7 +78,7 @@ jobs:
run: pnpm install
- name: Build packages
run: pnpm --filter=shadcn build
run: pnpm build:packages
- run: pnpm format:check
@@ -99,7 +99,7 @@ jobs:
name: Install pnpm
id: pnpm-install
with:
version: 9.0.6
version: 10.33.4
run_install: false
- name: Get pnpm store directory
@@ -117,6 +117,6 @@ jobs:
run: pnpm install
- name: Build packages
run: pnpm --filter=shadcn build
run: pnpm build:packages
- run: pnpm typecheck

View File

@@ -1,9 +1,9 @@
# Adapted from create-t3-app.
name: Write Beta Release comment
name: Write Prerelease comment
on:
workflow_run:
workflows: ["Release - Beta"]
workflows: ["Release"]
types:
- completed
@@ -11,55 +11,84 @@ jobs:
comment:
if: |
github.repository_owner == 'shadcn-ui' &&
${{ github.event.workflow_run.conclusion == 'success' }}
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
name: Write comment to the PR
steps:
- name: "Comment on PR"
uses: actions/github-script@v6
# Stable pushes and no-changeset runs upload no artifact, so a missing
# download is expected — gate the rest of the job on it succeeding.
- 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 allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
const fs = require("fs");
const info = JSON.parse(fs.readFileSync("prerelease-info.json", "utf8"));
for (const artifact of allArtifacts.data.artifacts) {
// Extract the PR number and package version from the artifact name
const match = /^npm-package-shadcn@(.*?)-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;
}
if (!info.packages || info.packages.length === 0) {
core.info("No prerelease packages to comment.");
return;
}
- name: "Comment on PR with Link"
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: ${{ env.WORKFLOW_RUN_PR }}
message: |
A new prerelease is available for testing:
number: ${{ steps.info.outputs.pr }}
message: ${{ steps.info.outputs.body }}
```sh
pnpm dlx shadcn@${{ env.BETA_PACKAGE_VERSION }}
```
- name: "Remove the autorelease label once published"
uses: actions/github-script@v6
- name: Remove the prerelease label once published
if: steps.info.outputs.pr
uses: actions/github-script@v7
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',
});
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number("${{ steps.info.outputs.pr }}"),
name: `release: ${{ steps.info.outputs.channel }}`,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
core.info("The prerelease label was already removed.");
}

View File

@@ -1,64 +0,0 @@
# Adapted from create-t3-app.
name: Release - Beta
on:
pull_request:
types: [labeled]
branches:
- main
permissions:
id-token: write
contents: read
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@v4
with:
fetch-depth: 0
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
version: 9.0.6
- 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
- name: Modify package.json version
run: node .github/version-script-beta.js
- 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/shadcn
- name: Upload packaged artifact
uses: actions/upload-artifact@v4
with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: packages/shadcn/dist/index.js

View File

@@ -2,31 +2,63 @@
name: Release
run-name: ${{ github.event_name == 'pull_request' && format('Release Prerelease - PR {0}', github.event.number) || 'Release Stable' }}
on:
pull_request:
types: [labeled]
branches:
- main
push:
branches:
- main
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
release:
if: ${{ github.repository_owner == 'shadcn-ui' }}
name: Create a PR for release workflow
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@v3
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: 9.0.6
version: 10.33.4
- name: Use Node.js 20
uses: actions/setup-node@v4
@@ -41,16 +73,101 @@ jobs:
- name: Install NPM Dependencies
run: pnpm install
# - name: Check for errors
# run: pnpm check
# 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: Build the package
run: pnpm shadcn:build
- 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:
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
name: Create Version PR or Publish Stable Release
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
pull-requests: write
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- 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
# Builds every publishable package under packages/* (shadcn, @shadcn/react),
# never apps/v4, so each dist is fresh before changeset publish.
- name: Build the packages
run: pnpm build:packages
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@v6
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
id: changesets
uses: changesets/action@v1
with:
setupGitUser: false
commit: "chore(release): version packages"
title: "chore(release): version packages"
version: node .github/changeset-version.js

75
.github/workflows/signed-commits.yml vendored Normal file
View File

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

314
.github/workflows/templates.yml vendored Normal file
View File

@@ -0,0 +1,314 @@
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

@@ -25,7 +25,46 @@ jobs:
name: Install pnpm
id: pnpm-install
with:
version: 9.0.6
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 Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: pnpm install
- 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
@@ -42,4 +81,4 @@ jobs:
- name: Install dependencies
run: pnpm install
- run: pnpm test
- run: pnpm --filter=@shadcn/react test

View File

@@ -3,54 +3,14 @@ name: Validate Registries
on:
pull_request:
paths:
- "apps/v4/public/r/registries.json"
- "apps/v4/registry/directory.json"
push:
branches:
- main
paths:
- "apps/v4/public/r/registries.json"
- "apps/v4/registry/directory.json"
jobs:
check-registry-sync:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
name: check-registry-sync
permissions:
contents: read
pull-requests: write
steps:
- name: Check changed files
id: changed
env:
GH_TOKEN: ${{ github.token }}
run: |
CHANGED_FILES=$(gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --name-only)
DIRECTORY_CHANGED=false
REGISTRIES_CHANGED=false
if echo "$CHANGED_FILES" | grep -q "^apps/v4/registry/directory.json$"; then
DIRECTORY_CHANGED=true
fi
if echo "$CHANGED_FILES" | grep -q "^apps/v4/public/r/registries.json$"; then
REGISTRIES_CHANGED=true
fi
echo "directory_changed=$DIRECTORY_CHANGED" >> $GITHUB_OUTPUT
echo "registries_changed=$REGISTRIES_CHANGED" >> $GITHUB_OUTPUT
- name: Flag missing registries.json update
if: steps.changed.outputs.directory_changed == 'true' && steps.changed.outputs.registries_changed == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr edit ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --add-label "registries: invalid"
gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --body "can you run \`pnpm registry:build\` and commit the json files please?"
exit 1
validate:
runs-on: ubuntu-latest
name: pnpm validate:registries
@@ -73,25 +33,20 @@ jobs:
node <<'EOF'
const fs = require("node:fs")
const files = [
"apps/v4/public/r/registries.json",
"apps/v4/registry/directory.json",
]
const file = "apps/v4/registry/directory.json"
const reservedNamespaces = new Set(
process.env.RESERVED_NAMESPACES.split(",").filter(Boolean)
)
function readNames(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8")).map(
function readNames() {
return JSON.parse(fs.readFileSync(file, "utf8")).map(
(entry) => entry.name
)
}
const violations = files.flatMap((filePath) => {
return readNames(filePath)
.filter((name) => reservedNamespaces.has(name))
.map((name) => `${filePath}: ${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:")
@@ -108,7 +63,7 @@ jobs:
name: Install pnpm
id: pnpm-install
with:
version: 9.0.6
version: 10.33.4
run_install: false
- name: Get pnpm store directory

8
.gitignore vendored
View File

@@ -43,5 +43,13 @@ tsconfig.tsbuildinfo
.notes
.playwright-mcp
.playwright-cli
shadcn-workspace
# vitest browser mode writes these only on test failure.
__screenshots__
.codex-artifacts
.tmp*
CONTEXT.md
docs/adr

View File

@@ -15,6 +15,7 @@
"search.exclude": {
"apps/v4/registry/radix-*": true,
"apps/v4/public/r/*": true,
"packages/shadcn/test/fixtures/*": true
"packages/shadcn/test/fixtures/*": true,
"apps/v4/styles/*": true
}
}

View File

@@ -141,6 +141,11 @@ When adding or modifying components, please ensure that:
2. You update the documentation.
3. You run `pnpm registry:build` 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
Before you create a Pull Request, please check whether your commits comply with

59
RELEASING.md Normal file
View File

@@ -0,0 +1,59 @@
# 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.

1
apps/v4/.gitignore vendored
View File

@@ -46,3 +46,4 @@ next-env.d.ts
.contentlayer
.content-collections
.source
.devtools

View File

@@ -1,7 +1,7 @@
"use client"
import { MENU_ACCENTS, type MenuAccentValue } from "@/registry/config"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -9,8 +9,8 @@ import {
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function MenuAccentPicker({
isMobile,

View File

@@ -12,7 +12,7 @@ import {
CommandItem,
CommandList,
} from "@/styles/base-nova/ui/command"
import { useActionMenu } from "@/app/(app)/create/hooks/use-action-menu"
import { useActionMenu } from "@/app/(app)/(create)/hooks/use-action-menu"
export const CMD_K_FORWARD_TYPE = "cmd-k-forward"

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import {
getThemesForBaseColor,
type ChartColorName,
} from "@/registry/config"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -17,8 +17,8 @@ import {
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function ChartColorPicker({
isMobile,
@@ -46,12 +46,6 @@ export function ChartColorPicker({
[params.chartColor]
)
React.useEffect(() => {
if (!currentChartColor && availableChartColors.length > 0) {
setParams({ chartColor: availableChartColors[0].name })
}
}, [currentChartColor, availableChartColors, setParams])
return (
<div className="group/picker relative">
<Picker>

View File

@@ -5,11 +5,12 @@ 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"
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) {
@@ -32,12 +33,13 @@ export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
<Button
variant="outline"
onClick={handleCopy}
title={label}
className={cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)}
>
<span>{hasCopied ? "Copied" : `--preset ${presetCode}`}</span>
<span className="block min-w-0 truncate">{label}</span>
</Button>
)
}

View File

@@ -0,0 +1,72 @@
"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

@@ -0,0 +1,15 @@
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

@@ -6,6 +6,7 @@ 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,
@@ -13,30 +14,41 @@ import {
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 { 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 { V0Button } from "@/app/(app)/create/components/v0-button"
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
import { MenuAccentPicker } from "@/app/(app)/(create)/components/accent-picker"
import { ActionMenu } from "@/app/(app)/(create)/components/action-menu"
import { BaseColorPicker } from "@/app/(app)/(create)/components/base-color-picker"
import { BasePicker } from "@/app/(app)/(create)/components/base-picker"
import { ChartColorPicker } from "@/app/(app)/(create)/components/chart-color-picker"
import { CopyPreset } from "@/app/(app)/(create)/components/copy-preset"
import { FontPicker } from "@/app/(app)/(create)/components/font-picker"
import { IconLibraryPicker } from "@/app/(app)/(create)/components/icon-library-picker"
import { MainMenu } from "@/app/(app)/(create)/components/main-menu"
import { MenuColorPicker } from "@/app/(app)/(create)/components/menu-picker"
import { OpenPreset } from "@/app/(app)/(create)/components/open-preset"
import { RadiusPicker } from "@/app/(app)/(create)/components/radius-picker"
import { RandomButton } from "@/app/(app)/(create)/components/random-button"
import { ResetDialog } from "@/app/(app)/(create)/components/reset-button"
import { StylePicker } from "@/app/(app)/(create)/components/style-picker"
import { ThemePicker } from "@/app/(app)/(create)/components/theme-picker"
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/(create)/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
// Only visible when user clicks "Create Project".
const ProjectForm = dynamic(() =>
import("@/app/(app)/create/components/project-form").then(
(m) => m.ProjectForm
)
// 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({
@@ -55,7 +67,7 @@ export function Customizer({
return (
<Card
className="dark top-24 right-12 isolate z-10 max-h-full min-h-0 w-full self-start rounded-2xl bg-card/90 shadow-xl backdrop-blur-xl md:w-(--customizer-width)"
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"
>
@@ -102,8 +114,12 @@ export function Customizer({
</FieldGroup>
</CardContent>
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:rounded-b-none md:**:[button,a]:w-full">
<CopyPreset className="flex-1 md:flex-none" />
<RandomButton className="flex-1 md:flex-none" />
<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>

View File

@@ -5,17 +5,24 @@ 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 { 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"
} 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"]
@@ -44,14 +51,17 @@ function buildCssRule(selector: string, cssVars?: Record<string, string>) {
return `${selector} {\n${declarations}\n}\n`
}
function buildThemeCssText(cssVars: RegistryThemeCssVars) {
function buildThemeCssText(cssVars: RegistryThemeCssVars, pointer: boolean) {
return [
buildCssRule(":root", {
...(cssVars.theme ?? {}),
...(cssVars.light ?? {}),
}),
buildCssRule(".dark", cssVars.dark),
].join("\n")
pointer ? POINTER_CURSOR_CSS : "",
]
.filter(Boolean)
.join("\n")
}
export function DesignSystemProvider({
@@ -73,6 +83,7 @@ export function DesignSystemProvider({
chartColor,
menuAccent,
menuColor,
pointer,
radius,
} = searchParams
const effectiveRadius = style === "lyra" ? "none" : radius
@@ -130,8 +141,13 @@ export function DesignSystemProvider({
useIframeMessageListener("design-system-params", handleDesignSystemMessage)
React.useEffect(() => {
if (style === "lyra" && radius !== "none") {
if (style === "lyra" || (style === "sera" && radius !== "none")) {
setSearchParams({ radius: "none" })
return
}
if (style === "rhea" && radius === "large") {
setSearchParams({ radius: "default" })
}
}, [style, radius, setSearchParams])
@@ -208,8 +224,8 @@ export function DesignSystemProvider({
document.head.appendChild(styleElement)
}
styleElement.textContent = buildThemeCssText(registryTheme.cssVars)
}, [registryTheme])
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.

View File

@@ -2,7 +2,7 @@
import * as React from "react"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -12,12 +12,12 @@ import {
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { FONTS } from "@/app/(app)/create/lib/fonts"
} 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"
} from "@/app/(app)/(create)/lib/search-params"
type FontPickerOption = {
name: string

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ 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"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
const IconLucide = lazy(() =>
import("@/registry/icons/icon-lucide").then((mod) => ({

View File

@@ -21,8 +21,8 @@ import {
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"
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">[]) => {

View File

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

View File

@@ -14,12 +14,13 @@ import {
PickerSeparator,
PickerShortcut,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useActionMenuTrigger } from "@/app/(app)/create/hooks/use-action-menu"
import { useHistory } from "@/app/(app)/create/hooks/use-history"
import { useRandom } from "@/app/(app)/create/hooks/use-random"
import { useReset } from "@/app/(app)/create/hooks/use-reset"
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
} from "@/app/(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/
@@ -27,6 +28,7 @@ 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()
@@ -55,6 +57,9 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
Navigate...
<PickerShortcut>{isMac ? "⌘P" : "Ctrl+P"}</PickerShortcut>
</PickerItem>
<PickerItem onClick={openPreset}>
Open Preset... <PickerShortcut>O</PickerShortcut>
</PickerItem>
<PickerItem onClick={randomize}>
Shuffle <PickerShortcut>R</PickerShortcut>
</PickerItem>

View File

@@ -7,7 +7,7 @@ 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 { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -17,11 +17,11 @@ import {
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
} from "@/app/(app)/(create)/components/picker"
import {
isTranslucentMenuColor,
useDesignSystemSearchParams,
} from "@/app/(app)/create/lib/search-params"
} from "@/app/(app)/(create)/lib/search-params"
type ColorChoice = "default" | "inverted"
type SurfaceChoice = "solid" | "translucent"

View File

@@ -5,7 +5,7 @@ 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"
import { useThemeToggle } from "@/app/(app)/(create)/hooks/use-theme-toggle"
export const DARK_MODE_FORWARD_TYPE = "dark-mode-forward"

View File

@@ -0,0 +1,203 @@
"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

@@ -4,7 +4,7 @@ import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/registry/bases/base/lib/utils"
import { IconPlaceholder } from "@/app/(app)/create/components/icon-placeholder"
import { IconPlaceholder } from "@/app/(app)/(create)/components/icon-placeholder"
function Picker({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
"use client"
import { Button } from "@/registry/new-york-v4/ui/button"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
const PREVIEW_ITEMS = [
{ label: "01", value: "preview-02" },

View File

@@ -0,0 +1,168 @@
"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

@@ -0,0 +1,726 @@
"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

@@ -3,7 +3,7 @@
import * as React from "react"
import { RADII, type RadiusValue } from "@/registry/config"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -12,8 +12,8 @@ import {
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function RadiusPicker({
isMobile,
@@ -23,7 +23,7 @@ export function RadiusPicker({
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const isRadiusLocked = params.style === "lyra"
const isRadiusLocked = params.style === "lyra" || params.style === "sera"
const selectedRadiusName = isRadiusLocked ? "none" : params.radius
const currentRadius = RADII.find(
@@ -93,6 +93,7 @@ export function RadiusPicker({
key={radius.name}
value={radius.name}
closeOnClick={isMobile}
disabled={params.style === "rhea" && radius.name === "large"}
>
{radius.label}
</PickerRadioItem>

View File

@@ -6,8 +6,8 @@ import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@/lib/utils"
import { Button } from "@/styles/base-nova/ui/button"
import { useRandom } from "@/app/(app)/create/hooks/use-random"
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
import { useRandom } from "@/app/(app)/(create)/hooks/use-random"
import { RESET_FORWARD_TYPE } from "@/app/(app)/(create)/hooks/use-reset"
export const RANDOMIZE_FORWARD_TYPE = "randomize-forward"
@@ -28,7 +28,7 @@ export function RandomButton({
)}
{...props}
>
<span className="w-full text-center font-medium">Shuffle</span>
<span className="w-full truncate text-center font-medium">Shuffle</span>
</Button>
)
}

View File

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

View File

@@ -6,8 +6,8 @@ import { HugeiconsIcon } from "@hugeicons/react"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/styles/base-nova/ui/button"
import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
import { usePresetCode } from "@/app/(app)/(create)/hooks/use-design-system"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function ShareButton() {
const [params] = useDesignSystemSearchParams()
@@ -16,8 +16,17 @@ export function ShareButton() {
const shareUrl = React.useMemo(() => {
const origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
return `${origin}/create?preset=${presetCode}&item=${params.item}`
}, [presetCode, params.item])
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) {

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import { type Style, type StyleName } from "@/registry/config"
import { LockButton } from "@/app/(app)/create/components/lock-button"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -11,8 +11,8 @@ import {
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function StylePicker({
styles,

View File

@@ -4,7 +4,7 @@ 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 { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
@@ -13,8 +13,8 @@ import {
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/create/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function ThemePicker({
themes,
@@ -38,12 +38,6 @@ export function ThemePicker({
[params.theme]
)
React.useEffect(() => {
if (!currentTheme && themes.length > 0) {
setParams({ theme: themes[0].name })
}
}, [currentTheme, themes, setParams])
return (
<div className="group/picker relative">
<Picker>

View File

@@ -8,7 +8,7 @@ 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"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function V0Button({ className }: { className?: string }) {
const [params] = useDesignSystemSearchParams()

View File

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

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

View File

@@ -5,14 +5,14 @@ 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"
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(
import("@/app/(app)/(create)/components/welcome-dialog").then(
(m) => m.WelcomeDialog
)
)

View File

@@ -4,8 +4,8 @@ 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"
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"

View File

@@ -1,7 +1,7 @@
"use client"
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
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() {

View File

@@ -2,7 +2,12 @@
import * as React from "react"
import { Suspense } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useSearchParams } from "next/navigation"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(app)/(create)/lib/search-params"
type HistoryContextValue = {
canGoBack: boolean
@@ -15,6 +20,9 @@ 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,
}: {
@@ -31,7 +39,11 @@ function PresetSync({
}
export function HistoryProvider({ children }: { children: React.ReactNode }) {
const router = useRouter()
// 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("")
@@ -81,17 +93,16 @@ export function HistoryProvider({ children }: { children: React.ReactNode }) {
indexRef.current = nextIndex
setIndex(nextIndex)
const targetPreset = entriesRef.current[nextIndex]
const params = new URLSearchParams(window.location.search)
if (targetPreset) {
params.set("preset", targetPreset)
} else {
params.delete("preset")
}
const pathname = window.location.pathname
const query = params.toString()
router.replace(query ? `${pathname}?${query}` : pathname)
}, [router])
// 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) {
@@ -103,17 +114,16 @@ export function HistoryProvider({ children }: { children: React.ReactNode }) {
indexRef.current = nextIndex
setIndex(nextIndex)
const targetPreset = entriesRef.current[nextIndex]
const params = new URLSearchParams(window.location.search)
if (targetPreset) {
params.set("preset", targetPreset)
} else {
params.delete("preset")
}
const pathname = window.location.pathname
const query = params.toString()
router.replace(query ? `${pathname}?${query}` : pathname)
}, [router])
// 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) => {

View File

@@ -2,7 +2,7 @@
import * as React from "react"
import type { DesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
import type { DesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
type ParentToIframeMessage = {
type: "design-system-params"

View File

@@ -24,15 +24,10 @@ const LocksContext = React.createContext<LocksContextValue | null>(null)
export function LocksProvider({ children }: { children: React.ReactNode }) {
const [locks, setLocks] = React.useState<Set<LockableParam>>(new Set())
const locksRef = React.useRef(locks)
React.useEffect(() => {
locksRef.current = locks
}, [locks])
// Stable callback — reads from ref so it doesn't change on every lock toggle.
const isLocked = React.useCallback(
(param: LockableParam) => locksRef.current.has(param),
[]
(param: LockableParam) => locks.has(param),
[locks]
)
const toggleLock = React.useCallback((param: LockableParam) => {

View File

@@ -0,0 +1,81 @@
"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,6 +1,7 @@
"use client"
import * as React from "react"
import { decodePreset } from "shadcn/preset"
import {
BASE_COLORS,
@@ -10,19 +11,30 @@ import {
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 { 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"
} from "@/app/(app)/(create)/lib/randomize-biases"
import {
isTranslucentMenuColor,
useDesignSystemSearchParams,
} from "@/app/(app)/create/lib/search-params"
} 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)]
@@ -37,7 +49,9 @@ export function useRandom() {
paramsRef.current = params
}, [params])
const randomize = React.useCallback(() => {
// 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
@@ -149,6 +163,60 @@ export function useRandom() {
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
@@ -177,5 +245,5 @@ export function useRandom() {
}
}, [])
return { randomize }
return { randomize, randomizeTrueRandom }
}

View File

@@ -4,7 +4,7 @@ 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"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
const RESET_DIALOG_KEY = "create:reset-dialog-open"
export const RESET_FORWARD_TYPE = "reset-forward"

View File

@@ -1,9 +1,8 @@
import { type NextRequest } from "next/server"
import { track } from "@vercel/analytics/server"
import { parseDesignSystemConfig } from "@/app/(create)/init/parse-config"
import { buildInstructions } from "./build-instructions"
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 {

View File

@@ -0,0 +1,74 @@
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

@@ -3,9 +3,13 @@ import { track } from "@vercel/analytics/server"
import { isPresetCode } from "shadcn/preset"
import { registryItemSchema } from "shadcn/schema"
import { buildRegistryBase } from "@/registry/config"
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
import { parseDesignSystemConfig } from "@/app/(create)/init/parse-config"
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 {
@@ -16,13 +20,20 @@ export async function GET(request: NextRequest) {
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 = buildRegistryBase(result.data)
const registryBase = onlyResult.parts
? buildPartialRegistryBase(result.data, onlyResult.parts)
: buildRegistryBase(result.data)
const parseResult = registryItemSchema.safeParse(registryBase)
if (!parseResult.success) {

View File

@@ -2,9 +2,9 @@ import { after, NextResponse, type NextRequest } from "next/server"
import { track } from "@vercel/analytics/server"
import { isPresetCode } from "shadcn/preset"
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
import { buildV0Payload } from "@/app/(app)/create/lib/v0"
import { parseDesignSystemConfig } from "@/app/(create)/init/parse-config"
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 {

View File

@@ -6,7 +6,7 @@ import { BASES, getThemesForBaseColor, type BaseName } from "@/registry/config"
import {
ALLOWED_ITEM_TYPES,
EXCLUDED_ITEMS,
} from "@/app/(app)/create/lib/constants"
} from "@/app/(app)/(create)/lib/constants"
export async function getItemsForBase(base: BaseName) {
const { Index } = await import("@/registry/bases/__index__")
@@ -42,7 +42,8 @@ export async function getBaseComponent(name: string, base: BaseName) {
return null
}
return index[name].component
const { Components } = await import("@/registry/bases/__components__")
return Components[base]?.[name] ?? null
}
export async function getAllItems() {

View File

@@ -0,0 +1,10 @@
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

@@ -0,0 +1,77 @@
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

@@ -0,0 +1,79 @@
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

@@ -4,7 +4,7 @@ import {
designSystemConfigSchema,
type DesignSystemConfig,
} from "@/registry/config"
import { resolvePresetOverrides } from "@/app/(app)/create/lib/preset-query"
import { resolvePresetOverrides } from "@/app/(app)/(create)/lib/preset-query"
// Parses design system config from URL search params.
export function parseDesignSystemConfig(searchParams: URLSearchParams) {
@@ -20,9 +20,10 @@ export function parseDesignSystemConfig(searchParams: URLSearchParams) {
configInput = {
...decoded,
...presetOverrides,
base: searchParams.get("base") ?? "radix",
base: searchParams.get("base") ?? "base",
template: searchParams.get("template") ?? undefined,
rtl: searchParams.get("rtl") === "true",
pointer: searchParams.get("pointer") === "true",
}
} else {
configInput = {
@@ -39,6 +40,7 @@ export function parseDesignSystemConfig(searchParams: URLSearchParams) {
radius: searchParams.get("radius"),
template: searchParams.get("template") ?? undefined,
rtl: searchParams.get("rtl") === "true",
pointer: searchParams.get("pointer") === "true",
}
}

View File

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,15 @@
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

@@ -76,6 +76,11 @@ export const RANDOMIZE_BIASES: RandomizeBiases = {
return radii.filter((radius) => radius.name === "none")
}
// Rhea does not support the "large" radius.
if (context.style === "rhea") {
return radii.filter((radius) => radius.name !== "large")
}
return radii
},
chartColors: (chartColors, context) => {

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest"
import { DEFAULT_CONFIG } from "@/registry/config"
import { buildPresetUrlUpdate } from "./search-params"
describe("buildPresetUrlUpdate", () => {
it("encodes design system params into preset and clears individual keys", () => {
const update = buildPresetUrlUpdate({
...DEFAULT_CONFIG,
preset: "b0",
template: "next",
item: "preview-02",
size: 100,
custom: false,
})
expect(update.preset).toBeTypeOf("string")
expect(update.style).toBeNull()
expect(update.theme).toBeNull()
expect(update.baseColor).toBeNull()
})
it("preserves template when syncing preset from ?template=start", () => {
const update = buildPresetUrlUpdate({
...DEFAULT_CONFIG,
preset: "b0",
template: "start",
item: "preview-02",
size: 100,
custom: false,
})
expect(update.template).toBe("start")
expect(update.preset).toBeTypeOf("string")
})
it("applies non-design-system updates from resolvedUpdates", () => {
const update = buildPresetUrlUpdate(
{
...DEFAULT_CONFIG,
preset: "b0",
template: "next",
item: "preview-02",
size: 100,
custom: false,
},
{ template: "vite" }
)
expect(update.template).toBe("vite")
})
})

View File

@@ -36,9 +36,9 @@ import {
type StyleName,
type ThemeName,
} from "@/registry/config"
import { FONTS } from "@/app/(app)/create/lib/fonts"
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
import { resolvePresetOverrides } from "@/app/(app)/create/lib/preset-query"
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
import { resolvePresetOverrides } from "@/app/(app)/(create)/lib/preset-query"
const designSystemSearchParams = {
preset: parseAsString.withDefault("b0"),
@@ -91,6 +91,7 @@ const designSystemSearchParams = {
"laravel",
] as const).withDefault("next"),
rtl: parseAsBoolean.withDefault(false),
pointer: parseAsBoolean.withDefault(false),
size: parseAsInteger.withDefault(100),
custom: parseAsBoolean.withDefault(false),
}
@@ -126,10 +127,14 @@ const NON_DESIGN_SYSTEM_KEYS = [
"preset",
"template",
"rtl",
"pointer",
"size",
"custom",
] as const
// Shared across hook instances so only one mount effect encodes the initial URL.
let hasSyncedPresetToUrl = false
export const loadDesignSystemSearchParams = createLoader(
designSystemSearchParams
)
@@ -224,6 +229,7 @@ function resolvePresetParams(
preset: rawParams.preset,
template: rawParams.template,
rtl: rawParams.rtl,
pointer: rawParams.pointer,
size: rawParams.size,
custom: rawParams.custom,
})
@@ -232,13 +238,42 @@ function resolvePresetParams(
return normalizeDesignSystemParams(rawParams)
}
export function buildPresetUrlUpdate(
merged: DesignSystemSearchParams,
resolvedUpdates: Partial<DesignSystemSearchParams> = {}
) {
const code = getPresetCode(merged)
const rawUpdate: Record<string, unknown> = { preset: code }
for (const key of DESIGN_SYSTEM_KEYS) {
rawUpdate[key] = null
}
for (const key of NON_DESIGN_SYSTEM_KEYS) {
if (key === "preset") {
continue
}
rawUpdate[key] =
key in resolvedUpdates
? (resolvedUpdates as Record<string, unknown>)[key]
: merged[key]
}
return rawUpdate
}
// Wraps nuqs useQueryStates with transparent preset encoding/decoding.
// - Reads: if ?preset=CODE is in the URL, decodes it and returns individual values.
// - Writes: when design system params are set, encodes them into a preset code.
//
// Default options use shallow: true so picker selections do not trigger a full
// Next.js server navigation. This prevents the customizer panel from flickering
// or resetting while the URL update propagates (#10910, #11060).
export function useDesignSystemSearchParams(options: Options = {}) {
const searchParams = useSearchParams()
const [rawParams, rawSetParams] = useQueryStates(designSystemSearchParams, {
shallow: false,
shallow: true,
history: "push",
...options,
})
@@ -254,6 +289,19 @@ export function useDesignSystemSearchParams(options: Options = {}) {
paramsRef.current = params
}, [params])
React.useEffect(() => {
if (hasSyncedPresetToUrl || searchParams.has("preset")) {
return
}
hasSyncedPresetToUrl = true
const merged = normalizeDesignSystemParams(paramsRef.current)
void rawSetParams(buildPresetUrlUpdate(merged) as RawSetParamsInput, {
history: "replace",
})
}, [rawSetParams, searchParams])
type RawSetParamsInput = Parameters<typeof rawSetParams>[0]
const setParams = React.useCallback(
@@ -283,24 +331,10 @@ export function useDesignSystemSearchParams(options: Options = {}) {
...paramsRef.current,
...resolvedUpdates,
})
// Encode design system fields into a preset code.
// Cast needed: merged values may include null from nuqs resets,
// but encodePreset handles missing values by falling back to defaults.
const code = getPresetCode(merged)
// Build update: set preset, clear individual DS params from URL.
const rawUpdate: Record<string, unknown> = { preset: code }
for (const key of DESIGN_SYSTEM_KEYS) {
rawUpdate[key] = null
}
// Pass through non-DS params that were explicitly in the update.
for (const key of NON_DESIGN_SYSTEM_KEYS) {
if (key in resolvedUpdates) {
rawUpdate[key] = (resolvedUpdates as Record<string, unknown>)[key]
}
}
return rawSetParams(rawUpdate as RawSetParamsInput, setOptions)
return rawSetParams(
buildPresetUrlUpdate(merged, resolvedUpdates) as RawSetParamsInput,
setOptions
)
},
[rawSetParams]
)

View File

@@ -0,0 +1,28 @@
// Curated preset codes from https://dialectcn.xyz.
export const SHUFFLE_PRESETS = [
"b6sUj34d9",
"b2tqYzpa88",
"b1W4tDrk",
"b1aIuQ2XC",
"b7jsW1RxJ5",
"b870VEw0in",
"b3Zheoix4U",
"b1x9M2c4aI",
"b1W7jDEW",
"b51GFh7y6",
"b2fms620zo",
"b1Q5GC",
"buKEvLs",
"b5rR41Mtnc",
"b6tOz2I0x",
"b2hNTREGRN",
"bdIJ7Sq",
"b6TqMNb5Wb",
"bJIirQ",
"b4aRK5K0fb",
"b5HCiD38LI",
"bdHjvCi",
"b7QDHijUjj",
"b4ZVZIPi9h",
"b1W4bcno",
]

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { DEFAULT_CONFIG } from "@/registry/config"
import { buildV0Payload } from "@/app/(app)/create/lib/v0"
import { buildV0Payload } from "@/app/(app)/(create)/lib/v0"
vi.mock("shadcn/schema", async () => {
return await vi.importActual("shadcn/schema")

View File

@@ -0,0 +1,94 @@
import {
AlertCircleIcon,
ArrowRight01Icon,
SquareLock02Icon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-rhea/ui/card"
import { Field, FieldGroup, FieldLabel } from "@/styles/base-rhea/ui/field"
import { Input } from "@/styles/base-rhea/ui/input"
import {
Item,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/styles/base-rhea/ui/item"
export function AccountAccess() {
return (
<Card>
<CardHeader>
<CardTitle>Account Access</CardTitle>
<CardDescription>
Update your credentials or re-authenticate.
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel htmlFor="email-address">Email Address</FieldLabel>
<Input
id="email-address"
type="email"
placeholder="artist@studio.inc"
/>
</Field>
<Field>
<div className="flex items-center justify-between">
<FieldLabel htmlFor="current-password">
Current Password
</FieldLabel>
<a
href="#"
className="text-xs font-medium tracking-wider text-muted-foreground uppercase hover:text-foreground"
>
Forgot?
</a>
</div>
<Input
id="current-password"
type="password"
placeholder="••••••••••••••••••••••••"
/>
</Field>
</FieldGroup>
</CardContent>
<CardFooter className="flex-col gap-4">
<Button className="w-full">
<HugeiconsIcon icon={SquareLock02Icon} strokeWidth={2} />
Update Security
</Button>
<Item variant="muted" render={<a href="#" />}>
<ItemMedia variant="icon">
<HugeiconsIcon
icon={AlertCircleIcon}
className="text-destructive"
strokeWidth={2}
/>
</ItemMedia>
<ItemContent>
<ItemTitle>Danger Zone</ItemTitle>
<ItemDescription className="line-clamp-1">
Archive account and remove catalog
</ItemDescription>
</ItemContent>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-4"
strokeWidth={2}
/>
</Item>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,46 @@
import { Badge } from "@/styles/base-rhea/ui/badge"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Card,
CardAction,
CardDescription,
CardHeader,
CardTitle,
} from "@/styles/base-rhea/ui/card"
const areaPath = "M0 52L18 40L36 46L54 70L72 50L100 49V86H0Z"
const strokePath = "M0 52L18 40L36 46L54 70L72 50L100 49"
export function AnalyticsCard() {
return (
<Card className="mx-auto w-full max-w-sm data-[size=sm]:pb-0" size="sm">
<CardHeader>
<CardTitle>Analytics</CardTitle>
<CardDescription>
418.2K Visitors <Badge>+10%</Badge>
</CardDescription>
<CardAction>
<Button variant="outline" size="sm">
View Analytics
</Button>
</CardAction>
</CardHeader>
<svg
viewBox="0 0 100 86"
preserveAspectRatio="none"
className="aspect-[1/0.35] w-full text-chart-1"
role="img"
aria-label="Visitor trend"
>
<path d={areaPath} fill="currentColor" opacity="0.28" />
<path
d={strokePath}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
vectorEffect="non-scaling-stroke"
/>
</svg>
</Card>
)
}

View File

@@ -0,0 +1,75 @@
import { Badge } from "@/styles/base-rhea/ui/badge"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-rhea/ui/card"
import { Item, ItemContent } from "@/styles/base-rhea/ui/item"
import { Separator } from "@/styles/base-rhea/ui/separator"
const netRoyalties = 1248.75
const processingFee = 37.46
const totalClaimable = netRoyalties - processingFee
const formatCurrency = (amount: number) =>
amount.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
export function ClaimableBalance() {
return (
<Card>
<CardHeader>
<CardDescription>Claimable Balance</CardDescription>
<CardTitle className="text-4xl tabular-nums">
${formatCurrency(totalClaimable)}
</CardTitle>
<Badge variant="outline">
<span className="size-2 rounded-full bg-yellow-500" />
Pending Setup
</Badge>
</CardHeader>
<CardContent className="flex flex-1 flex-col justify-end">
<Item variant="muted" className="flex-col items-stretch">
<ItemContent className="gap-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Net Royalties
</span>
<span className="text-sm font-medium tabular-nums">
${formatCurrency(netRoyalties)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Processing Fee
</span>
<span className="text-sm font-medium tabular-nums">
-${formatCurrency(processingFee)}
</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Total Ready to Claim
</span>
<span className="text-sm font-semibold tabular-nums">
${formatCurrency(totalClaimable)} USD
</span>
</div>
</ItemContent>
</Item>
</CardContent>
<CardFooter>
<CardDescription>
Once your bank is connected, balances over $10.00 are automatically
eligible for monthly distribution on the 15th of each month.
</CardDescription>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,88 @@
import { Badge } from "@/styles/base-rhea/ui/badge"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-rhea/ui/card"
import { Item, ItemContent, ItemDescription } from "@/styles/base-rhea/ui/item"
const chartData = [
{ month: "Dec", amount: 800 },
{ month: "Jan", amount: 1100 },
{ month: "Feb", amount: 900 },
{ month: "Mar", amount: 1300 },
{ month: "Apr", amount: 750 },
]
export function ContributionHistory() {
const maxAmount = Math.max(...chartData.map((item) => item.amount))
return (
<Card>
<CardHeader>
<CardTitle>Contribution History</CardTitle>
<CardDescription>Last 6 months of activity</CardDescription>
</CardHeader>
<CardContent>
<div
className="flex h-[200px] w-full items-end gap-3"
role="img"
aria-label="Last 6 months of contribution activity"
>
{chartData.map((item, index) => (
<div
key={item.month}
className="flex h-full flex-1 flex-col justify-end gap-2"
>
<div
data-index={index}
className="data-[index=5]:bg-chart-6 min-h-2 rounded-lg data-[index=0]:bg-chart-1 data-[index=1]:bg-chart-2 data-[index=2]:bg-chart-3 data-[index=3]:bg-chart-4 data-[index=4]:bg-chart-5"
style={{ height: `${(item.amount / maxAmount) * 100}%` }}
/>
<span className="text-center text-xs text-muted-foreground">
{item.month}
</span>
</div>
))}
</div>
</CardContent>
<CardContent>
<div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2">
<Item variant="muted" className="flex-col items-stretch">
<ItemContent className="gap-1">
<ItemDescription className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
Upcoming
</ItemDescription>
<span className="cn-font-heading text-base font-semibold">
May 2024
</span>
<span className="text-sm text-muted-foreground">Scheduled</span>
</ItemContent>
</Item>
<Item
variant="muted"
className="hidden flex-col items-stretch xl:flex"
>
<ItemContent className="gap-1">
<ItemDescription className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
Savings Plan
</ItemDescription>
<span className="cn-font-heading text-base font-semibold">
Accelerated
</span>
<span className="text-sm text-muted-foreground">Recurring</span>
</ItemContent>
</Item>
</div>
</CardContent>
<CardFooter>
<Button className="w-full">View Full Report</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,116 @@
import { Cancel01Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/styles/base-rhea/ui/card"
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle,
} from "@/styles/base-rhea/ui/item"
const HOLDINGS = [
{
name: "Vanguard",
shares: "450 Shares",
amount: "$1,842.10",
data: [
{ q: "Q1", value: 380 },
{ q: "Q2", value: 420 },
{ q: "Q3", value: 390 },
{ q: "Q4", value: 652 },
],
},
{
name: "S&P 500 VOO",
shares: "112 Shares",
amount: "$928.40",
data: [
{ q: "Q1", value: 180 },
{ q: "Q2", value: 210 },
{ q: "Q3", value: 320 },
{ q: "Q4", value: 218 },
],
},
{
name: "Apple AAPL",
shares: "85 Shares",
amount: "$340.00",
data: [
{ q: "Q1", value: 60 },
{ q: "Q2", value: 70 },
{ q: "Q3", value: 120 },
{ q: "Q4", value: 90 },
],
},
{
name: "Realty Income",
shares: "320 Shares",
amount: "$1,139.50",
data: [
{ q: "Q1", value: 240 },
{ q: "Q2", value: 260 },
{ q: "Q3", value: 280 },
{ q: "Q4", value: 360 },
],
},
]
export function DividendIncome() {
return (
<Card>
<CardHeader>
<CardTitle>Q2 Dividend Income</CardTitle>
<CardDescription>
Quarterly dividend payouts across your portfolio holdings.
</CardDescription>
<CardAction>
<Button
variant="ghost"
size="icon-sm"
className="bg-muted"
aria-label="Dismiss dividend income"
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
</Button>
</CardAction>
</CardHeader>
<CardContent>
<ItemGroup>
{HOLDINGS.map((holding) => (
<Item key={holding.name} role="listitem" variant="muted">
<ItemContent>
<ItemTitle>{holding.name}</ItemTitle>
<ItemDescription>{holding.shares}</ItemDescription>
</ItemContent>
<div
className="hidden h-8 w-24 items-end gap-1 md:flex"
role="img"
aria-label={`${holding.name} quarterly dividends`}
>
{holding.data.map((item) => (
<div
key={item.q}
className="min-h-1 flex-1 rounded-t-sm bg-chart-2"
style={{
height: `${(item.value / Math.max(...holding.data.map((point) => point.value))) * 100}%`,
}}
/>
))}
</div>
</Item>
))}
</ItemGroup>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,37 @@
import { Add01Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { Button } from "@/styles/base-rhea/ui/button"
import { Card, CardContent } from "@/styles/base-rhea/ui/card"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/base-rhea/ui/empty"
export function EmptyDistributeTrack() {
return (
<Card>
<CardContent>
<Empty className="p-4">
<EmptyMedia variant="icon">
<HugeiconsIcon icon={Add01Icon} strokeWidth={2} />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>Distribute Track</EmptyTitle>
<EmptyDescription>
Upload your first master to start reaching listeners on Spotify,
Apple Music, and more.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>Create Release</Button>
</EmptyContent>
</Empty>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,122 @@
import { MessageScrollerDemo } from "@/examples/radix/message-scroller-demo"
import { AccountAccess } from "./account-access"
import { AnalyticsCard } from "./analytics-card"
import { ClaimableBalance } from "./claimable-balance"
import { ContributionHistory } from "./contribution-history"
import { DividendIncome } from "./dividend-income"
import { EmptyDistributeTrack } from "./empty-distribute-track"
import { NewMilestone } from "./new-milestone"
import { NotificationSettings } from "./notification-settings"
import { Payments } from "./payments"
import { PayoutThreshold } from "./payout-threshold"
import { PowerUsage } from "./power-usage"
import { QrConnect } from "./qr-connect"
import { SavingsTargets } from "./savings-targets"
import { SidebarNav } from "./sidebar-nav"
import { AccountAccess as SkeletonAccountAccess } from "./skeleton/account-access"
import { AnalyticsCard as SkeletonAnalyticsCard } from "./skeleton/analytics-card"
import { ClaimableBalance as SkeletonClaimableBalance } from "./skeleton/claimable-balance"
import { ContributionHistory as SkeletonContributionHistory } from "./skeleton/contribution-history"
import { DividendIncome as SkeletonDividendIncome } from "./skeleton/dividend-income"
import { EmptyDistributeTrack as SkeletonEmptyDistributeTrack } from "./skeleton/empty-distribute-track"
import { NewMilestone as SkeletonNewMilestone } from "./skeleton/new-milestone"
import { NotificationSettings as SkeletonNotificationSettings } from "./skeleton/notification-settings"
import { Payments as SkeletonPayments } from "./skeleton/payments"
import { PayoutThreshold as SkeletonPayoutThreshold } from "./skeleton/payout-threshold"
import { PowerUsage as SkeletonPowerUsage } from "./skeleton/power-usage"
import { QrConnect as SkeletonQrConnect } from "./skeleton/qr-connect"
import { SavingsTargets as SkeletonSavingsTargets } from "./skeleton/savings-targets"
import { TransferFunds as SkeletonTransferFunds } from "./skeleton/transfer-funds"
import { UIElements as SkeletonUIElements } from "./skeleton/ui-elements"
import { TransferFunds } from "./transfer-funds"
import { UIElements } from "./ui-elements"
function CardsSkeletonRails() {
return (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-12 z-10 hidden min-[2200px]:block [&_[data-slot=skeleton]:nth-child(even)]:hidden"
>
<div className="absolute top-0 left-[calc(50%-950px-var(--rail-width)-var(--gap))] grid w-(--rail-width) grid-cols-[repeat(2,var(--rail-column))] gap-(--gap) opacity-50 [--rail-column:20rem] [--rail-width:calc(var(--rail-column)*2+var(--gap))]">
<div className="flex flex-col gap-(--gap)">
<SkeletonContributionHistory />
<SkeletonClaimableBalance />
<SkeletonDividendIncome />
<SkeletonPayoutThreshold />
</div>
<div className="flex flex-col gap-(--gap)">
<SkeletonUIElements />
<SkeletonSavingsTargets />
<SkeletonNewMilestone />
<SkeletonPayoutThreshold />
<SkeletonAccountAccess />
</div>
</div>
<div className="absolute top-0 right-[calc(50%-950px-var(--rail-width)-var(--gap))] grid w-(--rail-width) grid-cols-[repeat(2,var(--rail-column))] gap-(--gap) opacity-50 [--rail-column:20rem] [--rail-width:calc(var(--rail-column)*2+var(--gap))]">
<div className="flex flex-col gap-(--gap)">
<SkeletonNewMilestone />
<SkeletonPayoutThreshold />
<SkeletonAccountAccess />
<SkeletonQrConnect />
<SkeletonTransferFunds />
<SkeletonPayments />
<SkeletonEmptyDistributeTrack />
</div>
<div className="flex flex-col gap-(--gap)">
<SkeletonQrConnect />
<SkeletonTransferFunds />
<SkeletonPayments />
<SkeletonEmptyDistributeTrack />
<SkeletonAnalyticsCard />
<SkeletonNotificationSettings />
<SkeletonPowerUsage />
</div>
</div>
</div>
)
}
export function CardsDemo() {
return (
<div
data-slot="demo"
className="theme-neutral relative flex w-full max-w-none flex-col gap-(--gap) overflow-hidden bg-muted p-12 pb-0! [--gap:--spacing(8)] 3xl:[--gap:--spacing(8)] min-[1900px]:p-12 min-[1900px]:[--gap:--spacing(10)]! lg:p-6 lg:[--gap:--spacing(6)] dark:bg-background"
>
<CardsSkeletonRails />
<div className="relative z-10 mx-auto grid gap-(--gap) **:data-[slot=card]:w-full min-[1400px]:grid-cols-4! min-[1900px]:grid-cols-5! md:max-w-3xl md:grid-cols-2 lg:max-w-none lg:grid-cols-3 xl:max-w-[1600px] 2xl:max-w-[1900px]">
<div className="flex flex-col items-start gap-(--gap)">
<UIElements />
<SidebarNav />
<SavingsTargets />
</div>
<div className="hidden flex-col gap-(--gap) lg:flex">
<ContributionHistory />
<ClaimableBalance />
<DividendIncome />
</div>
<div className="hidden flex-col gap-(--gap) min-[1400px]:flex">
<NewMilestone />
<PayoutThreshold />
<AccountAccess />
</div>
<div className="hidden flex-col gap-(--gap) md:flex">
<QrConnect />
<div className="**:[.text-center.text-xs]:hidden">
<MessageScrollerDemo />
</div>
{/* <TransferFunds /> */}
<Payments />
</div>
<div className="hidden flex-col gap-(--gap) min-[1900px]:flex">
<EmptyDistributeTrack />
<AnalyticsCard />
<NotificationSettings />
<PowerUsage />
</div>
</div>
<div className="absolute inset-x-0 top-0 z-1 h-120 bg-linear-to-b from-background via-muted to-transparent dark:hidden" />
<div className="absolute inset-x-0 bottom-0 z-20 h-48 bg-linear-to-t from-background via-muted/80 to-transparent lg:h-80 xl:h-64 dark:via-background/80" />
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { Button } from "@/styles/base-rhea/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-rhea/ui/card"
import { Field, FieldGroup, FieldLabel } from "@/styles/base-rhea/ui/field"
import { Input } from "@/styles/base-rhea/ui/input"
export function NewMilestone() {
return (
<Card>
<CardHeader>
<CardTitle>Set a new milestone</CardTitle>
<CardDescription>
Define your financial target and we&apos;ll help you pace your
savings.
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel htmlFor="goal-name">Goal Name</FieldLabel>
<Input
id="goal-name"
placeholder="e.g. New Car, Home Downpayment"
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="target-amount">Target Amount</FieldLabel>
<Input id="target-amount" defaultValue="$15,000" />
</Field>
<Field>
<FieldLabel htmlFor="target-date">Target Date</FieldLabel>
<Input id="target-date" defaultValue="Dec 2025" />
</Field>
</div>
</FieldGroup>
</CardContent>
<CardFooter className="flex-col gap-2">
<Button className="w-full">Create Goal</Button>
<Button variant="outline" className="w-full">
Cancel
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,76 @@
import { Button } from "@/styles/base-rhea/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-rhea/ui/card"
import { Checkbox } from "@/styles/base-rhea/ui/checkbox"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/styles/base-rhea/ui/field"
const NOTIFICATIONS = [
{
id: "transactions",
label: "Transaction alerts",
description: "Deposits, withdrawals, and transfers.",
defaultChecked: true,
},
{
id: "security",
label: "Security alerts",
description: "Login attempts and account changes.",
defaultChecked: true,
},
{
id: "goals",
label: "Goal milestones",
description: "Updates at 25%, 50%, 75%, and 100%.",
defaultChecked: false,
},
{
id: "market",
label: "Market updates",
description: "Daily portfolio summary and price alerts.",
defaultChecked: false,
},
]
export function NotificationSettings() {
return (
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>
Choose which email and push alerts you want to receive.
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
{NOTIFICATIONS.map((n) => (
<Field key={n.id} orientation="horizontal">
<Checkbox
id={`notify-${n.id}`}
defaultChecked={n.defaultChecked}
/>
<FieldContent>
<FieldLabel htmlFor={`notify-${n.id}`}>{n.label}</FieldLabel>
<FieldDescription>{n.description}</FieldDescription>
</FieldContent>
</Field>
))}
</FieldGroup>
</CardContent>
<CardFooter>
<Button className="w-full">Save Preferences</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,139 @@
import {
ArrowRight01Icon,
Calendar03Icon,
MoreHorizontalCircle01Icon,
RefreshIcon,
Settings01Icon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/styles/base-rhea/ui/breadcrumb"
import { Button } from "@/styles/base-rhea/ui/button"
import { Card, CardContent, CardHeader } from "@/styles/base-rhea/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/base-rhea/ui/dropdown-menu"
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/styles/base-rhea/ui/item"
export function Payments() {
return (
<Card>
<CardHeader className="flex flex-col gap-3">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon-sm"
variant="ghost"
aria-label="Account options"
/>
}
>
<HugeiconsIcon
icon={MoreHorizontalCircle01Icon}
strokeWidth={2}
/>
<span className="sr-only">Account options</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Statements</DropdownMenuItem>
<DropdownMenuItem>Documents</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Payments</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</CardHeader>
<CardContent>
<ItemGroup>
<div role="listitem" className="w-full">
<Item variant="muted" render={<a href="#" />}>
<ItemMedia variant="icon">
<HugeiconsIcon icon={Settings01Icon} strokeWidth={2} />
</ItemMedia>
<ItemContent>
<ItemTitle>Change transfer limit</ItemTitle>
<ItemDescription>
Adjust how much you can send from your balance.
</ItemDescription>
</ItemContent>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-4 shrink-0 text-muted-foreground"
strokeWidth={2}
/>
</Item>
</div>
<div role="listitem" className="w-full">
<Item variant="muted" render={<a href="#" />}>
<ItemMedia variant="icon">
<HugeiconsIcon icon={Calendar03Icon} strokeWidth={2} />
</ItemMedia>
<ItemContent>
<ItemTitle>Scheduled transfers</ItemTitle>
<ItemDescription>
Set up a transfer to send at a later date.
</ItemDescription>
</ItemContent>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-4 shrink-0 text-muted-foreground"
strokeWidth={2}
/>
</Item>
</div>
<div role="listitem" className="w-full">
<Item variant="muted" render={<a href="#" />}>
<ItemMedia variant="icon">
<HugeiconsIcon icon={RefreshIcon} strokeWidth={2} />
</ItemMedia>
<ItemContent>
<ItemTitle>Recurring card payments</ItemTitle>
<ItemDescription>
Manage your repeated card transactions.
</ItemDescription>
</ItemContent>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-4 shrink-0 text-muted-foreground"
strokeWidth={2}
/>
</Item>
</div>
</ItemGroup>
</CardContent>
</Card>
)
}

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