handoff-app 2.0.0-8 → 2.0.0

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 (322) hide show
  1. package/{Changelog.md → CHANGELOG.md} +85 -1
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +816 -393
  4. package/RECIPES.md +975 -0
  5. package/UPGRADE.md +57 -9
  6. package/components.json +20 -20
  7. package/config/docs/foundations/colors/guidelines.md +0 -1
  8. package/config/docs/foundations/colors.md +0 -1
  9. package/config/docs/foundations/effects/guidelines.md +0 -1
  10. package/config/docs/foundations/effects/resources.md +0 -1
  11. package/config/docs/foundations/effects.md +0 -1
  12. package/config/docs/foundations/fonts.md +0 -1
  13. package/config/docs/foundations/grid.md +0 -1
  14. package/config/docs/foundations/icon.md +0 -1
  15. package/config/docs/foundations/icons/guidelines.md +0 -1
  16. package/config/docs/foundations/icons.md +0 -1
  17. package/config/docs/foundations/images/guidelines.md +0 -1
  18. package/config/docs/foundations/images/resources.md +0 -1
  19. package/config/docs/foundations/images.md +0 -1
  20. package/config/docs/foundations/logo/resources.md +0 -1
  21. package/config/docs/foundations/logo.md +0 -1
  22. package/config/docs/foundations/typography/guidelines.md +0 -1
  23. package/config/docs/foundations/typography/resources.md +0 -1
  24. package/config/docs/foundations/typography.md +0 -1
  25. package/config/docs/foundations.md +0 -1
  26. package/config/docs/guidelines.md +0 -1
  27. package/config/docs/index.md +0 -1
  28. package/config/docs/system/tokens/foundations/colors.md +0 -4
  29. package/config/docs/system/tokens/foundations/effects.md +0 -4
  30. package/config/docs/system/tokens/foundations/typography.md +0 -2
  31. package/config/docs/system.md +0 -2
  32. package/config/templates/page.md +2 -1
  33. package/config/templates/page.mdx +2 -1
  34. package/dist/ai/connections.d.ts +66 -0
  35. package/dist/ai/connections.js +149 -0
  36. package/dist/ai/define.d.ts +10 -0
  37. package/dist/ai/define.js +13 -0
  38. package/dist/ai/types.d.ts +26 -0
  39. package/dist/ai/types.js +10 -0
  40. package/dist/app-builder/build.d.ts +14 -7
  41. package/dist/app-builder/build.js +195 -66
  42. package/dist/app-builder/client-config.js +3 -2
  43. package/dist/app-builder/paths.d.ts +0 -12
  44. package/dist/app-builder/paths.js +3 -20
  45. package/dist/app-builder/static-export.js +1 -1
  46. package/dist/app-builder/vercel-output.d.ts +1 -1
  47. package/dist/app-builder/vercel-output.js +5 -4
  48. package/dist/cache/build-cache.d.ts +27 -4
  49. package/dist/cache/build-cache.js +114 -47
  50. package/dist/cache/build-inputs.d.ts +13 -0
  51. package/dist/cache/build-inputs.js +19 -0
  52. package/dist/cache/index.d.ts +2 -1
  53. package/dist/cache/index.js +5 -1
  54. package/dist/catalog/normalize.js +3 -0
  55. package/dist/catalog/previews.js +5 -4
  56. package/dist/catalog/types.d.ts +8 -0
  57. package/dist/cli/create.js +1 -2
  58. package/dist/commands/build/index.d.ts +1 -1
  59. package/dist/commands/build/index.js +1 -1
  60. package/dist/commands/utils.d.ts +3 -3
  61. package/dist/config/defaults.js +7 -4
  62. package/dist/config/from-env.d.ts +4 -0
  63. package/dist/config/from-env.js +38 -1
  64. package/dist/config/helpers.js +2 -2
  65. package/dist/config/index.d.ts +0 -1
  66. package/dist/config/index.js +1 -4
  67. package/dist/config/loader.js +28 -2
  68. package/dist/config/resolve-env.d.ts +2 -1
  69. package/dist/config/resolve-env.js +28 -12
  70. package/dist/config/runtime.js +9 -11
  71. package/dist/handlebars.d.ts +10 -2
  72. package/dist/index.d.ts +12 -7
  73. package/dist/index.js +18 -22
  74. package/dist/nav/index.d.ts +2 -2
  75. package/dist/nav/registry.d.ts +3 -3
  76. package/dist/nav/registry.js +1 -1
  77. package/dist/nav/workspace.d.ts +3 -3
  78. package/dist/nav/workspace.js +1 -1
  79. package/dist/pipeline/figma.d.ts +13 -2
  80. package/dist/pipeline/figma.js +86 -46
  81. package/dist/pipeline/styles.js +1 -1
  82. package/dist/pipeline/validation.d.ts +1 -1
  83. package/dist/pipeline/validation.js +3 -3
  84. package/dist/providers.d.ts +10 -0
  85. package/dist/providers.js +13 -0
  86. package/dist/registry/asset-storage/define.d.ts +5 -5
  87. package/dist/registry/asset-storage/define.js +4 -4
  88. package/dist/registry/asset-storage/resolve.d.ts +9 -19
  89. package/dist/registry/asset-storage/resolve.js +33 -21
  90. package/dist/registry/asset-storage/types.d.ts +37 -17
  91. package/dist/registry/asset-storage/types.js +5 -5
  92. package/dist/registry/auth/ai-keys.d.ts +32 -0
  93. package/dist/registry/auth/ai-keys.js +69 -0
  94. package/dist/registry/auth/crypto.d.ts +17 -0
  95. package/dist/registry/auth/crypto.js +61 -1
  96. package/dist/registry/auth/index.d.ts +2 -1
  97. package/dist/registry/auth/index.js +11 -2
  98. package/dist/registry/checkout/index.d.ts +2 -1
  99. package/dist/registry/checkout/index.js +57 -127
  100. package/dist/registry/client.js +53 -17
  101. package/dist/registry/db/driver.js +3 -3
  102. package/dist/registry/db/schema.d.ts +261 -3
  103. package/dist/registry/db/schema.js +29 -4
  104. package/dist/registry/email/define.d.ts +10 -0
  105. package/dist/registry/email/define.js +13 -0
  106. package/dist/registry/email/providers/smtp.d.ts +10 -0
  107. package/dist/registry/email/providers/smtp.js +73 -0
  108. package/dist/registry/email/resolve.d.ts +28 -0
  109. package/dist/registry/email/resolve.js +74 -0
  110. package/dist/registry/email/types.d.ts +24 -0
  111. package/dist/registry/email/types.js +6 -0
  112. package/dist/registry/path.d.ts +2 -0
  113. package/dist/registry/path.js +11 -1
  114. package/dist/registry/publish/package.js +8 -0
  115. package/dist/registry/transfer.d.ts +4 -0
  116. package/dist/store/registry.d.ts +3 -3
  117. package/dist/store/registry.js +4 -4
  118. package/dist/transformers/plugins/ssr-render.js +40 -15
  119. package/dist/transformers/preview/component/builder.d.ts +1 -9
  120. package/dist/transformers/preview/component/builder.js +17 -17
  121. package/dist/transformers/preview/component/css.d.ts +3 -1
  122. package/dist/transformers/preview/component/css.js +92 -51
  123. package/dist/transformers/preview/component/html.d.ts +3 -1
  124. package/dist/transformers/preview/component/html.js +6 -2
  125. package/dist/transformers/preview/component/javascript.d.ts +2 -1
  126. package/dist/transformers/preview/component/javascript.js +10 -4
  127. package/dist/transformers/preview/pattern/html.js +3 -1
  128. package/dist/transformers/preview/types.d.ts +13 -0
  129. package/dist/transformers/utils/build.d.ts +24 -0
  130. package/dist/transformers/utils/build.js +100 -1
  131. package/dist/transformers/utils/module.js +2 -1
  132. package/dist/types/config.d.ts +135 -18
  133. package/dist/utils/path.d.ts +1 -5
  134. package/dist/utils/path.js +6 -23
  135. package/docs/api.md +6 -4
  136. package/docs/cli.md +275 -54
  137. package/drizzle/0007_add_ai_keys.sql +11 -0
  138. package/drizzle/0008_add_source_dir.sql +2 -0
  139. package/drizzle/meta/0007_snapshot.json +2013 -0
  140. package/drizzle/meta/0008_snapshot.json +2025 -0
  141. package/drizzle/meta/_journal.json +14 -0
  142. package/package.json +19 -8
  143. package/src/ai/README.md +42 -0
  144. package/src/ai/connections.ts +189 -0
  145. package/src/ai/define.ts +15 -0
  146. package/src/ai/types.ts +30 -0
  147. package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
  148. package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
  149. package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
  150. package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
  151. package/src/app/components/AiAssistant/dockState.ts +86 -0
  152. package/src/app/components/AiAssistant/toolLabel.ts +24 -0
  153. package/src/app/components/AiAssistant/useAiChat.ts +147 -0
  154. package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
  155. package/src/app/components/AiAssistant/usePageContext.ts +63 -0
  156. package/src/app/components/Auth/AccountLayout.tsx +58 -19
  157. package/src/app/components/Auth/AuthShell.tsx +27 -33
  158. package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
  159. package/src/app/components/Auth/installStatus.ts +29 -0
  160. package/src/app/components/Component/PageSliceResolver.tsx +1 -0
  161. package/src/app/components/Component/Preview.tsx +383 -189
  162. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
  163. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  164. package/src/app/components/Layout/Header.tsx +21 -15
  165. package/src/app/components/Layout/Main.tsx +2 -2
  166. package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
  167. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  168. package/src/app/components/Navigation/MobileNav.tsx +2 -2
  169. package/src/app/components/Navigation/SideNav.tsx +1 -15
  170. package/src/app/components/context/NavProvider.tsx +7 -3
  171. package/src/app/components/ui/attachment.tsx +50 -0
  172. package/src/app/components/ui/bubble.tsx +57 -0
  173. package/src/app/components/ui/button.tsx +3 -1
  174. package/src/app/components/ui/confirm-dialog.tsx +87 -0
  175. package/src/app/components/ui/dialog.tsx +7 -2
  176. package/src/app/components/ui/marker.tsx +56 -0
  177. package/src/app/components/ui/sidebar.tsx +3 -1
  178. package/src/app/components/util/index.ts +3 -12
  179. package/src/app/css/index.css +64 -0
  180. package/src/app/lib/ai/auth.ts +33 -0
  181. package/src/app/lib/ai/context.ts +99 -0
  182. package/src/app/lib/ai/model.ts +79 -0
  183. package/src/app/lib/ai/prompt.ts +36 -0
  184. package/src/app/lib/ai/resolve.ts +97 -0
  185. package/src/app/lib/ai/tools.ts +120 -0
  186. package/src/app/lib/api/methods.ts +13 -0
  187. package/src/app/lib/asset-storage.ts +35 -36
  188. package/src/app/lib/auth/api.ts +4 -12
  189. package/src/app/lib/auth/email.ts +96 -23
  190. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  191. package/src/app/lib/docs-api/registry.ts +2 -3
  192. package/src/app/lib/docs-api/runtime-config.ts +47 -21
  193. package/src/app/lib/mcp/components.ts +523 -0
  194. package/src/app/lib/mcp/page-url.ts +24 -0
  195. package/src/app/lib/mcp/pages.ts +84 -0
  196. package/src/app/lib/mcp/result.ts +33 -0
  197. package/src/app/lib/mcp/server.ts +39 -318
  198. package/src/app/lib/mcp/tokens.ts +334 -0
  199. package/src/app/lib/mcp/tool.ts +19 -0
  200. package/src/app/lib/registry-api/asset-transfer.ts +119 -11
  201. package/src/app/lib/registry-api/errors.ts +2 -0
  202. package/src/app/lib/registry-api/handler.ts +7 -1
  203. package/src/app/lib/registry-api/transfer.ts +28 -6
  204. package/src/app/lib/server-module.ts +34 -0
  205. package/src/app/lib/utils.ts +20 -0
  206. package/src/app/next.config.mjs +26 -24
  207. package/src/app/pages/[...slug]/index.tsx +2 -1
  208. package/src/app/pages/_app.tsx +6 -2
  209. package/src/app/pages/_document.tsx +6 -0
  210. package/src/app/pages/account/ai.tsx +183 -0
  211. package/src/app/pages/account/index.tsx +68 -68
  212. package/src/app/pages/account/tokens.tsx +37 -7
  213. package/src/app/pages/account/users.tsx +75 -15
  214. package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
  215. package/src/app/pages/api/account/ai/keys/index.ts +20 -0
  216. package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
  217. package/src/app/pages/api/admin/users/index.ts +2 -2
  218. package/src/app/pages/api/ai/chat.ts +115 -0
  219. package/src/app/pages/api/ai/connections.ts +23 -0
  220. package/src/app/pages/api/auth/request-reset.ts +14 -16
  221. package/src/app/pages/api/docs/nav.json.ts +1 -1
  222. package/src/app/pages/api/install.ts +3 -3
  223. package/src/app/pages/api/mcp.ts +6 -0
  224. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
  225. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
  226. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  227. package/src/app/pages/cli/device.tsx +10 -5
  228. package/src/app/pages/foundations/colors.tsx +9 -8
  229. package/src/app/pages/foundations/index.tsx +3 -3
  230. package/src/app/pages/index.tsx +2 -2
  231. package/src/app/pages/install.tsx +7 -2
  232. package/src/app/pages/login.tsx +32 -27
  233. package/src/app/pages/reset-password.tsx +21 -20
  234. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  235. package/src/app/pages/system/index.tsx +11 -11
  236. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  237. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  238. package/src/app-builder/README.md +2 -1
  239. package/src/app-builder/build.ts +218 -73
  240. package/src/app-builder/client-config.ts +3 -2
  241. package/src/app-builder/paths.ts +3 -22
  242. package/src/app-builder/static-export.ts +1 -1
  243. package/src/app-builder/vercel-output.ts +5 -4
  244. package/src/cache/README.md +4 -1
  245. package/src/cache/build-cache.ts +128 -43
  246. package/src/cache/build-inputs.ts +28 -0
  247. package/src/cache/index.ts +4 -0
  248. package/src/catalog/README.md +3 -0
  249. package/src/catalog/normalize.ts +2 -0
  250. package/src/catalog/previews.ts +6 -3
  251. package/src/catalog/types.ts +8 -0
  252. package/src/cli/create.ts +1 -2
  253. package/src/commands/build/index.ts +1 -1
  254. package/src/config/README.md +10 -9
  255. package/src/config/defaults.ts +7 -4
  256. package/src/config/from-env.ts +33 -0
  257. package/src/config/helpers.ts +1 -3
  258. package/src/config/index.ts +0 -3
  259. package/src/config/loader.ts +27 -3
  260. package/src/config/resolve-env.ts +28 -15
  261. package/src/config/runtime.ts +10 -5
  262. package/src/handlebars.ts +8 -2
  263. package/src/index.ts +33 -29
  264. package/src/nav/index.ts +2 -2
  265. package/src/nav/registry.ts +3 -3
  266. package/src/nav/workspace.ts +3 -3
  267. package/src/pipeline/README.md +1 -1
  268. package/src/pipeline/figma.ts +98 -49
  269. package/src/pipeline/styles.ts +1 -1
  270. package/src/pipeline/validation.ts +3 -3
  271. package/src/providers.ts +18 -0
  272. package/src/registry/asset-storage/define.ts +6 -6
  273. package/src/registry/asset-storage/resolve.ts +38 -37
  274. package/src/registry/asset-storage/types.ts +39 -17
  275. package/src/registry/auth/ai-keys.ts +62 -0
  276. package/src/registry/auth/crypto.ts +63 -1
  277. package/src/registry/auth/index.ts +11 -1
  278. package/src/registry/checkout/index.ts +60 -138
  279. package/src/registry/client.ts +60 -20
  280. package/src/registry/db/driver.ts +2 -2
  281. package/src/registry/db/schema.ts +49 -22
  282. package/src/registry/email/define.ts +15 -0
  283. package/src/registry/email/providers/smtp.ts +39 -0
  284. package/src/registry/email/resolve.ts +94 -0
  285. package/src/registry/email/types.ts +28 -0
  286. package/src/registry/path.ts +11 -0
  287. package/src/registry/publish/package.ts +9 -0
  288. package/src/registry/transfer.ts +4 -0
  289. package/src/store/registry.ts +7 -7
  290. package/src/transformers/plugins/ssr-render.ts +53 -16
  291. package/src/transformers/preview/component/builder.ts +19 -25
  292. package/src/transformers/preview/component/css.ts +101 -53
  293. package/src/transformers/preview/component/html.ts +6 -2
  294. package/src/transformers/preview/component/javascript.ts +14 -4
  295. package/src/transformers/preview/pattern/html.ts +3 -1
  296. package/src/transformers/preview/types.ts +14 -0
  297. package/src/transformers/utils/build.ts +93 -0
  298. package/src/transformers/utils/module.ts +4 -2
  299. package/src/types/config.ts +148 -20
  300. package/src/utils/README.md +1 -1
  301. package/src/utils/path.ts +6 -29
  302. package/templates/default/handoff.config.js.tpl +10 -2
  303. package/templates/default/handoff.config.ts.tpl +10 -2
  304. package/templates/default/handoff.config.with-examples.js.tpl +10 -2
  305. package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
  306. package/templates/default/package.json.tpl +2 -2
  307. package/.editorconfig +0 -19
  308. package/.eslintrc.json +0 -4
  309. package/.prettierignore +0 -0
  310. package/.prettierrc.json +0 -8
  311. package/.vscode/settings.json +0 -17
  312. package/dist/config/validator.d.ts +0 -9
  313. package/dist/config/validator.js +0 -24
  314. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
  315. package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
  316. package/src/app/components/old/Header.tsx +0 -89
  317. package/src/app/lib/mcp/shape.ts +0 -596
  318. package/src/app/pages/assets/fonts.tsx +0 -129
  319. package/src/app/pages/assets/index.tsx +0 -91
  320. package/src/app/pages/assets/logos.tsx +0 -75
  321. package/src/config/validator.ts +0 -22
  322. package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import { Loader2 } from 'lucide-react';
4
+ import * as React from 'react';
5
+
6
+ import { Button } from './button';
7
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './dialog';
8
+
9
+ export interface ConfirmDialogProps {
10
+ open: boolean;
11
+ onOpenChange: (open: boolean) => void;
12
+ title: string;
13
+ description: React.ReactNode;
14
+ confirmLabel: string;
15
+ /** Shown on the confirm button while `onConfirm` runs. */
16
+ pendingLabel?: string;
17
+ variant?: 'default' | 'destructive';
18
+ /** The dialog closes when this resolves. When it throws, the dialog stays open and shows the error. */
19
+ onConfirm: () => void | Promise<void>;
20
+ }
21
+
22
+ /**
23
+ * A confirmation step before an action. It behaves as an alert dialog: a click outside does not dismiss
24
+ * it, and focus starts on Cancel, so pressing Enter does not confirm by accident.
25
+ */
26
+ export function ConfirmDialog({
27
+ open,
28
+ onOpenChange,
29
+ title,
30
+ description,
31
+ confirmLabel,
32
+ pendingLabel,
33
+ variant = 'default',
34
+ onConfirm,
35
+ }: ConfirmDialogProps) {
36
+ const [isPending, setIsPending] = React.useState(false);
37
+ const [error, setError] = React.useState<string | null>(null);
38
+
39
+ const changeOpen = (next: boolean) => {
40
+ if (isPending) return;
41
+ if (!next) setError(null);
42
+ onOpenChange(next);
43
+ };
44
+
45
+ const confirm = async () => {
46
+ if (isPending) return;
47
+ setIsPending(true);
48
+ setError(null);
49
+ try {
50
+ await onConfirm();
51
+ onOpenChange(false);
52
+ } catch (reason) {
53
+ setError(reason instanceof Error ? reason.message : 'Could not complete this action.');
54
+ } finally {
55
+ setIsPending(false);
56
+ }
57
+ };
58
+
59
+ return (
60
+ <Dialog open={open} onOpenChange={changeOpen}>
61
+ <DialogContent role="alertdialog" className="sm:max-w-md" onInteractOutside={(event) => event.preventDefault()}>
62
+ <DialogHeader>
63
+ <DialogTitle>{title}</DialogTitle>
64
+ <DialogDescription>{description}</DialogDescription>
65
+ </DialogHeader>
66
+
67
+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
68
+
69
+ <DialogFooter>
70
+ <Button variant="ghost" size="sm" disabled={isPending} onClick={() => changeOpen(false)}>
71
+ Cancel
72
+ </Button>
73
+ <Button variant={variant} size="sm" disabled={isPending} onClick={() => void confirm()}>
74
+ {isPending ? (
75
+ <>
76
+ <Loader2 className="animate-spin" aria-hidden />
77
+ {pendingLabel ?? confirmLabel}
78
+ </>
79
+ ) : (
80
+ confirmLabel
81
+ )}
82
+ </Button>
83
+ </DialogFooter>
84
+ </DialogContent>
85
+ </Dialog>
86
+ );
87
+ }
@@ -17,7 +17,7 @@ const DialogOverlay = React.forwardRef<
17
17
  <DialogPrimitive.Overlay
18
18
  ref={ref}
19
19
  className={cn(
20
- 'fixed inset-0 z-50 bg-gray-200/50 backdrop-blur-xs data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
20
+ 'fixed inset-0 z-50 bg-gray-200/50 backdrop-blur-xs dark:bg-gray-950/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
21
21
  className
22
22
  )}
23
23
  {...props}
@@ -54,6 +54,11 @@ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
54
54
  );
55
55
  DialogHeader.displayName = 'DialogHeader';
56
56
 
57
+ const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
58
+ <div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
59
+ );
60
+ DialogFooter.displayName = 'DialogFooter';
61
+
57
62
  const DialogTitle = React.forwardRef<
58
63
  React.ElementRef<typeof DialogPrimitive.Title>,
59
64
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
@@ -70,4 +75,4 @@ const DialogDescription = React.forwardRef<
70
75
  ));
71
76
  DialogDescription.displayName = DialogPrimitive.Description.displayName;
72
77
 
73
- export { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger };
78
+ export { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger };
@@ -0,0 +1,56 @@
1
+ import { Slot } from '@radix-ui/react-slot';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import * as React from 'react';
4
+
5
+ import { cn } from '../../lib/utils';
6
+
7
+ const markerVariants = cva(
8
+ "group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: '',
13
+ separator:
14
+ 'before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border',
15
+ border: 'border-b border-border pb-2',
16
+ },
17
+ },
18
+ }
19
+ );
20
+
21
+ function Marker({
22
+ className,
23
+ variant = 'default',
24
+ asChild = false,
25
+ ...props
26
+ }: React.ComponentProps<'div'> & VariantProps<typeof markerVariants> & { asChild?: boolean }) {
27
+ const Comp = asChild ? Slot : 'div';
28
+
29
+ return <Comp data-slot="marker" data-variant={variant} className={cn(markerVariants({ variant, className }))} {...props} />;
30
+ }
31
+
32
+ function MarkerIcon({ className, ...props }: React.ComponentProps<'span'>) {
33
+ return (
34
+ <span
35
+ data-slot="marker-icon"
36
+ aria-hidden="true"
37
+ className={cn("size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4", className)}
38
+ {...props}
39
+ />
40
+ );
41
+ }
42
+
43
+ function MarkerContent({ className, ...props }: React.ComponentProps<'span'>) {
44
+ return (
45
+ <span
46
+ data-slot="marker-content"
47
+ className={cn(
48
+ 'wrap-break-word min-w-0 group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center',
49
+ className
50
+ )}
51
+ {...props}
52
+ />
53
+ );
54
+ }
55
+
56
+ export { Marker, MarkerContent, MarkerIcon, markerVariants };
@@ -272,7 +272,9 @@ const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<'main
272
272
  <main
273
273
  ref={ref}
274
274
  className={cn(
275
- 'relative flex w-full flex-1 flex-col bg-background',
275
+ // `min-w-0` lets the column shrink below its widest descendant, so a wide table or code block
276
+ // scrolls inside itself instead of widening the page.
277
+ 'relative flex w-full min-w-0 flex-1 flex-col bg-background',
276
278
  'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm',
277
279
  className
278
280
  )}
@@ -10,6 +10,7 @@ import { KNOWN_PATHS } from '@handoff/utils/menu-shell';
10
10
  import { collectPageSlugSegments } from '@handoff/utils/pages';
11
11
  import { getRegistryNavData, getWorkspaceNavData, type NavData, type NavTokenSet, type SectionLink } from '@handoff/nav';
12
12
  import { resolveDocsBackend } from '../../lib/docs-api/backend';
13
+ import { documentationMetadata } from '../../lib/docs-api/page-rendering';
13
14
  import { tokenFormatStrings } from '../../lib/docs-api/token-detail';
14
15
  // Build-time-baked navigation shell. Imported statically (same as `pages/api/docs/nav.json.ts`) so
15
16
  // it is bundled into the route chunk and readable at request time in the Vercel registry lambda,
@@ -543,7 +544,8 @@ export const fetchDocPageMetadataAndContent = (localPath: string, slug: string |
543
544
  currentContents = fs.readFileSync(contentModuleFilePath, 'utf-8');
544
545
  }
545
546
 
546
- const { data: metadata, content } = parseMarkdown(currentContents);
547
+ const { data, content } = parseMarkdown(currentContents);
548
+ const metadata: typeof data = { ...data, ...documentationMetadata(data) };
547
549
 
548
550
  return { metadata, content, options: {} };
549
551
  };
@@ -555,17 +557,6 @@ export const fetchDocPageMetadataAndContent = (localPath: string, slug: string |
555
557
  */
556
558
  export const filterOutUndefined = <T>(value: T): value is NonNullable<T> => value !== undefined;
557
559
 
558
- /**
559
- * Create a title string from a prefix
560
- * @param prefix
561
- * @returns
562
- */
563
- export const titleString = (prefix: string | null): string => {
564
- const config = getClientRuntimeConfig();
565
- const prepend = prefix ? `${prefix} | ` : '';
566
- return `${prepend}${config?.app?.client} Design System`;
567
- };
568
-
569
560
  /**
570
561
  * Get the tokens for a component
571
562
  * @param component
@@ -159,6 +159,70 @@
159
159
  }
160
160
  }
161
161
 
162
+ /*
163
+ The docked assistant and the column the page leaves for it.
164
+
165
+ `--ai-dock-width` is the single source for both, so the panel and the page can never disagree and
166
+ one transition moves both. It is `0px` with the dock closed, or on a viewport too narrow to carry
167
+ a column. An inline script in `_document` sets it before the first paint, so a reader who left the
168
+ dock open does not watch the page reflow after hydration.
169
+
170
+ A resize drag writes the variable on every pointer move, so the transition is dropped for the drag
171
+ or the page eases along behind the pointer.
172
+ */
173
+ .ai-dock-inset {
174
+ padding-right: var(--ai-dock-width, 0px);
175
+ }
176
+
177
+ /* The app header keeps the full width, and the dock starts below it. */
178
+ .ai-dock-bleed {
179
+ margin-right: calc(-1 * var(--ai-dock-width, 0px));
180
+ }
181
+
182
+ .ai-dock-inset,
183
+ .ai-dock-bleed,
184
+ .ai-dock {
185
+ transition:
186
+ width 0.2s ease-out,
187
+ padding-right 0.2s ease-out,
188
+ margin-right 0.2s ease-out;
189
+ }
190
+
191
+ .ai-assistant-trigger {
192
+ display: inline-flex;
193
+ flex-shrink: 0;
194
+ max-width: 9rem;
195
+ overflow: hidden;
196
+ opacity: 1;
197
+ transition:
198
+ max-width 0.2s ease-out,
199
+ margin-right 0.2s ease-out,
200
+ opacity 0.2s ease-out;
201
+ }
202
+
203
+ .ai-assistant-trigger[data-open='true'],
204
+ [data-ai-dock-open='true'] .ai-assistant-trigger {
205
+ pointer-events: none;
206
+ margin-right: -1rem;
207
+ max-width: 0;
208
+ opacity: 0;
209
+ }
210
+
211
+ [data-ai-dock-resizing] .ai-dock-inset,
212
+ [data-ai-dock-resizing] .ai-dock-bleed,
213
+ [data-ai-dock-resizing] .ai-dock {
214
+ transition: none;
215
+ }
216
+
217
+ @media (prefers-reduced-motion: reduce) {
218
+ .ai-dock-inset,
219
+ .ai-dock-bleed,
220
+ .ai-dock,
221
+ .ai-assistant-trigger {
222
+ transition: none;
223
+ }
224
+ }
225
+
162
226
  @layer base {
163
227
  * {
164
228
  @apply border-border;
@@ -0,0 +1,33 @@
1
+ import type { NextApiRequest, NextApiResponse } from 'next';
2
+ import { getServerRuntimeConfig } from '../docs-api/runtime-config';
3
+
4
+ /**
5
+ * Authorization for the AI routes.
6
+ *
7
+ * The chat route authorizes once, on the session, the way the account routes do. This holds only
8
+ * while every tool the agent can reach is read-only; a tool that writes must assert its own scope.
9
+ *
10
+ * A build with the assistant turned off answers 404 rather than 403, so a feature that is absent
11
+ * reads as absent instead of as something a credential would unlock. That check runs before any
12
+ * auth work, exactly as `/api/mcp/` does it.
13
+ */
14
+
15
+ /** The reader behind one request: a registry user id, or `null` in a workspace, which has one user. */
16
+ export interface AiReader {
17
+ userId: string | null;
18
+ }
19
+
20
+ export const authorizeAiRequest = async (req: NextApiRequest, res: NextApiResponse, mutation = false): Promise<AiReader | null> => {
21
+ const runtime = getServerRuntimeConfig();
22
+ if (!runtime.ai.enabled) {
23
+ res.status(404).end();
24
+ return null;
25
+ }
26
+ if (runtime.mode !== 'registry') {
27
+ return { userId: null };
28
+ }
29
+ // Imported lazily so the registry auth/database dependencies stay out of the workspace path.
30
+ const { prepareRegistryApi } = await import('../auth/api');
31
+ const context = await prepareRegistryApi(req, res, { auth: 'user', mutation });
32
+ return context?.user ? { userId: context.user.id } : null;
33
+ };
@@ -0,0 +1,99 @@
1
+ import type { UIMessage } from 'ai';
2
+
3
+ import type { DocsBackend } from '../docs-api/backend';
4
+ import { pageIdFromUrl } from '../mcp/page-url';
5
+
6
+ /**
7
+ * The page a question was asked from.
8
+ *
9
+ * The client sends only the route. The server resolves it through the docs backend, so only
10
+ * backend data reaches the model and the raw route never does.
11
+ */
12
+
13
+ export type AiPageKind = 'component' | 'pattern' | 'page';
14
+
15
+ export interface AiPageRef {
16
+ kind: AiPageKind;
17
+ id: string;
18
+ }
19
+
20
+ /** Token and foundation routes map to token sets, not pages, so they give no context yet. */
21
+ const UNSUPPORTED = /^(system\/tokens|foundations)(\/|$)/;
22
+
23
+ /** The entity a route shows, or `null` for a route that gives no context. Pure, so the client uses it too. */
24
+ export const pageRefFromPath = (path: string): AiPageRef | null => {
25
+ const id = pageIdFromUrl(path);
26
+ if (id === null || UNSUPPORTED.test(id)) return null;
27
+ const entity = /^system\/(component|pattern)\/([^/]+)$/.exec(id);
28
+ if (entity) return { kind: entity[1] as AiPageKind, id: decodeURIComponent(entity[2]) };
29
+ return { kind: 'page', id };
30
+ };
31
+
32
+ /** One line, bounded, and unable to open or close a tag, so an authored title cannot reshape the prompt. */
33
+ const clean = (text: string, max: number): string => {
34
+ const line = text
35
+ .replace(/[\u0000-\u001f\u007f<>]+/g, ' ')
36
+ .replace(/\s+/g, ' ')
37
+ .trim();
38
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
39
+ };
40
+
41
+ /**
42
+ * The fields of a question's `<application_context>` block, or `null` when the route gives no
43
+ * context. Identity only: the agent reads the detail with its tools. Patterns have no tool, so their
44
+ * block also carries the description.
45
+ */
46
+ const describePage = async (backend: DocsBackend, path: string): Promise<string[] | null> => {
47
+ const ref = pageRefFromPath(path);
48
+ if (!ref) return null;
49
+ try {
50
+ if (ref.kind === 'component') {
51
+ const component = await backend.getComponentDetail(ref.id);
52
+ if (!component) return null;
53
+ return ['page: component', `title: ${clean(component.title || component.id, 50)}`, `id: ${clean(component.id, 50)}`];
54
+ }
55
+ if (ref.kind === 'pattern') {
56
+ const pattern = await backend.getPatternDetail(ref.id);
57
+ if (!pattern) return null;
58
+ return [
59
+ 'page: pattern',
60
+ `title: ${clean(pattern.title || pattern.id, 50)}`,
61
+ `id: ${clean(pattern.id, 50)}`,
62
+ ...(pattern.description ? [`description: ${clean(pattern.description, 80)}`] : []),
63
+ ];
64
+ }
65
+ const page = await backend.getPageDetail(ref.id);
66
+ if (!page) return null;
67
+ return ['page: docs page', `title: ${clean(page.title || page.id, 50)}`, `url: ${clean(page.path, 50)}`];
68
+ } catch {
69
+ return null;
70
+ }
71
+ };
72
+
73
+ const CONTEXT_TAG = /<(\/?)application_context/gi;
74
+
75
+ /**
76
+ * The conversation as the model reads it: each question starts with an `<application_context>`
77
+ * block for the page it was asked from, taken from its own `metadata.path`. A question with no
78
+ * resolvable page gets no block.
79
+ *
80
+ * The reader's own text has the tag escaped, so every block the model sees is one the site wrote.
81
+ */
82
+ export const withPageContext = async (messages: UIMessage[], backend: DocsBackend): Promise<UIMessage[]> => {
83
+ const pages = new Map<string, Promise<string[] | null>>();
84
+ return Promise.all(
85
+ messages.map(async (message) => {
86
+ if (message.role !== 'user') return message;
87
+ const parts = message.parts.map((part) =>
88
+ part.type === 'text' ? { ...part, text: part.text.replace(CONTEXT_TAG, '&lt;$1application_context') } : part
89
+ );
90
+ const path = (message.metadata as { path?: unknown } | undefined)?.path;
91
+ if (typeof path !== 'string') return { ...message, parts };
92
+ if (!pages.has(path)) pages.set(path, describePage(backend, path));
93
+ const fields = await pages.get(path);
94
+ if (!fields) return { ...message, parts };
95
+ const block = ['<application_context>', ...fields, '</application_context>'].join('\n');
96
+ return { ...message, parts: [{ type: 'text' as const, text: block }, ...parts] };
97
+ })
98
+ );
99
+ };
@@ -0,0 +1,79 @@
1
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
2
+ import { aiCredentialKind, type AiConnectionSettings } from '@handoff/ai/connections';
3
+
4
+ import type { AiProvider } from '@handoff/ai/types';
5
+ import type { LanguageModel } from 'ai';
6
+ import { getServerRuntimeConfig } from '../docs-api/runtime-config';
7
+ import { loadServerModule } from '../server-module';
8
+
9
+ /**
10
+ * Server-only model construction.
11
+ *
12
+ * Every built-in connection speaks the OpenAI-compatible `/chat/completions` API. A connection that
13
+ * fits nothing else names a `defineAiProvider()` module instead of a base URL.
14
+ *
15
+ * The base URL always comes from the config, never from the request, so the server can never be
16
+ * made to call an endpoint a reader chose.
17
+ */
18
+
19
+ /** Thrown when a connection cannot be used as configured. The message is safe to show the reader. */
20
+ export class AiConnectionError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = 'AiConnectionError';
24
+ }
25
+ }
26
+
27
+ /**
28
+ * The key to send for this connection: the deployment's own for a service connection, this reader's
29
+ * for a `credential: 'user'` one, and none where the endpoint needs none.
30
+ *
31
+ * The registry data layer is imported lazily so its Postgres dependencies stay out of the workspace
32
+ * path.
33
+ */
34
+ export const resolveAiKey = async (connection: AiConnectionSettings, userId: string | null): Promise<string | undefined> => {
35
+ const credential = aiCredentialKind(connection);
36
+ if (credential === 'none') return undefined;
37
+ if (credential === 'service') {
38
+ const key = connection.apiKeyEnv ? process.env[connection.apiKeyEnv]?.trim() : undefined;
39
+ if (!key) {
40
+ throw new AiConnectionError(`${connection.label} is not available: its API key environment variable is not set.`);
41
+ }
42
+ return key;
43
+ }
44
+ if (!userId || getServerRuntimeConfig().mode !== 'registry') {
45
+ throw new AiConnectionError(`${connection.label} needs your own API key, and this deployment has no place to store one.`);
46
+ }
47
+ const [{ getRegistryConnection }, { readRegistryAiKey }] = await Promise.all([
48
+ import('../registry-connection'),
49
+ import('@handoff/registry/auth'),
50
+ ]);
51
+ const { db } = await getRegistryConnection();
52
+ const key = await readRegistryAiKey(db, { userId, connectionId: connection.id });
53
+ if (!key) {
54
+ throw new AiConnectionError(`Add your ${connection.label} API key on the AI providers page before using this model.`);
55
+ }
56
+ return key;
57
+ };
58
+
59
+ /** Load a custom provider module and coerce its default export (provider object or factory) to a provider. */
60
+ const loadProviderModule = async (connection: AiConnectionSettings, apiKey: string | undefined): Promise<AiProvider> => {
61
+ const candidate = (await loadServerModule(connection.module!, { options: connection.options, apiKey })) as Partial<AiProvider> | null;
62
+ if (!candidate || typeof candidate.languageModel !== 'function') {
63
+ throw new AiConnectionError(`Custom AI provider module "${connection.module}" must default-export a defineAiProvider provider.`);
64
+ }
65
+ return candidate as AiProvider;
66
+ };
67
+
68
+ /** Build the language model for one declared connection and one of its declared models. */
69
+ export const createAiLanguageModel = async (
70
+ connection: AiConnectionSettings,
71
+ model: string,
72
+ userId: string | null
73
+ ): Promise<LanguageModel> => {
74
+ const apiKey = await resolveAiKey(connection, userId);
75
+ if (connection.module) {
76
+ return (await loadProviderModule(connection, apiKey)).languageModel(model);
77
+ }
78
+ return createOpenAICompatible({ name: connection.id, baseURL: connection.baseUrl!, apiKey }).chatModel(model);
79
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The assistant's system prompt.
3
+ *
4
+ * One instruction earns its place: the catalog is finite and checkable, so a confident wrong answer
5
+ * does real damage and "I don't know" has to read as the correct answer. Nothing here tells the
6
+ * agent to keep responses small, because no tool returns more than what it is asked for.
7
+ */
8
+ export const DOCS_ASSISTANT_PROMPT = [
9
+ 'You answer questions about the design system documented by this site.',
10
+ '',
11
+ 'Ground factual answers in the tools. Search with handoff_search_components or handoff_search_pages,',
12
+ 'then read the relevant record with handoff_get_component or handoff_get_page. For a component preview,',
13
+ 'use its preview id with handoff_get_component_preview. For its implementation, use',
14
+ 'handoff_get_component_source. Use handoff_get_tokens for colors, typography and spacing.',
15
+ '',
16
+ 'Do not invent components, props, variants, pages or token values. If the tools do not contain',
17
+ 'something, say that it is not in this design system.',
18
+ '',
19
+ 'Messages may include an <application_context> block written by the site. It describes what the',
20
+ 'reader was viewing when that message was sent. Do not mention the block.',
21
+ '',
22
+ 'Resolve references using this order:',
23
+ '1. An explicitly named subject in the current message.',
24
+ '2. The subject established by the recent conversation.',
25
+ '3. The application context for that message.',
26
+ '',
27
+ 'Treat follow-ups such as "it", "that component", "its props" or "what about variants?" as referring',
28
+ 'to the established conversational subject unless the reader clearly changes the subject.',
29
+ 'Treat phrases such as "this component", "this page" or "the current component" as referring to the',
30
+ 'application context when applicable.',
31
+ '',
32
+ 'When the application context identifies the needed record, read it directly instead of searching.',
33
+ '',
34
+ 'Answer in short, specific prose. Name components and tokens you read. Use code blocks only for',
35
+ 'code or markup the reader would copy. Do not list source pages; the site shows them separately.',
36
+ ].join('\n');
@@ -0,0 +1,97 @@
1
+ import { aiCredentialKind, parseAiModelId, type AiConnectionSettings } from '@handoff/ai/connections';
2
+ import type { AiCredentialKind } from '@handoff/types/config';
3
+ import { getServerRuntimeConfig } from '../docs-api/runtime-config';
4
+
5
+ /**
6
+ * Per-request AI connection resolution.
7
+ *
8
+ * Reports what the config declared and whether a usable key exists for the current reader. The key
9
+ * value itself never leaves the server: the browser only ever learns `configured`.
10
+ */
11
+
12
+ /** One connection as the browser sees it. */
13
+ export interface ResolvedAiConnection {
14
+ id: string;
15
+ label: string;
16
+ /** Absent for a connection served by a `defineAiProvider()` module rather than a base URL. */
17
+ baseUrl?: string;
18
+ models?: string[];
19
+ credential: AiCredentialKind;
20
+ /**
21
+ * Whether this reader can use the connection right now: the deployment's key is set, the endpoint
22
+ * needs none, or the reader has saved their own.
23
+ */
24
+ configured: boolean;
25
+ }
26
+
27
+ /** Find a declared connection by id. Ids only ever come from the resolved list, never from a reader. */
28
+ export const findAiConnection = (connectionId: string): AiConnectionSettings | undefined =>
29
+ getServerRuntimeConfig().ai.connections.find((connection) => connection.id === connectionId);
30
+
31
+ /**
32
+ * Read this reader's saved connection ids. Registry mode only — a workspace has one user, whose
33
+ * config file and `.env` are their user layer, so it has no per-user key store.
34
+ *
35
+ * The registry data layer is imported lazily so the Drizzle/Postgres dependencies stay out of the
36
+ * workspace path, the same way the rest of the registry runtime is reached.
37
+ */
38
+ const readSavedConnections = async (userId: string | null): Promise<Set<string>> => {
39
+ if (!userId || getServerRuntimeConfig().mode !== 'registry') return new Set();
40
+ const [{ getRegistryConnection }, { listRegistryAiKeyConnections }] = await Promise.all([
41
+ import('../registry-connection'),
42
+ import('@handoff/registry/auth'),
43
+ ]);
44
+ const { db } = await getRegistryConnection();
45
+ return new Set(await listRegistryAiKeyConnections(db, userId));
46
+ };
47
+
48
+ /** Whether a connection is usable for a reader, given the keys that reader has saved. */
49
+ const isConfigured = (connection: AiConnectionSettings, saved: ReadonlySet<string>): boolean => {
50
+ switch (aiCredentialKind(connection)) {
51
+ case 'service':
52
+ return Boolean(connection.apiKeyEnv && process.env[connection.apiKeyEnv]?.trim());
53
+ case 'user':
54
+ return saved.has(connection.id);
55
+ default:
56
+ return true;
57
+ }
58
+ };
59
+
60
+ /** Every declared connection, each marked with whether this reader can use it. */
61
+ export const describeAiConnections = async (userId: string | null): Promise<ResolvedAiConnection[]> => {
62
+ const { ai } = getServerRuntimeConfig();
63
+ const saved = await readSavedConnections(userId);
64
+ return ai.connections.map((connection) => ({
65
+ id: connection.id,
66
+ label: connection.label,
67
+ ...(connection.baseUrl ? { baseUrl: connection.baseUrl } : {}),
68
+ ...(connection.models?.length ? { models: connection.models } : {}),
69
+ credential: aiCredentialKind(connection),
70
+ configured: isConfigured(connection, saved),
71
+ }));
72
+ };
73
+
74
+ /** The connections a reader may add a key for. Empty when the config declares none. */
75
+ export const describeAiUserConnections = async (userId: string | null): Promise<ResolvedAiConnection[]> =>
76
+ (await describeAiConnections(userId)).filter((connection) => connection.credential === 'user');
77
+
78
+ /**
79
+ * The model to run, given what the reader asked for. A requested model must name a declared
80
+ * connection and one of that connection's declared models, so a reader can never reach a model the
81
+ * deployment did not list. Falls back to `defaultModel`, then to the first usable model.
82
+ */
83
+ export const selectAiModel = (
84
+ connections: readonly ResolvedAiConnection[],
85
+ requested: string | undefined
86
+ ): { connection: ResolvedAiConnection; model: string } | null => {
87
+ const usable = connections.filter((connection) => connection.configured && connection.models?.length);
88
+ const pick = (value: string | undefined) => {
89
+ const parsed = value ? parseAiModelId(value) : null;
90
+ if (!parsed) return null;
91
+ const connection = usable.find((candidate) => candidate.id === parsed.connectionId);
92
+ return connection?.models?.includes(parsed.model) ? { connection, model: parsed.model } : null;
93
+ };
94
+ const { ai } = getServerRuntimeConfig();
95
+ const first = usable[0];
96
+ return pick(requested) ?? pick(ai.defaultModel) ?? (first?.models?.[0] ? { connection: first, model: first.models[0] } : null);
97
+ };