# CLI (/docs/cli) `@aura-design/cli` is the on-ramp for Aura. It wraps Next.js scaffolding and the shadcn CLI with Aura registries, tokens, rules, skills, and optional project tooling. Invoke it with: ```bash pnpm dlx @aura-design/cli@latest ``` The published binary name is `aura`. ## Quick start [#quick-start] **New Next.js app** with Aura fixtures: ```bash pnpm dlx @aura-design/cli@latest init ``` **Existing Next.js app** (skips `create-next-app`): ```bash pnpm dlx @aura-design/cli@latest setup # or pnpm dlx @aura-design/cli@latest setup --dir ./apps/web ``` See also [Installation](/docs/installation) for adding every registry component via `all.txt`. ## Commands [#commands] ### `init` [#init] Creates a Next.js app with `create-next-app`, then applies Aura to the new (or current) project root: 1. Writes `components.json` with `@aura` and `@aura-dev` registries 2. Generates light/dark Radix scales into `globals.css` 3. Runs `shadcn add` for `@aura/class-names`, `@aura/page-get-starter`, `@aura/css-main`, `@aura/rules`, and `@aura/skills` 4. Runs the same scaffolding as [`blueprint`](#blueprint), including [gdp-ts](/docs/gdp-ts) proofs ```bash pnpm dlx @aura-design/cli@latest init ``` ### `setup` [#setup] Same Aura apply step as `init`, for a project that already has a `package.json`. Does **not** run `create-next-app`. | Option | Description | | ------------------ | ------------------------------------------------- | | `-d, --dir ` | Project root (default: current working directory) | ```bash pnpm dlx @aura-design/cli@latest setup --dir . ``` ### `link` [#link] Downloads the canonical Aura `components.json` (shadcn config with `@aura` registry URLs) into your project. Useful when you only need the registry config, not a full `setup`. | Option | Description | | --------------------- | -------------------------------------------------------- | | `-o, --output ` | Output path relative to cwd (default: `components.json`) | ```bash pnpm dlx @aura-design/cli@latest link pnpm dlx @aura-design/cli@latest link --output ./apps/web/components.json ``` ### `blueprint [projectDir]` [#blueprint-projectdir] Scaffolds Aura project tooling into any repo with a `package.json`: * `wiki/bruno-*` and `wiki/obsidian-*` (suffix from the package name, first segment stripped), including `00-General/Internal-Progress.md` * `.cursor/skills/generate-brand-images/` (identity-aware Gemini landing images) * Reconciled [Emil Kowalski skills](/docs/emil-skills) and Vercel `react-best-practices`, copied into `.cursor/skills/`. Re-running blueprint updates those files in place (no `--force`, no second copy) * `scripts/preflight.ts` and `scripts/brain-progress.ts` (`pnpm brain:progress`) * Upstream [shadcn/ui skills](https://ui.shadcn.com/docs/skills) via `pnpm dlx skills add shadcn/ui` (`.agents/skills/shadcn`) * `sonar-project.properties` plus Sonar-related scripts / devDependencies * `.gitignore` and `.env.example` entries for Sonar / Gemini keys * [gdp-ts](/docs/gdp-ts): `@gdp-ts/core`, the ESLint preset, the agent skill, a `typecheck` script when one is missing, and starter proofs (`session`, org role, project role, plan entitlement, and `deleteProject`) Also runs automatically at the end of `init` and `setup`. | Option | Description | | ------------------- | ------------------------------------------------------------------ | | `-f, --force` | Overwrite bootstrap files and `scripts/preflight.ts` if they exist | | `--suffix ` | Override wiki folder suffix | ```bash pnpm dlx @aura-design/cli@latest blueprint pnpm dlx @aura-design/cli@latest blueprint ./apps/web --force --suffix my-app ``` > **Note:** The docs page [Agent blueprint](/docs/mcp-agent-blueprint) is an **agent runbook** (`Ejecuta` + URL). It is not this CLI command, though the runbook may invoke `aura blueprint` when scaffolding is missing. ### `colors` [#colors] Regenerates `globals.css` from accent, gray, and background hex values (Radix-style 12-step scales, light and dark). Pass flags or answer prompts. | Option | Description | | -------------------- | ------------------- | | `--accent ` | Accent color | | `--gray ` | Gray / neutral base | | `--background ` | App background | ```bash pnpm dlx @aura-design/cli@latest colors --accent "#4015ca" --gray "#7254cb" --background "#FFFFFF" ``` Looks for `app/globals.css`, `src/app/globals.css`, `styles/globals.css`, or `src/styles/globals.css` (creates `app/globals.css` if none exist). ### `typography generate` [#typography-generate] Interactive prompts for body size, small size, and a headline scale (ratio or explicit `h6`–`h1`). Writes a custom `typography.css` aligned with Aura’s fluid `clamp()` philosophy. ```bash pnpm dlx @aura-design/cli@latest typography generate ``` ### `spacing [target]` [#spacing-target] Rewrites Tailwind spacing utilities in `.tsx` files from the default **4px** mental model to the closest **13px**-based token (Aura’s `--spacing`). | Option | Description | | ----------------------- | ------------------------------------------------- | | `-d, --dir ` | Scan root when `target` is omitted (default: `.`) | ```bash pnpm dlx @aura-design/cli@latest spacing pnpm dlx @aura-design/cli@latest spacing ./components pnpm dlx @aura-design/cli@latest spacing --dir ./apps/web ``` ## Adding components [#adding-components] After `init` or `setup`, install individual registry items with the shadcn CLI and the `@aura` namespace: ```bash pnpm dlx shadcn@latest add @aura/button ``` There is no Aura command that adds every component. Use the newline-delimited list at [https://auradesignsystem.com/all.txt](https://auradesignsystem.com/all.txt). ## Related [#related] * [Installation](/docs/installation) — shortest path to a working Aura app * [Registry](/docs/registry) — how `@aura/*` items are distributed * [Namespace](/docs/namespace) — `@aura` vs `@aura-dev` in `components.json` * [MCP](/docs/mcp) — browse and install registries from Cursor * [Agent blueprint](/docs/mcp-agent-blueprint) — agent runbook for existing projects * [gdp-ts](/docs/gdp-ts) — proof-carrying authz installed by `init` and `blueprint` # Emil Kowalski skills (/docs/emil-skills) Aura uses [Emil Kowalski’s skills](https://github.com/emilkowalski/skills) for motion, polish, and interface craft, and [Vercel’s React best practices](https://github.com/vercel-labs/agent-skills/tree/main/skills/react-best-practices) for React and Next.js performance. They were compared with the skills already in this repo and with the team’s skill library before anything was copied. Two skills that do the same job are not both installed. `aura init`, `aura setup`, and `aura blueprint` copy the reconciled set into `.cursor/skills/`. Docs for the runbook are the [Agent blueprint](/docs/mcp-agent-blueprint). This page is the practice: what each skill is for, when to apply it, and how to update it. ## Pins and license [#pins-and-license] | Source | Commit | License | | ----------------------------------------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- | | [emilkowalski/skills](https://github.com/emilkowalski/skills) | `e8a175de22ae1e49370fc144c1f3bb9aeedf988d` | MIT. Copyright (c) 2026 Emil Kowalski. The notice is in each skill’s `LICENSE`. | | [vercel-labs/agent-skills](https://github.com/vercel-labs/agent-skills) `skills/react-best-practices` | `063bee94c3f4df8453406c830b0a7df0f2860278` | MIT. The upstream README states MIT. Copyright (c) Vercel, Inc. is in that skill’s `LICENSE`. | Both licenses allow copying. The files in `packages/cli/templates/cursor/skills/` are the copies the blueprint ships. They are not floating installs of `npx skills add`. Aura foundation rules and `DESIGN.md` win when a skill disagrees: 13px spacing, accent and gray steps, fluid type, the 17px floor on editable inputs, and the animation tokens (buttons `cubic-bezier(0.4, 0, 0.6, 1)`, dialogs `cubic-bezier(0.16, 1, 0.3, 1)`, about 250ms, CSS `@theme` tokens). gdp-ts proofs win over the Vercel note `server-auth-actions`. ## What Aura ships [#what-aura-ships] | Skill | Use it when | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `emil-design-eng` | Shaping or reviewing polish, component decisions, and which motion belongs. Criteria only. It does not edit a design file. | | `animate` | Building a web animation: whether it should exist, then tool, properties, curve, and duration. | | `review-animations` | Reviewing motion in a diff against exact curves and durations. Explicit invoke. Default is to flag. | | `improve-animations` | Auditing motion in a codebase and writing self-contained fix plans. It does not apply the fixes. | | `find-animation-opportunities` | Looking for places that should animate, with exact values, and places that should not. Read-only. | | `animation-vocabulary` | A motion effect is described vaguely and needs its name before a design or implementation decision. | | `apple-design` | Gesture, spring, depth, type, or restraint decisions in an Apple-like way, translated for the web. | | `prototype` | Several genuinely different versions of one UI piece, behind a picker. Explicit invoke. | | `mobile-native` | A web app feels like a website on a phone: tap highlight, `100vh`, safe areas, overscroll, input zoom. Inputs stay at 17px. | | `break-ui` | Stress-testing a screen with worst-case data: long names, empty lists, huge counts. | | `ask-sonner` | A file already imports `sonner` (DataGrid still does). New toasts use the Aura toast. | | `pick-ui-library` | Explicitly asked which library fits a frontend task. If `@aura` already has the component, use it before an external library. | | `react-best-practices` | Writing or reviewing React or Next.js for waterfalls, bundle size, server work, and re-renders. The skill `name` is `vercel-react-best-practices`. | The team library already had adaptations of `emil-design-eng`, `animation-vocabulary`, `apple-design`, `find-animation-opportunities`, `improve-animations`, `review-animations`, and `prototype`. Where those were short pointers at an external criteria file, the full upstream text is now in the skill, and the team’s “do not edit a design file” line stays. Where the team’s body already matched upstream (`animation-vocabulary`, `find-animation-opportunities`), that body stayed. `humanize` is a team prose skill and does not share a job with this set, so blueprint does not install it. ## Comparison [#comparison] | Skill | Closest we had | Overlap | Decision | | ------------------------------ | ------------------------------------------------------------------------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------- | | `emil-design-eng` | Team skill (criteria stub) and the animation rule | Partial | Merge | | `animate` | Animation rule (guardrail, not a builder) | Partial | Merge. The rule stays; the skill builds. | | `animate-expo` | None | None | Skip. Pablo decided not to install it. | | `review-animations` | Team skill (criteria stub) | Partial | Merge | | `improve-animations` | Team skill (criteria stub) | Partial | Merge | | `find-animation-opportunities` | Team skill, same body | Same | Keep ours | | `animation-vocabulary` | Team skill, same body | Same | Keep ours | | `apple-design` | Team skill (criteria stub) | Partial | Merge | | `write-swift` | None | None | Skip. Pablo decided not to install it. | | `pick-ui-library` | shadcn skill and the Aura registry | Partial | Adopt. Aura-first: if `@aura` already has the component, use it before an external library. Pablo decided to ship it. | | `prototype` | Team skill (criteria stub) | Partial | Merge | | `mobile-native` | Typography rule (17px) and hover gating | Partial | Merge. 16px inputs raised to 17px. | | `break-ui` | None | None | Adopt | | `ask-sonner` | Aura Toast docs; DataGrid still imports `sonner` | Partial | Merge, scoped to existing `sonner` imports. Pablo kept it. | | `react-best-practices` | `apps/www/AGENTS.md` is a Next.js version note, not a performance guide. gdp-ts is authorization. | None for this job | Adopt | ## Skipped [#skipped] Pablo decided these stay out. * **`write-swift`** has no equivalent, and Aura and the SGD apps do not ship Swift. * **`animate-expo`** has no equivalent. It is React Native and Expo. Web motion is `animate`. ## Install or update [#install-or-update] From the app root: ```bash pnpm dlx @aura-design/cli@latest blueprint ``` The same command runs at the end of `aura init` and `aura setup`. Re-running it refreshes the pinned skills when the copy in the CLI has changed, and does nothing to them when they already match. It does not create a second folder. A same-named skill under `.agents/skills/` is removed. `--force` overwrites wiki bootstrap files. It is not required to update these skills. The skills are not part of `@aura/skills` in the registry. Consumer projects get them from blueprint. # gdp-ts (/docs/gdp-ts) # gdp-ts [#gdp-ts] How Aura uses Guillermo Rauch’s **gdp-ts** so a sensitive function cannot be called unless the authorization check for those exact ids already happened. Aura already gives agents a **visual contract** ([rules](/docs/rules), `DESIGN.md`, [`@shadcn/lint`](/docs/lint)), **procedures** (skills, the [Agent blueprint](/docs/mcp-agent-blueprint)), and **engineering playbooks** ([pstack](/docs/pstack)). **gdp-ts** is a different gate: proof-carrying authorization. The typechecker rejects a call that skipped the check, used a check about a different user or project, or passed a raw id. This page is the practice. Install and audit steps for a whole project live in the [Agent blueprint](/docs/mcp-agent-blueprint) and in `aura init` / `aura blueprint`. ## What gdp-ts is [#what-gdp-ts-is] **gdp-ts** ([`rauchg/gdp-ts`](https://github.com/rauchg/gdp-ts)) is a small TypeScript library, an ESLint (and Oxlint) preset, and an agent skill. The package on npm is [`@gdp-ts/core`](https://www.npmjs.com/package/@gdp-ts/core). It needs TypeScript 5.4 or newer. The library exports `name`, `defineProof`, `Named`, and `Proof`. There is no runtime policy engine inside it. 1. **Name values.** `name(sessionId, userId, projectId, (session, user, project) => …)` gives each value a compile-time name that exists only inside the callback. `name` accepts one, two, or three values. 2. **Prove facts in `proofs/`.** A trusted module calls `defineProof` (not exported) and returns `Proof<"…", [names]>` or `null`. At runtime a proof is a frozen `{ kind }` object. 3. **Demand proofs.** The sensitive function takes `Named` arguments plus those proof types. The wrong proof, a proof about another value, a raw id, or no proof is a compile error. The skill (`npx skills add rauchg/gdp-ts`) is the longer manual: recipe, patterns, error readings, and where to stop. ## What it does not replace [#what-it-does-not-replace] | Layer | Job | | --------------------------------------------------- | ---------------------------------------------------- | | Policy engines (CASL, Oso, Cerbos, Permit, OpenFGA) | Decide whether a subject may act | | Branded ids (`lib/ids.ts`) | Say what *kind* of id a string is | | zod / valibot / ArkType | Prove the *shape* of data at the edge | | [`@shadcn/lint`](/docs/lint) + Aura rules | Visual contract for UI | | [pstack](/docs/pstack) | How agents investigate, implement, and show evidence | gdp-ts only guarantees that a decision reaches the function that depends on it, about the right values. Call your policy engine inside a proof function and return a proof. It does not stop a stale proof, a bug inside the trusted module, or a forged proof if someone disables the linter. Those limits are spelled out in the upstream skill (`references/limits.md`). ## The practice [#the-practice] 1. **Every sensitive API requires a proof** about its exact arguments: a valid session, a role on that org or project, a plan entitlement, or whatever fact the operation trusts. Middleware may authenticate. The check itself runs in the handler, next to the call. 2. **Proofs are never cast.** Do not write `as SomeProof`, `as Proof`, `as Named`, or `any` to satisfy a proof parameter. If the honest path needs an assertion, the signature is wrong or the code is outside `proofs/`. The one intentional `as` in the library is inside `prove`. 3. **CI is typecheck plus lint.** `pnpm typecheck` (`tsc --noEmit`) rejects a missing or mismatched proof. `pnpm lint` runs the `@gdp-ts/core/lint/eslint` preset, which rejects what the typechecker cannot see: * `gdp-ts/no-define-proof` outside `proofs/` * `gdp-ts/no-exported-prover` inside `proofs/` * `gdp-ts/no-proof-assertion` (`{} as UserHasProjectRole`) * In strict mode only: every other `as`, and every `any` Aura wires the **default** preset (not strict). Default mode only flags code that touches proofs, so it can land in an existing Next.js app. Strict mode also bans `as` and `any` outside `proofs/` and `lib/ids.ts`. Turn that on when the codebase is clean. Brand constructors in `lib/ids.ts` are the allowed `as`. Keep each trusted module small and test the function that returns `null`. Everything downstream is the compiler’s job. Add a line under `// @ts-expect-error` in `data/mistakes.ts` when a call site almost slipped through. ## What `aura init` scaffolds [#what-aura-init-scaffolds] `aura init` and `aura setup` call `aura blueprint`. Blueprint does four things, matching the upstream install (`pnpm add @gdp-ts/core`, then `npx skills add rauchg/gdp-ts`): 1. Adds `@gdp-ts/core` (and ESLint 9+ / TypeScript 5.4+ when the app does not already have them). 2. Spreads the ESLint preset into `eslint.config.mjs` (or `.js` / `.ts`). If that file is not a flat array or `tseslint.config(...)` call, it writes `eslint.gdp-ts.mjs` and prints the one spread to add. CommonJS configs are left untouched for the same reason: the preset is ESM. 3. Installs the skill for Cursor, non-interactively: ```bash npx skills add rauchg/gdp-ts --skill gdp-ts -a cursor -y --copy ``` The skills CLI puts a Cursor project skill at `.agents/skills/gdp-ts/`. `--copy` writes real files so the skill can be committed. The README’s command is the same install without those flags; the flags only skip prompts and avoid a symlink. 4. Writes a starter module under the directory `@/` points at (`src/` when the tsconfig maps `@/*` to `./src/*`, otherwise the project root): | File | Fact | | ------------------------------------- | ----------------------------------------------------------------------------------- | | `lib/ids.ts` | Branded `UserId`, `SessionId`, `OrgId`, `ProjectId` | | `lib/authz-store.ts` | In-memory stand-in. Replace with your database. Handlers must not write roles here. | | `proofs/session-is-valid.ts` | This session belongs to this user | | `proofs/user-has-org-role.ts` | Owner or Member of this org | | `proofs/user-has-project-role.ts` | Owner or Member of this project | | `proofs/plan-includes-entitlement.ts` | Pro or Enterprise includes API project deletion | | `data/delete-project.ts` | Demands session, project role, and plan proofs for those ids | | `data/delete-project-handler.ts` | Names the three ids, turns `null` into 401/403, then calls `deleteProject` | | `data/mistakes.ts` | `@ts-expect-error` lines that must keep failing | `name` takes at most three values, so `deleteProject` names the session, the user, and the project. The org-role proof is the same pattern for an org-scoped function (name the user and the org). Demo rows in the store: `sess_demo`, `user_demo`, `prj_demo` (pro). `prj_hobby` stays on the hobby plan so the entitlement check returns `null`. Re-running blueprint skips files that already exist. `--force` overwrites the starter proofs and reinstalls the skill. ## Install on an existing app [#install-on-an-existing-app] ```bash pnpm dlx @aura-design/cli@latest blueprint . ``` Or the upstream commands, then copy the starter from a fresh blueprint if you want the example module: ```bash pnpm add @gdp-ts/core npx skills add rauchg/gdp-ts --skill gdp-ts -a cursor -y --copy ``` ```js import gdp from "@gdp-ts/core/lint/eslint"; export default [ ...yourExistingConfig, ...gdp({ proofs: ["**/proofs/**"] }), ]; ``` Oxlint (no TypeScript compiler API, so it also runs on TypeScript 7) is the other preset upstream ships: `import gdp from "@gdp-ts/core/lint/oxlint"`. Aura’s Next.js path uses the ESLint preset. The agent runbook audits this the same way it audits pstack. Paste: ```text Ejecuta https://auradesignsystem.com/docs/mcp-agent-blueprint ``` ## Related [#related] * [Agent blueprint](/docs/mcp-agent-blueprint) — audit + install, including gdp-ts * [CLI](/docs/cli) — `aura init`, `setup`, and `blueprint` * [pstack](/docs/pstack) — verification playbooks; it does not check authorization proofs * [Lint](/docs/lint) — `@shadcn/lint` for UI tokens * Upstream: [rauchg/gdp-ts](https://github.com/rauchg/gdp-ts) # Introduction (/docs) ## Do you? [#do-you] Building something different always comes with some friction. It requires a bit more work, more intention, sometimes even going against the grain. But that's what makes it worth it. Like cooking with your own spices, the end result is richer and more satisfying because it tastes uniquely yours. This is not a component library in the traditional sense. It's a collection of components built from our team's experience, and we are distributing them using the 'shadcn mode'. It was built for our team, but you can use it too. **What do you mean by not a component library?** I mean you own the code. You can decide how the components are built and styled. You can copy and paste the code into your project and customize it to your needs. ## FAQ [#faq] ### How is this different from other component libraries? [#how-is-this-different-from-other-component-libraries] Just the taste. It's one more design system for the web. ### How do I pull upstream updates? [#how-do-i-pull-upstream-updates] The `@aura-design/cli init` command is a wrapper around the original `shadcn/ui` CLI. It includes some fixtures to adapt the components to the Aura Design System configuration. When you run `init`, it will ask you if you want to overwrite the existing components. You can say yes to get the latest updates. You can also manually copy and paste the code from the [GitHub repository](https://github.com/somosgented/aura-design-system). ### What is the `@aura-design/cli init` command? [#what-is-the-aura-designcli-init-command] The `init` command from `@aura-design/cli` is a wrapper around the original `shadcn/ui` CLI. It has been adapted with specific fixtures to seamlessly integrate with the Aura Design System's configuration. This ensures that when you initialize components, they are already set up to work within the Aura ecosystem, saving you time and effort in manual configuration. See the [CLI](/docs/cli) docs for `setup`, `link`, `blueprint`, `colors`, `typography`, and `spacing`. The visual rules — color steps, the 13px grid, type, icons, motion — are written up as [Foundations](/docs/foundations). # Installation (/docs/installation) To install, simply run the Aura Design CLI. ```bash pnpm dlx @aura-design/cli@latest init ``` This command will automatically execute the original shadcn/ui CLI under the hood, and then apply all necessary tokens and configuration fixtures specific to the Aura Design System. You don't need to manually configure anything — just run the command below and your project will be set up with Aura's tokens and settings seamlessly. For an existing Next.js app, use `setup` instead of `init`. Full command reference: [CLI](/docs/cli). > **Note:** There isn't a custom CLI command to add *all* components at once. If you want to install all available Aura components in one shot, you can use the list at [https://auradesignsystem.com/all.txt](https://auradesignsystem.com/all.txt). This URL always contains an up-to-date, newline-delimited list of all registry component names. # Lint (/docs/lint) `@shadcn/lint` is the **machine-checkable** layer for Aura’s design system. Cursor [rules](/docs/rules) and `DESIGN.md` tell agents what to do; lint errors tell them **how to fix** UI that drifts from Aura. It works with Tailwind v4 design systems (shadcn/ui is **not** required). Aura ships a ready policy as registry items. ## Quick start [#quick-start] Requires **ESLint ≥ 9.30** and **Node ≥ 20.19**. ```bash pnpm add -D @shadcn/lint pnpm dlx shadcn@latest add @aura/eslint-shadcn-lint ``` That installs: * `eslint.aura-shadcn.mjs` — Aura policy fragment (`createAuraShadcnLintConfig`) * `.cursor/rules/shadcn-lint.mdc` — agent guidance (`@aura/rule-shadcn-lint` installs the same pair.) Merge into your existing `eslint.config.mjs` — do **not** replace Next/ESLint presets: ```js import { plugin as shadcn } from "@shadcn/lint" import { createAuraShadcnLintConfig } from "./eslint.aura-shadcn.mjs" export default [ ...existingConfig, ...createAuraShadcnLintConfig(shadcn), ] ``` Ensure `package.json` has a `lint` script. After UI work, run it and clear findings. On an existing Aura app, the [Agent blueprint](/docs/mcp-agent-blueprint) runbook (§ D) audits and installs this for you. ## What Aura enables [#what-aura-enables] Rules start at **`warn`**. Promote to **`error`** once the baseline is clean. | Rule | Aura intent | | ---------------------------- | ------------------------------------------------------------------------ | | `shadcn/no-arbitrary-values` | Use the **13px** spacing scale — no `p-[16px]` | | `shadcn/no-raw-colors` | Use **accent/gray** steps or semantic tokens — no `bg-pink-500` | | `shadcn/no-restyle` | Callers may use **layout/width** only; appearance stays on the component | ### Form controls [#form-controls] `Input`, `Textarea`, `SelectTrigger`, `MaskInput`, and `SegmentedInput` **own their padding**. Use layout/width at the call site. If the design needs different padding, **add a size or variant on the component** — do not override padding with `className`. Never put `text-sm` / `text-xs` on editable controls (Aura floor is **17px**). See [Typography rules](/docs/rules/fundations-typography). Form shells (`Form`, `FormField`, …) may use **layout + spacing** (e.g. gap around fields). ### Component directory [#component-directory] Keep `no-restyle` and `no-arbitrary-values` **off** under `components/ui/**`. Components own their appearance; the policy applies to **callers**. Adjust `settings.shadcn.ui` (default `@/components/ui`) if your alias differs. ## Why lint (not only Cursor rules) [#why-lint-not-only-cursor-rules] Prose rules guide agents. Lint closes the loop: write UI → `pnpm lint` → fixable error with Aura-specific guidance → correct in one round. Upstream project and evals: [shadcn-ui/lint](https://github.com/shadcn-ui/lint). ## Related [#related] * [Agent blueprint](/docs/mcp-agent-blueprint) — install via `Ejecuta` runbook (§ D) * [Rules / shadcn lint](/docs/rules/shadcn-lint) — Cursor rule mirror * [DESIGN.md rule](/docs/rules/design-md) — narrative design spec * [MCP](/docs/mcp) — registries and skills setup # Agent blueprint (/docs/mcp-agent-blueprint) **Human usage:** copy this page URL and tell the agent: ```text Ejecuta https://auradesignsystem.com/docs/mcp-agent-blueprint ``` (Local docs: `http://localhost:4000/docs/mcp-agent-blueprint`.) This page is an **agent runbook**, not the CLI command `aura blueprint`. The runbook may invoke that command when the project is missing its wiki, image-generation command, preflight, Sonar scaffolding, or gdp-ts proofs. *** ## Agent: follow this runbook [#agent-follow-this-runbook] If the user pasted this URL with **Ejecuta**, **Run**, **Execute**, or **Aplica**, you are the agent. Do not only explain. **Audit the workspace, install what is missing, and report.** Default target: **consumer project** (an app using Aura). Only use the Aura monorepo registry path if the repo clearly is `aura-design-system` (has `packages/registry/registry/default/components/`). ### 0. Preconditions [#0-preconditions] * Work in the project root that has (or will have) `package.json` / the Next app. * Prefer non-interactive installs: `pnpm dlx …`. * Do not invent secrets. Do not force-push. * If `components.json` is missing and the app is not Next-like, say what blocked you and stop after the audit. ### 1. Audit (read before write) [#1-audit-read-before-write] Check and record **present / missing** for each: | Item | How to detect | | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Aura `components.json` | File exists; `registries["@aura"]` points at Aura registry JSON | | `DESIGN.md` (root) | File at project root (or install via `@aura/rule-design-md` / `@aura/design-md`) | | Cursor rules | `.cursor/rules/*.mdc` (foundations, principles, design-md, etc.) | | Cursor skills | `.cursor/skills/port-component-to-aura/SKILL.md` and `.cursor/skills/generate-brand-images/SKILL.md` | | Reconciled Emil + Vercel skills | `.cursor/skills/emil-design-eng/SKILL.md` and `.cursor/skills/react-best-practices/SKILL.md` (the rest of the set is installed with them). No second copy under `.agents/skills/` | | Image identity | `wiki/obsidian-*/01-Identity/Image-Identity.md`; `status: ready` only when style, palette, composition/motifs, and exclusions are concrete | | Gemini image env | `.env.example` names `GOOGLE_API_KEY`; only check whether `.env` has `GOOGLE_API_KEY` or `GEMINI_API_KEY`, never print its value | | shadcn MCP | `.cursor/mcp.json` (or user MCP) with `shadcn` → `npx shadcn@latest mcp` | | `@shadcn/lint` | Dep `@shadcn/lint` present; `eslint.config.*` registers `shadcn/*` rules; optional `eslint.aura-shadcn.mjs` + `.cursor/rules/shadcn-lint.mdc` | | pstack / `/poteto-mode` | Marketplace plugin **pstack** enabled for this Cursor workspace **or** project has a usable poteto-mode entry (e.g. `.cursor/skills/poteto-mode/SKILL.md` or `AGENTS.md` explicitly requiring `/poteto-mode`). Record: `plugin` / `repo-skill` / `missing` | | pstack model setup (optional) | User-level `~/.cursor/rules/pstack-models.mdc` exists after `/setup-pstack` — note present/missing; do **not** fail the Aura blueprint if missing | | `@gdp-ts/core` | `dependencies` or `devDependencies` in `package.json` | | gdp-ts ESLint preset | An `eslint.config.*` file references `@gdp-ts/core/lint/eslint`, or `eslint.gdp-ts.mjs` exists and the flat config spreads it | | gdp-ts skill | `.agents/skills/gdp-ts/SKILL.md` or `.cursor/skills/gdp-ts/SKILL.md` | | gdp-ts proofs | `proofs/session-is-valid.ts`, `proofs/user-has-project-role.ts`, `proofs/plan-includes-entitlement.ts`, and `data/delete-project.ts` (project root or `src/`, wherever `@/` points) | | Package manager | Prefer `pnpm`; fall back to npm/yarn if that is what the repo uses | Also note: Tailwind/Aura CSS already applied? (`globals.css` with accent/gray scales, or prior `aura setup` / `init`). **Cloud agents:** marketplace plugins may not load reliably on cloud VMs. Prefer detecting repo-local skill / `AGENTS.md`, and report **plugin unknown on cloud VM** honestly — never claim pstack is installed if `/poteto-mode` does not resolve. ### 2. Install / fix gaps [#2-install--fix-gaps] Apply **only what is missing**. Prefer the smallest fix. #### A. Full Aura apply (no Aura yet, or heavily incomplete) [#a-full-aura-apply-no-aura-yet-or-heavily-incomplete] ```bash pnpm dlx @aura-design/cli@latest setup ``` (`setup` updates `components.json`, `globals.css`, adds registry packages, rules, skills, and runs CLI blueprint scaffolding. Use from the app root.) If the user only wants AI context (not full theme rewrite), skip `setup` and use the targeted adds below. #### B. Targeted AI context (typical “old project already on Aura”) [#b-targeted-ai-context-typical-old-project-already-on-aura] ```bash pnpm dlx shadcn@latest add \ @aura/rules \ @aura/skills \ @aura/rule-design-md \ @aura/eslint-shadcn-lint ``` * `@aura/rules` → `.cursor/rules/` * `@aura/skills` → `.cursor/skills/` (includes `port-component-to-aura` and `generate-brand-images`) * `@aura/rule-design-md` → rule + root `DESIGN.md` * `@aura/eslint-shadcn-lint` → `eslint.aura-shadcn.mjs` + `.cursor/rules/shadcn-lint.mdc` (see **D** below) If the blueprint wiki, `Image-Identity.md`, or `pnpm ai:image` is missing, scaffold those project capabilities without rewriting the Aura theme: ```bash pnpm dlx @aura-design/cli@latest blueprint . ``` Ensure `components.json` has: ```json "registries": { "@aura": "https://auradesignsystem.com/r/{name}.json" } ``` (Optional local registry while developing Aura itself: `"@aura-dev": "http://localhost:4000/r/{name}.json"`.) #### C. MCP config (create if missing) [#c-mcp-config-create-if-missing] Write **project** `.cursor/mcp.json` if absent: ```json { "mcpServers": { "shadcn": { "command": "npx", "args": ["shadcn@latest", "mcp"] } } } ``` Then tell the user (you cannot finish this in the terminal): 1. Restart Cursor or reload MCP. 2. Settings → MCP → enable **shadcn** (green dot). #### D. `@shadcn/lint` (Aura Tailwind policy for agents) [#d-shadcnlint-aura-tailwind-policy-for-agents] Machine-checks design-system usage so agents get fixable errors (not only prose rules). Requires **ESLint ≥ 9.30** and **Node ≥ 20.19**. If missing: ```bash pnpm add -D @shadcn/lint pnpm dlx shadcn@latest add @aura/eslint-shadcn-lint ``` (`@aura/rule-shadcn-lint` installs the same pair: Cursor rule + `eslint.aura-shadcn.mjs`.) Merge into the app’s `eslint.config.mjs` (do **not** replace an existing Next/ESLint setup): ```js import { plugin as shadcn } from "@shadcn/lint" import { createAuraShadcnLintConfig, } from "./eslint.aura-shadcn.mjs" export default [ ...existingConfig, ...createAuraShadcnLintConfig(shadcn), ] ``` Policy shipped at **`warn`**: | Rule | Aura intent | | ---------------------------- | --------------------------------------------------------------------- | | `shadcn/no-arbitrary-values` | 13px spacing scale — no `p-[16px]` | | `shadcn/no-raw-colors` | accent/gray / semantic tokens only | | `shadcn/no-restyle` | layout only; form controls own padding (add a size/variant if needed) | Keep `no-restyle` / `no-arbitrary-values` **off** under `components/ui/**`. Ensure `package.json` has a `lint` script; after UI work, run it and fix findings. Promote rules to `error` once the warning baseline is under control. Upstream: [shadcn-ui/lint](https://github.com/shadcn-ui/lint). Docs: [Lint](/docs/lint). #### E. pstack (agent rigor / poteto-mode) [#e-pstack-agent-rigor--poteto-mode] Additive **agent-rigor** layer (verification + playbooks). It does **not** replace `@aura/rules`, Aura skills, `DESIGN.md`, shadcn MCP, or `@shadcn/lint`. **When to install:** Always offer when missing on an Aura **consumer** or **aura-monorepo** workspace where the user is using Cursor agents. Skip only if the user said “Aura UI context only, no pstack.” **Easy path (preferred)** — Cursor marketplace plugin `pstack` (id `9717366`). Prefer this over vendoring `backnotprop/pstack` into apps: ```text 1. In Cursor chat on this project: /add-plugin pstack 2. Pick pstack in the plugin picker 3. /setup-pstack 4. Start a new chat 5. Smoke: /poteto-mode . Done means . Do not edit unless asked. ``` **Aura monorepo:** if scenario is `aura-monorepo`, link root [`AGENTS.md`](https://github.com/somosgented/aura-design-system/blob/canary/AGENTS.md) (and `.cursor/skills/aura-constraints/`, `.cursor/skills/aura-verification/`) — do **not** duplicate a full pstack vendor into the registry. **Cloud agent note:** If `/poteto-mode` is not found on a cloud agent, say so in the result table. Workarounds: (1) open [cursor.com/agents](https://cursor.com/agents) and prompt “use /poteto-mode (pstack) …”; (2) optional thin `.cursor/skills/poteto-mode/SKILL.md` committed only when the team opts in — do **not** auto-vendor the full plugin. **Grok Bot note:** Account install of marketplace plugin `9717366` enables pstack skills for Grok Bots; still run `/setup-pstack` in Cursor IDE for local model roles. **Do not:** `git clone` pstack into `node_modules` or app `src/`; do not replace Aura lint/rules with pstack principles. #### F. Reconciled Emil skills and Vercel React practices [#f-reconciled-emil-skills-and-vercel-react-practices] Motion, polish, mobile-web feel, worst-case UI data, and React/Next performance come from a reconciled set, not a blind `skills add`. The comparison and the skipped skills are on [Emil Kowalski skills](/docs/emil-skills). **When to install or update:** Whenever any skill in that set is missing, or when a re-run should pick up the pinned copy. Skip only if the user said “no Emil skills”. `aura init` and `aura setup` already run this. On an existing app: ```bash pnpm dlx @aura-design/cli@latest blueprint . ``` That writes `.cursor/skills//` for `emil-design-eng`, `animate`, `review-animations`, `improve-animations`, `find-animation-opportunities`, `animation-vocabulary`, `apple-design`, `prototype`, `mobile-native`, `break-ui`, `ask-sonner`, `pick-ui-library`, and `react-best-practices`. Re-running updates those files in place. `--force` is not required. A same-named folder under `.agents/skills/` is removed so the project does not keep two skills for one job. Aura foundation rules win when a skill disagrees (13px spacing, accent/gray, fluid type, 17px inputs, animation tokens). `ask-sonner` is only for files that already import `sonner`. gdp-ts proofs win over the Vercel `server-auth-actions` note. **Do not:** install `write-swift` or `animate-expo`. Do not vendor this set into the Aura registry. `pick-ui-library` uses an `@aura` component before an external library. #### G. gdp-ts (proof-carrying authz) [#g-gdp-ts-proof-carrying-authz] Every sensitive function takes a typed proof that the check already ran for those ids. Proofs are never cast. CI is `pnpm typecheck` and `pnpm lint`. This does **not** replace a policy engine, Aura rules, `@shadcn/lint`, or pstack. See [gdp-ts](/docs/gdp-ts). **When to install:** Whenever `@gdp-ts/core`, the ESLint preset, the skill, or the starter proofs are missing. Skip only if the user said “no gdp-ts”. `aura init` and `aura setup` already run this. On an existing app, re-run blueprint (it skips wiki and proof files that exist; it updates the reconciled skills in place): ```bash pnpm dlx @aura-design/cli@latest blueprint . ``` That installs `@gdp-ts/core`, spreads `@gdp-ts/core/lint/eslint` into the flat ESLint config, runs: ```bash pnpm dlx skills add shadcn/ui -a cursor -y --copy npx skills add rauchg/gdp-ts --skill gdp-ts -a cursor -y --copy ``` and writes `lib/ids.ts`, `lib/authz-store.ts`, `proofs/*`, and `data/delete-project.ts` (plus the handler and `data/mistakes.ts`) under the `@/` root. Aura Keep invariants in `.cursor/rules/` win when the upstream shadcn skill conflicts (icons, forms, 13px spacing, typography). **Do not:** vendor the skill into the Aura registry, cast `as Proof`, export a prover, or call `defineProof` outside `proofs/`. The default preset is not strict mode; do not turn on `strict: true` in an app that still uses `as` for unrelated code. Replace `lib/authz-store.ts` with the real database before shipping. The in-memory maps are a stand-in, and handlers must not write roles into them. ### 3. Verify [#3-verify] Confirm after installs: * `.cursor/rules/` has Aura foundation rules (including `aura-agent-surface.mdc` when installed via `@aura/rules`) * `.cursor/skills/port-component-to-aura/SKILL.md` exists * `.cursor/skills/generate-brand-images/SKILL.md` and its generator script exist * `.cursor/skills/verify-aura-ui/SKILL.md` and `.cursor/skills/update-project-brain/SKILL.md` exist when `@aura/skills` was added * `.cursor/skills/emil-design-eng/SKILL.md` and `.cursor/skills/react-best-practices/SKILL.md` exist, and `.agents/skills/` does not also contain those names * `.agents/skills/shadcn/SKILL.md` exists (or document the gap) * `wiki/obsidian-*/01-Identity/Image-Identity.md` exists * `package.json` has `ai:image` and `brain:progress`; `.env.example` names `GOOGLE_API_KEY` * Root `DESIGN.md` exists (or user declined design-md) * `components.json` includes `@aura` * `.cursor/mcp.json` has shadcn MCP (user must enable in UI) * `@shadcn/lint` installed; `eslint.config.*` spreads `createAuraShadcnLintConfig` (or equivalent); `eslint.aura-shadcn.mjs` / `shadcn-lint` rule present when using the registry item * pstack available: `/poteto-mode` resolves in this environment **or** documented missing with reason (local plugin / cloud gap / user skipped) * If installed (or install was offered): agent can state that `/setup-pstack` was offered or completed * `@gdp-ts/core` is installed; the ESLint preset is spread into the flat config (or `eslint.gdp-ts.mjs` exists and the gap is named); `.agents/skills/gdp-ts/SKILL.md` or `.cursor/skills/gdp-ts/SKILL.md` exists; `data/delete-project.ts` demands session, project-role, and plan proofs Optional smoke: `pnpm dlx shadcn@latest add @aura/button` only if the user asked to install a component; do not add random UI. ### 4. How to work after setup [#4-how-to-work-after-setup] * **Consumer app:** follow the skill `port-component-to-aura` in **project** mode — write into `@/components` aliases; do **not** create Ladle/registry metadata. * **Aura monorepo:** skill **registry** mode — component + Ladle `Default` story + `metadata/{kebab}.yml` + `registry:generate` / `registry:build`. * **Landing with images:** follow `generate-brand-images`. Read the blueprint `01-Identity` notes first. If image identity is undefined, ask focused identity questions and update `Image-Identity.md` before generating. * **Gemini access:** the bundled command reads `GOOGLE_API_KEY` or `GEMINI_API_KEY` directly from root `.env`. If absent, ask whether the user wants to add a key, use agent-native image generation when available, or continue with placeholders; never fabricate a key. * **Batch landing flow:** inspect the landing, create one manifest for the necessary image set, run `pnpm ai:image -- --manifest `, integrate accepted local assets, then record their paths in the wiki. * **Mobile form UX (critical):** `input` / `textarea` / `select` font-size **MUST be ≥ 17px**. Never put `text-sm` or `text-xs` on editable fields—iOS Safari zooms on focus below that size. Keep `html { font-size: 17px; }` and the global floor in `styles/main.css` (`font-size: max(1rem, 17px)`). See [Typography rules](/docs/rules/fundations-typography) and `DESIGN.md`. * **Design lint:** after UI edits, run `pnpm lint` (or the app’s lint script) and clear `@shadcn/lint` warnings. Form controls own padding — add a size/variant on the component if spacing must change. * **Hard engineering / multi-step changes:** prefer `/poteto-mode` (pstack) with an explicit **Done means** check. Keep Aura skills for porting components (`port-component-to-aura`), brand images, and `DESIGN.md`. Use `@shadcn/lint` + Aura rules as the **Gardener** gates; use pstack for playbooks + verification. After UI edits still run `pnpm lint`. * **Sensitive APIs:** follow the gdp-ts skill. A function that reads or changes something only some callers may touch takes a proof about its named arguments. Never cast a proof. After authz edits, run `pnpm typecheck` and `pnpm lint`. * **Verification skill (optional):** `/create-verification-skill` is **project-local**. Inside the Aura monorepo it should target Aura surfaces (Ladle stories, token docs, package exports) — not product-app flows unless the consumer asks. * Concepts: [MCP overview](/docs/mcp) · [Rules](/docs/rules) · [Installation](/docs/installation) ### 5. Final reply to the user (required format) [#5-final-reply-to-the-user-required-format] ```markdown ## Agent blueprint — result **Project:** **Scenario:** consumer | aura-monorepo | Check | Before | Action | After | | --- | --- | --- | --- | | components.json @aura | … | … | … | | DESIGN.md | … | … | … | | .cursor/rules | … | … | … | | .cursor/skills | … | … | … | | Emil + Vercel skills | … | … | … | | Blueprint image identity | … | … | … | | Gemini image command | … | … | … | | .cursor/mcp.json | … | … | … | | @shadcn/lint + Aura eslint fragment | … | … | … | | pstack / poteto-mode | … | … | … | | pstack-models setup (optional) | … | … | … | | @gdp-ts/core | … | … | … | | gdp-ts ESLint preset | … | … | … | | gdp-ts skill | … | … | … | | gdp-ts proofs (`deleteProject`) | … | … | … | **Manual step for you:** enable shadcn MCP in Cursor Settings (if not already green). If pstack was offered, finish `/add-plugin pstack` → `/setup-pstack` in Cursor desktop when you are not already set up. **Next:** paste a component URL to port it, or describe a landing page; the agent can plan and generate its identity-aligned image set. For rigorous / multi-step changes, paste a `/poteto-mode … Done means …` task after the blueprint finishes. ``` Stop when the table is accurate. Do not claim MCP is connected if the user still needs to flip the Settings toggle. Do not claim pstack/`/poteto-mode` is available if the plugin did not resolve (especially on cloud agents). *** ## Related [#related] * [pstack](/docs/pstack) — what `/poteto-mode` is, why Aura uses it, easy install * [gdp-ts](/docs/gdp-ts) — proof-carrying authz, starter proofs, CI typecheck + lint * [Emil Kowalski skills](/docs/emil-skills) — reconciled motion, polish, and React performance skills, and the update command * [Lint](/docs/lint) — `@shadcn/lint` setup and Aura policy * [MCP](/docs/mcp) — what MCP/skills/rules are * [Rules](/docs/rules) — install `@aura/rules` * [Installation](/docs/installation) — `aura` CLI init * CLI `aura blueprint` — wiki, preflight, Sonar, and gdp-ts scaffolding (not this runbook) # MCP (/docs/mcp) ## Update an existing project (copy → Ejecuta) [#update-an-existing-project-copy--ejecuta] You do **not** only paste the MCP overview. For an old app, use the **agent blueprint** page: one URL the agent audits and installs (MCP config, rules, skills, DESIGN.md). 1. Copy: [Agent blueprint](/docs/mcp-agent-blueprint) 2. Tell the agent: ```text Ejecuta https://auradesignsystem.com/docs/mcp-agent-blueprint ``` That runbook is **not** the CLI command `aura blueprint`. It is instructions for the agent to evaluate and wire AI context into the repo. *** Aura is a [shadcn-compatible registry](/docs/registry). You do not write a custom MCP server to browse it. You wire the **shadcn MCP**, keep **Aura rules** as the visual contract, and add a **Cursor skill** for the porting workflow. [shadcn MCP documentation](https://ui.shadcn.com/docs/mcp) ## What MCP does [#what-mcp-does] The shadcn MCP server is a bridge between Cursor, **component registries**, and the **shadcn CLI**. Once it is enabled, the assistant can: * List and search components across configured registries * Fetch registry JSON for an item * Install items with natural language (“add the dialog”) It reads registries from `components.json`. Aura is already a namespace: ```json { "registries": { "@aura": "https://auradesignsystem.com/r/{name}.json", "@aura-dev": "http://localhost:4000/r/{name}.json" } } ``` The default shadcn/ui registry needs no extra config. MCP **fetches and installs**. It does not restyle shadcn into Aura. Recreating a component with Aura tokens is a **skill** plus the [Rules](/docs/rules). ## Configure Cursor [#configure-cursor] Add a project MCP config at `.cursor/mcp.json`: ```json { "mcpServers": { "shadcn": { "command": "npx", "args": ["shadcn@latest", "mcp"] } } } ``` Then: 1. Restart Cursor (or reload MCP). 2. Enable **shadcn** in Cursor Settings → MCP until it shows a green dot. 3. Confirm browse / search / add tools appear. `components.json` must live in the project you install *into*. In this monorepo that is typically `apps/www`. For local registry work, keep `@aura-dev` pointed at `http://localhost:4000/r/{name}.json` and run the docs app. Example prompts: * Show components in the shadcn registry * Show `@aura` registry items * Fetch `@shadcn/dialog` JSON, do not install yet **Inside the Aura repo**, prefer fetching source over `shadcn add` of upstream shadcn into `packages/registry`. Copy behavior, then write an Aura registry item. If MCP shows “No tools or prompts”: run `npx clear-npx-cache`, re-enable the server, and check **View → Output → MCP**. ### Other clients [#other-clients] Same command, different config file: `.mcp.json` (Claude Code), `.vscode/mcp.json` (VS Code), or `~/.codex/config.toml` (Codex). See the [shadcn MCP client notes](https://ui.shadcn.com/docs/mcp). ## Three layers [#three-layers] | Layer | Role | | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | **MCP** | Live catalog: search and fetch registry items | | **[Rules](/docs/rules)** + [DESIGN.md](https://design-md.auradesignsystem.com/) | Always-on Aura constraints (color steps, 13px spacing, fluid type, Radix icons, motion) | | **Skill** | Procedure: pasted URL → Aura registry item | Do not put the full “port from URL” playbook in an always-apply rule. Rules stay small and constant. The skill runs only when someone pastes a component link or asks to recreate one. ```text You paste: https://ui.shadcn.com/docs/components/sheet │ ▼ Skill: port-component-to-aura │ ┌───────┴────────┐ ▼ ▼ MCP shadcn Rules + DESIGN.md (source JSON) (Aura constraints) │ │ └───────┬────────┘ ▼ New @aura item in packages/registry ``` ## Skill: URL → Aura component [#skill-url--aura-component] Install like rules: ```bash pnpm dlx shadcn@latest add @aura/skill-port-component-to-aura ``` All Aura skills: ```bash pnpm dlx shadcn@latest add @aura/skills ``` That writes the component-porting and identity-aware image-generation skills under `.cursor/skills/`. `aura init` / `aura setup` also install `@aura/skills` next to `@aura/rules`. Authoring in this monorepo: edit the relevant folder under `.cursor/skills/`, then from `packages/registry`: ```bash pnpm sync:skills pnpm registry:generate pnpm registry:build ``` `sync:skills` copies whitelisted skill folders into `registry/default/skills/`; `registry:generate` publishes `@aura/skill-*` and `@aura/skills`. ### Skill: prompt → identity-aligned landing images [#skill-prompt--identity-aligned-landing-images] `generate-brand-images` reads the blueprint vault's `01-Identity` notes, asks for missing image direction before generation, and batches all required landing assets through Gemini. The generated project command reads `GOOGLE_API_KEY` or `GEMINI_API_KEY` directly from root `.env`: ```bash pnpm ai:image -- --manifest tmp/aura-image-plan.json ``` If the key is absent, the agent asks whether to add one, use agent-native image generation when available, or keep placeholders. The skill never creates or exposes API keys. ### Two scenarios [#two-scenarios] The skill **detects context** before writing: | Context | Goal | Output | | ------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Consumer project** (typical Next app with Aura) | Component for **this app** | Files under `components.json` aliases (`@/components/ui`, etc.). No Ladle, no `metadata/*.yml`, no registry publish. | | **Aura design system monorepo** | Document for the **public registry** | `packages/registry/...` component + Ladle story with `export const Default` + `metadata/{kebab}.yml`, then `registry:generate` / `registry:build` / `docs:generate`. | ### Shared workflow [#shared-workflow] 1. **Read the source, do not paste blindly** (MCP / registry JSON / fetch docs). 2. **Map to Aura** — primitives, Radix `icon`, 13px spacing, fluid type, 12-step colors, reduced motion. ### Consumer project [#consumer-project] * Prefer `shadcn add @aura/` when an Aura item already exists. * Otherwise write Aura-styled source into the app only. * Done when it compiles and follows Aura rules in that project. ### Aura monorepo (registry) [#aura-monorepo-registry] 1. Component under `packages/registry/registry/default/components/ui/` 2. Story at `packages/registry/src/{kebab}.stories.tsx` with **`export const Default`** 3. Metadata at `packages/registry/metadata/{kebab}.yml` (`header.description` + `extends: _templates/content.yml`) — docs automation uses `preview: Default` 4. Rebuild: `pnpm registry:generate && pnpm registry:build` (and `pnpm docs:generate` for MDX/demos) 5. Verify in Ladle: `pnpm --filter @aura-design/registry dev` ## Any design system [#any-design-system] | Source | MCP? | What to do | | ---------------------------------- | --------------------------- | ------------------------------------------------------ | | shadcn/ui | Yes | Search or get item JSON, then rewrite | | Aura (`@aura`, `@aura-dev`) | Yes, via `components.json` | Prefer existing items; extend, do not fork | | Other shadcn-compatible registries | Yes, add under `registries` | Same as shadcn | | Random DS docs | No | Fetch URL, extract API, implement with Aura primitives | | Figma | Figma MCP, not shadcn MCP | Design-to-code, then the same Aura mapping | Another shadcn-style registry: ```json { "registries": { "@aura": "https://auradesignsystem.com/r/{name}.json", "@acme": "https://acme.com/r/{name}.json" } } ``` Private registries use `headers` and environment variables. See [shadcn registry authentication](https://ui.shadcn.com/docs/mcp). Aura already *is* a registry. With shadcn MCP enabled, Cursor can list `@aura/*` the same way it lists shadcn. A second “Aura MCP” is only useful if you want custom tools (token search, 13px class validation, `registry.json` scaffolding). ## Setup order [#setup-order] **Existing project:** prefer [Agent blueprint](/docs/mcp-agent-blueprint) (`Ejecuta` + that URL). Manual checklist: 1. Add `.cursor/mcp.json` and enable the shadcn server. 2. Keep using existing [Rules](/docs/rules) and DESIGN.md (`pnpm dlx shadcn@latest add @aura/rules`). 3. Install skills with `pnpm dlx shadcn@latest add @aura/skills`. 4. Install Aura design lint: see [Lint](/docs/lint) (`@aura/eslint-shadcn-lint` + `@shadcn/lint`). 5. Proof-carrying authz: [gdp-ts](/docs/gdp-ts). `aura init` / `setup` / `blueprint` install `@gdp-ts/core`, the ESLint preset, the skill, and starter proofs (blueprint § F). 6. Optional agent rigor: [pstack](/docs/pstack) via `/add-plugin pstack` then `/setup-pstack` (blueprint also audits it — [Agent blueprint](/docs/mcp-agent-blueprint) § E). Does not replace Aura rules/lint or gdp-ts. 7. Paste a component URL, or describe a complete landing page and let the agent plan its required image set. # Namespaces (/docs/namespace) ## What is a Namespace? [#what-is-a-namespace] In the context of our design system's CLI, a namespace is a unique prefix used to identify a specific source of components. Think of it as a label for a component "store" or library. This system is built to be flexible. It allows you to pull components from multiple different sources, such as your own internal registry, a third-party registry (like Radix UI), or the default design system registry. The namespace is what tells the CLI where to look for the component you're requesting. ## Why Use Namespaces? [#why-use-namespaces] * **Clarity**: When you run a command like add `@aura/button`, it's perfectly clear that you want the "button" component specifically from the "@aura" source. * **Flexibility**: It prevents naming conflicts. You could have a "button" in your internal `@aura` registry and a different "button" in another registry (e.g., `@acme/button`), and the CLI can tell them apart. * **Targeting**: It allows the CLI to look up the correct URL for fetching the component's metadata, which is defined in your `components.json` configuration file. Your components.json (or similar configuration file) includes this registries object: ```json { "registries": { "@aura": "https://auradesignsystem.com/r/{name}.json" } } ``` Let's break this down: * `registries:` This is the main object that holds all your component sources. * `@aura`: This is the namespace. It's the unique prefix you will use in the CLI. * "[https://auradesignsystem.com/r/\{name}.json](https://auradesignsystem.com/r/\{name}.json)": This is the URL endpoint for that namespace. It's a template that tells the CLI how to fetch information about a component. The `{name}` part is a placeholder that will be replaced by the component name you request. ### How it Works in Practice [#how-it-works-in-practice] Here is the step-by-step workflow when you want to add a component: # pstack (/docs/pstack) # pstack [#pstack] How Aura uses Lauren Tan’s **pstack** so developers and agents ship design-system work with trust—not more slop. Aura already gives agents a **visual contract** (rules, `DESIGN.md`, [`@shadcn/lint`](/docs/lint)) and **procedures** (skills like [`port-component-to-aura`](/docs/mcp), the [Agent blueprint](/docs/mcp-agent-blueprint)). **pstack** adds the missing layer: **engineering playbooks + verification**—the same habits Lauren Tan open-sourced as a Cursor plugin so agents can investigate, design, implement, prove, and ship like a careful teammate. This page is context for humans and for agents. Prefer `/poteto-mode` for multi-step or risky changes. Keep Aura skills for porting UI and brand images. Keep lint and rules as the **Gardener** gates. Audit and install steps for a whole project live in the [Agent blueprint](/docs/mcp-agent-blueprint)—this page does not duplicate that runbook. ## What pstack is [#what-pstack-is] **pstack** is a Cursor marketplace plugin (skills + playbooks + principles). The main entry is: ```text /poteto-mode ``` You describe the goal and how you’ll know it’s done. `poteto-mode` picks a playbook (bug fix, feature, refactor, investigation, perf, …), builds a visible task list, calls specialist skills when needed, and **demands evidence** before “done.” Internally Lauren called her mode `/lauren-mode`; in the public plugin it is `/poteto-mode`. pstack is **not** a replacement for: | Aura layer | Job | | -------------------------------------------- | ---------------------------------------------------- | | [MCP](/docs/mcp) + shadcn | Catalog / fetch registry items | | [Rules](/docs/rules) + `DESIGN.md` | Always-on Aura visual constraints | | Aura skills | `port-component-to-aura`, `generate-brand-images`, … | | [`@shadcn/lint`](/docs/lint) | Machine-check spacing / colors / restyle | | [Agent blueprint](/docs/mcp-agent-blueprint) | Audit + install AI context on a project | pstack sits **beside** those: playbooks for hard work, verification on the real artifact, multi-model review when needed. Marketplace: [pstack on Cursor](https://cursor.com/marketplace/cursor/pstack) (plugin id `9717366`). ## Why Aura cares (trust before scale) [#why-aura-cares-trust-before-scale] Lauren’s thesis (shipping thousands of PRs with cloud agents): **trust is the bottleneck**. Spawning more agents without verification and a paved codebase creates regressions and “slop PRs.” Patterns that map cleanly onto a design system: 1. **Codebase as memory** — Agents extend what they see. Clean Aura patterns get copied; hacks go viral. 2. **Gardener** — Someone (or CI + lint) keeps one paved path; delete anti-patterns before agents multiply them. 3. **Hierarchy of correction** (strong → weak): make bad code impossible in architecture → linters → rules/Bugbot → skills → style guide / human review alone. 4. **Verification** — “CI green” is not enough. Drive the real UI (Ladle stories, docs, package exports) and capture proof. 5. **Feature map** — Materialized map of how to reach and assert features so agents can navigate DS surfaces. Aura’s tokens, registry ownership model, and `@shadcn/lint` are already Gardener tools. pstack teaches agents **how to work**; Aura rules teach them **what good UI looks like**. ## Install (easy path) [#install-easy-path] ### 1. Cursor plugin (preferred) [#1-cursor-plugin-preferred] In a Cursor chat on your Aura consumer app **or** the [`somosgented/aura-design-system`](https://github.com/somosgented/aura-design-system) monorepo: ```text /add-plugin pstack ``` Pick **pstack**, then: ```text /setup-pstack ``` That writes optional model-role overrides to `~/.cursor/rules/pstack-models.mdc`. Start a **new chat**, then: ```text /poteto-mode Done means . Keep . ``` Marketplace plugin id: `9717366` (also listed as pstack for Grok Bot). Account install helps Grok Bots; still run `/setup-pstack` in the IDE for local model roles. ### 2. Wire Aura AI context first (if the project is incomplete) [#2-wire-aura-ai-context-first-if-the-project-is-incomplete] For an existing app, prefer the agent runbook: ```text Ejecuta https://auradesignsystem.com/docs/mcp-agent-blueprint ``` That audits/installs Aura rules, skills, `DESIGN.md`, shadcn MCP, lint, and also **audits pstack** (see blueprint § E). Use that page for the full check table. Targeted AI context without full theme rewrite: ```bash pnpm dlx shadcn@latest add \ @aura/rules \ @aura/skills \ @aura/rule-design-md \ @aura/eslint-shadcn-lint ``` (`@aura/skills` includes `port-component-to-aura` and `generate-brand-images`. See [MCP](/docs/mcp) and [Lint](/docs/lint).) ### 3. Cloud agents caveat [#3-cloud-agents-caveat] Cloud Agents run on a remote VM. Marketplace plugins **sometimes do not load** there even when installed locally. If `/poteto-mode` is missing on a cloud agent: * Prompt in plain words: “use pstack / poteto-mode …” * Prefer [cursor.com/agents](https://cursor.com/agents) if desktop autocomplete hides the skill * Optional (team decision): thin repo skill under `.cursor/skills/` or monorepo `AGENTS.md` requiring poteto-mode — **do not** vendor the entire pstack plugin into every consumer app by default ### 4. Monorepo `AGENTS.md` [#4-monorepo-agentsmd] On `somosgented/aura-design-system`, keep a short [`AGENTS.md`](https://github.com/somosgented/aura-design-system/blob/canary/AGENTS.md) + Aura constraint skills (tokens, exports, stories, no drive-by API changes). That is Aura-specific pavement; pstack remains the general engineering router. ## How to work day to day [#how-to-work-day-to-day] ### Humans [#humans] * Small UI tweak → Aura skill / normal Cursor chat + `pnpm lint`. * Multi-step, bug with unclear cause, refactor, or “ship while I sleep” → `/poteto-mode` with an explicit **Done means**. * After UI edits → always `pnpm lint` (Aura `@shadcn/lint` policy). ### Agents (read this) [#agents-read-this] When this page is in context or the user asks for rigorous Aura work: 1. Prefer `/poteto-mode` for non-trivial tasks; put **Done means** in the same message. 2. Obey Aura [rules](/docs/rules) / `DESIGN.md` / [lint](/docs/lint) (13px spacing, accent/gray tokens, no restyle on form controls, mobile inputs ≥ 17px). 3. **Consumer project:** write into app aliases; do not invent Ladle/registry metadata. 4. **Aura monorepo:** registry mode — component + `Default` story + metadata + `registry:generate` / `registry:build` as in [MCP](/docs/mcp) docs. 5. Do not claim verification you did not run. Prefer real Ladle stories, docs pages, or export checks. 6. Never invent API keys. Never force-push. ### Example prompts [#example-prompts] Read-only orientation: ```text /poteto-mode how does @aura button spacing and variants map to the 13px scale in this repo? Done means cite real file paths. Do not edit. ``` Consumer change: ```text /poteto-mode port this dialog to Aura tokens in our app. Done means it compiles, matches DESIGN.md, and pnpm lint is clean for touched files. ``` Monorepo registry: ```text /poteto-mode add a new @aura checkbox following existing registry patterns. Done means Default story + metadata + registry generate/build succeed. ``` ## Useful pstack commands (cheat sheet) [#useful-pstack-commands-cheat-sheet] You do not need all of these daily. `/poteto-mode` routes most work. | Command | Use when | | -------------------- | ----------------------------------- | | `/poteto-mode` | Default router for non-trivial work | | `/setup-pstack` | First-time model/role setup | | `/add-plugin pstack` | Install marketplace plugin | For the full skill set and playbooks, see the [pstack marketplace page](https://cursor.com/marketplace/cursor/pstack) and [pstack guide](https://github.com/cursor/plugins/tree/main/pstack/docs/guide). ## Related [#related] * [Agent blueprint](/docs/mcp-agent-blueprint) — audit + install Aura AI context + pstack check * [gdp-ts](/docs/gdp-ts) — proof-carrying authz; a separate gate from pstack playbooks * [MCP](/docs/mcp) — shadcn MCP, rules, and Aura skills * [Lint](/docs/lint) — `@shadcn/lint` Gardener gates * [Rules](/docs/rules) — install `@aura/rules` * Monorepo [`AGENTS.md`](https://github.com/somosgented/aura-design-system/blob/canary/AGENTS.md) — Aura-specific agent constraints # Registry (/docs/registry) ## What is a Registry? [#what-is-a-registry] A component registry is a system used to store, manage, and distribute individual pieces of our design system, such as components, utilities, or styles. Unlike a traditional component library published as a single package on npm (e.g., `material-ui` or `chakra-ui`), a registry holds the source code for each component separately. When you "install" a component, you are not adding a new dependency to your `node_modules`. Instead, you are **copying the actual source code** directly into your project. ## Why Use a Registry? [#why-use-a-registry] This registry-based approach provides several key advantages over traditional libraries: * **Full Ownership & Control:** Because the component code lives in your codebase (e.g., in a `/components/ui` folder), you have complete control. You can modify, adapt, and restyle any component to fit your specific needs without waiting for updates or fighting with library opinions. * **No "Black Box":** You can see exactly what the code is doing, making it easier to debug, customize, and understand. * **Pick What You Need:** You only add the components you are actively using. This prevents bloating your application with unused code that often comes bundled in a monolithic library. * **Framework Agnostic (by design):** The components are designed to be copied and pasted. They are not tied to a specific version of our library and don't create complex dependency chains. You own the code and its dependencies. * **Easy Updates:** While you own the code, our CLI tool makes it easy to check for updates to a component and "diff" the changes, allowing you to decide whether to incorporate them. ## How to Consume the Registry [#how-to-consume-the-registry] Interaction with the registry is handled through our Command Line Interface (CLI). This tool is responsible for initializing your project and fetching component code. See [CLI](/docs/cli) for the full command reference. # Taste (/docs/taste) The following sections outline our approach to foundational design tokens that give Aura its distinct identity. ## Spacing [#spacing] A unit of space in this system is 13px. Spacing tokens are defined in increments of 1 or 0.5 units, such as 1 (13px), 1.5 (19.5px), 2 (26px), 2.5 (32.5px), 3 (39px), 3.5 (45.5px), and 4 (52px). Example spacing scale: | Token | Value | | ----- | ------ | | 1 | 13px | | 1.5 | 19.5px | | 2 | 26px | | 2.5 | 32.5px | | 3 | 39px | | 3.5 | 45.5px | | 4 | 52px | In CSS, you might define these as: ```css @theme inline { --spacing: 13px; } ``` Use these variables for margin, padding, gaps, etc., to maintain consistent spacing across your design system. ## Colors [#colors] To create a custom color palette, follow these steps: * Open the color mixer * Move to light mode * Input your Accent color (e.g., `#0000b3` for blue). * Input your Gray color (e.g., `#F0EFEB` for gray). * Input your Background color (e.g., `#fafafa` for white). * Download the generated CSS variables. Once you have your color scales, integrate them into your CSS file. Define the colors as CSS variables under the @theme inline layer, prefixed with color-accent. **Naming Logic:** `color-{purpose}-{value}` (e.g., `color-accent-9` for primary brand color). ```css @import "tailwindcss"; @theme inline { --spacing: 13px; --font-sans: var(--font-gabarito, "Gabarito"), sans-serif; --info: #e8ebfe; --info-contrast: #0927ec; --success: #e8fef7; --success-contrast: #045d3c; --danger: #feefe8; --danger-contrast: #8e3106; --warning: #fefbe8; --warning-contrast: #5d5104; --radius-sm: calc(var(--spacing) * 0.5); --radius-md: calc(var(--spacing) * 1); --radius-lg: calc(var(--spacing) * 1.5); --radius-xl: calc(var(--spacing) * 2); --aura-loader: var(--aura-loader); --color-warning-contrast: var(--warning-contrast); --color-warning: var(--warning); --color-danger-contrast: var(--danger-contrast); --color-danger: var(--danger); --color-success-contrast: var(--success-contrast); --color-success: var(--success); --color-info-contrast: var(--info-contrast); --color-info: var(--info); --secundary-foreground: var(--secundary-foreground); --secundary: var(--secundary); --primary-foreground: var(--primary-foreground); --primary: var(--primary); --color-gray-track: var(--gray-track); --color-gray-indicator: var(--gray-indicator); --color-gray-surface: var(--gray-surface); --color-gray-contrast: var(--gray-contrast); --color-gray-a12: var(--gray-a12); --color-gray-a11: var(--gray-a11); --color-gray-a10: var(--gray-a10); --color-gray-a9: var(--gray-a9); --color-gray-a8: var(--gray-a8); --color-gray-a7: var(--gray-a7); --color-gray-a6: var(--gray-a6); --color-gray-a5: var(--gray-a5); --color-gray-a4: var(--gray-a4); --color-gray-a3: var(--gray-a3); --color-gray-a2: var(--gray-a2); --color-gray-a1: var(--gray-a1); --color-gray-12: var(--gray-12); --color-gray-11: var(--gray-11); --color-gray-10: var(--gray-10); --color-gray-9: var(--gray-9); --color-gray-8: var(--gray-8); --color-gray-7: var(--gray-7); --color-gray-6: var(--gray-6); --color-gray-5: var(--gray-5); --color-gray-4: var(--gray-4); --color-gray-3: var(--gray-3); --color-gray-2: var(--gray-2); --color-gray-1: var(--gray-1); --color-accent-track: var(--accent-track); --color-accent-indicator: var(--accent-indicator); --color-accent-surface: var(--accent-surface); --color-accent-contrast: var(--accent-contrast); --color-accent-a12: var(--accent-a12); --color-accent-a11: var(--accent-a11); --color-accent-a10: var(--accent-a10); --color-accent-a9: var(--accent-a9); --color-accent-a8: var(--accent-a8); --color-accent-a7: var(--accent-a7); --color-accent-a6: var(--accent-a6); --color-accent-a5: var(--accent-a5); --color-accent-a4: var(--accent-a4); --color-accent-a3: var(--accent-a3); --color-accent-a2: var(--accent-a2); --color-accent-a1: var(--accent-a1); --color-accent-12: var(--accent-12); --color-accent-11: var(--accent-11); --color-accent-10: var(--accent-10); --color-accent-9: var(--accent-9); --color-accent-8: var(--accent-8); --color-accent-7: var(--accent-7); --color-accent-6: var(--accent-6); --color-accent-5: var(--accent-5); --color-accent-4: var(--accent-4); --color-accent-3: var(--accent-3); --color-accent-2: var(--accent-2); --color-accent-1: var(--accent-1); } :root { /* Accent color scale */ --accent-1: #fdfdff; --accent-2: #f7f8ff; --accent-3: #eef1ff; --accent-4: #e2e7ff; --accent-5: #d5dcff; --accent-6: #c5cdff; --accent-7: #b0b9ff; --accent-8: #939bff; --accent-9: #4015ca; --accent-10: #3500b6; --accent-11: #5446e1; --accent-12: #272370; --accent-a1: #0000ff02; --accent-a2: #0020ff08; --accent-a3: #002dff11; --accent-a4: #002cff1d; --accent-a5: #002bff2a; --accent-a6: #0024ff3a; --accent-a7: #001eff4f; --accent-a8: #0013ff6c; --accent-a9: #2f00c5ea; --accent-a10: #3500b6; --accent-a11: #1300d6b9; --accent-a12: #050059dc; --accent-contrast: #fff; --accent-surface: #f5f6ffcc; --accent-indicator: #4015ca; --accent-track: #4015ca; /* Gray color scale */ --gray-1: #fcfaff; --gray-2: #f9f6ff; --gray-3: #f0ecff; --gray-4: #e8e2ff; --gray-5: #e0daff; --gray-6: #d9d1ff; --gray-7: #cfc4ff; --gray-8: #bcabff; --gray-9: #936cff; --gray-10: #8863f2; --gray-11: #6949bf; --gray-12: #27005f; --gray-a1: #6600ff05; --gray-a2: #5500ff09; --gray-a3: #3600ff13; --gray-a4: #3500ff1d; --gray-a5: #2a00ff25; --gray-a6: #2d00ff2e; --gray-a7: #3001ff3b; --gray-a8: #3400ff54; --gray-a9: #4400ff93; --gray-a10: #3d00ea9c; --gray-a11: #2d00a6b6; --gray-a12: #27005f; --gray-contrast: #ffffff; --gray-surface: #ffffffcc; --gray-indicator: #936cff; --gray-track: #936cff; --primary: var(--accent-9); --primary-foreground: var(--accent-contrast); --secundary: var(--accent-8); --secundary-foreground: var(--accent-contrast); /* Aura Design System - Root Tokens */ --aura: 13px; --aura-font-stack: var(--font-gabarito, "Gabarito"), -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; --aura-font-quotes: serif; /* Aura Background & Text - Mapped to gray scale */ --aura-bg: var(--gray-1); --aura-text-primary: var(--accent-12); --aura-text-primary-inverse: var(--gray-contrast); --aura-accents-primary: var(--accent-9); --aura-accents-secondary: var(--accent-9); /* Aura Component Tokens - Mapped to 12-step system */ --aura-input-radius: 6.5px; --aura-button-radius: 6.5px; --aura-input-bg: var(--accent-2); --aura-input-placeholder-color: var(--gray-11); --aura-radius: 0.5rem; --aura-opacity: 0.5; --aura-outline: var(--accent-9) solid 2px; --aura-button-hover: var(--accent-11); --aura-link: var(--gray-12); --aura-link-hover: var(--accent-2); --aura-selector: var(--accent-surface); --aura-loader: var(--primary); --aura-skeleton-start: var(--gray-8); --aura-skeleton-end: var(--gray-5); --radius: 0.5rem; } @media (prefers-color-scheme: dark) { :root { /* Accent color scale */ --accent-1: #0b0c22; --accent-2: #121331; --accent-3: #1e1167; --accent-4: #2b0096; --accent-5: #3300aa; --accent-6: #3b18bb; --accent-7: #4626d4; --accent-8: #542bfd; --accent-9: #4015ca; --accent-10: #3400ab; --accent-11: #a3acff; --accent-12: #d9dfff; --accent-a1: #0000f614; --accent-a2: #252cfa24; --accent-a3: #3814ff5d; --accent-a4: #4100ff8f; --accent-a5: #4700fea5; --accent-a6: #4d1cfeb7; --accent-a7: #512bfed2; --accent-a8: #552bfffd; --accent-a9: #4e17fec7; --accent-a10: #4800fea6; --accent-a11: #a3acff; --accent-a12: #d9dfff; --accent-contrast: #fff; --accent-surface: #15175280; --accent-indicator: #4015ca; --accent-track: #4015ca; /* Gray color scale */ --gray-1: #100b21; --gray-2: #19152b; --gray-3: #231d3c; --gray-4: #2b214d; --gray-5: #33265c; --gray-6: #3c2d6c; --gray-7: #4a3786; --gray-8: #6548b6; --gray-9: #7254cb; --gray-10: #7f63d9; --gray-11: #b5a2ff; --gray-12: #eeecff; --gray-a1: #1d00f513; --gray-a2: #6744fe1d; --gray-a3: #7c5bff2f; --gray-a4: #7c55fc42; --gray-a5: #7f57fd52; --gray-a6: #835dfd63; --gray-a7: #8660ff7e; --gray-a8: #8a60feb2; --gray-a9: #8e67ffc8; --gray-a10: #9473fed7; --gray-a11: #b5a2ff; --gray-a12: #eeecff; --gray-contrast: #ffffff; --gray-surface: rgba(0, 0, 0, 0.05); --gray-indicator: #7254cb; --gray-track: #7254cb; } } @layer base { @import "../styles/main.css"; html { font-size: 17px; } main { background-color: var(--gray-1); } } @layer components { } ``` ## Typography [#typography] The typography system is designed to ensure consistent and responsive text scaling across different screen sizes. It uses the CSS `clamp()` function combined with custom properties (CSS variables) to dynamically adjust font sizes based on the viewport width. This approach ensures readability and visual harmony on all devices. ## How It Works [#how-it-works] The system uses the `clamp()` function, which takes three arguments: * **min**: The minimum font size (in `rem`). * **val**: The preferred font size (in `vw` for responsiveness). * **max**: The maximum font size (in `rem`). The formula ensures that the font size scales fluidly between the `min` and `max` values based on the viewport width. ```css font-size: clamp(var(--min), var(--val), var(--max)); ``` Here’s the complete CSS code for the typography system: ```css body h1, body h2, body h3, body h4, body h5, body h6, body p, body .h1, body .h2, body .h3, body .h4, body .h5, body .h6 { font-size: clamp(var(--min), var(--val), var(--max)); } h1, .h1 { --min: 1.9838rem; --val: 4.029999999999999vw; --max: 3.052rem; } h2, .h2 { --min: 1.58665rem; --val: 4.029999999999999vw; --max: 2.441rem; } h3, .h3 { --min: 1.482rem; --val: 4.029999999999999vw; --max: 2.28rem; } h4, .h4, blockquote { --min: 1.26945rem; --val: 4.029999999999999vw; --max: 1.953rem; } h5, .h5 { --min: 1.17225rem; --val: 4.029999999999999vw; --max: 1.563rem; } h6, .h6 { --min: 1.0625rem; --val: 4.029999999999999vw; --max: 1.25rem; } p, .p { --min: 1rem; --val: 4.029999999999999vw; --max: 1rem; } ``` # Area Chart (/docs/charts/area-chart) ## Preview [#preview] ```tsx import { AreaChart } from "@/components/ui/AreaChart"; export function AreaChartDemo() { return (
{variants.map((variant) => ( ))}
) ```
## Installation [#installation] Make sure that `namespace` is set in your component.json file. Namespace docs: [Learn more about namespaces](/docs/namespace) ```bash pnpm dlx shadcn@latest add @aura/area-chart ``` ### Manual [#manual] Install the following dependencies: ```bash pnpm install @radix-ui/react-icons recharts@2.15.4 ``` Copy and paste the card component into your `components/ui/Card.tsx` file. ```tsx /** * @description Displays a card with header, content, and footer sections. * CardAction is the right-hand slot of CardHeader (top right). */ import * as React from "react"; import { cn } from "@/utils/class-names"; type CardProps = React.ComponentProps<"div"> & { /** * Clickable card. Hover uses a gray-8 border and a soft shadow. * The card is in the tab order and shows a focus-visible ring. */ interactive?: boolean; }; function Card({ className, interactive = false, tabIndex, onKeyDown, onClick, ...props }: CardProps) { const clickable = interactive || typeof onClick === "function"; return (
{ onKeyDown?.(event); if (event.defaultPrevented) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.currentTarget.click(); } } : onKeyDown } className={cn( "overflow-hidden border border-gray-6 bg-gray-2 rounded-(--radius-md)", clickable && "cursor-pointer transition-colors motion-reduce:transition-none hover:border-gray-8 hover:shadow-[0_2px_8px_var(--gray-a3)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-8", className )} {...props} /> ); } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardTitle({ className, ...props }: React.ComponentProps<"h3">) { return (

); } function CardDescription({ className, ...props }: React.ComponentProps<"p">) { return (

); } /** * Right-hand slot of CardHeader. Place it as a direct child so it sits at the top right, beside the title and description. */ function CardAction({ className, ...props }: React.ComponentProps<"div">) { return (

); } function CardContent({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return (
); } export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, }; ``` Copy and paste the chart component into your `components/ui/Chart.tsx` file. ```tsx "use client"; /** * @description Chart container, tooltip, legend, and series wrappers for Recharts, colored with Aura series tokens. */ import * as React from "react"; import { Area, Bar, Legend, Line, Pie, Radar, RadialBar, ResponsiveContainer, Tooltip, } from "recharts"; import { cn } from "@/utils/class-names"; const THEMES = { light: "", dark: ".dark" } as const; type SeriesColor = { color?: string; theme?: never; } | { color?: never; theme: Partial>; }; export type ChartConfig = { [key: string]: { label?: React.ReactNode; icon?: React.ComponentType<{ className?: string }>; } & SeriesColor; }; type ChartTooltipItem = { type?: string; dataKey?: string | number; name?: string; value?: number | string; color?: string; payload?: Record & { fill?: string }; fill?: string; }; type ChartContextValue = { config: ChartConfig; reducedMotion: boolean; }; const ChartContext = React.createContext(null); function usePrefersReducedMotion() { return React.useSyncExternalStore( (onStoreChange) => { const media = window.matchMedia("(prefers-reduced-motion: reduce)"); media.addEventListener("change", onStoreChange); return () => media.removeEventListener("change", onStoreChange); }, () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, () => false ); } function useChart() { const context = React.useContext(ChartContext); if (!context) { throw new Error("useChart must be used within a "); } return context; } const VAR_COLOR = /^var\(--[a-zA-Z0-9_-]+(?:\s*,\s*(?:#[0-9a-fA-F]{3,8}|[a-zA-Z]+))?\)$/; const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; const NAMED_COLOR = /^[a-zA-Z]+$/; const FUNC_COLOR = /^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix)\((?:[^()]|\([^()]*\))+\)$/; function isSafeChartColor(value: string) { if (/[;{}\\]|url\s*\(|\/\*|\*\//i.test(value)) return false; return ( VAR_COLOR.test(value) || HEX_COLOR.test(value) || NAMED_COLOR.test(value) || FUNC_COLOR.test(value) ); } function tokenColor(color: string | undefined) { if (!color) return undefined; const value = color.trim(); if (!value) return undefined; if (isSafeChartColor(value)) return value; if (process.env.NODE_ENV !== "production") { console.warn( `[chart] Ignored color "${value}". Use a var(--token), hex, rgb(), hsl(), or a CSS named color.` ); } return undefined; } const CHART_INSET_FALLBACK = 13; let chartInsetCache: number | undefined; function measureCssPx(expression: string, fallback: number) { if (typeof document === "undefined") return fallback; const probe = document.createElement("div"); probe.style.position = "absolute"; probe.style.visibility = "hidden"; probe.style.pointerEvents = "none"; probe.style.inlineSize = expression; document.documentElement.appendChild(probe); const width = probe.getBoundingClientRect().width; probe.remove(); return Number.isFinite(width) && width > 0 ? width : fallback; } /** Pixel size of `--chart-inset`, which follows `--spacing`. */ function chartInsetPx() { if (chartInsetCache != null) return chartInsetCache; const value = measureCssPx( "var(--chart-inset, var(--spacing, 13px))", CHART_INSET_FALLBACK ); if (typeof document !== "undefined") chartInsetCache = value; return value; } function chartTickMargin() { return chartInsetPx(); } function chartPlotMargin(overrides?: { top?: number; right?: number; left?: number; bottom?: number; }) { const inset = chartInsetPx(); return { top: inset, right: inset, left: inset, bottom: 0, ...overrides, }; } const chartAxisTick = { fill: "var(--chart-tick, var(--gray-11))", fontSize: "var(--chart-tick-size, var(--spacing))", }; function ChartContainer({ id, className, children, config, label, ...props }: React.ComponentProps<"div"> & { config: ChartConfig; children: React.ComponentProps["children"]; label?: string; }) { const uniqueId = React.useId(); const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; const reducedMotion = usePrefersReducedMotion(); return (
{children}
); } function ChartStyle({ id, config }: { id: string; config: ChartConfig }) { const colorConfig = Object.entries(config).filter( ([, item]) => item.theme || item.color ); if (!colorConfig.length) return null; const css = Object.entries(THEMES) .map(([theme, prefix]) => { const lines = colorConfig .map(([key, item]) => { const color = tokenColor( item.theme?.[theme as keyof typeof item.theme] || item.color ) ?? undefined; return color ? ` --color-${key}: ${color};` : null; }) .filter(Boolean) .join("\n"); if (!lines) return ""; return `${prefix} [data-chart=${id}] {\n${lines}\n}`; }) .filter(Boolean) .join("\n"); if (!css) return null; return
); } function useReduced() { const [reduced, setReduced] = React.useState(false); React.useEffect(() => { const query = window.matchMedia("(prefers-reduced-motion: reduce)"); const update = () => setReduced(query.matches); update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); return reduced; } export { Marquee }; ``` # Mask Input (/docs/components/mask-input) ## Preview [#preview] ```tsx import * as React from "react"; import { MaskInput, type MaskPattern } from "@/components/ui/MaskInput"; export function MaskInputDemo() { const [phone, setPhone] = React.useState(""); return (
setPhone(masked)} />

Masked value: {phone || "—"}

); } ```
## Installation [#installation] Make sure that `namespace` is set in your component.json file. Namespace docs: [Learn more about namespaces](/docs/namespace) ```bash pnpm dlx shadcn@latest add @aura/mask-input ``` ### Manual [#manual] Install the following dependencies: ```bash pnpm install radix-ui ``` Copy and paste the class names utility into your `utils/class-names.ts` file. ```ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ``` Copy and paste the compose refs utility into your `utils/compose-refs.tsx` file. ```tsx /** * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx */ import * as React from "react"; type PossibleRef = React.Ref | undefined; /** * Set a given ref to a given value * This utility takes care of different types of refs: callback refs and RefObject(s) */ function setRef(ref: PossibleRef, value: T) { if (typeof ref === "function") { return ref(value); } if (ref !== null && ref !== undefined) { ref.current = value; } } /** * A utility to compose multiple refs together * Accepts callback refs and RefObject(s) */ function composeRefs(...refs: PossibleRef[]): React.RefCallback { return (node) => { let hasCleanup = false; const cleanups = refs.map((ref) => { const cleanup = setRef(ref, node); if (!hasCleanup && typeof cleanup === "function") { hasCleanup = true; } return cleanup; }); // React <19 will log an error to the console if a callback ref returns a // value. We don't use ref cleanups internally so this will only happen if a // user's ref callback returns a value, which we only expect if they are // using the cleanup functionality added in React 19. if (hasCleanup) { return () => { for (let i = 0; i < cleanups.length; i++) { const cleanup = cleanups[i]; if (typeof cleanup === "function") { cleanup(); } else { setRef(refs[i], null); } } }; } }; } /** * A custom hook that composes multiple refs * Accepts callback refs and RefObject(s) */ function useComposedRefs(...refs: PossibleRef[]): React.RefCallback { // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values return React.useCallback(composeRefs(...refs), refs); } export { composeRefs, useComposedRefs }; ``` Copy and paste the MaskInput component into your `components/ui/MaskInput.tsx` file. ```tsx "use client"; /** * @description Input that formats values with built-in or custom mask patterns. */ import * as React from "react"; import { Slot as SlotPrimitive } from "radix-ui"; import { useComposedRefs } from "@/utils/compose-refs"; import { cn } from "@/utils/class-names"; const PAST_YEARS_LIMIT = 120; const FUTURE_YEARS_LIMIT = 10; const DEFAULT_CURRENCY = "USD"; const DEFAULT_LOCALE = "en-US"; const NUMERIC_MASK_PATTERNS = /^(phone|zipCode|zipCodeExtended|ssn|ein|time|date|creditCard|creditCardExpiry)$/; const CURRENCY_PERCENTAGE_SYMBOLS = /[€$%]/; interface CurrencySymbols { currency: string; decimal: string; group: string; } const formattersCache = new Map(); const currencyAtEndCache = new Map(); const currencySymbolsCache = new Map(); const daysInMonthCache = [ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, ] as const; const REGEX_CACHE = { digitsOnly: /^\d+$/, nonDigits: /\D/g, nonAlphaNumeric: /[^A-Z0-9]/gi, nonNumericDot: /[^0-9.]/g, nonCurrencyChars: /[^\d.,]/g, hashPattern: /#/g, currencyAtEnd: /\d\s*[^\d\s]+$/, percentageChars: /[^\d.]/g, phone: /^\d{10}$/, ssn: /^\d{9}$/, zipCode: /^\d{5}$/, zipCodeExtended: /^\d{9}$/, isbn: /^\d{13}$/, ein: /^\d{9}$/, time: /^\d{4}$/, creditCard: /^\d{13,19}$/, creditCardExpiry: /^\d{4}$/, licensePlate: /^[A-Z0-9]{6}$/, macAddress: /^[A-F0-9]{12}$/, currencyValidation: /^\d+(\.\d{1,2})?$/, ipv4Segment: /^\d{1,3}$/, } as const; function getCachedFormatter( locale: string | undefined, opts: Intl.NumberFormatOptions, ): Intl.NumberFormat { const { currency, minimumFractionDigits = 0, maximumFractionDigits = 2, } = opts; const key = `${locale}|${currency}|${minimumFractionDigits}|${maximumFractionDigits}`; if (!formattersCache.has(key)) { try { formattersCache.set( key, new Intl.NumberFormat(locale, { style: "currency", currency, ...opts, }), ); } catch { formattersCache.set( key, new Intl.NumberFormat(DEFAULT_LOCALE, { style: "currency", currency: DEFAULT_CURRENCY, ...opts, }), ); } } const formatter = formattersCache.get(key); if (!formatter) { throw new Error(`Failed to create formatter for ${key}`); } return formatter; } function getCachedCurrencySymbols(opts: TransformOptions): CurrencySymbols { const { locale, currency } = opts; const key = `${locale}|${currency}`; const cached = currencySymbolsCache.get(key); if (cached) { return cached; } let currencySymbol = "$"; let decimalSeparator = "."; let groupSeparator = ","; try { const formatter = getCachedFormatter(locale, { currency, minimumFractionDigits: 0, maximumFractionDigits: 2, }); const parts = formatter.formatToParts(1234.5); const currencyPart = parts.find((part) => part.type === "currency"); const decimalPart = parts.find((part) => part.type === "decimal"); const groupPart = parts.find((part) => part.type === "group"); if (currencyPart) currencySymbol = currencyPart.value; if (decimalPart) decimalSeparator = decimalPart.value; if (groupPart) groupSeparator = groupPart.value; } catch { // Keep defaults } const symbols: CurrencySymbols = { currency: currencySymbol, decimal: decimalSeparator, group: groupSeparator, }; currencySymbolsCache.set(key, symbols); return symbols; } function isCurrencyAtEnd(opts: TransformOptions): boolean { const { locale, currency } = opts; const key = `${locale}|${currency}`; const cached = currencyAtEndCache.get(key); if (cached !== undefined) { return cached; } try { const formatter = getCachedFormatter(locale, { currency, minimumFractionDigits: 0, maximumFractionDigits: 0, }); const sample = formatter.format(123); const result = REGEX_CACHE.currencyAtEnd.test(sample); currencyAtEndCache.set(key, result); return result; } catch { currencyAtEndCache.set(key, false); return false; } } function isCurrencyMask(opts: { mask: MaskPatternKey | MaskPattern | undefined; pattern?: string; }): boolean { const { mask, pattern } = opts; return ( mask === "currency" || Boolean(pattern && (pattern.includes("$") || pattern.includes("€"))) ); } interface TransformOptions { currency?: string; locale?: string; } interface ValidateOptions { min?: number; max?: number; } interface MaskPattern { pattern: string; transform?: (value: string, opts?: TransformOptions) => string; validate?: (value: string, opts?: ValidateOptions) => boolean; } type MaskPatternKey = | "phone" | "ssn" | "date" | "time" | "creditCard" | "creditCardExpiry" | "zipCode" | "zipCodeExtended" | "currency" | "percentage" | "licensePlate" | "ipv4" | "macAddress" | "isbn" | "ein"; const MASK_PATTERNS: Record = { phone: { pattern: "(###) ###-####", transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""), validate: (value) => REGEX_CACHE.phone.test(value.replace(REGEX_CACHE.nonDigits, "")), }, ssn: { pattern: "###-##-####", transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""), validate: (value) => REGEX_CACHE.ssn.test(value.replace(REGEX_CACHE.nonDigits, "")), }, date: { pattern: "##/##/####", transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""), validate: (value) => { const cleaned = value.replace(REGEX_CACHE.nonDigits, ""); if (cleaned.length !== 8) return false; const month = parseInt(cleaned.substring(0, 2), 10); const day = parseInt(cleaned.substring(2, 4), 10); const year = parseInt(cleaned.substring(4, 8), 10); const currentYear = new Date().getFullYear(); const minYear = currentYear - PAST_YEARS_LIMIT; const maxYear = currentYear + FUTURE_YEARS_LIMIT; if ( month < 1 || month > 12 || day < 1 || year < minYear || year > maxYear ) return false; const maxDays = month === 2 && ((year % 4 === 0 && year % 100 !== 0) || year % 400 === 0) ? 29 : (daysInMonthCache[month - 1] ?? 31); return day <= maxDays; }, }, time: { pattern: "##:##", transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""), validate: (value) => { const cleaned = value.replace(REGEX_CACHE.nonDigits, ""); if (!REGEX_CACHE.time.test(cleaned)) return false; const hours = parseInt(cleaned.substring(0, 2), 10); const minutes = parseInt(cleaned.substring(2, 4), 10); return hours <= 23 && minutes <= 59; }, }, creditCard: { pattern: "#### #### #### ####", transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""), validate: (value) => { const cleaned = value.replace(REGEX_CACHE.nonDigits, ""); if (!REGEX_CACHE.creditCard.test(cleaned)) return false; let sum = 0; let isEven = false; for (let i = cleaned.length - 1; i >= 0; i--) { const digitChar = cleaned[i]; if (!digitChar) continue; let digit = parseInt(digitChar, 10); if (isEven) { digit *= 2; if (digit > 9) { digit -= 9; } } sum += digit; isEven = !isEven; } return sum % 10 === 0; }, }, creditCardExpiry: { pattern: "##/##", transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""), validate: (value) => { const cleaned = value.replace(REGEX_CACHE.nonDigits, ""); if (!REGEX_CACHE.creditCardExpiry.test(cleaned)) return false; const month = parseInt(cleaned.substring(0, 2), 10); const year = parseInt(cleaned.substring(2, 4), 10); if (month < 1 || month > 12) return false; const now = new Date(); const currentYear = now.getFullYear(); const currentMonth = now.getMonth() + 1; const fullYear = year <= 75 ? 2000 + year : 1900 + year; if ( fullYear < currentYear || (fullYear === currentYear && month < currentMonth) ) { return false; } const maxYear = currentYear + 50; if (fullYear > maxYear) { return false; } return true; }, }, zipCode: { pattern: "#####", transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""), validate: (value) => REGEX_CACHE.zipCode.test(value.replace(REGEX_CACHE.nonDigits, "")), }, zipCodeExtended: { pattern: "#####-####", transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""), validate: (value) => REGEX_CACHE.zipCodeExtended.test( value.replace(REGEX_CACHE.nonDigits, ""), ), }, currency: { pattern: "$###,###.##", transform: ( value, { currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE } = {}, ) => { let localeDecimalSeparator = "."; try { const formatter = getCachedFormatter(locale, { currency, minimumFractionDigits: 0, maximumFractionDigits: 2, }); const parts = formatter.formatToParts(1234.5); const decimalPart = parts.find((part) => part.type === "decimal"); if (decimalPart) localeDecimalSeparator = decimalPart.value; } catch { // Keep defaults } const cleaned = value.replace(REGEX_CACHE.nonCurrencyChars, ""); const dotIndex = cleaned.indexOf("."); const commaIndex = cleaned.indexOf(","); let hasDecimalSeparator = false; let decimalIndex = -1; if (localeDecimalSeparator === ",") { const lastCommaIndex = cleaned.lastIndexOf(","); if (lastCommaIndex !== -1) { const afterComma = cleaned.substring(lastCommaIndex + 1); if (afterComma.length <= 2 && /^\d*$/.test(afterComma)) { hasDecimalSeparator = true; decimalIndex = lastCommaIndex; } } if (!hasDecimalSeparator && dotIndex !== -1) { const afterDot = cleaned.substring(dotIndex + 1); if (afterDot.length <= 2 && /^\d*$/.test(afterDot)) { hasDecimalSeparator = true; decimalIndex = dotIndex; } } if (!hasDecimalSeparator && cleaned.length >= 4) { const match = cleaned.match(/^(\d+)\.(\d{3})(\d{1,2})$/); if (match) { const [, beforeDot, thousandsPart, decimalPart] = match; const integerPart = (beforeDot ?? "") + (thousandsPart ?? ""); const result = `${integerPart}.${decimalPart}`; return result; } } } else { const lastDotIndex = cleaned.lastIndexOf("."); if (lastDotIndex !== -1) { const afterDot = cleaned.substring(lastDotIndex + 1); if (afterDot.length <= 2 && /^\d*$/.test(afterDot)) { hasDecimalSeparator = true; decimalIndex = lastDotIndex; } } if (!hasDecimalSeparator && commaIndex !== -1) { const afterComma = cleaned.substring(commaIndex + 1); const looksLikeThousands = commaIndex <= 3 && afterComma.length >= 3; if ( !looksLikeThousands && afterComma.length <= 2 && /^\d*$/.test(afterComma) ) { hasDecimalSeparator = true; decimalIndex = commaIndex; } } } if (hasDecimalSeparator && decimalIndex !== -1) { const beforeDecimal = cleaned .substring(0, decimalIndex) .replace(/[.,]/g, ""); const afterDecimal = cleaned .substring(decimalIndex + 1) .replace(/[.,]/g, ""); if (afterDecimal === "") { const result = `${beforeDecimal}.`; return result; } const result = `${beforeDecimal}.${afterDecimal.substring(0, 2)}`; return result; } const digitsOnly = cleaned.replace(/[.,]/g, ""); return digitsOnly; }, validate: (value) => { if (!REGEX_CACHE.currencyValidation.test(value)) return false; const num = parseFloat(value); return !Number.isNaN(num) && num >= 0; }, }, percentage: { pattern: "##.##%", transform: (value) => { const cleaned = value.replace(REGEX_CACHE.percentageChars, ""); const parts = cleaned.split("."); if (parts.length > 2) { return `${parts[0]}.${parts.slice(1).join("")}`; } if (parts[1] && parts[1].length > 2) { return `${parts[0]}.${parts[1].substring(0, 2)}`; } return cleaned; }, validate: (value, opts = {}) => { const num = parseFloat(value); const min = opts.min ?? 0; const max = opts.max ?? 100; return !Number.isNaN(num) && num >= min && num <= max; }, }, licensePlate: { pattern: "###-###", transform: (value) => value.replace(REGEX_CACHE.nonAlphaNumeric, "").toUpperCase(), validate: (value) => REGEX_CACHE.licensePlate.test(value), }, ipv4: { pattern: "###.###.###.###", transform: (value) => value.replace(REGEX_CACHE.nonNumericDot, ""), validate: (value) => { if (value.includes(".")) { const segments = value.split("."); if (segments.length > 4) return false; return segments.every((segment) => { if (segment === "") return true; if (!REGEX_CACHE.ipv4Segment.test(segment)) return false; const num = parseInt(segment, 10); return num <= 255; }); } else { if (!REGEX_CACHE.digitsOnly.test(value)) return false; if (value.length > 12) return false; const chunks = []; for (let i = 0; i < value.length; i += 3) { chunks.push(value.substring(i, i + 3)); } if (chunks.length > 4) return false; return chunks.every((chunk) => { const num = parseInt(chunk, 10); return num >= 0 && num <= 255; }); } }, }, macAddress: { pattern: "##:##:##:##:##:##", transform: (value) => value.replace(REGEX_CACHE.nonAlphaNumeric, "").toUpperCase(), validate: (value) => REGEX_CACHE.macAddress.test(value), }, isbn: { pattern: "###-#-###-#####-#", transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""), validate: (value) => REGEX_CACHE.isbn.test(value.replace(REGEX_CACHE.nonDigits, "")), }, ein: { pattern: "##-#######", transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""), validate: (value) => REGEX_CACHE.ein.test(value.replace(REGEX_CACHE.nonDigits, "")), }, }; function applyMask(opts: { value: string; pattern: string; currency?: string; locale?: string; mask?: MaskPatternKey | MaskPattern; }): string { const { value, pattern, currency, locale, mask } = opts; const cleanValue = value; if (pattern.includes("$") || pattern.includes("€") || mask === "currency") { return applyCurrencyMask({ value: cleanValue, currency: currency ?? DEFAULT_CURRENCY, locale: locale ?? DEFAULT_LOCALE, }); } if (pattern.includes("%")) { return applyPercentageMask(cleanValue); } if (mask === "ipv4") { return cleanValue; } const maskedChars: string[] = []; let valueIndex = 0; for (let i = 0; i < pattern.length && valueIndex < cleanValue.length; i++) { const patternChar = pattern[i]; const valueChar = cleanValue[valueIndex]; if (patternChar === "#" && valueChar) { maskedChars.push(valueChar); valueIndex++; } else if (patternChar) { maskedChars.push(patternChar); } } return maskedChars.join(""); } function applyCurrencyMask(opts: { value: string; currency?: string; locale?: string; }): string { const { value, currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE } = opts; if (!value) return ""; const { currency: currencySymbol, decimal: decimalSeparator, group: groupSeparator, } = getCachedCurrencySymbols({ locale, currency }); const normalizedValue = value .replace( new RegExp( `\\${groupSeparator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "g", ), "", ) .replace(decimalSeparator, "."); const parts = normalizedValue.split("."); const integerPart = parts[0] ?? ""; const fractionalPart = parts[1] ?? ""; if (!integerPart && !fractionalPart) return ""; const intValue = integerPart ?? "0"; const fracValue = fractionalPart.slice(0, 2); const num = Number(`${intValue}.${fracValue ?? ""}`); if (Number.isNaN(num)) { const cleanedDigits = value.replace(/[^\d]/g, ""); if (!cleanedDigits) return ""; return `${currencySymbol}${cleanedDigits}`; } const hasExplicitDecimal = value.includes(".") || value.includes(decimalSeparator); try { const formatter = getCachedFormatter(locale, { currency, minimumFractionDigits: fracValue ? fracValue.length : 0, maximumFractionDigits: 2, }); const result = formatter.format(num); if (hasExplicitDecimal && !fracValue) { if (result.match(/^[^\d\s]+/)) { const finalResult = result.replace(/(\d)$/, `$1${decimalSeparator}`); return finalResult; } else { const finalResult = result.replace( /(\d)(\s*)([^\d\s]+)$/, `$1${decimalSeparator}$2$3`, ); return finalResult; } } return result; } catch { const formattedInt = intValue.replace( /\B(?=(\d{3})+(?!\d))/g, groupSeparator, ); let result = `${currencySymbol}${formattedInt}`; if (hasExplicitDecimal) { result += `${decimalSeparator}${fracValue}`; } return result; } } function applyPercentageMask(value: string): string { if (!value) return ""; const parts = value.split("."); let result = parts[0] ?? "0"; if (value.includes(".")) { result += `.${(parts[1] ?? "").substring(0, 2)}`; } return `${result}%`; } function getUnmaskedValue(opts: { value: string; currency?: string; locale?: string; transform?: (value: string, opts?: TransformOptions) => string; }): string { const { value, transform, currency, locale } = opts; return transform ? transform(value, { currency, locale }) : value.replace(REGEX_CACHE.nonDigits, ""); } function toUnmaskedIndex(opts: { masked: string; pattern: string; caret: number; }): number { const { masked, pattern, caret } = opts; let idx = 0; for (let i = 0; i < caret && i < masked.length && i < pattern.length; i++) { if (pattern[i] === "#") { idx++; } } return idx; } function fromUnmaskedIndex(opts: { masked: string; pattern: string; unmaskedIndex: number; }): number { const { masked, pattern, unmaskedIndex } = opts; let seen = 0; for (let i = 0; i < masked.length && i < pattern.length; i++) { if (pattern[i] === "#") { seen++; if (seen === unmaskedIndex) { return i + 1; } } } return masked.length; } function getCurrencyCaretPosition(opts: { newValue: string; mask: MaskPatternKey | MaskPattern | undefined; transformOpts: TransformOptions; oldCursorPosition?: number; oldValue?: string; previousUnmasked?: string; }): number { const { newValue, mask, transformOpts, oldCursorPosition, oldValue, previousUnmasked, } = opts; if ( oldCursorPosition !== undefined && oldValue && previousUnmasked !== undefined ) { if (oldCursorPosition < oldValue.length) { const digitsBeforeCursor = oldValue .substring(0, oldCursorPosition) .replace(/\D/g, "").length; let digitCount = 0; for (let i = 0; i < newValue.length; i++) { if (/\d/.test(newValue[i] ?? "")) { digitCount++; if (digitCount === digitsBeforeCursor) { return i + 1; } } } } } if (mask === "currency") { const currencyAtEnd = isCurrencyAtEnd(transformOpts); if (currencyAtEnd) { const match = newValue.match(/(\d)\s*([^\d\s]+)$/); if (match?.[1]) { return newValue.lastIndexOf(match[1]) + 1; } else { return newValue.length; } } else { return newValue.length; } } else { return newValue.length; } } function getPatternCaretPosition(opts: { newValue: string; maskPattern: MaskPattern; currentUnmasked: string; oldCursorPosition?: number; oldValue?: string; previousUnmasked?: string; }): number { const { newValue, maskPattern, currentUnmasked, oldCursorPosition, oldValue, previousUnmasked, } = opts; let position = 0; let unmaskedCount = 0; if ( oldCursorPosition !== undefined && oldValue && previousUnmasked !== undefined ) { const oldUnmaskedIndex = toUnmaskedIndex({ masked: oldValue, pattern: maskPattern.pattern, caret: oldCursorPosition, }); if (oldCursorPosition < oldValue.length) { const targetUnmaskedIndex = Math.min( oldUnmaskedIndex, currentUnmasked.length, ); for ( let i = 0; i < maskPattern.pattern.length && i < newValue.length; i++ ) { if (maskPattern.pattern[i] === "#") { unmaskedCount++; if (unmaskedCount <= targetUnmaskedIndex) { position = i + 1; } } } return position; } } for (let i = 0; i < maskPattern.pattern.length && i < newValue.length; i++) { if (maskPattern.pattern[i] === "#") { unmaskedCount++; if (unmaskedCount <= currentUnmasked.length) { position = i + 1; } } } return position; } type InputElement = React.ComponentRef<"input">; interface MaskInputProps extends React.ComponentProps<"input"> { value?: string; defaultValue?: string; onValueChange?: (maskedValue: string, unmaskedValue: string) => void; onValidate?: (isValid: boolean, unmaskedValue: string) => void; validationMode?: "onChange" | "onBlur" | "onSubmit" | "onTouched" | "all"; mask?: MaskPatternKey | MaskPattern; maskPlaceholder?: string; currency?: string; locale?: string; asChild?: boolean; invalid?: boolean; withoutMask?: boolean; } function MaskInput(props: MaskInputProps) { const { value: valueProp, defaultValue, onValueChange: onValueChangeProp, onValidate, onBlur: onBlurProp, onFocus: onFocusProp, onKeyDown: onKeyDownProp, onPaste: onPasteProp, onCompositionStart: onCompositionStartProp, onCompositionEnd: onCompositionEndProp, validationMode = "onChange", mask, maskPlaceholder, currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE, placeholder, inputMode, min, max, maxLength, asChild = false, disabled = false, invalid = false, readOnly = false, required = false, withoutMask = false, className, ref, ...inputProps } = props; const [internalValue, setInternalValue] = React.useState(defaultValue ?? ""); const [focused, setFocused] = React.useState(false); const [composing, setComposing] = React.useState(false); const [touched, setTouched] = React.useState(false); const inputRef = React.useRef(null); const composedRef = useComposedRefs(ref, inputRef); const isControlled = valueProp !== undefined; const value = isControlled ? valueProp : internalValue; const maskPattern = React.useMemo(() => { if (typeof mask === "string") { return MASK_PATTERNS[mask]; } return mask; }, [mask]); const transformOpts = React.useMemo( () => ({ currency, locale, }), [currency, locale], ); const placeholderValue = React.useMemo(() => { if (withoutMask) return placeholder; if (placeholder && maskPlaceholder) { return focused ? maskPlaceholder : placeholder; } if (maskPlaceholder) { return focused ? maskPlaceholder : undefined; } return placeholder; }, [placeholder, maskPlaceholder, focused, withoutMask]); const displayValue = React.useMemo(() => { if (withoutMask || !maskPattern || !value) return value ?? ""; const unmasked = getUnmaskedValue({ value, transform: maskPattern.transform, ...transformOpts, }); return applyMask({ value: unmasked, pattern: maskPattern.pattern, ...transformOpts, mask, }); }, [value, maskPattern, withoutMask, transformOpts, mask]); const tokenCount = React.useMemo(() => { if (!maskPattern || CURRENCY_PERCENTAGE_SYMBOLS.test(maskPattern.pattern)) return undefined; return maskPattern.pattern.match(REGEX_CACHE.hashPattern)?.length ?? 0; }, [maskPattern]); const calculatedMaxLength = tokenCount ? maskPattern?.pattern.length : maxLength; const calculatedInputMode = React.useMemo(() => { if (inputMode) return inputMode; if (!maskPattern) return undefined; if (mask === "currency" || mask === "percentage" || mask === "ipv4") { return "decimal"; } if (typeof mask === "string" && NUMERIC_MASK_PATTERNS.test(mask)) { return "numeric"; } return undefined; }, [maskPattern, mask, inputMode]); const shouldValidate = React.useCallback( (trigger: "change" | "blur") => { if (!onValidate || !maskPattern?.validate) return false; switch (validationMode) { case "onChange": return trigger === "change"; case "onBlur": return trigger === "blur"; case "onSubmit": return false; case "onTouched": return touched ? trigger === "change" : trigger === "blur"; case "all": return true; default: return trigger === "change"; } }, [onValidate, maskPattern, validationMode, touched], ); const validationOpts = React.useMemo( () => ({ min: typeof min === "string" ? parseFloat(min) : min, max: typeof max === "string" ? parseFloat(max) : max, }), [min, max], ); const onInputValidate = React.useCallback( (unmaskedValue: string) => { if (onValidate && maskPattern?.validate) { const isValid = maskPattern.validate(unmaskedValue, validationOpts); onValidate(isValid, unmaskedValue); } }, [onValidate, maskPattern?.validate, validationOpts], ); const onValueChange = React.useCallback( (event: React.ChangeEvent) => { const inputValue = event.target.value; let newValue = inputValue; let unmaskedValue = inputValue; if (composing) { if (!isControlled) setInternalValue(inputValue); return; } if (withoutMask || !maskPattern) { if (!isControlled) setInternalValue(inputValue); if (shouldValidate("change")) onValidate?.(true, inputValue); onValueChangeProp?.(inputValue, inputValue); return; } if (maskPattern) { unmaskedValue = getUnmaskedValue({ value: inputValue, transform: maskPattern.transform, ...transformOpts, }); newValue = applyMask({ value: unmaskedValue, pattern: maskPattern.pattern, ...transformOpts, mask, }); if (inputRef.current && newValue !== inputValue) { const inputElement = inputRef.current; if (!(inputElement instanceof HTMLInputElement)) return; const oldCursorPosition = inputElement.selectionStart ?? 0; inputElement.value = newValue; const currentUnmasked = getUnmaskedValue({ value: newValue, transform: maskPattern.transform, ...transformOpts, }); let newCursorPosition: number; const previousUnmasked = getUnmaskedValue({ value, transform: maskPattern.transform, ...transformOpts, }); if (CURRENCY_PERCENTAGE_SYMBOLS.test(maskPattern.pattern)) { newCursorPosition = getCurrencyCaretPosition({ newValue, mask, transformOpts, oldCursorPosition, oldValue: inputValue, previousUnmasked, }); } else { newCursorPosition = getPatternCaretPosition({ newValue, maskPattern, currentUnmasked, oldCursorPosition, oldValue: inputValue, previousUnmasked, }); } if (isCurrencyMask({ mask, pattern: maskPattern.pattern })) { if (mask === "currency") { const currencyAtEnd = isCurrencyAtEnd(transformOpts); if (!currencyAtEnd) { newCursorPosition = Math.max(1, newCursorPosition); } } else { newCursorPosition = Math.max(1, newCursorPosition); } } else if (maskPattern.pattern.includes("%")) { newCursorPosition = Math.min( newValue.length - 1, newCursorPosition, ); } newCursorPosition = Math.min(newCursorPosition, newValue.length); inputElement.setSelectionRange(newCursorPosition, newCursorPosition); } } if (!isControlled) { setInternalValue(newValue); } if (shouldValidate("change")) { onInputValidate(unmaskedValue); } onValueChangeProp?.(newValue, unmaskedValue); }, [ maskPattern, isControlled, onValueChangeProp, onValidate, onInputValidate, composing, shouldValidate, withoutMask, transformOpts, mask, value, ], ); const onFocus = React.useCallback( (event: React.FocusEvent) => { onFocusProp?.(event); if (event.defaultPrevented) return; setFocused(true); }, [onFocusProp], ); const onBlur = React.useCallback( (event: React.FocusEvent) => { onBlurProp?.(event); if (event.defaultPrevented) return; setFocused(false); if (!touched) { setTouched(true); } if (shouldValidate("blur")) { const currentValue = event.target.value; const unmaskedValue = maskPattern ? getUnmaskedValue({ value: currentValue, transform: maskPattern.transform, ...transformOpts, }) : currentValue; onInputValidate(unmaskedValue); } }, [ onBlurProp, touched, shouldValidate, onInputValidate, maskPattern, transformOpts, ], ); const onCompositionStart = React.useCallback( (event: React.CompositionEvent) => { onCompositionStartProp?.(event); if (event.defaultPrevented) return; setComposing(true); }, [onCompositionStartProp], ); const onCompositionEnd = React.useCallback( (event: React.CompositionEvent) => { onCompositionEndProp?.(event); if (event.defaultPrevented) return; setComposing(false); const inputElement = inputRef.current; if (!inputElement) return; if (!(inputElement instanceof HTMLInputElement)) return; const inputValue = inputElement.value; if (!maskPattern || withoutMask) { if (!isControlled) setInternalValue(inputValue); if (shouldValidate("change")) onValidate?.(true, inputValue); onValueChangeProp?.(inputValue, inputValue); return; } const unmasked = getUnmaskedValue({ value: inputValue, transform: maskPattern.transform, ...transformOpts, }); const masked = applyMask({ value: unmasked, pattern: maskPattern.pattern, ...transformOpts, mask, }); if (!isControlled) setInternalValue(masked); if (shouldValidate("change")) onInputValidate(unmasked); onValueChangeProp?.(masked, unmasked); }, [ onCompositionEndProp, maskPattern, withoutMask, isControlled, shouldValidate, onValidate, onValueChangeProp, transformOpts, mask, onInputValidate, ], ); const onPaste = React.useCallback( (event: React.ClipboardEvent) => { onPasteProp?.(event); if (event.defaultPrevented) return; if (withoutMask || !maskPattern) return; if (mask === "ipv4") return; const target = event.target as InputElement; if (!(target instanceof HTMLInputElement)) return; const pastedData = event.clipboardData.getData("text"); if (!pastedData) return; event.preventDefault(); const currentValue = target.value; const selectionStart = target.selectionStart ?? 0; const selectionEnd = target.selectionEnd ?? 0; const beforeSelection = currentValue.slice(0, selectionStart); const afterSelection = currentValue.slice(selectionEnd); const newInputValue = beforeSelection + pastedData + afterSelection; const unmasked = getUnmaskedValue({ value: newInputValue, transform: maskPattern.transform, ...transformOpts, }); const newMaskedValue = applyMask({ value: unmasked, pattern: maskPattern.pattern, ...transformOpts, mask, }); target.value = newMaskedValue; if (isCurrencyMask({ mask, pattern: maskPattern.pattern })) { const currencyAtEnd = isCurrencyAtEnd(transformOpts); const caret = currencyAtEnd ? newMaskedValue.search(/\s*[^\d\s]+$/) : newMaskedValue.length; target.setSelectionRange(caret, caret); return; } if (maskPattern.pattern.includes("%")) { target.setSelectionRange( newMaskedValue.length - 1, newMaskedValue.length - 1, ); return; } let newCursorPosition = newMaskedValue.length; try { const unmaskedCount = unmasked.length; let position = 0; let count = 0; for ( let i = 0; i < maskPattern.pattern.length && i < newMaskedValue.length; i++ ) { if (maskPattern.pattern[i] === "#") { count++; if (count <= unmaskedCount) { position = i + 1; } } } newCursorPosition = position; } catch { // fallback to end } target.setSelectionRange(newCursorPosition, newCursorPosition); if (!isControlled) setInternalValue(newMaskedValue); if (shouldValidate("change")) onInputValidate(unmasked); onValueChangeProp?.(newMaskedValue, unmasked); }, [ onPasteProp, withoutMask, maskPattern, mask, transformOpts, isControlled, shouldValidate, onInputValidate, onValueChangeProp, ], ); const onKeyDown = React.useCallback( (event: React.KeyboardEvent) => { onKeyDownProp?.(event); if (event.defaultPrevented) return; if (withoutMask || !maskPattern) return; if (mask === "ipv4") return; if (event.key === "Backspace") { const target = event.target as InputElement; if (!(target instanceof HTMLInputElement)) return; const cursorPosition = target.selectionStart ?? 0; const selectionEnd = target.selectionEnd ?? 0; const currentValue = target.value; if ( mask === "currency" || mask === "percentage" || maskPattern.pattern.includes("$") || maskPattern.pattern.includes("€") || maskPattern.pattern.includes("%") ) { return; } if (cursorPosition !== selectionEnd) { return; } if (cursorPosition > 0) { const charBeforeCursor = currentValue[cursorPosition - 1]; if (charBeforeCursor) { event.preventDefault(); const unmaskedIndex = toUnmaskedIndex({ masked: currentValue, pattern: maskPattern.pattern, caret: cursorPosition, }); if (unmaskedIndex > 0) { const currentUnmasked = getUnmaskedValue({ value: currentValue, transform: maskPattern.transform, ...transformOpts, }); const nextUnmasked = currentUnmasked.slice(0, unmaskedIndex - 1) + currentUnmasked.slice(unmaskedIndex); const nextMasked = applyMask({ value: nextUnmasked, pattern: maskPattern.pattern, ...transformOpts, mask, }); target.value = nextMasked; const nextCaret = fromUnmaskedIndex({ masked: nextMasked, pattern: maskPattern.pattern, unmaskedIndex: unmaskedIndex - 1, }); target.setSelectionRange(nextCaret, nextCaret); onValueChangeProp?.(nextMasked, nextUnmasked); } return; } } } if (event.key === "Delete") { const target = event.target as InputElement; if (!(target instanceof HTMLInputElement)) return; const cursorPosition = target.selectionStart ?? 0; const selectionEnd = target.selectionEnd ?? 0; const currentValue = target.value; if ( mask === "currency" || mask === "percentage" || maskPattern.pattern.includes("$") || maskPattern.pattern.includes("€") || maskPattern.pattern.includes("%") ) { return; } if (cursorPosition !== selectionEnd) { return; } if (cursorPosition < currentValue.length) { const charAtCursor = currentValue[cursorPosition]; if (charAtCursor) { event.preventDefault(); const unmaskedIndex = toUnmaskedIndex({ masked: currentValue, pattern: maskPattern.pattern, caret: cursorPosition, }); const currentUnmasked = getUnmaskedValue({ value: currentValue, transform: maskPattern.transform, ...transformOpts, }); if (unmaskedIndex < currentUnmasked.length) { const nextUnmasked = currentUnmasked.slice(0, unmaskedIndex) + currentUnmasked.slice(unmaskedIndex + 1); const nextMasked = applyMask({ value: nextUnmasked, pattern: maskPattern.pattern, ...transformOpts, mask, }); target.value = nextMasked; const nextCaret = fromUnmaskedIndex({ masked: nextMasked, pattern: maskPattern.pattern, unmaskedIndex: unmaskedIndex, }); target.setSelectionRange(nextCaret, nextCaret); onValueChangeProp?.(nextMasked, nextUnmasked); } return; } } } }, [ maskPattern, onKeyDownProp, onValueChangeProp, transformOpts, mask, withoutMask, ], ); const InputPrimitive = asChild ? SlotPrimitive.Slot : "input"; return ( ); } export { applyCurrencyMask, applyMask, applyPercentageMask, fromUnmaskedIndex, getUnmaskedValue, MASK_PATTERNS, MaskInput, type MaskInputProps, type MaskPattern, toUnmaskedIndex, }; ``` ## Usage [#usage] ### BuiltInPatterns [#builtinpatterns] ```tsx export const BuiltInPatterns = () => { const [values, setValues] = React.useState({ phone: "", date: "", currency: "", euro: "", creditCard: "", percentage: "", }); const setField = React.useCallback( (field: keyof typeof values) => (masked: string) => { setValues((prev) => ({ ...prev, [field]: masked })); }, [], ); return (
); }; ``` ### CustomPattern [#custompattern] ```tsx export const CustomPattern = () => { const [value, setValue] = React.useState(""); const licensePlate: MaskPattern = { pattern: "###-####", transform: (input) => input.replace(/[^A-Z0-9]/gi, "").toUpperCase(), validate: (input) => input.replace(/[^A-Z0-9]/gi, "").length === 7, }; return (
setValue(masked)} />

Custom alphanumeric pattern

); }; ``` ### ValidationModes [#validationmodes] ```tsx export const ValidationModes = () => { const [value, setValue] = React.useState(""); const [valid, setValid] = React.useState(null); return (
setValue(masked)} onValidate={(isValid) => setValid(isValid)} invalid={valid === false} />

{valid === null ? "Blur to validate" : valid ? "Valid SSN" : "Invalid SSN"}

); }; ``` ### CardInformation [#cardinformation] ```tsx export const CardInformation = () => { const [card, setCard] = React.useState(""); const [expiry, setExpiry] = React.useState(""); return (
setCard(masked)} />
setExpiry(masked)} />
); }; ``` # Masonry (/docs/components/masonry) ## Preview [#preview] ```tsx import { Masonry, MasonryItem } from "@/components/ui/Masonry"; export function MasonryDemo() { return (
Color
Typography runs taller
Space
Motion
) } ```
## Installation [#installation] Make sure that `namespace` is set in your component.json file. Namespace docs: [Learn more about namespaces](/docs/namespace) ```bash pnpm dlx shadcn@latest add @aura/masonry ``` ### Manual [#manual] Copy and paste the class names utility into your `utils/class-names.ts` file. ```ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ``` Copy and paste the Masonry component into your `components/ui/Masonry.tsx` file. ```tsx "use client"; import * as React from "react"; import { cn } from "@/utils/class-names"; function Masonry({ className, ...props }: React.ComponentProps<"div">) { return (
); } function MasonryItem({ className, ...props }: React.ComponentProps<"div">) { return (
); } export { Masonry, MasonryItem }; ``` # Media Player (/docs/components/media-player) ## Preview [#preview] ```tsx import { useEffect, useState } from "react"; import { MediaPlayer } from "@/components/ui/MediaPlayer"; export function MediaPlayerDemo() { const [src, setSrc] = useState(); useEffect(() => { const sampleRate = 8000; const samples = 800; const buffer = new ArrayBuffer(44 + samples * 2); const view = new DataView(buffer); const write = (offset: number, text: string) => { for (let index = 0; index < text.length; index += 1) { view.setUint8(offset + index, text.charCodeAt(index)); } }; write(0, "RIFF"); view.setUint32(4, 36 + samples * 2, true); write(8, "WAVE"); write(12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); write(36, "data"); view.setUint32(40, samples * 2, true); for (let index = 0; index < samples; index += 1) { const sample = Math.sin((2 * Math.PI * 440 * index) / sampleRate) * 0.2; view.setInt16(44 + index * 2, Math.round(sample * 0x7fff), true); } const url = URL.createObjectURL(new Blob([buffer], { type: "audio/wav" })); setSrc(url); return () => URL.revokeObjectURL(url); }, []); return ; } ``` ## Installation [#installation] Make sure that `namespace` is set in your component.json file. Namespace docs: [Learn more about namespaces](/docs/namespace) ```bash pnpm dlx shadcn@latest add @aura/media-player ``` ### Manual [#manual] Copy and paste the button component into your `components/ui/Button.tsx` file. ```tsx /** * @description Displays a button or a component that looks like a button. */ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/utils/class-names"; import { Spinner } from "@/components/ui/Spinner"; const buttonVariants = cva("button", { variants: { variant: { default: "button-fill", fill: "button-fill", pill: "button-pill border border-gray-6 text-gray-11 bg-gray-2 hover:bg-gray-3", link: "button-link", menu: "button-menu", }, size: { default: "h-4", xs: "h-2.5", sm: "h-3", md: "h-4", lg: "h-5", xl: "h-6", icon: "w-3 h-3 p-0", "icon-md": "w-4 h-4 p-0", }, }, defaultVariants: { variant: "default", size: "default", }, }); interface ButtonProps extends React.ComponentProps<"button">, VariantProps { asChild?: boolean; isDisabled?: boolean; isLoading?: boolean; isLoadingText?: string | React.ReactNode; mode?: VariantProps["variant"]; label?: string | React.ReactNode; } const Button = React.forwardRef( (props: ButtonProps, ref) => { const { className, variant, mode, size, asChild = false, isDisabled, isLoading, isLoadingText, children, label, ...rest } = props; const Comp = asChild ? Slot : "button"; const disabled = isDisabled || isLoading || props.disabled; const effectiveVariant = variant ?? mode; return ( {asChild ? ( children ) : ( <> {isLoading && isLoadingText ? ( isLoadingText ) : ( <> {isLoading ? : null} {label} {children} )} )} ); } ); Button.displayName = "Button"; export { Button, buttonVariants }; export type { ButtonProps }; export default Button; ``` Copy and paste the class names utility into your `utils/class-names.ts` file. ```ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ``` Copy and paste the MediaPlayer component into your `components/ui/MediaPlayer.tsx` file. ```tsx "use client"; import * as React from "react"; import { cn } from "@/utils/class-names"; import { Button } from "@/components/ui/Button"; function formatTime(seconds: number) { if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; const whole = Math.floor(seconds); const minutes = Math.floor(whole / 60); const remain = whole % 60; return `${minutes}:${remain.toString().padStart(2, "0")}`; } function MediaPlayer({ className, src, label = "Media player", }: { className?: string; src?: string; label?: string; }) { const media = React.useRef(null); const [playing, setPlaying] = React.useState(false); const [muted, setMuted] = React.useState(false); const [time, setTime] = React.useState(0); const [duration, setDuration] = React.useState(0); const toggle = () => { const node = media.current; if (!node) return; if (node.paused) void node.play(); else node.pause(); }; return (
); } export { MediaPlayer }; ```
# Mention (/docs/components/mention) ## Preview [#preview] ```tsx import * as React from "react"; import { Mention, MentionContent, MentionInput, MentionItem, MentionLabel, MentionPortal, } from "@/components/ui/Mention"; import { Textarea } from "@/components/ui/Textarea"; export function MentionDemo() { return ( Mention users