@intelligo-dev/cli 1.0.0-beta.1 → 1.0.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +102 -0
  3. package/dist/bin.js +122 -37
  4. package/dist/bin.js.map +1 -1
  5. package/dist/commands/add.d.ts +26 -1
  6. package/dist/commands/add.d.ts.map +1 -1
  7. package/dist/commands/add.js +70 -4
  8. package/dist/commands/add.js.map +1 -1
  9. package/dist/commands/create-flow.d.ts +37 -0
  10. package/dist/commands/create-flow.d.ts.map +1 -0
  11. package/dist/commands/create-flow.js +212 -0
  12. package/dist/commands/create-flow.js.map +1 -0
  13. package/dist/commands/create.d.ts +27 -6
  14. package/dist/commands/create.d.ts.map +1 -1
  15. package/dist/commands/create.js +53 -13
  16. package/dist/commands/create.js.map +1 -1
  17. package/dist/commands/doctor.d.ts +14 -3
  18. package/dist/commands/doctor.d.ts.map +1 -1
  19. package/dist/commands/doctor.js +295 -37
  20. package/dist/commands/doctor.js.map +1 -1
  21. package/dist/commands/migrate-check.d.ts +73 -1
  22. package/dist/commands/migrate-check.d.ts.map +1 -1
  23. package/dist/commands/migrate-check.js +130 -5
  24. package/dist/commands/migrate-check.js.map +1 -1
  25. package/dist/commands/migrate.d.ts +97 -0
  26. package/dist/commands/migrate.d.ts.map +1 -0
  27. package/dist/commands/migrate.js +141 -0
  28. package/dist/commands/migrate.js.map +1 -0
  29. package/dist/commands/sync-messages.d.ts +37 -0
  30. package/dist/commands/sync-messages.d.ts.map +1 -0
  31. package/dist/commands/sync-messages.js +88 -0
  32. package/dist/commands/sync-messages.js.map +1 -0
  33. package/dist/commands/sync-scaffold.d.ts +36 -0
  34. package/dist/commands/sync-scaffold.d.ts.map +1 -0
  35. package/dist/commands/sync-scaffold.js +53 -0
  36. package/dist/commands/sync-scaffold.js.map +1 -0
  37. package/dist/commands/sync.d.ts +103 -0
  38. package/dist/commands/sync.d.ts.map +1 -0
  39. package/dist/commands/sync.js +361 -0
  40. package/dist/commands/sync.js.map +1 -0
  41. package/dist/commands/upgrade-check.d.ts +5 -5
  42. package/dist/commands/upgrade-check.d.ts.map +1 -1
  43. package/dist/commands/upgrade-check.js +22 -6
  44. package/dist/commands/upgrade-check.js.map +1 -1
  45. package/dist/env-files.d.ts +12 -0
  46. package/dist/env-files.d.ts.map +1 -0
  47. package/dist/env-files.js +29 -0
  48. package/dist/env-files.js.map +1 -0
  49. package/dist/index.d.ts +9 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +9 -0
  52. package/dist/index.js.map +1 -1
  53. package/dist/manifest.d.ts +36 -15
  54. package/dist/manifest.d.ts.map +1 -1
  55. package/dist/manifest.js +32 -16
  56. package/dist/manifest.js.map +1 -1
  57. package/dist/migrations.d.ts +3 -6
  58. package/dist/migrations.d.ts.map +1 -1
  59. package/dist/migrations.js +3 -6
  60. package/dist/migrations.js.map +1 -1
  61. package/dist/model-catalogue.d.ts +14 -0
  62. package/dist/model-catalogue.d.ts.map +1 -0
  63. package/dist/model-catalogue.js +34 -0
  64. package/dist/model-catalogue.js.map +1 -0
  65. package/dist/module-exports.d.ts +63 -0
  66. package/dist/module-exports.d.ts.map +1 -0
  67. package/dist/module-exports.js +231 -0
  68. package/dist/module-exports.js.map +1 -0
  69. package/dist/registry-bundle.d.ts +43 -0
  70. package/dist/registry-bundle.d.ts.map +1 -0
  71. package/dist/registry-bundle.js +74 -0
  72. package/dist/registry-bundle.js.map +1 -0
  73. package/dist/registry-items.d.ts +84 -0
  74. package/dist/registry-items.d.ts.map +1 -0
  75. package/dist/registry-items.js +187 -0
  76. package/dist/registry-items.js.map +1 -0
  77. package/package.json +36 -10
  78. package/src/bin.ts +316 -0
  79. package/src/commands/add.ts +255 -0
  80. package/src/commands/create-flow.ts +284 -0
  81. package/src/commands/create.ts +149 -0
  82. package/src/commands/doctor.ts +487 -0
  83. package/src/commands/migrate-check.ts +298 -0
  84. package/src/commands/migrate.ts +228 -0
  85. package/src/commands/sync-messages.ts +114 -0
  86. package/src/commands/sync-scaffold.ts +83 -0
  87. package/src/commands/sync.ts +534 -0
  88. package/src/commands/upgrade-check.ts +160 -0
  89. package/src/env-files.ts +31 -0
  90. package/src/index.ts +39 -0
  91. package/src/manifest.ts +147 -0
  92. package/src/migrations-dir.ts +25 -0
  93. package/src/migrations.ts +133 -0
  94. package/src/model-catalogue.ts +35 -0
  95. package/src/module-exports.ts +266 -0
  96. package/src/registry-bundle.ts +103 -0
  97. package/src/registry-items.ts +237 -0
  98. package/templates/admin-page/admin-page.tsx.tpl +2 -3
  99. package/templates/app-scaffold/assistant-route.ts.tpl +2 -2
  100. package/templates/app-scaffold/auth-route.ts.tpl +10 -0
  101. package/templates/app-scaffold/components.json.tpl +6 -2
  102. package/templates/app-scaffold/db-schema.ts.tpl +29 -0
  103. package/templates/app-scaffold/drizzle-journal.json.tpl +5 -0
  104. package/templates/app-scaffold/drizzle.config.ts.tpl +45 -0
  105. package/templates/app-scaffold/env.example.tpl +65 -1
  106. package/templates/app-scaffold/globals.css.tpl +172 -281
  107. package/templates/app-scaffold/i18n-request.ts.tpl +25 -0
  108. package/templates/app-scaffold/instrumentation.ts.tpl +21 -0
  109. package/templates/app-scaffold/intelligo.ts.tpl +95 -41
  110. package/templates/app-scaffold/layout.tsx.tpl +18 -1
  111. package/templates/app-scaffold/lib-utils.ts.tpl +1 -6
  112. package/templates/app-scaffold/next.config.mjs.tpl +2 -0
  113. package/templates/app-scaffold/package.json.tpl +31 -17
  114. package/templates/app-scaffold/page.tsx.tpl +16 -0
  115. package/templates/app-scaffold/plans.ts.tpl +32 -13
  116. package/templates/app-scaffold/{middleware.ts.tpl → proxy.ts.tpl} +6 -0
  117. package/templates/app-scaffold/sonner.tsx.tpl +42 -20
  118. package/templates/app-scaffold/stripe-webhook-route.ts.tpl +24 -0
  119. package/templates/app-scaffold/theme-provider.tsx.tpl +6 -4
  120. package/templates/app-scaffold/tsconfig.json.tpl +20 -4
  121. package/templates/app-scaffold/use-mobile.ts.tpl +19 -0
  122. package/templates/app-scaffold/workspace-bootstrap.ts.tpl +30 -0
  123. package/templates/maintenance/maintenance-route.ts.tpl +119 -0
  124. package/templates/manifest.json +75 -17
  125. package/templates/registry/ai-agent-activity.json +23 -0
  126. package/templates/registry/ai-agent-progress.json +21 -0
  127. package/templates/registry/ai-approval-card.json +27 -0
  128. package/templates/registry/ai-artifact.json +22 -0
  129. package/templates/registry/ai-branch.json +21 -0
  130. package/templates/registry/ai-citations.json +24 -0
  131. package/templates/registry/ai-code-block.json +23 -0
  132. package/templates/registry/ai-composer-menu.json +19 -0
  133. package/templates/registry/ai-file-diff.json +25 -0
  134. package/templates/registry/ai-image-generation.json +23 -0
  135. package/templates/registry/ai-markdown.json +23 -0
  136. package/templates/registry/ai-message-bubble.json +23 -0
  137. package/templates/registry/ai-message-scroller.json +22 -0
  138. package/templates/registry/ai-message.json +21 -0
  139. package/templates/registry/ai-motion.json +19 -0
  140. package/templates/registry/ai-prompt-input.json +28 -0
  141. package/templates/registry/ai-reasoning-text.json +22 -0
  142. package/templates/registry/ai-reasoning.json +22 -0
  143. package/templates/registry/ai-shimmer-text.json +19 -0
  144. package/templates/registry/ai-sidebar.json +25 -0
  145. package/templates/registry/ai-speech-input.json +21 -0
  146. package/templates/registry/ai-streaming-response.json +24 -0
  147. package/templates/registry/ai-suggestion.json +19 -0
  148. package/templates/registry/ai-todo-list.json +22 -0
  149. package/templates/registry/ai-tool-approval.json +26 -0
  150. package/templates/registry/ai-tool-result.json +25 -0
  151. package/templates/registry/alert-dialog.json +23 -0
  152. package/templates/registry/animated-list.json +21 -0
  153. package/templates/registry/app-shell.json +93 -0
  154. package/templates/registry/artifacts.json +82 -0
  155. package/templates/registry/attachment.json +22 -0
  156. package/templates/registry/auth-email-verification.json +39 -0
  157. package/templates/registry/auth-login.json +72 -0
  158. package/templates/registry/auth-password-reset.json +53 -0
  159. package/templates/registry/auth-signup.json +41 -0
  160. package/templates/registry/billing-settings.json +60 -0
  161. package/templates/registry/button.json +22 -0
  162. package/templates/registry/chat-eve.json +19 -0
  163. package/templates/registry/chat-panel.json +30 -0
  164. package/templates/registry/chat-share.json +42 -0
  165. package/templates/registry/chat-widget.json +34 -0
  166. package/templates/registry/chat.json +326 -0
  167. package/templates/registry/checkbox.json +22 -0
  168. package/templates/registry/checkout.json +40 -0
  169. package/templates/registry/collapsible.json +19 -0
  170. package/templates/registry/command.json +23 -0
  171. package/templates/registry/copy-button.json +21 -0
  172. package/templates/registry/dashboard.json +69 -0
  173. package/templates/registry/dialog.json +24 -0
  174. package/templates/registry/document-viewer.json +22 -0
  175. package/templates/registry/dropdown-menu.json +23 -0
  176. package/templates/registry/expandable-tabs.json +22 -0
  177. package/templates/registry/feature-gating.json +73 -0
  178. package/templates/registry/file-upload.json +24 -0
  179. package/templates/registry/hold-action-button.json +22 -0
  180. package/templates/registry/input-group.json +23 -0
  181. package/templates/registry/input.json +19 -0
  182. package/templates/registry/intelligo.json +187 -0
  183. package/templates/registry/invitation-accept.json +64 -0
  184. package/templates/registry/language-switcher.json +29 -0
  185. package/templates/registry/morphing-modal.json +24 -0
  186. package/templates/registry/notification-stack.json +24 -0
  187. package/templates/registry/notifications.json +87 -0
  188. package/templates/registry/onboarding.json +83 -0
  189. package/templates/registry/otp-input.json +22 -0
  190. package/templates/registry/page-header.json +15 -0
  191. package/templates/registry/payment-poll.json +52 -0
  192. package/templates/registry/popover-morph.json +22 -0
  193. package/templates/registry/popover.json +22 -0
  194. package/templates/registry/pricing.json +111 -0
  195. package/templates/registry/privacy-settings.json +65 -0
  196. package/templates/registry/profile-settings.json +68 -0
  197. package/templates/registry/progress.json +19 -0
  198. package/templates/registry/radio-group.json +22 -0
  199. package/templates/registry/registry.json +2945 -0
  200. package/templates/registry/route-error.json +65 -0
  201. package/templates/registry/select-morph.json +23 -0
  202. package/templates/registry/select.json +23 -0
  203. package/templates/registry/settings-shell.json +47 -0
  204. package/templates/registry/sheet.json +24 -0
  205. package/templates/registry/sidebar.json +28 -0
  206. package/templates/registry/smoke.json +34 -0
  207. package/templates/registry/spinner.json +16 -0
  208. package/templates/registry/stat-card.json +18 -0
  209. package/templates/registry/status-badge.json +18 -0
  210. package/templates/registry/switch.json +20 -0
  211. package/templates/registry/tabs.json +23 -0
  212. package/templates/registry/team-settings.json +97 -0
  213. package/templates/registry/textarea.json +16 -0
  214. package/templates/registry/tooltip.json +22 -0
  215. package/templates/registry/trial-banner.json +43 -0
  216. package/templates/registry/usage.json +84 -0
  217. package/templates/registry/workspace-settings.json +71 -0
  218. package/templates/registry-items.json +116 -0
  219. package/templates/registry-requires.json +180 -0
  220. package/templates/usage-page/usage-page.tsx.tpl +23 -8
  221. package/templates/vitest/server-only.ts.tpl +7 -0
  222. package/templates/vitest/vitest.config.ts.tpl +37 -0
  223. package/templates/billing-page/billing-page.tsx.tpl +0 -72
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "file-upload",
4
+ "title": "File Upload",
5
+ "description": "A controlled file upload — a drop zone with a queue of rows that slide in, fill a progress bar and settle on done or error, or a compact chat-composer wrapper with a drop overlay and chips.",
6
+ "dependencies": [
7
+ "motion",
8
+ "lucide-react"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/ai-motion",
12
+ "@intelligo/button",
13
+ "@intelligo/spinner"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "base/ui/file-upload/file-upload.tsx",
18
+ "content": "\"use client\";\n\n/*\n * File upload: a drop zone that lifts under a dragged file and a queue of\n * rows that slide in, fill a progress bar, settle on done or error and\n * fold away on remove — or, compact, a chat composer wrapper with a drop\n * overlay and a row of chips. The list is controlled; the component\n * validates what arrives and reports it.\n */\n\nimport * as React from \"react\";\nimport {\n AlertCircleIcon,\n CheckCircle2Icon,\n FileArchiveIcon,\n FileAudioIcon,\n FileCode2Icon,\n FileIcon,\n FileImageIcon,\n FileSpreadsheetIcon,\n FileTextIcon,\n FileVideoIcon,\n PaperclipIcon,\n RotateCcwIcon,\n UploadIcon,\n XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n EASE_OUT,\n SPRING_LAYOUT,\n SPRING_PRESS,\n} from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\n\n/* ----------------------------------------------------------------------------\n * The item shape and helpers.\n * ------------------------------------------------------------------------- */\n\nexport type FileUploadStatus = \"uploading\" | \"done\" | \"error\";\nexport type FileRejectReason = \"too-large\" | \"not-accepted\" | \"max-files\";\n\nexport interface FileUploadItem {\n id: string;\n /** The picked file; `name`, `size` and `type` fall back to it. */\n file?: File;\n name?: string;\n size?: number;\n type?: string;\n /** 0–100 while uploading; omit for an indeterminate upload. */\n progress?: number;\n status: FileUploadStatus;\n error?: string;\n /** An image thumbnail (an object URL or a remote URL). */\n previewUrl?: string;\n}\n\nexport interface FileRejection {\n file: File;\n reason: FileRejectReason;\n}\n\n/** An item for a freshly picked file, ready to be put into the list. */\nexport function createFileUploadItem(file: File): FileUploadItem {\n const id =\n typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n ? crypto.randomUUID()\n : `${Date.now()}-${Math.random().toString(36).slice(2)}-${file.name}`;\n return { id, file, status: \"uploading\", progress: 0 };\n}\n\nexport function formatFileSize(bytes: number | undefined) {\n if (bytes === undefined || !Number.isFinite(bytes) || bytes <= 0)\n return \"0 B\";\n const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n const exponent = Math.min(\n Math.floor(Math.log(bytes) / Math.log(1024)),\n units.length - 1\n );\n const value = bytes / 1024 ** exponent;\n return `${value >= 10 || exponent === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[exponent]}`;\n}\n\nfunction itemName(item: FileUploadItem) {\n return item.name ?? item.file?.name ?? \"\";\n}\n\nfunction itemSize(item: FileUploadItem) {\n return item.size ?? item.file?.size;\n}\n\nfunction itemType(item: FileUploadItem) {\n return item.type ?? item.file?.type ?? \"\";\n}\n\nfunction extensionOf(name: string) {\n return name.includes(\".\") ? (name.split(\".\").pop()?.toLowerCase() ?? \"\") : \"\";\n}\n\nfunction iconFor(item: FileUploadItem) {\n const type = itemType(item);\n const ext = extensionOf(itemName(item));\n if (type.startsWith(\"image/\")) return FileImageIcon;\n if (type.startsWith(\"video/\")) return FileVideoIcon;\n if (type.startsWith(\"audio/\")) return FileAudioIcon;\n if (\n /zip|compressed/.test(type) ||\n [\"zip\", \"rar\", \"7z\", \"tar\", \"gz\"].includes(ext)\n )\n return FileArchiveIcon;\n if (/spreadsheet|excel/.test(type) || [\"csv\", \"xls\", \"xlsx\"].includes(ext))\n return FileSpreadsheetIcon;\n if (\n type.includes(\"pdf\") ||\n type.startsWith(\"text/\") ||\n [\"pdf\", \"doc\", \"docx\", \"md\", \"txt\"].includes(ext)\n )\n return FileTextIcon;\n if (\n [\n \"css\",\n \"html\",\n \"js\",\n \"jsx\",\n \"json\",\n \"mdx\",\n \"ts\",\n \"tsx\",\n \"xml\",\n \"yaml\",\n \"yml\",\n ].includes(ext)\n )\n return FileCode2Icon;\n return FileIcon;\n}\n\n/** Whether a file matches an `accept` string (MIME types, `type/*`, `.ext`). */\nfunction matchesAccept(file: File, accept: string | undefined) {\n if (!accept) return true;\n const name = file.name.toLowerCase();\n const type = file.type.toLowerCase();\n return accept\n .split(\",\")\n .map((token) => token.trim().toLowerCase())\n .filter(Boolean)\n .some((token) => {\n if (token.startsWith(\".\")) return name.endsWith(token);\n if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n return type === token;\n });\n}\n\n/* ----------------------------------------------------------------------------\n * Labels. Every visible or announced string, with an English default.\n * ------------------------------------------------------------------------- */\n\nexport interface FileUploadLabels {\n title: string;\n /** Under the title; defaults to the size limit when `maxSize` is set. */\n description?: string;\n browse: string;\n limitReached: string;\n dropOverlay: string;\n attach: string;\n input: string;\n maxSize: (size: string) => string;\n remove: (name: string) => string;\n retry: (name: string) => string;\n progress: (name: string) => string;\n status: Record<FileUploadStatus, string>;\n}\n\nconst DEFAULT_LABELS: FileUploadLabels = {\n title: \"Drop files here\",\n browse: \"Browse\",\n limitReached: \"Upload limit reached\",\n dropOverlay: \"Drop files to attach\",\n attach: \"Attach files\",\n input: \"Upload files\",\n maxSize: (size) => `Up to ${size} per file`,\n remove: (name) => `Remove ${name}`,\n retry: (name) => `Retry ${name}`,\n progress: (name) => `${name} upload progress`,\n status: { uploading: \"Uploading\", done: \"Uploaded\", error: \"Failed\" },\n};\n\n/* ----------------------------------------------------------------------------\n * Context: the hidden input and the list, shared with the trigger.\n * ------------------------------------------------------------------------- */\n\ninterface FileUploadContextValue {\n open: () => void;\n disabled: boolean;\n labels: FileUploadLabels;\n}\n\nconst FileUploadContext = React.createContext<FileUploadContextValue | null>(\n null\n);\n\nfunction useFileUploadContext(part: string) {\n const context = React.useContext(FileUploadContext);\n if (!context) throw new Error(`${part} must be used inside <FileUpload>.`);\n return context;\n}\n\n/* ----------------------------------------------------------------------------\n * FileUpload.\n * ------------------------------------------------------------------------- */\n\nexport interface FileUploadProps {\n /** The list — controlled; add and remove through the callbacks. */\n files: FileUploadItem[];\n /** Files that passed validation. Create items with `createFileUploadItem`. */\n onFilesAdded?: (files: File[]) => void;\n /** Files turned away, with why. */\n onFilesRejected?: (rejections: FileRejection[]) => void;\n onRemove?: (item: FileUploadItem) => void;\n /** Offers a retry on failed items when given. */\n onRetry?: (item: FileUploadItem) => void;\n accept?: string;\n /** Largest accepted file, in bytes. */\n maxSize?: number;\n maxFiles?: number;\n multiple?: boolean;\n disabled?: boolean;\n /**\n * \"default\": a drop zone with a queue of rows. \"compact\": wraps a chat\n * composer (the children) with a drop overlay and a row of chips; open\n * the picker with `<FileUploadTrigger>` inside it.\n */\n variant?: \"default\" | \"compact\";\n labels?: Partial<FileUploadLabels>;\n className?: string;\n children?: React.ReactNode;\n}\n\nexport function FileUpload({\n files,\n onFilesAdded,\n onFilesRejected,\n onRemove,\n onRetry,\n accept,\n maxSize,\n maxFiles,\n multiple = true,\n disabled = false,\n variant = \"default\",\n labels: labelsProp,\n className,\n children,\n}: FileUploadProps) {\n const labels = { ...DEFAULT_LABELS, ...labelsProp };\n const reduced = useReducedMotion() ?? false;\n const inputRef = React.useRef<HTMLInputElement>(null);\n const dragDepth = React.useRef(0);\n const [dragging, setDragging] = React.useState(false);\n\n const maxReached = maxFiles !== undefined && files.length >= maxFiles;\n const inactive = disabled || maxReached;\n\n const addFiles = (incoming: File[]) => {\n if (disabled || incoming.length === 0) return;\n const rejections: FileRejection[] = [];\n let slots = maxFiles === undefined ? Infinity : maxFiles - files.length;\n if (!multiple) slots = Math.min(slots, 1);\n const accepted: File[] = [];\n for (const file of incoming) {\n if (!matchesAccept(file, accept)) {\n rejections.push({ file, reason: \"not-accepted\" });\n } else if (maxSize !== undefined && file.size > maxSize) {\n rejections.push({ file, reason: \"too-large\" });\n } else if (accepted.length >= slots) {\n rejections.push({ file, reason: \"max-files\" });\n } else {\n accepted.push(file);\n }\n }\n if (rejections.length > 0) onFilesRejected?.(rejections);\n if (accepted.length > 0) onFilesAdded?.(accepted);\n };\n\n const open = () => {\n if (!inactive) inputRef.current?.click();\n };\n\n const hasFiles = (event: React.DragEvent) =>\n Array.from(event.dataTransfer.types).includes(\"Files\");\n\n const dropHandlers = {\n onDragEnter: (event: React.DragEvent) => {\n if (inactive || !hasFiles(event)) return;\n event.preventDefault();\n dragDepth.current += 1;\n setDragging(true);\n },\n onDragOver: (event: React.DragEvent) => {\n if (inactive || !hasFiles(event)) return;\n event.preventDefault();\n event.dataTransfer.dropEffect = \"copy\";\n },\n onDragLeave: (event: React.DragEvent) => {\n if (inactive || !hasFiles(event)) return;\n dragDepth.current = Math.max(0, dragDepth.current - 1);\n if (dragDepth.current === 0) setDragging(false);\n },\n onDrop: (event: React.DragEvent) => {\n if (inactive || !hasFiles(event)) return;\n event.preventDefault();\n dragDepth.current = 0;\n setDragging(false);\n addFiles(Array.from(event.dataTransfer.files));\n },\n };\n\n const input = (\n <input\n ref={inputRef}\n type=\"file\"\n aria-label={labels.input}\n accept={accept}\n multiple={multiple}\n disabled={inactive}\n tabIndex={-1}\n className=\"sr-only\"\n onChange={(event) => {\n addFiles(Array.from(event.currentTarget.files ?? []));\n event.currentTarget.value = \"\";\n }}\n />\n );\n\n const context: FileUploadContextValue = {\n open,\n disabled: inactive,\n labels,\n };\n\n if (variant === \"compact\") {\n return (\n <FileUploadContext.Provider value={context}>\n <div\n data-slot=\"file-upload\"\n data-variant=\"compact\"\n data-dragging={dragging || undefined}\n className={cn(\"relative flex flex-col gap-2\", className)}\n {...dropHandlers}\n >\n {input}\n <FileUploadChips\n files={files}\n onRemove={onRemove}\n onRetry={onRetry}\n labels={labels}\n reduced={reduced}\n />\n {children}\n <AnimatePresence>\n {dragging ? (\n <motion.div\n aria-hidden\n initial={{ opacity: 0 }}\n animate={{ opacity: 1 }}\n exit={{ opacity: 0 }}\n transition={{ duration: reduced ? 0 : 0.16, ease: EASE_OUT }}\n className=\"pointer-events-none absolute inset-0 z-10 grid place-items-center rounded-xl border-2 border-dashed border-ring bg-background/85 backdrop-blur-sm\"\n >\n <motion.span\n initial={reduced ? false : { y: 6, scale: 0.96 }}\n animate={{ y: 0, scale: 1 }}\n transition={reduced ? { duration: 0 } : SPRING_PRESS}\n className=\"inline-flex items-center gap-2 text-sm font-medium text-foreground\"\n >\n <UploadIcon className=\"size-4\" />\n {labels.dropOverlay}\n </motion.span>\n </motion.div>\n ) : null}\n </AnimatePresence>\n </div>\n </FileUploadContext.Provider>\n );\n }\n\n const description =\n labels.description ??\n (maxSize !== undefined\n ? labels.maxSize(formatFileSize(maxSize))\n : undefined);\n\n return (\n <FileUploadContext.Provider value={context}>\n <div\n data-slot=\"file-upload\"\n data-variant=\"default\"\n className={cn(\"flex w-full flex-col gap-3\", className)}\n >\n {input}\n <motion.button\n type=\"button\"\n disabled={inactive}\n data-dragging={dragging || undefined}\n onClick={open}\n {...dropHandlers}\n animate={reduced ? undefined : { scale: dragging ? 1.01 : 1 }}\n whileTap={reduced || inactive ? undefined : { scale: 0.99 }}\n transition={SPRING_PRESS}\n className={cn(\n \"group relative flex w-full items-center gap-4 rounded-xl border border-dashed border-border bg-background p-5 text-left outline-none\",\n \"transition-colors duration-200 hover:border-input hover:bg-muted/40\",\n \"focus-visible:ring-3 focus-visible:ring-ring/40 data-dragging:border-ring data-dragging:bg-muted/60\",\n \"disabled:pointer-events-none disabled:opacity-50\"\n )}\n >\n <motion.span\n aria-hidden\n animate={\n reduced\n ? undefined\n : { y: dragging ? -3 : 0, scale: dragging ? 1.06 : 1 }\n }\n transition={{ duration: 0.2, ease: EASE_OUT }}\n className=\"grid size-12 shrink-0 place-items-center rounded-lg bg-muted text-foreground transition-colors duration-200 group-data-dragging:bg-primary group-data-dragging:text-primary-foreground\"\n >\n <UploadIcon className=\"size-5\" />\n </motion.span>\n <span className=\"min-w-0 flex-1\">\n <span className=\"block text-sm font-semibold text-foreground\">\n {maxReached ? labels.limitReached : labels.title}\n </span>\n {description && !maxReached ? (\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {description}\n </span>\n ) : null}\n </span>\n <span className=\"shrink-0 rounded-full border border-border px-3.5 py-1.5 text-xs font-medium text-foreground transition-colors duration-150 group-hover:bg-accent\">\n {labels.browse}\n </span>\n </motion.button>\n\n <ul\n data-slot=\"file-upload-list\"\n className=\"flex flex-col gap-2 empty:hidden\"\n >\n <AnimatePresence initial={false}>\n {files.map((item) => (\n <FileUploadRow\n key={item.id}\n item={item}\n onRemove={onRemove}\n onRetry={onRetry}\n labels={labels}\n reduced={reduced}\n />\n ))}\n </AnimatePresence>\n </ul>\n </div>\n </FileUploadContext.Provider>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * A queue row: icon, name, size or error, status, actions, progress.\n * ------------------------------------------------------------------------- */\n\nfunction StatusGlyph({\n status,\n label,\n reduced,\n}: {\n status: FileUploadStatus;\n label: string;\n reduced: boolean;\n}) {\n return (\n <AnimatePresence mode=\"wait\" initial={false}>\n <motion.span\n key={status}\n initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4 }}\n animate={{ opacity: 1, y: 0 }}\n exit={reduced ? { opacity: 0 } : { opacity: 0, y: -4 }}\n transition={{ duration: reduced ? 0 : 0.16, ease: EASE_OUT }}\n className={cn(\n \"grid size-7 place-items-center\",\n status === \"done\"\n ? \"text-success\"\n : status === \"error\"\n ? \"text-destructive\"\n : \"text-muted-foreground\"\n )}\n >\n {status === \"done\" ? (\n <CheckCircle2Icon aria-hidden className=\"size-4\" />\n ) : status === \"error\" ? (\n <AlertCircleIcon aria-hidden className=\"size-4\" />\n ) : (\n <Spinner aria-hidden role=\"presentation\" />\n )}\n <span className=\"sr-only\">{label}</span>\n </motion.span>\n </AnimatePresence>\n );\n}\n\nfunction FileUploadRow({\n item,\n onRemove,\n onRetry,\n labels,\n reduced,\n}: {\n item: FileUploadItem;\n onRemove?: (item: FileUploadItem) => void;\n onRetry?: (item: FileUploadItem) => void;\n labels: FileUploadLabels;\n reduced: boolean;\n}) {\n const name = itemName(item);\n const Icon = iconFor(item);\n const progress =\n item.status === \"done\"\n ? 100\n : Math.max(0, Math.min(100, item.progress ?? 0));\n const determinate = item.status === \"done\" || item.progress !== undefined;\n const showProgress = item.status !== \"error\";\n\n return (\n <motion.li\n layout={!reduced}\n data-status={item.status}\n initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8 }}\n animate={{ opacity: 1, y: 0 }}\n exit={\n reduced\n ? { opacity: 0, transition: { duration: 0 } }\n : { opacity: 0, y: -6 }\n }\n transition={\n reduced ? { duration: 0 } : { duration: 0.22, ease: EASE_OUT }\n }\n className={cn(\n \"rounded-xl border bg-card p-3\",\n item.status === \"error\" ? \"border-destructive/30\" : \"border-border\"\n )}\n >\n <div className=\"flex items-center gap-3\">\n {item.previewUrl ? (\n <img\n src={item.previewUrl}\n alt=\"\"\n className=\"size-11 shrink-0 rounded-md bg-muted object-cover\"\n />\n ) : (\n <span\n aria-hidden\n className=\"grid size-11 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground\"\n >\n <Icon className=\"size-5\" />\n </span>\n )}\n\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium text-foreground\">{name}</p>\n <p\n className={cn(\n \"mt-0.5 truncate text-xs\",\n item.status === \"error\"\n ? \"text-destructive\"\n : \"text-muted-foreground\"\n )}\n >\n {formatFileSize(itemSize(item))}\n {item.status === \"error\" && item.error ? ` · ${item.error}` : null}\n </p>\n </div>\n\n <div className=\"flex shrink-0 items-center gap-0.5\">\n <StatusGlyph\n status={item.status}\n label={labels.status[item.status]}\n reduced={reduced}\n />\n {item.status === \"error\" && onRetry ? (\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={labels.retry(name)}\n onClick={() => onRetry(item)}\n >\n <RotateCcwIcon />\n </Button>\n ) : null}\n {onRemove ? (\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={labels.remove(name)}\n onClick={() => onRemove(item)}\n >\n <XIcon />\n </Button>\n ) : null}\n </div>\n </div>\n\n {showProgress ? (\n <div\n role=\"progressbar\"\n aria-label={labels.progress(name)}\n aria-valuemin={0}\n aria-valuemax={100}\n aria-valuenow={determinate ? Math.round(progress) : undefined}\n className=\"mt-3 h-1.5 overflow-hidden rounded-full bg-muted\"\n >\n <motion.div\n initial={false}\n animate={{\n scaleX: determinate ? progress / 100 : 0.3,\n opacity: 1,\n }}\n transition={reduced ? { duration: 0 } : SPRING_LAYOUT}\n className={cn(\n \"h-full origin-left rounded-full\",\n item.status === \"done\" ? \"bg-success\" : \"bg-primary\",\n !determinate && !reduced && \"animate-pulse\"\n )}\n />\n </div>\n ) : null}\n </motion.li>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * Compact chips for the composer.\n * ------------------------------------------------------------------------- */\n\nfunction FileUploadChips({\n files,\n onRemove,\n onRetry,\n labels,\n reduced,\n}: {\n files: FileUploadItem[];\n onRemove?: (item: FileUploadItem) => void;\n onRetry?: (item: FileUploadItem) => void;\n labels: FileUploadLabels;\n reduced: boolean;\n}) {\n return (\n <ul\n data-slot=\"file-upload-chips\"\n className=\"flex flex-wrap gap-1.5 empty:hidden\"\n >\n <AnimatePresence initial={false}>\n {files.map((item) => {\n const name = itemName(item);\n const Icon = iconFor(item);\n const error = item.status === \"error\";\n const progress =\n item.status === \"uploading\" && item.progress !== undefined\n ? Math.max(0, Math.min(100, item.progress))\n : null;\n return (\n <motion.li\n key={item.id}\n layout={!reduced}\n data-status={item.status}\n title={error && item.error ? item.error : name}\n initial={\n reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9, y: 4 }\n }\n animate={{ opacity: 1, scale: 1, y: 0 }}\n exit={\n reduced\n ? { opacity: 0, transition: { duration: 0 } }\n : { opacity: 0, scale: 0.9, transition: { duration: 0.14 } }\n }\n transition={reduced ? { duration: 0 } : SPRING_LAYOUT}\n className={cn(\n \"relative isolate flex h-8 max-w-56 items-center gap-1.5 overflow-hidden rounded-full border bg-card pr-1 pl-1 text-xs\",\n error\n ? \"border-destructive/40 text-destructive\"\n : \"border-border text-foreground\"\n )}\n >\n {progress !== null ? (\n <motion.span\n aria-hidden\n initial={false}\n animate={{ scaleX: progress / 100, opacity: 1 }}\n transition={reduced ? { duration: 0 } : SPRING_LAYOUT}\n className=\"absolute inset-0 -z-10 origin-left bg-muted\"\n />\n ) : null}\n {item.previewUrl ? (\n <img\n src={item.previewUrl}\n alt=\"\"\n className=\"size-6 shrink-0 rounded-full object-cover\"\n />\n ) : (\n <span\n aria-hidden\n className=\"grid size-6 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground\"\n >\n {item.status === \"uploading\" ? (\n <Spinner role=\"presentation\" className=\"size-3.5\" />\n ) : error ? (\n <AlertCircleIcon className=\"size-3.5 text-destructive\" />\n ) : (\n <Icon className=\"size-3.5\" />\n )}\n </span>\n )}\n <span className=\"min-w-0 truncate font-medium\">{name}</span>\n <span className=\"sr-only\">{labels.status[item.status]}</span>\n {error && onRetry ? (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={labels.retry(name)}\n onClick={() => onRetry(item)}\n >\n <RotateCcwIcon />\n </Button>\n ) : null}\n {onRemove ? (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={labels.remove(name)}\n onClick={() => onRemove(item)}\n >\n <XIcon />\n </Button>\n ) : null}\n </motion.li>\n );\n })}\n </AnimatePresence>\n </ul>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * FileUploadTrigger: the composer's attach button; opens the picker.\n * ------------------------------------------------------------------------- */\n\nexport type FileUploadTriggerProps = Omit<\n React.ComponentProps<typeof Button>,\n \"onClick\"\n>;\n\nexport function FileUploadTrigger({\n variant = \"ghost\",\n size = \"icon-sm\",\n children,\n disabled,\n ...props\n}: FileUploadTriggerProps) {\n const {\n open,\n disabled: inactive,\n labels,\n } = useFileUploadContext(\"FileUploadTrigger\");\n return (\n <Button\n type=\"button\"\n variant={variant}\n size={size}\n aria-label={children ? undefined : labels.attach}\n disabled={disabled || inactive}\n onClick={open}\n {...props}\n >\n {children ?? <PaperclipIcon />}\n </Button>\n );\n}\n",
19
+ "type": "registry:ui",
20
+ "target": "components/ui/file-upload.tsx"
21
+ }
22
+ ],
23
+ "type": "registry:ui"
24
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "hold-action-button",
4
+ "title": "Hold Action Button",
5
+ "description": "A press-and-hold confirmation for a destructive action, by pointer or held Space/Enter, whose fill sweeps across while held and drains on an early release.",
6
+ "dependencies": [
7
+ "motion"
8
+ ],
9
+ "registryDependencies": [
10
+ "@intelligo/ai-motion",
11
+ "@intelligo/button"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "base/ui/hold-action-button/hold-action-button.tsx",
16
+ "content": "\"use client\";\n\n/*\n * A press-and-hold confirmation for a destructive action: a fill sweeps\n * across the button for as long as it is held and the action fires when it\n * is full; releasing early drains it and nothing happens. Holding Space or\n * Enter works the same way.\n */\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nimport { EASE_OUT } from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ntype ButtonProps = React.ComponentProps<typeof Button>;\ntype HoldVariant = NonNullable<ButtonProps[\"variant\"]>;\n\nexport interface HoldActionButtonLabels {\n /** Shown while the button is held. */\n holding: React.ReactNode;\n /** Shown once the hold has confirmed, until release. */\n complete: React.ReactNode;\n /** Accessible description of the gesture, read with the button's name. */\n description: string;\n /** Announced to assistive technology when the action fires. */\n confirmed: string;\n}\n\nconst DEFAULT_LABELS: HoldActionButtonLabels = {\n holding: \"Keep holding\",\n complete: \"Done\",\n description: \"Press and hold to confirm. Release to cancel.\",\n confirmed: \"Confirmed\",\n};\n\nconst FILL_CLASS: Record<HoldVariant, string> = {\n destructive: \"bg-destructive/20\",\n default: \"bg-primary-foreground/20\",\n outline: \"bg-foreground/10\",\n secondary: \"bg-foreground/10\",\n ghost: \"bg-foreground/10\",\n link: \"bg-foreground/10\",\n};\n\nexport interface HoldActionButtonProps extends Omit<\n ButtonProps,\n | \"children\"\n | \"onClick\"\n | \"onPointerDown\"\n | \"onPointerUp\"\n | \"onPointerMove\"\n | \"onPointerCancel\"\n | \"onPointerLeave\"\n | \"onKeyDown\"\n | \"onKeyUp\"\n | \"onBlur\"\n | \"render\"\n | \"ripple\"\n> {\n children: React.ReactNode;\n /** Fires once, when the hold completes. */\n onConfirm?: () => void;\n /** How long the button must be held, in milliseconds. */\n holdDuration?: number;\n labels?: Partial<HoldActionButtonLabels>;\n fillClassName?: string;\n}\n\nfunction isHoldKey(key: string) {\n return key === \" \" || key === \"Enter\";\n}\n\nexport function HoldActionButton({\n children,\n onConfirm,\n holdDuration = 1600,\n variant = \"destructive\",\n size = \"lg\",\n labels: labelsProp,\n disabled,\n className,\n fillClassName,\n ...props\n}: HoldActionButtonProps) {\n const labels = { ...DEFAULT_LABELS, ...labelsProp };\n const reduced = useReducedMotion() ?? false;\n const descriptionId = React.useId();\n const [holding, setHolding] = React.useState(false);\n const [completed, setCompleted] = React.useState(false);\n const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n const onConfirmRef = React.useRef(onConfirm);\n onConfirmRef.current = onConfirm;\n\n const clearTimer = React.useCallback(() => {\n if (timerRef.current !== null) clearTimeout(timerRef.current);\n timerRef.current = null;\n }, []);\n\n React.useEffect(() => clearTimer, [clearTimer]);\n\n // A disabled button abandons any hold in progress.\n React.useEffect(() => {\n if (!disabled) return;\n clearTimer();\n setHolding(false);\n setCompleted(false);\n }, [disabled, clearTimer]);\n\n const startHold = () => {\n if (disabled || timerRef.current !== null) return;\n setCompleted(false);\n setHolding(true);\n // The timer, not the fill animation, decides: the fill is feedback.\n timerRef.current = setTimeout(() => {\n timerRef.current = null;\n setCompleted(true);\n onConfirmRef.current?.();\n }, holdDuration);\n };\n\n const cancelHold = () => {\n clearTimer();\n setHolding(false);\n setCompleted(false);\n };\n\n const active = holding || completed;\n const variantKey: HoldVariant = variant ?? \"destructive\";\n\n return (\n <>\n <Button\n {...props}\n type=\"button\"\n variant={variant}\n size={size}\n disabled={disabled}\n aria-describedby={descriptionId}\n data-holding={holding || undefined}\n data-complete={completed || undefined}\n onPointerDown={(event) => {\n if (event.button !== 0) return;\n startHold();\n try {\n event.currentTarget.setPointerCapture(event.pointerId);\n } catch {\n // Capture is a convenience; the hold works without it.\n }\n }}\n onPointerUp={cancelHold}\n onPointerCancel={cancelHold}\n onPointerMove={(event) => {\n // A captured pointer gets no leave events, so measure: sliding off\n // the button abandons the hold.\n if (!holding) return;\n const rect = event.currentTarget.getBoundingClientRect();\n if (\n event.clientX < rect.left ||\n event.clientX > rect.right ||\n event.clientY < rect.top ||\n event.clientY > rect.bottom\n ) {\n cancelHold();\n }\n }}\n onKeyDown={(event) => {\n if (!isHoldKey(event.key)) return;\n event.preventDefault();\n if (!event.repeat) startHold();\n }}\n onKeyUp={(event) => {\n if (!isHoldKey(event.key)) return;\n event.preventDefault();\n cancelHold();\n }}\n onBlur={cancelHold}\n onContextMenu={(event) => event.preventDefault()}\n className={cn(\n \"relative isolate touch-none overflow-hidden select-none\",\n className\n )}\n >\n <span\n aria-hidden\n className=\"pointer-events-none absolute inset-0 -z-10 overflow-hidden\"\n style={{ borderRadius: \"inherit\" }}\n >\n <motion.span\n initial={false}\n animate={\n reduced\n ? { opacity: active ? 1 : 0, scaleX: 1 }\n : { opacity: 1, scaleX: active ? 1 : 0 }\n }\n transition={\n active\n ? { duration: holdDuration / 1000, ease: \"linear\" }\n : { duration: reduced ? 0 : 0.24, ease: EASE_OUT }\n }\n className={cn(\n \"absolute inset-0 origin-left\",\n FILL_CLASS[variantKey],\n fillClassName\n )}\n />\n </span>\n\n <span className=\"grid place-items-center\">\n <motion.span\n animate={{ opacity: active ? 0 : 1 }}\n transition={{ duration: reduced ? 0 : 0.12, ease: EASE_OUT }}\n className=\"col-start-1 row-start-1 inline-flex items-center gap-1.5\"\n >\n {children}\n </motion.span>\n <motion.span\n aria-hidden\n animate={{ opacity: holding && !completed ? 1 : 0 }}\n transition={{ duration: reduced ? 0 : 0.12, ease: EASE_OUT }}\n className=\"col-start-1 row-start-1\"\n >\n {labels.holding}\n </motion.span>\n <motion.span\n aria-hidden\n animate={{ opacity: completed ? 1 : 0 }}\n transition={{ duration: reduced ? 0 : 0.12, ease: EASE_OUT }}\n className=\"col-start-1 row-start-1\"\n >\n {labels.complete}\n </motion.span>\n </span>\n </Button>\n {/* Outside the button, so neither joins its accessible name. */}\n <span id={descriptionId} className=\"sr-only\">\n {labels.description}\n </span>\n <span aria-live=\"assertive\" className=\"sr-only\">\n {completed ? labels.confirmed : \"\"}\n </span>\n </>\n );\n}\n",
17
+ "type": "registry:ui",
18
+ "target": "components/ui/hold-action-button.tsx"
19
+ }
20
+ ],
21
+ "type": "registry:ui"
22
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "input-group",
4
+ "title": "Input Group",
5
+ "description": "An input or textarea with inline addons, buttons and text.",
6
+ "dependencies": [
7
+ "class-variance-authority"
8
+ ],
9
+ "registryDependencies": [
10
+ "@intelligo/button",
11
+ "@intelligo/input",
12
+ "@intelligo/textarea"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "base/ui/input-group/input-group.tsx",
17
+ "content": "\"use client\";\n\n/*\n * The input group: an input or textarea with inline addons, buttons and\n * text, on the input's field.\n */\n\nimport * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\n\nfunction InputGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"input-group\"\n role=\"group\"\n className={cn(\n \"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input bg-card transition-[border-color,box-shadow] duration-normal ease-standard outline-none hover:border-ring/50 in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/20 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5\",\n className\n )}\n {...props}\n />\n );\n}\n\nconst inputGroupAddonVariants = cva(\n \"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-sm [&>svg:not([class*='size-'])]:size-4\",\n {\n variants: {\n align: {\n \"inline-start\":\n \"order-first pl-2 has-[>button]:-ml-1.25 has-[>kbd]:-ml-0.5\",\n \"inline-end\":\n \"order-last pr-2 has-[>button]:-mr-1.25 has-[>kbd]:-mr-0.5\",\n \"block-start\":\n \"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2\",\n \"block-end\":\n \"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2\",\n },\n },\n defaultVariants: {\n align: \"inline-start\",\n },\n }\n);\n\nfunction InputGroupAddon({\n className,\n align = \"inline-start\",\n ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof inputGroupAddonVariants>) {\n return (\n <div\n role=\"group\"\n data-slot=\"input-group-addon\"\n data-align={align}\n className={cn(inputGroupAddonVariants({ align }), className)}\n onClick={(e) => {\n if ((e.target as HTMLElement).closest(\"button\")) {\n return;\n }\n e.currentTarget.parentElement?.querySelector(\"input\")?.focus();\n }}\n {...props}\n />\n );\n}\n\nconst inputGroupButtonVariants = cva(\n \"flex items-center gap-2 text-sm shadow-none\",\n {\n variants: {\n size: {\n xs: \"h-6 gap-1 rounded-md px-1.5 [&>svg:not([class*='size-'])]:size-3.5\",\n sm: \"\",\n \"icon-xs\": \"size-6 rounded-md p-0 has-[>svg]:p-0\",\n \"icon-sm\": \"size-8 p-0 has-[>svg]:p-0\",\n },\n },\n defaultVariants: {\n size: \"xs\",\n },\n }\n);\n\nfunction InputGroupButton({\n className,\n type = \"button\",\n variant = \"ghost\",\n size = \"xs\",\n ...props\n}: Omit<React.ComponentProps<typeof Button>, \"size\" | \"type\"> &\n VariantProps<typeof inputGroupButtonVariants> & {\n type?: \"button\" | \"submit\" | \"reset\";\n }) {\n return (\n <Button\n type={type}\n data-size={size}\n variant={variant}\n className={cn(inputGroupButtonVariants({ size }), className)}\n {...props}\n />\n );\n}\n\nfunction InputGroupText({ className, ...props }: React.ComponentProps<\"span\">) {\n return (\n <span\n className={cn(\n \"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction InputGroupInput({\n className,\n ...props\n}: React.ComponentProps<\"input\">) {\n return (\n <Input\n data-slot=\"input-group-control\"\n className={cn(\n \"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction InputGroupTextarea({\n className,\n ...props\n}: React.ComponentProps<\"textarea\">) {\n return (\n <Textarea\n data-slot=\"input-group-control\"\n className={cn(\n \"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport {\n InputGroup,\n InputGroupAddon,\n InputGroupButton,\n InputGroupText,\n InputGroupInput,\n InputGroupTextarea,\n};\n",
18
+ "type": "registry:ui",
19
+ "target": "components/ui/input-group.tsx"
20
+ }
21
+ ],
22
+ "type": "registry:ui"
23
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "input",
4
+ "title": "Input",
5
+ "description": "A single-line text field whose border and soft ring ease in on focus.",
6
+ "dependencies": [
7
+ "@base-ui/react"
8
+ ],
9
+ "registryDependencies": [],
10
+ "files": [
11
+ {
12
+ "path": "base/ui/input/input.tsx",
13
+ "content": "/*\n * The input: a card-toned field whose border and soft ring ease in on\n * focus.\n */\n\nimport * as React from \"react\";\nimport { Input as InputPrimitive } from \"@base-ui/react/input\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n return (\n <InputPrimitive\n type={type}\n data-slot=\"input\"\n className={cn(\n \"h-8 w-full min-w-0 rounded-lg border border-input bg-card px-2.5 py-1 text-base transition-[border-color,box-shadow,background-color] duration-normal ease-standard outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-ring/50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport { Input };\n",
14
+ "type": "registry:ui",
15
+ "target": "components/ui/input.tsx"
16
+ }
17
+ ],
18
+ "type": "registry:ui"
19
+ }
@@ -0,0 +1,187 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "extends": "none",
4
+ "name": "intelligo",
5
+ "title": "Intelligo design system",
6
+ "description": "The base every Intelligo page is built on: shadcn base-nova on Base UI, the neutral theme with its focus ring, muted text and destructive raised to WCAG AA, and additive tokens for status (success, warning, info, destructive-foreground), layers and motion. Initialise an app from it before installing any other item.",
7
+ "dependencies": [
8
+ "class-variance-authority",
9
+ "cn",
10
+ "tw-animate-css",
11
+ "@base-ui/react",
12
+ "lucide-react"
13
+ ],
14
+ "registryDependencies": [
15
+ "utils",
16
+ "font-geist"
17
+ ],
18
+ "cssVars": {
19
+ "theme": {
20
+ "font-heading": "var(--font-sans)",
21
+ "color-destructive-foreground": "var(--destructive-foreground)",
22
+ "color-success": "var(--success)",
23
+ "color-success-foreground": "var(--success-foreground)",
24
+ "color-warning": "var(--warning)",
25
+ "color-warning-foreground": "var(--warning-foreground)",
26
+ "color-info": "var(--info)",
27
+ "color-info-foreground": "var(--info-foreground)",
28
+ "ease-standard": "cubic-bezier(0.16, 1, 0.3, 1)",
29
+ "ease-emphasized": "cubic-bezier(0.32, 0.72, 0, 1)",
30
+ "ease-exit": "cubic-bezier(0.77, 0, 0.175, 1)"
31
+ },
32
+ "light": {
33
+ "font-sans": "\"Geist\", \"Geist Fallback\", -apple-system, \"Segoe UI\", sans-serif",
34
+ "background": "oklch(0.99 0 0)",
35
+ "foreground": "oklch(0.15 0 0)",
36
+ "card": "oklch(0.97 0 0)",
37
+ "card-foreground": "oklch(0.15 0 0)",
38
+ "popover": "oklch(0.99 0 0)",
39
+ "popover-foreground": "oklch(0.15 0 0)",
40
+ "primary": "oklch(0.15 0 0)",
41
+ "primary-foreground": "oklch(0.99 0 0)",
42
+ "secondary": "oklch(0.955 0 0)",
43
+ "secondary-foreground": "oklch(0.15 0 0)",
44
+ "muted": "oklch(0.97 0 0)",
45
+ "muted-foreground": "oklch(0.5 0 0)",
46
+ "accent": "oklch(0.95 0 0)",
47
+ "accent-foreground": "oklch(0.15 0 0)",
48
+ "destructive": "oklch(0.52 0.2 25)",
49
+ "border": "oklch(0.15 0 0 / 8%)",
50
+ "input": "oklch(0.15 0 0 / 14%)",
51
+ "ring": "oklch(0.55 0 0)",
52
+ "chart-1": "oklch(0.87 0 0)",
53
+ "chart-2": "oklch(0.556 0 0)",
54
+ "chart-3": "oklch(0.439 0 0)",
55
+ "chart-4": "oklch(0.371 0 0)",
56
+ "chart-5": "oklch(0.269 0 0)",
57
+ "radius": "0.5rem",
58
+ "sidebar": "oklch(0.975 0 0)",
59
+ "sidebar-foreground": "oklch(0.15 0 0)",
60
+ "sidebar-primary": "oklch(0.15 0 0)",
61
+ "sidebar-primary-foreground": "oklch(0.99 0 0)",
62
+ "sidebar-accent": "oklch(0.94 0 0)",
63
+ "sidebar-accent-foreground": "oklch(0.15 0 0)",
64
+ "sidebar-border": "oklch(0.15 0 0 / 8%)",
65
+ "sidebar-ring": "oklch(0.55 0 0)",
66
+ "destructive-foreground": "oklch(0.99 0 0)",
67
+ "success": "oklch(0.49 0.12 155)",
68
+ "success-foreground": "oklch(0.99 0 0)",
69
+ "warning": "oklch(0.5 0.105 75)",
70
+ "warning-foreground": "oklch(0.99 0 0)",
71
+ "info": "oklch(0.5 0.135 245)",
72
+ "info-foreground": "oklch(0.99 0 0)"
73
+ },
74
+ "dark": {
75
+ "background": "oklch(0.196 0 0)",
76
+ "foreground": "oklch(0.96 0 0)",
77
+ "card": "oklch(0.227 0 0)",
78
+ "card-foreground": "oklch(0.96 0 0)",
79
+ "popover": "oklch(0.227 0 0)",
80
+ "popover-foreground": "oklch(0.96 0 0)",
81
+ "primary": "oklch(0.96 0 0)",
82
+ "primary-foreground": "oklch(0.196 0 0)",
83
+ "secondary": "oklch(0.27 0 0)",
84
+ "secondary-foreground": "oklch(0.96 0 0)",
85
+ "muted": "oklch(0.227 0 0)",
86
+ "muted-foreground": "oklch(0.68 0 0)",
87
+ "accent": "oklch(0.28 0 0)",
88
+ "accent-foreground": "oklch(0.96 0 0)",
89
+ "destructive": "oklch(0.7 0.18 25)",
90
+ "border": "oklch(1 0 0 / 7%)",
91
+ "input": "oklch(1 0 0 / 12%)",
92
+ "ring": "oklch(0.58 0 0)",
93
+ "chart-1": "oklch(0.87 0 0)",
94
+ "chart-2": "oklch(0.556 0 0)",
95
+ "chart-3": "oklch(0.439 0 0)",
96
+ "chart-4": "oklch(0.371 0 0)",
97
+ "chart-5": "oklch(0.269 0 0)",
98
+ "sidebar": "oklch(0.175 0 0)",
99
+ "sidebar-foreground": "oklch(0.96 0 0)",
100
+ "sidebar-primary": "oklch(0.96 0 0)",
101
+ "sidebar-primary-foreground": "oklch(0.196 0 0)",
102
+ "sidebar-accent": "oklch(0.26 0 0)",
103
+ "sidebar-accent-foreground": "oklch(0.96 0 0)",
104
+ "sidebar-border": "oklch(1 0 0 / 7%)",
105
+ "sidebar-ring": "oklch(0.58 0 0)",
106
+ "destructive-foreground": "oklch(0.196 0 0)",
107
+ "success": "oklch(0.72 0.16 155)",
108
+ "success-foreground": "oklch(0.196 0 0)",
109
+ "warning": "oklch(0.78 0.15 75)",
110
+ "warning-foreground": "oklch(0.196 0 0)",
111
+ "info": "oklch(0.72 0.13 245)",
112
+ "info-foreground": "oklch(0.196 0 0)"
113
+ }
114
+ },
115
+ "css": {
116
+ "@import \"tw-animate-css\"": {},
117
+ "@import \"shadcn/tailwind.css\"": {},
118
+ "@layer base": {
119
+ "*": {
120
+ "@apply border-border outline-ring/50": {}
121
+ },
122
+ "body": {
123
+ "@apply bg-background text-foreground": {}
124
+ },
125
+ ":root": {
126
+ "--z-sticky": "10",
127
+ "--z-dropdown": "20",
128
+ "--z-overlay": "40",
129
+ "--z-modal": "50",
130
+ "--z-popover": "60",
131
+ "--z-toast": "70",
132
+ "--duration-fast": "120ms",
133
+ "--duration-normal": "200ms",
134
+ "--duration-slow": "320ms"
135
+ }
136
+ },
137
+ "@utility z-sticky": {
138
+ "z-index": "var(--z-sticky)"
139
+ },
140
+ "@utility z-dropdown": {
141
+ "z-index": "var(--z-dropdown)"
142
+ },
143
+ "@utility z-overlay": {
144
+ "z-index": "var(--z-overlay)"
145
+ },
146
+ "@utility z-modal": {
147
+ "z-index": "var(--z-modal)"
148
+ },
149
+ "@utility z-popover": {
150
+ "z-index": "var(--z-popover)"
151
+ },
152
+ "@utility z-toast": {
153
+ "z-index": "var(--z-toast)"
154
+ },
155
+ "@utility duration-fast": {
156
+ "transition-duration": "var(--duration-fast)",
157
+ "animation-duration": "var(--duration-fast)"
158
+ },
159
+ "@utility duration-normal": {
160
+ "transition-duration": "var(--duration-normal)",
161
+ "animation-duration": "var(--duration-normal)"
162
+ },
163
+ "@utility duration-slow": {
164
+ "transition-duration": "var(--duration-slow)",
165
+ "animation-duration": "var(--duration-slow)"
166
+ },
167
+ "@media (prefers-reduced-motion: reduce)": {
168
+ "*, ::before, ::after": {
169
+ "animation-duration": "0.01ms !important",
170
+ "animation-iteration-count": "1 !important",
171
+ "transition-duration": "0.01ms !important",
172
+ "scroll-behavior": "auto !important"
173
+ }
174
+ }
175
+ },
176
+ "type": "registry:base",
177
+ "config": {
178
+ "style": "base-nova",
179
+ "tailwind": {
180
+ "baseColor": "neutral"
181
+ },
182
+ "iconLibrary": "lucide",
183
+ "rtl": false,
184
+ "menuColor": "default",
185
+ "menuAccent": "subtle"
186
+ }
187
+ }
@@ -0,0 +1,64 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "invitation-accept",
4
+ "title": "Invitation Accept",
5
+ "description": "Accept or decline a workspace invitation via the team service. Requires the team-settings item (provides lib/team.ts and the role badge); install it first.",
6
+ "dependencies": [
7
+ "@intelligo-dev/auth",
8
+ "@intelligo-dev/core",
9
+ "@intelligo-dev/next",
10
+ "sonner",
11
+ "next-intl"
12
+ ],
13
+ "registryDependencies": [
14
+ "alert",
15
+ "card",
16
+ "skeleton",
17
+ "@intelligo/button"
18
+ ],
19
+ "files": [
20
+ {
21
+ "path": "base/invitation-accept/page.tsx",
22
+ "content": "/**\n * Loads the caller's own invitation by id through the team service\n * (`@/lib/team`, installed by the `team-settings` item) and renders one\n * of three states.\n *\n * `team.getUserInvitations()` wraps Better-Auth's\n * `/organization/list-user-invitations`, which returns pending\n * invitations only. An id missing from that list may never have\n * existed, belong to another account, or be already answered; nothing\n * tells them apart, so \"Invitation unavailable\" covers all three.\n * \"Expired\" is reachable: the endpoint filters on `status`, not\n * `expiresAt`, so this page compares the timestamp itself.\n *\n * It sits outside `(app)`: the shell's redirects — to `/login` without\n * a way back, to onboarding before anything else — would lose the\n * invitation. The layout sends a visitor without a session to sign in\n * and back here.\n */\n\nimport { getFormatter, getTranslations } from \"next-intl/server\";\n\nimport { requireAuth } from \"@intelligo-dev/auth\";\n\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from \"@/components/ui/card\";\nimport { RoleBadge } from \"@/components/team/role-badge\";\nimport { InvitationActions } from \"@/components/invitation/invitation-actions\";\nimport { team } from \"@/lib/team\";\n\ninterface Props {\n params: Promise<{ id: string }>;\n}\n\nfunction StatusCard({\n title,\n description,\n}: {\n title: string;\n description: string;\n}) {\n return (\n <div className=\"flex min-h-96 items-center justify-center p-4\">\n <Card className=\"w-full max-w-md\">\n <CardHeader>\n <CardTitle>{title}</CardTitle>\n </CardHeader>\n <CardContent>\n <Alert variant=\"destructive\">\n <AlertDescription>{description}</AlertDescription>\n </Alert>\n </CardContent>\n </Card>\n </div>\n );\n}\n\nexport default async function AcceptInvitationPage({ params }: Props) {\n const t = await getTranslations(\"invitation-accept\");\n const format = await getFormatter();\n await requireAuth();\n const { id } = await params;\n\n const invitations = await team.getUserInvitations();\n const invitation = invitations.find((inv) => inv.id === id);\n\n if (!invitation) {\n return (\n <StatusCard\n title={t(\"unavailable.title\")}\n description={t(\"unavailable.description\")}\n />\n );\n }\n\n const isExpired = new Date(invitation.expiresAt) < new Date();\n\n if (isExpired) {\n return (\n <StatusCard\n title={t(\"expired.title\")}\n description={t(\"expired.description\")}\n />\n );\n }\n\n return (\n <div className=\"flex min-h-96 items-center justify-center p-4\">\n <Card className=\"w-full max-w-md\">\n <CardHeader>\n <CardTitle>{t(\"card.title\")}</CardTitle>\n <CardDescription>{t(\"card.description\")}</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-6\">\n <div className=\"space-y-4\">\n <div>\n <p className=\"text-sm font-medium text-muted-foreground\">\n {t(\"card.workspaceLabel\")}\n </p>\n <p className=\"text-base font-semibold\">\n {invitation.organizationName ?? t(\"card.workspaceFallback\")}\n </p>\n </div>\n\n <div>\n <p className=\"text-sm font-medium text-muted-foreground\">\n {t(\"card.invitedByLabel\")}\n </p>\n <p className=\"text-base\">\n {invitation.inviterEmail ?? t(\"card.invitedByFallback\")}\n </p>\n </div>\n\n <div>\n <p className=\"text-sm font-medium text-muted-foreground\">\n {t(\"card.roleLabel\")}\n </p>\n <div className=\"mt-1\">\n <RoleBadge role={invitation.role} />\n </div>\n </div>\n\n <div>\n <p className=\"text-sm font-medium text-muted-foreground\">\n {t(\"card.expiresLabel\")}\n </p>\n <p className=\"text-base\">\n {format.dateTime(new Date(invitation.expiresAt), {\n dateStyle: \"medium\",\n })}\n </p>\n </div>\n </div>\n\n <InvitationActions invitationId={invitation.id} />\n </CardContent>\n </Card>\n </div>\n );\n}\n",
23
+ "type": "registry:page",
24
+ "target": "app/[locale]/accept-invitation/[id]/page.tsx"
25
+ },
26
+ {
27
+ "path": "base/invitation-accept/layout.tsx",
28
+ "content": "import type { ReactNode } from \"react\";\nimport { getLocale } from \"next-intl/server\";\n\nimport { getAuthSession } from \"@intelligo-dev/auth\";\n\nimport { redirect } from \"@/i18n/navigation\";\n\n/**\n * Sends a visitor without a session to sign in, and back here after.\n * A layout rather than the page: the page streams behind `loading.tsx`,\n * and a server redirect thrown mid-stream does not move the browser.\n */\nexport default async function AcceptInvitationLayout({\n children,\n params,\n}: {\n children: ReactNode;\n params: Promise<{ id: string }>;\n}) {\n if (!(await getAuthSession())) {\n const { id } = await params;\n const here = `/accept-invitation/${encodeURIComponent(id)}`;\n redirect({\n href: `/login?next=${encodeURIComponent(here)}`,\n locale: await getLocale(),\n });\n }\n return children;\n}\n",
29
+ "type": "registry:page",
30
+ "target": "app/[locale]/accept-invitation/[id]/layout.tsx"
31
+ },
32
+ {
33
+ "path": "base/invitation-accept/loading.tsx",
34
+ "content": "import { Card, CardContent, CardHeader } from \"@/components/ui/card\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport default function AcceptInvitationLoading() {\n return (\n <div className=\"flex min-h-96 items-center justify-center p-4\">\n <Card className=\"w-full max-w-md\">\n <CardHeader>\n <Skeleton className=\"mb-2 h-7 w-48\" />\n <Skeleton className=\"h-4 w-64\" />\n </CardHeader>\n <CardContent className=\"space-y-6\">\n <div className=\"space-y-4\">\n {[1, 2, 3, 4].map((i) => (\n <div key={i}>\n <Skeleton className=\"mb-1 h-4 w-20\" />\n <Skeleton className=\"h-5 w-full\" />\n </div>\n ))}\n </div>\n\n <div className=\"flex gap-2\">\n <Skeleton className=\"h-10 flex-1\" />\n <Skeleton className=\"h-10 flex-1\" />\n </div>\n </CardContent>\n </Card>\n </div>\n );\n}\n",
35
+ "type": "registry:page",
36
+ "target": "app/[locale]/accept-invitation/[id]/loading.tsx"
37
+ },
38
+ {
39
+ "path": "base/invitation-accept/error.tsx",
40
+ "content": "\"use client\";\n\nimport { useEffect } from \"react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { Link } from \"@/i18n/navigation\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\n\nexport default function AcceptInvitationError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n const t = useTranslations(\"invitation-accept\");\n\n useEffect(() => {\n console.error(\"[accept-invitation] error boundary caught:\", error);\n }, [error]);\n\n return (\n <div className=\"flex min-h-96 items-center justify-center p-4\">\n <div className=\"w-full max-w-md space-y-4\">\n <Alert variant=\"destructive\">\n <AlertDescription>{t(\"errorBoundary.message\")}</AlertDescription>\n </Alert>\n\n <div className=\"flex justify-center gap-3\">\n <Button onClick={reset}>{t(\"errorBoundary.retry\")}</Button>\n <Button\n variant=\"outline\"\n render={<Link href=\"/\" />}\n nativeButton={false}\n >\n {t(\"errorBoundary.goHome\")}\n </Button>\n </div>\n </div>\n </div>\n );\n}\n",
41
+ "type": "registry:page",
42
+ "target": "app/[locale]/accept-invitation/[id]/error.tsx"
43
+ },
44
+ {
45
+ "path": "base/invitation-accept/components/invitation-actions.tsx",
46
+ "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { toast } from \"sonner\";\n\nimport { useRouter } from \"@/i18n/navigation\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { acceptInvitation, rejectInvitation } from \"@/actions/invitation\";\n\ninterface InvitationActionsProps {\n invitationId: string;\n}\n\nexport function InvitationActions({ invitationId }: InvitationActionsProps) {\n const t = useTranslations(\"invitation-accept\");\n const router = useRouter();\n const [error, setError] = useState<string | null>(null);\n const [isAccepting, setIsAccepting] = useState(false);\n const [isDeclining, setIsDeclining] = useState(false);\n\n async function handleAccept() {\n setError(null);\n setIsAccepting(true);\n\n const result = await acceptInvitation(invitationId);\n\n if (!result.success) {\n setError(result.error);\n toast.error(result.error);\n setIsAccepting(false);\n return;\n }\n\n router.push(\"/\");\n router.refresh();\n }\n\n async function handleDecline() {\n setError(null);\n setIsDeclining(true);\n\n const result = await rejectInvitation(invitationId);\n\n if (!result.success) {\n setError(result.error);\n toast.error(result.error);\n setIsDeclining(false);\n return;\n }\n\n router.push(\"/\");\n router.refresh();\n }\n\n const isPending = isAccepting || isDeclining;\n\n return (\n <div className=\"space-y-4\">\n {error && (\n <Alert variant=\"destructive\">\n <AlertDescription>{error}</AlertDescription>\n </Alert>\n )}\n\n <div className=\"flex gap-3\">\n <Button onClick={handleAccept} disabled={isPending} size=\"lg\">\n {isAccepting ? t(\"actions.acceptPending\") : t(\"actions.accept\")}\n </Button>\n <Button\n onClick={handleDecline}\n disabled={isPending}\n variant=\"outline\"\n size=\"lg\"\n >\n {isDeclining ? t(\"actions.declinePending\") : t(\"actions.decline\")}\n </Button>\n </div>\n </div>\n );\n}\n",
47
+ "type": "registry:component",
48
+ "target": "components/invitation/invitation-actions.tsx"
49
+ },
50
+ {
51
+ "path": "base/invitation-accept/actions.ts",
52
+ "content": "\"use server\";\n\n/**\n * Thin transport over the team service bound at `@/lib/team` (installed\n * by the `team-settings` item). Authorization, invitation ownership and\n * membership checks live in the service; this file maps a thrown\n * `TeamServiceError` to a message and revalidates.\n */\n\nimport { revalidatePath } from \"next/cache\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport {\n isTeamServiceError,\n type TeamServiceErrorCode,\n} from \"@intelligo-dev/auth\";\nimport type { ActionResult } from \"@intelligo-dev/next\";\n\nimport { team } from \"@/lib/team\";\n\nexport type InvitationActionResult = ActionResult;\n\ntype Translator = Awaited<\n ReturnType<typeof getTranslations<\"invitation-accept\">>\n>;\n\nfunction friendlyMessages(\n t: Translator\n): Partial<Record<TeamServiceErrorCode, string>> {\n return {\n invitation_not_found: t(\"errors.invitationNotFound\"),\n accept_verification_failed: t(\"errors.acceptVerificationFailed\"),\n forbidden: t(\"errors.forbidden\"),\n invalid_input: t(\"errors.invalidInput\"),\n provider_error: t(\"errors.providerError\"),\n };\n}\n\nfunction toMessage(error: unknown, fallback: string, t: Translator): string {\n if (isTeamServiceError(error)) {\n return friendlyMessages(t)[error.code] ?? fallback;\n }\n return fallback;\n}\n\nexport async function acceptInvitation(\n invitationId: string\n): Promise<InvitationActionResult> {\n const t = await getTranslations(\"invitation-accept\");\n try {\n await team.acceptInvitation(invitationId);\n } catch (error) {\n return {\n success: false,\n error: toMessage(error, t(\"errors.acceptFailed\"), t),\n };\n }\n\n revalidatePath(\"/[locale]/accept-invitation/[id]\", \"page\");\n revalidatePath(\"/settings/team\");\n revalidatePath(\"/\");\n return { success: true, data: undefined };\n}\n\nexport async function rejectInvitation(\n invitationId: string\n): Promise<InvitationActionResult> {\n const t = await getTranslations(\"invitation-accept\");\n try {\n await team.rejectInvitation(invitationId);\n } catch (error) {\n return {\n success: false,\n error: toMessage(error, t(\"errors.declineFailed\"), t),\n };\n }\n\n revalidatePath(\"/[locale]/accept-invitation/[id]\", \"page\");\n return { success: true, data: undefined };\n}\n",
53
+ "type": "registry:file",
54
+ "target": "actions/invitation.ts"
55
+ },
56
+ {
57
+ "path": "base/invitation-accept/messages/en.json",
58
+ "content": "{\n \"unavailable\": {\n \"title\": \"Invitation unavailable\",\n \"description\": \"This invitation link is invalid, has already been used, or belongs to a different account. Ask the workspace owner to send a new one if you still need access.\"\n },\n \"expired\": {\n \"title\": \"Invitation expired\",\n \"description\": \"This invitation has expired. Ask the workspace owner to send a new one.\"\n },\n \"card\": {\n \"title\": \"Workspace invitation\",\n \"description\": \"You've been invited to join a workspace.\",\n \"workspaceLabel\": \"Workspace\",\n \"workspaceFallback\": \"This workspace\",\n \"invitedByLabel\": \"Invited by\",\n \"invitedByFallback\": \"A team member\",\n \"roleLabel\": \"Role\",\n \"expiresLabel\": \"Expires\"\n },\n \"actions\": {\n \"accept\": \"Accept\",\n \"acceptPending\": \"Accepting…\",\n \"decline\": \"Decline\",\n \"declinePending\": \"Declining…\"\n },\n \"errorBoundary\": {\n \"message\": \"Something went wrong while loading this invitation. Please try again.\",\n \"retry\": \"Try again\",\n \"goHome\": \"Go home\"\n },\n \"errors\": {\n \"invitationNotFound\": \"This invitation could not be found. It may have already been used, or the link may be invalid.\",\n \"acceptVerificationFailed\": \"We couldn't confirm you joined the workspace. Please try again or contact the workspace owner.\",\n \"forbidden\": \"You need to be signed in to respond to this invitation.\",\n \"invalidInput\": \"This invitation link looks invalid.\",\n \"providerError\": \"Something went wrong on our end. Please try again.\",\n \"acceptFailed\": \"Failed to accept invitation.\",\n \"declineFailed\": \"Failed to decline invitation.\"\n }\n}\n",
59
+ "type": "registry:file",
60
+ "target": "messages/en/invitation-accept.json"
61
+ }
62
+ ],
63
+ "type": "registry:block"
64
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "language-switcher",
4
+ "title": "Language Switcher",
5
+ "description": "Locale dropdown over the locales i18n/routing.ts declares, labeled with each language's own name. Renders nothing in a single-locale deployment, so it is safe to mount unconditionally (e.g. in shellConfig.headerRight). Adding a locale to routing.ts plus a messages directory is the whole job.",
6
+ "dependencies": [
7
+ "lucide-react",
8
+ "next-intl"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/button",
12
+ "@intelligo/dropdown-menu"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "base/language-switcher/components/language-switcher.tsx",
17
+ "content": "\"use client\";\n\n/**\n * A dropdown over the locales your `i18n/routing.ts` declares.\n *\n * Zero config: locales come from `routing.locales`, and each option is\n * labeled with the language's own name (endonym) via\n * `Intl.DisplayNames` — \"English\", \"Монгол\" — so adding a locale to\n * `routing.ts` + a `messages/<locale>/` directory is the whole job.\n * In a single-locale deployment this renders nothing, which makes it\n * safe to mount unconditionally (e.g. in `shellConfig.headerRight`).\n *\n * Switching navigates to the same pathname under the new locale via\n * `@/i18n/navigation`'s `router.replace` — next-intl's middleware\n * persists the choice in its locale cookie from there.\n */\n\nimport { Languages } from \"lucide-react\";\nimport { useLocale, useTranslations } from \"next-intl\";\n\nimport { routing } from \"@/i18n/routing\";\nimport { usePathname, useRouter } from \"@/i18n/navigation\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nfunction endonym(code: string): string {\n try {\n const label = new Intl.DisplayNames([code], { type: \"language\" }).of(code);\n if (!label) return code;\n return label.charAt(0).toLocaleUpperCase(code) + label.slice(1);\n } catch {\n return code;\n }\n}\n\nexport function LanguageSwitcher() {\n const t = useTranslations(\"language-switcher\");\n const locale = useLocale();\n const router = useRouter();\n const pathname = usePathname();\n\n const locales: readonly string[] = routing.locales;\n if (locales.length < 2) return null;\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger\n render={\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9\"\n aria-label={t(\"label\")}\n />\n }\n >\n <Languages className=\"size-4\" />\n <span className=\"sr-only\">{t(\"label\")}</span>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {locales.map((code) => (\n <DropdownMenuItem\n key={code}\n onClick={() => router.replace(pathname, { locale: code })}\n className={code === locale ? \"bg-accent font-medium\" : \"\"}\n >\n {endonym(code)}\n {code === locale && (\n <span className=\"ml-auto text-xs text-muted-foreground\">✓</span>\n )}\n </DropdownMenuItem>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n",
18
+ "type": "registry:component",
19
+ "target": "components/layout/language-switcher.tsx"
20
+ },
21
+ {
22
+ "path": "base/language-switcher/messages/en.json",
23
+ "content": "{\n \"label\": \"Change language\"\n}\n",
24
+ "type": "registry:file",
25
+ "target": "messages/en/language-switcher.json"
26
+ }
27
+ ],
28
+ "type": "registry:block"
29
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "morphing-modal",
4
+ "title": "Morphing Modal",
5
+ "description": "A card or button that grows into a centred or bottom dialog through a shared layout, cross-fading between views while the panel resizes.",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "motion",
9
+ "lucide-react"
10
+ ],
11
+ "registryDependencies": [
12
+ "@intelligo/ai-motion",
13
+ "@intelligo/button"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "base/ui/morphing-modal/morphing-modal.tsx",
18
+ "content": "\"use client\";\n\n/*\n * A card or button that opens into a dialog: the trigger's surface and the\n * panel share a layoutId, so the panel grows out of the trigger to the\n * centre (or the bottom) of the screen and folds back on close; swapping\n * `viewKey` cross-fades the content while the panel resizes around it.\n */\n\nimport * as React from \"react\";\nimport { Dialog as DialogPrimitive } from \"@base-ui/react/dialog\";\nimport { XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n EASE_OUT,\n SPRING_LAYOUT,\n backdropMotion,\n useOpenState,\n} from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ntype MorphingModalContextValue = {\n open: boolean;\n layoutId: string;\n};\n\nconst MorphingModalContext =\n React.createContext<MorphingModalContextValue | null>(null);\n\nfunction useMorphingModal(component: string) {\n const context = React.useContext(MorphingModalContext);\n if (!context) {\n throw new Error(`${component} must be used within <MorphingModal>`);\n }\n return context;\n}\n\n// The layout morph settles in about this long; the popup stays mounted\n// through it so the panel can fold back into the trigger.\nconst MORPH_HOLD = 0.4;\n\nconst OVERLAY =\n \"fixed inset-0 isolate z-overlay bg-background/60 supports-backdrop-filter:backdrop-blur-sm\";\n\nexport type MorphingModalProps = DialogPrimitive.Root.Props;\n\n/**\n * The root, a modal Base UI Dialog: focus trap, scroll lock, Escape and\n * backdrop dismissal (`disablePointerDismissal` turns the latter off).\n * Controlled or uncontrolled.\n */\nfunction MorphingModal({\n open: openProp,\n defaultOpen,\n onOpenChange,\n children,\n ...props\n}: MorphingModalProps) {\n const [open, setOpen] = useOpenState(openProp, defaultOpen, onOpenChange);\n const layoutId = `morphing-modal-${React.useId()}`;\n const context = React.useMemo(() => ({ open, layoutId }), [open, layoutId]);\n\n return (\n <MorphingModalContext.Provider value={context}>\n <DialogPrimitive.Root\n data-slot=\"morphing-modal\"\n open={open}\n onOpenChange={setOpen}\n {...props}\n >\n {children}\n </DialogPrimitive.Root>\n </MorphingModalContext.Provider>\n );\n}\n\nexport interface MorphingModalTriggerProps\n extends DialogPrimitive.Trigger.Props {\n /** The trigger surface's corner radius, in px. Default 12. */\n radius?: number;\n}\n\n/**\n * The card or button the modal grows out of. Its surface hands over to\n * the panel while open and its content fades, leaving the slot it holds.\n */\nfunction MorphingModalTrigger({\n radius = 12,\n className,\n children,\n ...props\n}: MorphingModalTriggerProps) {\n const context = useMorphingModal(\"MorphingModalTrigger\");\n const reduced = useReducedMotion() ?? false;\n\n return (\n <DialogPrimitive.Trigger\n data-slot=\"morphing-modal-trigger\"\n className={cn(\n \"relative isolate flex flex-col items-start gap-1 p-4 text-left text-sm text-card-foreground outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50\",\n className\n )}\n style={{ borderRadius: radius }}\n {...props}\n >\n {!context.open && (\n <motion.span\n aria-hidden\n layoutId={reduced ? undefined : context.layoutId}\n transition={SPRING_LAYOUT}\n className=\"absolute inset-0 -z-10 border border-border bg-card shadow-xs\"\n style={{ borderRadius: radius }}\n />\n )}\n <span\n className={cn(\n \"flex w-full flex-col items-start gap-1 transition-opacity duration-fast ease-standard motion-reduce:transition-none\",\n context.open && \"opacity-0\"\n )}\n >\n {children}\n </span>\n </DialogPrimitive.Trigger>\n );\n}\n\nexport interface MorphingModalContentProps\n extends Omit<DialogPrimitive.Popup.Props, \"children\" | \"className\"> {\n children?: React.ReactNode;\n className?: string;\n /** The current view. Changing it cross-fades the content and resizes the panel. */\n viewKey?: React.Key;\n /** \"center\" centres the panel; \"bottom\" anchors it to the viewport bottom. */\n placement?: \"center\" | \"bottom\";\n /** Panel corner radius, in px. Default 12. */\n radius?: number;\n /** Render the close control in the panel's top-right corner. Default true. */\n showCloseButton?: boolean;\n labels?: { close?: string };\n}\n\n/** The dialog panel, grown out of the trigger's surface. */\nfunction MorphingModalContent({\n children,\n className,\n viewKey = \"default\",\n placement = \"center\",\n radius = 12,\n showCloseButton = true,\n labels,\n ...props\n}: MorphingModalContentProps) {\n const context = useMorphingModal(\"MorphingModalContent\");\n const reduced = useReducedMotion() ?? false;\n const instant = { duration: 0 };\n\n return (\n <AnimatePresence>\n {context.open && (\n <DialogPrimitive.Portal data-slot=\"morphing-modal-portal\" keepMounted>\n <DialogPrimitive.Backdrop\n data-slot=\"morphing-modal-overlay\"\n render={<motion.div {...backdropMotion(reduced)} />}\n className={OVERLAY}\n />\n {/* The popup is the placing layer; it lets presses through to the\n backdrop, so a press beside the panel still dismisses. Its\n opacity is what Base UI waits on while the panel folds back. */}\n <DialogPrimitive.Popup\n data-slot=\"morphing-modal-content\"\n render={\n <motion.div\n initial={{ opacity: 0.9999 }}\n animate={{ opacity: 1 }}\n exit={{\n opacity: 0.9999,\n transition: { duration: reduced ? 0 : MORPH_HOLD },\n }}\n />\n }\n className={cn(\n \"pointer-events-none fixed inset-4 z-modal flex justify-center outline-none\",\n placement === \"bottom\" ? \"items-end\" : \"items-center\"\n )}\n {...props}\n >\n <motion.div\n layoutId={reduced ? undefined : context.layoutId}\n layout={!reduced}\n transition={SPRING_LAYOUT}\n className={cn(\n \"pointer-events-auto relative flex max-h-full w-full max-w-md flex-col overflow-hidden border border-border bg-popover text-sm text-popover-foreground shadow-2xl\",\n className\n )}\n style={{ borderRadius: radius }}\n >\n <motion.div layout={reduced ? false : \"position\"} className=\"min-h-0 overflow-y-auto p-5\">\n <AnimatePresence mode=\"popLayout\" initial={false}>\n <motion.div\n key={viewKey}\n initial={\n reduced\n ? { opacity: 0 }\n : { opacity: 0, y: 8, filter: \"blur(4px)\" }\n }\n animate={{\n opacity: 1,\n y: 0,\n filter: \"blur(0px)\",\n transition: reduced\n ? instant\n : { duration: 0.24, delay: 0.06, ease: EASE_OUT },\n }}\n exit={\n reduced\n ? { opacity: 0, transition: instant }\n : {\n opacity: 0,\n y: -8,\n filter: \"blur(4px)\",\n transition: { duration: 0.14, ease: EASE_OUT },\n }\n }\n className=\"grid gap-4\"\n >\n {children}\n </motion.div>\n </AnimatePresence>\n </motion.div>\n\n {showCloseButton && (\n <motion.div\n layout={reduced ? false : \"position\"}\n initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}\n animate={{\n opacity: 1,\n scale: 1,\n transition: reduced\n ? instant\n : { delay: 0.16, duration: 0.2, ease: EASE_OUT },\n }}\n exit={{\n opacity: 0,\n transition: reduced ? instant : { duration: 0.1 },\n }}\n className=\"absolute top-3 right-3\"\n >\n <DialogPrimitive.Close\n data-slot=\"morphing-modal-close\"\n render={<Button variant=\"ghost\" size=\"icon-sm\" />}\n >\n <XIcon />\n <span className=\"sr-only\">{labels?.close ?? \"Close\"}</span>\n </DialogPrimitive.Close>\n </motion.div>\n )}\n </motion.div>\n </DialogPrimitive.Popup>\n </DialogPrimitive.Portal>\n )}\n </AnimatePresence>\n );\n}\n\nfunction MorphingModalHeader({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"morphing-modal-header\"\n className={cn(\"flex flex-col gap-1.5 pr-8\", className)}\n {...props}\n />\n );\n}\n\nfunction MorphingModalTitle({\n className,\n ...props\n}: DialogPrimitive.Title.Props) {\n return (\n <DialogPrimitive.Title\n data-slot=\"morphing-modal-title\"\n className={cn(\"font-heading text-base leading-none font-medium\", className)}\n {...props}\n />\n );\n}\n\nfunction MorphingModalDescription({\n className,\n ...props\n}: DialogPrimitive.Description.Props) {\n return (\n <DialogPrimitive.Description\n data-slot=\"morphing-modal-description\"\n className={cn(\"text-sm text-muted-foreground\", className)}\n {...props}\n />\n );\n}\n\nfunction MorphingModalClose(props: DialogPrimitive.Close.Props) {\n return <DialogPrimitive.Close data-slot=\"morphing-modal-close\" {...props} />;\n}\n\nexport {\n MorphingModal,\n MorphingModalClose,\n MorphingModalContent,\n MorphingModalDescription,\n MorphingModalHeader,\n MorphingModalTitle,\n MorphingModalTrigger,\n};\n",
19
+ "type": "registry:ui",
20
+ "target": "components/ui/morphing-modal.tsx"
21
+ }
22
+ ],
23
+ "type": "registry:ui"
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "notification-stack",
4
+ "title": "Notification Stack",
5
+ "description": "Notifications fanned into a stack — the newest in front, the rest peeking out scaled back — that springs apart into a list of openable, dismissable cards on hover or click.",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "motion",
9
+ "lucide-react"
10
+ ],
11
+ "registryDependencies": [
12
+ "@intelligo/ai-motion",
13
+ "@intelligo/button"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "base/ui/notification-stack/notification-stack.tsx",
18
+ "content": "\"use client\";\n\n/*\n * Notifications fanned into a stack: the newest card in front, the next\n * ones peeking out below it, scaled back. Hovering or clicking the stack\n * springs the cards apart into a list; each card then opens and dismisses\n * on its own. Fits a notification popover's width.\n */\n\nimport * as React from \"react\";\nimport { Button as ButtonPrimitive } from \"@base-ui/react/button\";\nimport { BellOffIcon, ChevronDownIcon, XIcon } from \"lucide-react\";\nimport {\n AnimatePresence,\n motion,\n useReducedMotion,\n type Transition,\n} from \"motion/react\";\n\nimport { EASE_OUT, SPRING_LAYOUT, SwapText } from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NotificationStackItem {\n id: string;\n title: React.ReactNode;\n description?: React.ReactNode;\n /** A relative time or date, shown beside the title. */\n time?: React.ReactNode;\n /** An icon or avatar node, shown at the start of the card. */\n icon?: React.ReactNode;\n unread?: boolean;\n}\n\nexport interface NotificationStackLabels {\n /** The toggle while the stack is folded. */\n expand: string;\n /** The toggle while the stack is fanned out. */\n collapse: string;\n /** Shown in place of the stack when there is nothing to show. */\n empty: string;\n /** Accessible name of each card's dismiss button. */\n dismiss: string;\n /** Accessible name of the list, given the number of notifications. */\n list: (count: number) => string;\n /** Read out beside the unread marker. */\n unread: string;\n}\n\nconst DEFAULT_LABELS: NotificationStackLabels = {\n expand: \"Show all\",\n collapse: \"Show less\",\n empty: \"You're all caught up\",\n dismiss: \"Dismiss notification\",\n list: (count) => `${count} ${count === 1 ? \"notification\" : \"notifications\"}`,\n unread: \"Unread\",\n};\n\nexport interface NotificationStackProps\n extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n items: NotificationStackItem[];\n /** Fanned out (controlled). */\n expanded?: boolean;\n defaultExpanded?: boolean;\n onExpandedChange?: (expanded: boolean) => void;\n /** Called when a card is chosen; without it cards are not interactive. */\n onItemClick?: (item: NotificationStackItem) => void;\n /** Called from a card's dismiss button; without it there is none. */\n onDismiss?: (item: NotificationStackItem) => void;\n /** How many cards show in the folded stack, the front one included. */\n maxVisible?: number;\n /** Fan out while a mouse hovers the stack. */\n expandOnHover?: boolean;\n labels?: Partial<NotificationStackLabels>;\n}\n\n/** How far each card behind the front one peeks out, in px. */\nconst PEEK = 8;\n/** How much smaller each card behind the front one is. */\nconst SCALE_STEP = 0.05;\n\nfunction useExpanded(\n expanded: boolean | undefined,\n defaultExpanded: boolean,\n onExpandedChange: ((expanded: boolean) => void) | undefined\n) {\n const [uncontrolled, setUncontrolled] = React.useState(defaultExpanded);\n const controlled = expanded !== undefined;\n const setExpanded = React.useCallback(\n (next: boolean) => {\n if (!controlled) setUncontrolled(next);\n onExpandedChange?.(next);\n },\n [controlled, onExpandedChange]\n );\n return [controlled ? expanded : uncontrolled, setExpanded] as const;\n}\n\nfunction NotificationCardBody({\n item,\n labels,\n}: {\n item: NotificationStackItem;\n labels: NotificationStackLabels;\n}) {\n return (\n <>\n {item.icon ? (\n <span className=\"grid size-8 shrink-0 place-items-center overflow-hidden rounded-full bg-muted text-muted-foreground [&_svg:not([class*='size-'])]:size-4\">\n {item.icon}\n </span>\n ) : null}\n <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <span className=\"flex min-w-0 items-baseline gap-2\">\n <span\n className={cn(\n \"min-w-0 flex-1 truncate text-sm leading-5 text-foreground\",\n item.unread ? \"font-semibold\" : \"font-medium\"\n )}\n >\n {item.title}\n </span>\n {item.time ? (\n <span className=\"shrink-0 text-xs text-muted-foreground\">\n {item.time}\n </span>\n ) : null}\n </span>\n {item.description ? (\n <span className=\"line-clamp-2 text-xs leading-4 text-muted-foreground\">\n {item.description}\n </span>\n ) : null}\n </span>\n {item.unread ? (\n <span className=\"mt-1.5 size-2 shrink-0 rounded-full bg-primary\">\n <span className=\"sr-only\">{labels.unread}</span>\n </span>\n ) : null}\n </>\n );\n}\n\nfunction NotificationStack({\n items,\n expanded: expandedProp,\n defaultExpanded = false,\n onExpandedChange,\n onItemClick,\n onDismiss,\n maxVisible = 3,\n expandOnHover = true,\n labels: labelOverrides,\n className,\n onPointerEnter,\n onPointerLeave,\n ...props\n}: NotificationStackProps) {\n const reduced = useReducedMotion() ?? false;\n const labels = React.useMemo(\n () => ({ ...DEFAULT_LABELS, ...labelOverrides }),\n [labelOverrides]\n );\n const listId = React.useId();\n const rootRef = React.useRef<HTMLDivElement>(null);\n // Set when a hovering mouse fanned the stack out: only then does the\n // mouse leaving fold it back. A click that opened it keeps it open.\n const openedByHover = React.useRef(false);\n const [expanded, setExpanded] = useExpanded(\n expandedProp,\n defaultExpanded,\n onExpandedChange\n );\n\n const canStack = items.length > 1;\n const stacked = canStack && !expanded;\n const peeking = Math.min(items.length, Math.max(1, maxVisible)) - 1;\n\n const cardTransition: Transition = reduced\n ? { duration: 0 }\n : { ...SPRING_LAYOUT, opacity: { duration: 0.2, ease: EASE_OUT } };\n\n if (items.length === 0) {\n return (\n <div\n data-slot=\"notification-stack\"\n className={cn(\n \"flex w-full items-center justify-center gap-2 rounded-lg bg-muted px-4 py-8 text-sm font-medium text-muted-foreground\",\n className\n )}\n {...props}\n >\n <BellOffIcon className=\"size-4\" aria-hidden />\n {labels.empty}\n </div>\n );\n }\n\n const toggle = () => {\n openedByHover.current = false;\n setExpanded(!expanded);\n };\n\n return (\n <div\n ref={rootRef}\n data-slot=\"notification-stack\"\n data-state={stacked ? \"stacked\" : \"expanded\"}\n className={cn(\"flex w-full flex-col gap-1.5\", className)}\n onPointerEnter={(event) => {\n onPointerEnter?.(event);\n if (!expandOnHover || event.pointerType !== \"mouse\") return;\n if (stacked) {\n openedByHover.current = true;\n setExpanded(true);\n }\n }}\n onPointerLeave={(event) => {\n onPointerLeave?.(event);\n if (event.pointerType !== \"mouse\" || !openedByHover.current) return;\n // Keyboard focus inside the list keeps it open.\n if (rootRef.current?.contains(document.activeElement)) return;\n openedByHover.current = false;\n setExpanded(false);\n }}\n {...props}\n >\n <motion.ul\n id={listId}\n aria-label={labels.list(items.length)}\n initial={false}\n animate={{ marginBottom: stacked ? peeking * PEEK : 0 }}\n transition={cardTransition}\n // Folded, the whole stack is one target that fans it out; the\n // toggle below is its keyboard and screen-reader route.\n onClick={stacked ? () => setExpanded(true) : undefined}\n className={cn(\n \"relative flex flex-col gap-2\",\n stacked && \"cursor-pointer\"\n )}\n >\n <AnimatePresence initial={false}>\n {items.map((item, index) => {\n const behind = stacked && index > 0;\n const depth = stacked ? Math.min(index, peeking) : 0;\n const hidden = stacked && index > peeking;\n const interactive = !stacked;\n\n return (\n <motion.li\n key={item.id}\n layout={!reduced}\n initial={reduced ? { opacity: 0 } : { opacity: 0, y: -8 }}\n animate={{\n opacity: hidden ? 0 : 1,\n y: depth * PEEK,\n scale: 1 - depth * SCALE_STEP,\n }}\n exit={\n reduced\n ? { opacity: 0, transition: { duration: 0 } }\n : {\n opacity: 0,\n x: 24,\n transition: { duration: 0.16, ease: EASE_OUT },\n }\n }\n transition={cardTransition}\n inert={behind}\n aria-hidden={behind || undefined}\n data-unread={item.unread || undefined}\n className={cn(\n \"group/notification relative flex overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-xs\",\n behind && \"absolute inset-0\"\n )}\n style={{\n zIndex: items.length - index,\n transformOrigin: \"bottom center\",\n }}\n >\n <div\n className={cn(\n \"flex min-w-0 flex-1\",\n behind && \"invisible\"\n )}\n >\n {interactive && onItemClick ? (\n <ButtonPrimitive\n className=\"flex min-w-0 flex-1 items-start gap-3 rounded-xl p-3 text-left outline-none transition-colors duration-fast hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n onClick={() => onItemClick(item)}\n >\n <NotificationCardBody item={item} labels={labels} />\n </ButtonPrimitive>\n ) : (\n <div className=\"flex min-w-0 flex-1 items-start gap-3 p-3\">\n <NotificationCardBody item={item} labels={labels} />\n </div>\n )}\n {interactive && onDismiss ? (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={labels.dismiss}\n className=\"absolute top-2 right-2 bg-card opacity-0 group-focus-within/notification:opacity-100 group-hover/notification:opacity-100 pointer-coarse:opacity-100\"\n onClick={() => onDismiss(item)}\n >\n <XIcon />\n </Button>\n ) : null}\n </div>\n </motion.li>\n );\n })}\n </AnimatePresence>\n </motion.ul>\n\n {canStack ? (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n aria-expanded={!stacked}\n aria-controls={listId}\n onClick={toggle}\n className=\"self-start\"\n >\n <span className=\"grid h-5 min-w-5 place-items-center rounded-full bg-primary px-1.5 text-xs font-medium text-primary-foreground tabular-nums\">\n {items.length}\n </span>\n <SwapText value={stacked ? \"expand\" : \"collapse\"}>\n {stacked ? labels.expand : labels.collapse}\n </SwapText>\n <motion.span\n aria-hidden\n className=\"inline-flex\"\n initial={false}\n animate={{ rotate: stacked ? 0 : 180 }}\n transition={reduced ? { duration: 0 } : SPRING_LAYOUT}\n >\n <ChevronDownIcon className=\"size-3.5\" />\n </motion.span>\n </Button>\n ) : null}\n </div>\n );\n}\n\nexport { NotificationStack };\n",
19
+ "type": "registry:ui",
20
+ "target": "components/ui/notification-stack.tsx"
21
+ }
22
+ ],
23
+ "type": "registry:ui"
24
+ }