Thomas Zhang 3 місяців тому
батько
коміт
98c639e9c7
100 змінених файлів з 5062 додано та 3 видалено
  1. 242 0
      .agents/skills/shadcn/SKILL.md
  2. 5 0
      .agents/skills/shadcn/agents/openai.yml
  3. BIN
      .agents/skills/shadcn/assets/shadcn-small.png
  4. BIN
      .agents/skills/shadcn/assets/shadcn.png
  5. 257 0
      .agents/skills/shadcn/cli.md
  6. 202 0
      .agents/skills/shadcn/customization.md
  7. 47 0
      .agents/skills/shadcn/evals/evals.json
  8. 94 0
      .agents/skills/shadcn/mcp.md
  9. 306 0
      .agents/skills/shadcn/rules/base-vs-radix.md
  10. 195 0
      .agents/skills/shadcn/rules/composition.md
  11. 192 0
      .agents/skills/shadcn/rules/forms.md
  12. 101 0
      .agents/skills/shadcn/rules/icons.md
  13. 162 0
      .agents/skills/shadcn/rules/styling.md
  14. 4 0
      .prettierignore
  15. 20 0
      components.json
  16. 894 2
      package-lock.json
  17. 17 0
      package.json
  18. 10 0
      skills-lock.json
  19. 1 1
      src/app.html
  20. 27 0
      src/lib/components/controls/accordion/accordion-content.svelte
  21. 17 0
      src/lib/components/controls/accordion/accordion-item.svelte
  22. 32 0
      src/lib/components/controls/accordion/accordion-trigger.svelte
  23. 19 0
      src/lib/components/controls/accordion/accordion.svelte
  24. 16 0
      src/lib/components/controls/accordion/index.ts
  25. 27 0
      src/lib/components/controls/alert-dialog/alert-dialog-action.svelte
  26. 27 0
      src/lib/components/controls/alert-dialog/alert-dialog-cancel.svelte
  27. 32 0
      src/lib/components/controls/alert-dialog/alert-dialog-content.svelte
  28. 17 0
      src/lib/components/controls/alert-dialog/alert-dialog-description.svelte
  29. 23 0
      src/lib/components/controls/alert-dialog/alert-dialog-footer.svelte
  30. 20 0
      src/lib/components/controls/alert-dialog/alert-dialog-header.svelte
  31. 20 0
      src/lib/components/controls/alert-dialog/alert-dialog-media.svelte
  32. 17 0
      src/lib/components/controls/alert-dialog/alert-dialog-overlay.svelte
  33. 7 0
      src/lib/components/controls/alert-dialog/alert-dialog-portal.svelte
  34. 17 0
      src/lib/components/controls/alert-dialog/alert-dialog-title.svelte
  35. 7 0
      src/lib/components/controls/alert-dialog/alert-dialog-trigger.svelte
  36. 7 0
      src/lib/components/controls/alert-dialog/alert-dialog.svelte
  37. 40 0
      src/lib/components/controls/alert-dialog/index.ts
  38. 20 0
      src/lib/components/controls/alert/alert-action.svelte
  39. 23 0
      src/lib/components/controls/alert/alert-description.svelte
  40. 23 0
      src/lib/components/controls/alert/alert-title.svelte
  41. 43 0
      src/lib/components/controls/alert/alert.svelte
  42. 17 0
      src/lib/components/controls/alert/index.ts
  43. 49 0
      src/lib/components/controls/badge/badge.svelte
  44. 2 0
      src/lib/components/controls/badge/index.ts
  45. 23 0
      src/lib/components/controls/breadcrumb/breadcrumb-ellipsis.svelte
  46. 20 0
      src/lib/components/controls/breadcrumb/breadcrumb-item.svelte
  47. 31 0
      src/lib/components/controls/breadcrumb/breadcrumb-link.svelte
  48. 20 0
      src/lib/components/controls/breadcrumb/breadcrumb-list.svelte
  49. 23 0
      src/lib/components/controls/breadcrumb/breadcrumb-page.svelte
  50. 27 0
      src/lib/components/controls/breadcrumb/breadcrumb-separator.svelte
  51. 22 0
      src/lib/components/controls/breadcrumb/breadcrumb.svelte
  52. 25 0
      src/lib/components/controls/breadcrumb/index.ts
  53. 23 0
      src/lib/components/controls/button-group/button-group-separator.svelte
  54. 28 0
      src/lib/components/controls/button-group/button-group-text.svelte
  55. 46 0
      src/lib/components/controls/button-group/button-group.svelte
  56. 15 0
      src/lib/components/controls/button-group/index.ts
  57. 82 0
      src/lib/components/controls/button/button.svelte
  58. 17 0
      src/lib/components/controls/button/index.ts
  59. 76 0
      src/lib/components/controls/calendar/calendar-caption.svelte
  60. 19 0
      src/lib/components/controls/calendar/calendar-cell.svelte
  61. 33 0
      src/lib/components/controls/calendar/calendar-day.svelte
  62. 12 0
      src/lib/components/controls/calendar/calendar-grid-body.svelte
  63. 12 0
      src/lib/components/controls/calendar/calendar-grid-head.svelte
  64. 12 0
      src/lib/components/controls/calendar/calendar-grid-row.svelte
  65. 16 0
      src/lib/components/controls/calendar/calendar-grid.svelte
  66. 19 0
      src/lib/components/controls/calendar/calendar-head-cell.svelte
  67. 19 0
      src/lib/components/controls/calendar/calendar-header.svelte
  68. 16 0
      src/lib/components/controls/calendar/calendar-heading.svelte
  69. 48 0
      src/lib/components/controls/calendar/calendar-month-select.svelte
  70. 15 0
      src/lib/components/controls/calendar/calendar-month.svelte
  71. 19 0
      src/lib/components/controls/calendar/calendar-months.svelte
  72. 19 0
      src/lib/components/controls/calendar/calendar-nav.svelte
  73. 36 0
      src/lib/components/controls/calendar/calendar-next-button.svelte
  74. 36 0
      src/lib/components/controls/calendar/calendar-prev-button.svelte
  75. 47 0
      src/lib/components/controls/calendar/calendar-year-select.svelte
  76. 115 0
      src/lib/components/controls/calendar/calendar.svelte
  77. 40 0
      src/lib/components/controls/calendar/index.ts
  78. 23 0
      src/lib/components/controls/card/card-action.svelte
  79. 20 0
      src/lib/components/controls/card/card-content.svelte
  80. 20 0
      src/lib/components/controls/card/card-description.svelte
  81. 20 0
      src/lib/components/controls/card/card-footer.svelte
  82. 23 0
      src/lib/components/controls/card/card-header.svelte
  83. 20 0
      src/lib/components/controls/card/card-title.svelte
  84. 22 0
      src/lib/components/controls/card/card.svelte
  85. 25 0
      src/lib/components/controls/card/index.ts
  86. 80 0
      src/lib/components/controls/chart/chart-container.svelte
  87. 37 0
      src/lib/components/controls/chart/chart-style.svelte
  88. 184 0
      src/lib/components/controls/chart/chart-tooltip.svelte
  89. 68 0
      src/lib/components/controls/chart/chart-utils.ts
  90. 6 0
      src/lib/components/controls/chart/index.ts
  91. 39 0
      src/lib/components/controls/checkbox/checkbox.svelte
  92. 6 0
      src/lib/components/controls/checkbox/index.ts
  93. 7 0
      src/lib/components/controls/collapsible/collapsible-content.svelte
  94. 7 0
      src/lib/components/controls/collapsible/collapsible-trigger.svelte
  95. 11 0
      src/lib/components/controls/collapsible/collapsible.svelte
  96. 13 0
      src/lib/components/controls/collapsible/index.ts
  97. 42 0
      src/lib/components/controls/command/command-dialog.svelte
  98. 17 0
      src/lib/components/controls/command/command-empty.svelte
  99. 32 0
      src/lib/components/controls/command/command-group.svelte
  100. 31 0
      src/lib/components/controls/command/command-input.svelte

+ 242 - 0
.agents/skills/shadcn/SKILL.md

@@ -0,0 +1,242 @@
+---
+name: shadcn
+description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
+user-invocable: false
+allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
+---
+
+# shadcn/ui
+
+A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
+
+> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
+
+## Current Project Context
+
+```json
+!`npx shadcn@latest info --json`
+```
+
+The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
+
+## Principles
+
+1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
+2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
+3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
+4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
+
+## Critical Rules
+
+These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
+
+### Styling & Tailwind → [styling.md](./rules/styling.md)
+
+- **`className` for layout, not styling.** Never override component colors or typography.
+- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
+- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
+- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
+- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
+- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
+- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
+
+### Forms & Inputs → [forms.md](./rules/forms.md)
+
+- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
+- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
+- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
+- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
+- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
+- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
+
+### Component Structure → [composition.md](./rules/composition.md)
+
+- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
+- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
+- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
+- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
+- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
+- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
+- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
+
+### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
+
+- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
+- **Callouts use `Alert`.** Don't build custom styled divs.
+- **Empty states use `Empty`.** Don't build custom empty state markup.
+- **Toast via `sonner`.** Use `toast()` from `sonner`.
+- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
+- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
+- **Use `Badge`** instead of custom styled spans.
+
+### Icons → [icons.md](./rules/icons.md)
+
+- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
+- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
+- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
+
+### CLI
+
+- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest init --preset <code>`.
+
+## Key Patterns
+
+These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
+
+```tsx
+// Form layout: FieldGroup + Field, not div + Label.
+<FieldGroup>
+  <Field>
+    <FieldLabel htmlFor="email">Email</FieldLabel>
+    <Input id="email" />
+  </Field>
+</FieldGroup>
+
+// Validation: data-invalid on Field, aria-invalid on the control.
+<Field data-invalid>
+  <FieldLabel>Email</FieldLabel>
+  <Input aria-invalid />
+  <FieldDescription>Invalid email.</FieldDescription>
+</Field>
+
+// Icons in buttons: data-icon, no sizing classes.
+<Button>
+  <SearchIcon data-icon="inline-start" />
+  Search
+</Button>
+
+// Spacing: gap-*, not space-y-*.
+<div className="flex flex-col gap-4">  // correct
+<div className="space-y-4">           // wrong
+
+// Equal dimensions: size-*, not w-* h-*.
+<Avatar className="size-10">   // correct
+<Avatar className="w-10 h-10"> // wrong
+
+// Status colors: Badge variants or semantic tokens, not raw colors.
+<Badge variant="secondary">+20.1%</Badge>    // correct
+<span className="text-emerald-600">+20.1%</span> // wrong
+```
+
+## Component Selection
+
+| Need                       | Use                                                                                                 |
+| -------------------------- | --------------------------------------------------------------------------------------------------- |
+| Button/action              | `Button` with appropriate variant                                                                   |
+| Form inputs                | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
+| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem`                                                                   |
+| Data display               | `Table`, `Card`, `Badge`, `Avatar`                                                                  |
+| Navigation                 | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination`                                     |
+| Overlays                   | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation)       |
+| Feedback                   | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner`                                        |
+| Command palette            | `Command` inside `Dialog`                                                                           |
+| Charts                     | `Chart` (wraps Recharts)                                                                            |
+| Layout                     | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible`                          |
+| Empty states               | `Empty`                                                                                             |
+| Menus                      | `DropdownMenu`, `ContextMenu`, `Menubar`                                                            |
+| Tooltips/info              | `Tooltip`, `HoverCard`, `Popover`                                                                   |
+
+## Key Fields
+
+The injected project context contains these key fields:
+
+- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
+- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
+- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
+- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
+- **`style`** → component visual treatment (e.g. `nova`, `vega`).
+- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
+- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
+- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
+- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
+- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
+
+See [cli.md — `info` command](./cli.md) for the full field reference.
+
+## Component Docs, Examples, and Usage
+
+Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
+
+```bash
+npx shadcn@latest docs button dialog select
+```
+
+**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
+
+## Workflow
+
+1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
+2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
+3. **Find components** — `npx shadcn@latest search`.
+4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
+5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
+6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
+7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
+8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
+9. **Switching presets** — Ask the user first: **reinstall**, **merge**, or **skip**?
+   - **Reinstall**: `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all components.
+   - **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
+   - **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
+   - **Important**: Always run preset commands inside the user's project directory. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
+
+## Updating Components
+
+When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
+
+1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
+2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
+3. Decide per file based on the diff:
+   - No local changes → safe to overwrite.
+   - Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
+   - User says "just update everything" → use `--overwrite`, but confirm first.
+4. **Never use `--overwrite` without the user's explicit approval.**
+
+## Quick Reference
+
+```bash
+# Create a new project.
+npx shadcn@latest init --name my-app --preset base-nova
+npx shadcn@latest init --name my-app --preset a2r6bw --template vite
+
+# Create a monorepo project.
+npx shadcn@latest init --name my-app --preset base-nova --monorepo
+npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
+
+# Initialize existing project.
+npx shadcn@latest init --preset base-nova
+npx shadcn@latest init --defaults  # shortcut: --template=next --preset=base-nova
+
+# Add components.
+npx shadcn@latest add button card dialog
+npx shadcn@latest add @magicui/shimmer-button
+npx shadcn@latest add --all
+
+# Preview changes before adding/updating.
+npx shadcn@latest add button --dry-run
+npx shadcn@latest add button --diff button.tsx
+npx shadcn@latest add @acme/form --view button.tsx
+
+# Search registries.
+npx shadcn@latest search @shadcn -q "sidebar"
+npx shadcn@latest search @tailark -q "stats"
+
+# Get component docs and example URLs.
+npx shadcn@latest docs button dialog select
+
+# View registry item details (for items not yet installed).
+npx shadcn@latest view @shadcn/button
+```
+
+**Named presets:** `base-nova`, `radix-nova`
+**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
+**Preset codes:** Base62 strings starting with `a` (e.g. `a2r6bw`), from [ui.shadcn.com](https://ui.shadcn.com).
+
+## Detailed References
+
+- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
+- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
+- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
+- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
+- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
+- [cli.md](./cli.md) — Commands, flags, presets, templates
+- [customization.md](./customization.md) — Theming, CSS variables, extending components

+ 5 - 0
.agents/skills/shadcn/agents/openai.yml

@@ -0,0 +1,5 @@
+interface:
+  display_name: "shadcn/ui"
+  short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
+  icon_small: "./assets/shadcn-small.png"
+  icon_large: "./assets/shadcn.png"

BIN
.agents/skills/shadcn/assets/shadcn-small.png


BIN
.agents/skills/shadcn/assets/shadcn.png


+ 257 - 0
.agents/skills/shadcn/cli.md

@@ -0,0 +1,257 @@
+# shadcn CLI Reference
+
+Configuration is read from `components.json`.
+
+> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
+
+> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
+
+## Contents
+
+- Commands: init, add (dry-run, smart merge), search, view, docs, info, build
+- Templates: next, vite, start, react-router, astro
+- Presets: named, code, URL formats and fields
+- Switching presets
+
+---
+
+## Commands
+
+### `init` — Initialize or create a project
+
+```bash
+npx shadcn@latest init [components...] [options]
+```
+
+Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
+
+| Flag                    | Short | Description                                               | Default |
+| ----------------------- | ----- | --------------------------------------------------------- | ------- |
+| `--template <template>` | `-t`  | Template (next, start, vite, next-monorepo, react-router) | —       |
+| `--preset [name]`       | `-p`  | Preset configuration (named, code, or URL)                | —       |
+| `--yes`                 | `-y`  | Skip confirmation prompt                                  | `true`  |
+| `--defaults`            | `-d`  | Use defaults (`--template=next --preset=base-nova`)       | `false` |
+| `--force`               | `-f`  | Force overwrite existing configuration                    | `false` |
+| `--cwd <cwd>`           | `-c`  | Working directory                                         | current |
+| `--name <name>`         | `-n`  | Name for new project                                      | —       |
+| `--silent`              | `-s`  | Mute output                                               | `false` |
+| `--rtl`                 |       | Enable RTL support                                        | —       |
+| `--reinstall`           |       | Re-install existing UI components                         | `false` |
+| `--monorepo`            |       | Scaffold a monorepo project                               | —       |
+| `--no-monorepo`         |       | Skip the monorepo prompt                                  | —       |
+
+`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
+
+### `add` — Add components
+
+> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
+
+```bash
+npx shadcn@latest add [components...] [options]
+```
+
+Accepts component names, registry-prefixed names (`@magicui/shimmer-button`), URLs, or local paths.
+
+| Flag            | Short | Description                                                                                                          | Default |
+| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
+| `--yes`         | `-y`  | Skip confirmation prompt                                                                                             | `false` |
+| `--overwrite`   | `-o`  | Overwrite existing files                                                                                             | `false` |
+| `--cwd <cwd>`   | `-c`  | Working directory                                                                                                    | current |
+| `--all`         | `-a`  | Add all available components                                                                                         | `false` |
+| `--path <path>` | `-p`  | Target path for the component                                                                                        | —       |
+| `--silent`      | `-s`  | Mute output                                                                                                          | `false` |
+| `--dry-run`     |       | Preview all changes without writing files                                                                            | `false` |
+| `--diff [path]` |       | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`)         | —       |
+| `--view [path]` |       | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | —       |
+
+#### Dry-Run Mode
+
+Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
+
+```bash
+# Preview all changes.
+npx shadcn@latest add button --dry-run
+
+# Show diffs for all files (top 5).
+npx shadcn@latest add button --diff
+
+# Show the diff for a specific file.
+npx shadcn@latest add button --diff button.tsx
+
+# Show contents for all files (top 5).
+npx shadcn@latest add button --view
+
+# Show the full content of a specific file.
+npx shadcn@latest add button --view button.tsx
+
+# Works with URLs too.
+npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
+
+# CSS diffs.
+npx shadcn@latest add button --diff globals.css
+```
+
+**When to use dry-run:**
+
+- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
+- Before overwriting existing components — use `--diff` to preview the changes first.
+- When the user wants to inspect component source code without installing — use `--view`.
+- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
+- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
+
+> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
+
+#### Smart Merge from Upstream
+
+See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
+
+### `search` — Search registries
+
+```bash
+npx shadcn@latest search <registries...> [options]
+```
+
+Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Without `-q`, lists all items.
+
+| Flag                | Short | Description            | Default |
+| ------------------- | ----- | ---------------------- | ------- |
+| `--query <query>`   | `-q`  | Search query           | —       |
+| `--limit <number>`  | `-l`  | Max items per registry | `100`   |
+| `--offset <number>` | `-o`  | Items to skip          | `0`     |
+| `--cwd <cwd>`       | `-c`  | Working directory      | current |
+
+### `view` — View item details
+
+```bash
+npx shadcn@latest view <items...> [options]
+```
+
+Displays item info including file contents. Example: `npx shadcn@latest view @shadcn/button`.
+
+### `docs` — Get component documentation URLs
+
+```bash
+npx shadcn@latest docs <components...> [options]
+```
+
+Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
+
+Example output for `npx shadcn@latest docs input button`:
+
+```
+base  radix
+
+input
+  docs      https://ui.shadcn.com/docs/components/radix/input
+  examples  https://raw.githubusercontent.com/.../examples/input-example.tsx
+
+button
+  docs      https://ui.shadcn.com/docs/components/radix/button
+  examples  https://raw.githubusercontent.com/.../examples/button-example.tsx
+```
+
+Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
+
+### `diff` — Check for updates
+
+Do not use this command. Use `npx shadcn@latest add --diff` instead.
+
+### `info` — Project information
+
+```bash
+npx shadcn@latest info [options]
+```
+
+Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
+
+| Flag          | Short | Description       | Default |
+| ------------- | ----- | ----------------- | ------- |
+| `--cwd <cwd>` | `-c`  | Working directory | current |
+
+**Project Info fields:**
+
+| Field                | Type      | Meaning                                                            |
+| -------------------- | --------- | ------------------------------------------------------------------ |
+| `framework`          | `string`  | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
+| `frameworkVersion`   | `string`  | Framework version (e.g. `15.2.4`)                                  |
+| `isSrcDir`           | `boolean` | Whether the project uses a `src/` directory                        |
+| `isRSC`              | `boolean` | Whether React Server Components are enabled                        |
+| `isTsx`              | `boolean` | Whether the project uses TypeScript                                |
+| `tailwindVersion`    | `string`  | `"v3"` or `"v4"`                                                   |
+| `tailwindConfigFile` | `string`  | Path to the Tailwind config file                                   |
+| `tailwindCssFile`    | `string`  | Path to the global CSS file                                        |
+| `aliasPrefix`        | `string`  | Import alias prefix (e.g. `@`, `~`, `@/`)                          |
+| `packageManager`     | `string`  | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`)            |
+
+**Components.json fields:**
+
+| Field                | Type      | Meaning                                                                                    |
+| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
+| `base`               | `string`  | Primitive library (`radix` or `base`) — determines component APIs and available props      |
+| `style`              | `string`  | Visual style (e.g. `nova`, `vega`)                                                         |
+| `rsc`                | `boolean` | RSC flag from config                                                                       |
+| `tsx`                | `boolean` | TypeScript flag                                                                            |
+| `tailwind.config`    | `string`  | Tailwind config path                                                                       |
+| `tailwind.css`       | `string`  | Global CSS path — this is where custom CSS variables go                                    |
+| `iconLibrary`        | `string`  | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
+| `aliases.components` | `string`  | Component import alias (e.g. `@/components`)                                               |
+| `aliases.utils`      | `string`  | Utils import alias (e.g. `@/lib/utils`)                                                    |
+| `aliases.ui`         | `string`  | UI component alias (e.g. `@/components/ui`)                                                |
+| `aliases.lib`        | `string`  | Lib alias (e.g. `@/lib`)                                                                   |
+| `aliases.hooks`      | `string`  | Hooks alias (e.g. `@/hooks`)                                                               |
+| `resolvedPaths`      | `object`  | Absolute file-system paths for each alias                                                  |
+| `registries`         | `object`  | Configured custom registries                                                               |
+
+**Links fields:**
+
+The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
+
+### `build` — Build a custom registry
+
+```bash
+npx shadcn@latest build [registry] [options]
+```
+
+Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
+
+| Flag              | Short | Description       | Default      |
+| ----------------- | ----- | ----------------- | ------------ |
+| `--output <path>` | `-o`  | Output directory  | `./public/r` |
+| `--cwd <cwd>`     | `-c`  | Working directory | current      |
+
+---
+
+## Templates
+
+| Value          | Framework      | Monorepo support |
+| -------------- | -------------- | ---------------- |
+| `next`         | Next.js        | Yes              |
+| `vite`         | Vite           | Yes              |
+| `start`        | TanStack Start | Yes              |
+| `react-router` | React Router   | Yes              |
+| `astro`        | Astro          | Yes              |
+| `laravel`      | Laravel        | No               |
+
+All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
+
+---
+
+## Presets
+
+Three ways to specify a preset via `--preset`:
+
+1. **Named:** `--preset base-nova` or `--preset radix-nova`
+2. **Code:** `--preset a2r6bw` (base62 string, starts with lowercase `a`)
+3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
+
+> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
+
+## Switching Presets
+
+Ask the user first: **reinstall**, **merge**, or **skip** existing components?
+
+- **Re-install** → `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all component files with the new preset styles. Use when the user hasn't customized components.
+- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
+- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
+
+Always run preset commands inside the user's project directory. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.

+ 202 - 0
.agents/skills/shadcn/customization.md

@@ -0,0 +1,202 @@
+# Customization & Theming
+
+Components reference semantic CSS variable tokens. Change the variables to change every component.
+
+## Contents
+
+- How it works (CSS variables → Tailwind utilities → components)
+- Color variables and OKLCH format
+- Dark mode setup
+- Changing the theme (presets, CSS variables)
+- Adding custom colors (Tailwind v3 and v4)
+- Border radius
+- Customizing components (variants, className, wrappers)
+- Checking for updates
+
+---
+
+## How It Works
+
+1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
+2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
+3. Components use these utilities — changing a variable changes all components that reference it.
+
+---
+
+## Color Variables
+
+Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
+
+| Variable                                     | Purpose                          |
+| -------------------------------------------- | -------------------------------- |
+| `--background` / `--foreground`              | Page background and default text |
+| `--card` / `--card-foreground`               | Card surfaces                    |
+| `--primary` / `--primary-foreground`         | Primary buttons and actions      |
+| `--secondary` / `--secondary-foreground`     | Secondary actions                |
+| `--muted` / `--muted-foreground`             | Muted/disabled states            |
+| `--accent` / `--accent-foreground`           | Hover and accent states          |
+| `--destructive` / `--destructive-foreground` | Error and destructive actions    |
+| `--border`                                   | Default border color             |
+| `--input`                                    | Form input borders               |
+| `--ring`                                     | Focus ring color                 |
+| `--chart-1` through `--chart-5`              | Chart/data visualization         |
+| `--sidebar-*`                                | Sidebar-specific colors          |
+| `--surface` / `--surface-foreground`         | Secondary surface                |
+
+Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
+
+---
+
+## Dark Mode
+
+Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
+
+```tsx
+import { ThemeProvider } from "next-themes"
+
+<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
+  {children}
+</ThemeProvider>
+```
+
+---
+
+## Changing the Theme
+
+```bash
+# Apply a preset code from ui.shadcn.com.
+npx shadcn@latest init --preset a2r6bw --force
+
+# Switch to a named preset.
+npx shadcn@latest init --preset radix-nova --force
+npx shadcn@latest init --reinstall  # update existing components to match
+
+# Use a custom theme URL.
+npx shadcn@latest init --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..." --force
+```
+
+Or edit CSS variables directly in `globals.css`.
+
+---
+
+## Adding Custom Colors
+
+Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
+
+```css
+/* 1. Define in the global CSS file. */
+:root {
+  --warning: oklch(0.84 0.16 84);
+  --warning-foreground: oklch(0.28 0.07 46);
+}
+.dark {
+  --warning: oklch(0.41 0.11 46);
+  --warning-foreground: oklch(0.99 0.02 95);
+}
+```
+
+```css
+/* 2a. Register with Tailwind v4 (@theme inline). */
+@theme inline {
+  --color-warning: var(--warning);
+  --color-warning-foreground: var(--warning-foreground);
+}
+```
+
+When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
+
+```js
+// 2b. Register with Tailwind v3 (tailwind.config.js).
+module.exports = {
+  theme: {
+    extend: {
+      colors: {
+        warning: "oklch(var(--warning) / <alpha-value>)",
+        "warning-foreground":
+          "oklch(var(--warning-foreground) / <alpha-value>)",
+      },
+    },
+  },
+}
+```
+
+```tsx
+// 3. Use in components.
+<div className="bg-warning text-warning-foreground">Warning</div>
+```
+
+---
+
+## Border Radius
+
+`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
+
+---
+
+## Customizing Components
+
+See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
+
+Prefer these approaches in order:
+
+### 1. Built-in variants
+
+```tsx
+<Button variant="outline" size="sm">Click</Button>
+```
+
+### 2. Tailwind classes via `className`
+
+```tsx
+<Card className="max-w-md mx-auto">...</Card>
+```
+
+### 3. Add a new variant
+
+Edit the component source to add a variant via `cva`:
+
+```tsx
+// components/ui/button.tsx
+warning: "bg-warning text-warning-foreground hover:bg-warning/90",
+```
+
+### 4. Wrapper components
+
+Compose shadcn/ui primitives into higher-level components:
+
+```tsx
+export function ConfirmDialog({ title, description, onConfirm, children }) {
+  return (
+    <AlertDialog>
+      <AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
+      <AlertDialogContent>
+        <AlertDialogHeader>
+          <AlertDialogTitle>{title}</AlertDialogTitle>
+          <AlertDialogDescription>{description}</AlertDialogDescription>
+        </AlertDialogHeader>
+        <AlertDialogFooter>
+          <AlertDialogCancel>Cancel</AlertDialogCancel>
+          <AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
+        </AlertDialogFooter>
+      </AlertDialogContent>
+    </AlertDialog>
+  )
+}
+```
+
+---
+
+## Checking for Updates
+
+```bash
+npx shadcn@latest add button --diff
+```
+
+To preview exactly what would change before updating, use `--dry-run` and `--diff`:
+
+```bash
+npx shadcn@latest add button --dry-run        # see all affected files
+npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
+```
+
+See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.

+ 47 - 0
.agents/skills/shadcn/evals/evals.json

@@ -0,0 +1,47 @@
+{
+  "skill_name": "shadcn",
+  "evals": [
+    {
+      "id": 1,
+      "prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
+      "expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
+      "files": [],
+      "expectations": [
+        "Uses FieldGroup and Field components for form layout instead of raw div with space-y",
+        "Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
+        "Uses data-invalid on Field and aria-invalid on the input control for validation states",
+        "Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
+        "Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
+        "No manual dark: color overrides"
+      ]
+    },
+    {
+      "id": 2,
+      "prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
+      "expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
+      "files": [],
+      "expectations": [
+        "Includes DialogTitle for accessibility (visible or with sr-only class)",
+        "Avatar component includes AvatarFallback",
+        "Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
+        "No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
+        "Uses tabler icons (@tabler/icons-react) instead of lucide-react",
+        "Uses asChild for custom triggers (radix preset)"
+      ]
+    },
+    {
+      "id": 3,
+      "prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
+      "expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
+      "files": [],
+      "expectations": [
+        "Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
+        "Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
+        "Uses Badge component for percentage change instead of custom styled spans",
+        "Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
+        "Uses gap-* instead of space-y-* or space-x-* for spacing",
+        "Uses size-* when width and height are equal instead of separate w-* h-*"
+      ]
+    }
+  ]
+}

+ 94 - 0
.agents/skills/shadcn/mcp.md

@@ -0,0 +1,94 @@
+# shadcn MCP Server
+
+The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries.
+
+---
+
+## Setup
+
+```bash
+shadcn mcp        # start the MCP server (stdio)
+shadcn mcp init   # write config for your editor
+```
+
+Editor config files:
+
+| Editor | Config file |
+|--------|------------|
+| Claude Code | `.mcp.json` |
+| Cursor | `.cursor/mcp.json` |
+| VS Code | `.vscode/mcp.json` |
+| OpenCode | `opencode.json` |
+| Codex | `~/.codex/config.toml` (manual) |
+
+---
+
+## Tools
+
+> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
+
+### `shadcn:get_project_registries`
+
+Returns registry names from `components.json`. Errors if no `components.json` exists.
+
+**Input:** none
+
+### `shadcn:list_items_in_registries`
+
+Lists all items from one or more registries.
+
+**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
+
+### `shadcn:search_items_in_registries`
+
+Fuzzy search across registries.
+
+**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
+
+### `shadcn:view_items_in_registries`
+
+View item details including full file contents.
+
+**Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]`
+
+### `shadcn:get_item_examples_from_registries`
+
+Find usage examples and demos with source code.
+
+**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
+
+### `shadcn:get_add_command_for_items`
+
+Returns the CLI install command.
+
+**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
+
+### `shadcn:get_audit_checklist`
+
+Returns a checklist for verifying components (imports, deps, lint, TypeScript).
+
+**Input:** none
+
+---
+
+## Configuring Registries
+
+Registries are set in `components.json`. The `@shadcn` registry is always built-in.
+
+```json
+{
+  "registries": {
+    "@acme": "https://acme.com/r/{name}.json",
+    "@private": {
+      "url": "https://private.com/r/{name}.json",
+      "headers": { "Authorization": "Bearer ${MY_TOKEN}" }
+    }
+  }
+}
+```
+
+- Names must start with `@`.
+- URLs must contain `{name}`.
+- `${VAR}` references are resolved from environment variables.
+
+Community registry index: `https://ui.shadcn.com/r/registries.json`

+ 306 - 0
.agents/skills/shadcn/rules/base-vs-radix.md

@@ -0,0 +1,306 @@
+# Base vs Radix
+
+API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
+
+## Contents
+
+- Composition: asChild vs render
+- Button / trigger as non-button element
+- Select (items prop, placeholder, positioning, multiple, object values)
+- ToggleGroup (type vs multiple)
+- Slider (scalar vs array)
+- Accordion (type and defaultValue)
+
+---
+
+## Composition: asChild (radix) vs render (base)
+
+Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
+
+**Incorrect:**
+
+```tsx
+<DialogTrigger>
+  <div>
+    <Button>Open</Button>
+  </div>
+</DialogTrigger>
+```
+
+**Correct (radix):**
+
+```tsx
+<DialogTrigger asChild>
+  <Button>Open</Button>
+</DialogTrigger>
+```
+
+**Correct (base):**
+
+```tsx
+<DialogTrigger render={<Button />}>Open</DialogTrigger>
+```
+
+This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
+
+---
+
+## Button / trigger as non-button element (base only)
+
+When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
+
+**Incorrect (base):** missing `nativeButton={false}`.
+
+```tsx
+<Button render={<a href="/docs" />}>Read the docs</Button>
+```
+
+**Correct (base):**
+
+```tsx
+<Button render={<a href="/docs" />} nativeButton={false}>
+  Read the docs
+</Button>
+```
+
+**Correct (radix):**
+
+```tsx
+<Button asChild>
+  <a href="/docs">Read the docs</a>
+</Button>
+```
+
+Same for triggers whose `render` is not a `Button`:
+
+```tsx
+// base.
+<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
+  Pick date
+</PopoverTrigger>
+```
+
+---
+
+## Select
+
+**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
+
+**Incorrect (base):**
+
+```tsx
+<Select>
+  <SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
+</Select>
+```
+
+**Correct (base):**
+
+```tsx
+const items = [
+  { label: "Select a fruit", value: null },
+  { label: "Apple", value: "apple" },
+  { label: "Banana", value: "banana" },
+]
+
+<Select items={items}>
+  <SelectTrigger>
+    <SelectValue />
+  </SelectTrigger>
+  <SelectContent>
+    <SelectGroup>
+      {items.map((item) => (
+        <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
+      ))}
+    </SelectGroup>
+  </SelectContent>
+</Select>
+```
+
+**Correct (radix):**
+
+```tsx
+<Select>
+  <SelectTrigger>
+    <SelectValue placeholder="Select a fruit" />
+  </SelectTrigger>
+  <SelectContent>
+    <SelectGroup>
+      <SelectItem value="apple">Apple</SelectItem>
+      <SelectItem value="banana">Banana</SelectItem>
+    </SelectGroup>
+  </SelectContent>
+</Select>
+```
+
+**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
+
+**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
+
+```tsx
+// base.
+<SelectContent alignItemWithTrigger={false} side="bottom">
+
+// radix.
+<SelectContent position="popper">
+```
+
+---
+
+## Select — multiple selection and object values (base only)
+
+Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
+
+**Correct (base — multiple selection):**
+
+```tsx
+<Select items={items} multiple defaultValue={[]}>
+  <SelectTrigger>
+    <SelectValue>
+      {(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
+    </SelectValue>
+  </SelectTrigger>
+  ...
+</Select>
+```
+
+**Correct (base — object values):**
+
+```tsx
+<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
+  <SelectTrigger>
+    <SelectValue>{(value) => value.name}</SelectValue>
+  </SelectTrigger>
+  ...
+</Select>
+```
+
+---
+
+## ToggleGroup
+
+Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
+
+**Incorrect (base):**
+
+```tsx
+<ToggleGroup type="single" defaultValue="daily">
+  <ToggleGroupItem value="daily">Daily</ToggleGroupItem>
+</ToggleGroup>
+```
+
+**Correct (base):**
+
+```tsx
+// Single (no prop needed), defaultValue is always an array.
+<ToggleGroup defaultValue={["daily"]} spacing={2}>
+  <ToggleGroupItem value="daily">Daily</ToggleGroupItem>
+  <ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
+</ToggleGroup>
+
+// Multi-selection.
+<ToggleGroup multiple>
+  <ToggleGroupItem value="bold">Bold</ToggleGroupItem>
+  <ToggleGroupItem value="italic">Italic</ToggleGroupItem>
+</ToggleGroup>
+```
+
+**Correct (radix):**
+
+```tsx
+// Single, defaultValue is a string.
+<ToggleGroup type="single" defaultValue="daily" spacing={2}>
+  <ToggleGroupItem value="daily">Daily</ToggleGroupItem>
+  <ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
+</ToggleGroup>
+
+// Multi-selection.
+<ToggleGroup type="multiple">
+  <ToggleGroupItem value="bold">Bold</ToggleGroupItem>
+  <ToggleGroupItem value="italic">Italic</ToggleGroupItem>
+</ToggleGroup>
+```
+
+**Controlled single value:**
+
+```tsx
+// base — wrap/unwrap arrays.
+const [value, setValue] = React.useState("normal")
+<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
+
+// radix — plain string.
+const [value, setValue] = React.useState("normal")
+<ToggleGroup type="single" value={value} onValueChange={setValue}>
+```
+
+---
+
+## Slider
+
+Base accepts a plain number for a single thumb. Radix always requires an array.
+
+**Incorrect (base):**
+
+```tsx
+<Slider defaultValue={[50]} max={100} step={1} />
+```
+
+**Correct (base):**
+
+```tsx
+<Slider defaultValue={50} max={100} step={1} />
+```
+
+**Correct (radix):**
+
+```tsx
+<Slider defaultValue={[50]} max={100} step={1} />
+```
+
+Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
+
+```tsx
+// base.
+const [value, setValue] = React.useState([0.3, 0.7])
+<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
+
+// radix.
+const [value, setValue] = React.useState([0.3, 0.7])
+<Slider value={value} onValueChange={setValue} />
+```
+
+---
+
+## Accordion
+
+Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
+
+**Incorrect (base):**
+
+```tsx
+<Accordion type="single" collapsible defaultValue="item-1">
+  <AccordionItem value="item-1">...</AccordionItem>
+</Accordion>
+```
+
+**Correct (base):**
+
+```tsx
+<Accordion defaultValue={["item-1"]}>
+  <AccordionItem value="item-1">...</AccordionItem>
+</Accordion>
+
+// Multi-select.
+<Accordion multiple defaultValue={["item-1", "item-2"]}>
+  <AccordionItem value="item-1">...</AccordionItem>
+  <AccordionItem value="item-2">...</AccordionItem>
+</Accordion>
+```
+
+**Correct (radix):**
+
+```tsx
+<Accordion type="single" collapsible defaultValue="item-1">
+  <AccordionItem value="item-1">...</AccordionItem>
+</Accordion>
+```

+ 195 - 0
.agents/skills/shadcn/rules/composition.md

@@ -0,0 +1,195 @@
+# Component Composition
+
+## Contents
+
+- Items always inside their Group component
+- Callouts use Alert
+- Empty states use Empty component
+- Toast notifications use sonner
+- Choosing between overlay components
+- Dialog, Sheet, and Drawer always need a Title
+- Card structure
+- Button has no isPending or isLoading prop
+- TabsTrigger must be inside TabsList
+- Avatar always needs AvatarFallback
+- Use Separator instead of raw hr or border divs
+- Use Skeleton for loading placeholders
+- Use Badge instead of custom styled spans
+
+---
+
+## Items always inside their Group component
+
+Never render items directly inside the content container.
+
+**Incorrect:**
+
+```tsx
+<SelectContent>
+  <SelectItem value="apple">Apple</SelectItem>
+  <SelectItem value="banana">Banana</SelectItem>
+</SelectContent>
+```
+
+**Correct:**
+
+```tsx
+<SelectContent>
+  <SelectGroup>
+    <SelectItem value="apple">Apple</SelectItem>
+    <SelectItem value="banana">Banana</SelectItem>
+  </SelectGroup>
+</SelectContent>
+```
+
+This applies to all group-based components:
+
+| Item | Group |
+|------|-------|
+| `SelectItem`, `SelectLabel` | `SelectGroup` |
+| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
+| `MenubarItem` | `MenubarGroup` |
+| `ContextMenuItem` | `ContextMenuGroup` |
+| `CommandItem` | `CommandGroup` |
+
+---
+
+## Callouts use Alert
+
+```tsx
+<Alert>
+  <AlertTitle>Warning</AlertTitle>
+  <AlertDescription>Something needs attention.</AlertDescription>
+</Alert>
+```
+
+---
+
+## Empty states use Empty component
+
+```tsx
+<Empty>
+  <EmptyHeader>
+    <EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
+    <EmptyTitle>No projects yet</EmptyTitle>
+    <EmptyDescription>Get started by creating a new project.</EmptyDescription>
+  </EmptyHeader>
+  <EmptyContent>
+    <Button>Create Project</Button>
+  </EmptyContent>
+</Empty>
+```
+
+---
+
+## Toast notifications use sonner
+
+```tsx
+import { toast } from "sonner"
+
+toast.success("Changes saved.")
+toast.error("Something went wrong.")
+toast("File deleted.", {
+  action: { label: "Undo", onClick: () => undoDelete() },
+})
+```
+
+---
+
+## Choosing between overlay components
+
+| Use case | Component |
+|----------|-----------|
+| Focused task that requires input | `Dialog` |
+| Destructive action confirmation | `AlertDialog` |
+| Side panel with details or filters | `Sheet` |
+| Mobile-first bottom panel | `Drawer` |
+| Quick info on hover | `HoverCard` |
+| Small contextual content on click | `Popover` |
+
+---
+
+## Dialog, Sheet, and Drawer always need a Title
+
+`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
+
+```tsx
+<DialogContent>
+  <DialogHeader>
+    <DialogTitle>Edit Profile</DialogTitle>
+    <DialogDescription>Update your profile.</DialogDescription>
+  </DialogHeader>
+  ...
+</DialogContent>
+```
+
+---
+
+## Card structure
+
+Use full composition — don't dump everything into `CardContent`:
+
+```tsx
+<Card>
+  <CardHeader>
+    <CardTitle>Team Members</CardTitle>
+    <CardDescription>Manage your team.</CardDescription>
+  </CardHeader>
+  <CardContent>...</CardContent>
+  <CardFooter>
+    <Button>Invite</Button>
+  </CardFooter>
+</Card>
+```
+
+---
+
+## Button has no isPending or isLoading prop
+
+Compose with `Spinner` + `data-icon` + `disabled`:
+
+```tsx
+<Button disabled>
+  <Spinner data-icon="inline-start" />
+  Saving...
+</Button>
+```
+
+---
+
+## TabsTrigger must be inside TabsList
+
+Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
+
+```tsx
+<Tabs defaultValue="account">
+  <TabsList>
+    <TabsTrigger value="account">Account</TabsTrigger>
+    <TabsTrigger value="password">Password</TabsTrigger>
+  </TabsList>
+  <TabsContent value="account">...</TabsContent>
+</Tabs>
+```
+
+---
+
+## Avatar always needs AvatarFallback
+
+Always include `AvatarFallback` for when the image fails to load:
+
+```tsx
+<Avatar>
+  <AvatarImage src="/avatar.png" alt="User" />
+  <AvatarFallback>JD</AvatarFallback>
+</Avatar>
+```
+
+---
+
+## Use existing components instead of custom markup
+
+| Instead of | Use |
+|---|---|
+| `<hr>` or `<div className="border-t">` | `<Separator />` |
+| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
+| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |

+ 192 - 0
.agents/skills/shadcn/rules/forms.md

@@ -0,0 +1,192 @@
+# Forms & Inputs
+
+## Contents
+
+- Forms use FieldGroup + Field
+- InputGroup requires InputGroupInput/InputGroupTextarea
+- Buttons inside inputs use InputGroup + InputGroupAddon
+- Option sets (2–7 choices) use ToggleGroup
+- FieldSet + FieldLegend for grouping related fields
+- Field validation and disabled states
+
+---
+
+## Forms use FieldGroup + Field
+
+Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
+
+```tsx
+<FieldGroup>
+  <Field>
+    <FieldLabel htmlFor="email">Email</FieldLabel>
+    <Input id="email" type="email" />
+  </Field>
+  <Field>
+    <FieldLabel htmlFor="password">Password</FieldLabel>
+    <Input id="password" type="password" />
+  </Field>
+</FieldGroup>
+```
+
+Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
+
+**Choosing form controls:**
+
+- Simple text input → `Input`
+- Dropdown with predefined options → `Select`
+- Searchable dropdown → `Combobox`
+- Native HTML select (no JS) → `native-select`
+- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
+- Single choice from few options → `RadioGroup`
+- Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem`
+- OTP/verification code → `InputOTP`
+- Multi-line text → `Textarea`
+
+---
+
+## InputGroup requires InputGroupInput/InputGroupTextarea
+
+Never use raw `Input` or `Textarea` inside an `InputGroup`.
+
+**Incorrect:**
+
+```tsx
+<InputGroup>
+  <Input placeholder="Search..." />
+</InputGroup>
+```
+
+**Correct:**
+
+```tsx
+import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
+
+<InputGroup>
+  <InputGroupInput placeholder="Search..." />
+</InputGroup>
+```
+
+---
+
+## Buttons inside inputs use InputGroup + InputGroupAddon
+
+Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
+
+**Incorrect:**
+
+```tsx
+<div className="relative">
+  <Input placeholder="Search..." className="pr-10" />
+  <Button className="absolute right-0 top-0" size="icon">
+    <SearchIcon />
+  </Button>
+</div>
+```
+
+**Correct:**
+
+```tsx
+import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
+
+<InputGroup>
+  <InputGroupInput placeholder="Search..." />
+  <InputGroupAddon>
+    <Button size="icon">
+      <SearchIcon data-icon="inline-start" />
+    </Button>
+  </InputGroupAddon>
+</InputGroup>
+```
+
+---
+
+## Option sets (2–7 choices) use ToggleGroup
+
+Don't manually loop `Button` components with active state.
+
+**Incorrect:**
+
+```tsx
+const [selected, setSelected] = useState("daily")
+
+<div className="flex gap-2">
+  {["daily", "weekly", "monthly"].map((option) => (
+    <Button
+      key={option}
+      variant={selected === option ? "default" : "outline"}
+      onClick={() => setSelected(option)}
+    >
+      {option}
+    </Button>
+  ))}
+</div>
+```
+
+**Correct:**
+
+```tsx
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+
+<ToggleGroup spacing={2}>
+  <ToggleGroupItem value="daily">Daily</ToggleGroupItem>
+  <ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
+  <ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
+</ToggleGroup>
+```
+
+Combine with `Field` for labelled toggle groups:
+
+```tsx
+<Field orientation="horizontal">
+  <FieldTitle id="theme-label">Theme</FieldTitle>
+  <ToggleGroup aria-labelledby="theme-label" spacing={2}>
+    <ToggleGroupItem value="light">Light</ToggleGroupItem>
+    <ToggleGroupItem value="dark">Dark</ToggleGroupItem>
+    <ToggleGroupItem value="system">System</ToggleGroupItem>
+  </ToggleGroup>
+</Field>
+```
+
+> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
+
+---
+
+## FieldSet + FieldLegend for grouping related fields
+
+Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
+
+```tsx
+<FieldSet>
+  <FieldLegend variant="label">Preferences</FieldLegend>
+  <FieldDescription>Select all that apply.</FieldDescription>
+  <FieldGroup className="gap-3">
+    <Field orientation="horizontal">
+      <Checkbox id="dark" />
+      <FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
+    </Field>
+  </FieldGroup>
+</FieldSet>
+```
+
+---
+
+## Field validation and disabled states
+
+Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
+
+```tsx
+// Invalid.
+<Field data-invalid>
+  <FieldLabel htmlFor="email">Email</FieldLabel>
+  <Input id="email" aria-invalid />
+  <FieldDescription>Invalid email address.</FieldDescription>
+</Field>
+
+// Disabled.
+<Field data-disabled>
+  <FieldLabel htmlFor="email">Email</FieldLabel>
+  <Input id="email" disabled />
+</Field>
+```
+
+Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.

+ 101 - 0
.agents/skills/shadcn/rules/icons.md

@@ -0,0 +1,101 @@
+# Icons
+
+**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide` → `lucide-react`, `tabler` → `@tabler/icons-react`, etc. Never assume `lucide-react`.
+
+---
+
+## Icons in Button use data-icon attribute
+
+Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
+
+**Incorrect:**
+
+```tsx
+<Button>
+  <SearchIcon className="mr-2 size-4" />
+  Search
+</Button>
+```
+
+**Correct:**
+
+```tsx
+<Button>
+  <SearchIcon data-icon="inline-start"/>
+  Search
+</Button>
+
+<Button>
+  Next
+  <ArrowRightIcon data-icon="inline-end"/>
+</Button>
+```
+
+---
+
+## No sizing classes on icons inside components
+
+Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
+
+**Incorrect:**
+
+```tsx
+<Button>
+  <SearchIcon className="size-4" data-icon="inline-start" />
+  Search
+</Button>
+
+<DropdownMenuItem>
+  <SettingsIcon className="mr-2 size-4" />
+  Settings
+</DropdownMenuItem>
+```
+
+**Correct:**
+
+```tsx
+<Button>
+  <SearchIcon data-icon="inline-start" />
+  Search
+</Button>
+
+<DropdownMenuItem>
+  <SettingsIcon />
+  Settings
+</DropdownMenuItem>
+```
+
+---
+
+## Pass icons as component objects, not string keys
+
+Use `icon={CheckIcon}`, not a string key to a lookup map.
+
+**Incorrect:**
+
+```tsx
+const iconMap = {
+  check: CheckIcon,
+  alert: AlertIcon,
+}
+
+function StatusBadge({ icon }: { icon: string }) {
+  const Icon = iconMap[icon]
+  return <Icon />
+}
+
+<StatusBadge icon="check" />
+```
+
+**Correct:**
+
+```tsx
+// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
+import { CheckIcon } from "lucide-react"
+
+function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
+  return <Icon />
+}
+
+<StatusBadge icon={CheckIcon} />
+```

+ 162 - 0
.agents/skills/shadcn/rules/styling.md

@@ -0,0 +1,162 @@
+# Styling & Customization
+
+See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
+
+## Contents
+
+- Semantic colors
+- Built-in variants first
+- className for layout only
+- No space-x-* / space-y-*
+- Prefer size-* over w-* h-* when equal
+- Prefer truncate shorthand
+- No manual dark: color overrides
+- Use cn() for conditional classes
+- No manual z-index on overlay components
+
+---
+
+## Semantic colors
+
+**Incorrect:**
+
+```tsx
+<div className="bg-blue-500 text-white">
+  <p className="text-gray-600">Secondary text</p>
+</div>
+```
+
+**Correct:**
+
+```tsx
+<div className="bg-primary text-primary-foreground">
+  <p className="text-muted-foreground">Secondary text</p>
+</div>
+```
+
+---
+
+## No raw color values for status/state indicators
+
+For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
+
+**Incorrect:**
+
+```tsx
+<span className="text-emerald-600">+20.1%</span>
+<span className="text-green-500">Active</span>
+<span className="text-red-600">-3.2%</span>
+```
+
+**Correct:**
+
+```tsx
+<Badge variant="secondary">+20.1%</Badge>
+<Badge>Active</Badge>
+<span className="text-destructive">-3.2%</span>
+```
+
+If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
+
+---
+
+## Built-in variants first
+
+**Incorrect:**
+
+```tsx
+<Button className="border border-input bg-transparent hover:bg-accent">
+  Click me
+</Button>
+```
+
+**Correct:**
+
+```tsx
+<Button variant="outline">Click me</Button>
+```
+
+---
+
+## className for layout only
+
+Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
+
+**Incorrect:**
+
+```tsx
+<Card className="bg-blue-100 text-blue-900 font-bold">
+  <CardContent>Dashboard</CardContent>
+</Card>
+```
+
+**Correct:**
+
+```tsx
+<Card className="max-w-md mx-auto">
+  <CardContent>Dashboard</CardContent>
+</Card>
+```
+
+To customize a component's appearance, prefer these approaches in order:
+1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc.
+2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`.
+3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
+
+---
+
+## No space-x-* / space-y-*
+
+Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`.
+
+```tsx
+<div className="flex flex-col gap-4">
+  <Input />
+  <Input />
+  <Button>Submit</Button>
+</div>
+```
+
+---
+
+## Prefer size-* over w-* h-* when equal
+
+`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
+
+---
+
+## Prefer truncate shorthand
+
+`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
+
+---
+
+## No manual dark: color overrides
+
+Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
+
+---
+
+## Use cn() for conditional classes
+
+Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
+
+**Incorrect:**
+
+```tsx
+<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
+```
+
+**Correct:**
+
+```tsx
+import { cn } from "@/lib/utils"
+
+<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
+```
+
+---
+
+## No manual z-index on overlay components
+
+`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.

+ 4 - 0
.prettierignore

@@ -5,5 +5,9 @@ yarn.lock
 bun.lock
 bun.lockb
 
+# Shadcn
+src/lib/components/controls/
+
 # Miscellaneous
 /static/
+.agents

+ 20 - 0
components.json

@@ -0,0 +1,20 @@
+{
+	"$schema": "https://shadcn-svelte.com/schema.json",
+	"tailwind": {
+		"css": "src/routes/layout.css",
+		"baseColor": "mauve"
+	},
+	"aliases": {
+		"components": "$lib/components",
+		"utils": "$lib/utils",
+		"ui": "$lib/components/controls",
+		"hooks": "$lib/hooks",
+		"lib": "$lib"
+	},
+	"typescript": true,
+	"registry": "https://shadcn-svelte.com/registry",
+	"style": "nova",
+	"iconLibrary": "lucide",
+	"menuColor": "default",
+	"menuAccent": "subtle"
+}

Різницю між файлами не показано, бо вона завелика
+ 894 - 2
package-lock.json


+ 17 - 0
package.json

@@ -19,6 +19,9 @@
 	"devDependencies": {
 		"@eslint/compat": "^2.0.2",
 		"@eslint/js": "^9.39.2",
+		"@fontsource-variable/inter": "^5.2.8",
+		"@internationalized/date": "^3.12.0",
+		"@lucide/svelte": "^1.7.0",
 		"@playwright/test": "^1.58.2",
 		"@sveltejs/adapter-auto": "^7.0.0",
 		"@sveltejs/kit": "^2.50.2",
@@ -26,22 +29,36 @@
 		"@tailwindcss/forms": "^0.5.11",
 		"@tailwindcss/typography": "^0.5.19",
 		"@tailwindcss/vite": "^4.1.18",
+		"@tanstack/table-core": "^8.21.3",
 		"@types/node": "^22",
 		"@vitest/browser-playwright": "^4.1.0",
+		"bits-ui": "^2.16.5",
+		"clsx": "^2.1.1",
 		"eslint": "^9.39.2",
 		"eslint-config-prettier": "^10.1.8",
 		"eslint-plugin-simple-import-sort": "^13.0.0",
 		"eslint-plugin-svelte": "^3.14.0",
+		"formsnap": "^2.0.1",
 		"globals": "^17.3.0",
+		"layerchart": "^2.0.0-next.48",
+		"mode-watcher": "^1.1.0",
+		"paneforge": "^1.0.2",
 		"playwright": "^1.58.2",
 		"prettier": "^3.8.1",
 		"prettier-plugin-svelte": "^3.4.1",
 		"prettier-plugin-tailwindcss": "^0.7.2",
+		"shadcn-svelte": "^1.2.6",
 		"svelte": "^5.51.0",
 		"svelte-check": "^4.4.2",
+		"svelte-sonner": "^1.1.0",
+		"sveltekit-superforms": "^2.30.1",
+		"tailwind-merge": "^3.5.0",
+		"tailwind-variants": "^3.2.2",
 		"tailwindcss": "^4.1.18",
+		"tw-animate-css": "^1.4.0",
 		"typescript": "^5.9.3",
 		"typescript-eslint": "^8.54.0",
+		"vaul-svelte": "^1.0.0-next.7",
 		"vite": "^7.3.1",
 		"vitest": "^4.1.0",
 		"vitest-browser-svelte": "^2.0.2"

+ 10 - 0
skills-lock.json

@@ -0,0 +1,10 @@
+{
+	"version": 1,
+	"skills": {
+		"shadcn": {
+			"source": "shadcn/ui",
+			"sourceType": "github",
+			"computedHash": "507f011a70e8b3ae242bdc0bb5b39fd91a1d4049a0f3c281991ccf84973d591c"
+		}
+	}
+}

+ 1 - 1
src/app.html

@@ -1,5 +1,5 @@
 <!doctype html>
-<html lang="en">
+<html lang="en" class="dark">
 	<head>
 		<meta charset="utf-8" />
 		<meta name="viewport" content="width=device-width, initial-scale=1" />

+ 27 - 0
src/lib/components/controls/accordion/accordion-content.svelte

@@ -0,0 +1,27 @@
+<script lang="ts">
+	import { Accordion as AccordionPrimitive } from "bits-ui";
+	import { cn, type WithoutChild } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
+</script>
+
+<AccordionPrimitive.Content
+	bind:ref
+	data-slot="accordion-content"
+	class="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
+	{...restProps}
+>
+	<div
+		class={cn(
+			"pt-0 pb-2.5 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
+			className
+		)}
+	>
+		{@render children?.()}
+	</div>
+</AccordionPrimitive.Content>

+ 17 - 0
src/lib/components/controls/accordion/accordion-item.svelte

@@ -0,0 +1,17 @@
+<script lang="ts">
+	import { Accordion as AccordionPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: AccordionPrimitive.ItemProps = $props();
+</script>
+
+<AccordionPrimitive.Item
+	bind:ref
+	data-slot="accordion-item"
+	class={cn("not-last:border-b", className)}
+	{...restProps}
+/>

+ 32 - 0
src/lib/components/controls/accordion/accordion-trigger.svelte

@@ -0,0 +1,32 @@
+<script lang="ts">
+	import { Accordion as AccordionPrimitive } from "bits-ui";
+	import { cn, type WithoutChild } from "$lib/utils.js";
+	import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
+	import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		level = 3,
+		children,
+		...restProps
+	}: WithoutChild<AccordionPrimitive.TriggerProps> & {
+		level?: AccordionPrimitive.HeaderProps["level"];
+	} = $props();
+</script>
+
+<AccordionPrimitive.Header {level} class="flex">
+	<AccordionPrimitive.Trigger
+		data-slot="accordion-trigger"
+		bind:ref
+		class={cn(
+			"focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50",
+			className
+		)}
+		{...restProps}
+	>
+		{@render children?.()}
+		<ChevronDownIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
+		<ChevronUpIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
+	</AccordionPrimitive.Trigger>
+</AccordionPrimitive.Header>

+ 19 - 0
src/lib/components/controls/accordion/accordion.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	import { Accordion as AccordionPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		value = $bindable(),
+		class: className,
+		...restProps
+	}: AccordionPrimitive.RootProps = $props();
+</script>
+
+<AccordionPrimitive.Root
+	bind:ref
+	bind:value={value as never}
+	data-slot="accordion"
+	class={cn("cn-accordion flex w-full flex-col", className)}
+	{...restProps}
+/>

+ 16 - 0
src/lib/components/controls/accordion/index.ts

@@ -0,0 +1,16 @@
+import Root from "./accordion.svelte";
+import Content from "./accordion-content.svelte";
+import Item from "./accordion-item.svelte";
+import Trigger from "./accordion-trigger.svelte";
+
+export {
+	Root,
+	Content,
+	Item,
+	Trigger,
+	//
+	Root as Accordion,
+	Content as AccordionContent,
+	Item as AccordionItem,
+	Trigger as AccordionTrigger,
+};

+ 27 - 0
src/lib/components/controls/alert-dialog/alert-dialog-action.svelte

@@ -0,0 +1,27 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+	import {
+		buttonVariants,
+		type ButtonVariant,
+		type ButtonSize,
+	} from "$lib/components/controls/button/index.js";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		variant = "default",
+		size = "default",
+		...restProps
+	}: AlertDialogPrimitive.ActionProps & {
+		variant?: ButtonVariant;
+		size?: ButtonSize;
+	} = $props();
+</script>
+
+<AlertDialogPrimitive.Action
+	bind:ref
+	data-slot="alert-dialog-action"
+	class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-action", className)}
+	{...restProps}
+/>

+ 27 - 0
src/lib/components/controls/alert-dialog/alert-dialog-cancel.svelte

@@ -0,0 +1,27 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+	import {
+		buttonVariants,
+		type ButtonVariant,
+		type ButtonSize,
+	} from "$lib/components/controls/button/index.js";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		variant = "outline",
+		size = "default",
+		...restProps
+	}: AlertDialogPrimitive.CancelProps & {
+		variant?: ButtonVariant;
+		size?: ButtonSize;
+	} = $props();
+</script>
+
+<AlertDialogPrimitive.Cancel
+	bind:ref
+	data-slot="alert-dialog-cancel"
+	class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-cancel", className)}
+	{...restProps}
+/>

+ 32 - 0
src/lib/components/controls/alert-dialog/alert-dialog-content.svelte

@@ -0,0 +1,32 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+	import AlertDialogPortal from "./alert-dialog-portal.svelte";
+	import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
+	import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
+	import type { ComponentProps } from "svelte";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		size = "default",
+		portalProps,
+		...restProps
+	}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
+		size?: "default" | "sm";
+		portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
+	} = $props();
+</script>
+
+<AlertDialogPortal {...portalProps}>
+	<AlertDialogOverlay />
+	<AlertDialogPrimitive.Content
+		bind:ref
+		data-slot="alert-dialog-content"
+		data-size={size}
+		class={cn(
+			"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
+			className
+		)}
+		{...restProps}
+	/>
+</AlertDialogPortal>

+ 17 - 0
src/lib/components/controls/alert-dialog/alert-dialog-description.svelte

@@ -0,0 +1,17 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: AlertDialogPrimitive.DescriptionProps = $props();
+</script>
+
+<AlertDialogPrimitive.Description
+	bind:ref
+	data-slot="alert-dialog-description"
+	class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
+	{...restProps}
+/>

+ 23 - 0
src/lib/components/controls/alert-dialog/alert-dialog-footer.svelte

@@ -0,0 +1,23 @@
+<script lang="ts">
+	import { cn, type WithElementRef } from "$lib/utils.js";
+	import type { HTMLAttributes } from "svelte/elements";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="alert-dialog-footer"
+	class={cn(
+		"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
+		className
+	)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 20 - 0
src/lib/components/controls/alert-dialog/alert-dialog-header.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="alert-dialog-header"
+	class={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 20 - 0
src/lib/components/controls/alert-dialog/alert-dialog-media.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="alert-dialog-media"
+	class={cn("bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 17 - 0
src/lib/components/controls/alert-dialog/alert-dialog-overlay.svelte

@@ -0,0 +1,17 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: AlertDialogPrimitive.OverlayProps = $props();
+</script>
+
+<AlertDialogPrimitive.Overlay
+	bind:ref
+	data-slot="alert-dialog-overlay"
+	class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
+	{...restProps}
+/>

+ 7 - 0
src/lib/components/controls/alert-dialog/alert-dialog-portal.svelte

@@ -0,0 +1,7 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+
+	let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
+</script>
+
+<AlertDialogPrimitive.Portal {...restProps} />

+ 17 - 0
src/lib/components/controls/alert-dialog/alert-dialog-title.svelte

@@ -0,0 +1,17 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: AlertDialogPrimitive.TitleProps = $props();
+</script>
+
+<AlertDialogPrimitive.Title
+	bind:ref
+	data-slot="alert-dialog-title"
+	class={cn("text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
+	{...restProps}
+/>

+ 7 - 0
src/lib/components/controls/alert-dialog/alert-dialog-trigger.svelte

@@ -0,0 +1,7 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+
+	let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
+</script>
+
+<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />

+ 7 - 0
src/lib/components/controls/alert-dialog/alert-dialog.svelte

@@ -0,0 +1,7 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+
+	let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
+</script>
+
+<AlertDialogPrimitive.Root bind:open {...restProps} />

+ 40 - 0
src/lib/components/controls/alert-dialog/index.ts

@@ -0,0 +1,40 @@
+import Root from "./alert-dialog.svelte";
+import Portal from "./alert-dialog-portal.svelte";
+import Trigger from "./alert-dialog-trigger.svelte";
+import Title from "./alert-dialog-title.svelte";
+import Action from "./alert-dialog-action.svelte";
+import Cancel from "./alert-dialog-cancel.svelte";
+import Footer from "./alert-dialog-footer.svelte";
+import Header from "./alert-dialog-header.svelte";
+import Overlay from "./alert-dialog-overlay.svelte";
+import Content from "./alert-dialog-content.svelte";
+import Description from "./alert-dialog-description.svelte";
+import Media from "./alert-dialog-media.svelte";
+
+export {
+	Root,
+	Title,
+	Action,
+	Cancel,
+	Portal,
+	Footer,
+	Header,
+	Trigger,
+	Overlay,
+	Content,
+	Description,
+	Media,
+	//
+	Root as AlertDialog,
+	Title as AlertDialogTitle,
+	Action as AlertDialogAction,
+	Cancel as AlertDialogCancel,
+	Portal as AlertDialogPortal,
+	Footer as AlertDialogFooter,
+	Header as AlertDialogHeader,
+	Trigger as AlertDialogTrigger,
+	Overlay as AlertDialogOverlay,
+	Content as AlertDialogContent,
+	Description as AlertDialogDescription,
+	Media as AlertDialogMedia,
+};

+ 20 - 0
src/lib/components/controls/alert/alert-action.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="alert-action"
+	class={cn("absolute top-2 right-2", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 23 - 0
src/lib/components/controls/alert/alert-description.svelte

@@ -0,0 +1,23 @@
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="alert-description"
+	class={cn(
+		"text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
+		className
+	)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 23 - 0
src/lib/components/controls/alert/alert-title.svelte

@@ -0,0 +1,23 @@
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="alert-title"
+	class={cn(
+		"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
+		className
+	)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 43 - 0
src/lib/components/controls/alert/alert.svelte

@@ -0,0 +1,43 @@
+<script lang="ts" module>
+	import { type VariantProps, tv } from "tailwind-variants";
+
+	export const alertVariants = tv({
+		base: "grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 group/alert relative w-full",
+		variants: {
+			variant: {
+				default: "bg-card text-card-foreground",
+				destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
+			},
+		},
+		defaultVariants: {
+			variant: "default",
+		},
+	});
+
+	export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
+</script>
+
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		variant = "default",
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
+		variant?: AlertVariant;
+	} = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="alert"
+	role="alert"
+	class={cn(alertVariants({ variant }), className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 17 - 0
src/lib/components/controls/alert/index.ts

@@ -0,0 +1,17 @@
+import Root from "./alert.svelte";
+import Description from "./alert-description.svelte";
+import Title from "./alert-title.svelte";
+import Action from "./alert-action.svelte";
+export { alertVariants, type AlertVariant } from "./alert.svelte";
+
+export {
+	Root,
+	Description,
+	Title,
+	Action,
+	//
+	Root as Alert,
+	Description as AlertDescription,
+	Title as AlertTitle,
+	Action as AlertAction,
+};

+ 49 - 0
src/lib/components/controls/badge/badge.svelte

@@ -0,0 +1,49 @@
+<script lang="ts" module>
+	import { type VariantProps, tv } from "tailwind-variants";
+
+	export const badgeVariants = tv({
+		base: "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none",
+		variants: {
+			variant: {
+				default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+				secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+				destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
+				outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+				ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+				link: "text-primary underline-offset-4 hover:underline",
+			},
+		},
+		defaultVariants: {
+			variant: "default",
+		},
+	});
+
+	export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
+</script>
+
+<script lang="ts">
+	import type { HTMLAnchorAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		href,
+		class: className,
+		variant = "default",
+		children,
+		...restProps
+	}: WithElementRef<HTMLAnchorAttributes> & {
+		variant?: BadgeVariant;
+	} = $props();
+</script>
+
+<svelte:element
+	this={href ? "a" : "span"}
+	bind:this={ref}
+	data-slot="badge"
+	{href}
+	class={cn(badgeVariants({ variant }), className)}
+	{...restProps}
+>
+	{@render children?.()}
+</svelte:element>

+ 2 - 0
src/lib/components/controls/badge/index.ts

@@ -0,0 +1,2 @@
+export { default as Badge } from "./badge.svelte";
+export { badgeVariants, type BadgeVariant } from "./badge.svelte";

+ 23 - 0
src/lib/components/controls/breadcrumb/breadcrumb-ellipsis.svelte

@@ -0,0 +1,23 @@
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
+	import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
+</script>
+
+<span
+	bind:this={ref}
+	data-slot="breadcrumb-ellipsis"
+	role="presentation"
+	aria-hidden="true"
+	class={cn("size-5 [&>svg]:size-4 flex items-center justify-center", className)}
+	{...restProps}
+>
+	<MoreHorizontalIcon  />
+	<span class="sr-only">More</span>
+</span>

+ 20 - 0
src/lib/components/controls/breadcrumb/breadcrumb-item.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import type { HTMLLiAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLLiAttributes> = $props();
+</script>
+
+<li
+	bind:this={ref}
+	data-slot="breadcrumb-item"
+	class={cn("gap-1 inline-flex items-center", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</li>

+ 31 - 0
src/lib/components/controls/breadcrumb/breadcrumb-link.svelte

@@ -0,0 +1,31 @@
+<script lang="ts">
+	import type { HTMLAnchorAttributes } from "svelte/elements";
+	import type { Snippet } from "svelte";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		href = undefined,
+		child,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAnchorAttributes> & {
+		child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
+	} = $props();
+
+	const attrs = $derived({
+		"data-slot": "breadcrumb-link",
+		class: cn("hover:text-foreground transition-colors", className),
+		href,
+		...restProps,
+	});
+</script>
+
+{#if child}
+	{@render child({ props: attrs })}
+{:else}
+	<a bind:this={ref} {...attrs}>
+		{@render children?.()}
+	</a>
+{/if}

+ 20 - 0
src/lib/components/controls/breadcrumb/breadcrumb-list.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import type { HTMLOlAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLOlAttributes> = $props();
+</script>
+
+<ol
+	bind:this={ref}
+	data-slot="breadcrumb-list"
+	class={cn("text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</ol>

+ 23 - 0
src/lib/components/controls/breadcrumb/breadcrumb-page.svelte

@@ -0,0 +1,23 @@
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
+</script>
+
+<span
+	bind:this={ref}
+	data-slot="breadcrumb-page"
+	role="link"
+	aria-disabled="true"
+	aria-current="page"
+	class={cn("text-foreground font-normal", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</span>

+ 27 - 0
src/lib/components/controls/breadcrumb/breadcrumb-separator.svelte

@@ -0,0 +1,27 @@
+<script lang="ts">
+	import { cn, type WithElementRef } from "$lib/utils.js";
+	import type { HTMLLiAttributes } from "svelte/elements";
+	import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLLiAttributes> = $props();
+</script>
+
+<li
+	bind:this={ref}
+	data-slot="breadcrumb-separator"
+	role="presentation"
+	aria-hidden="true"
+	class={cn("[&>svg]:size-3.5", className)}
+	{...restProps}
+>
+	{#if children}
+		{@render children?.()}
+	{:else}
+		<ChevronRightIcon  />
+	{/if}
+</li>

+ 22 - 0
src/lib/components/controls/breadcrumb/breadcrumb.svelte

@@ -0,0 +1,22 @@
+<script lang="ts">
+	import type { WithElementRef } from "$lib/utils.js";
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
+</script>
+
+<nav
+	bind:this={ref}
+	data-slot="breadcrumb"
+	aria-label="breadcrumb"
+	class={cn("cn-breadcrumb", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</nav>

+ 25 - 0
src/lib/components/controls/breadcrumb/index.ts

@@ -0,0 +1,25 @@
+import Root from "./breadcrumb.svelte";
+import Ellipsis from "./breadcrumb-ellipsis.svelte";
+import Item from "./breadcrumb-item.svelte";
+import Separator from "./breadcrumb-separator.svelte";
+import Link from "./breadcrumb-link.svelte";
+import List from "./breadcrumb-list.svelte";
+import Page from "./breadcrumb-page.svelte";
+
+export {
+	Root,
+	Ellipsis,
+	Item,
+	Separator,
+	Link,
+	List,
+	Page,
+	//
+	Root as Breadcrumb,
+	Ellipsis as BreadcrumbEllipsis,
+	Item as BreadcrumbItem,
+	Separator as BreadcrumbSeparator,
+	Link as BreadcrumbLink,
+	List as BreadcrumbList,
+	Page as BreadcrumbPage,
+};

+ 23 - 0
src/lib/components/controls/button-group/button-group-separator.svelte

@@ -0,0 +1,23 @@
+<script lang="ts">
+	import { cn } from "$lib/utils.js";
+	import type { ComponentProps } from "svelte";
+	import { Separator } from "$lib/components/controls/separator/index.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		orientation = "vertical",
+		...restProps
+	}: ComponentProps<typeof Separator> = $props();
+</script>
+
+<Separator
+	bind:ref
+	data-slot="button-group-separator"
+	{orientation}
+	class={cn(
+		"bg-input relative self-stretch data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto",
+		className
+	)}
+	{...restProps}
+/>

+ 28 - 0
src/lib/components/controls/button-group/button-group-text.svelte

@@ -0,0 +1,28 @@
+<script lang="ts">
+	import { cn, type WithElementRef } from "$lib/utils.js";
+	import type { HTMLAttributes } from "svelte/elements";
+	import type { Snippet } from "svelte";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		child,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
+		child?: Snippet<[{ props: Record<string, unknown> }]>;
+	} = $props();
+
+	const mergedProps = $derived({
+		...restProps,
+		class: cn("bg-muted gap-2 rounded-lg border px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none", className),
+		"data-slot": "button-group-text",
+	});
+</script>
+
+{#if child}
+	{@render child({ props: mergedProps })}
+{:else}
+	<div bind:this={ref} {...mergedProps}>
+		{@render mergedProps.children?.()}
+	</div>
+{/if}

+ 46 - 0
src/lib/components/controls/button-group/button-group.svelte

@@ -0,0 +1,46 @@
+<script lang="ts" module>
+	import { tv, type VariantProps } from "tailwind-variants";
+
+	export const buttonGroupVariants = tv({
+		base: "has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+		variants: {
+			orientation: {
+				horizontal:
+					"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
+				vertical:
+					"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
+			},
+		},
+		defaultVariants: {
+			orientation: "horizontal",
+		},
+	});
+
+	export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>["orientation"];
+</script>
+
+<script lang="ts">
+	import { cn, type WithElementRef } from "$lib/utils.js";
+	import type { HTMLAttributes } from "svelte/elements";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		orientation = "horizontal",
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
+		orientation?: ButtonGroupOrientation;
+	} = $props();
+</script>
+
+<div
+	bind:this={ref}
+	role="group"
+	data-slot="button-group"
+	data-orientation={orientation}
+	class={cn(buttonGroupVariants({ orientation }), className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 15 - 0
src/lib/components/controls/button-group/index.ts

@@ -0,0 +1,15 @@
+import Root, { buttonGroupVariants, type ButtonGroupOrientation } from "./button-group.svelte";
+import Text from "./button-group-text.svelte";
+import Separator from "./button-group-separator.svelte";
+
+export {
+	Root,
+	Text,
+	Separator,
+	buttonGroupVariants,
+	type ButtonGroupOrientation,
+	//
+	Root as ButtonGroup,
+	Text as ButtonGroupText,
+	Separator as ButtonGroupSeparator,
+};

+ 82 - 0
src/lib/components/controls/button/button.svelte

@@ -0,0 +1,82 @@
+<script lang="ts" module>
+	import { cn, type WithElementRef } from "$lib/utils.js";
+	import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
+	import { type VariantProps, tv } from "tailwind-variants";
+
+	export const buttonVariants = tv({
+		base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+		variants: {
+			variant: {
+				default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+				outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
+				secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+				ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
+				destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
+				link: "text-primary underline-offset-4 hover:underline",
+			},
+			size: {
+				default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+				xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+				sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+				lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+				icon: "size-8",
+				"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+				"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+				"icon-lg": "size-9",
+			},
+		},
+		defaultVariants: {
+			variant: "default",
+			size: "default",
+		},
+	});
+
+	export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
+	export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
+
+	export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
+		WithElementRef<HTMLAnchorAttributes> & {
+			variant?: ButtonVariant;
+			size?: ButtonSize;
+		};
+</script>
+
+<script lang="ts">
+	let {
+		class: className,
+		variant = "default",
+		size = "default",
+		ref = $bindable(null),
+		href = undefined,
+		type = "button",
+		disabled,
+		children,
+		...restProps
+	}: ButtonProps = $props();
+</script>
+
+{#if href}
+	<a
+		bind:this={ref}
+		data-slot="button"
+		class={cn(buttonVariants({ variant, size }), className)}
+		href={disabled ? undefined : href}
+		aria-disabled={disabled}
+		role={disabled ? "link" : undefined}
+		tabindex={disabled ? -1 : undefined}
+		{...restProps}
+	>
+		{@render children?.()}
+	</a>
+{:else}
+	<button
+		bind:this={ref}
+		data-slot="button"
+		class={cn(buttonVariants({ variant, size }), className)}
+		{type}
+		{disabled}
+		{...restProps}
+	>
+		{@render children?.()}
+	</button>
+{/if}

+ 17 - 0
src/lib/components/controls/button/index.ts

@@ -0,0 +1,17 @@
+import Root, {
+	type ButtonProps,
+	type ButtonSize,
+	type ButtonVariant,
+	buttonVariants,
+} from "./button.svelte";
+
+export {
+	Root,
+	type ButtonProps as Props,
+	//
+	Root as Button,
+	buttonVariants,
+	type ButtonProps,
+	type ButtonSize,
+	type ButtonVariant,
+};

+ 76 - 0
src/lib/components/controls/calendar/calendar-caption.svelte

@@ -0,0 +1,76 @@
+<script lang="ts">
+	import type { ComponentProps } from "svelte";
+	import type Calendar from "./calendar.svelte";
+	import CalendarMonthSelect from "./calendar-month-select.svelte";
+	import CalendarYearSelect from "./calendar-year-select.svelte";
+	import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
+
+	let {
+		captionLayout,
+		months,
+		monthFormat,
+		years,
+		yearFormat,
+		month,
+		locale,
+		placeholder = $bindable(),
+		monthIndex = 0,
+	}: {
+		captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
+		months: ComponentProps<typeof CalendarMonthSelect>["months"];
+		monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
+		years: ComponentProps<typeof CalendarYearSelect>["years"];
+		yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
+		month: DateValue;
+		placeholder: DateValue | undefined;
+		locale: string;
+		monthIndex: number;
+	} = $props();
+
+	function formatYear(date: DateValue) {
+		const dateObj = date.toDate(getLocalTimeZone());
+		if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
+		return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
+	}
+
+	function formatMonth(date: DateValue) {
+		const dateObj = date.toDate(getLocalTimeZone());
+		if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
+		return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
+	}
+</script>
+
+{#snippet MonthSelect()}
+	<CalendarMonthSelect
+		{months}
+		{monthFormat}
+		value={month.month}
+		onchange={(e) => {
+			if (!placeholder) return;
+			const v = Number.parseInt(e.currentTarget.value);
+			const newPlaceholder = placeholder.set({ month: v });
+			placeholder = newPlaceholder.subtract({ months: monthIndex });
+		}}
+	/>
+{/snippet}
+
+{#snippet YearSelect()}
+	<CalendarYearSelect {years} {yearFormat} value={month.year} />
+{/snippet}
+
+{#if captionLayout === "dropdown"}
+	{@render MonthSelect()}
+	{@render YearSelect()}
+{:else if captionLayout === "dropdown-months"}
+	{@render MonthSelect()}
+	{#if placeholder}
+		{formatYear(placeholder)}
+	{/if}
+{:else if captionLayout === "dropdown-years"}
+	{#if placeholder}
+		{formatMonth(placeholder)}
+	{/if}
+	{@render YearSelect()}
+{:else}
+	{formatMonth(month)} {formatYear(month)}
+{/if}

+ 19 - 0
src/lib/components/controls/calendar/calendar-cell.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: CalendarPrimitive.CellProps = $props();
+</script>
+
+<CalendarPrimitive.Cell
+	bind:ref
+	class={cn(
+		"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-(--cell-radius) [&:last-child[data-selected]_[data-bits-day]]:rounded-e-(--cell-radius)",
+		className
+	)}
+	{...restProps}
+/>

+ 33 - 0
src/lib/components/controls/calendar/calendar-day.svelte

@@ -0,0 +1,33 @@
+<script lang="ts">
+	import { cn } from "$lib/utils.js";
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: CalendarPrimitive.DayProps = $props();
+</script>
+
+<CalendarPrimitive.Day
+	bind:ref
+	class={cn(
+		"flex size-(--cell-size) flex-col items-center justify-center gap-1 rounded-(--cell-radius) p-0 leading-none font-normal whitespace-nowrap select-none",
+		"[&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
+		"not-data-selected:hover:bg-accent/50 not-data-selected:hover:text-accent-foreground",
+		"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
+		"data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:hover:text-foreground",
+		// Outside months
+		"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
+		// Disabled
+		"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+		// Unavailable
+		"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
+		// focus
+		"focus:border-ring focus:ring-ring/50 focus:relative",
+		// inner spans
+		"[&>span]:text-xs [&>span]:opacity-70",
+		className
+	)}
+	{...restProps}
+/>

+ 12 - 0
src/lib/components/controls/calendar/calendar-grid-body.svelte

@@ -0,0 +1,12 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: CalendarPrimitive.GridBodyProps = $props();
+</script>
+
+<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />

+ 12 - 0
src/lib/components/controls/calendar/calendar-grid-head.svelte

@@ -0,0 +1,12 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: CalendarPrimitive.GridHeadProps = $props();
+</script>
+
+<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />

+ 12 - 0
src/lib/components/controls/calendar/calendar-grid-row.svelte

@@ -0,0 +1,12 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: CalendarPrimitive.GridRowProps = $props();
+</script>
+
+<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />

+ 16 - 0
src/lib/components/controls/calendar/calendar-grid.svelte

@@ -0,0 +1,16 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: CalendarPrimitive.GridProps = $props();
+</script>
+
+<CalendarPrimitive.Grid
+	bind:ref
+	class={cn("flex w-full border-collapse flex-col", className)}
+	{...restProps}
+/>

+ 19 - 0
src/lib/components/controls/calendar/calendar-head-cell.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: CalendarPrimitive.HeadCellProps = $props();
+</script>
+
+<CalendarPrimitive.HeadCell
+	bind:ref
+	class={cn(
+		"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
+		className
+	)}
+	{...restProps}
+/>

+ 19 - 0
src/lib/components/controls/calendar/calendar-header.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: CalendarPrimitive.HeaderProps = $props();
+</script>
+
+<CalendarPrimitive.Header
+	bind:ref
+	class={cn(
+		"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
+		className
+	)}
+	{...restProps}
+/>

+ 16 - 0
src/lib/components/controls/calendar/calendar-heading.svelte

@@ -0,0 +1,16 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: CalendarPrimitive.HeadingProps = $props();
+</script>
+
+<CalendarPrimitive.Heading
+	bind:ref
+	class={cn("px-(--cell-size) text-sm font-medium", className)}
+	{...restProps}
+/>

+ 48 - 0
src/lib/components/controls/calendar/calendar-month-select.svelte

@@ -0,0 +1,48 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
+	import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		value,
+		onchange,
+		...restProps
+	}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
+</script>
+
+<span
+	class={cn(
+		"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
+		className
+	)}
+>
+	<CalendarPrimitive.MonthSelect
+		bind:ref
+		class="bg-background dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
+		{...restProps}
+	>
+		{#snippet child({ props, monthItems, selectedMonthItem })}
+			<select {...props} {value} {onchange}>
+				{#each monthItems as monthItem (monthItem.value)}
+					<option
+						value={monthItem.value}
+						selected={value !== undefined
+							? monthItem.value === value
+							: monthItem.value === selectedMonthItem.value}
+					>
+						{monthItem.label}
+					</option>
+				{/each}
+			</select>
+			<span
+				class="[&>svg]:text-muted-foreground flex h-(--cell-size) items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
+				aria-hidden="true"
+			>
+				{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
+				<ChevronDownIcon class="size-4" />
+			</span>
+		{/snippet}
+	</CalendarPrimitive.MonthSelect>
+</span>

+ 15 - 0
src/lib/components/controls/calendar/calendar-month.svelte

@@ -0,0 +1,15 @@
+<script lang="ts">
+	import { type WithElementRef, cn } from "$lib/utils.js";
+	import type { HTMLAttributes } from "svelte/elements";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
+</script>
+
+<div {...restProps} bind:this={ref} class={cn("flex w-full flex-col gap-4", className)}>
+	{@render children?.()}
+</div>

+ 19 - 0
src/lib/components/controls/calendar/calendar-months.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	class={cn("relative flex flex-col gap-4 md:flex-row", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 19 - 0
src/lib/components/controls/calendar/calendar-nav.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	import { cn, type WithElementRef } from "$lib/utils.js";
+	import type { HTMLAttributes } from "svelte/elements";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
+</script>
+
+<nav
+	{...restProps}
+	bind:this={ref}
+	class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
+>
+	{@render children?.()}
+</nav>

+ 36 - 0
src/lib/components/controls/calendar/calendar-next-button.svelte

@@ -0,0 +1,36 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
+	import { buttonVariants, type ButtonVariant } from "$lib/components/controls/button/index.js";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		variant = "ghost",
+		...restProps
+	}: CalendarPrimitive.NextButtonProps & {
+		variant?: ButtonVariant;
+	} = $props();
+</script>
+
+{#snippet Fallback()}
+	<ChevronRightIcon class="size-4" />
+{/snippet}
+
+<CalendarPrimitive.NextButton
+	bind:ref
+	class={cn(
+		buttonVariants({ variant }),
+		"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
+		className
+	)}
+	{...restProps}
+>
+	{#if children}
+		{@render children?.()}
+	{:else}
+		{@render Fallback()}
+	{/if}
+</CalendarPrimitive.NextButton>

+ 36 - 0
src/lib/components/controls/calendar/calendar-prev-button.svelte

@@ -0,0 +1,36 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
+	import { buttonVariants, type ButtonVariant } from "$lib/components/controls/button/index.js";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		variant = "ghost",
+		...restProps
+	}: CalendarPrimitive.PrevButtonProps & {
+		variant?: ButtonVariant;
+	} = $props();
+</script>
+
+{#snippet Fallback()}
+	<ChevronLeftIcon class="size-4" />
+{/snippet}
+
+<CalendarPrimitive.PrevButton
+	bind:ref
+	class={cn(
+		buttonVariants({ variant }),
+		"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
+		className
+	)}
+	{...restProps}
+>
+	{#if children}
+		{@render children?.()}
+	{:else}
+		{@render Fallback()}
+	{/if}
+</CalendarPrimitive.PrevButton>

+ 47 - 0
src/lib/components/controls/calendar/calendar-year-select.svelte

@@ -0,0 +1,47 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
+	import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		value,
+		...restProps
+	}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
+</script>
+
+<span
+	class={cn(
+		"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
+		className
+	)}
+>
+	<CalendarPrimitive.YearSelect
+		bind:ref
+		class="dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
+		{...restProps}
+	>
+		{#snippet child({ props, yearItems, selectedYearItem })}
+			<select {...props} {value}>
+				{#each yearItems as yearItem (yearItem.value)}
+					<option
+						value={yearItem.value}
+						selected={value !== undefined
+							? yearItem.value === value
+							: yearItem.value === selectedYearItem.value}
+					>
+						{yearItem.label}
+					</option>
+				{/each}
+			</select>
+			<span
+				class="[&>svg]:text-muted-foreground flex h-(--cell-size) items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
+				aria-hidden="true"
+			>
+				{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
+				<ChevronDownIcon class="size-4" />
+			</span>
+		{/snippet}
+	</CalendarPrimitive.YearSelect>
+</span>

+ 115 - 0
src/lib/components/controls/calendar/calendar.svelte

@@ -0,0 +1,115 @@
+<script lang="ts">
+	import { Calendar as CalendarPrimitive } from "bits-ui";
+	import * as Calendar from "./index.js";
+	import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
+	import type { ButtonVariant } from "../button/button.svelte";
+	import { isEqualMonth, type DateValue } from "@internationalized/date";
+	import type { Snippet } from "svelte";
+
+	let {
+		ref = $bindable(null),
+		value = $bindable(),
+		placeholder = $bindable(),
+		class: className,
+		weekdayFormat = "short",
+		buttonVariant = "ghost",
+		captionLayout = "label",
+		locale = "en-US",
+		months: monthsProp,
+		years,
+		monthFormat: monthFormatProp,
+		yearFormat = "numeric",
+		day,
+		disableDaysOutsideMonth = false,
+		...restProps
+	}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
+		buttonVariant?: ButtonVariant;
+		captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
+		months?: CalendarPrimitive.MonthSelectProps["months"];
+		years?: CalendarPrimitive.YearSelectProps["years"];
+		monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
+		yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
+		day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
+	} = $props();
+
+	const monthFormat = $derived.by(() => {
+		if (monthFormatProp) return monthFormatProp;
+		if (captionLayout.startsWith("dropdown")) return "short";
+		return "long";
+	});
+</script>
+
+<!--
+Discriminated Unions + Destructing (required for bindable) do not
+get along, so we shut typescript up by casting `value` to `never`.
+-->
+<CalendarPrimitive.Root
+	bind:value={value as never}
+	bind:ref
+	bind:placeholder
+	{weekdayFormat}
+	{disableDaysOutsideMonth}
+	class={cn(
+		"p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] bg-background group/calendar in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
+		className
+	)}
+	{locale}
+	{monthFormat}
+	{yearFormat}
+	{...restProps}
+>
+	{#snippet children({ months, weekdays })}
+		<Calendar.Months>
+			<Calendar.Nav>
+				<Calendar.PrevButton variant={buttonVariant} />
+				<Calendar.NextButton variant={buttonVariant} />
+			</Calendar.Nav>
+			{#each months as month, monthIndex (month)}
+				<Calendar.Month>
+					<Calendar.Header>
+						<Calendar.Caption
+							{captionLayout}
+							months={monthsProp}
+							{monthFormat}
+							{years}
+							{yearFormat}
+							month={month.value}
+							bind:placeholder
+							{locale}
+							{monthIndex}
+						/>
+					</Calendar.Header>
+					<Calendar.Grid>
+						<Calendar.GridHead>
+							<Calendar.GridRow class="select-none">
+								{#each weekdays as weekday (weekday)}
+									<Calendar.HeadCell>
+										{weekday.slice(0, 2)}
+									</Calendar.HeadCell>
+								{/each}
+							</Calendar.GridRow>
+						</Calendar.GridHead>
+						<Calendar.GridBody>
+							{#each month.weeks as weekDates (weekDates)}
+								<Calendar.GridRow class="mt-2 w-full">
+									{#each weekDates as date (date)}
+										<Calendar.Cell {date} month={month.value}>
+											{#if day}
+												{@render day({
+													day: date,
+													outsideMonth: !isEqualMonth(date, month.value),
+												})}
+											{:else}
+												<Calendar.Day />
+											{/if}
+										</Calendar.Cell>
+									{/each}
+								</Calendar.GridRow>
+							{/each}
+						</Calendar.GridBody>
+					</Calendar.Grid>
+				</Calendar.Month>
+			{/each}
+		</Calendar.Months>
+	{/snippet}
+</CalendarPrimitive.Root>

+ 40 - 0
src/lib/components/controls/calendar/index.ts

@@ -0,0 +1,40 @@
+import Root from "./calendar.svelte";
+import Cell from "./calendar-cell.svelte";
+import Day from "./calendar-day.svelte";
+import Grid from "./calendar-grid.svelte";
+import Header from "./calendar-header.svelte";
+import Months from "./calendar-months.svelte";
+import GridRow from "./calendar-grid-row.svelte";
+import Heading from "./calendar-heading.svelte";
+import GridBody from "./calendar-grid-body.svelte";
+import GridHead from "./calendar-grid-head.svelte";
+import HeadCell from "./calendar-head-cell.svelte";
+import NextButton from "./calendar-next-button.svelte";
+import PrevButton from "./calendar-prev-button.svelte";
+import MonthSelect from "./calendar-month-select.svelte";
+import YearSelect from "./calendar-year-select.svelte";
+import Month from "./calendar-month.svelte";
+import Nav from "./calendar-nav.svelte";
+import Caption from "./calendar-caption.svelte";
+
+export {
+	Day,
+	Cell,
+	Grid,
+	Header,
+	Months,
+	GridRow,
+	Heading,
+	GridBody,
+	GridHead,
+	HeadCell,
+	NextButton,
+	PrevButton,
+	Nav,
+	Month,
+	YearSelect,
+	MonthSelect,
+	Caption,
+	//
+	Root as Calendar,
+};

+ 23 - 0
src/lib/components/controls/card/card-action.svelte

@@ -0,0 +1,23 @@
+<script lang="ts">
+	import { cn, type WithElementRef } from "$lib/utils.js";
+	import type { HTMLAttributes } from "svelte/elements";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="card-action"
+	class={cn(
+		"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+		className
+	)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 20 - 0
src/lib/components/controls/card/card-content.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="card-content"
+	class={cn("px-4 group-data-[size=sm]/card:px-3", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 20 - 0
src/lib/components/controls/card/card-description.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
+</script>
+
+<p
+	bind:this={ref}
+	data-slot="card-description"
+	class={cn("text-muted-foreground text-sm", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</p>

+ 20 - 0
src/lib/components/controls/card/card-footer.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import { cn, type WithElementRef } from "$lib/utils.js";
+	import type { HTMLAttributes } from "svelte/elements";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="card-footer"
+	class={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 23 - 0
src/lib/components/controls/card/card-header.svelte

@@ -0,0 +1,23 @@
+<script lang="ts">
+	import { cn, type WithElementRef } from "$lib/utils.js";
+	import type { HTMLAttributes } from "svelte/elements";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="card-header"
+	class={cn(
+		"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
+		className
+	)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 20 - 0
src/lib/components/controls/card/card-title.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="card-title"
+	class={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 22 - 0
src/lib/components/controls/card/card.svelte

@@ -0,0 +1,22 @@
+<script lang="ts">
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		size = "default",
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="card"
+	data-size={size}
+	class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 25 - 0
src/lib/components/controls/card/index.ts

@@ -0,0 +1,25 @@
+import Root from "./card.svelte";
+import Content from "./card-content.svelte";
+import Description from "./card-description.svelte";
+import Footer from "./card-footer.svelte";
+import Header from "./card-header.svelte";
+import Title from "./card-title.svelte";
+import Action from "./card-action.svelte";
+
+export {
+	Root,
+	Content,
+	Description,
+	Footer,
+	Header,
+	Title,
+	Action,
+	//
+	Root as Card,
+	Content as CardContent,
+	Description as CardDescription,
+	Footer as CardFooter,
+	Header as CardHeader,
+	Title as CardTitle,
+	Action as CardAction,
+};

+ 80 - 0
src/lib/components/controls/chart/chart-container.svelte

@@ -0,0 +1,80 @@
+<script lang="ts">
+	import { cn, type WithElementRef } from "$lib/utils.js";
+	import type { HTMLAttributes } from "svelte/elements";
+	import ChartStyle from "./chart-style.svelte";
+	import { setChartContext, type ChartConfig } from "./chart-utils.js";
+
+	const uid = $props.id();
+
+	let {
+		ref = $bindable(null),
+		id = uid,
+		class: className,
+		children,
+		config,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLElement>> & {
+		config: ChartConfig;
+	} = $props();
+
+	const chartId = $derived(`chart-${id || uid.replace(/:/g, "")}`);
+
+	setChartContext({
+		get config() {
+			return config;
+		},
+	});
+</script>
+
+<div
+	bind:this={ref}
+	data-chart={chartId}
+	data-slot="chart"
+	class={cn(
+		"flex aspect-video justify-center overflow-visible text-xs",
+		// Overrides
+		//
+		// Stroke around dots/marks when hovering
+		"[&_.lc-highlight-point]:stroke-transparent",
+		// override the default stroke color of lines
+		"[&_.lc-line]:stroke-border/50",
+
+		// by default, layerchart shows a line intersecting the point when hovering, this hides that
+		"[&_.lc-highlight-line]:stroke-0",
+
+		// by default, when you hover a point on a stacked series chart, it will drop the opacity
+		// of the other series, this overrides that
+		"[&_.lc-area-path]:opacity-100 [&_.lc-highlight-line]:opacity-100 [&_.lc-highlight-point]:opacity-100 [&_.lc-spline-path]:opacity-100 [&_.lc-text]:text-xs [&_.lc-text-svg]:overflow-visible",
+
+		// We don't want the little tick lines between the axis labels and the chart, so we remove
+		// the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every
+		// chart.
+		"[&_.lc-axis-tick]:stroke-0",
+
+		// We don't want to display the rule on the x/y axis, as there is already going to be
+		// a grid line there and rule ends up overlapping the marks because it is rendered after
+		// the marks
+		"[&_.lc-rule-x-line:not(.lc-grid-x-rule)]:stroke-0 [&_.lc-rule-y-line:not(.lc-grid-y-rule)]:stroke-0",
+		"[&_.lc-grid-x-radial-line]:stroke-border [&_.lc-grid-x-radial-circle]:stroke-border",
+		"[&_.lc-grid-y-radial-line]:stroke-border [&_.lc-grid-y-radial-circle]:stroke-border",
+
+		// Legend adjustments
+		"[&_.lc-legend-swatch-button]:items-center [&_.lc-legend-swatch-button]:gap-1.5",
+		"[&_.lc-legend-swatch-group]:items-center [&_.lc-legend-swatch-group]:gap-4",
+		"[&_.lc-legend-swatch]:size-2.5 [&_.lc-legend-swatch]:rounded-[2px]",
+
+		// Labels
+		"[&_.lc-labels-text:not([fill])]:fill-foreground [&_text]:stroke-transparent",
+
+		// Tick labels on th x/y axes
+		"[&_.lc-axis-tick-label]:fill-muted-foreground [&_.lc-axis-tick-label]:font-normal",
+		"[&_.lc-tooltip-rects-g]:fill-transparent",
+		"[&_.lc-layout-svg-g]:fill-transparent",
+		"[&_.lc-root-container]:w-full",
+		className
+	)}
+	{...restProps}
+>
+	<ChartStyle id={chartId} {config} />
+	{@render children?.()}
+</div>

+ 37 - 0
src/lib/components/controls/chart/chart-style.svelte

@@ -0,0 +1,37 @@
+<script lang="ts">
+	import { THEMES, type ChartConfig } from "./chart-utils.js";
+
+	let { id, config }: { id: string; config: ChartConfig } = $props();
+
+	const colorConfig = $derived(
+		config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null
+	);
+
+	const themeContents = $derived.by(() => {
+		if (!colorConfig || !colorConfig.length) return;
+
+		const themeContents = [];
+		for (const [_theme, prefix] of Object.entries(THEMES)) {
+			let content = `${prefix} [data-chart=${id}] {\n`;
+			const color = colorConfig.map(([key, itemConfig]) => {
+				const theme = _theme as keyof typeof itemConfig.theme;
+				const color = itemConfig.theme?.[theme] || itemConfig.color;
+				return color ? `\t--color-${key}: ${color};` : null;
+			});
+
+			content += color.join("\n") + "\n}";
+
+			themeContents.push(content);
+		}
+
+		return themeContents.join("\n");
+	});
+</script>
+
+{#if themeContents}
+	{#key id}
+		<svelte:element this={"style"}>
+			{themeContents}
+		</svelte:element>
+	{/key}
+{/if}

+ 184 - 0
src/lib/components/controls/chart/chart-tooltip.svelte

@@ -0,0 +1,184 @@
+<script lang="ts">
+	import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
+	import type { HTMLAttributes } from "svelte/elements";
+	import { getPayloadConfigFromPayload, useChart, type TooltipPayload } from "./chart-utils.js";
+	import { getChartContext, Tooltip as TooltipPrimitive } from "layerchart";
+	import type { Snippet } from "svelte";
+
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	function defaultFormatter(value: any, _payload: TooltipPayload[]) {
+		return `${value}`;
+	}
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		hideLabel = false,
+		indicator = "dot",
+		hideIndicator = false,
+		labelKey,
+		label,
+		labelFormatter = defaultFormatter,
+		labelClassName,
+		formatter,
+		nameKey,
+		color,
+		...restProps
+	}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> & {
+		hideLabel?: boolean;
+		label?: string;
+		indicator?: "line" | "dot" | "dashed";
+		nameKey?: string;
+		labelKey?: string;
+		hideIndicator?: boolean;
+		labelClassName?: string;
+		labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any
+			((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null;
+		formatter?: Snippet<
+			[
+				{
+					value: unknown;
+					name: string;
+					item: TooltipPayload;
+					index: number;
+					payload: TooltipPayload[];
+				},
+			]
+		>;
+	} = $props();
+
+	const chart = useChart();
+	const chartCtx = getChartContext();
+
+	// Filter to series with defined values (important for item-based charts like Pie/Arc
+	// where only the hovered item has a value)
+	const visibleSeries = $derived(
+		chartCtx.tooltip.series.filter((s: TooltipPayload) => s.value !== undefined)
+	);
+
+	const formattedLabel = $derived.by(() => {
+		if (hideLabel || !visibleSeries?.length) return null;
+
+		const [item] = visibleSeries;
+		const tooltipData = chartCtx.tooltip.data;
+
+		// Get the x-axis label value from the raw tooltip data (e.g. a Date or month string)
+		const dataLabel = tooltipData != null ? chartCtx.x(tooltipData) : undefined;
+
+		const key = labelKey ?? item?.label ?? item?.key ?? "value";
+		const itemConfig = getPayloadConfigFromPayload(
+			chart.config,
+			item,
+			key,
+			tooltipData as Record<string, unknown> | null
+		);
+
+		let value: unknown;
+		if (!labelKey && typeof label === "string") {
+			value = chart.config[label as keyof typeof chart.config]?.label ?? label;
+		} else if (labelKey) {
+			value = itemConfig?.label ?? dataLabel;
+		} else {
+			value = dataLabel;
+		}
+
+		if (value === undefined) return null;
+		if (!labelFormatter) return value;
+		return labelFormatter(value, visibleSeries);
+	});
+
+	const nestLabel = $derived(visibleSeries.length === 1 && indicator !== "dot");
+</script>
+
+{#snippet TooltipLabel()}
+	{#if formattedLabel}
+		<div class={cn("font-medium", labelClassName)}>
+			{#if typeof formattedLabel === "function"}
+				{@render formattedLabel()}
+			{:else}
+				{formattedLabel}
+			{/if}
+		</div>
+	{/if}
+{/snippet}
+
+<TooltipPrimitive.Root variant="none">
+	<div
+		bind:this={ref}
+		class={cn(
+			"border-border/50 bg-background grid min-w-[9rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
+			className
+		)}
+		{...restProps}
+	>
+		{#if !nestLabel}
+			{@render TooltipLabel()}
+		{/if}
+		<div class="grid gap-1.5">
+			{#each visibleSeries as item, i (item.key + i)}
+				{@const key = `${nameKey || item.key || item.label || "value"}`}
+				{@const itemConfig = getPayloadConfigFromPayload(
+					chart.config,
+					item,
+					key,
+					chartCtx.tooltip.data
+				)}
+				{@const indicatorColor = color || item.config?.color || item.color}
+				<div
+					class={cn(
+						"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5",
+						indicator === "dot" && "items-center"
+					)}
+				>
+					{#if formatter && item.value !== undefined && item.label}
+						{@render formatter({
+							value: item.value,
+							name: item.label,
+							item,
+							index: i,
+							payload: visibleSeries,
+						})}
+					{:else}
+						{#if itemConfig?.icon}
+							<itemConfig.icon />
+						{:else if !hideIndicator}
+							<div
+								style="--color-bg: {indicatorColor}; --color-border: {indicatorColor};"
+								class={cn(
+									"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
+									{
+										"size-2.5": indicator === "dot",
+										"h-full w-1": indicator === "line",
+										"w-0 border-[1.5px] border-dashed bg-transparent":
+											indicator === "dashed",
+										"my-0.5": nestLabel && indicator === "dashed",
+									}
+								)}
+							></div>
+						{/if}
+						<div
+							class={cn(
+								"flex flex-1 shrink-0 justify-between leading-none",
+								nestLabel ? "items-end" : "items-center"
+							)}
+						>
+							<div class="grid gap-1.5">
+								{#if nestLabel}
+									{@render TooltipLabel()}
+								{/if}
+								<span class="text-muted-foreground">
+									{itemConfig?.label || item.label}
+								</span>
+							</div>
+							{#if item.value !== undefined}
+								<span class="text-foreground font-mono font-medium tabular-nums">
+									{item.value.toLocaleString()}
+								</span>
+							{/if}
+						</div>
+					{/if}
+				</div>
+			{/each}
+		</div>
+	</div>
+</TooltipPrimitive.Root>

+ 68 - 0
src/lib/components/controls/chart/chart-utils.ts

@@ -0,0 +1,68 @@
+import type { Tooltip } from "layerchart";
+import { getContext, setContext, type Component, type Snippet } from "svelte";
+
+export const THEMES = { light: "", dark: ".dark" } as const;
+
+export type ChartConfig = {
+	[k in string]: {
+		label?: string;
+		icon?: Component;
+	} & (
+		| { color?: string; theme?: never }
+		| { color?: never; theme: Record<keyof typeof THEMES, string> }
+	);
+};
+
+export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never;
+
+export type TooltipPayload = Tooltip.TooltipSeries;
+
+// Helper to extract item config from a payload.
+export function getPayloadConfigFromPayload(
+	config: ChartConfig,
+	payload: TooltipPayload,
+	key: string,
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	data?: Record<string, any> | null
+) {
+	if (typeof payload !== "object" || payload === null) return undefined;
+
+	const payloadConfig =
+		"config" in payload && typeof payload.config === "object" && payload.config !== null
+			? payload.config
+			: undefined;
+
+	let configLabelKey: string = key;
+
+	if (payload.key === key) {
+		configLabelKey = payload.key;
+	} else if (payload.label === key) {
+		configLabelKey = payload.label;
+	} else if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
+		configLabelKey = payload[key as keyof typeof payload] as string;
+	} else if (
+		payloadConfig !== undefined &&
+		key in payloadConfig &&
+		typeof payloadConfig[key as keyof typeof payloadConfig] === "string"
+	) {
+		configLabelKey = payloadConfig[key as keyof typeof payloadConfig] as string;
+	} else if (data != null && key in data && typeof data[key] === "string") {
+		configLabelKey = data[key] as string;
+	}
+
+	return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
+}
+
+type ChartContextValue = {
+	config: ChartConfig;
+};
+
+const chartContextKey = Symbol("chart-context");
+
+export function setChartContext(value: ChartContextValue) {
+	return setContext(chartContextKey, value);
+}
+
+export function useChart() {
+	return getContext<ChartContextValue>(chartContextKey);
+}

+ 6 - 0
src/lib/components/controls/chart/index.ts

@@ -0,0 +1,6 @@
+import ChartContainer from "./chart-container.svelte";
+import ChartTooltip from "./chart-tooltip.svelte";
+
+export { getPayloadConfigFromPayload, type ChartConfig } from "./chart-utils.js";
+
+export { ChartContainer, ChartTooltip, ChartContainer as Container, ChartTooltip as Tooltip };

+ 39 - 0
src/lib/components/controls/checkbox/checkbox.svelte

@@ -0,0 +1,39 @@
+<script lang="ts">
+	import { Checkbox as CheckboxPrimitive } from "bits-ui";
+	import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
+	import CheckIcon from '@lucide/svelte/icons/check';
+	import MinusIcon from '@lucide/svelte/icons/minus';
+
+	let {
+		ref = $bindable(null),
+		checked = $bindable(false),
+		indeterminate = $bindable(false),
+		class: className,
+		...restProps
+	}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
+</script>
+
+<CheckboxPrimitive.Root
+	bind:ref
+	data-slot="checkbox"
+	class={cn(
+		"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
+		className
+	)}
+	bind:checked
+	bind:indeterminate
+	{...restProps}
+>
+	{#snippet children({ checked, indeterminate })}
+		<div
+			data-slot="checkbox-indicator"
+			class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
+		>
+			{#if checked}
+				<CheckIcon  />
+			{:else if indeterminate}
+				<MinusIcon  />
+			{/if}
+		</div>
+	{/snippet}
+</CheckboxPrimitive.Root>

+ 6 - 0
src/lib/components/controls/checkbox/index.ts

@@ -0,0 +1,6 @@
+import Root from "./checkbox.svelte";
+export {
+	Root,
+	//
+	Root as Checkbox,
+};

+ 7 - 0
src/lib/components/controls/collapsible/collapsible-content.svelte

@@ -0,0 +1,7 @@
+<script lang="ts">
+	import { Collapsible as CollapsiblePrimitive } from "bits-ui";
+
+	let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
+</script>
+
+<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />

+ 7 - 0
src/lib/components/controls/collapsible/collapsible-trigger.svelte

@@ -0,0 +1,7 @@
+<script lang="ts">
+	import { Collapsible as CollapsiblePrimitive } from "bits-ui";
+
+	let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
+</script>
+
+<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />

+ 11 - 0
src/lib/components/controls/collapsible/collapsible.svelte

@@ -0,0 +1,11 @@
+<script lang="ts">
+	import { Collapsible as CollapsiblePrimitive } from "bits-ui";
+
+	let {
+		ref = $bindable(null),
+		open = $bindable(false),
+		...restProps
+	}: CollapsiblePrimitive.RootProps = $props();
+</script>
+
+<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />

+ 13 - 0
src/lib/components/controls/collapsible/index.ts

@@ -0,0 +1,13 @@
+import Root from "./collapsible.svelte";
+import Trigger from "./collapsible-trigger.svelte";
+import Content from "./collapsible-content.svelte";
+
+export {
+	Root,
+	Content,
+	Trigger,
+	//
+	Root as Collapsible,
+	Content as CollapsibleContent,
+	Trigger as CollapsibleTrigger,
+};

+ 42 - 0
src/lib/components/controls/command/command-dialog.svelte

@@ -0,0 +1,42 @@
+<script lang="ts">
+	import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from "bits-ui";
+	import type { Snippet } from "svelte";
+	import Command from "./command.svelte";
+	import * as Dialog from "$lib/components/controls/dialog/index.js";
+	import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
+
+	let {
+		open = $bindable(false),
+		ref = $bindable(null),
+		value = $bindable(""),
+		title = "Command Palette",
+		description = "Search for a command to run...",
+		showCloseButton = false,
+		portalProps,
+		children,
+		class: className,
+		...restProps
+	}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
+		WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
+			portalProps?: DialogPrimitive.PortalProps;
+			children: Snippet;
+			title?: string;
+			description?: string;
+			showCloseButton?: boolean;
+			class?: string;
+		} = $props();
+</script>
+
+<Dialog.Root bind:open {...restProps}>
+	<Dialog.Header class="sr-only">
+		<Dialog.Title>{title}</Dialog.Title>
+		<Dialog.Description>{description}</Dialog.Description>
+	</Dialog.Header>
+	<Dialog.Content
+		class={cn("rounded-xl! top-1/3 translate-y-0 overflow-hidden p-0", className)}
+		{showCloseButton}
+		{portalProps}
+	>
+		<Command {...restProps} bind:value bind:ref {children} />
+	</Dialog.Content>
+</Dialog.Root>

+ 17 - 0
src/lib/components/controls/command/command-empty.svelte

@@ -0,0 +1,17 @@
+<script lang="ts">
+	import { Command as CommandPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: CommandPrimitive.EmptyProps = $props();
+</script>
+
+<CommandPrimitive.Empty
+	bind:ref
+	data-slot="command-empty"
+	class={cn("py-6 text-center text-sm", className)}
+	{...restProps}
+/>

+ 32 - 0
src/lib/components/controls/command/command-group.svelte

@@ -0,0 +1,32 @@
+<script lang="ts">
+	import { Command as CommandPrimitive, useId } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		heading,
+		value,
+		...restProps
+	}: CommandPrimitive.GroupProps & {
+		heading?: string;
+	} = $props();
+</script>
+
+<CommandPrimitive.Group
+	bind:ref
+	data-slot="command-group"
+	class={cn("text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium", className)}
+	value={value ?? heading ?? `----${useId()}`}
+	{...restProps}
+>
+	{#if heading}
+		<CommandPrimitive.GroupHeading
+			class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
+		>
+			{heading}
+		</CommandPrimitive.GroupHeading>
+	{/if}
+	<CommandPrimitive.GroupItems {children} />
+</CommandPrimitive.Group>

+ 31 - 0
src/lib/components/controls/command/command-input.svelte

@@ -0,0 +1,31 @@
+<script lang="ts">
+	import { Command as CommandPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+	import * as InputGroup from "$lib/components/controls/input-group/index.js";
+	import SearchIcon from '@lucide/svelte/icons/search';
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		value = $bindable(""),
+		...restProps
+	}: CommandPrimitive.InputProps = $props();
+</script>
+
+<div data-slot="command-input-wrapper" class="p-1 pb-0">
+	<InputGroup.Root class="bg-input/30 border-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!">
+		<CommandPrimitive.Input
+			data-slot="command-input"
+			class={cn(
+				"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
+				className
+			)}
+			bind:ref
+			{...restProps}
+			bind:value
+		/>
+		<InputGroup.Addon>
+			<SearchIcon class="size-4 shrink-0 opacity-50" />
+		</InputGroup.Addon>
+	</InputGroup.Root>
+</div>

Деякі файли не було показано, через те що забагато файлів було змінено