handoff-app 2.0.0-9 → 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 (303) hide show
  1. package/{Changelog.md → CHANGELOG.md} +85 -1
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +813 -406
  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/cli/create.js +1 -2
  55. package/dist/commands/build/index.d.ts +1 -1
  56. package/dist/commands/build/index.js +1 -1
  57. package/dist/commands/utils.d.ts +3 -3
  58. package/dist/config/defaults.js +7 -4
  59. package/dist/config/from-env.d.ts +4 -0
  60. package/dist/config/from-env.js +38 -1
  61. package/dist/config/helpers.js +2 -2
  62. package/dist/config/index.d.ts +0 -1
  63. package/dist/config/index.js +1 -4
  64. package/dist/config/loader.js +26 -2
  65. package/dist/config/resolve-env.d.ts +2 -1
  66. package/dist/config/resolve-env.js +28 -12
  67. package/dist/config/runtime.js +1 -1
  68. package/dist/index.d.ts +12 -7
  69. package/dist/index.js +18 -22
  70. package/dist/nav/index.d.ts +2 -2
  71. package/dist/nav/registry.d.ts +3 -3
  72. package/dist/nav/registry.js +1 -1
  73. package/dist/nav/workspace.d.ts +3 -3
  74. package/dist/nav/workspace.js +1 -1
  75. package/dist/pipeline/figma.d.ts +13 -2
  76. package/dist/pipeline/figma.js +86 -46
  77. package/dist/pipeline/styles.js +1 -1
  78. package/dist/pipeline/validation.d.ts +1 -1
  79. package/dist/pipeline/validation.js +3 -3
  80. package/dist/providers.d.ts +10 -0
  81. package/dist/providers.js +13 -0
  82. package/dist/registry/asset-storage/define.d.ts +5 -5
  83. package/dist/registry/asset-storage/define.js +4 -4
  84. package/dist/registry/asset-storage/resolve.d.ts +9 -19
  85. package/dist/registry/asset-storage/resolve.js +33 -21
  86. package/dist/registry/asset-storage/types.d.ts +37 -17
  87. package/dist/registry/asset-storage/types.js +5 -5
  88. package/dist/registry/auth/ai-keys.d.ts +32 -0
  89. package/dist/registry/auth/ai-keys.js +69 -0
  90. package/dist/registry/auth/crypto.d.ts +17 -0
  91. package/dist/registry/auth/crypto.js +61 -1
  92. package/dist/registry/auth/index.d.ts +2 -1
  93. package/dist/registry/auth/index.js +11 -2
  94. package/dist/registry/checkout/index.d.ts +2 -1
  95. package/dist/registry/checkout/index.js +57 -127
  96. package/dist/registry/client.js +53 -17
  97. package/dist/registry/db/driver.js +3 -3
  98. package/dist/registry/db/schema.d.ts +261 -3
  99. package/dist/registry/db/schema.js +29 -4
  100. package/dist/registry/email/define.d.ts +10 -0
  101. package/dist/registry/email/define.js +13 -0
  102. package/dist/registry/email/providers/smtp.d.ts +10 -0
  103. package/dist/registry/email/providers/smtp.js +73 -0
  104. package/dist/registry/email/resolve.d.ts +28 -0
  105. package/dist/registry/email/resolve.js +74 -0
  106. package/dist/registry/email/types.d.ts +24 -0
  107. package/dist/registry/email/types.js +6 -0
  108. package/dist/registry/path.d.ts +2 -0
  109. package/dist/registry/path.js +11 -1
  110. package/dist/registry/publish/package.js +8 -0
  111. package/dist/registry/transfer.d.ts +4 -0
  112. package/dist/store/registry.d.ts +3 -3
  113. package/dist/store/registry.js +4 -4
  114. package/dist/transformers/preview/component/builder.d.ts +1 -9
  115. package/dist/transformers/preview/component/builder.js +17 -17
  116. package/dist/transformers/preview/component/css.d.ts +2 -1
  117. package/dist/transformers/preview/component/css.js +8 -5
  118. package/dist/transformers/preview/component/html.d.ts +3 -1
  119. package/dist/transformers/preview/component/html.js +6 -2
  120. package/dist/transformers/preview/component/javascript.d.ts +2 -1
  121. package/dist/transformers/preview/component/javascript.js +10 -4
  122. package/dist/transformers/utils/build.d.ts +5 -0
  123. package/dist/transformers/utils/build.js +21 -4
  124. package/dist/types/config.d.ts +129 -18
  125. package/dist/utils/path.d.ts +1 -5
  126. package/dist/utils/path.js +6 -23
  127. package/docs/api.md +6 -4
  128. package/docs/cli.md +275 -54
  129. package/drizzle/0007_add_ai_keys.sql +11 -0
  130. package/drizzle/0008_add_source_dir.sql +2 -0
  131. package/drizzle/meta/0007_snapshot.json +2013 -0
  132. package/drizzle/meta/0008_snapshot.json +2025 -0
  133. package/drizzle/meta/_journal.json +14 -0
  134. package/package.json +19 -8
  135. package/src/ai/README.md +42 -0
  136. package/src/ai/connections.ts +189 -0
  137. package/src/ai/define.ts +15 -0
  138. package/src/ai/types.ts +30 -0
  139. package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
  140. package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
  141. package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
  142. package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
  143. package/src/app/components/AiAssistant/dockState.ts +86 -0
  144. package/src/app/components/AiAssistant/toolLabel.ts +24 -0
  145. package/src/app/components/AiAssistant/useAiChat.ts +147 -0
  146. package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
  147. package/src/app/components/AiAssistant/usePageContext.ts +63 -0
  148. package/src/app/components/Auth/AccountLayout.tsx +58 -19
  149. package/src/app/components/Auth/AuthShell.tsx +27 -33
  150. package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
  151. package/src/app/components/Auth/installStatus.ts +29 -0
  152. package/src/app/components/Component/Preview.tsx +5 -2
  153. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  154. package/src/app/components/Layout/Header.tsx +21 -15
  155. package/src/app/components/Layout/Main.tsx +2 -2
  156. package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
  157. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  158. package/src/app/components/Navigation/MobileNav.tsx +2 -2
  159. package/src/app/components/Navigation/SideNav.tsx +1 -15
  160. package/src/app/components/context/NavProvider.tsx +7 -3
  161. package/src/app/components/ui/attachment.tsx +50 -0
  162. package/src/app/components/ui/bubble.tsx +57 -0
  163. package/src/app/components/ui/button.tsx +3 -1
  164. package/src/app/components/ui/confirm-dialog.tsx +87 -0
  165. package/src/app/components/ui/dialog.tsx +7 -2
  166. package/src/app/components/ui/marker.tsx +56 -0
  167. package/src/app/components/ui/sidebar.tsx +3 -1
  168. package/src/app/components/util/index.ts +3 -12
  169. package/src/app/css/index.css +64 -0
  170. package/src/app/lib/ai/auth.ts +33 -0
  171. package/src/app/lib/ai/context.ts +99 -0
  172. package/src/app/lib/ai/model.ts +79 -0
  173. package/src/app/lib/ai/prompt.ts +36 -0
  174. package/src/app/lib/ai/resolve.ts +97 -0
  175. package/src/app/lib/ai/tools.ts +120 -0
  176. package/src/app/lib/api/methods.ts +13 -0
  177. package/src/app/lib/asset-storage.ts +35 -36
  178. package/src/app/lib/auth/api.ts +4 -12
  179. package/src/app/lib/auth/email.ts +96 -23
  180. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  181. package/src/app/lib/docs-api/registry.ts +2 -3
  182. package/src/app/lib/docs-api/runtime-config.ts +47 -21
  183. package/src/app/lib/mcp/components.ts +523 -0
  184. package/src/app/lib/mcp/page-url.ts +24 -0
  185. package/src/app/lib/mcp/pages.ts +84 -0
  186. package/src/app/lib/mcp/result.ts +33 -0
  187. package/src/app/lib/mcp/server.ts +39 -318
  188. package/src/app/lib/mcp/tokens.ts +334 -0
  189. package/src/app/lib/mcp/tool.ts +19 -0
  190. package/src/app/lib/registry-api/asset-transfer.ts +119 -11
  191. package/src/app/lib/registry-api/errors.ts +2 -0
  192. package/src/app/lib/registry-api/handler.ts +7 -1
  193. package/src/app/lib/registry-api/transfer.ts +28 -6
  194. package/src/app/lib/server-module.ts +34 -0
  195. package/src/app/lib/utils.ts +20 -0
  196. package/src/app/next.config.mjs +26 -24
  197. package/src/app/pages/[...slug]/index.tsx +2 -1
  198. package/src/app/pages/_app.tsx +6 -2
  199. package/src/app/pages/_document.tsx +6 -0
  200. package/src/app/pages/account/ai.tsx +183 -0
  201. package/src/app/pages/account/index.tsx +68 -68
  202. package/src/app/pages/account/tokens.tsx +37 -7
  203. package/src/app/pages/account/users.tsx +75 -15
  204. package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
  205. package/src/app/pages/api/account/ai/keys/index.ts +20 -0
  206. package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
  207. package/src/app/pages/api/admin/users/index.ts +2 -2
  208. package/src/app/pages/api/ai/chat.ts +115 -0
  209. package/src/app/pages/api/ai/connections.ts +23 -0
  210. package/src/app/pages/api/auth/request-reset.ts +14 -16
  211. package/src/app/pages/api/docs/nav.json.ts +1 -1
  212. package/src/app/pages/api/install.ts +3 -3
  213. package/src/app/pages/api/mcp.ts +6 -0
  214. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
  215. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
  216. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  217. package/src/app/pages/cli/device.tsx +10 -5
  218. package/src/app/pages/foundations/colors.tsx +9 -8
  219. package/src/app/pages/foundations/index.tsx +3 -3
  220. package/src/app/pages/index.tsx +2 -2
  221. package/src/app/pages/install.tsx +7 -2
  222. package/src/app/pages/login.tsx +32 -27
  223. package/src/app/pages/reset-password.tsx +21 -20
  224. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  225. package/src/app/pages/system/index.tsx +11 -11
  226. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  227. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  228. package/src/app-builder/README.md +2 -1
  229. package/src/app-builder/build.ts +218 -73
  230. package/src/app-builder/client-config.ts +3 -2
  231. package/src/app-builder/paths.ts +3 -22
  232. package/src/app-builder/static-export.ts +1 -1
  233. package/src/app-builder/vercel-output.ts +5 -4
  234. package/src/cache/README.md +4 -1
  235. package/src/cache/build-cache.ts +128 -43
  236. package/src/cache/build-inputs.ts +28 -0
  237. package/src/cache/index.ts +4 -0
  238. package/src/cli/create.ts +1 -2
  239. package/src/commands/build/index.ts +1 -1
  240. package/src/config/README.md +10 -9
  241. package/src/config/defaults.ts +7 -4
  242. package/src/config/from-env.ts +33 -0
  243. package/src/config/helpers.ts +1 -3
  244. package/src/config/index.ts +0 -3
  245. package/src/config/loader.ts +25 -3
  246. package/src/config/resolve-env.ts +28 -15
  247. package/src/config/runtime.ts +1 -1
  248. package/src/index.ts +33 -29
  249. package/src/nav/index.ts +2 -2
  250. package/src/nav/registry.ts +3 -3
  251. package/src/nav/workspace.ts +3 -3
  252. package/src/pipeline/README.md +1 -1
  253. package/src/pipeline/figma.ts +98 -49
  254. package/src/pipeline/styles.ts +1 -1
  255. package/src/pipeline/validation.ts +3 -3
  256. package/src/providers.ts +18 -0
  257. package/src/registry/asset-storage/define.ts +6 -6
  258. package/src/registry/asset-storage/resolve.ts +38 -37
  259. package/src/registry/asset-storage/types.ts +39 -17
  260. package/src/registry/auth/ai-keys.ts +62 -0
  261. package/src/registry/auth/crypto.ts +63 -1
  262. package/src/registry/auth/index.ts +11 -1
  263. package/src/registry/checkout/index.ts +60 -138
  264. package/src/registry/client.ts +60 -20
  265. package/src/registry/db/driver.ts +2 -2
  266. package/src/registry/db/schema.ts +49 -22
  267. package/src/registry/email/define.ts +15 -0
  268. package/src/registry/email/providers/smtp.ts +39 -0
  269. package/src/registry/email/resolve.ts +94 -0
  270. package/src/registry/email/types.ts +28 -0
  271. package/src/registry/path.ts +11 -0
  272. package/src/registry/publish/package.ts +9 -0
  273. package/src/registry/transfer.ts +4 -0
  274. package/src/store/registry.ts +7 -7
  275. package/src/transformers/preview/component/builder.ts +19 -25
  276. package/src/transformers/preview/component/css.ts +9 -4
  277. package/src/transformers/preview/component/html.ts +6 -2
  278. package/src/transformers/preview/component/javascript.ts +14 -4
  279. package/src/transformers/utils/build.ts +16 -3
  280. package/src/types/config.ts +142 -20
  281. package/src/utils/README.md +1 -1
  282. package/src/utils/path.ts +6 -29
  283. package/templates/default/handoff.config.js.tpl +10 -2
  284. package/templates/default/handoff.config.ts.tpl +10 -2
  285. package/templates/default/handoff.config.with-examples.js.tpl +10 -2
  286. package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
  287. package/templates/default/package.json.tpl +2 -2
  288. package/.editorconfig +0 -19
  289. package/.eslintrc.json +0 -4
  290. package/.prettierignore +0 -0
  291. package/.prettierrc.json +0 -8
  292. package/.vscode/settings.json +0 -17
  293. package/dist/config/validator.d.ts +0 -9
  294. package/dist/config/validator.js +0 -24
  295. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
  296. package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
  297. package/src/app/components/old/Header.tsx +0 -89
  298. package/src/app/lib/mcp/shape.ts +0 -596
  299. package/src/app/pages/assets/fonts.tsx +0 -129
  300. package/src/app/pages/assets/index.tsx +0 -91
  301. package/src/app/pages/assets/logos.tsx +0 -75
  302. package/src/config/validator.ts +0 -22
  303. package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
@@ -1,12 +1,10 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import type { RuntimeMode } from '@handoff/types/config';
4
- import {
5
- DEFAULT_DATABASE_URL_ENV,
6
- DEFAULT_REGISTRY_DRIVER,
7
- type RegistryDatabaseDriver,
8
- } from '@handoff/registry/db/driver';
9
- import { DEFAULT_ASSET_STORAGE_ADAPTER, type AssetStorageSettings } from '@handoff/registry/asset-storage/resolve';
4
+ import { DEFAULT_DATABASE_URL_ENV, DEFAULT_REGISTRY_DRIVER, type RegistryDatabaseDriver } from '@handoff/registry/db/driver';
5
+ import { DEFAULT_ASSET_STORAGE_PROVIDER, type AssetStorageSettings } from '@handoff/registry/asset-storage/resolve';
6
+ import { mergeAiConnections, parseAiConnections, type AiSettings } from '@handoff/ai/connections';
7
+ import { parseEmailSettings, type EmailSettings } from '@handoff/registry/email/resolve';
10
8
 
11
9
  /**
12
10
  * Server-side runtime resolution for the docs read API.
@@ -35,12 +33,20 @@ export interface ServerRuntimeConfig {
35
33
  mode: RuntimeMode;
36
34
  /** Whether this build serves `/api/mcp/`. Config-only, baked at build time. */
37
35
  mcp: boolean;
36
+ /**
37
+ * The AI assistant: whether this build serves it, and the connections it can reach. `enabled` is
38
+ * baked; the connection list is resolved per request so a deployment can change it without a
39
+ * rebuild.
40
+ */
41
+ ai: AiSettings;
38
42
  registry: ServerRegistryRuntimeConfig;
39
43
  /**
40
- * Asset storage selection for registry mode (provider + non-secret options + env-var names). Secret
41
- * values are resolved from `process.env` at request time, never persisted here.
44
+ * Asset storage provider, module, and options for registry mode. Options hold `{ $env }` references,
45
+ * never secret values.
42
46
  */
43
47
  assetStorage: AssetStorageSettings;
48
+ /** Registry email provider, sender, and options. Options hold `{ $env }` references, never secret values. */
49
+ email: EmailSettings;
44
50
  }
45
51
 
46
52
  let cached: ServerRuntimeConfig | null = null;
@@ -49,16 +55,18 @@ let cached: ServerRuntimeConfig | null = null;
49
55
  const defaults = (): ServerRuntimeConfig => ({
50
56
  mode: 'workspace',
51
57
  mcp: true,
58
+ ai: { enabled: false, connections: [] },
52
59
  registry: {
53
60
  driver: DEFAULT_REGISTRY_DRIVER,
54
61
  databaseUrlEnv: DEFAULT_DATABASE_URL_ENV,
55
62
  },
56
- assetStorage: { adapter: DEFAULT_ASSET_STORAGE_ADAPTER },
63
+ assetStorage: { provider: DEFAULT_ASSET_STORAGE_PROVIDER },
64
+ email: { options: {} },
57
65
  });
58
66
 
59
67
  /** Parse the baked asset-storage selection from env (names/selectors only; JSON options tolerated). */
60
68
  const assetStorageFromEnv = (): AssetStorageSettings => {
61
- const adapter = process.env.HANDOFF_ASSET_STORAGE_ADAPTER?.trim();
69
+ const provider = process.env.HANDOFF_ASSET_STORAGE_PROVIDER?.trim();
62
70
  let options: Record<string, unknown> | undefined;
63
71
  const rawOptions = process.env.HANDOFF_ASSET_STORAGE_OPTIONS?.trim();
64
72
  if (rawOptions) {
@@ -71,22 +79,33 @@ const assetStorageFromEnv = (): AssetStorageSettings => {
71
79
  }
72
80
  const maxInline = Number(process.env.HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES);
73
81
  return {
74
- adapter: adapter === 'vercel-blob' || adapter === 'custom' ? adapter : DEFAULT_ASSET_STORAGE_ADAPTER,
82
+ provider: provider === 'custom' ? provider : DEFAULT_ASSET_STORAGE_PROVIDER,
75
83
  module: process.env.HANDOFF_ASSET_STORAGE_MODULE?.trim() || undefined,
76
- tokenEnv: process.env.HANDOFF_ASSET_STORAGE_TOKEN_ENV?.trim() || undefined,
77
84
  maxInlineBytes: Number.isFinite(maxInline) && maxInline > 0 ? maxInline : undefined,
78
85
  options,
79
86
  };
80
87
  };
81
88
 
89
+ /**
90
+ * Resolve the AI settings from the baked flag and connection list, then merge the
91
+ * deployment-supplied `HANDOFF_AI_CONNECTIONS` over it by id.
92
+ *
93
+ * `HANDOFF_AI_CONNECTIONS` is read dynamically rather than as a literal `process.env` reference: the
94
+ * baked values in `next.config.mjs` are inlined at build time, so a name in that block can never
95
+ * carry a deploy-time value. That is why the baked list uses its own `HANDOFF_AI_BAKED_CONNECTIONS`.
96
+ */
97
+ const aiFromEnv = (): AiSettings => ({
98
+ enabled: process.env.HANDOFF_AI_ENABLED?.trim() === 'true',
99
+ connections: mergeAiConnections(
100
+ parseAiConnections(process.env.HANDOFF_AI_BAKED_CONNECTIONS),
101
+ parseAiConnections(process.env['HANDOFF_AI_CONNECTIONS'])
102
+ ),
103
+ defaultModel: process.env.HANDOFF_AI_DEFAULT_MODEL?.trim() || undefined,
104
+ });
105
+
82
106
  /** Absolute path of the server-only runtime config persisted next to `client.config.json`. */
83
107
  const serverRuntimeConfigPath = (): string =>
84
- path.resolve(
85
- process.env.HANDOFF_MODULE_PATH ?? '',
86
- '.handoff',
87
- process.env.HANDOFF_PROJECT_ID ?? '',
88
- 'runtime.server.json'
89
- );
108
+ path.resolve(process.env.HANDOFF_MODULE_PATH ?? '', '.handoff', process.env.HANDOFF_PROJECT_ID ?? '', 'runtime.server.json');
90
109
 
91
110
  /**
92
111
  * Resolve the server-side runtime config from the values baked into the bundle at build time
@@ -107,11 +126,18 @@ const fromEnv = (): ServerRuntimeConfig | null => {
107
126
  return {
108
127
  mode: mode === 'registry' ? 'registry' : 'workspace',
109
128
  mcp: process.env.HANDOFF_MCP_ENABLED?.trim() !== 'false',
129
+ ai: aiFromEnv(),
110
130
  registry: {
111
131
  driver: process.env.HANDOFF_REGISTRY_DRIVER?.trim() === 'neon' ? 'neon' : 'pg',
112
132
  databaseUrlEnv,
113
133
  },
114
134
  assetStorage: assetStorageFromEnv(),
135
+ email: parseEmailSettings({
136
+ from: process.env.HANDOFF_EMAIL_FROM,
137
+ provider: process.env.HANDOFF_EMAIL_PROVIDER?.trim(),
138
+ module: process.env.HANDOFF_EMAIL_MODULE,
139
+ options: process.env.HANDOFF_EMAIL_OPTIONS,
140
+ }),
115
141
  };
116
142
  };
117
143
 
@@ -141,17 +167,17 @@ export const getServerRuntimeConfig = (): ServerRuntimeConfig => {
141
167
  ? parsed.registry.databaseUrlEnv.trim()
142
168
  : DEFAULT_DATABASE_URL_ENV;
143
169
  const assetStorage: AssetStorageSettings =
144
- parsed?.assetStorage && typeof parsed.assetStorage === 'object'
145
- ? parsed.assetStorage
146
- : { adapter: DEFAULT_ASSET_STORAGE_ADAPTER };
170
+ parsed?.assetStorage && typeof parsed.assetStorage === 'object' ? parsed.assetStorage : { provider: DEFAULT_ASSET_STORAGE_PROVIDER };
147
171
  cached = {
148
172
  mode: parsed?.mode === 'registry' ? 'registry' : 'workspace',
149
173
  mcp: parsed?.mcp !== false,
174
+ ai: aiFromEnv(),
150
175
  registry: {
151
176
  driver: parsed?.registry?.driver === 'neon' ? 'neon' : 'pg',
152
177
  databaseUrlEnv,
153
178
  },
154
179
  assetStorage,
180
+ email: parseEmailSettings(parsed?.email ?? {}),
155
181
  };
156
182
  return cached;
157
183
  }
@@ -0,0 +1,523 @@
1
+ import { buildArtifactUrl } from '@handoff/artifacts/url';
2
+ import type { SlotMetadata } from '@handoff/transformers/preview/component';
3
+ import type { ComponentListObject, OptionalPreviewRender, TransformComponentTokensResult } from '@handoff/transformers/preview/types';
4
+ import type { DocsBackend } from '../docs-api/backend';
5
+ import { basePath, fail, ok, read } from './result';
6
+ import { defineTool, type McpTool } from './tool';
7
+
8
+ /** The component tools: search, read, one preview, and the source. */
9
+
10
+ /** Search results are capped so a large design system cannot flood an agent's context. */
11
+ const DEFAULT_SEARCH_LIMIT = 25;
12
+ const MAX_SEARCH_LIMIT = 100;
13
+
14
+ /** The source fields handoff_get_component_source returns; the build artifact carries them under these names. */
15
+ const SOURCE_FIELDS = ['code', 'css', 'sass', 'js'] as const;
16
+ type SourceField = (typeof SOURCE_FIELDS)[number];
17
+
18
+ const componentId = { type: 'string', description: 'Component id, as returned by handoff_search_components.' };
19
+
20
+ export const componentTools: McpTool[] = [
21
+ defineTool<{ query?: string; group?: string; category?: string; tag?: string; limit?: number }>(
22
+ {
23
+ name: 'handoff_search_components',
24
+ title: 'Search components',
25
+ description:
26
+ 'Find components. `query` matches id, title, group, categories and tags. With no arguments, ' +
27
+ 'lists all components. Each result has id, title, description, group, type, categories and ' +
28
+ 'tags. Read one component with handoff_get_component.',
29
+ annotations: { readOnlyHint: true, openWorldHint: false },
30
+ inputSchema: {
31
+ type: 'object',
32
+ properties: {
33
+ query: { type: 'string', description: 'Text to find. Case-insensitive substring match.' },
34
+ group: { type: 'string', description: 'Group name, for example "Form Elements". Exact match, case-insensitive.' },
35
+ category: { type: 'string', description: 'Category name. Exact match, case-insensitive.' },
36
+ tag: { type: 'string', description: 'Tag name. Exact match, case-insensitive.' },
37
+ limit: { type: 'integer', minimum: 1, maximum: MAX_SEARCH_LIMIT, description: `Max results (default ${DEFAULT_SEARCH_LIMIT}).` },
38
+ },
39
+ },
40
+ },
41
+ async ({ query, group, category, tag, limit }) =>
42
+ read('the component catalog', async (backend) => {
43
+ const matched = (await backend.listComponents()).filter((record: ComponentListObject) =>
44
+ matchesComponent(record, { query, group, category, tag })
45
+ );
46
+ const components = matched.slice(0, limit ?? DEFAULT_SEARCH_LIMIT).map(toComponentSummary);
47
+ return ok({ total: matched.length, returned: components.length, components });
48
+ })
49
+ ),
50
+
51
+ defineTool<{ id: string }>(
52
+ {
53
+ name: 'handoff_get_component',
54
+ title: 'Get component',
55
+ description:
56
+ 'One component: properties, variants, usage and guidelines. `usage` is the snippet of the first ' +
57
+ 'preview. When `usage` is absent, as for handlebars components, read the template with ' +
58
+ 'handoff_get_component_source. `variants` lists the values the previews show; ' +
59
+ '`properties[].type` has all allowed values. To see another state, pass a `previews[].id` to ' +
60
+ 'handoff_get_component_preview. `tokens.set`, when present, is the id to pass to ' +
61
+ 'handoff_get_tokens. Do not derive a token set id from the component id.',
62
+ annotations: { readOnlyHint: true, openWorldHint: false },
63
+ inputSchema: { type: 'object', properties: { id: componentId }, required: ['id'] },
64
+ },
65
+ async ({ id }) =>
66
+ read(`component "${id}"`, async (backend) => {
67
+ const record = await backend.getComponentDetail(id);
68
+ if (!record) {
69
+ return componentNotFound(id);
70
+ }
71
+ const [artifact, tokenSets] = await Promise.all([readComponentArtifact(backend, id), backend.listTokenSets()]);
72
+ return ok(
73
+ toComponentResult(
74
+ record,
75
+ artifact,
76
+ basePath(),
77
+ tokenSets.map((set) => set.id)
78
+ )
79
+ );
80
+ })
81
+ ),
82
+
83
+ defineTool<{ id: string; preview: string }>(
84
+ {
85
+ name: 'handoff_get_component_preview',
86
+ title: 'Get component preview',
87
+ description:
88
+ 'One state of a component: `values` (the args), `usage` (the snippet) and `html` (the rendered ' +
89
+ 'markup). `html` is absent when the component is not built.',
90
+ annotations: { readOnlyHint: true, openWorldHint: false },
91
+ inputSchema: {
92
+ type: 'object',
93
+ properties: {
94
+ id: componentId,
95
+ preview: { type: 'string', description: 'Preview id, as listed in `previews[].id` by handoff_get_component.' },
96
+ },
97
+ required: ['id', 'preview'],
98
+ },
99
+ },
100
+ async ({ id, preview }) =>
101
+ read(`preview "${preview}" of component "${id}"`, async (backend) => {
102
+ const record = await backend.getComponentDetail(id);
103
+ if (!record) {
104
+ return componentNotFound(id);
105
+ }
106
+ const artifact = await readComponentArtifact(backend, id);
107
+ const previews = artifact?.previews ?? record.previews ?? {};
108
+ const found = previews[preview];
109
+ if (!found) {
110
+ const ids = Object.keys(previews);
111
+ return fail(
112
+ `Component "${id}" has no preview "${preview}". ${ids.length ? `Its previews are: ${ids.join(', ')}.` : 'It has no previews.'}`
113
+ );
114
+ }
115
+ return ok(toPreviewResult(record, preview, found, basePath(), await readPreviewDocument(backend, id, preview)));
116
+ })
117
+ ),
118
+
119
+ defineTool<{ id: string }>(
120
+ {
121
+ name: 'handoff_get_component_source',
122
+ title: 'Get component source',
123
+ description:
124
+ 'The implementation of a component: `code` (the component or template) and, when present, ' +
125
+ '`css`, `sass` and `js`. Use it only when handoff_get_component is not enough. The source ' +
126
+ 'fields are absent when the component is not built.',
127
+ annotations: { readOnlyHint: true, openWorldHint: false },
128
+ inputSchema: { type: 'object', properties: { id: componentId }, required: ['id'] },
129
+ },
130
+ async ({ id }) =>
131
+ read(`source of component "${id}"`, async (backend) => {
132
+ const record = await backend.getComponentDetail(id);
133
+ if (!record) {
134
+ return componentNotFound(id);
135
+ }
136
+ return ok(toComponentSource(record, await readComponentArtifact(backend, id)));
137
+ })
138
+ ),
139
+ ];
140
+
141
+ const componentNotFound = (id: string) =>
142
+ fail(`Component "${id}" was not found. Use handoff_search_components to list the components that exist.`);
143
+
144
+ /** One preview's rendered document, or `undefined` when the component has not been built. */
145
+ const readPreviewDocument = async (backend: DocsBackend, id: string, previewId: string): Promise<string | undefined> => {
146
+ const artifact = await backend.resolveArtifact(['component', `${id}-${previewId}.html`]);
147
+ return artifact?.body.toString() || undefined;
148
+ };
149
+
150
+ /** Read and parse a component's build artifact, or `null` when the component has not been built. */
151
+ const readComponentArtifact = async (backend: DocsBackend, id: string): Promise<TransformComponentTokensResult | null> => {
152
+ const artifact = await backend.resolveArtifact(['component', `${id}.json`]);
153
+ if (!artifact) {
154
+ return null;
155
+ }
156
+ try {
157
+ return JSON.parse(artifact.body.toString()) as TransformComponentTokensResult;
158
+ } catch {
159
+ // A corrupt artifact should not sink the metadata the agent can still use.
160
+ return null;
161
+ }
162
+ };
163
+
164
+ /**
165
+ * One row of a component search result: identity and classification, and nothing else.
166
+ *
167
+ * Search answers "which component do I want", so a row carries only what tells one component from
168
+ * another. Previews, properties and usage belong to handoff_get_component.
169
+ */
170
+ export interface ComponentSummary {
171
+ id: string;
172
+ title: string;
173
+ description?: string;
174
+ group?: string;
175
+ type?: string;
176
+ categories?: string[];
177
+ tags?: string[];
178
+ }
179
+
180
+ const nonEmpty = (values: string[] | undefined): string[] | undefined => (values?.length ? values : undefined);
181
+
182
+ export const toComponentSummary = (record: ComponentListObject): ComponentSummary => ({
183
+ id: record.id,
184
+ title: record.title,
185
+ description: record.description || undefined,
186
+ group: record.group || undefined,
187
+ type: record.type || undefined,
188
+ categories: nonEmpty(record.categories),
189
+ tags: nonEmpty(record.tags),
190
+ });
191
+
192
+ /** Case-insensitive substring test that treats a missing haystack as no match. */
193
+ const contains = (haystack: string | undefined, needle: string): boolean => (haystack ?? '').toLowerCase().includes(needle);
194
+
195
+ /** Case-insensitive equality, used by the `group`/`category`/`tag` filters. */
196
+ const equals = (value: string | undefined, other: string): boolean => (value ?? '').toLowerCase() === other;
197
+
198
+ export interface ComponentSearchFilters {
199
+ query?: string;
200
+ group?: string;
201
+ category?: string;
202
+ tag?: string;
203
+ }
204
+
205
+ /**
206
+ * Whether a record satisfies every supplied filter. `query` is a substring across id, title, group,
207
+ * categories and tags; the others are exact case-insensitive matches, so an agent can pivot straight
208
+ * off a value it just read out of a search result.
209
+ */
210
+ export const matchesComponent = (record: ComponentListObject, filters: ComponentSearchFilters): boolean => {
211
+ const query = filters.query?.trim().toLowerCase();
212
+ const group = filters.group?.trim().toLowerCase();
213
+ const category = filters.category?.trim().toLowerCase();
214
+ const tag = filters.tag?.trim().toLowerCase();
215
+
216
+ if (query) {
217
+ const hit =
218
+ contains(record.id, query) ||
219
+ contains(record.title, query) ||
220
+ contains(record.group, query) ||
221
+ (record.categories ?? []).some((value) => contains(value, query)) ||
222
+ (record.tags ?? []).some((value) => contains(value, query));
223
+ if (!hit) return false;
224
+ }
225
+ if (group && !equals(record.group, group)) return false;
226
+ if (category && !(record.categories ?? []).some((value) => equals(value, category))) return false;
227
+ if (tag && !(record.tags ?? []).some((value) => equals(value, tag))) return false;
228
+ return true;
229
+ };
230
+
231
+ /** A component property as an agent needs it: what to pass, of what type, and whether it is required. */
232
+ export interface ComponentProperty {
233
+ name: string;
234
+ description?: string;
235
+ /** The prop's type: its real signature when docgen resolved one, else the coarse slot type. */
236
+ type: string;
237
+ default?: unknown;
238
+ required?: boolean;
239
+ /** Element type, for an array property. */
240
+ items?: string;
241
+ /** Nested shape, for an object or array-of-object property. */
242
+ properties?: Record<string, ComponentProperty>;
243
+ }
244
+
245
+ /**
246
+ * Flatten one `SlotMetadata`.
247
+ *
248
+ * `type` prefers `docgenType` because `SlotType` mostly falls back to `text` once docgen has run: it
249
+ * reports a boolean prop, an enum and an array alike, which is enough to make an agent pass the
250
+ * wrong thing. `docgenType` is the real signature (`boolean | undefined`, `"sm" | "lg" | undefined`,
251
+ * `SelectOption[]`), and it covers values no preview happens to demonstrate. `SlotType` is the
252
+ * fallback for a component with no docgen, such as handlebars or CSF.
253
+ *
254
+ * The rest of the docgen output is dropped: `deepType` is a whole type AST, and `typeRefs`,
255
+ * `annotations` and `warnings` give an agent nothing to act on.
256
+ */
257
+ export const toProperty = (key: string, slot: SlotMetadata): ComponentProperty => ({
258
+ name: slot.name || key,
259
+ description: slot.description || undefined,
260
+ type: slot.docgenType || slot.type,
261
+ default: slot.default ?? undefined,
262
+ required: slot.rules?.required || undefined,
263
+ items: slot.items?.type,
264
+ properties: toProperties(slot.properties ?? slot.items?.properties),
265
+ });
266
+
267
+ export const toProperties = (slots: { [key: string]: SlotMetadata } | undefined): Record<string, ComponentProperty> | undefined => {
268
+ if (!slots || Object.keys(slots).length === 0) return undefined;
269
+ return Object.fromEntries(Object.entries(slots).map(([key, slot]) => [key, toProperty(key, slot)]));
270
+ };
271
+
272
+ /**
273
+ * One row of a component's preview index: enough to choose a preview, and nothing else.
274
+ *
275
+ * What a preview demonstrates and what it renders to are the bulk of a component record and are
276
+ * wanted one at a time, so they live behind handoff_get_component_preview. `variants` already names
277
+ * the axes these previews cover.
278
+ */
279
+ export interface ComponentPreviewSummary {
280
+ id: string;
281
+ title: string;
282
+ /** Canonical URL of the rendered preview artifact. */
283
+ url: string;
284
+ }
285
+
286
+ /** One preview in full: the args it passes, its usage snippet and the markup it renders to. */
287
+ export interface ComponentPreviewResult extends ComponentPreviewSummary {
288
+ /** The component it belongs to, so the caller can link to it without a second call. */
289
+ component: { id: string; title: string };
290
+ values: Record<string, unknown>;
291
+ usage?: string;
292
+ /** Markup this preview renders to. Absent when the component has not been built. */
293
+ html?: string;
294
+ }
295
+
296
+ /**
297
+ * The rendered markup inside a preview document's `<body>`.
298
+ *
299
+ * An emitted preview is a whole HTML page: doctype, stylesheet links, the hydration script. None of
300
+ * that is the component, and all of it is noise in an agent's context. `trimPreview` narrows the
301
+ * same way, but its parser lives in a module that also pulls in prettier. These documents are
302
+ * generator-written with exactly one `<body>`, so a pattern is enough here.
303
+ */
304
+ const previewBody = (document: string): string => {
305
+ const match = /<body[^>]*>([\s\S]*)<\/body>/i.exec(document);
306
+ return (match ? match[1] : document).trim();
307
+ };
308
+
309
+ /**
310
+ * The URL a preview's rendered artifact is served at. The builder writes the record's own `url`
311
+ * empty, so it is derived here the way the docs UI does.
312
+ */
313
+ const previewUrl = (id: string, previewId: string, basePath: string): string =>
314
+ buildArtifactUrl(`component/${id}-${previewId}.html`, basePath);
315
+
316
+ /** The component's previews as an index: which ones exist, and where each is rendered. */
317
+ export const toPreviewIndex = (
318
+ id: string,
319
+ previews: { [key: string]: OptionalPreviewRender } | undefined,
320
+ basePath: string
321
+ ): ComponentPreviewSummary[] =>
322
+ Object.entries(previews ?? {}).map(([previewId, preview]) => ({
323
+ id: previewId,
324
+ title: preview.title,
325
+ url: previewUrl(id, previewId, basePath),
326
+ }));
327
+
328
+ /** One preview in full, with the component it belongs to so the caller can link back to it. */
329
+ export const toPreviewResult = (
330
+ record: ComponentListObject,
331
+ previewId: string,
332
+ preview: OptionalPreviewRender,
333
+ basePath: string,
334
+ document?: string
335
+ ): ComponentPreviewResult => ({
336
+ component: { id: record.id, title: record.title },
337
+ id: previewId,
338
+ title: preview.title,
339
+ url: previewUrl(record.id, previewId, basePath),
340
+ values: preview.values ?? {},
341
+ usage: preview.usage || undefined,
342
+ html: document ? previewBody(document) : undefined,
343
+ });
344
+
345
+ /**
346
+ * Property names that are content slots by convention. Only consulted for a component with no type
347
+ * information at all, such as a handlebars template; a declared type always wins.
348
+ */
349
+ const CONTENT_PROPERTIES = new Set([
350
+ 'children',
351
+ 'label',
352
+ 'title',
353
+ 'text',
354
+ 'content',
355
+ 'description',
356
+ 'placeholder',
357
+ 'alt',
358
+ 'src',
359
+ 'href',
360
+ 'caption',
361
+ 'subtitle',
362
+ ]);
363
+
364
+ /**
365
+ * Whether a property's type is a closed set of choices, which is what makes it a variant axis.
366
+ *
367
+ * True for a boolean, for `SlotType`'s `boolean`/`enum`, and for a union of literals
368
+ * (`"sm" | "lg" | undefined`). False for anything open-ended: `string`, `ReactNode`, `SelectOption[]`.
369
+ * `undefined`/`null` members are ignored so an optional prop is judged on its real choices.
370
+ */
371
+ const isClosedSet = (type: string | undefined): boolean => {
372
+ if (!type) return false;
373
+ const parts = type
374
+ .split('|')
375
+ .map((part) => part.trim())
376
+ .filter((part) => part && part !== 'undefined' && part !== 'null');
377
+ if (parts.length === 0) return false;
378
+ if (parts.length === 1 && (parts[0] === 'boolean' || parts[0] === 'enum')) return true;
379
+ return parts.every((part) => /^(['"]).*\1$/.test(part) || /^-?\d+(\.\d+)?$/.test(part) || part === 'true' || part === 'false');
380
+ };
381
+
382
+ /**
383
+ * The component's variant axes: the properties that select between visual states, and the values the
384
+ * previews demonstrate for each.
385
+ *
386
+ * A property qualifies on its *type*, not its values. A closed set of choices (an enum, a boolean)
387
+ * is an axis, anything open-ended is content. That is what keeps a button's `children` out of the
388
+ * list: `"Primary Action"` is a label, not a variant, however much it looks like one next to
389
+ * `primary`.
390
+ *
391
+ * With no type information at all, as with a handlebars component, booleans are still axes and
392
+ * strings count unless the property is a conventional content slot. That rule is weaker, so it is
393
+ * only reached when there is nothing better to go on.
394
+ *
395
+ * The values here are the ones the previews show, so each is something you can go and look at.
396
+ * `properties[].type` carries the full declared set, which is usually wider.
397
+ */
398
+ export const deriveVariants = (
399
+ previews: { [key: string]: OptionalPreviewRender } | undefined,
400
+ properties: Record<string, ComponentProperty> | undefined
401
+ ): Record<string, (string | number | boolean)[]> => {
402
+ const axes: Record<string, (string | number | boolean)[]> = {};
403
+ for (const preview of Object.values(previews ?? {})) {
404
+ for (const [property, value] of Object.entries(preview.values ?? {})) {
405
+ const declared = properties?.[property];
406
+ const isAxis = declared
407
+ ? isClosedSet(declared.type)
408
+ : typeof value === 'boolean' || (typeof value === 'string' && !CONTENT_PROPERTIES.has(property.toLowerCase()));
409
+ if (!isAxis) continue;
410
+ if (typeof value !== 'string' && typeof value !== 'boolean' && typeof value !== 'number') continue;
411
+ if (value === '') continue;
412
+ const values = (axes[property] ??= []);
413
+ if (!values.includes(value)) values.push(value);
414
+ }
415
+ }
416
+ return axes;
417
+ };
418
+
419
+ export interface ComponentResult extends ComponentSummary {
420
+ renderer?: string;
421
+ /** Source format the implementation is written in, when the renderer alone does not say. */
422
+ sourceFormat?: string;
423
+ properties?: Record<string, ComponentProperty>;
424
+ /** Which previews exist and where each is rendered; the bodies come from a preview read. */
425
+ previews: ComponentPreviewSummary[];
426
+ variants: Record<string, (string | number | boolean)[]>;
427
+ /** The first preview's usage snippet. Absent when the renderer generates none, as with handlebars. */
428
+ usage?: string;
429
+ guidelines?: { shouldDo?: string[]; shouldNotDo?: string[] };
430
+ /** This component's token set, when its declaration names the Figma component it maps to. */
431
+ tokens?: { set: string };
432
+ }
433
+
434
+ /** The component's source, as returned by handoff_get_component_source. */
435
+ export interface ComponentSourceResult extends Partial<Record<SourceField, string>> {
436
+ id: string;
437
+ title: string;
438
+ renderer?: string;
439
+ sourceFormat?: string;
440
+ }
441
+
442
+ /**
443
+ * The component's own source.
444
+ *
445
+ * The artifact's top-level `html` is left out. That field holds the rendered markup of one preview
446
+ * picked by the renderer (the first for handlebars, the last for react and CSF), and the pick can
447
+ * land on an internal pattern preview that is stripped from `previews` before the artifact is
448
+ * written. So it is markup for a state the response does not describe, with no way for an agent to
449
+ * tell which. Rendered markup is per preview and returned by handoff_get_component_preview, read
450
+ * from that preview's own artifact.
451
+ */
452
+ const pickCode = (artifact: TransformComponentTokensResult): Partial<Record<SourceField, string>> => {
453
+ const code: Partial<Record<SourceField, string>> = {};
454
+ for (const field of SOURCE_FIELDS) {
455
+ const value = artifact?.[field];
456
+ if (typeof value === 'string' && value) code[field] = value;
457
+ }
458
+ return code;
459
+ };
460
+
461
+ /**
462
+ * Merge a component record with its build artifact into one agent-facing result.
463
+ *
464
+ * `artifact` is the `component/{id}.json` build output, and is `null` for a component that is
465
+ * declared but not built. That still has usable metadata, so it comes back without `usage` rather
466
+ * than as an error. The source is left out: it is most of the result and an agent writing markup
467
+ * needs the snippet, not the implementation, so it lives behind handoff_get_component_source. So are
468
+ * `sharedStyles`, `validations`, `docgen`, `page`, `options` and the Figma sync fields.
469
+ */
470
+ export const toComponentResult = (
471
+ record: ComponentListObject,
472
+ artifact: TransformComponentTokensResult | null,
473
+ basePath: string,
474
+ tokenSetIds: readonly string[] = []
475
+ ): ComponentResult => {
476
+ const previews = artifact?.previews ?? record.previews;
477
+ const properties = toProperties(artifact?.properties ?? record.properties);
478
+ // The docs record does not carry the guidelines; the build artifact does.
479
+ const shouldDo = nonEmpty(artifact?.should_do ?? record.should_do);
480
+ const shouldNotDo = nonEmpty(artifact?.should_not_do ?? record.should_not_do);
481
+
482
+ return {
483
+ ...toComponentSummary(record),
484
+ renderer: artifact?.renderer ?? record.renderer,
485
+ sourceFormat: artifact?.sourceFormat ?? record.sourceFormat,
486
+ properties,
487
+ previews: toPreviewIndex(record.id, previews, basePath),
488
+ variants: deriveVariants(previews, properties),
489
+ usage: Object.values(previews ?? {})[0]?.usage || undefined,
490
+ guidelines: shouldDo || shouldNotDo ? { shouldDo, shouldNotDo } : undefined,
491
+ tokens: componentTokenSet(record, tokenSetIds),
492
+ };
493
+ };
494
+
495
+ /** The component's source, or only its identity when it has not been built. */
496
+ export const toComponentSource = (record: ComponentListObject, artifact: TransformComponentTokensResult | null): ComponentSourceResult => ({
497
+ id: record.id,
498
+ title: record.title,
499
+ renderer: artifact?.renderer ?? record.renderer,
500
+ sourceFormat: artifact?.sourceFormat ?? record.sourceFormat,
501
+ ...(artifact ? pickCode(artifact) : {}),
502
+ });
503
+
504
+ /**
505
+ * The component's token set, when one can be named with certainty.
506
+ *
507
+ * A token set is keyed by the *Figma* component name, which need not match the component's own id:
508
+ * `button` is backed by `component/buttons` here, and a code-only component has no set at all. The
509
+ * declaration's `figmaComponentId` is the join, and without it there is nothing to match on, so
510
+ * nothing is claimed. Guessing `component/{id}` would quietly point `button` at a set that does not
511
+ * exist.
512
+ *
513
+ * Returned as `{ set }` rather than a bare string so the value reads as a set id to pass to
514
+ * `handoff_get_tokens`, not as the tokens themselves.
515
+ */
516
+ const componentTokenSet = (record: ComponentListObject, tokenSetIds: readonly string[]): { set: string } | undefined => {
517
+ const figmaId = record.figmaComponentId?.trim();
518
+ if (!figmaId) {
519
+ return undefined;
520
+ }
521
+ const set = `component/${figmaId}`;
522
+ return tokenSetIds.includes(set) ? { set } : undefined;
523
+ };