@intelligo-dev/cli 1.0.0-beta.13 → 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 (171) hide show
  1. package/README.md +41 -2
  2. package/dist/bin.js +37 -0
  3. package/dist/bin.js.map +1 -1
  4. package/dist/commands/add.d.ts +4 -0
  5. package/dist/commands/add.d.ts.map +1 -1
  6. package/dist/commands/add.js +9 -0
  7. package/dist/commands/add.js.map +1 -1
  8. package/dist/commands/create-flow.d.ts +4 -1
  9. package/dist/commands/create-flow.d.ts.map +1 -1
  10. package/dist/commands/create-flow.js +63 -31
  11. package/dist/commands/create-flow.js.map +1 -1
  12. package/dist/commands/create.d.ts +8 -4
  13. package/dist/commands/create.d.ts.map +1 -1
  14. package/dist/commands/create.js +15 -7
  15. package/dist/commands/create.js.map +1 -1
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +21 -19
  18. package/dist/commands/doctor.js.map +1 -1
  19. package/dist/commands/migrate-check.d.ts +20 -1
  20. package/dist/commands/migrate-check.d.ts.map +1 -1
  21. package/dist/commands/migrate-check.js +35 -6
  22. package/dist/commands/migrate-check.js.map +1 -1
  23. package/dist/commands/migrate.d.ts.map +1 -1
  24. package/dist/commands/migrate.js +2 -8
  25. package/dist/commands/migrate.js.map +1 -1
  26. package/dist/commands/sync-messages.d.ts +37 -0
  27. package/dist/commands/sync-messages.d.ts.map +1 -0
  28. package/dist/commands/sync-messages.js +88 -0
  29. package/dist/commands/sync-messages.js.map +1 -0
  30. package/dist/commands/sync-scaffold.d.ts +36 -0
  31. package/dist/commands/sync-scaffold.d.ts.map +1 -0
  32. package/dist/commands/sync-scaffold.js +53 -0
  33. package/dist/commands/sync-scaffold.js.map +1 -0
  34. package/dist/commands/sync.d.ts +103 -0
  35. package/dist/commands/sync.d.ts.map +1 -0
  36. package/dist/commands/sync.js +361 -0
  37. package/dist/commands/sync.js.map +1 -0
  38. package/dist/commands/upgrade-check.d.ts.map +1 -1
  39. package/dist/commands/upgrade-check.js +4 -1
  40. package/dist/commands/upgrade-check.js.map +1 -1
  41. package/dist/manifest.d.ts +27 -0
  42. package/dist/manifest.d.ts.map +1 -1
  43. package/dist/manifest.js +30 -3
  44. package/dist/manifest.js.map +1 -1
  45. package/dist/module-exports.d.ts +63 -0
  46. package/dist/module-exports.d.ts.map +1 -0
  47. package/dist/module-exports.js +231 -0
  48. package/dist/module-exports.js.map +1 -0
  49. package/dist/registry-bundle.d.ts +43 -0
  50. package/dist/registry-bundle.d.ts.map +1 -0
  51. package/dist/registry-bundle.js +74 -0
  52. package/dist/registry-bundle.js.map +1 -0
  53. package/dist/registry-items.d.ts +41 -13
  54. package/dist/registry-items.d.ts.map +1 -1
  55. package/dist/registry-items.js +117 -33
  56. package/dist/registry-items.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/bin.ts +52 -0
  59. package/src/commands/add.ts +11 -0
  60. package/src/commands/create-flow.ts +82 -26
  61. package/src/commands/create.ts +17 -7
  62. package/src/commands/doctor.ts +27 -27
  63. package/src/commands/migrate-check.ts +51 -10
  64. package/src/commands/migrate.ts +2 -8
  65. package/src/commands/sync-messages.ts +114 -0
  66. package/src/commands/sync-scaffold.ts +83 -0
  67. package/src/commands/sync.ts +534 -0
  68. package/src/commands/upgrade-check.ts +4 -1
  69. package/src/manifest.ts +57 -3
  70. package/src/module-exports.ts +266 -0
  71. package/src/registry-bundle.ts +103 -0
  72. package/src/registry-items.ts +141 -36
  73. package/templates/app-scaffold/intelligo.ts.tpl +6 -47
  74. package/templates/manifest.json +19 -0
  75. package/templates/registry/ai-agent-activity.json +23 -0
  76. package/templates/registry/ai-agent-progress.json +21 -0
  77. package/templates/registry/ai-approval-card.json +27 -0
  78. package/templates/registry/ai-artifact.json +22 -0
  79. package/templates/registry/ai-branch.json +21 -0
  80. package/templates/registry/ai-citations.json +24 -0
  81. package/templates/registry/ai-code-block.json +23 -0
  82. package/templates/registry/ai-composer-menu.json +19 -0
  83. package/templates/registry/ai-file-diff.json +25 -0
  84. package/templates/registry/ai-image-generation.json +23 -0
  85. package/templates/registry/ai-markdown.json +23 -0
  86. package/templates/registry/ai-message-bubble.json +23 -0
  87. package/templates/registry/ai-message-scroller.json +22 -0
  88. package/templates/registry/ai-message.json +21 -0
  89. package/templates/registry/ai-motion.json +19 -0
  90. package/templates/registry/ai-prompt-input.json +28 -0
  91. package/templates/registry/ai-reasoning-text.json +22 -0
  92. package/templates/registry/ai-reasoning.json +22 -0
  93. package/templates/registry/ai-shimmer-text.json +19 -0
  94. package/templates/registry/ai-sidebar.json +25 -0
  95. package/templates/registry/ai-speech-input.json +21 -0
  96. package/templates/registry/ai-streaming-response.json +24 -0
  97. package/templates/registry/ai-suggestion.json +19 -0
  98. package/templates/registry/ai-todo-list.json +22 -0
  99. package/templates/registry/ai-tool-approval.json +26 -0
  100. package/templates/registry/ai-tool-result.json +25 -0
  101. package/templates/registry/alert-dialog.json +23 -0
  102. package/templates/registry/animated-list.json +21 -0
  103. package/templates/registry/app-shell.json +93 -0
  104. package/templates/registry/artifacts.json +82 -0
  105. package/templates/registry/attachment.json +22 -0
  106. package/templates/registry/auth-email-verification.json +39 -0
  107. package/templates/registry/auth-login.json +72 -0
  108. package/templates/registry/auth-password-reset.json +53 -0
  109. package/templates/registry/auth-signup.json +41 -0
  110. package/templates/registry/billing-settings.json +60 -0
  111. package/templates/registry/button.json +22 -0
  112. package/templates/registry/chat-eve.json +19 -0
  113. package/templates/registry/chat-panel.json +30 -0
  114. package/templates/registry/chat-share.json +42 -0
  115. package/templates/registry/chat-widget.json +34 -0
  116. package/templates/registry/chat.json +326 -0
  117. package/templates/registry/checkbox.json +22 -0
  118. package/templates/registry/checkout.json +40 -0
  119. package/templates/registry/collapsible.json +19 -0
  120. package/templates/registry/command.json +23 -0
  121. package/templates/registry/copy-button.json +21 -0
  122. package/templates/registry/dashboard.json +69 -0
  123. package/templates/registry/dialog.json +24 -0
  124. package/templates/registry/document-viewer.json +22 -0
  125. package/templates/registry/dropdown-menu.json +23 -0
  126. package/templates/registry/expandable-tabs.json +22 -0
  127. package/templates/registry/feature-gating.json +73 -0
  128. package/templates/registry/file-upload.json +24 -0
  129. package/templates/registry/hold-action-button.json +22 -0
  130. package/templates/registry/input-group.json +23 -0
  131. package/templates/registry/input.json +19 -0
  132. package/templates/registry/intelligo.json +187 -0
  133. package/templates/registry/invitation-accept.json +64 -0
  134. package/templates/registry/language-switcher.json +29 -0
  135. package/templates/registry/morphing-modal.json +24 -0
  136. package/templates/registry/notification-stack.json +24 -0
  137. package/templates/registry/notifications.json +87 -0
  138. package/templates/registry/onboarding.json +83 -0
  139. package/templates/registry/otp-input.json +22 -0
  140. package/templates/registry/page-header.json +15 -0
  141. package/templates/registry/payment-poll.json +52 -0
  142. package/templates/registry/popover-morph.json +22 -0
  143. package/templates/registry/popover.json +22 -0
  144. package/templates/registry/pricing.json +111 -0
  145. package/templates/registry/privacy-settings.json +65 -0
  146. package/templates/registry/profile-settings.json +68 -0
  147. package/templates/registry/progress.json +19 -0
  148. package/templates/registry/radio-group.json +22 -0
  149. package/templates/registry/registry.json +2945 -0
  150. package/templates/registry/route-error.json +65 -0
  151. package/templates/registry/select-morph.json +23 -0
  152. package/templates/registry/select.json +23 -0
  153. package/templates/registry/settings-shell.json +47 -0
  154. package/templates/registry/sheet.json +24 -0
  155. package/templates/registry/sidebar.json +28 -0
  156. package/templates/registry/smoke.json +34 -0
  157. package/templates/registry/spinner.json +16 -0
  158. package/templates/registry/stat-card.json +18 -0
  159. package/templates/registry/status-badge.json +18 -0
  160. package/templates/registry/switch.json +20 -0
  161. package/templates/registry/tabs.json +23 -0
  162. package/templates/registry/team-settings.json +97 -0
  163. package/templates/registry/textarea.json +16 -0
  164. package/templates/registry/tooltip.json +22 -0
  165. package/templates/registry/trial-banner.json +43 -0
  166. package/templates/registry/usage.json +84 -0
  167. package/templates/registry/workspace-settings.json +71 -0
  168. package/templates/registry-items.json +1 -1
  169. package/templates/registry-requires.json +26 -2
  170. package/templates/vitest/server-only.ts.tpl +7 -0
  171. package/templates/vitest/vitest.config.ts.tpl +37 -0
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "checkbox",
4
+ "title": "Checkbox",
5
+ "description": "A checkbox whose check draws itself in, with an indeterminate state.",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "motion"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/ai-motion"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "base/ui/checkbox/checkbox.tsx",
16
+ "content": "\"use client\";\n\n/*\n * The checkbox: the box fills and the check draws itself in.\n */\n\nimport { Checkbox as CheckboxPrimitive } from \"@base-ui/react/checkbox\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nimport { EASE_OUT } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\nfunction Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {\n const reduced = useReducedMotion() ?? false;\n\n return (\n <CheckboxPrimitive.Root\n data-slot=\"checkbox\"\n className={cn(\n \"peer relative flex size-4 shrink-0 items-center justify-center rounded-sm border border-input bg-card transition-[background-color,border-color,box-shadow,scale] duration-fast ease-standard outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 hover:border-ring/60 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/40 active:scale-92 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground data-indeterminate:border-primary data-indeterminate:bg-primary data-indeterminate:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary\",\n className\n )}\n {...props}\n >\n <CheckboxPrimitive.Indicator\n data-slot=\"checkbox-indicator\"\n keepMounted\n className=\"group/checkbox-indicator grid place-content-center text-current\"\n render={(indicatorProps, state) => (\n <span {...indicatorProps}>\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={3}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden\n className=\"size-3.5\"\n >\n {/* The tick draws itself in; the dash grows from the centre. */}\n <motion.path\n d=\"M5 13l4 4L19 7\"\n initial={false}\n animate={{\n pathLength: state.checked && !state.indeterminate ? 1 : 0,\n opacity: state.checked && !state.indeterminate ? 1 : 0,\n }}\n transition={\n reduced ? { duration: 0 } : { duration: 0.24, ease: EASE_OUT }\n }\n />\n <motion.path\n d=\"M6 12h12\"\n initial={false}\n animate={{\n scaleX: state.indeterminate ? 1 : 0,\n opacity: state.indeterminate ? 1 : 0,\n }}\n transition={\n reduced ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }\n }\n />\n </svg>\n </span>\n )}\n />\n </CheckboxPrimitive.Root>\n );\n}\n\nexport { Checkbox };\n",
17
+ "type": "registry:ui",
18
+ "target": "components/ui/checkbox.tsx"
19
+ }
20
+ ],
21
+ "type": "registry:ui"
22
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "checkout",
4
+ "title": "Checkout",
5
+ "description": "Checkout-success page that verifies the Stripe session directly through the framework's checkout service, covering the webhook-race window between the browser redirect and Stripe's webhook delivery.",
6
+ "dependencies": [
7
+ "@intelligo-dev/auth",
8
+ "@intelligo-dev/billing",
9
+ "lucide-react",
10
+ "next-intl"
11
+ ],
12
+ "registryDependencies": [
13
+ "alert",
14
+ "@intelligo/button",
15
+ "card",
16
+ "@intelligo/spinner",
17
+ "@intelligo/status-badge"
18
+ ],
19
+ "files": [
20
+ {
21
+ "path": "base/checkout/success-page.tsx",
22
+ "content": "import { CheckCircle2 } from \"lucide-react\";\nimport { getLocale, getTranslations } from \"next-intl/server\";\n\nimport { requireWorkspace } from \"@intelligo-dev/auth\";\nimport { getCheckoutSession } from \"@intelligo-dev/billing\";\n\nimport { Link, redirect } from \"@/i18n/navigation\";\nimport { StatusBadge } from \"@/components/ui/status-badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Card,\n CardContent,\n CardDescription,\n CardFooter,\n CardHeader,\n CardTitle,\n} from \"@/components/ui/card\";\nimport { Spinner } from \"@/components/ui/spinner\";\n\ninterface CheckoutSuccessPageProps {\n searchParams: Promise<{ session_id?: string }>;\n}\n\n/**\n * Verifies the checkout through `getCheckoutSession`\n * (`@intelligo-dev/billing`) rather than the workspace's local\n * subscription row: Stripe's webhook can land after the browser's\n * redirect here, and reading the checkout session shows the right plan\n * regardless of webhook timing.\n */\nexport default async function CheckoutSuccessPage({\n searchParams,\n}: CheckoutSuccessPageProps) {\n const t = await getTranslations(\"checkout\");\n const { session_id: sessionId } = await searchParams;\n\n if (!sessionId) {\n redirect({ href: \"/pricing\", locale: await getLocale() });\n return null; // redirect throws; this narrows sessionId for TS\n }\n\n const { workspace } = await requireWorkspace();\n\n try {\n const session = await getCheckoutSession({\n sessionId,\n workspaceId: workspace.id,\n });\n\n if (session.status === \"complete\" && session.isSubscriptionActive) {\n return (\n <div className=\"container max-w-2xl py-16\">\n <Card className=\"border-success/30\">\n <CardHeader className=\"text-center\">\n <div className=\"mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-success/10\">\n <CheckCircle2 className=\"size-10 text-success\" />\n </div>\n <CardTitle className=\"text-3xl\">{t(\"success.title\")}</CardTitle>\n <CardDescription className=\"text-base\">\n {session.planName\n ? t(\"success.planActive\", { planName: session.planName })\n : t(\"success.subscriptionActive\")}\n </CardDescription>\n </CardHeader>\n\n <CardContent className=\"space-y-6\">\n <div className=\"rounded-lg border bg-background p-4\">\n <div className=\"flex items-center justify-between\">\n <div>\n {session.planName && (\n <p className=\"font-medium\">\n {t(\"success.planLabel\", { planName: session.planName })}\n </p>\n )}\n {session.billingInterval && (\n <p className=\"text-sm text-muted-foreground\">\n {session.billingInterval === \"month\"\n ? t(\"success.billedMonthly\")\n : t(\"success.billedYearly\")}\n </p>\n )}\n </div>\n <StatusBadge status=\"success\" dot>\n {t(\"success.status\")}\n </StatusBadge>\n </div>\n </div>\n\n {session.customerEmail && (\n <p className=\"text-center text-sm text-muted-foreground\">\n {t(\"success.receiptSent\", { email: session.customerEmail })}\n </p>\n )}\n </CardContent>\n\n <CardFooter className=\"flex flex-col gap-3 sm:flex-row\">\n <Button\n className=\"w-full sm:flex-1\"\n render={<Link href=\"/dashboard\" />}\n nativeButton={false}\n >\n {t(\"actions.goToDashboard\")}\n </Button>\n <Button\n variant=\"outline\"\n className=\"w-full sm:flex-1\"\n render={<Link href=\"/settings/billing\" />}\n nativeButton={false}\n >\n {t(\"actions.viewBillingSettings\")}\n </Button>\n </CardFooter>\n </Card>\n </div>\n );\n }\n\n if (session.status !== \"complete\") {\n return (\n <div className=\"container max-w-2xl py-16\">\n <Card>\n <CardHeader className=\"text-center\">\n <CardTitle>{t(\"pending.title\")}</CardTitle>\n <CardDescription>{t(\"pending.description\")}</CardDescription>\n </CardHeader>\n <CardContent className=\"flex justify-center\">\n <Spinner className=\"size-8 text-muted-foreground\" />\n </CardContent>\n <CardFooter className=\"justify-center\">\n <Button\n variant=\"outline\"\n render={<Link href=\"/settings/billing\" />}\n nativeButton={false}\n >\n {t(\"actions.checkBillingSettings\")}\n </Button>\n </CardFooter>\n </Card>\n </div>\n );\n }\n\n return (\n <div className=\"container max-w-2xl py-16\">\n <Card>\n <CardHeader className=\"text-center\">\n <CardTitle>{t(\"received.title\")}</CardTitle>\n <CardDescription>{t(\"received.description\")}</CardDescription>\n </CardHeader>\n <CardFooter className=\"justify-center gap-3\">\n <Button render={<Link href=\"/dashboard\" />} nativeButton={false}>\n {t(\"actions.goToDashboard\")}\n </Button>\n <Button\n variant=\"outline\"\n render={<Link href=\"/settings/billing\" />}\n nativeButton={false}\n >\n {t(\"actions.viewBillingSettings\")}\n </Button>\n </CardFooter>\n </Card>\n </div>\n );\n } catch {\n // session_not_found (a stale/invalid session id) or a Stripe\n // outage — either way, the customer likely did pay. Show a\n // generic thank-you and point at billing settings rather than\n // surfacing a Stripe error to someone who just completed checkout.\n return (\n <div className=\"container max-w-2xl py-16\">\n <Card>\n <CardHeader className=\"text-center\">\n <div className=\"mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-success/10\">\n <CheckCircle2 className=\"size-10 text-success\" />\n </div>\n <CardTitle>{t(\"fallback.title\")}</CardTitle>\n <CardDescription>{t(\"fallback.description\")}</CardDescription>\n </CardHeader>\n <CardContent className=\"text-center\">\n <p className=\"text-sm text-muted-foreground\">\n {t(\"fallback.note\")}\n </p>\n </CardContent>\n <CardFooter className=\"justify-center gap-3\">\n <Button render={<Link href=\"/dashboard\" />} nativeButton={false}>\n {t(\"actions.goToDashboard\")}\n </Button>\n <Button\n variant=\"outline\"\n render={<Link href=\"/settings/billing\" />}\n nativeButton={false}\n >\n {t(\"actions.viewBillingSettings\")}\n </Button>\n </CardFooter>\n </Card>\n </div>\n );\n }\n}\n",
23
+ "type": "registry:page",
24
+ "target": "app/[locale]/(app)/checkout/success/page.tsx"
25
+ },
26
+ {
27
+ "path": "base/checkout/error.tsx",
28
+ "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 CheckoutSuccessError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n const t = useTranslations(\"checkout\");\n\n useEffect(() => {\n console.error(\"[checkout/success] 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(\"error.description\")}</AlertDescription>\n </Alert>\n\n <div className=\"flex justify-center gap-3\">\n <Button onClick={reset}>{t(\"actions.tryAgain\")}</Button>\n <Button\n variant=\"outline\"\n render={<Link href=\"/settings/billing\" />}\n nativeButton={false}\n >\n {t(\"actions.viewBillingSettings\")}\n </Button>\n </div>\n </div>\n </div>\n );\n}\n",
29
+ "type": "registry:page",
30
+ "target": "app/[locale]/(app)/checkout/success/error.tsx"
31
+ },
32
+ {
33
+ "path": "base/checkout/messages/en.json",
34
+ "content": "{\n \"actions\": {\n \"tryAgain\": \"Try again\",\n \"goToDashboard\": \"Go to dashboard\",\n \"viewBillingSettings\": \"View billing settings\",\n \"checkBillingSettings\": \"Check billing settings\"\n },\n \"error\": {\n \"description\": \"Something went wrong while confirming your payment. If you were charged, it will still be applied — check your billing settings.\"\n },\n \"success\": {\n \"title\": \"You're all set\",\n \"subscriptionActive\": \"Your subscription is active.\",\n \"planActive\": \"You're now on the {planName} plan.\",\n \"planLabel\": \"{planName} plan\",\n \"billedMonthly\": \"Billed monthly\",\n \"billedYearly\": \"Billed yearly\",\n \"status\": \"Active\",\n \"receiptSent\": \"A receipt was sent to {email}.\"\n },\n \"pending\": {\n \"title\": \"Finishing up\",\n \"description\": \"We're confirming your payment. This usually takes a few seconds.\"\n },\n \"received\": {\n \"title\": \"Payment received\",\n \"description\": \"Your payment went through. It may take a moment to reflect in your billing settings.\"\n },\n \"fallback\": {\n \"title\": \"Thank you\",\n \"description\": \"Your payment is being processed.\",\n \"note\": \"Check your billing settings for the latest status.\"\n }\n}\n",
35
+ "type": "registry:file",
36
+ "target": "messages/en/checkout.json"
37
+ }
38
+ ],
39
+ "type": "registry:block"
40
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "collapsible",
4
+ "title": "Collapsible",
5
+ "description": "Content that eases open and closed from its measured height.",
6
+ "dependencies": [
7
+ "@base-ui/react"
8
+ ],
9
+ "registryDependencies": [],
10
+ "files": [
11
+ {
12
+ "path": "base/ui/collapsible/collapsible.tsx",
13
+ "content": "\"use client\";\n\n/*\n * The collapsible: the panel's height eases open and closed from Base UI's\n * measured height instead of snapping.\n */\n\nimport { Collapsible as CollapsiblePrimitive } from \"@base-ui/react/collapsible\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {\n return <CollapsiblePrimitive.Root data-slot=\"collapsible\" {...props} />;\n}\n\nfunction CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {\n return (\n <CollapsiblePrimitive.Trigger data-slot=\"collapsible-trigger\" {...props} />\n );\n}\n\nfunction CollapsibleContent({\n className,\n ...props\n}: CollapsiblePrimitive.Panel.Props) {\n return (\n <CollapsiblePrimitive.Panel\n data-slot=\"collapsible-content\"\n className={cn(\n \"h-(--collapsible-panel-height) overflow-hidden transition-[height,opacity] duration-slow ease-emphasized data-ending-style:h-0 data-ending-style:opacity-0 data-ending-style:duration-normal data-ending-style:ease-exit data-starting-style:h-0 data-starting-style:opacity-0\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent };\n",
14
+ "type": "registry:ui",
15
+ "target": "components/ui/collapsible.tsx"
16
+ }
17
+ ],
18
+ "type": "registry:ui"
19
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "command",
4
+ "title": "Command",
5
+ "description": "A searchable list of actions, inline or in a dialog.",
6
+ "dependencies": [
7
+ "cmdk",
8
+ "lucide-react"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/dialog",
12
+ "@intelligo/input-group"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "base/ui/command/command.tsx",
17
+ "content": "\"use client\";\n\n/*\n * The command palette: a searchable list of actions, in the dialog.\n */\n\nimport * as React from \"react\";\nimport { Command as CommandPrimitive } from \"cmdk\";\n\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { InputGroup, InputGroupAddon } from \"@/components/ui/input-group\";\nimport { SearchIcon, CheckIcon } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nfunction Command({\n className,\n ...props\n}: React.ComponentProps<typeof CommandPrimitive>) {\n return (\n <CommandPrimitive\n data-slot=\"command\"\n className={cn(\n \"flex size-full flex-col overflow-hidden rounded-lg! bg-popover p-1 text-popover-foreground\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction CommandDialog({\n title = \"Command Palette\",\n description = \"Search for a command to run...\",\n children,\n className,\n showCloseButton = false,\n ...props\n}: Omit<React.ComponentProps<typeof Dialog>, \"children\"> & {\n title?: string;\n description?: string;\n className?: string;\n showCloseButton?: boolean;\n children: React.ReactNode;\n}) {\n return (\n <Dialog {...props}>\n <DialogHeader className=\"sr-only\">\n <DialogTitle>{title}</DialogTitle>\n <DialogDescription>{description}</DialogDescription>\n </DialogHeader>\n <DialogContent\n className={cn(\n \"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0\",\n className\n )}\n showCloseButton={showCloseButton}\n >\n {children}\n </DialogContent>\n </Dialog>\n );\n}\n\nfunction CommandInput({\n className,\n ...props\n}: React.ComponentProps<typeof CommandPrimitive.Input>) {\n return (\n <div data-slot=\"command-input-wrapper\" className=\"p-1 pb-0\">\n <InputGroup className=\"h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!\">\n <CommandPrimitive.Input\n data-slot=\"command-input\"\n className={cn(\n \"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50\",\n className\n )}\n {...props}\n />\n <InputGroupAddon>\n <SearchIcon className=\"size-4 shrink-0 opacity-50\" />\n </InputGroupAddon>\n </InputGroup>\n </div>\n );\n}\n\nfunction CommandList({\n className,\n ...props\n}: React.ComponentProps<typeof CommandPrimitive.List>) {\n return (\n <CommandPrimitive.List\n data-slot=\"command-list\"\n className={cn(\n \"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction CommandEmpty({\n className,\n ...props\n}: React.ComponentProps<typeof CommandPrimitive.Empty>) {\n return (\n <CommandPrimitive.Empty\n data-slot=\"command-empty\"\n className={cn(\"py-6 text-center text-sm\", className)}\n {...props}\n />\n );\n}\n\nfunction CommandGroup({\n className,\n ...props\n}: React.ComponentProps<typeof CommandPrimitive.Group>) {\n return (\n <CommandPrimitive.Group\n data-slot=\"command-group\"\n className={cn(\n \"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction CommandSeparator({\n className,\n ...props\n}: React.ComponentProps<typeof CommandPrimitive.Separator>) {\n return (\n <CommandPrimitive.Separator\n data-slot=\"command-separator\"\n className={cn(\"-mx-1 h-px bg-border\", className)}\n {...props}\n />\n );\n}\n\nfunction CommandItem({\n className,\n children,\n ...props\n}: React.ComponentProps<typeof CommandPrimitive.Item>) {\n return (\n <CommandPrimitive.Item\n data-slot=\"command-item\"\n className={cn(\n \"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground\",\n className\n )}\n {...props}\n >\n {children}\n <CheckIcon className=\"ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100\" />\n </CommandPrimitive.Item>\n );\n}\n\nfunction CommandShortcut({\n className,\n ...props\n}: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"command-shortcut\"\n className={cn(\n \"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport {\n Command,\n CommandDialog,\n CommandInput,\n CommandList,\n CommandEmpty,\n CommandGroup,\n CommandItem,\n CommandShortcut,\n CommandSeparator,\n};\n",
18
+ "type": "registry:ui",
19
+ "target": "components/ui/command.tsx"
20
+ }
21
+ ],
22
+ "type": "registry:ui"
23
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "copy-button",
4
+ "title": "Copy Button",
5
+ "description": "Copy to the clipboard with confirmation and a live announcement; labels are passed in, so it carries no copy of its own.",
6
+ "dependencies": [
7
+ "lucide-react"
8
+ ],
9
+ "registryDependencies": [
10
+ "@intelligo/button"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "base/ui/copy-button/copy-button.tsx",
15
+ "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { CheckIcon, CopyIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\n\ntype CopyButtonProps = Omit<\n React.ComponentProps<typeof Button>,\n \"onClick\" | \"value\"\n> & {\n /** The text written to the clipboard. */\n value: string;\n /** Accessible name before copying — pass translated copy. */\n label: string;\n /** Announced once the text is on the clipboard. */\n copiedLabel: string;\n onCopy?: () => void;\n onCopyError?: (error: unknown) => void;\n /** How long the confirmation shows, in milliseconds. */\n timeout?: number;\n};\n\n/**\n * Copy with confirmation, the same everywhere. The\n * icon swaps and a polite live region announces the copy; failures go to\n * `onCopyError` so the caller decides whether to toast.\n */\nfunction CopyButton({\n value,\n label,\n copiedLabel,\n onCopy,\n onCopyError,\n timeout = 2000,\n variant = \"ghost\",\n size = \"icon-sm\",\n children,\n ...props\n}: CopyButtonProps) {\n const [copied, setCopied] = React.useState(false);\n\n React.useEffect(() => {\n if (!copied) return;\n const timer = setTimeout(() => setCopied(false), timeout);\n return () => clearTimeout(timer);\n }, [copied, timeout]);\n\n async function copy() {\n try {\n await navigator.clipboard.writeText(value);\n setCopied(true);\n onCopy?.();\n } catch (error) {\n onCopyError?.(error);\n }\n }\n\n return (\n <Button\n data-slot=\"copy-button\"\n data-copied={copied || undefined}\n variant={variant}\n size={size}\n aria-label={children ? undefined : copied ? copiedLabel : label}\n onClick={copy}\n {...props}\n >\n {copied ? (\n <CheckIcon data-icon=\"inline-start\" />\n ) : (\n <CopyIcon data-icon=\"inline-start\" />\n )}\n {children}\n <span aria-live=\"polite\" className=\"sr-only\">\n {copied ? copiedLabel : \"\"}\n </span>\n </Button>\n );\n}\n\nexport { CopyButton };\n",
16
+ "type": "registry:ui",
17
+ "target": "components/ui/copy-button.tsx"
18
+ }
19
+ ],
20
+ "type": "registry:ui"
21
+ }
@@ -0,0 +1,69 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "dashboard",
4
+ "title": "Dashboard",
5
+ "description": "An AI-first workspace home: a hero and a real composer centred as one unit, starter chips that fill it, and a resume affordance for unfinished work — and nothing else, because history belongs to the sidebar and spend to /usage. Configurable through lib/dashboard-config.tsx (copy, starters, chat base path) and lib/dashboard-data.ts (what \"resume\" means). Pairs with the chat item for its /chat routes; install it first.",
6
+ "dependencies": [
7
+ "@intelligo-dev/auth",
8
+ "@intelligo-dev/core",
9
+ "lucide-react",
10
+ "server-only",
11
+ "next-intl"
12
+ ],
13
+ "registryDependencies": [
14
+ "skeleton",
15
+ "@intelligo/ai-prompt-input",
16
+ "@intelligo/ai-speech-input"
17
+ ],
18
+ "files": [
19
+ {
20
+ "path": "base/dashboard/page.tsx",
21
+ "content": "/**\n * Workspace dashboard: a hero, a composer and starter chips.\n *\n * Everything configurable lives in two consumer-owned seams:\n * `@/lib/dashboard-config` (hero copy, starters, chat base path) and\n * `@/lib/dashboard-data` (`getResume` — what \"unfinished work\" means\n * for this product). Neither requires editing a file this item ships.\n *\n * Pairs with the `chat` item: the composer and the starters open\n * `${chatBasePath}/<new-uuid>?query=…`, which the chat panel sends as\n * the first turn. Without a chat surface installed, set `chatBasePath`\n * or drop those affordances.\n *\n * `requireWorkspace()` is allowed to throw: the `app-shell` layout\n * already guarantees an active workspace, so a failure here (a revoked\n * session mid-request) belongs to `error.tsx`, not an empty state.\n */\n\nimport type { Metadata } from \"next\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { requireWorkspace } from \"@intelligo-dev/auth\";\n\nimport { DashboardHero } from \"@/components/dashboard/dashboard-hero\";\nimport { PromptBar } from \"@/components/dashboard/prompt-bar\";\nimport { dashboardConfig } from \"@/lib/dashboard-config\";\nimport { getResume } from \"@/lib/dashboard-data\";\n\nexport async function generateMetadata(): Promise<Metadata> {\n const t = await getTranslations(\"dashboard\");\n return { title: t(\"meta.title\") };\n}\n\nexport default async function DashboardPage() {\n const { workspace, user } = await requireWorkspace();\n\n const resume = await getResume(\n { workspaceId: workspace.id, userId: user.id },\n { chatBasePath: dashboardConfig.chatBasePath }\n );\n\n return (\n <div className=\"flex min-h-full flex-col justify-center px-4 py-10\">\n <div className=\"mx-auto w-full max-w-3xl\">\n <DashboardHero resume={resume} />\n <PromptBar />\n </div>\n </div>\n );\n}\n",
22
+ "type": "registry:page",
23
+ "target": "app/[locale]/(app)/dashboard/page.tsx"
24
+ },
25
+ {
26
+ "path": "base/dashboard/loading.tsx",
27
+ "content": "/**\n * Mirrors the page's layout (hero, composer, starter chips) so first\n * paint doesn't jump when the real content arrives.\n */\n\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport default function DashboardLoading() {\n return (\n <div className=\"flex min-h-full flex-col justify-center px-4 py-10\">\n <div className=\"mx-auto w-full max-w-3xl\">\n <div className=\"flex flex-col items-center gap-4\">\n <Skeleton className=\"size-12 rounded-full\" />\n <Skeleton className=\"h-9 w-72 max-w-full\" />\n <Skeleton className=\"h-4 w-96 max-w-full\" />\n </div>\n\n <Skeleton className=\"mt-8 h-28 w-full rounded-xl\" />\n\n <div className=\"mt-3 flex flex-wrap justify-center gap-2\">\n {Array.from({ length: 3 }).map((_, index) => (\n <Skeleton\n key={index}\n className=\"h-7 w-48 max-w-full rounded-full\"\n />\n ))}\n </div>\n </div>\n </div>\n );\n}\n",
28
+ "type": "registry:page",
29
+ "target": "app/[locale]/(app)/dashboard/loading.tsx"
30
+ },
31
+ {
32
+ "path": "base/dashboard/messages/en.json",
33
+ "content": "{\n \"meta\": {\n \"title\": \"Dashboard\"\n },\n \"hero\": {\n \"title\": \"What can I help with?\",\n \"subtitle\": \"Ask a question, draft something, or pick up where you left off. Every turn is metered against your plan.\",\n \"resume\": \"Continue where you left off\"\n },\n \"starters\": {\n \"first\": \"Explain what this workspace can do\",\n \"second\": \"Draft a short update for my team\",\n \"third\": \"Summarize the key points of an idea\"\n },\n \"promptBar\": {\n \"placeholder\": \"Ask anything…\",\n \"send\": \"Send\",\n \"voiceStart\": \"Start voice input\",\n \"voiceStop\": \"Stop voice input\"\n },\n \"promptBar\": {\n \"placeholder\": \"Ask anything…\",\n \"send\": \"Send\",\n \"voiceStart\": \"Start voice input\",\n \"voiceStop\": \"Stop voice input\"\n }\n}\n",
34
+ "type": "registry:file",
35
+ "target": "messages/en/dashboard.json"
36
+ },
37
+ {
38
+ "path": "base/dashboard/error.tsx",
39
+ "content": "\"use client\";\n\n/**\n * Renders the shared `RouteError` from the `route-error` item — install\n * it alongside this one.\n */\n\nimport { RouteError } from \"@/components/shared/route-error\";\n\nexport default function SegmentError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n return <RouteError error={error} reset={reset} scope=\"dashboard\" />;\n}\n",
40
+ "type": "registry:page",
41
+ "target": "app/[locale]/(app)/dashboard/error.tsx"
42
+ },
43
+ {
44
+ "path": "base/dashboard/components/dashboard-hero.tsx",
45
+ "content": "\"use client\";\n\n/**\n * What this workspace is for, and a way back into unfinished work.\n * Starters belong to the composer (`prompt-bar.tsx`), not here.\n */\n\nimport { ArrowRight, Sparkles } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { useRouter } from \"@/i18n/navigation\";\nimport { dashboardConfig } from \"@/lib/dashboard-config\";\nimport type { ResumeTarget } from \"@/lib/dashboard-data\";\n\ninterface DashboardHeroProps {\n resume: ResumeTarget | null;\n}\n\nexport function DashboardHero({ resume }: DashboardHeroProps) {\n const t = useTranslations(\"dashboard\");\n // Namespace-less: hero keys are fully qualified so a product can\n // point them at its own namespace (see `@/lib/dashboard-config`).\n const tAny = useTranslations();\n const router = useRouter();\n\n const Icon = dashboardConfig.hero?.icon ?? Sparkles;\n\n const title = dashboardConfig.hero?.titleKey\n ? tAny(dashboardConfig.hero.titleKey)\n : t(\"hero.title\");\n const subtitle = dashboardConfig.hero?.subtitleKey\n ? tAny(dashboardConfig.hero.subtitleKey)\n : t(\"hero.subtitle\");\n\n return (\n <div className=\"text-center\">\n <div className=\"flex justify-center\">\n <div className=\"flex size-12 items-center justify-center rounded-full bg-primary/10\">\n <Icon className=\"size-6 text-primary\" strokeWidth={1.75} />\n </div>\n </div>\n\n <h1 className=\"mt-5 text-3xl font-semibold tracking-tight md:text-4xl\">\n {title}\n </h1>\n <p className=\"mx-auto mt-3 max-w-xl text-base text-muted-foreground\">\n {subtitle}\n </p>\n\n {resume ? (\n <button\n type=\"button\"\n onClick={() => router.push(resume.href)}\n // `max-w-full` and a truncating label: a resumed conversation\n // is titled from its first message, which on a phone is wider\n // than the screen.\n className=\"mt-5 inline-flex max-w-full items-center gap-1.5 rounded-full bg-accent/60 px-3.5 py-1.5 text-xs font-medium transition-colors hover:bg-accent\"\n >\n <span\n className=\"size-1.5 shrink-0 rounded-full bg-primary\"\n aria-hidden\n />\n <span className=\"truncate\">{resume.label ?? t(\"hero.resume\")}</span>\n <ArrowRight className=\"size-3.5 shrink-0\" />\n </button>\n ) : null}\n </div>\n );\n}\n",
46
+ "type": "registry:component",
47
+ "target": "components/dashboard/dashboard-hero.tsx"
48
+ },
49
+ {
50
+ "path": "base/dashboard/components/prompt-bar.tsx",
51
+ "content": "\"use client\";\n\n/**\n * The composer, and the starter chips that fill it. Uses the same\n * primitives as the `chat` item's composer: Enter sends, Shift+Enter\n * breaks a line (IME-safe), dictation appends to the draft.\n *\n * Both affordances — the composer and the starter cards — mint a\n * client-side UUID and navigate to `${chatBasePath}/${id}?query=…`; the\n * chat panel sends that as the first turn. Nothing is written here, so\n * an abandoned prompt leaves no empty conversation behind.\n *\n * No attachments: the handoff to the chat surface is a URL, which\n * carries text only, so a file picked here would be dropped.\n */\n\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\n\nimport {\n PromptInput,\n PromptInputFooter,\n PromptInputSubmit,\n PromptInputTextarea,\n PromptInputTools,\n} from \"@/components/ui/ai-prompt-input\";\nimport { SpeechInput } from \"@/components/ui/ai-speech-input\";\nimport { useRouter } from \"@/i18n/navigation\";\nimport { dashboardConfig } from \"@/lib/dashboard-config\";\n\nexport function PromptBar() {\n const t = useTranslations(\"dashboard\");\n // Namespace-less: starter keys are fully qualified so a product can\n // point them at its own namespace (the `chatConfig.starters` contract).\n const tAny = useTranslations();\n const router = useRouter();\n const [message, setMessage] = useState(\"\");\n\n const chatBasePath = dashboardConfig.chatBasePath ?? \"/chat\";\n const starters = (dashboardConfig.starters ?? []).map((key) => tAny(key));\n\n function start(prompt: string) {\n const id = crypto.randomUUID();\n router.push(`${chatBasePath}/${id}?query=${encodeURIComponent(prompt)}`);\n }\n\n return (\n <div className=\"mt-8\">\n <PromptInput\n onSubmit={({ text }) => {\n const prompt = (text ?? \"\").trim();\n if (!prompt) return;\n start(prompt);\n }}\n >\n <PromptInputTextarea\n value={message}\n onChange={(event) => setMessage(event.target.value)}\n placeholder={t(\"promptBar.placeholder\")}\n aria-label={t(\"promptBar.placeholder\")}\n />\n <PromptInputFooter>\n <PromptInputTools>\n <SpeechInput\n startLabel={t(\"promptBar.voiceStart\")}\n stopLabel={t(\"promptBar.voiceStop\")}\n onTranscript={(spokenText, isFinal) => {\n if (!isFinal) return;\n const spoken = spokenText.trim();\n if (!spoken) return;\n setMessage((current) =>\n current ? `${current.replace(/\\s+$/, \"\")} ${spoken}` : spoken\n );\n }}\n />\n </PromptInputTools>\n <PromptInputSubmit\n status=\"ready\"\n label={t(\"promptBar.send\")}\n disabled={!message.trim()}\n />\n </PromptInputFooter>\n </PromptInput>\n\n {starters.length > 0 ? (\n <div className=\"mt-3 flex flex-wrap justify-center gap-2\">\n {starters.map((starter, index) => (\n <button\n key={index}\n type=\"button\"\n onClick={() => start(starter)}\n className=\"max-w-full rounded-full border border-border/60 px-3.5 py-1.5 text-xs text-muted-foreground transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground\"\n >\n <span className=\"block truncate\">{starter}</span>\n </button>\n ))}\n </div>\n ) : null}\n </div>\n );\n}\n",
52
+ "type": "registry:component",
53
+ "target": "components/dashboard/prompt-bar.tsx"
54
+ },
55
+ {
56
+ "path": "base/dashboard/lib/dashboard-config.tsx",
57
+ "content": "/**\n * Dashboard configuration — consumer-owned, imported by client\n * components (so, like `nav-config.ts`, it may hold React/Lucide\n * component values that must never cross the RSC boundary as props).\n *\n * The defaults render a hero from this item's own messages, a composer\n * that opens a new conversation, and starter chips that fill it. Every\n * part is replaceable here rather than in a component:\n *\n * - `hero`: icon plus fully-qualified message keys for the title and\n * subtitle. Point them at your product's namespace to say what this\n * workspace is actually for.\n * - `starters`: fully-qualified message keys (the `chatConfig.starters`\n * contract). Each renders as a chip under the composer that opens a\n * new conversation prefilled with that text. Empty hides the row.\n * - `chatBasePath`: where the composer and the starters send the user.\n * Defaults to the `chat` item's `/chat`; change it if your chat\n * surface lives elsewhere, and note that the whole composer\n * affordance only makes sense with a chat surface installed.\n */\n\nimport { Sparkles, type LucideIcon } from \"lucide-react\";\n\nexport interface DashboardConfig {\n hero?: {\n icon?: LucideIcon;\n titleKey?: string;\n subtitleKey?: string;\n };\n /** Fully-qualified message keys, resolved namespace-less. */\n starters?: string[];\n chatBasePath?: string;\n}\n\nexport const dashboardConfig: DashboardConfig = {\n hero: {\n icon: Sparkles,\n titleKey: \"dashboard.hero.title\",\n subtitleKey: \"dashboard.hero.subtitle\",\n },\n starters: [\n \"dashboard.starters.first\",\n \"dashboard.starters.second\",\n \"dashboard.starters.third\",\n ],\n chatBasePath: \"/chat\",\n};\n",
58
+ "type": "registry:file",
59
+ "target": "lib/dashboard-config.tsx"
60
+ },
61
+ {
62
+ "path": "base/dashboard/lib/dashboard-data.ts",
63
+ "content": "import \"server-only\";\n\n/**\n * Dashboard data seam — consumer-owned, read on the server.\n *\n * `getResume` answers \"what was this person in the middle of?\" The\n * default is the most recently updated conversation, which is right for\n * a generic AI workspace. A product whose work has real structure —\n * a multi-phase assessment, a wizard, a review queue — binds its own\n * notion of progress here and the hero's resume pill picks it up with\n * no component edit:\n *\n * export async function getResume(actor: ResumeActor) {\n * const run = await getActiveAssessment(actor.workspaceId, actor.userId);\n * if (!run) return null;\n * return {\n * label: `Phase ${run.phase} of ${run.totalPhases}`,\n * href: `/chat/${run.conversationId}`,\n * };\n * }\n *\n * Return `null` when there is nothing to resume — the pill is hidden\n * rather than rendered empty.\n */\n\nimport { listConversations } from \"@intelligo-dev/core/conversations\";\n\nexport interface ResumeActor {\n workspaceId: string;\n userId: string;\n}\n\nexport interface ResumeTarget {\n /**\n * Plain text for the pill (already localized by whoever produced\n * it). Omit to let the dashboard use its own \"continue\" copy.\n */\n label?: string;\n /** Locale-less href; `@/i18n/navigation`'s Link localizes it. */\n href: string;\n}\n\nexport async function getResume(\n actor: ResumeActor,\n options?: { chatBasePath?: string }\n): Promise<ResumeTarget | null> {\n const [latest] = await listConversations(actor, { limit: 1 });\n if (!latest) return null;\n\n const base = options?.chatBasePath ?? \"/chat\";\n return {\n label: latest.title ?? undefined,\n href: `${base}/${latest.id}`,\n };\n}\n",
64
+ "type": "registry:file",
65
+ "target": "lib/dashboard-data.ts"
66
+ }
67
+ ],
68
+ "type": "registry:block"
69
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "dialog",
4
+ "title": "Dialog",
5
+ "description": "A modal window over a blurred backdrop that springs up into place.",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "lucide-react",
9
+ "motion"
10
+ ],
11
+ "registryDependencies": [
12
+ "@intelligo/button",
13
+ "@intelligo/ai-motion"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "base/ui/dialog/dialog.tsx",
18
+ "content": "\"use client\";\n\n/*\n * The dialog: a blurred backdrop and a panel that springs up from slightly\n * smaller. Backdrop and panel animate with motion; the root is kept\n * controlled so the exit plays.\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 backdropMotion,\n popupMotion,\n useOpenState,\n} from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nconst DialogOpenContext = React.createContext<boolean | null>(null);\n\nconst OVERLAY =\n \"fixed inset-0 isolate z-overlay bg-background/60 supports-backdrop-filter:backdrop-blur-sm\";\n\nfunction Dialog({\n open: openProp,\n defaultOpen,\n onOpenChange,\n ...props\n}: DialogPrimitive.Root.Props) {\n const [open, setOpen] = useOpenState(openProp, defaultOpen, onOpenChange);\n return (\n <DialogOpenContext.Provider value={open}>\n <DialogPrimitive.Root\n data-slot=\"dialog\"\n open={open}\n onOpenChange={setOpen}\n {...props}\n />\n </DialogOpenContext.Provider>\n );\n}\n\nfunction DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {\n return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />;\n}\n\nfunction DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {\n return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />;\n}\n\nfunction DialogClose({ ...props }: DialogPrimitive.Close.Props) {\n return <DialogPrimitive.Close data-slot=\"dialog-close\" {...props} />;\n}\n\nfunction DialogOverlay({\n className,\n ...props\n}: DialogPrimitive.Backdrop.Props) {\n return (\n <DialogPrimitive.Backdrop\n data-slot=\"dialog-overlay\"\n className={cn(\n OVERLAY,\n \"transition-[opacity,backdrop-filter] duration-normal ease-standard data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-fast\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction DialogContent({\n className,\n children,\n showCloseButton = true,\n ...props\n}: DialogPrimitive.Popup.Props & {\n showCloseButton?: boolean;\n}) {\n const open = React.useContext(DialogOpenContext) ?? true;\n const reduced = useReducedMotion() ?? false;\n\n return (\n <AnimatePresence>\n {open && (\n <DialogPrimitive.Portal data-slot=\"dialog-portal\" keepMounted>\n <DialogPrimitive.Backdrop\n data-slot=\"dialog-overlay\"\n render={<motion.div {...backdropMotion(reduced)} />}\n className={OVERLAY}\n />\n <DialogPrimitive.Popup\n data-slot=\"dialog-content\"\n render={<motion.div {...popupMotion(reduced, 0.94)} />}\n className={cn(\n \"fixed top-1/2 left-1/2 z-modal grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border bg-popover p-5 text-sm text-popover-foreground shadow-2xl outline-none sm:max-w-sm\",\n className\n )}\n {...props}\n >\n {children}\n {showCloseButton && (\n <DialogPrimitive.Close\n data-slot=\"dialog-close\"\n render={\n <Button\n variant=\"ghost\"\n className=\"absolute top-3 right-3\"\n size=\"icon-sm\"\n />\n }\n >\n <XIcon />\n <span className=\"sr-only\">Close</span>\n </DialogPrimitive.Close>\n )}\n </DialogPrimitive.Popup>\n </DialogPrimitive.Portal>\n )}\n </AnimatePresence>\n );\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"dialog-header\"\n className={cn(\"flex flex-col gap-2\", className)}\n {...props}\n />\n );\n}\n\nfunction DialogFooter({\n className,\n showCloseButton = false,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n showCloseButton?: boolean;\n}) {\n return (\n <div\n data-slot=\"dialog-footer\"\n className={cn(\n \"-mx-5 -mb-5 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/60 px-5 py-4 sm:flex-row sm:justify-end\",\n className\n )}\n {...props}\n >\n {children}\n {showCloseButton && (\n <DialogPrimitive.Close render={<Button variant=\"outline\" />}>\n Close\n </DialogPrimitive.Close>\n )}\n </div>\n );\n}\n\nfunction DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {\n return (\n <DialogPrimitive.Title\n data-slot=\"dialog-title\"\n className={cn(\n \"font-heading text-base leading-none font-medium\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction DialogDescription({\n className,\n ...props\n}: DialogPrimitive.Description.Props) {\n return (\n <DialogPrimitive.Description\n data-slot=\"dialog-description\"\n className={cn(\n \"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogOverlay,\n DialogPortal,\n DialogTitle,\n DialogTrigger,\n};\n",
19
+ "type": "registry:ui",
20
+ "target": "components/ui/dialog.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": "document-viewer",
4
+ "title": "Document Viewer",
5
+ "description": "How a saved document reads when nobody is editing it: markdown with maths and diagrams, highlighted code, a sheet's rows, an image, and a sandboxed HTML preview — plus the title-to-language and kind-glyph helpers the chat canvas and the artifacts library share.",
6
+ "dependencies": [
7
+ "lucide-react"
8
+ ],
9
+ "registryDependencies": [
10
+ "@intelligo/ai-code-block",
11
+ "@intelligo/ai-markdown"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "base/ui/document-viewer/document-viewer.tsx",
16
+ "content": "\"use client\";\n\n/**\n * How a saved document reads when nobody is editing it. The chat's canvas\n * and the artifacts library both compose it, so a document reads the same\n * in both: how its title names a language, how it is drawn, which glyph\n * stands for its kind. Editing and the words for a kind stay with each item.\n */\n\nimport type { ComponentProps } from \"react\";\nimport {\n FileCodeIcon,\n FileTextIcon,\n ImageIcon,\n SheetIcon,\n type LucideIcon,\n} from \"lucide-react\";\n\nimport { CodeBlock } from \"@/components/ui/ai-code-block\";\nimport { Markdown } from \"@/components/ui/ai-markdown\";\n\nconst LANGUAGE_BY_EXTENSION: Record<string, string> = {\n ts: \"typescript\",\n tsx: \"tsx\",\n js: \"javascript\",\n jsx: \"jsx\",\n py: \"python\",\n rb: \"ruby\",\n go: \"go\",\n rs: \"rust\",\n java: \"java\",\n kt: \"kotlin\",\n swift: \"swift\",\n cs: \"csharp\",\n sh: \"bash\",\n sql: \"sql\",\n json: \"json\",\n yaml: \"yaml\",\n yml: \"yaml\",\n md: \"markdown\",\n html: \"html\",\n htm: \"html\",\n svg: \"xml\",\n css: \"css\",\n};\n\ntype CodeLanguage = ComponentProps<typeof CodeBlock>[\"language\"];\n\n/** A title's file extension, lower-cased: `report.PY` → `py`. */\nexport function extensionOf(title: string): string | undefined {\n const match = /\\.([a-z0-9]{1,8})$/i.exec(title.trim());\n return match?.[1]?.toLowerCase();\n}\n\nexport function languageOf(title: string): CodeLanguage {\n return (LANGUAGE_BY_EXTENSION[extensionOf(title) ?? \"\"] ??\n \"text\") as CodeLanguage;\n}\n\n/**\n * The name a downloaded document gets: its title, with an extension.\n * Takes the fallback extension rather than a kind, so nothing here has\n * to know what a canvas kind is.\n */\nexport function fileNameOf(title: string, extension?: string): string {\n if (extensionOf(title)) return title.trim();\n const base = title.trim().replace(/[\\\\/:*?\"<>|]+/g, \"-\") || \"document\";\n return `${base}.${extension ?? \"txt\"}`;\n}\n\nconst PREVIEWABLE_EXTENSIONS = new Set([\"html\", \"htm\", \"svg\"]);\n\n/** Whether this document has a rendered form worth offering. */\nexport function isPreviewableTitle(title: string): boolean {\n return PREVIEWABLE_EXTENSIONS.has(extensionOf(title) ?? \"\");\n}\n\nconst KIND_ICONS: Record<string, LucideIcon> = {\n text: FileTextIcon,\n code: FileCodeIcon,\n sheet: SheetIcon,\n image: ImageIcon,\n};\n\n/** A kind's glyph; an unknown kind reads as a document. */\nexport function documentKindIcon(kind: string): LucideIcon {\n return KIND_ICONS[kind] ?? FileTextIcon;\n}\n\n/**\n * What a download of this kind is called when the title carries no\n * extension of its own. One table, so both surfaces offer a download\n * under the same extension.\n */\nconst KIND_EXTENSIONS: Record<string, string> = {\n text: \"md\",\n code: \"txt\",\n sheet: \"csv\",\n image: \"png\",\n};\n\nexport function documentKindExtension(kind: string): string | undefined {\n return KIND_EXTENSIONS[kind];\n}\n\nexport interface DocumentViewProps {\n content: string;\n title: string;\n /** Still arriving, so markdown renders incrementally. */\n streaming?: boolean;\n}\n\n/** Notes and drafts: markdown, with maths and diagrams. */\nexport function TextView({ content, streaming }: DocumentViewProps) {\n return (\n <div className=\"prose prose-sm max-w-none dark:prose-invert\">\n <Markdown mode={streaming ? \"streaming\" : \"static\"}>{content}</Markdown>\n </div>\n );\n}\n\nexport function CodeView({ content, title }: DocumentViewProps) {\n return <CodeBlock code={content} language={languageOf(title)} showLineNumbers />;\n}\n\n/** A sheet as its raw rows — a grid is an editor's job. */\nexport function SheetView({ content }: DocumentViewProps) {\n return (\n <pre className=\"overflow-auto font-mono text-xs leading-relaxed whitespace-pre\">\n {content}\n </pre>\n );\n}\n\n/** A generated image: the content is a data URL or an https URL. */\nexport function ImageView({ content, title }: DocumentViewProps) {\n if (!content) return null;\n return (\n <img\n src={content}\n alt={title}\n className=\"mx-auto max-h-full max-w-full rounded-lg object-contain\"\n />\n );\n}\n\n/**\n * An HTML page or an SVG, rendered in a sandbox: scripts run, but in an\n * opaque origin with no access to the app, its cookies or its storage.\n * The document asks for a light canvas, so a page written without a\n * background reads as it would on its own.\n */\nexport function HtmlPreview({ content, title }: DocumentViewProps) {\n return (\n <iframe\n title={title}\n sandbox=\"allow-scripts\"\n srcDoc={`<meta name=\"color-scheme\" content=\"light\">${content}`}\n className=\"size-full min-h-96 rounded-lg border\"\n />\n );\n}\n\n/**\n * A document drawn by its kind. An unknown kind — a product's own —\n * falls back to plain text rather than to nothing, which is the same\n * promise the canvas makes when it cannot resolve a kind.\n */\nexport function DocumentView({\n kind,\n ...props\n}: DocumentViewProps & { kind: string }) {\n switch (kind) {\n case \"code\":\n return <CodeView {...props} />;\n case \"sheet\":\n return <SheetView {...props} />;\n case \"image\":\n return <ImageView {...props} />;\n case \"text\":\n return <TextView {...props} />;\n default:\n return (\n <div className=\"text-sm leading-relaxed whitespace-pre-wrap\">\n {props.content}\n </div>\n );\n }\n}\n",
17
+ "type": "registry:ui",
18
+ "target": "components/ui/document-viewer.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": "dropdown-menu",
4
+ "title": "Dropdown Menu",
5
+ "description": "Actions, checkboxes and radio groups with submenus, in a panel that scales out of its trigger.",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "lucide-react",
9
+ "motion"
10
+ ],
11
+ "registryDependencies": [
12
+ "@intelligo/ai-motion"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "base/ui/dropdown-menu/dropdown-menu.tsx",
17
+ "content": "\"use client\";\n\n/*\n * The dropdown menu: a rounded panel that scales out of its trigger, rows\n * that tint as the highlight moves. The panel springs open with motion;\n * the root is kept controlled so the exit plays.\n */\n\nimport * as React from \"react\";\nimport { Menu as MenuPrimitive } from \"@base-ui/react/menu\";\nimport { ChevronRightIcon, CheckIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { popupMotion, useOpenState } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// The open state of the nearest menu — a submenu provides its own.\nconst MenuOpenContext = React.createContext<boolean | null>(null);\n\nfunction DropdownMenu({\n open: openProp,\n defaultOpen,\n onOpenChange,\n ...props\n}: MenuPrimitive.Root.Props) {\n const [open, setOpen] = useOpenState(openProp, defaultOpen, onOpenChange);\n return (\n <MenuOpenContext.Provider value={open}>\n <MenuPrimitive.Root\n data-slot=\"dropdown-menu\"\n open={open}\n onOpenChange={setOpen}\n {...props}\n />\n </MenuOpenContext.Provider>\n );\n}\n\nfunction DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {\n return <MenuPrimitive.Portal data-slot=\"dropdown-menu-portal\" {...props} />;\n}\n\nfunction DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {\n return <MenuPrimitive.Trigger data-slot=\"dropdown-menu-trigger\" {...props} />;\n}\n\nfunction DropdownMenuContent({\n align = \"start\",\n alignOffset = 0,\n side = \"bottom\",\n sideOffset = 4,\n className,\n ...props\n}: MenuPrimitive.Popup.Props &\n Pick<\n MenuPrimitive.Positioner.Props,\n \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\"\n >) {\n const open = React.useContext(MenuOpenContext) ?? true;\n const reduced = useReducedMotion() ?? false;\n\n return (\n <AnimatePresence>\n {open && (\n <MenuPrimitive.Portal keepMounted>\n <MenuPrimitive.Positioner\n className=\"isolate z-popover outline-none\"\n align={align}\n alignOffset={alignOffset}\n side={side}\n sideOffset={sideOffset}\n >\n <MenuPrimitive.Popup\n data-slot=\"dropdown-menu-content\"\n render={<motion.div {...popupMotion(reduced)} />}\n className={cn(\n \"max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none data-closed:overflow-hidden\",\n className\n )}\n {...props}\n />\n </MenuPrimitive.Positioner>\n </MenuPrimitive.Portal>\n )}\n </AnimatePresence>\n );\n}\n\nfunction DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {\n return <MenuPrimitive.Group data-slot=\"dropdown-menu-group\" {...props} />;\n}\n\nfunction DropdownMenuLabel({\n className,\n inset,\n ...props\n}: MenuPrimitive.GroupLabel.Props & {\n inset?: boolean;\n}) {\n return (\n <MenuPrimitive.GroupLabel\n data-slot=\"dropdown-menu-label\"\n data-inset={inset}\n className={cn(\n \"px-2 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction DropdownMenuItem({\n className,\n inset,\n variant = \"default\",\n ...props\n}: MenuPrimitive.Item.Props & {\n inset?: boolean;\n variant?: \"default\" | \"destructive\";\n}) {\n return (\n <MenuPrimitive.Item\n data-slot=\"dropdown-menu-item\"\n data-inset={inset}\n data-variant={variant}\n className={cn(\n \"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm outline-hidden transition-colors duration-fast select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction DropdownMenuSub({\n open: openProp,\n defaultOpen,\n onOpenChange,\n ...props\n}: MenuPrimitive.SubmenuRoot.Props) {\n const [open, setOpen] = useOpenState(openProp, defaultOpen, onOpenChange);\n return (\n <MenuOpenContext.Provider value={open}>\n <MenuPrimitive.SubmenuRoot\n data-slot=\"dropdown-menu-sub\"\n open={open}\n onOpenChange={setOpen}\n {...props}\n />\n </MenuOpenContext.Provider>\n );\n}\n\nfunction DropdownMenuSubTrigger({\n className,\n inset,\n children,\n ...props\n}: MenuPrimitive.SubmenuTrigger.Props & {\n inset?: boolean;\n}) {\n return (\n <MenuPrimitive.SubmenuTrigger\n data-slot=\"dropdown-menu-sub-trigger\"\n data-inset={inset}\n className={cn(\n \"flex cursor-default items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm outline-hidden transition-colors duration-fast select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n className\n )}\n {...props}\n >\n {children}\n <ChevronRightIcon className=\"ml-auto\" />\n </MenuPrimitive.SubmenuTrigger>\n );\n}\n\nfunction DropdownMenuSubContent({\n align = \"start\",\n alignOffset = -3,\n side = \"right\",\n sideOffset = 0,\n className,\n ...props\n}: React.ComponentProps<typeof DropdownMenuContent>) {\n return (\n <DropdownMenuContent\n data-slot=\"dropdown-menu-sub-content\"\n className={cn(\"w-auto min-w-24\", className)}\n align={align}\n alignOffset={alignOffset}\n side={side}\n sideOffset={sideOffset}\n {...props}\n />\n );\n}\n\nfunction DropdownMenuCheckboxItem({\n className,\n children,\n checked,\n inset,\n ...props\n}: MenuPrimitive.CheckboxItem.Props & {\n inset?: boolean;\n}) {\n return (\n <MenuPrimitive.CheckboxItem\n data-slot=\"dropdown-menu-checkbox-item\"\n data-inset={inset}\n className={cn(\n \"relative flex cursor-default items-center gap-1.5 rounded-lg py-1.5 pr-8 pl-2 text-sm outline-hidden transition-colors duration-fast select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n className\n )}\n checked={checked}\n {...props}\n >\n <span\n className=\"pointer-events-none absolute right-2 flex items-center justify-center\"\n data-slot=\"dropdown-menu-checkbox-item-indicator\"\n >\n <MenuPrimitive.CheckboxItemIndicator>\n <CheckIcon />\n </MenuPrimitive.CheckboxItemIndicator>\n </span>\n {children}\n </MenuPrimitive.CheckboxItem>\n );\n}\n\nfunction DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {\n return (\n <MenuPrimitive.RadioGroup\n data-slot=\"dropdown-menu-radio-group\"\n {...props}\n />\n );\n}\n\nfunction DropdownMenuRadioItem({\n className,\n children,\n inset,\n ...props\n}: MenuPrimitive.RadioItem.Props & {\n inset?: boolean;\n}) {\n return (\n <MenuPrimitive.RadioItem\n data-slot=\"dropdown-menu-radio-item\"\n data-inset={inset}\n className={cn(\n \"relative flex cursor-default items-center gap-1.5 rounded-lg py-1.5 pr-8 pl-2 text-sm outline-hidden transition-colors duration-fast select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n className\n )}\n {...props}\n >\n <span\n className=\"pointer-events-none absolute right-2 flex items-center justify-center\"\n data-slot=\"dropdown-menu-radio-item-indicator\"\n >\n <MenuPrimitive.RadioItemIndicator>\n <CheckIcon />\n </MenuPrimitive.RadioItemIndicator>\n </span>\n {children}\n </MenuPrimitive.RadioItem>\n );\n}\n\nfunction DropdownMenuSeparator({\n className,\n ...props\n}: MenuPrimitive.Separator.Props) {\n return (\n <MenuPrimitive.Separator\n data-slot=\"dropdown-menu-separator\"\n className={cn(\"-mx-1 my-1 h-px bg-border\", className)}\n {...props}\n />\n );\n}\n\nfunction DropdownMenuShortcut({\n className,\n ...props\n}: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"dropdown-menu-shortcut\"\n className={cn(\n \"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport {\n DropdownMenu,\n DropdownMenuPortal,\n DropdownMenuTrigger,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuLabel,\n DropdownMenuItem,\n DropdownMenuCheckboxItem,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuSeparator,\n DropdownMenuShortcut,\n DropdownMenuSub,\n DropdownMenuSubTrigger,\n DropdownMenuSubContent,\n};\n",
18
+ "type": "registry:ui",
19
+ "target": "components/ui/dropdown-menu.tsx"
20
+ }
21
+ ],
22
+ "type": "registry:ui"
23
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "expandable-tabs",
4
+ "title": "Expandable Tabs",
5
+ "description": "A bar of icon tabs on Base UI's Tabs where the active tab widens to unfurl its label, the pill glides to it and its panel rises in.",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "motion"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/ai-motion"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "base/ui/expandable-tabs/expandable-tabs.tsx",
16
+ "content": "\"use client\";\n\n/*\n * A bar of icon tabs where the active tab widens to show its label: the\n * label unfurls, the pill glides to it and its panel rises in. Tablist\n * semantics, roving focus and arrow keys come from Base UI's Tabs.\n */\n\nimport * as React from \"react\";\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ExpandableTabsItem {\n id: string;\n /** Shown inside the active tab; always the tab's accessible name. */\n label: string;\n icon: React.ReactNode;\n /** Optional panel shown for this tab. */\n content?: React.ReactNode;\n disabled?: boolean;\n}\n\nexport interface ExpandableTabsClassNames {\n root?: string;\n list?: string;\n tab?: string;\n pill?: string;\n label?: string;\n panel?: string;\n}\n\nexport interface ExpandableTabsProps {\n items: ExpandableTabsItem[];\n /** Active tab id; `null` means no tab is open (with `collapsible`). */\n value?: string | null;\n defaultValue?: string | null;\n onValueChange?: (id: string | null) => void;\n /** Pressing the active tab again closes it. */\n collapsible?: boolean;\n /** Accessible name of the tab list. */\n \"aria-label\"?: string;\n className?: string;\n classNames?: ExpandableTabsClassNames;\n}\n\nconst LABEL_OPEN = { type: \"spring\", duration: 0.38, bounce: 0.03 } as const;\nconst LABEL_CLOSE = { duration: 0.16, ease: EASE_OUT } as const;\n\nexport function ExpandableTabs({\n items,\n value: valueProp,\n defaultValue,\n onValueChange,\n collapsible = false,\n \"aria-label\": ariaLabel = \"Tabs\",\n className,\n classNames,\n}: ExpandableTabsProps) {\n const reduced = useReducedMotion() ?? false;\n const pillId = React.useId();\n const [uncontrolled, setUncontrolled] = React.useState<string | null>(\n defaultValue !== undefined\n ? defaultValue\n : collapsible\n ? null\n : (items[0]?.id ?? null)\n );\n const value = valueProp !== undefined ? valueProp : uncontrolled;\n\n const setValue = (next: string | null) => {\n if (valueProp === undefined) setUncontrolled(next);\n onValueChange?.(next);\n };\n\n const active = items.find((item) => item.id === value);\n const hasPanels = items.some((item) => item.content !== undefined);\n\n return (\n <TabsPrimitive.Root\n data-slot=\"expandable-tabs\"\n value={value}\n onValueChange={(next) => setValue(next as string | null)}\n className={cn(\n \"flex flex-col items-center gap-2\",\n className,\n classNames?.root\n )}\n >\n {hasPanels\n ? items.map((item) => (\n <TabsPrimitive.Panel\n key={item.id}\n value={item.id}\n className={cn(\"w-full outline-none\", classNames?.panel)}\n >\n <motion.div\n initial={\n reduced\n ? { opacity: 0 }\n : { opacity: 0, y: 6, scale: 0.98, filter: \"blur(4px)\" }\n }\n animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n transition={\n reduced ? { duration: 0 } : { duration: 0.28, ease: EASE_OUT }\n }\n >\n {item.content}\n </motion.div>\n </TabsPrimitive.Panel>\n ))\n : null}\n\n <TabsPrimitive.List\n aria-label={ariaLabel}\n className={cn(\n \"inline-flex items-center gap-1 rounded-lg border border-border bg-card p-1\",\n classNames?.list\n )}\n >\n {items.map((item) => {\n const selected = item.id === active?.id;\n return (\n <TabsPrimitive.Tab\n key={item.id}\n value={item.id}\n disabled={item.disabled}\n aria-label={item.label}\n onClick={() => {\n // Base UI ignores a press on the active tab; collapsing is ours.\n if (collapsible && selected) setValue(null);\n }}\n render={\n <motion.button\n layout={reduced ? false : \"position\"}\n transition={reduced ? { duration: 0 } : SPRING_LAYOUT}\n />\n }\n className={cn(\n \"relative isolate inline-flex h-9 min-w-9 shrink-0 items-center justify-center rounded-md px-2.5 text-sm font-medium whitespace-nowrap outline-none\",\n \"text-muted-foreground transition-colors duration-150 hover:text-foreground data-active:text-foreground\",\n \"focus-visible:ring-3 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50\",\n \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n classNames?.tab\n )}\n >\n {selected ? (\n <motion.span\n aria-hidden\n layoutId={reduced ? undefined : pillId}\n transition={reduced ? { duration: 0 } : SPRING_LAYOUT}\n className={cn(\n \"absolute inset-0 -z-10 rounded-md bg-accent\",\n classNames?.pill\n )}\n />\n ) : null}\n <span aria-hidden className=\"grid place-items-center\">\n {item.icon}\n </span>\n <motion.span\n aria-hidden\n initial={false}\n animate={\n reduced\n ? {\n width: selected ? \"auto\" : 0,\n opacity: selected ? 1 : 0,\n marginLeft: selected ? 6 : 0,\n }\n : {\n width: selected ? \"auto\" : 0,\n opacity: selected ? 1 : 0,\n marginLeft: selected ? 6 : 0,\n filter: selected ? \"blur(0px)\" : \"blur(3px)\",\n }\n }\n transition={\n reduced\n ? { duration: 0 }\n : selected\n ? LABEL_OPEN\n : LABEL_CLOSE\n }\n className={cn(\n \"inline-block overflow-hidden\",\n classNames?.label\n )}\n >\n {item.label}\n </motion.span>\n </TabsPrimitive.Tab>\n );\n })}\n </TabsPrimitive.List>\n </TabsPrimitive.Root>\n );\n}\n",
17
+ "type": "registry:ui",
18
+ "target": "components/ui/expandable-tabs.tsx"
19
+ }
20
+ ],
21
+ "type": "registry:ui"
22
+ }
@@ -0,0 +1,73 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "feature-gating",
4
+ "title": "Feature Gating",
5
+ "description": "The vocabulary for selling a plan: a server-side FeatureGate that renders gated content only to entitled workspaces, three densities of upgrade prompt (full, compact, inline), a quota warning that escalates as a limit approaches, and a preview paywall that blurs content instead of hiding it. Which features exist and which plan unlocks them is consumer config (lib/feature-catalog.ts). Install pricing or billing-settings for somewhere to send the user.",
6
+ "dependencies": [
7
+ "@intelligo-dev/billing",
8
+ "lucide-react",
9
+ "next-intl"
10
+ ],
11
+ "registryDependencies": [
12
+ "@intelligo/button",
13
+ "card",
14
+ "@intelligo/dialog"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "base/feature-gating/components/feature-gate.tsx",
19
+ "content": "/**\n * Server-side entitlement gate: renders `children` when the active\n * workspace is entitled to `feature`, and an upgrade prompt when it\n * isn't.\n *\n * A server component so gated content never reaches a browser that is\n * not entitled to it. It is still not authorization: the action or\n * route behind the feature must check for itself.\n *\n * What a locked feature is called, and which plan unlocks it, comes\n * from `@/lib/feature-catalog` — consumer-owned, because features are\n * product data. An unlisted feature still gates; it just falls back to\n * generic copy.\n */\n\nimport { getTranslations } from \"next-intl/server\";\n\nimport { getWorkspacePlan, hasFeature } from \"@intelligo-dev/billing\";\nimport {\n getDefaultProductSlug,\n getPlanBySlug,\n} from \"@intelligo-dev/billing/plans\";\n\nimport { featureCatalog } from \"@/lib/feature-catalog\";\nimport { UpgradePrompt } from \"./upgrade-prompt\";\n\ninterface FeatureGateProps {\n workspaceId: string;\n /** Feature key, as registered with `registerProductFeatures`. */\n feature: string;\n children: React.ReactNode;\n /** Rendered instead of the default prompt when the gate closes. */\n fallback?: React.ReactNode;\n variant?: \"full\" | \"compact\";\n}\n\nexport async function FeatureGate({\n workspaceId,\n feature,\n children,\n fallback,\n variant = \"full\",\n}: FeatureGateProps) {\n if (await hasFeature(workspaceId, feature)) {\n return <>{children}</>;\n }\n\n if (fallback) {\n return <>{fallback}</>;\n }\n\n const t = await getTranslations(\"feature-gating\");\n // Namespace-less: catalogue keys are fully qualified so a product can\n // point them at its own namespace.\n const tAny = await getTranslations();\n\n const descriptor = featureCatalog[feature];\n const currentPlanSlug = await getWorkspacePlan(workspaceId);\n\n // The plan catalogue is registered per product by the composition\n // root; `getDefaultProductSlug()` is the slug it set there.\n const product = getDefaultProductSlug();\n const planName = (slug: string) => getPlanBySlug(slug, product)?.name ?? slug;\n\n return (\n <UpgradePrompt\n feature={descriptor ? tAny(descriptor.labelKey) : t(\"genericFeature\")}\n requiredPlan={\n descriptor?.requiredPlan\n ? planName(descriptor.requiredPlan)\n : t(\"genericPlan\")\n }\n description={\n descriptor?.descriptionKey ? tAny(descriptor.descriptionKey) : undefined\n }\n currentPlan={planName(currentPlanSlug)}\n variant={variant}\n />\n );\n}\n",
20
+ "type": "registry:component",
21
+ "target": "components/billing/feature-gate.tsx"
22
+ },
23
+ {
24
+ "path": "base/feature-gating/components/upgrade-prompt.tsx",
25
+ "content": "\"use client\";\n\n/**\n * What a closed `FeatureGate` renders: the feature's name, the plan\n * that unlocks it, and one way forward.\n *\n * `full` fills a page section, for a gate that replaces the whole\n * surface; `compact` fits a card or panel.\n */\n\nimport { ArrowUpRight, Lock } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { Link } from \"@/i18n/navigation\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from \"@/components/ui/card\";\nimport { featureGatingConfig } from \"@/lib/feature-gating-config\";\n\ninterface UpgradePromptProps {\n /** Display name of the gated feature, already localized. */\n feature: string;\n /** Display name of the plan that unlocks it, already localized. */\n requiredPlan: string;\n description?: string;\n currentPlan?: string;\n variant?: \"full\" | \"compact\";\n}\n\nexport function UpgradePrompt({\n feature,\n requiredPlan,\n description,\n currentPlan,\n variant = \"full\",\n}: UpgradePromptProps) {\n const t = useTranslations(\"feature-gating\");\n const compact = variant === \"compact\";\n\n return (\n <div\n className={\n compact ? \"p-6\" : \"flex min-h-100 items-center justify-center p-6\"\n }\n >\n <Card className=\"w-full max-w-md text-center\">\n <CardHeader>\n <div\n className={`mx-auto mb-4 flex items-center justify-center rounded-full bg-muted ${\n compact ? \"size-8\" : \"size-12\"\n }`}\n >\n <Lock className={compact ? \"size-4\" : \"size-6\"} aria-hidden />\n </div>\n <CardTitle className={compact ? \"text-lg\" : \"text-xl\"}>\n {t(\"prompt.title\", { feature, plan: requiredPlan })}\n </CardTitle>\n <CardDescription>\n {description ??\n t(\"prompt.description\", { feature, plan: requiredPlan })}\n </CardDescription>\n </CardHeader>\n <CardContent>\n {!compact && currentPlan ? (\n <p className=\"mb-4 text-sm text-muted-foreground\">\n {t.rich(\"prompt.currentPlan\", {\n plan: currentPlan,\n strong: (chunks) => (\n <span className=\"font-medium text-foreground\">{chunks}</span>\n ),\n })}\n </p>\n ) : null}\n <Button\n size={compact ? \"sm\" : \"default\"}\n className=\"w-full\"\n render={<Link href={featureGatingConfig.upgradeHref} />}\n nativeButton={false}\n >\n {t(\"prompt.cta\", { plan: requiredPlan })}\n <ArrowUpRight className=\"ml-2 size-4\" />\n </Button>\n </CardContent>\n </Card>\n </div>\n );\n}\n",
26
+ "type": "registry:component",
27
+ "target": "components/billing/upgrade-prompt.tsx"
28
+ },
29
+ {
30
+ "path": "base/feature-gating/components/inline-upgrade-banner.tsx",
31
+ "content": "\"use client\";\n\n/**\n * The quietest of the three gating densities: one row, for a control\n * that is present but unavailable — a disabled toggle, a menu item a\n * plan doesn't include. Use `UpgradePrompt` when the gate replaces\n * real content, and this when it only annotates it.\n */\n\nimport { ArrowUpRight, Lock } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { Link } from \"@/i18n/navigation\";\nimport { Button } from \"@/components/ui/button\";\nimport { featureGatingConfig } from \"@/lib/feature-gating-config\";\n\ninterface InlineUpgradeBannerProps {\n /** Display name of the gated feature, already localized. */\n feature: string;\n /** Display name of the plan that unlocks it, already localized. */\n requiredPlan: string;\n /** Overrides the default sentence entirely. */\n message?: string;\n}\n\nexport function InlineUpgradeBanner({\n feature,\n requiredPlan,\n message,\n}: InlineUpgradeBannerProps) {\n const t = useTranslations(\"feature-gating\");\n\n return (\n <div className=\"flex items-center gap-3 rounded-lg border bg-muted/50 p-3\">\n <span className=\"flex size-8 shrink-0 items-center justify-center rounded-full bg-muted\">\n <Lock className=\"size-4 text-muted-foreground\" aria-hidden />\n </span>\n <p className=\"flex-1 text-sm text-muted-foreground\">\n {message ?? t(\"inline.message\", { feature, plan: requiredPlan })}\n </p>\n <Button\n size=\"sm\"\n variant=\"outline\"\n render={<Link href={featureGatingConfig.upgradeHref} />}\n nativeButton={false}\n >\n {t(\"inline.cta\")}\n <ArrowUpRight className=\"ml-2 size-3\" />\n </Button>\n </div>\n );\n}\n",
32
+ "type": "registry:component",
33
+ "target": "components/billing/inline-upgrade-banner.tsx"
34
+ },
35
+ {
36
+ "path": "base/feature-gating/components/quota-warning.tsx",
37
+ "content": "\"use client\";\n\n/**\n * Approaching-the-limit banner for a metered action.\n *\n * Renders nothing below the warning threshold, muted between it and\n * the urgent one, destructive above. Thresholds live in\n * `@/lib/feature-gating-config`.\n *\n * The copy is this item's own, computed from `remaining`, rather than\n * the `warning` string `@intelligo-dev/billing` returns: that string is\n * produced by a package that cannot know the caller's locale. Pass\n * `message` to override.\n */\n\nimport { AlertTriangle } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\n\nimport type { FeatureQuotaResult } from \"@intelligo-dev/billing\";\nimport { cn } from \"@/lib/utils\";\nimport { featureGatingConfig } from \"@/lib/feature-gating-config\";\n\ninterface QuotaWarningProps {\n quota: FeatureQuotaResult;\n /** Overrides the computed sentence. */\n message?: string;\n className?: string;\n}\n\nexport function QuotaWarning({ quota, message, className }: QuotaWarningProps) {\n const t = useTranslations(\"feature-gating\");\n const { warnAtPercent, urgentAtPercent } = featureGatingConfig;\n\n if (quota.percentage < warnAtPercent) return null;\n\n const isUrgent = quota.percentage >= urgentAtPercent;\n\n return (\n <div\n role=\"status\"\n className={cn(\n \"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm\",\n isUrgent\n ? \"border-destructive/20 bg-destructive/10 text-destructive\"\n : \"border-border bg-muted text-foreground\",\n className\n )}\n >\n <AlertTriangle className=\"size-4 shrink-0\" aria-hidden />\n <span>\n {message ??\n (quota.remaining > 0\n ? t(\"quota.remaining\", { count: quota.remaining })\n : t(\"quota.exhausted\"))}\n </span>\n </div>\n );\n}\n",
38
+ "type": "registry:component",
39
+ "target": "components/billing/quota-warning.tsx"
40
+ },
41
+ {
42
+ "path": "base/feature-gating/components/quota-limit-dialog.tsx",
43
+ "content": "\"use client\";\n\n/**\n * The modal shown when a metered action is refused mid-flow (unlike\n * `QuotaWarning`, which appears before the limit). It says what ran\n * out, offers the plans passed in, and can always be dismissed.\n */\n\nimport { ArrowRight, Sparkles } from \"lucide-react\";\nimport { useFormatter, useTranslations } from \"next-intl\";\n\nimport type { FeatureQuotaResult } from \"@intelligo-dev/billing\";\nimport type { PlanConfig } from \"@intelligo-dev/billing/plans\";\n\nimport { Link } from \"@/i18n/navigation\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { CURRENCY } from \"@/lib/billing-config\";\nimport { featureGatingConfig } from \"@/lib/feature-gating-config\";\n\ninterface QuotaLimitDialogProps {\n open: boolean;\n onClose: () => void;\n quota: FeatureQuotaResult;\n /**\n * Plans to offer, already filtered to ones above the caller's\n * current plan. Resolve them on the server (`getPlanConfigs`) and\n * pass them down — this component does not decide who is eligible\n * for what.\n */\n upgradeOptions: PlanConfig[];\n /** Localized name of the action that ran out (e.g. \"Messages\"). */\n actionLabel?: string;\n}\n\nexport function QuotaLimitDialog({\n open,\n onClose,\n quota,\n upgradeOptions,\n actionLabel,\n}: QuotaLimitDialogProps) {\n const t = useTranslations(\"feature-gating\");\n const format = useFormatter();\n\n const price = (plan: PlanConfig) => {\n const amount = plan.priceMonthly ?? plan.priceOneTime;\n if (!amount) return null;\n return format.number(amount, {\n style: \"currency\",\n currency: CURRENCY,\n maximumFractionDigits: 0,\n });\n };\n\n return (\n <Dialog open={open} onOpenChange={onClose}>\n <DialogContent className=\"sm:max-w-md\">\n <DialogHeader>\n <div className=\"mx-auto mb-2 flex size-12 items-center justify-center rounded-full bg-primary/10\">\n <Sparkles className=\"size-6 text-primary\" aria-hidden />\n </div>\n <DialogTitle className=\"text-center\">\n {t(\"limitDialog.title\", {\n action: actionLabel ?? t(\"limitDialog.genericAction\"),\n })}\n </DialogTitle>\n <DialogDescription className=\"text-center\">\n {t(\"limitDialog.description\", {\n used: quota.used,\n limit: quota.limit,\n })}\n </DialogDescription>\n </DialogHeader>\n\n <div className=\"mt-4 space-y-3\">\n {upgradeOptions.map((plan, index) => {\n const amount = price(plan);\n return (\n <Button\n key={plan.slug}\n size=\"lg\"\n variant={index === 0 ? \"default\" : \"outline\"}\n className=\"w-full\"\n render={<Link href={featureGatingConfig.upgradeHref} />}\n nativeButton={false}\n >\n {amount\n ? t(\"limitDialog.planCta\", {\n plan: plan.name,\n price: amount,\n })\n : t(\"limitDialog.planCtaNoPrice\", { plan: plan.name })}\n <ArrowRight className=\"ml-2 size-4\" />\n </Button>\n );\n })}\n\n <Button variant=\"ghost\" className=\"w-full\" onClick={onClose}>\n {t(\"limitDialog.dismiss\")}\n </Button>\n </div>\n </DialogContent>\n </Dialog>\n );\n}\n",
44
+ "type": "registry:component",
45
+ "target": "components/billing/quota-limit-dialog.tsx"
46
+ },
47
+ {
48
+ "path": "base/feature-gating/components/paywall-blur.tsx",
49
+ "content": "\"use client\";\n\n/**\n * Preview paywall: shows that the content exists and is worth having,\n * without giving it away. The children render blurred, inert and\n * unselectable behind an overlay with the upgrade CTA.\n *\n * The blur is presentation, not protection: the content is in the DOM.\n * Anything that must not reach an unentitled browser belongs behind\n * `FeatureGate` on the server instead.\n */\n\nimport { ArrowRight, Lock } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { Link } from \"@/i18n/navigation\";\nimport { Button } from \"@/components/ui/button\";\nimport { featureGatingConfig } from \"@/lib/feature-gating-config\";\n\ninterface PaywallBlurProps {\n children: React.ReactNode;\n isLocked: boolean;\n /** Heading over the blur; falls back to this item's copy. */\n title?: string;\n /** Sentence under the heading. */\n description?: string;\n /** CTA label; falls back to this item's copy. */\n ctaLabel?: string;\n}\n\nexport function PaywallBlur({\n children,\n isLocked,\n title,\n description,\n ctaLabel,\n}: PaywallBlurProps) {\n const t = useTranslations(\"feature-gating\");\n\n if (!isLocked) return <>{children}</>;\n\n return (\n <div className=\"relative\">\n <div aria-hidden className=\"pointer-events-none select-none blur-sm\">\n {children}\n </div>\n\n <div className=\"absolute inset-0 flex items-center justify-center rounded-xl bg-background/60 backdrop-blur-[2px]\">\n <div className=\"max-w-sm p-6 text-center\">\n <div className=\"mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-primary/10\">\n <Lock className=\"size-5 text-primary\" aria-hidden />\n </div>\n <h3 className=\"text-lg font-semibold\">\n {title ?? t(\"paywall.title\")}\n </h3>\n <p className=\"mt-2 text-sm text-muted-foreground\">\n {description ?? t(\"paywall.description\")}\n </p>\n <Button\n size=\"lg\"\n className=\"mt-4\"\n render={<Link href={featureGatingConfig.upgradeHref} />}\n nativeButton={false}\n >\n {ctaLabel ?? t(\"paywall.cta\")}\n <ArrowRight className=\"ml-2 size-4\" />\n </Button>\n </div>\n </div>\n </div>\n );\n}\n",
50
+ "type": "registry:component",
51
+ "target": "components/billing/paywall-blur.tsx"
52
+ },
53
+ {
54
+ "path": "base/feature-gating/lib/feature-catalog.ts",
55
+ "content": "/**\n * Feature catalogue — consumer-owned.\n *\n * Which features exist, what to call them, and which plan unlocks them\n * is product data, not framework data: `@intelligo-dev/billing` answers\n * \"is this workspace entitled to feature X\" (`hasFeature`), and this\n * file answers \"what should we say when it isn't\".\n *\n * `labelKey` and `descriptionKey` are fully-qualified message keys\n * resolved namespace-less (the `chatConfig.starters` contract), so a\n * feature's copy lives in your own message files. `requiredPlan` is\n * the plan slug that unlocks it — rendered through the plan catalogue\n * registered by your composition root, so the name a user sees stays\n * in one place.\n *\n * A feature missing from this map still gates correctly; it just falls\n * back to generic copy.\n */\n\nexport interface FeatureDescriptor {\n /** Fully-qualified message key for the feature's display name. */\n labelKey: string;\n /** Plan slug that unlocks it (must exist in your plan catalogue). */\n requiredPlan: string;\n /** Fully-qualified message key for a longer explanation. */\n descriptionKey?: string;\n}\n\n/**\n * Example, once your product has features to gate:\n *\n * export const featureCatalog: Record<string, FeatureDescriptor> = {\n * file_uploads: {\n * labelKey: \"features.fileUploads.label\",\n * descriptionKey: \"features.fileUploads.description\",\n * requiredPlan: \"pro\",\n * },\n * };\n */\nexport const featureCatalog: Record<string, FeatureDescriptor> = {};\n",
56
+ "type": "registry:file",
57
+ "target": "lib/feature-catalog.ts"
58
+ },
59
+ {
60
+ "path": "base/feature-gating/lib/feature-gating-config.ts",
61
+ "content": "/**\n * Feature-gating configuration — consumer-owned.\n *\n * `upgradeHref`: where every gating surface sends the user. Defaults\n * to the billing settings page (the `billing-settings` item's route),\n * which is where a plan change actually happens; point it at\n * `/pricing` if your product prefers the comparison first.\n *\n * `warnAtPercent` / `urgentAtPercent`: when `QuotaWarning` starts\n * showing, and when it escalates. Below the first, it renders nothing.\n */\n\nexport interface FeatureGatingConfig {\n upgradeHref: string;\n warnAtPercent: number;\n urgentAtPercent: number;\n}\n\nexport const featureGatingConfig: FeatureGatingConfig = {\n upgradeHref: \"/settings/billing\",\n warnAtPercent: 80,\n urgentAtPercent: 95,\n};\n",
62
+ "type": "registry:file",
63
+ "target": "lib/feature-gating-config.ts"
64
+ },
65
+ {
66
+ "path": "base/feature-gating/messages/en.json",
67
+ "content": "{\n \"genericFeature\": \"This feature\",\n \"genericPlan\": \"a paid plan\",\n \"prompt\": {\n \"title\": \"{feature} requires {plan}\",\n \"description\": \"{feature} is included from the {plan} plan up. Upgrade to unlock it.\",\n \"currentPlan\": \"You're currently on the <strong>{plan}</strong> plan.\",\n \"cta\": \"Upgrade to {plan}\"\n },\n \"inline\": {\n \"message\": \"Upgrade to {plan} to use {feature}\",\n \"cta\": \"Upgrade\"\n },\n \"quota\": {\n \"remaining\": \"{count, plural, one {# use left} other {# uses left}}\",\n \"exhausted\": \"You've used everything included in your plan.\"\n },\n \"paywall\": {\n \"title\": \"See the full version\",\n \"description\": \"Upgrade to read this in full, and to keep everything you generate.\",\n \"cta\": \"Upgrade\"\n },\n \"limitDialog\": {\n \"title\": \"You've used all your {action}\",\n \"genericAction\": \"included usage\",\n \"description\": \"You've used {used} of {limit}. Upgrading raises the limit right away.\",\n \"planCta\": \"Upgrade to {plan} — {price}\",\n \"planCtaNoPrice\": \"Upgrade to {plan}\",\n \"dismiss\": \"Not now\"\n }\n}\n",
68
+ "type": "registry:file",
69
+ "target": "messages/en/feature-gating.json"
70
+ }
71
+ ],
72
+ "type": "registry:block"
73
+ }