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
@@ -0,0 +1,34 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { pathToFileURL } from 'url';
4
+ import { resolveEnvReferences } from '@handoff/config/from-env';
5
+
6
+ /**
7
+ * Import a consumer's server-only module (a custom asset storage, AI or email
8
+ * provider) by the path the config gives, relative to the project.
9
+ *
10
+ * A registry bundle mirrors the tracing root and runs with its root as the working directory, so the
11
+ * traced copy is at `<cwd>/<HANDOFF_BUNDLE_PROJECT_DIR>/<module>`. A workspace server reads the
12
+ * original file under `HANDOFF_WORKING_PATH`. A bare import would resolve against the webpack chunk.
13
+ */
14
+ export const importServerModule = async (specifier: string): Promise<any> => {
15
+ const candidates = path.isAbsolute(specifier)
16
+ ? [specifier]
17
+ : [
18
+ path.resolve(process.cwd(), process.env.HANDOFF_BUNDLE_PROJECT_DIR ?? '', specifier),
19
+ path.resolve(process.env.HANDOFF_WORKING_PATH ?? process.cwd(), specifier),
20
+ ];
21
+ const target = candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[candidates.length - 1];
22
+ return import(/* webpackIgnore: true */ pathToFileURL(target).href);
23
+ };
24
+
25
+ /**
26
+ * Import a custom module and return its default export. A factory export is called first, with
27
+ * `fromEnv()` values in `context.options` resolved.
28
+ */
29
+ export const loadServerModule = async (specifier: string, context: { options?: Record<string, unknown>; [key: string]: unknown }): Promise<unknown> => {
30
+ const mod = await importServerModule(specifier);
31
+ const exported = mod?.default ?? mod;
32
+ if (typeof exported !== 'function') return exported;
33
+ return exported({ ...context, options: resolveEnvReferences(context.options ?? {}, process.env), env: process.env });
34
+ };
@@ -14,6 +14,19 @@ export const toAbsolutePath = (input: string): string => {
14
14
  return `/${trimSlashes(input)}`;
15
15
  };
16
16
 
17
+ /**
18
+ * A server-built path with the app's base path removed, so `next/link` does not re-apply it. Menu
19
+ * items and tool sources already carry the prefix; with no base path this returns the route as is.
20
+ */
21
+ export const stripBasePath = (input: string): string => {
22
+ const base = trimSlashes(process.env.HANDOFF_APP_BASE_PATH ?? '');
23
+ let rel = input.replace(/^\/+/, '');
24
+ if (base && (rel === base || rel.startsWith(`${base}/`))) {
25
+ rel = rel.slice(base.length).replace(/^\/+/, '');
26
+ }
27
+ return `/${rel}`;
28
+ };
29
+
17
30
  export const normalizePathForMatch = (input: string): string => {
18
31
  const [pathname] = input.split(/[?#]/);
19
32
  return trimSlashes(pathname);
@@ -38,3 +51,10 @@ export const filterOutNull = <T>(value: T): value is NonNullable<T> => value !==
38
51
  */
39
52
  export const hasRenderableNav = (section?: SectionLink | null): boolean =>
40
53
  !!section?.subSections?.some((sub) => (sub.menu && sub.menu.length > 0) || !!sub.dynamic);
54
+
55
+ /**
56
+ * The browser title: a page's own `metaTitle` as written, otherwise its title followed by the
57
+ * site title from `app.title`.
58
+ */
59
+ export const pageTitle = (metadata: { title?: string; metaTitle?: string }, siteTitle?: string): string =>
60
+ metadata.metaTitle || [metadata.title, siteTitle].filter(Boolean).join(' | ');
@@ -18,18 +18,7 @@ const resolveBasePath = (rawBasePath) => {
18
18
  // - otherwise → a normal server (workspace `next dev`/`start`).
19
19
  const handoffBuildTarget = process.env.HANDOFF_BUILD_TARGET;
20
20
  const handoffWorkingPath = path.resolve('%HANDOFF_WORKING_PATH%');
21
-
22
- // A configured custom asset-storage adapter module (relative to the consumer project). It is loaded
23
- // by a variable dynamic import at runtime, which nft cannot statically trace, so force it (and its
24
- // resolvable deps) into the registry bundle. Empty/unset for the built-in database/Vercel adapters.
25
- const handoffAssetStorageModule = '%HANDOFF_ASSET_STORAGE_MODULE%';
26
- const resolveAssetStorageInclude = () => {
27
- if (handoffBuildTarget !== 'registry' || !handoffAssetStorageModule || handoffAssetStorageModule.startsWith('%HANDOFF_')) {
28
- return undefined;
29
- }
30
- const abs = path.isAbsolute(handoffAssetStorageModule) ? handoffAssetStorageModule : path.resolve(handoffWorkingPath, handoffAssetStorageModule);
31
- return { '/api/**': [abs] };
32
- };
21
+ const handoffTracingRoot = path.resolve('%HANDOFF_TRACING_ROOT%');
33
22
 
34
23
  const resolveOutputMode = (target) => {
35
24
  if (target === 'static') {
@@ -49,11 +38,10 @@ const resolveOutputMode = (target) => {
49
38
  /** @type {import('next').NextConfig} */
50
39
  const nextConfig = {
51
40
  output: resolveOutputMode(handoffBuildTarget),
52
- // The tracer only copies files inside its root, so root it at the consumer project — the common
53
- // ancestor of the staged app (`node_modules/handoff-app/.handoff/<projectId>`) and the runtime deps
54
- // hoisted to the top-level `node_modules`. Rooting at the package dir would trace an empty
55
- // `node_modules` and ship a non-bootable bundle. Registry-only; workspace dev/static is unchanged.
56
- outputFileTracingRoot: handoffBuildTarget === 'registry' ? handoffWorkingPath : undefined,
41
+ // Rooted at the common ancestor of the staged app and the `node_modules` that the runtime
42
+ // dependencies resolve from. The builder bakes this value (`resolveRegistryTracingRoot`), so the
43
+ // assembly step uses the same root. This applies to the registry target only.
44
+ outputFileTracingRoot: handoffBuildTarget === 'registry' ? handoffTracingRoot : undefined,
57
45
  // nft speculatively traces the export-only paths the app references (`export-detail.json` and the
58
46
  // materializer's `.next/export/**` targets) as runtime deps. They never exist in a `standalone`
59
47
  // build, and `copyTracedFiles` copies traced files with no existence guard, so leaving them in
@@ -61,9 +49,6 @@ const nextConfig = {
61
49
  // Registry-only; static export, which legitimately produces these files, is untouched.
62
50
  outputFileTracingExcludes:
63
51
  handoffBuildTarget === 'registry' ? { '**': ['**/export-detail.json', '**/.next/export/**'] } : undefined,
64
- // Force a configured custom asset-storage module into the registry trace (dynamic import is opaque
65
- // to nft). Its SDK deps are additionally asserted via `getRequiredRegistryRuntimeModules`.
66
- outputFileTracingIncludes: resolveAssetStorageInclude(),
67
52
  reactStrictMode: true,
68
53
  pageExtensions: ['js', 'jsx', 'ts', 'tsx'],
69
54
  trailingSlash: true,
@@ -102,6 +87,9 @@ const nextConfig = {
102
87
  // static export). Read straight from the build env — empty for workspace dev/start.
103
88
  HANDOFF_BUILD_TARGET: handoffBuildTarget ?? '',
104
89
  HANDOFF_WORKING_PATH: '%HANDOFF_WORKING_PATH%',
90
+ // Where the project sits inside a registry bundle, which mirrors the tracing root. Custom server
91
+ // modules are imported from there (see `lib/server-module.ts`).
92
+ HANDOFF_BUNDLE_PROJECT_DIR: path.relative(handoffTracingRoot, handoffWorkingPath),
105
93
  HANDOFF_MODULE_PATH: '%HANDOFF_MODULE_PATH%',
106
94
  HANDOFF_EXPORT_PATH: '%HANDOFF_EXPORT_PATH%',
107
95
  HANDOFF_WEBSOCKET_PORT: '%HANDOFF_WEBSOCKET_PORT%',
@@ -114,15 +102,29 @@ const nextConfig = {
114
102
  // Whether this build serves `/api/mcp/`. Both the route and the header UI read this, so a
115
103
  // disabled build hides the affordance instead of showing it fail against a 404.
116
104
  HANDOFF_MCP_ENABLED: '%HANDOFF_MCP_ENABLED%',
105
+ // Whether this build serves the docs AI assistant. Config-only and baked, like the MCP flag, so
106
+ // a build without it leaves out both `/api/ai/*` and the header control.
107
+ HANDOFF_AI_ENABLED: '%HANDOFF_AI_ENABLED%',
108
+ // The connection list `runtime.ai.connections` declared (env-var *names* and non-secret options
109
+ // only). Deliberately not named `HANDOFF_AI_CONNECTIONS`: Next inlines every name in this block
110
+ // at build time, which would shadow the deployment-supplied list of that name that the server
111
+ // reads at request time and merges over this one.
112
+ HANDOFF_AI_BAKED_CONNECTIONS: '%HANDOFF_AI_BAKED_CONNECTIONS%',
113
+ HANDOFF_AI_DEFAULT_MODEL: '%HANDOFF_AI_DEFAULT_MODEL%',
117
114
  HANDOFF_REGISTRY_DRIVER: '%HANDOFF_REGISTRY_DRIVER%',
118
115
  HANDOFF_REGISTRY_DATABASE_URL_ENV: '%HANDOFF_REGISTRY_DATABASE_URL_ENV%',
119
- // Asset storage selection (provider + non-secret options + env-var names). Secret values (Blob
120
- // token, custom credentials) are read from their named env var at request time, never baked.
121
- HANDOFF_ASSET_STORAGE_ADAPTER: '%HANDOFF_ASSET_STORAGE_ADAPTER%',
116
+ // Asset storage provider, module, and options JSON. Option references keep only their variable
117
+ // name and are resolved at request time, so secret values are never baked.
118
+ HANDOFF_ASSET_STORAGE_PROVIDER: '%HANDOFF_ASSET_STORAGE_PROVIDER%',
122
119
  HANDOFF_ASSET_STORAGE_MODULE: '%HANDOFF_ASSET_STORAGE_MODULE%',
123
- HANDOFF_ASSET_STORAGE_TOKEN_ENV: '%HANDOFF_ASSET_STORAGE_TOKEN_ENV%',
124
120
  HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES: '%HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES%',
125
121
  HANDOFF_ASSET_STORAGE_OPTIONS: '%HANDOFF_ASSET_STORAGE_OPTIONS%',
122
+ // Registry email provider, sender, module, and options JSON. Option references keep only their
123
+ // env-var names; the values are read at request time.
124
+ HANDOFF_EMAIL_PROVIDER: '%HANDOFF_EMAIL_PROVIDER%',
125
+ HANDOFF_EMAIL_FROM: '%HANDOFF_EMAIL_FROM%',
126
+ HANDOFF_EMAIL_MODULE: '%HANDOFF_EMAIL_MODULE%',
127
+ HANDOFF_EMAIL_OPTIONS: '%HANDOFF_EMAIL_OPTIONS%',
126
128
  },
127
129
  images: {
128
130
  unoptimized: true,
@@ -20,6 +20,7 @@ import {
20
20
  } from '../../components/util';
21
21
  import { resolveDocsBackend } from '../../lib/docs-api/backend';
22
22
  import { documentationMetadata, REGISTRY_PAGE_REVALIDATE_SECONDS } from '../../lib/docs-api/page-rendering';
23
+ import { pageTitle } from '../../lib/utils';
23
24
  import { Home, getStaticProps as getHomeStaticProps } from '../index';
24
25
 
25
26
  type CatchAllDocumentationProps = DocumentationProps & { homeAlias?: boolean };
@@ -114,7 +115,7 @@ export default function DocCatchAllPage({
114
115
  return (
115
116
  <div className="flex min-h-screen flex-col items-center justify-center bg-white dark:bg-gray-900">
116
117
  <Head>
117
- <title>404 - Page Not Found</title>
118
+ <title>{pageTitle({ title: 'Page Not Found' }, config?.app?.title)}</title>
118
119
  <meta name="description" content="Page Not Found" />
119
120
  </Head>
120
121
  <NotFound />
@@ -1,5 +1,6 @@
1
1
  import type { AppProps } from 'next/app';
2
2
 
3
+ import { AiAssistantProvider } from '../components/AiAssistant/AiAssistantProvider';
3
4
  import { RegistrySessionProvider } from '../components/Auth/RegistrySessionProvider';
4
5
  import { RegistryInstallGate } from '../components/Auth/RegistryInstallGate';
5
6
  import { NavProvider } from '../components/context/NavProvider';
@@ -13,8 +14,11 @@ function MyApp({ Component, pageProps }: AppProps) {
13
14
  <RegistrySessionProvider>
14
15
  <RegistryInstallGate>
15
16
  <NavProvider initialNav={pageProps.navData} currentSectionId={pageProps.currentSectionId}>
16
- {/* @ts-ignore */}
17
- <Component {...pageProps} />
17
+ {/* Above the page tree: every page builds its own Layout, which a soft navigation replaces. */}
18
+ <AiAssistantProvider>
19
+ {/* @ts-ignore */}
20
+ <Component {...pageProps} />
21
+ </AiAssistantProvider>
18
22
  </NavProvider>
19
23
  </RegistryInstallGate>
20
24
  </RegistrySessionProvider>
@@ -1,9 +1,13 @@
1
1
  import Document, { Head, Html, Main, NextScript } from 'next/document';
2
2
  import Script from 'next/script';
3
+ import { dockWidthScript } from '../components/AiAssistant/dockState';
3
4
  import { getClientRuntimeConfig } from '../components/util';
4
5
 
5
6
  const config = getClientRuntimeConfig();
6
7
 
8
+ // A build with no assistant must not reserve a column for state an earlier build left behind.
9
+ const hasAssistant = process.env.HANDOFF_AI_ENABLED === 'true' && process.env.HANDOFF_BUILD_TARGET !== 'static';
10
+
7
11
  class MyDocument extends Document {
8
12
  render() {
9
13
  return (
@@ -11,6 +15,8 @@ class MyDocument extends Document {
11
15
  <Head>
12
16
  <link rel="shortcut icon" href={`${process.env.HANDOFF_APP_BASE_PATH ?? ''}/favicon.ico`} />
13
17
  <link rel="icon" sizes="16x16 32x32 64x64" href={`${process.env.HANDOFF_APP_BASE_PATH ?? ''}/favicon.ico`} />
18
+ {/* Blocking on purpose: it reserves the assistant's column before the first paint. */}
19
+ {hasAssistant && <script dangerouslySetInnerHTML={{ __html: dockWidthScript }} />}
14
20
  {config?.app?.google_tag_manager && (
15
21
  <Script id="google-tag-manager" strategy="afterInteractive">
16
22
  {`
@@ -0,0 +1,183 @@
1
+ import type { ClientConfig } from '@handoff/types/config';
2
+ import type { GetStaticProps } from 'next';
3
+ import { getClientRuntimeConfig } from '../../components/util';
4
+ import { Check, KeyRound, Trash2 } from 'lucide-react';
5
+ import { useSession } from 'next-auth/react';
6
+ import { FormEvent, useCallback, useEffect, useState } from 'react';
7
+ import { AccountLayout } from '../../components/Auth/AccountLayout';
8
+ import { authApiUrl, readApiError } from '../../components/Auth/api';
9
+ import { Alert, AlertDescription } from '../../components/ui/alert';
10
+ import { Badge } from '../../components/ui/badge';
11
+ import { Button } from '../../components/ui/button';
12
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
13
+ import { ConfirmDialog } from '../../components/ui/confirm-dialog';
14
+ import { Input } from '../../components/ui/input';
15
+
16
+ /**
17
+ * `/account/ai`: the one reader-facing setting this feature adds.
18
+ *
19
+ * One list, no add form. Each row is a connection the config declares with `credential: 'user'`,
20
+ * its endpoint and models shown as read-only text. There is no endpoint or model to enter.
21
+ *
22
+ * A key is write-only: once saved, the row shows only that it is configured.
23
+ */
24
+
25
+ interface AiKeyConnection {
26
+ id: string;
27
+ label: string;
28
+ baseUrl?: string;
29
+ models?: string[];
30
+ configured: boolean;
31
+ }
32
+
33
+ export const getStaticProps: GetStaticProps = async () => ({ props: { config: getClientRuntimeConfig() } });
34
+
35
+ export default function AiKeysPage({ config }: { config: ClientConfig }) {
36
+ if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
37
+ return <AccountLayout config={config} title="AI providers">{null}</AccountLayout>;
38
+ }
39
+
40
+ return <RegistryAiKeysPage config={config} />;
41
+ }
42
+
43
+ function RegistryAiKeysPage({ config }: { config: ClientConfig }) {
44
+ const { data: session } = useSession();
45
+ const [connections, setConnections] = useState<AiKeyConnection[]>([]);
46
+ const [pending, setPending] = useState<string | null>(null);
47
+ const [error, setError] = useState<string | null>(null);
48
+ const [saved, setSaved] = useState<string | null>(null);
49
+ const [connectionToRemove, setConnectionToRemove] = useState<AiKeyConnection | null>(null);
50
+
51
+ const load = useCallback(async () => {
52
+ const response = await fetch(authApiUrl('/api/account/ai/keys'), { credentials: 'include', cache: 'no-store' });
53
+ if (!response.ok) throw new Error(await readApiError(response, 'Could not load AI providers.'));
54
+ const body = (await response.json()) as { connections: AiKeyConnection[] };
55
+ setConnections(body.connections);
56
+ }, []);
57
+
58
+ useEffect(() => {
59
+ if (session?.user) void load().catch((reason: Error) => setError(reason.message));
60
+ }, [load, session]);
61
+
62
+ const save = async (connection: AiKeyConnection, event: FormEvent<HTMLFormElement>) => {
63
+ event.preventDefault();
64
+ const form = event.currentTarget;
65
+ const key = String(new FormData(form).get('key') ?? '').trim();
66
+ setPending(connection.id);
67
+ setError(null);
68
+ setSaved(null);
69
+ try {
70
+ const response = await fetch(authApiUrl(`/api/account/ai/keys/${encodeURIComponent(connection.id)}`), {
71
+ method: 'PUT',
72
+ credentials: 'include',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ key }),
75
+ });
76
+ if (!response.ok) {
77
+ setError(await readApiError(response, 'Could not save the API key.'));
78
+ return;
79
+ }
80
+ form.reset();
81
+ setSaved(connection.id);
82
+ await load();
83
+ } catch {
84
+ setError('Could not connect to the registry.');
85
+ } finally {
86
+ setPending(null);
87
+ }
88
+ };
89
+
90
+ const remove = async (connection: AiKeyConnection) => {
91
+ setPending(connection.id);
92
+ setError(null);
93
+ setSaved(null);
94
+ try {
95
+ const response = await fetch(authApiUrl(`/api/account/ai/keys/${encodeURIComponent(connection.id)}`), {
96
+ method: 'DELETE',
97
+ credentials: 'include',
98
+ });
99
+ if (!response.ok) {
100
+ setError(await readApiError(response, 'Could not remove the API key.'));
101
+ return;
102
+ }
103
+ await load();
104
+ } finally {
105
+ setPending(null);
106
+ }
107
+ };
108
+
109
+ return (
110
+ <AccountLayout config={config} title="AI providers" description="Add your own keys for the AI providers this site uses.">
111
+ <div className="space-y-6">
112
+ {error && (
113
+ <Alert variant="destructive">
114
+ <AlertDescription>{error}</AlertDescription>
115
+ </Alert>
116
+ )}
117
+
118
+ {connections.length === 0 ? (
119
+ <p className="text-sm text-muted-foreground">This site does not ask readers for their own AI provider keys.</p>
120
+ ) : (
121
+ connections.map((connection) => (
122
+ <Card key={connection.id}>
123
+ <CardHeader>
124
+ <div className="flex flex-wrap items-center gap-2">
125
+ <CardTitle className="text-base">{connection.label}</CardTitle>
126
+ {connection.configured && (
127
+ <Badge variant="secondary" className="gap-1">
128
+ <Check className="h-3 w-3" />
129
+ Configured
130
+ </Badge>
131
+ )}
132
+ </div>
133
+ <CardDescription>
134
+ {connection.baseUrl}
135
+ {connection.models?.length ? ` · ${connection.models.join(', ')}` : ''}
136
+ </CardDescription>
137
+ </CardHeader>
138
+ <CardContent>
139
+ {connection.configured ? (
140
+ <Button variant="outline" onClick={() => setConnectionToRemove(connection)} disabled={pending === connection.id}>
141
+ <Trash2 className="mr-2 h-4 w-4" />
142
+ Remove
143
+ </Button>
144
+ ) : (
145
+ <form onSubmit={(event) => void save(connection, event)} className="flex flex-wrap items-center gap-2">
146
+ <Input
147
+ name="key"
148
+ type="password"
149
+ autoComplete="off"
150
+ placeholder={`${connection.label} API key`}
151
+ className="max-w-sm"
152
+ required
153
+ />
154
+ <Button type="submit" disabled={pending === connection.id}>
155
+ <KeyRound className="mr-2 h-4 w-4" />
156
+ Save
157
+ </Button>
158
+ {saved === connection.id && <span className="text-sm text-muted-foreground">Saved.</span>}
159
+ </form>
160
+ )}
161
+ </CardContent>
162
+ </Card>
163
+ ))
164
+ )}
165
+ </div>
166
+
167
+ {connectionToRemove ? (
168
+ <ConfirmDialog
169
+ open
170
+ onOpenChange={(open) => {
171
+ if (!open) setConnectionToRemove(null);
172
+ }}
173
+ title={`Remove your ${connectionToRemove.label} API key?`}
174
+ description="You can add a new key at any time."
175
+ confirmLabel="Remove"
176
+ pendingLabel="Removing…"
177
+ variant="destructive"
178
+ onConfirm={() => remove(connectionToRemove)}
179
+ />
180
+ ) : null}
181
+ </AccountLayout>
182
+ );
183
+ }
@@ -1,9 +1,11 @@
1
+ import type { ClientConfig } from '@handoff/types/config';
2
+ import type { GetStaticProps } from 'next';
3
+ import { getClientRuntimeConfig } from '../../components/util';
1
4
  import { AccountLayout } from '../../components/Auth/AccountLayout';
2
5
  import { authApiUrl, readApiError } from '../../components/Auth/api';
3
6
  import { Alert, AlertDescription } from '../../components/ui/alert';
4
7
  import { Badge } from '../../components/ui/badge';
5
8
  import { Button } from '../../components/ui/button';
6
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
7
9
  import { Input } from '../../components/ui/input';
8
10
  import { Label } from '../../components/ui/label';
9
11
  import { useSession } from 'next-auth/react';
@@ -19,15 +21,17 @@ interface AccountUser {
19
21
  status?: 'invited' | 'active' | 'deactivated';
20
22
  }
21
23
 
22
- export default function AccountPage() {
24
+ export const getStaticProps: GetStaticProps = async () => ({ props: { config: getClientRuntimeConfig() } });
25
+
26
+ export default function AccountPage({ config }: { config: ClientConfig }) {
23
27
  if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
24
- return <AccountLayout title="Profile">{null}</AccountLayout>;
28
+ return <AccountLayout config={config} title="Profile">{null}</AccountLayout>;
25
29
  }
26
30
 
27
- return <RegistryAccountPage />;
31
+ return <RegistryAccountPage config={config} />;
28
32
  }
29
33
 
30
- function RegistryAccountPage() {
34
+ function RegistryAccountPage({ config }: { config: ClientConfig }) {
31
35
  const { data: session } = useSession();
32
36
  const [user, setUser] = useState<AccountUser | null>(null);
33
37
  const [name, setName] = useState('');
@@ -83,71 +87,67 @@ function RegistryAccountPage() {
83
87
  .toUpperCase();
84
88
 
85
89
  return (
86
- <AccountLayout title="Profile">
87
- <Card>
88
- <CardHeader>
89
- <CardTitle>Profile</CardTitle>
90
- <CardDescription>Manage how your account appears in this registry.</CardDescription>
91
- </CardHeader>
92
- <CardContent className="space-y-5">
93
- {message ? (
94
- <Alert variant={message.error ? 'destructive' : 'green'}>
95
- <AlertDescription>{message.text}</AlertDescription>
96
- </Alert>
97
- ) : null}
98
- {loading ? (
99
- <p className="text-sm text-muted-foreground">Loading profile…</p>
100
- ) : user ? (
101
- <>
102
- <div className="flex items-center gap-4">
103
- {avatarUrl ? (
104
- // eslint-disable-next-line @next/next/no-img-element
105
- <img src={avatarUrl} alt="" className="h-16 w-16 rounded-full border object-cover" />
106
- ) : (
107
- <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted text-lg font-semibold">{initials}</div>
108
- )}
109
- <div>
110
- <p className="font-medium">{name || user.email}</p>
111
- <Badge variant={user.status === 'deactivated' ? 'destructive' : 'secondary'} className="mt-1">
112
- {user.role}
113
- </Badge>
114
- </div>
115
- </div>
116
- <div className="space-y-2">
117
- <Label htmlFor="account-email">Email</Label>
118
- <Input id="account-email" value={user.email} disabled />
119
- <p className="text-xs text-muted-foreground">Email addresses cannot be changed.</p>
120
- </div>
121
- <div className="space-y-2">
122
- <Label htmlFor="account-name">Display name</Label>
123
- <Input id="account-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={100} />
90
+ <AccountLayout config={config} title="Profile" description="Manage how your account appears in this registry.">
91
+ <div className="space-y-5">
92
+ {message ? (
93
+ <Alert variant={message.error ? 'destructive' : 'green'}>
94
+ <AlertDescription>{message.text}</AlertDescription>
95
+ </Alert>
96
+ ) : null}
97
+ {loading ? (
98
+ <p className="text-sm text-muted-foreground">Loading profile…</p>
99
+ ) : user ? (
100
+ <>
101
+ <div className="flex items-center gap-4">
102
+ {avatarUrl ? (
103
+ // eslint-disable-next-line @next/next/no-img-element
104
+ <img src={avatarUrl} alt="" className="h-16 w-16 rounded-full border object-cover" />
105
+ ) : (
106
+ <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted text-lg font-semibold">{initials}</div>
107
+ )}
108
+ <div>
109
+ <p className="font-medium">{name || user.email}</p>
110
+ <Badge variant={user.status === 'deactivated' ? 'destructive' : 'secondary'} className="mt-1">
111
+ {user.role}
112
+ </Badge>
124
113
  </div>
125
- <div className="space-y-2">
126
- <div className="flex items-center justify-between gap-2">
127
- <Label htmlFor="account-avatar">Avatar URL</Label>
128
- {gravatarUrl ? (
129
- <Button type="button" variant="ghost" size="sm" onClick={() => setAvatarUrl(gravatarUrl)}>
130
- Use Gravatar
131
- </Button>
132
- ) : null}
133
- </div>
134
- <Input
135
- id="account-avatar"
136
- type="url"
137
- placeholder="https://example.com/avatar.png"
138
- value={avatarUrl}
139
- onChange={(event) => setAvatarUrl(event.target.value)}
140
- maxLength={500}
141
- />
142
- <p className="text-xs text-muted-foreground">Use an HTTPS image URL, click Use Gravatar, or leave this empty to show your initials.</p>
114
+ </div>
115
+ <div className="space-y-2">
116
+ <Label htmlFor="account-email">Email</Label>
117
+ <Input id="account-email" value={user.email} disabled />
118
+ <p className="text-xs text-muted-foreground">Email addresses cannot be changed.</p>
119
+ </div>
120
+ <div className="space-y-2">
121
+ <Label htmlFor="account-name">Display name</Label>
122
+ <Input id="account-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={100} />
123
+ </div>
124
+ <div className="space-y-2">
125
+ <div className="flex items-center justify-between gap-2">
126
+ <Label htmlFor="account-avatar">Avatar URL</Label>
127
+ {gravatarUrl ? (
128
+ <Button type="button" variant="ghost" size="sm" onClick={() => setAvatarUrl(gravatarUrl)}>
129
+ Use Gravatar
130
+ </Button>
131
+ ) : null}
143
132
  </div>
144
- <Button onClick={() => void save()} disabled={saving}>
145
- {saving ? 'Saving…' : 'Save changes'}
146
- </Button>
147
- </>
148
- ) : null}
149
- </CardContent>
150
- </Card>
133
+ <Input
134
+ id="account-avatar"
135
+ type="url"
136
+ placeholder="https://example.com/avatar.png"
137
+ value={avatarUrl}
138
+ onChange={(event) => setAvatarUrl(event.target.value)}
139
+ maxLength={500}
140
+ />
141
+ <p className="text-xs text-muted-foreground">
142
+ Use an HTTPS image URL, click Use Gravatar, or leave this empty to show your initials.
143
+ </p>
144
+ </div>
145
+ <Button onClick={() => void save()} disabled={saving}>
146
+ {saving ? 'Saving…' : 'Save changes'}
147
+ </Button>
148
+ </>
149
+ ) : null}
150
+ </div>
151
151
  </AccountLayout>
152
152
  );
153
153
  }