@kitn.ai/ui 0.26.0 → 0.27.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 (474) hide show
  1. package/README.md +16 -5
  2. package/bin/mcp.js +16 -3
  3. package/bin/route.js +23 -0
  4. package/bin/route.test.js +58 -0
  5. package/dist/{elements/chunks/Icon--Y8vsFal.js → Icon-BnFKqqtY.js} +1 -1
  6. package/dist/action-icons-CBskfBMz.js +1 -0
  7. package/dist/arrow-down-Br0qtpsz.js +1 -0
  8. package/dist/arrow-left-Dj7xgE12.js +1 -0
  9. package/dist/artifact-CvXEm1lS.js +1 -0
  10. package/dist/assets/dev-5XzXYR21.js +91 -0
  11. package/dist/attachments-C43ZegEq.js +1 -0
  12. package/dist/{elements/chunks/audio-visualizer-D7lB6mvR.js → audio-visualizer-B-79y1Tj.js} +1 -1
  13. package/dist/badge-BpUbq8c0.js +1 -0
  14. package/dist/bash-InADTalH.js +1 -6
  15. package/dist/bell-sn17Ip9v.js +1 -0
  16. package/dist/{elements/chunks/button-flUbeufF.js → button-BH6rN0U-.js} +1 -1
  17. package/dist/card-renderer-Cb1gQmth.js +1 -0
  18. package/dist/card-routing-DifQgS7n.js +1 -0
  19. package/dist/check-DPeeMUYx.js +1 -0
  20. package/dist/checkbox-Dc9Yov_B.js +1 -0
  21. package/dist/checkbox-group-B1kDLLYk.js +1 -0
  22. package/dist/chevron-down-CHSWk7ZT.js +1 -0
  23. package/dist/chevron-right-B7l_fMgx.js +1 -0
  24. package/dist/choice-card-BT3c1S8g.js +1 -0
  25. package/dist/circle-Bb0yOpqM.js +1 -0
  26. package/dist/circle-check-BGZpCiz7.js +1 -0
  27. package/dist/circle-x-wobWkoSx.js +1 -0
  28. package/dist/{elements/chunks/cn-CU7UAWzk.js → cn-DN5AWfiS.js} +1 -1
  29. package/dist/{elements/chunks/code-block-BdLiTitd.js → code-block-CUQ5mtv0.js} +1 -1
  30. package/dist/{elements/chunks/collapsible-BzbXpPzr.js → collapsible-CPJbf6FM.js} +1 -1
  31. package/dist/components/attachment-types.d.ts +7 -0
  32. package/dist/components/audio-visualizer/fit-scale.d.ts +1 -1
  33. package/dist/components/card-renderer.d.ts +13 -0
  34. package/dist/components/card.d.ts +4 -0
  35. package/dist/components/chat-thread.d.ts +46 -1
  36. package/dist/components/conversation-item.d.ts +19 -7
  37. package/dist/components/conversation-list.d.ts +17 -5
  38. package/dist/components/form-widgets.d.ts +67 -2
  39. package/dist/components/form.d.ts +48 -1
  40. package/dist/components/message.d.ts +25 -3
  41. package/dist/components/reasoning.d.ts +6 -0
  42. package/dist/components/scroll-button.d.ts +11 -0
  43. package/dist/components/thread.d.ts +5 -0
  44. package/dist/components/tool-types.d.ts +9 -0
  45. package/dist/composer-CjngLzal.js +106 -0
  46. package/dist/confirm-card-DCx_A4bU.js +1 -0
  47. package/dist/construct-cli.es.js +1169 -0
  48. package/dist/{elements/chunks/context-VLIL3sJ-.js → context-BY8V-Nlp.js} +1 -1
  49. package/dist/controllable-D43BKmtX.js +1 -0
  50. package/dist/conversation-list-D4lLU_yS.js +1 -0
  51. package/dist/copy-B6DO4PqB.js +1 -0
  52. package/dist/core-AYMC6_lb.js +12 -5874
  53. package/dist/{create-tween-CQED4T0g.js → create-tween-BPzaefTp.js} +1 -1
  54. package/dist/{create-tween-BFT6c-Y8.js → create-tween-CnMHviKB.js} +1 -1
  55. package/dist/{create-tween-CJNlzoaO.js → create-tween-D1aiGZZh.js} +1 -1
  56. package/dist/{elements/chunks/create-tween-c4Z8TDYz.js → create-tween-DUSQUKpr.js} +2 -2
  57. package/dist/css-M7EaDHN_.js +1 -6
  58. package/dist/custom-elements.json +1400 -218
  59. package/dist/default-input-CucQEOqH.js +1 -0
  60. package/dist/define-CGGX-7Vg.js +1 -0
  61. package/dist/define.d.ts +4 -0
  62. package/dist/define.js +510 -0
  63. package/dist/define.server.d.ts +5 -0
  64. package/dist/define.server.js +506 -0
  65. package/dist/{elements/chunks/disclosure-CcNbyLy0.js → disclosure-CbLtajUQ.js} +1 -1
  66. package/dist/{elements/chunks/dropdown-BgWTthUx.js → dropdown-BwhViJFd.js} +1 -1
  67. package/dist/elements/agent-card.js +1 -1
  68. package/dist/elements/artifact.js +1 -1
  69. package/dist/elements/attachments.js +1 -1
  70. package/dist/elements/audio-visualizer.js +1 -1
  71. package/dist/elements/autoloader.js +1 -1
  72. package/dist/elements/avatar.js +1 -1
  73. package/dist/elements/badge.js +1 -1
  74. package/dist/elements/button.js +1 -1
  75. package/dist/elements/card.js +1 -1
  76. package/dist/elements/cards.js +1 -1
  77. package/dist/elements/chain-of-thought.js +1 -1
  78. package/dist/elements/chat-scope-picker.js +1 -1
  79. package/dist/elements/chat-workspace.js +1 -1
  80. package/dist/elements/chat.js +1 -1
  81. package/dist/elements/checkbox-group.d.ts +1 -0
  82. package/dist/elements/checkbox-group.js +1 -0
  83. package/dist/elements/checkbox.d.ts +1 -0
  84. package/dist/elements/checkbox.js +1 -0
  85. package/dist/elements/checkpoint.js +1 -1
  86. package/dist/elements/choice.js +1 -1
  87. package/dist/elements/coachmark.js +1 -1
  88. package/dist/elements/code-block.js +1 -1
  89. package/dist/elements/command.js +1 -1
  90. package/dist/elements/compare.js +1 -1
  91. package/dist/elements/composer.js +1 -1
  92. package/dist/elements/confirm-card.js +1 -1
  93. package/dist/elements/context-meter.js +1 -1
  94. package/dist/elements/conversation-item.js +1 -1
  95. package/dist/elements/conversation-list.js +1 -1
  96. package/dist/elements/define-entry.d.ts +12 -0
  97. package/dist/elements/dialog.js +1 -1
  98. package/dist/elements/dock.js +101 -7
  99. package/dist/elements/dropdown.js +1 -1
  100. package/dist/elements/editable-label.js +1 -1
  101. package/dist/elements/element-data-types.d.ts +37 -0
  102. package/dist/elements/embed.js +1 -1
  103. package/dist/elements/empty.js +1 -1
  104. package/dist/elements/feedback-bar.js +1 -1
  105. package/dist/elements/file-tree.js +1 -1
  106. package/dist/elements/file-upload.js +1 -1
  107. package/dist/elements/form.js +1 -1
  108. package/dist/elements/hover-card.js +1 -1
  109. package/dist/elements/icon.js +1 -1
  110. package/dist/elements/image.js +1 -1
  111. package/dist/elements/input.js +1 -1
  112. package/dist/elements/kbd.js +1 -1
  113. package/dist/elements/link-preview.js +1 -1
  114. package/dist/elements/loader.js +1 -1
  115. package/dist/elements/markdown.js +1 -1
  116. package/dist/elements/menu.js +1 -1
  117. package/dist/elements/message-skills.js +1 -1
  118. package/dist/elements/message.js +1 -1
  119. package/dist/elements/model-switcher.js +1 -1
  120. package/dist/elements/nav.js +1 -1
  121. package/dist/elements/notice.js +1 -1
  122. package/dist/elements/pane-grid.d.ts +1 -0
  123. package/dist/elements/pane-grid.js +1 -0
  124. package/dist/elements/pane-group.js +1 -1
  125. package/dist/elements/pane.js +1 -1
  126. package/dist/elements/popover.js +1 -1
  127. package/dist/elements/progress-bar.js +1 -1
  128. package/dist/elements/prompt-dock.js +1 -1
  129. package/dist/elements/prompt-input.js +1 -1
  130. package/dist/elements/prompt-suggestions.js +1 -1
  131. package/dist/elements/radio-group.d.ts +1 -0
  132. package/dist/elements/radio-group.js +1 -0
  133. package/dist/elements/reasoning.js +1 -1
  134. package/dist/elements/remote.js +1 -1
  135. package/dist/elements/resizable.js +1 -1
  136. package/dist/elements/response-stream.js +2 -2
  137. package/dist/elements/screen.js +1 -1
  138. package/dist/elements/scroll-area.js +1 -1
  139. package/dist/elements/scroll-button.js +1 -1
  140. package/dist/elements/search.js +1 -1
  141. package/dist/elements/segmented.js +1 -1
  142. package/dist/elements/select.d.ts +1 -0
  143. package/dist/elements/select.js +1 -0
  144. package/dist/elements/separator.js +1 -1
  145. package/dist/elements/setting-item.js +1 -1
  146. package/dist/elements/settings-group.js +1 -1
  147. package/dist/elements/skeleton.js +1 -1
  148. package/dist/elements/slider.d.ts +1 -0
  149. package/dist/elements/slider.js +1 -0
  150. package/dist/elements/slots.d.ts +1 -1
  151. package/dist/elements/source.js +1 -1
  152. package/dist/elements/status.js +1 -1
  153. package/dist/elements/switch.js +1 -1
  154. package/dist/elements/tabs.js +1 -1
  155. package/dist/elements/tasks.js +1 -1
  156. package/dist/elements/text-shimmer.js +1 -1
  157. package/dist/elements/thinking-bar.js +1 -1
  158. package/dist/elements/thread.js +1 -1
  159. package/dist/elements/toast.js +1 -1
  160. package/dist/elements/tool.js +1 -1
  161. package/dist/elements/tooltip.js +1 -1
  162. package/dist/elements/voice-input.js +1 -1
  163. package/dist/elements/voice-output.js +1 -1
  164. package/dist/elements.d.ts +362 -24
  165. package/dist/{elements/chunks/ellipsis-7gU33mL2.js → ellipsis-DFr4Vqr3.js} +1 -1
  166. package/dist/embed-5s_vU4J0.js +1 -0
  167. package/dist/engine-javascript-vq0WuIJl.js +14 -2516
  168. package/dist/{elements/chunks/external-link-CSOEsNlx.js → external-link-u9d85ORQ.js} +1 -1
  169. package/dist/file-text-CX7_x42o.js +1 -0
  170. package/dist/{elements/chunks/file-tree-zvB2i7P9.js → file-tree-D3kBjXI5.js} +1 -1
  171. package/dist/folder-V-YdIL8f.js +1 -0
  172. package/dist/form-BRNUMCN2.js +1 -0
  173. package/dist/github-dark-dimmed-DUshB20C.js +1 -4
  174. package/dist/github-light-JYsPkUQd.js +1 -4
  175. package/dist/hover-card-DaeiYrTA.js +1 -0
  176. package/dist/html-CPZ3oZQ7.js +1 -10
  177. package/dist/icon-BpixdZNq.js +1 -0
  178. package/dist/{elements/chunks/index-BCRhi84a.js → index-xu_pdcPi.js} +1 -1
  179. package/dist/index.d.ts +1 -1
  180. package/dist/index.js +7330 -5852
  181. package/dist/index.server.js +5823 -4759
  182. package/dist/info-8mFsIjS3.js +1 -0
  183. package/dist/input-d1Yuu-hz.js +1 -0
  184. package/dist/javascript-C25yR2R2.js +1 -6
  185. package/dist/json-DxJze_jm.js +1 -6
  186. package/dist/kai.es.js +1 -1
  187. package/dist/kbd-961zMPFB.js +1 -0
  188. package/dist/link-DlZZBGaR.js +1 -0
  189. package/dist/{elements/chunks/link-preview-OQ9IEq0f.js → link-preview-DIIbV2Sd.js} +1 -1
  190. package/dist/loader-YQszHFaW.js +1 -0
  191. package/dist/{elements/chunks/markdown-DgBFaGAz.js → markdown-CVk1MPsj.js} +1 -1
  192. package/dist/mcp.es.js +1285 -79
  193. package/dist/message-CwKVEdZg.js +1 -0
  194. package/dist/message-DS8lzHc1.js +1 -0
  195. package/dist/message-circle-Xf8Q-vZp.js +1 -0
  196. package/dist/message-square-Cxnwgkns.js +1 -0
  197. package/dist/{elements/chunks/minimize-2-DPIUKTrT.js → minimize-2-UEZmP2QT.js} +1 -1
  198. package/dist/{elements/chunks/model-switcher-DsT8tFB0.js → model-switcher-Kk1jEQ5t.js} +1 -1
  199. package/dist/{elements/chunks/overlay-DuiwdXjm.js → overlay-C1od6LOz.js} +1 -1
  200. package/dist/panel-right-ryET0yyn.js +1 -0
  201. package/dist/paperclip-BDNoZFJP.js +1 -0
  202. package/dist/play-OFIvYXUQ.js +1 -0
  203. package/dist/primitives/card-data-types.d.ts +49 -0
  204. package/dist/primitives/card-routing.d.ts +1 -33
  205. package/dist/primitives/card-validate.d.ts +2 -2
  206. package/dist/primitives/chat-config.d.ts +29 -2
  207. package/dist/primitives/field-mask.d.ts +96 -0
  208. package/dist/primitives/field-semantics.d.ts +36 -0
  209. package/dist/primitives/input-mask.d.ts +46 -0
  210. package/dist/primitives/url-scheme-policy.d.ts +34 -0
  211. package/dist/primitives/use-resize-observer.d.ts +2 -2
  212. package/dist/progress-bar-BvYX0aoy.js +1 -0
  213. package/dist/prompt-suggestion-BRFu2lcu.js +1 -0
  214. package/dist/radio-DcL23cNJ.js +1 -0
  215. package/dist/react/index.d.ts +193 -19
  216. package/dist/react.js +216 -180
  217. package/dist/reasoning-Cackq4Rh.js +1 -0
  218. package/dist/register-impl-C7rENvPK.js +424 -0
  219. package/dist/{elements/chunks/resizable-D181b4fM.js → resizable-Cp9HUYih.js} +1 -1
  220. package/dist/rotate-ccw-0DLRDHH5.js +1 -0
  221. package/dist/rotate-cw-DOB9IGcU.js +1 -0
  222. package/dist/schemas/form.schema.json +21 -1
  223. package/dist/schemas/tool-defs.d.ts +11 -1
  224. package/dist/schemas.js +81 -81
  225. package/dist/scroll-area-emVmWP4K.js +1 -0
  226. package/dist/scroll-button-CeP_gv1x.js +1 -0
  227. package/dist/select-CWmu00tS.js +1 -0
  228. package/dist/{elements/chunks/separator-CeJHQqLW.js → separator-CE6vly6z.js} +1 -1
  229. package/dist/{elements/chunks/settings-CqaHgJF9.js → settings-CE-78SvZ.js} +1 -1
  230. package/dist/{elements/chunks/settings-group-CjqjLCOn.js → settings-group-ZFWNxFQR.js} +1 -1
  231. package/dist/{elements/chunks/skeleton-D8akBnKS.js → skeleton-BebdP4mf.js} +1 -1
  232. package/dist/slider-BAKRYUlz.js +1 -0
  233. package/dist/{solid-BtdqNZI7.js → solid-BE5ui6sy.js} +8727 -7212
  234. package/dist/{solid-CnavwBye.js → solid-DFhsce3O.js} +7015 -5886
  235. package/dist/solid.d.ts +10 -0
  236. package/dist/solid.js +240 -234
  237. package/dist/solid.server.js +240 -234
  238. package/dist/source-B0FlguSx.js +1 -0
  239. package/dist/star-CBMZvLuN.js +1 -0
  240. package/dist/state/index.d.ts +1 -1
  241. package/dist/state/mock.d.ts +32 -2
  242. package/dist/state.js +133 -108
  243. package/dist/{elements/chunks/store-Cjq0qwYN.js → store-7eoadydQ.js} +1 -1
  244. package/dist/svelte--5p79yCD.js +1 -15
  245. package/dist/switch-CViARZCy.js +1 -0
  246. package/dist/tasks-card-MjCcOhR_.js +1 -0
  247. package/dist/text-shimmer-DKlwRF2G.js +1 -0
  248. package/dist/textarea-3ay2Me2-.js +1 -0
  249. package/dist/theme.tokens.css +82 -13
  250. package/dist/{elements/chunks/thumbs-up-DX2Ijk3H.js → thumbs-up-DQcu2HMO.js} +1 -1
  251. package/dist/toast-store-C6yKOhbk.js +1 -0
  252. package/dist/{elements/chunks/tool-DKQ2D1DP.js → tool-BCYNlfKy.js} +1 -1
  253. package/dist/{elements/chunks/tooltip-B0G99y8w.js → tooltip-05zI4hjs.js} +1 -1
  254. package/dist/trash-2-BR1RxGky.js +1 -0
  255. package/dist/{elements/chunks/triangle-alert-F9cyUEyy.js → triangle-alert-DHohDOCy.js} +1 -1
  256. package/dist/tsx-B8rCNbgL.js +1 -6
  257. package/dist/typescript-RycA9KXf.js +1 -6
  258. package/dist/ui/checkbox-group.d.ts +83 -0
  259. package/dist/ui/checkbox.d.ts +40 -0
  260. package/dist/ui/dock.d.ts +22 -0
  261. package/dist/ui/icon.d.ts +3 -0
  262. package/dist/ui/input.d.ts +48 -2
  263. package/dist/ui/radio.d.ts +85 -0
  264. package/dist/ui/select.d.ts +71 -0
  265. package/dist/ui/slider.d.ts +65 -0
  266. package/dist/ui/switch.d.ts +9 -2
  267. package/dist/upload-BbGW6A_u.js +1 -0
  268. package/dist/url-scheme-policy-DHpoTJwB.js +1 -0
  269. package/dist/use-card-resolution-CJW4grFa.js +1 -0
  270. package/dist/{variant-aurora-B8II3-I1.js → variant-aurora-BUOVgPOB.js} +2 -2
  271. package/dist/{variant-aurora-CMNWOSJR.js → variant-aurora-CUsUJvFI.js} +2 -2
  272. package/dist/{variant-aurora-CcuDMWwQ.js → variant-aurora-CW2i4bfr.js} +1 -1
  273. package/dist/{elements/chunks/variant-aurora-qt6Zct8M.js → variant-aurora-xiMPty8f.js} +1 -1
  274. package/dist/{variant-custom-L16JLLZy.js → variant-custom-BQtOnmeO.js} +1 -1
  275. package/dist/{variant-custom-C714mqKl.js → variant-custom-CAm9L-E5.js} +2 -2
  276. package/dist/{variant-custom-BdvYC7af.js → variant-custom-CAnzzppW.js} +2 -2
  277. package/dist/{elements/chunks/variant-custom-tJp22_BQ.js → variant-custom-D91HjAKz.js} +1 -1
  278. package/dist/{variant-wave-ecwykeyL.js → variant-wave-Ca1Ls4_y.js} +2 -2
  279. package/dist/{variant-wave-D7ruiA1W.js → variant-wave-CxX39DKd.js} +2 -2
  280. package/dist/{elements/chunks/variant-wave-CaQ5ti39.js → variant-wave-DYyzIIZv.js} +1 -1
  281. package/dist/{variant-wave-2S4La2Ka.js → variant-wave-uY5crU_K.js} +1 -1
  282. package/dist/vue-BmIZj4XD.js +1 -33
  283. package/dist/x-C5m1JFgi.js +1 -0
  284. package/frameworks/react/index.tsx +198 -20
  285. package/llms-full.txt +1178 -21
  286. package/llms.txt +6 -3
  287. package/package.json +17 -4
  288. package/src/agent-tooling/catalog/invariants.ts +1 -1
  289. package/src/agent-tooling/catalog/surfaces.ts +72 -0
  290. package/src/agent-tooling/construct/cli-entry.ts +8 -0
  291. package/src/agent-tooling/construct/cli.ts +131 -0
  292. package/src/agent-tooling/construct/codegen.ts +1353 -0
  293. package/src/agent-tooling/construct/construct.v1.schema.json +250 -0
  294. package/src/agent-tooling/construct/dev.ts +148 -0
  295. package/src/agent-tooling/construct/fixtures/demo-widget.construct.json +7 -0
  296. package/src/agent-tooling/construct/fixtures/ops-console.construct.json +44 -0
  297. package/src/agent-tooling/construct/fixtures/owner-widget.construct.json +23 -0
  298. package/src/agent-tooling/construct/schema.ts +371 -0
  299. package/src/agent-tooling/mcp/server.ts +2 -1
  300. package/src/agent-tooling/mcp/tools/construct.ts +130 -0
  301. package/src/agent-tooling/mcp/tools/debug.ts +126 -4
  302. package/src/agent-tooling/mcp/tools/reference.ts +104 -3
  303. package/src/agent-tooling/recipes/composed-thread.ts +714 -0
  304. package/src/agent-tooling/recipes/index.ts +22 -0
  305. package/src/agent-tooling/recipes/types.ts +32 -0
  306. package/src/agent-tooling/registry.ts +5 -0
  307. package/src/components/attachment-types.ts +7 -0
  308. package/src/components/audio-visualizer/fit-scale.ts +1 -1
  309. package/src/components/card-renderer.tsx +39 -8
  310. package/src/components/card.tsx +10 -2
  311. package/src/components/chat-scope-picker.tsx +2 -2
  312. package/src/components/chat-thread.tsx +72 -9
  313. package/src/components/choice-card.tsx +102 -37
  314. package/src/components/coachmark.tsx +1 -1
  315. package/src/components/code-block.tsx +2 -2
  316. package/src/components/composer.tsx +89 -15
  317. package/src/components/confirm-card.tsx +20 -4
  318. package/src/components/conversation-item.tsx +33 -9
  319. package/src/components/conversation-list.tsx +25 -10
  320. package/src/components/empty.tsx +14 -3
  321. package/src/components/file-tree.tsx +5 -1
  322. package/src/components/form-widgets.tsx +257 -195
  323. package/src/components/form.tsx +297 -9
  324. package/src/components/loader.tsx +12 -5
  325. package/src/components/message-skills.tsx +1 -1
  326. package/src/components/message.tsx +71 -11
  327. package/src/components/prompt-input.tsx +7 -1
  328. package/src/components/prompt-suggestion.tsx +4 -1
  329. package/src/components/reasoning.tsx +30 -3
  330. package/src/components/response-compare.tsx +2 -2
  331. package/src/components/scroll-button.tsx +56 -4
  332. package/src/components/source.tsx +6 -1
  333. package/src/components/tasks-card.tsx +58 -16
  334. package/src/components/thread.tsx +10 -1
  335. package/src/components/toast.tsx +1 -1
  336. package/src/components/tool-types.ts +9 -0
  337. package/src/elements/attachments.tsx +3 -1
  338. package/src/elements/chat-workspace.tsx +1 -1
  339. package/src/elements/chat.tsx +22 -4
  340. package/src/elements/checkbox-group.tsx +164 -0
  341. package/src/elements/checkbox.tsx +134 -0
  342. package/src/elements/compiled.css +1 -1
  343. package/src/elements/composer.tsx +12 -1
  344. package/src/elements/conversation-item.tsx +62 -22
  345. package/src/elements/conversation-list.tsx +2 -2
  346. package/src/elements/define-entry.ts +12 -0
  347. package/src/elements/dock.tsx +11 -0
  348. package/src/elements/element-data-types.ts +40 -0
  349. package/src/elements/element-manifest.json +24 -0
  350. package/src/elements/element-meta.json +736 -46
  351. package/src/elements/element-nonscalar.json +15 -0
  352. package/src/elements/element-types.d.ts +362 -24
  353. package/src/elements/feedback-bar.tsx +5 -0
  354. package/src/elements/icon-names.json +29 -0
  355. package/src/elements/input.tsx +312 -23
  356. package/src/elements/message.tsx +2 -0
  357. package/src/elements/pane-grid.tsx +111 -0
  358. package/src/elements/prompt-input.tsx +8 -2
  359. package/src/elements/radio-group.tsx +129 -0
  360. package/src/elements/register-impl.ts +6 -0
  361. package/src/elements/scroll-button.tsx +54 -6
  362. package/src/elements/select.tsx +165 -0
  363. package/src/elements/slider.tsx +171 -0
  364. package/src/elements/slots.ts +1 -1
  365. package/src/elements/styles.css +89 -3
  366. package/src/elements/thread.tsx +2 -0
  367. package/src/elements/toast.tsx +14 -1
  368. package/src/index.ts +2 -1
  369. package/src/primitives/card-data-types.ts +49 -0
  370. package/src/primitives/card-host.tsx +4 -2
  371. package/src/primitives/card-routing.ts +7 -56
  372. package/src/primitives/card-schemas/form.schema.json +21 -1
  373. package/src/primitives/card-validate.ts +10 -2
  374. package/src/primitives/chat-config.tsx +33 -6
  375. package/src/primitives/field-mask.ts +256 -0
  376. package/src/primitives/field-semantics.ts +115 -0
  377. package/src/primitives/input-mask.ts +853 -0
  378. package/src/primitives/toast-store.ts +66 -4
  379. package/src/primitives/url-scheme-policy.ts +70 -0
  380. package/src/primitives/use-resize-observer.ts +2 -2
  381. package/src/schemas/tool-defs.ts +12 -2
  382. package/src/solid.ts +10 -0
  383. package/src/state/index.ts +1 -1
  384. package/src/state/mock.ts +92 -6
  385. package/src/ui/agent-card.tsx +2 -2
  386. package/src/ui/avatar.tsx +1 -1
  387. package/src/ui/checkbox-group.tsx +153 -0
  388. package/src/ui/checkbox.tsx +63 -0
  389. package/src/ui/dock.tsx +145 -6
  390. package/src/ui/icon.tsx +53 -0
  391. package/src/ui/input.tsx +249 -9
  392. package/src/ui/kbd.tsx +1 -1
  393. package/src/ui/nav.tsx +1 -1
  394. package/src/ui/pane-group.tsx +2 -2
  395. package/src/ui/radio.tsx +150 -0
  396. package/src/ui/select.tsx +168 -0
  397. package/src/ui/slider.tsx +178 -0
  398. package/src/ui/switch.tsx +49 -18
  399. package/src/utils/cn.ts +11 -5
  400. package/theme.css +84 -13
  401. package/dist/elements/chunks/action-icons-D9u3OzYW.js +0 -1
  402. package/dist/elements/chunks/arrow-left-CVh1TeN7.js +0 -1
  403. package/dist/elements/chunks/artifact-BkYcfIlZ.js +0 -1
  404. package/dist/elements/chunks/attachments-DwfNlubq.js +0 -1
  405. package/dist/elements/chunks/badge-Q0-Cmwzz.js +0 -1
  406. package/dist/elements/chunks/bash-InADTalH.js +0 -1
  407. package/dist/elements/chunks/card-renderer-3Dyl-FpN.js +0 -1
  408. package/dist/elements/chunks/card-routing-BK09BdmL.js +0 -1
  409. package/dist/elements/chunks/check-B5kBPKeo.js +0 -1
  410. package/dist/elements/chunks/chevron-down-DZ1GS39h.js +0 -1
  411. package/dist/elements/chunks/chevron-right-CvP7OUd2.js +0 -1
  412. package/dist/elements/chunks/choice-card-D4QUIOWZ.js +0 -1
  413. package/dist/elements/chunks/circle-CDf9g6MQ.js +0 -1
  414. package/dist/elements/chunks/circle-check-CA47Nqbz.js +0 -1
  415. package/dist/elements/chunks/circle-x-CJ_HVbC8.js +0 -1
  416. package/dist/elements/chunks/composer-Byve-n8K.js +0 -69
  417. package/dist/elements/chunks/confirm-card-B0flE3MN.js +0 -1
  418. package/dist/elements/chunks/controllable-DTwTIlwp.js +0 -1
  419. package/dist/elements/chunks/conversation-item-BDztezOG.js +0 -1
  420. package/dist/elements/chunks/copy-Nf-kqxXH.js +0 -1
  421. package/dist/elements/chunks/core-AYMC6_lb.js +0 -12
  422. package/dist/elements/chunks/css-M7EaDHN_.js +0 -1
  423. package/dist/elements/chunks/default-input-C2iLFq0b.js +0 -1
  424. package/dist/elements/chunks/define-wjHR1MkU.js +0 -1
  425. package/dist/elements/chunks/download-C4V7tBdb.js +0 -1
  426. package/dist/elements/chunks/embed-D3KewsIJ.js +0 -1
  427. package/dist/elements/chunks/engine-javascript-vq0WuIJl.js +0 -141
  428. package/dist/elements/chunks/file-text-D1G8khle.js +0 -1
  429. package/dist/elements/chunks/folder-WW2LGLid.js +0 -1
  430. package/dist/elements/chunks/form-CQirdC4W.js +0 -1
  431. package/dist/elements/chunks/github-dark-dimmed-DUshB20C.js +0 -1
  432. package/dist/elements/chunks/github-light-JYsPkUQd.js +0 -1
  433. package/dist/elements/chunks/hover-card-CJJkfHTK.js +0 -1
  434. package/dist/elements/chunks/html-CPZ3oZQ7.js +0 -1
  435. package/dist/elements/chunks/icon-Cfgmf4fa.js +0 -1
  436. package/dist/elements/chunks/info-DX3YMOt6.js +0 -1
  437. package/dist/elements/chunks/input-BtW9U6Sq.js +0 -1
  438. package/dist/elements/chunks/javascript-C25yR2R2.js +0 -1
  439. package/dist/elements/chunks/json-DxJze_jm.js +0 -1
  440. package/dist/elements/chunks/kbd-LGXwuT_V.js +0 -1
  441. package/dist/elements/chunks/link-2My69BqT.js +0 -1
  442. package/dist/elements/chunks/loader-iOxc_Tu-.js +0 -1
  443. package/dist/elements/chunks/message-VMZohrXx.js +0 -1
  444. package/dist/elements/chunks/message-circle-CpXMM-_n.js +0 -1
  445. package/dist/elements/chunks/message-square-DxcWtETQ.js +0 -1
  446. package/dist/elements/chunks/message-tZmV2A3p.js +0 -1
  447. package/dist/elements/chunks/paperclip-CRZVp4z_.js +0 -1
  448. package/dist/elements/chunks/progress-bar-DNy66e_E.js +0 -1
  449. package/dist/elements/chunks/prompt-suggestion-Bn64Nl7L.js +0 -1
  450. package/dist/elements/chunks/reasoning-Emn9N4-T.js +0 -1
  451. package/dist/elements/chunks/rotate-cw-CPWe37D9.js +0 -1
  452. package/dist/elements/chunks/scroll-area-ClZkZxIk.js +0 -1
  453. package/dist/elements/chunks/scroll-button-DPE-t3x_.js +0 -1
  454. package/dist/elements/chunks/share-CyfnNEqp.js +0 -1
  455. package/dist/elements/chunks/source-UWCvmVak.js +0 -1
  456. package/dist/elements/chunks/star-DoHFAikQ.js +0 -1
  457. package/dist/elements/chunks/svelte--5p79yCD.js +0 -1
  458. package/dist/elements/chunks/tasks-card-CmZ5xh6v.js +0 -1
  459. package/dist/elements/chunks/text-shimmer-DYn09zrz.js +0 -1
  460. package/dist/elements/chunks/textarea-skiGHURM.js +0 -1
  461. package/dist/elements/chunks/toast-store-CFnQpThX.js +0 -1
  462. package/dist/elements/chunks/tsx-B8rCNbgL.js +0 -1
  463. package/dist/elements/chunks/typescript-RycA9KXf.js +0 -1
  464. package/dist/elements/chunks/use-card-resolution-DoA2f_EU.js +0 -1
  465. package/dist/elements/chunks/vue-BmIZj4XD.js +0 -1
  466. package/dist/elements/chunks/x-B6UnOiLw.js +0 -1
  467. package/dist/llms/llms-full.txt +0 -2949
  468. package/dist/llms/llms.txt +0 -156
  469. package/dist/register-impl-DpE7icIb.js +0 -293
  470. package/dist/ui/stat.d.ts +0 -16
  471. package/src/ui/stat.tsx +0 -41
  472. /package/dist/{elements/chunks/card-tags-D8lZ-C_U.js → card-tags-D8lZ-C_U.js} +0 -0
  473. /package/dist/{elements/chunks/link-preview-DNILK391.js → link-preview-DNILK391.js} +0 -0
  474. /package/dist/{elements/chunks/slots-DaNjX0rc.js → slots-DaNjX0rc.js} +0 -0
@@ -0,0 +1,1353 @@
1
+ /**
2
+ * construct → generated Solid mini-project. THE single generation path:
3
+ * kai dev, kai compile and kai eject all call generateProject — the preview IS
4
+ * the artifact (owner-picked option B; no interpreter to drift).
5
+ *
6
+ * Quality bar: the output is the EJECT artifact. Deterministic (no dates, no
7
+ * randomness, object keys emitted in fixed order), idiomatic, readable.
8
+ * Interior is pure Solid composing @kitn.ai/ui/solid; provider glue imports
9
+ * @kitn.ai/ui/state + /wire (never a hand-rolled SSE reader); the one
10
+ * defineWebComponent facade carries the tag, theme default and slots.
11
+ * Styling: kit components + inline styles only — defineWebComponent injects
12
+ * the compiled kit CSS into the shadow root, so the generated project needs no
13
+ * Tailwind, no CSS build, nothing.
14
+ */
15
+ import { createRequire } from 'node:module';
16
+ import { mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import type { Construct } from './schema';
19
+
20
+ export interface GeneratedFile {
21
+ path: string;
22
+ code: string;
23
+ }
24
+
25
+ export interface GenerateOptions {
26
+ /** Dependency spec for @kitn.ai/ui in the generated package.json.
27
+ * Default: `^<this package's version>` (self-name resolution, mcp/server.ts pattern).
28
+ * The gates pass a local tarball path here. */
29
+ uiSpec?: string;
30
+ }
31
+
32
+ function kitVersion(): string {
33
+ const require = createRequire(import.meta.url);
34
+ const pkg = require('@kitn.ai/ui/package.json') as { version: string };
35
+ return pkg.version;
36
+ }
37
+
38
+ const themeMode = (c: Construct): 'light' | 'dark' | 'auto' =>
39
+ c.theme?.mode === 'light' ? 'light' : c.theme?.mode === 'dark' ? 'dark' : 'auto';
40
+
41
+ // ── accent contrast (codegen-time, static) ─────────────────────────────────
42
+ // A light accent (e.g. yellow) paired with the kit's default near-white
43
+ // foreground is unreadable, so the construct's accent needs a matching
44
+ // --kai-color-primary-foreground computed at generation time (the accent is
45
+ // static per construct — no reason to compute this at runtime for every
46
+ // browser that can't do it natively). Parses only the numeric CSS color forms
47
+ // (#rgb/#rrggbb/#rrggbbaa, rgb()/rgba(), hsl()/hsla()) — named colors, var(),
48
+ // and anything else exotic are NOT guessed at; see resolveContrastForeground.
49
+
50
+ /** sRGB channel (0-255) -> linear-light value, per the WCAG relative
51
+ * luminance formula. */
52
+ function srgbChannelToLinear(c: number): number {
53
+ const cs = c / 255;
54
+ return cs <= 0.03928 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4);
55
+ }
56
+
57
+ /** WCAG relative luminance, 0 (black) to 1 (white). */
58
+ function relativeLuminance(r: number, g: number, b: number): number {
59
+ return 0.2126 * srgbChannelToLinear(r) + 0.7152 * srgbChannelToLinear(g) + 0.0722 * srgbChannelToLinear(b);
60
+ }
61
+
62
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
63
+ const hue = ((h % 360) + 360) % 360;
64
+ const chroma = (1 - Math.abs(2 * l - 1)) * s;
65
+ const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
66
+ const m = l - chroma / 2;
67
+ const [r1, g1, b1] =
68
+ hue < 60 ? [chroma, x, 0]
69
+ : hue < 120 ? [x, chroma, 0]
70
+ : hue < 180 ? [0, chroma, x]
71
+ : hue < 240 ? [0, x, chroma]
72
+ : hue < 300 ? [x, 0, chroma]
73
+ : [chroma, 0, x];
74
+ return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)];
75
+ }
76
+
77
+ /** Parse the numeric CSS color forms only. Returns null for anything this
78
+ * can't resolve to concrete RGB without guessing (named colors, var(),
79
+ * color-mix(), oklch(), etc.) — the caller must decide loudly rather than
80
+ * silently picking a default for those. */
81
+ function parseAccentRgb(accent: string): [number, number, number] | null {
82
+ const s = accent.trim();
83
+ const hex3 = /^#([0-9a-fA-F]{3})$/.exec(s);
84
+ if (hex3) {
85
+ const [r, g, b] = hex3[1].split('').map((ch) => parseInt(ch + ch, 16));
86
+ return [r, g, b];
87
+ }
88
+ const hex6 = /^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/.exec(s);
89
+ if (hex6) {
90
+ const hex = hex6[1];
91
+ return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
92
+ }
93
+ const rgbFn = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$/.exec(s);
94
+ if (rgbFn) {
95
+ const [r, g, b] = [rgbFn[1], rgbFn[2], rgbFn[3]].map(Number);
96
+ return [r, g, b].every((v) => v >= 0 && v <= 255) ? [r, g, b] : null;
97
+ }
98
+ const hslFn = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*[\d.]+\s*)?\)$/.exec(s);
99
+ if (hslFn) {
100
+ return hslToRgb(Number(hslFn[1]), Number(hslFn[2]) / 100, Number(hslFn[3]) / 100);
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * The paired --kai-color-primary-foreground for a parseable accent, or null
107
+ * when the accent can't be resolved to concrete RGB without guessing.
108
+ *
109
+ * Threshold: white when it sits closer to white than to black on the WCAG
110
+ * relative-luminance scale (L <= 0.5), else black — equivalently "contrast
111
+ * against white (1 - L) >= contrast against black (L)". This is a deliberately
112
+ * simpler comparison than the full WCAG *contrast-ratio* formula (which adds
113
+ * a 0.05 offset to both sides): the ratio formula's asymmetric offset flips
114
+ * the choice for at least one real accent this codegen ships in its own demo
115
+ * fixture (#e91e63, L≈0.1915 — contrast-ratio picks black at 4.83:1 over
116
+ * white's 4.35:1, but a straight luminance-distance comparison, and every
117
+ * reference brand palette pairing that color with white text, picks white).
118
+ * Verified by direct computation, not assumed — see the "accent contrast"
119
+ * describe block in codegen.test.ts for the worked numbers.
120
+ */
121
+ export function resolveContrastForeground(accent: string): '#000000' | '#ffffff' | null {
122
+ const rgb = parseAccentRgb(accent);
123
+ if (!rgb) return null;
124
+ const luminance = relativeLuminance(...rgb);
125
+ return luminance <= 0.5 ? '#ffffff' : '#000000';
126
+ }
127
+
128
+ /**
129
+ * Shallow-merge a model's card tool-call args onto a DECLARED form schema's
130
+ * field defaults — "model proposes, user confirms" (CD-1, owner ruling
131
+ * 2026-08-26). Only top-level keys the args and the schema BOTH name are
132
+ * touched; the schema's own field shape (title/type/widget/validation) is
133
+ * never altered, and a key the model sent that isn't a declared field is
134
+ * ignored (the construct's vocabulary wins, not the model's). One level deep
135
+ * only — a nested object field's own defaults are not recursed into; no
136
+ * evidence of need yet (vocabulary-on-evidence).
137
+ *
138
+ * This is the real, module-level version used by this file's own tests
139
+ * (imported directly — see codegen-cards.render.test.tsx). `emitCardsImport`
140
+ * below emits an equivalent function VERBATIM as a string into the
141
+ * construct's own generated App.tsx: that copy is construct-glue code the
142
+ * eject artifact must own standalone (same as every other piece of logic
143
+ * emitCardsImport/emitApplyCardTools already emit inline), not an import
144
+ * from the kit — so the two are kept in sync by hand, not by import. Keep
145
+ * them behaviorally identical if you change one.
146
+ */
147
+ export function mergeToolArgsIntoFormDefaults(
148
+ schema: Record<string, unknown>,
149
+ args: Record<string, unknown>,
150
+ ): Record<string, unknown> {
151
+ const declared = schema.properties;
152
+ if (!declared || typeof declared !== 'object') return schema;
153
+ const patched: Record<string, unknown> = {};
154
+ for (const [key, value] of Object.entries(args)) {
155
+ if (!(key in (declared as Record<string, unknown>))) continue;
156
+ patched[key] = {
157
+ ...((declared as Record<string, unknown>)[key] as Record<string, unknown>),
158
+ default: value,
159
+ };
160
+ }
161
+ return { ...schema, properties: { ...(declared as Record<string, unknown>), ...patched } };
162
+ }
163
+
164
+ /**
165
+ * Neutralize characters that could break out of a comment before embedding
166
+ * untrusted text in one. Two contexts reuse this: the CLI/dev notice line
167
+ * (plain terminal text — newlines just garble it) and the CSS NOTICE comment
168
+ * emitted into element.tsx, where the two-character close-comment sequence in
169
+ * the accent would otherwise end the comment early and let the rest of the
170
+ * accent's text land as live CSS inside the generated stylesheet.
171
+ */
172
+ function commentSafe(text: string): string {
173
+ return text.replace(/[\r\n]/g, ' ').replace(/\*\//g, '* /');
174
+ }
175
+
176
+ const CONTRAST_COLOR_SUPPORTS = '@supports (color: contrast-color(red))';
177
+
178
+ /**
179
+ * One line for whichever host decides loudly about generation-time notices
180
+ * (the CLI today; `dev`'s watch loop reuses it on every regen). Null when
181
+ * there's nothing to say — no accent, or the accent parsed fine.
182
+ */
183
+ export function accentContrastNotice(construct: Construct): string | null {
184
+ const accent = construct.theme?.accent;
185
+ if (!accent || resolveContrastForeground(accent) !== null) return null;
186
+ return `accent '${commentSafe(accent)}' not parseable for contrast; foreground left at theme default in browsers without CSS contrast-color() support`;
187
+ }
188
+
189
+ export function generateProject(construct: Construct, opts: GenerateOptions = {}): GeneratedFile[] {
190
+ const uiSpec = opts.uiSpec ?? `^${kitVersion()}`;
191
+ const files: GeneratedFile[] = [
192
+ { path: 'package.json', code: emitPackageJson(construct, uiSpec) },
193
+ { path: 'tsconfig.json', code: emitTsconfig() },
194
+ { path: 'vite.config.ts', code: emitViteDev() },
195
+ { path: 'vite.config.lib.ts', code: emitViteLib(construct) },
196
+ { path: 'index.html', code: emitIndexHtml(construct) },
197
+ { path: 'src/element.tsx', code: emitElement(construct) },
198
+ { path: 'src/App.tsx', code: emitApp(construct) },
199
+ ];
200
+ if (construct.cards) files.push({ path: 'src/cards.ts', code: emitCardsRegistry(construct.cards) });
201
+ return files;
202
+ }
203
+
204
+ // ── cards ────────────────────────────────────────────────────────────────
205
+ // Named generative-UI card definitions the model can emit as tool calls.
206
+ // Registration only — the projection into provider tool definitions is the
207
+ // kit's OWN `cardTools`/`toOpenAITools`/`toAnthropicTools` (@kitn.ai/ui/schemas,
208
+ // src/schemas/tool-defs.ts), never a second one authored here.
209
+
210
+ /** `src/cards.ts` — the construct's card registry, verbatim from the
211
+ * construct. Each schema is `JSON.stringify(schema, null, 2)`'d and reindented
212
+ * under its key — deterministic because the construct's own key order (and
213
+ * each schema's own JSON key order) is preserved; nothing here re-sorts. */
214
+ function emitCardsRegistry(cards: NonNullable<Construct['cards']>): string {
215
+ const entries = cards
216
+ .map((card) => ` ${card.name}: ${JSON.stringify(card.schema, null, 2).split('\n').join('\n ')},`)
217
+ .join('\n');
218
+ return `// src/cards.ts — the construct's card registry, verbatim from the construct.
219
+ // Tool definitions for YOUR backend derive from this same object via
220
+ // @kitn.ai/ui/schemas (cardTools / toOpenAITools / toAnthropicTools) — one
221
+ // projection, shared with the kit.
222
+ export const cards = {
223
+ ${entries}
224
+ } as const;
225
+ `;
226
+ }
227
+
228
+ /** `, BUILTIN_CARD_COMPONENTS` spliced onto the `@kitn.ai/ui/solid` named-import
229
+ * list at the top of App.tsx (below) when cards are declared — the built-in
230
+ * `.form` renderer every declared card routes to. Empty otherwise. */
231
+ function emitCardComponentImport(c: Construct): string {
232
+ return c.cards ? ', BUILTIN_CARD_COMPONENTS' : '';
233
+ }
234
+
235
+ /** The `import`s cards need in App.tsx: the registry itself, `BUILTIN_CARD_COMPONENTS`
236
+ * (@kitn.ai/ui/solid re-exports it from the root entry) so every declared card can
237
+ * route to the kit's own schema-driven form renderer, `cardFromToolCall` (turns a
238
+ * settled tool-call ToolPart into a renderable `card` MessagePart — see
239
+ * emitApplyCardTools) and, for an endpoint construct, the wire-matching tool
240
+ * projection for the fetch body. Empty when the construct declares no cards at all
241
+ * (format rule: undeclared -> no affordance, no import).
242
+ *
243
+ * RULING (supervisor, this task): v1 renders EVERY declared card as the kit's own
244
+ * `form` card (`BUILTIN_CARD_COMPONENTS.form`, components/form.tsx) — it walks a
245
+ * JSON-Schema-shaped `data` into real input fields and honors `x-kai-format`/
246
+ * `x-kai-mask`/`x-kai-mask-guide` hints itself (field-mask.ts); no engine work is
247
+ * needed for masks specifically. This is deliberately NOT the same precedent as
248
+ * examples/apps/ops-console/shared/cards.ts's `createCardRegistry` (which maps
249
+ * several DISTINCT built-in kinds — confirm/form/choice/tasks — onto an app's own
250
+ * tool names): a construct's `cards` field carries only a `schema`, no `kind`, so
251
+ * there is no vocabulary yet to route on. Adding a `kind`/`type` field to pick
252
+ * confirm/choice/tasks is explicitly deferred to vocabulary-on-evidence, not done
253
+ * here — every construct card is a form until a later task adds that field. */
254
+ function emitCardsImport(c: Construct): string {
255
+ if (!c.cards) return '';
256
+ const toolsImport =
257
+ c.provider.mode === 'endpoint' ? (c.provider.wire === 'openai' ? ', toOpenAITools' : ', toAnthropicTools') : '';
258
+ return `import { cards } from './cards';
259
+ // Generative-UI cards, v1: every declared card renders as the kit's own
260
+ // schema-driven FORM (BUILTIN_CARD_COMPONENTS.form, components/form.tsx) — it
261
+ // walks the card's JSON Schema into real input fields, honoring
262
+ // x-kai-format/x-kai-mask/x-kai-mask-guide hints itself. ChatThread's own
263
+ // MessageBody already matches \`part.type === 'card'\` in its part rendering and
264
+ // draws it with the kit's own \`CardRenderer\` (components/card-renderer.tsx),
265
+ // which picks the component from \`cardTypes\` (below) by envelope.type — so
266
+ // there is nothing to hand-compose beyond that one map. Turning a model's tool
267
+ // call into that renderable part is \`cardFromToolCall\` (the inverse of
268
+ // \`cardTools\`), applied once per settled turn below; its data is then replaced
269
+ // with the DECLARED card schema (not the model's call arguments) — the fields
270
+ // on screen are the construct's own vocabulary, not whatever shape a model
271
+ // happened to send.
272
+ //
273
+ // UPDATE (CD-1, owner ruling 2026-08-26, Task 19g): the field SHAPE (title/
274
+ // type/widget/validation) still comes from the construct's own declared
275
+ // schema, never the model's — that part is unchanged. But discarding the
276
+ // model's call arguments wholesale also threw away any VALUE it wanted to
277
+ // pre-fill, breaking "model proposes, user confirms" (kai_refund_approval
278
+ // {amount:50} rendered an empty form). So the model's args are now
279
+ // shallow-merged onto the declared schema's field \`default\`s below
280
+ // (mergeToolArgsIntoFormDefaults) before the card is added — see
281
+ // emitApplyCardTools.
282
+ import { cardFromToolCall${toolsImport} } from '@kitn.ai/ui/schemas';
283
+
284
+ // Every declared card name routes to the SAME form renderer — cardFromToolCall
285
+ // makes envelope.type equal the card's own name (kai_refund_approval ->
286
+ // 'refund_approval'), and CardRenderer resolves a type's component from this
287
+ // map.
288
+ //
289
+ // Deliberately NOT also wiring ChatThread's \`cardSchemas\` prop to this
290
+ // registry below. That prop validates envelope.data AGAINST the named schema,
291
+ // and this card's data IS \`cards[name]\` itself (see emitApplyCardTools) — the
292
+ // construct's declared field schema, not values shaped like it. Wiring it as
293
+ // its own validator asks "does this FormDefinition itself have an \`amount\`
294
+ // key" and a well-formed FormDefinition never does, so every card would
295
+ // render the HARD validation-failure fallback instead of the form (caught
296
+ // live: eject + kai dev showed exactly that "(root).amount: required"
297
+ // failure before this comment existed). The construct's own schema.ts
298
+ // already checks \`cards\` structurally at validate time; there is nothing
299
+ // left for a second, self-referential check here to catch.
300
+ const cardTypes = Object.fromEntries(Object.keys(cards).map((name) => [name, BUILTIN_CARD_COMPONENTS.form] as const));
301
+
302
+ // CD-1 (owner ruling 2026-08-26, Task 19g): shallow-merge a model's card
303
+ // tool-call args onto a DECLARED form schema's field defaults — "model
304
+ // proposes, user confirms". Only top-level keys the args AND the schema both
305
+ // name are touched; the schema's own field shape (title/type/widget/
306
+ // validation) is never altered, and a key the model sent that isn't a
307
+ // declared field is ignored (the construct's vocabulary wins, not the
308
+ // model's). One level deep only — a nested object field's own defaults are
309
+ // not recursed into; no evidence of need yet (vocabulary-on-evidence).
310
+ function mergeToolArgsIntoFormDefaults(
311
+ schema: Record<string, unknown> & { properties?: Record<string, unknown> },
312
+ args: Record<string, unknown>,
313
+ ): Record<string, unknown> {
314
+ const declared = schema.properties;
315
+ if (!declared || typeof declared !== 'object') return schema;
316
+ const patched: Record<string, unknown> = {};
317
+ for (const [key, value] of Object.entries(args)) {
318
+ if (!(key in declared)) continue;
319
+ patched[key] = { ...(declared[key] as Record<string, unknown>), default: value };
320
+ }
321
+ return { ...schema, properties: { ...schema.properties, ...patched } };
322
+ }`;
323
+ }
324
+
325
+ /** `cardTypes={cardTypes}` on ChatThread — which component draws each
326
+ * declared card name (see emitCardsImport for why `cardSchemas` is
327
+ * deliberately NOT also registered). The host to emit card events off is
328
+ * already supplied by the ChatThread/kai-chat path (F-26); nothing else to
329
+ * thread through here. */
330
+ function emitCardTypesProp(c: Construct): string {
331
+ return c.cards ? ' cardTypes={cardTypes}' : '';
332
+ }
333
+
334
+ /** Settle a turn's tool calls into cards, called once after the read
335
+ * resolves and before `stream.done()`/`stream.abort()`. `AssistantStream`
336
+ * has no getter of its own, so the just-written parts are read back off
337
+ * `chat.messages()` by the stream's own id — the same pattern the kit's own
338
+ * `cardFromToolCall` doc comment (schemas/from-tool-call.ts) shows for a
339
+ * tool loop. A `kai_`-prefixed call becomes a card; anything else is the
340
+ * construct's own tool and is left as a plain `tool` part.
341
+ *
342
+ * `cardFromToolCall` supplies the envelope's `type`/`id` and `data` (the
343
+ * model's raw tool-call `input`, verbatim). `data` is then set to the card's
344
+ * own DECLARED schema off the registry (`cards[card.type]`) — the form
345
+ * renders the construct author's fields, matching the supervisor ruling
346
+ * that every declared card is a schema-driven form in v1 — with the
347
+ * model's args (`card.data`, read before this replaces it) shallow-merged
348
+ * onto that schema's field `default`s (CD-1, owner ruling 2026-08-26,
349
+ * Task 19g: `mergeToolArgsIntoFormDefaults`, emitted above by
350
+ * emitCardsImport) so "model proposes, user confirms" pre-fills the form
351
+ * instead of discarding the model's values outright. The `card.type in
352
+ * cards` guard only fires for a `kai_` call this construct never declared
353
+ * (an off-vocabulary call slipping through); it is silently dropped rather
354
+ * than rendered, matching cardFromToolCall's own "not every kai_ call is
355
+ * renderable" boundary (see its module header). */
356
+ function emitApplyCardTools(c: Construct): string {
357
+ if (!c.cards) return '';
358
+ return `
359
+ for (const part of chat.messages().find((m) => m.id === stream.id)?.parts ?? []) {
360
+ if (part.type !== 'tool' || part.tool.state !== 'input-available') continue;
361
+ const card = cardFromToolCall(part.tool.type, part.tool.input, { id: part.tool.toolCallId ?? crypto.randomUUID() });
362
+ if (card && card.type in cards) {
363
+ const declared = cards[card.type as keyof typeof cards];
364
+ const args = card.data as Record<string, unknown> | undefined;
365
+ const merged = args && typeof args === 'object'
366
+ ? mergeToolArgsIntoFormDefaults(declared, args)
367
+ : declared;
368
+ stream.addCard({ ...card, data: merged });
369
+ }
370
+ }`;
371
+ }
372
+
373
+ /** The endpoint fetch body's `tools` field — the projected tool defs for
374
+ * every declared card, matching the construct's own wire. No cards, no
375
+ * field: the format rule (undeclared capability's affordance is OFF) holds
376
+ * for tools the same way it holds for suggestions/attach/reasoning above. */
377
+ function emitToolsField(c: Construct): string {
378
+ if (!c.cards || c.provider.mode !== 'endpoint') return '';
379
+ const toolsFn = c.provider.wire === 'openai' ? 'toOpenAITools' : 'toAnthropicTools';
380
+ return `, tools: ${toolsFn}(cards)`;
381
+ }
382
+
383
+ function emitPackageJson(c: Construct, uiSpec: string): string {
384
+ return `${JSON.stringify(
385
+ {
386
+ name: c.name,
387
+ private: true,
388
+ type: 'module',
389
+ scripts: {
390
+ dev: 'vite',
391
+ build: 'vite build --config vite.config.lib.ts',
392
+ typecheck: 'tsc --noEmit',
393
+ },
394
+ dependencies: {
395
+ '@kitn.ai/ui': uiSpec,
396
+ 'solid-js': '^1.9.0',
397
+ },
398
+ devDependencies: {
399
+ typescript: '^5.6.0',
400
+ vite: '^6.0.0',
401
+ 'vite-plugin-solid': '^2.11.0',
402
+ },
403
+ },
404
+ null,
405
+ 2,
406
+ )}\n`;
407
+ }
408
+
409
+ function emitTsconfig(): string {
410
+ return `${JSON.stringify(
411
+ {
412
+ compilerOptions: {
413
+ target: 'ES2022',
414
+ module: 'ESNext',
415
+ moduleResolution: 'bundler',
416
+ jsx: 'preserve',
417
+ jsxImportSource: 'solid-js',
418
+ strict: true,
419
+ noUnusedLocals: true,
420
+ skipLibCheck: true,
421
+ types: ['vite/client'],
422
+ },
423
+ include: ['src'],
424
+ },
425
+ null,
426
+ 2,
427
+ )}\n`;
428
+ }
429
+
430
+ function emitViteDev(): string {
431
+ return `import { defineConfig } from 'vite';
432
+ import solid from 'vite-plugin-solid';
433
+
434
+ export default defineConfig({ plugins: [solid()] });
435
+ `;
436
+ }
437
+
438
+ function emitViteLib(c: Construct): string {
439
+ return `import { defineConfig } from 'vite';
440
+ import solid from 'vite-plugin-solid';
441
+
442
+ // kai compile: ONE self-registering .js. Everything is inlined (no externals):
443
+ // the consumer installs nothing but this output.
444
+ export default defineConfig({
445
+ plugins: [solid()],
446
+ build: {
447
+ lib: { entry: 'src/element.tsx', formats: ['es'], fileName: () => '${c.name}.js' },
448
+ },
449
+ });
450
+ `;
451
+ }
452
+
453
+ function emitIndexHtml(c: Construct): string {
454
+ // A demo host page, not the emitted widget: purely so a first-time preview
455
+ // isn't a mystery blank tab with one small launcher in the corner. Outside
456
+ // the custom element entirely (a sibling in <body>), inline-styled, and
457
+ // worded so nobody mistakes it for the construct's own output. Keyed off
458
+ // `layout`: the "bottom-right corner" wording is only true for `widget` (a
459
+ // floating launcher) — `fullscreen`/`aside`/`split` (Task 12) fill or dock
460
+ // the page themselves, so they get no hint at all rather than a wrong one.
461
+ // Decide loudly by omission, not by a stale claim.
462
+ const hint =
463
+ c.layout === 'widget'
464
+ ? `\n <p style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0; color: #94a3b8; font: 14px system-ui, sans-serif; text-align: center; max-width: 28rem; padding: 0 1rem;">This blank page stands in for your site. The chat widget is in the bottom-right corner.</p>`
465
+ : '';
466
+ // Task 13: when slots are declared, project real demo content into each one
467
+ // in the copy's own <${c.name}> tag — so the preview shows the escape hatch
468
+ // WORKING (light-DOM children of a custom element project into its shadow
469
+ // <slot> natively) rather than a mystery about how to use it. Slot names are
470
+ // already schema-validated to `^[a-z][a-z0-9-]*$` (schema.ts) — no
471
+ // quote/backslash payload possible, so no HTML-escaping is needed here,
472
+ // unlike a free-text construct-authored field.
473
+ const slotDemo = (c.slots ?? [])
474
+ .map(
475
+ (name) =>
476
+ `\n <div slot="${name}" style="padding: 0.5rem 1rem; font: 13px system-ui, sans-serif; color: #64748b;">Projected into slot "${name}" — replace with your own markup.</div>`,
477
+ )
478
+ .join('');
479
+ const body = slotDemo ? `\n <${c.name}>${slotDemo}\n </${c.name}>` : `\n <${c.name}></${c.name}>`;
480
+ return `<!doctype html>
481
+ <html lang="en">
482
+ <head>
483
+ <meta charset="utf-8" />
484
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
485
+ <title>${c.name} — construct preview</title>
486
+ </head>
487
+ <body style="margin: 0;">${hint}${body}
488
+ <script type="module" src="/src/element.tsx"></script>
489
+ </body>
490
+ </html>
491
+ `;
492
+ }
493
+
494
+ function emitElement(c: Construct): string {
495
+ const accent = c.theme?.accent;
496
+ if (!accent) {
497
+ // `empty` (Task 14) composes straight into ChatThread's own `emptyContent`
498
+ // prop now (see emitEmptyContentProp's doc) — a plain JSX value passed down
499
+ // through App, not a Portal onto the host — so the facade needs no `ctx` and
500
+ // every construct, `empty` declared or not, keeps this line byte-for-byte
501
+ // unchanged.
502
+ const facade = '() => <App />';
503
+ return `import { defineWebComponent } from '@kitn.ai/ui/define';
504
+ import { App } from './App';
505
+
506
+ // The one facade. Interior stays pure Solid (no nested element registrations);
507
+ // the kit CSS is injected into the shadow root by defineWebComponent itself.
508
+ defineWebComponent('${c.name}', { theme: '${themeMode(c)}' as 'light' | 'dark' | 'auto' }, ${facade});
509
+ `;
510
+ }
511
+
512
+ // The accent has to land on the HOST element, not anywhere inside this
513
+ // shadow root. The kit's --color-primary token is resolved ONCE, by a rule
514
+ // scoped to `:root, :host` (`@layer theme { :root, :host { --color-primary:
515
+ // var(--kai-color-primary, <fallback>) } }`) — so --kai-color-primary has to
516
+ // be set AT the host for that rule to see it; a descendant inside the
517
+ // shadow tree can set --kai-color-primary on itself all day and it will
518
+ // never flow back up into a value the :host rule already resolved. This is
519
+ // why the theme accent rendered nowhere in the T5 demo despite being
520
+ // "wired": an earlier version set it on a div INSIDE App's own render.
521
+ //
522
+ // `ctx.element` (the facade's second argument) IS the host, so this sets it
523
+ // from the one place inside the shadow root that has a handle to it.
524
+ // `style.setProperty` is also the safe way to carry the accent, distinct
525
+ // from string-interpolating it into CSS text: a custom property's value is
526
+ // an opaque token substituted via var(), so it can never break out into a
527
+ // new declaration or rule the way raw CSS/JS text interpolation could.
528
+ //
529
+ // The PAIRED --kai-color-primary-foreground is a separate concern: a light
530
+ // accent (yellow) next to the kit's default near-white foreground is
531
+ // unreadable. That pairing DOES need to live in a CSS block rather than a
532
+ // second setProperty call, because it has two layers that must be able to
533
+ // override each other in order: (1) a black/white value computed HERE, at
534
+ // generation time (the accent is static per construct), as the floor for
535
+ // browsers without CSS contrast-color() (Baseline Newly Available April
536
+ // 2026 — Chrome/Edge 147, Firefox 146, Safari 26; Widely Available is not
537
+ // until ~2028, and this widget embeds in arbitrary sites), and (2) inside
538
+ // `@supports (color: contrast-color(red))`, the NATIVE answer
539
+ // (contrast-color(var(--kai-color-primary))), which wins where supported —
540
+ // including for an accent this codegen couldn't parse. An inline
541
+ // `style.setProperty` always beats a stylesheet rule (short of
542
+ // `!important`), so achieving "the native answer wins where present" needs
543
+ // both layers to be plain `:host {}` declarations in one stylesheet, base
544
+ // rule first, @supports override after — ordinary cascade order, no
545
+ // `!important` required.
546
+ const foreground = resolveContrastForeground(accent);
547
+ const foregroundCss =
548
+ foreground !== null
549
+ ? `:host { --kai-color-primary-foreground: ${foreground}; }\n`
550
+ : // Not guessed at: an unparseable accent (var(), a named color, a
551
+ // color-mix()/oklch() call, …) leaves NO base declaration, so the
552
+ // kit's own theme default stands — except in a browser new enough to
553
+ // resolve contrast-color() itself, where the @supports block below
554
+ // still gets it right natively.
555
+ `/* NOTICE: accent '${commentSafe(accent)}' not parseable for contrast at generation time; the paired foreground falls back to the theme default in browsers without CSS contrast-color() support. */\n`;
556
+ const styleText =
557
+ foregroundCss + `${CONTRAST_COLOR_SUPPORTS} {\n :host { --kai-color-primary-foreground: contrast-color(var(--kai-color-primary)); }\n}`;
558
+
559
+ const facade = `(_props, ctx) => {
560
+ ctx.element.style.setProperty('--kai-color-primary', ${JSON.stringify(accent)});
561
+ return (
562
+ <>
563
+ <style>{${JSON.stringify(styleText)}}</style>
564
+ <App />
565
+ </>
566
+ );
567
+ }`;
568
+ return `import { defineWebComponent } from '@kitn.ai/ui/define';
569
+ import { App } from './App';
570
+
571
+ // The one facade. Interior stays pure Solid (no nested element registrations);
572
+ // the kit CSS is injected into the shadow root by defineWebComponent itself.
573
+ defineWebComponent('${c.name}', { theme: '${themeMode(c)}' as 'light' | 'dark' | 'auto' }, ${facade});
574
+ `;
575
+ }
576
+
577
+ // ── App interior ─────────────────────────────────────────────────────────────
578
+ // The chat spine is IMPLIED: thread + input + streaming are always emitted and
579
+ // wired; the construct declares deviations and additions only. Seams below are
580
+ // where later tasks splice capability code; each is a pure string join, so the
581
+ // determinism test keeps holding.
582
+
583
+ function emitApp(c: Construct): string {
584
+ if (c.layout === 'custom') return emitCustomApp(c);
585
+ return `${emitSolidJsImport(c)}import { ChatThread, createKaiChat${emitLayoutImport(c)}${emitCardComponentImport(c)}${emitEmptyComponentImport(c)}${emitHeaderCloseImport(c)} } from '@kitn.ai/ui/solid';
586
+ import type { AttachmentData${emitHistoryTypeImport(c)} } from '@kitn.ai/ui/solid';
587
+ ${emitProviderImports(c)}
588
+ ${emitCardsImport(c)}
589
+
590
+ ${emitProviderSetup(c)}
591
+ ${emitHistorySetup(c)}
592
+
593
+ // ChatThread is the kit's own MOST-INTEGRATED chat surface — the same
594
+ // composition <kai-chat>'s facade renders (src/elements/chat.tsx). It owns
595
+ // the message list, the composer (padding, focus ring, the send button) and
596
+ // their layout AS ONE UNIT, so nothing here re-derives spacing, alignment or
597
+ // focus styling by hand: every prior version of this file that hand-composed
598
+ // Thread + PromptInput + Button was restating layout the kit already owns,
599
+ // and every visual defect the owner hit (flush composer, a clipped focus
600
+ // ring) traced back to that restatement. Composing ChatThread directly
601
+ // leaves NOTHING here to restate it with.
602
+ //
603
+ // Capability gating (format rule: an undeclared capability's affordance must
604
+ // be OFF). The construct schema carries ONE capability field so far
605
+ // (capabilities.starters, Task 8) — every other affordance below is gated to
606
+ // "off" unconditionally, not per-construct, until there's a field to gate ON.
607
+ // - webSearch / voice: real ChatThreadProps booleans, default OFF when
608
+ // omitted — set to \`false\` explicitly rather than left implicit, so the
609
+ // gating decision is visible in the emitted source, not just inferred
610
+ // from an absent prop.
611
+ // - suggestions: ChatThread ALREADY owns starter prompts end to end — its
612
+ // own \`suggestions\` prop renders the chips, hides them once
613
+ // \`messages\` is non-empty, and (default \`suggestionMode="submit"\`)
614
+ // calls \`onSubmit\` with the clicked text exactly like a typed submit.
615
+ // So capabilities.starters threads straight into that prop; there is
616
+ // nothing to hand-compose. Omitted (undefined) when no starters are
617
+ // declared, same off-by-default effect as the booleans above.
618
+ // - models: omitted (undefined) — no model switcher; no capabilities field yet.
619
+ // - attachments (the paperclip): gated via ChatThread's \`attach\`/\`accept\`
620
+ // props (kit gap closed — ChatThread forwards both to DefaultPromptInput,
621
+ // mirroring webSearch/voice). ChatThread ALREADY owns the whole
622
+ // round-trip end to end — the paperclip button, staged previews, staging
623
+ // each file as a data URI (never a blob object URL; see
624
+ // AttachmentData.url's doc in components/attachment-types.ts), and
625
+ // handing the staged list back via onSubmit's \`attachments\` — and its
626
+ // Message component ALREADY groups consecutive file parts into one
627
+ // attachment row (message.tsx). So there is nothing to hand-compose
628
+ // here, same lesson as suggestions above: hand-rolling a second picker or
629
+ // a second file-part renderer would restate what ChatThread/Message
630
+ // already own. capabilities.attachments threads straight into
631
+ // attach/accept; the only App.tsx-owned piece is folding the picked
632
+ // attachments into the outgoing message's parts at the submit site
633
+ // (see emitProviderSetup) since createKaiChat's own append/streamAssistant
634
+ // ops don't do that folding themselves.
635
+ // - reasoning: gated via ChatThread's own \`reasoning\` prop (kit gap closed
636
+ // — ChatThread forwards it to every MessageBody as \`reasoningMode\`,
637
+ // mirroring attach/accept). \`'full'\` is both the schema default and
638
+ // ChatThread's own default, so it and an absent field emit no prop at
639
+ // all — the SAME off-by-default convention as every other capability
640
+ // here, just anchored on the medium's existing default instead of an
641
+ // "off" value, since a reasoning disclosure is normal chat behavior, not
642
+ // an opt-in affordance like the paperclip or a starter chip.
643
+ // - empty (the welcome-screen greeting, Task 14): gated via ChatThread's
644
+ // own \`emptyContent\` prop, plain JSX rendered in the SAME shadow tree
645
+ // this file's App already composes ChatThread inside of (see
646
+ // emitEmptyContentProp's own doc for why that boundary needs no Portal
647
+ // at all). \`capabilities.starters\`' chips and the composer still render
648
+ // underneath it: ChatThread's own doc comment on \`emptyContent\` is
649
+ // explicit that it replaces only the empty MESSAGE LIST.
650
+ // - the widget close control (owner feedback on the live demo): a declared
651
+ // \`header.title\` on a \`widget\` layout gets its close button threaded
652
+ // into ChatThread's own header row via \`headerEndContent\`, wired back to
653
+ // Dock's \`controllerRef\` seam through a local closure — see
654
+ // emitDockCloseVar/emitHeaderEndContentProp's docs. No header means no
655
+ // row for it to sit in, so that case is untouched and Dock's own built-in
656
+ // mobile X keeps covering it.
657
+ export function App() {
658
+ ${emitDockCloseVar(c, ' ')} return (
659
+ ${emitLayoutOpen(c)}${emitSlots(c.slots, ' ')} <ChatThread messages={chat.messages()} loading={chat.loading()} placeholder="Ask anything" onSubmit={submit} webSearch={false} voice={false}${emitHeaderProp(c)}${emitHeaderEndContentProp(c)}${emitAttachProps(c)}${emitStartersProp(c)}${emitReasoningProp(c)}${emitReasoningOpenProp(c)}${emitEmptyContentProp(c)}${emitCardTypesProp(c)} />
660
+ ${emitLayoutClose(c)} );
661
+ }
662
+ `;
663
+ }
664
+
665
+ /**
666
+ * `layout: 'custom'` — the escape hatch's own layout: minimal/no chrome, just
667
+ * the bare chat spine plus the declared `slots` positioned by the consumer.
668
+ * Composed from `Thread` (the message-list primitive, no composer/header/
669
+ * suggestions of its own — components/thread.tsx) + the `PromptInput`
670
+ * compound primitive, NOT `ChatThread`: `ChatThread` bundles its composer
671
+ * INSIDE itself (`DefaultPromptInput`, internal-only), which leaves no seam to
672
+ * splice a slot between the thread and the input the way this layout's
673
+ * placement rule needs. This is the one layout where hand-composing the spine
674
+ * is correct rather than a restatement — every other layout in this file
675
+ * wraps `ChatThread` precisely to avoid this hand-composition (see emitApp's
676
+ * doc comment above).
677
+ *
678
+ * Slot placement is a fixed, deterministic rule, spelled out in the emitted
679
+ * comment: the FIRST declared slot sits above the thread, every other
680
+ * declared slot sits below the composer, in declaration order. There is no
681
+ * vocabulary for a different arrangement (a `position` per slot, an
682
+ * interleaved grain) — reordering means ejecting and rearranging the JSX by
683
+ * hand, which this format's own rule already commits to (no code-in-JSON).
684
+ *
685
+ * Capability gating: only cards are wired here (`Thread` accepts `cardTypes`
686
+ * natively, same as `ChatThread`). starters/attachments/reasoning are NOT
687
+ * wired for `custom` in v1 — `Thread`/`PromptInput` don't carry the kit's own
688
+ * plumbing for those (they live inside `ChatThread`'s composer), and
689
+ * hand-rolling a second copy here is exactly the restatement this file
690
+ * avoids everywhere else. Decided loudly in the emitted comment below, not
691
+ * silently: this is the eject artifact, so a construct author who needs one
692
+ * of them on `custom` adds it directly to the plain Solid file they now own.
693
+ */
694
+ function emitCustomApp(c: Construct): string {
695
+ const slots = c.slots ?? [];
696
+ const [headerSlot, ...restSlots] = slots;
697
+ const history = c.capabilities?.history;
698
+ const solidJsNames = ['createSignal', ...(history && history.persistence !== 'none' ? ['createEffect'] : [])];
699
+ return `import { ${solidJsNames.join(', ')} } from 'solid-js';
700
+ import { Thread, PromptInput, PromptInputTextarea, PromptInputActions, Button, createKaiChat${emitCardComponentImport(c)} } from '@kitn.ai/ui/solid';
701
+ import type { AttachmentData${emitHistoryTypeImport(c)} } from '@kitn.ai/ui/solid';
702
+ ${emitProviderImports(c)}
703
+ ${emitCardsImport(c)}
704
+
705
+ ${emitProviderSetup(c)}
706
+ ${emitHistorySetup(c)}
707
+
708
+ // layout: custom — minimal chrome, no ChatThread/Dock/PaneGroup. The bare
709
+ // spine (Thread + PromptInput) plus the declared slots, positioned by hand so
710
+ // YOU own the surrounding DOM. Capabilities beyond the spine (starters,
711
+ // attachments, reasoning display-mode, reasoningOpen, header.title, empty)
712
+ // are NOT wired here in v1 — this file is the eject artifact; add them the
713
+ // way ChatThread composes them (components/chat-thread.tsx in the kit's own
714
+ // source) if this construct needs them on a custom layout.
715
+ export function App() {
716
+ const [value, setValue] = createSignal('');
717
+
718
+ const handleSubmit = () => {
719
+ const text = value();
720
+ if (!text.trim() || chat.loading()) return;
721
+ setValue('');
722
+ void submit({ value: text, attachments: [] });
723
+ };
724
+
725
+ return (
726
+ <div style={{ height: '100dvh', display: 'flex', 'flex-direction': 'column' }}>
727
+ ${emitSlots(headerSlot ? [headerSlot] : undefined, ' ')} <Thread messages={chat.messages()} loading={chat.loading()} class="min-h-0 flex-1"${emitCardTypesProp(c)} />
728
+ {/* Slot placement rule: first declared slot above the thread, every other
729
+ declared slot below the composer, in declaration order. Reorder by
730
+ ejecting — this is the whole grain dimmer. */}
731
+ <PromptInput value={value()} onValueChange={setValue} isLoading={chat.loading()} onSubmit={handleSubmit}>
732
+ <PromptInputTextarea placeholder="Ask anything" />
733
+ <PromptInputActions>
734
+ <Button onClick={handleSubmit}>Send</Button>
735
+ </PromptInputActions>
736
+ </PromptInput>
737
+ ${emitSlots(restSlots, ' ')} </div>
738
+ );
739
+ }
740
+ `;
741
+ }
742
+
743
+ /** header.title -> ChatThread's own \`chatTitle\` prop. Construct-authored/
744
+ * untrusted text like starters/theme.accent/provider.url, so JSON.stringify'd
745
+ * into a real JS string-literal expression. Omitted entirely (not even the
746
+ * prop) when no header is declared — the same off-by-default gating as every
747
+ * capability in this file, even though \`header\` isn't itself a capability. */
748
+ function emitHeaderProp(c: Construct): string {
749
+ const title = c.header?.title;
750
+ if (!title) return '';
751
+ return ` chatTitle={${JSON.stringify(title)}}`;
752
+ }
753
+
754
+ /** `empty` -> ChatThread's own `emptyContent` prop (chat-thread.tsx), a plain
755
+ * JSX value rendered INSIDE ChatThread's own tree — fully covered by the
756
+ * shadow root's adopted stylesheet, unlike the `empty`/`slot="empty"` boolean
757
+ * pairing this used to go through. That boundary was LIGHT-DOM: `<Portal
758
+ * mount={element}>` manufactured a real child of the host element tagged
759
+ * `slot="empty"` so ChatThread's `<slot name="empty">` could redistribute it
760
+ * — a detour needed only because a shadow `<slot>` redistributes light-DOM
761
+ * children of the HOST, never a Solid sibling's own JSX. It worked, but light
762
+ * DOM sits outside the shadow root's adopted stylesheet, so the Tailwind
763
+ * utility classes the kit's own `Empty` composition is built from resolved to
764
+ * nothing — the greeting rendered, unstyled (owner report against the live
765
+ * widget: "doesn't look like the empty component"). `App` already composes
766
+ * `ChatThread` directly as a plain Solid component in the SAME shadow tree
767
+ * `defineWebComponent` attaches, so there is no boundary to cross here at
768
+ * all — `emptyContent` just hands the JSX straight down, and it inherits the
769
+ * same styling as the rest of `App`. No Portal, no host-element param on
770
+ * `App`, no light-DOM indirection.
771
+ *
772
+ * `capabilities.starters`' chips and the composer still render underneath
773
+ * it: ChatThread's own doc comment on `empty`/`emptyContent` is explicit
774
+ * that this REPLACES only the empty MESSAGE LIST.
775
+ *
776
+ * Uses the kit's own `Empty`/`EmptyHeader`/`EmptyMedia`/`EmptyTitle`/
777
+ * `EmptyDescription` composition (components/empty.tsx) rather than hand-
778
+ * rolled markup — the same "don't restate the kit's own layout" rule
779
+ * `emitApp`'s header comment states for ChatThread itself. `title`/
780
+ * `description` are construct-authored/untrusted text, JSON.stringify'd into
781
+ * real JS string-literal expressions like every other free-text field in
782
+ * this file; `icon` is schema-validated by `isSafeUrl` (schema.ts) before
783
+ * codegen ever sees it, the same policy `widget.launcherIcon` uses. */
784
+ function emitEmptyContentProp(c: Construct): string {
785
+ const empty = c.empty;
786
+ if (!empty) return '';
787
+ const title = `<EmptyTitle>{${JSON.stringify(empty.title)}}</EmptyTitle>`;
788
+ const icon = empty.icon
789
+ ? `<EmptyMedia><img src={${JSON.stringify(empty.icon)}} alt="" style={{ width: '40px', height: '40px', 'border-radius': '9999px' }} /></EmptyMedia>`
790
+ : '';
791
+ const description = empty.description
792
+ ? `<EmptyDescription>{${JSON.stringify(empty.description)}}</EmptyDescription>`
793
+ : '';
794
+ return ` emptyContent={<Empty><EmptyHeader>${icon}${title}${description}</EmptyHeader></Empty>}`;
795
+ }
796
+
797
+ /** The `Empty` composition components `emitEmptyContentProp` needs, appended
798
+ * onto the same `@kitn.ai/ui/solid` import ChatThread/createKaiChat already
799
+ * use — never a second import statement for the same module. `EmptyMedia`/
800
+ * `EmptyDescription` are named only when `icon`/`description` are actually
801
+ * declared: `verify:scaffold` compiles emitted output with `tsc --strict
802
+ * --noUnusedLocals`, so an always-imported-but-sometimes-unused name would
803
+ * fail that gate the moment a construct omits one. */
804
+ function emitEmptyComponentImport(c: Construct): string {
805
+ if (!c.empty) return '';
806
+ let names = ', Empty, EmptyHeader, EmptyTitle';
807
+ if (c.empty.icon) names += ', EmptyMedia';
808
+ if (c.empty.description) names += ', EmptyDescription';
809
+ return names;
810
+ }
811
+
812
+ /** `widget` layout with a declared `header.title` gets its own close control
813
+ * integrated INTO ChatThread's header row instead of relying solely on
814
+ * Dock's own floating mobile X (see `dock.tsx`'s `hideClose` doc for the
815
+ * "why": a header-row X and a floating X over that same row read as
816
+ * unintentional together — owner feedback against the live widget). No
817
+ * header means no row for a close control to sit in at all, so this stays
818
+ * false and Dock's built-in fallback X keeps covering that case unchanged. */
819
+ function widgetHasHeaderClose(c: Construct): boolean {
820
+ return c.layout === 'widget' && !!c.header?.title;
821
+ }
822
+
823
+ /** The local closure `emitHeaderEndContentProp`/`emitDockControllerRef` share:
824
+ * Dock's `controllerRef` hands back `{ open, setOpen }` (ui/dock.tsx) — the
825
+ * existing imperative seam, not a new one — and this captures `setOpen`
826
+ * behind a plain function so ChatThread's `headerEndContent` button (which
827
+ * renders as a sibling, not a Dock descendant) can call it. Declared inside
828
+ * `App()`, not at module scope: `App()` runs once per widget instance, and a
829
+ * module-level variable would let one instance's close button reach into
830
+ * another's Dock if two ever rendered on the same page. */
831
+ function emitDockCloseVar(c: Construct, indent: string): string {
832
+ return widgetHasHeaderClose(c) ? `${indent}let dockClose: (() => void) | undefined;\n` : '';
833
+ }
834
+
835
+ /** Threads `emitDockCloseVar`'s closure onto `<Dock controllerRef>`. */
836
+ function emitDockControllerRef(c: Construct): string {
837
+ return widgetHasHeaderClose(c) ? ' controllerRef={(api) => (dockClose = () => api.setOpen(false))}' : '';
838
+ }
839
+
840
+ /** Suppresses Dock's own built-in mobile close X (see its `hideClose` doc)
841
+ * when ChatThread's header row is carrying an equivalent control instead —
842
+ * otherwise the two stack, one floating over the other's row. */
843
+ function emitDockHideClose(c: Construct): string {
844
+ return widgetHasHeaderClose(c) ? ' hideClose={true}' : '';
845
+ }
846
+
847
+ /** ChatThread's `headerEndContent` prop (chat-thread.tsx): the close button
848
+ * itself, sharing the header row with the title instead of floating as a
849
+ * second, visually unrelated control — the owner's stated preference over
850
+ * Dock's previous "reserved dead-row band" fix. Reuses the kit's own
851
+ * `DockCloseGlyph` (the same X Dock's built-in button draws) and `Button`
852
+ * (ghost/icon-sm, the same weight ChatThread's own header controls use —
853
+ * see `ModelSwitcher`'s trigger) rather than hand-rolling either, and calls
854
+ * back into `emitDockCloseVar`'s closure to actually close the panel —
855
+ * Dock's own `controllerRef` seam, not a new one. */
856
+ function emitHeaderEndContentProp(c: Construct): string {
857
+ if (!widgetHasHeaderClose(c)) return '';
858
+ return ` headerEndContent={<Button variant="ghost" size="icon-sm" aria-label="Close ${c.name}" onClick={() => dockClose?.()}><DockCloseGlyph /></Button>}`;
859
+ }
860
+
861
+ /** `Button`/`DockCloseGlyph`, needed only by `emitHeaderEndContentProp` above
862
+ * — appended onto the same `@kitn.ai/ui/solid` import as `emitEmptyComponentImport`,
863
+ * never a second import statement for the module. */
864
+ function emitHeaderCloseImport(c: Construct): string {
865
+ return widgetHasHeaderClose(c) ? ', Button, DockCloseGlyph' : '';
866
+ }
867
+
868
+ /** capabilities.attachments -> ChatThread's own \`attach\`/\`accept\` props.
869
+ * Undeclared keeps the explicit off-by-default gating (\`attach={false}\`,
870
+ * matching webSearch/voice above). Declared flips \`attach={true}\` and
871
+ * threads the accept list through — construct-authored/untrusted like
872
+ * \`starters\`/\`theme.accent\`/\`provider.url\`, so JSON.stringify'd into a
873
+ * real JS string-literal expression rather than a raw JSX attribute
874
+ * string (JSX attribute strings don't interpret escapes the way JS string
875
+ * literals do, so a raw \`accept="..."\` would be a breakout surface for a
876
+ * hostile media-type entry containing a \`"\`). */
877
+ function emitAttachProps(c: Construct): string {
878
+ const attachments = c.capabilities?.attachments;
879
+ if (!attachments) return ' attach={false}';
880
+ return ` attach={true} accept={${JSON.stringify(attachments.accept.join(','))}}`;
881
+ }
882
+
883
+ /** capabilities.starters -> ChatThread's own \`suggestions\` prop. Starter
884
+ * strings are construct-authored (untrusted the same way theme.accent and
885
+ * provider.url are) — JSON.stringify produces a real JS array-of-string-
886
+ * literals expression, the same safe-interpolation convention used for the
887
+ * accent (element.tsx) and the endpoint url (fetch() above): no quote,
888
+ * backslash or line-separator payload can break out of it. Omitted
889
+ * entirely (not even the prop) when no starters are declared, matching the
890
+ * off-by-default gating for every other capability. */
891
+ function emitStartersProp(c: Construct): string {
892
+ const starters = c.capabilities?.starters;
893
+ if (!starters || starters.length === 0) return '';
894
+ return ` suggestions={${JSON.stringify(starters)}}`;
895
+ }
896
+
897
+ /** capabilities.reasoning -> ChatThread's own `reasoning` prop. `'full'`
898
+ * and absent are the SAME thing (the schema default, matching ChatThread's
899
+ * own default) so both emit nothing at all — the off-by-default gating
900
+ * convention every other capability in this file follows: only a value that
901
+ * DEVIATES from the medium's default costs a byte in the emitted source.
902
+ * `'compact'`/`'off'` are plain string literals, not JSON.stringify'd like
903
+ * starters/accept/url — the schema already constrains this to one of three
904
+ * fixed enum members (schema.ts), so unlike those fields there is no
905
+ * construct-authored free text here to escape. */
906
+ function emitReasoningProp(c: Construct): string {
907
+ const reasoning = c.capabilities?.reasoning;
908
+ if (!reasoning || reasoning === 'full') return '';
909
+ return ` reasoning="${reasoning}"`;
910
+ }
911
+
912
+ /** capabilities.reasoningOpen -> ChatThread's own `reasoningOpen` prop. Only
913
+ * `true` costs a byte (off-by-default, matching every capability here);
914
+ * false/absent matches the kit's own new default (closed chip). */
915
+ function emitReasoningOpenProp(c: Construct): string {
916
+ return c.capabilities?.reasoningOpen === true ? ' reasoningOpen={true}' : '';
917
+ }
918
+
919
+ /** capabilities.history -> whether the App module needs `createEffect`
920
+ * (both persisted variants react to `chat.messages()` changing; `none`/
921
+ * absent needs no extra Solid import at all, matching the off-by-default
922
+ * gating everywhere else in this file). */
923
+ function emitSolidJsImport(c: Construct): string {
924
+ const history = c.capabilities?.history;
925
+ if (!history || history.persistence === 'none') return '';
926
+ return `import { createEffect } from 'solid-js';\n`;
927
+ }
928
+
929
+ /** capabilities.history -> whether the AttachmentData type import also needs
930
+ * ChatMessage (only the persisted variants read/write full ChatMessage[]
931
+ * arrays). */
932
+ function emitHistoryTypeImport(c: Construct): string {
933
+ const history = c.capabilities?.history;
934
+ if (!history || history.persistence === 'none') return '';
935
+ // The enclosing statement is already `import type { ... }` (AttachmentData),
936
+ // so this must NOT repeat the `type` modifier inside the braces — `import
937
+ // type { AttachmentData, type ChatMessage }` is a TS syntax error.
938
+ return ', ChatMessage';
939
+ }
940
+
941
+ /** capabilities.history -> the persistence block spliced after
942
+ * createKaiChat/submit (emitProviderSetup). `none`/absent emits nothing at
943
+ * all — the format rule (undeclared capability's affordance is OFF).
944
+ *
945
+ * `local`: keyed by the construct's own tag (one thread per construct, no
946
+ * cross-construct collision) — restoring on mount MUST hand createKaiChat's
947
+ * setMessages a NEW array reference (the kit's reactivity contract; see
948
+ * CLAUDE.md), which the updater-returns-parsed-array form does for free. A
949
+ * parsed value that is well-formed JSON but the WRONG SHAPE (an object, a
950
+ * number, ...) is just as dangerous as a storage exception — handing it to
951
+ * `chat.setMessages` would crash ChatThread's render — so it gets the same
952
+ * `Array.isArray` gate as the endpoint variant below, not just a try/catch
953
+ * around the parse.
954
+ * localStorage access is wrapped: it can throw in private mode or over
955
+ * quota, and a corrupt/foreign value under the key must not white-screen —
956
+ * neither failure is guessed at silently, both fall back to running
957
+ * in-memory (decide loudly: see the comment emitted alongside).
958
+ * Retention/eviction (how much, how long) is deliberately absent — an
959
+ * application-layer decision (component-scope-boundary), not this
960
+ * construct's to make.
961
+ *
962
+ * `endpoint`: the CONSUMER's own thread route — GET on mount (kit parses
963
+ * the response as ChatMessage[]; a non-OK response, a rejected fetch, or a
964
+ * non-array body all fall back to an empty thread rather than throwing or
965
+ * crashing render — matching the shape-check discipline above), PUT on
966
+ * every change. Both fetches are wrapped (try/catch around the GET chain,
967
+ * `.catch` on the PUT) and decide loudly on failure (`console.error`) —
968
+ * mirroring the adjacent provider-endpoint fetch's own try/catch +
969
+ * `stream.abort` pattern, not a silent swallow. The `hydrated` flag guards
970
+ * against the mount-load's own setMessages call immediately re-triggering a
971
+ * PUT that writes back exactly what was just read — but a FAILED GET must
972
+ * still flip it, in `finally`: a transient GET failure (offline/CORS/DNS)
973
+ * degrading to "start fresh, keep saving" is the recoverable failure mode;
974
+ * leaving `hydrated` false forever would permanently disable every future
975
+ * PUT for the tab's life over one blip. No retry/backoff — that belongs to
976
+ * the app, not this construct (component-scope-boundary). url is
977
+ * construct-authored/untrusted like theme.accent and provider.url, so it is
978
+ * JSON.stringify'd at both fetch call sites — never string-concatenated
979
+ * (see the endpoint-provider comment on this same class of bug). */
980
+ /** Top-level userId -> the `x-kai-user-id` header on every emitted fetch that
981
+ * talks to the consumer's own backend (the endpoint provider's chat POST, and
982
+ * history's endpoint GET/PUT) — so a route can tell which user's thread this
983
+ * is. `local` persistence folds it into THREAD_KEY instead (see
984
+ * emitHistorySetup) — no network call to header there. userId is
985
+ * construct-authored data (like theme.accent/provider.url), so it is
986
+ * JSON.stringify'd wherever it is interpolated — never string-concatenated. */
987
+ function emitUserIdHeaderEntry(c: Construct): string {
988
+ return c.userId ? `, 'x-kai-user-id': ${JSON.stringify(c.userId)}` : '';
989
+ }
990
+
991
+ function emitHistorySetup(c: Construct): string {
992
+ const history = c.capabilities?.history;
993
+ if (!history || history.persistence === 'none') return '';
994
+
995
+ if (history.persistence === 'local') {
996
+ const key = JSON.stringify(c.userId ? `kai:${c.name}:${c.userId}:thread` : `kai:${c.name}:thread`);
997
+ return `
998
+ // History: persisted locally in this browser, keyed by the element tag. What to
999
+ // retain and for how long is an app decision — clear the key to reset.
1000
+ const THREAD_KEY = ${key};
1001
+ try {
1002
+ const saved = localStorage.getItem(THREAD_KEY);
1003
+ if (saved) {
1004
+ const parsed: unknown = JSON.parse(saved);
1005
+ if (Array.isArray(parsed)) {
1006
+ chat.setMessages(() => parsed as ChatMessage[]);
1007
+ } else {
1008
+ console.warn(\`[\${THREAD_KEY}] stored history was not an array; ignoring and starting fresh\`);
1009
+ }
1010
+ }
1011
+ } catch { /* storage unavailable or corrupt: run in-memory */ }
1012
+ createEffect(() => {
1013
+ try {
1014
+ localStorage.setItem(THREAD_KEY, JSON.stringify(chat.messages()));
1015
+ } catch { /* storage unavailable: run in-memory */ }
1016
+ });
1017
+ `;
1018
+ }
1019
+
1020
+ const url = JSON.stringify(history.url);
1021
+ return `
1022
+ // History: persisted to your endpoint (GET on mount, PUT on every change) —
1023
+ // the kit PARSES, this app FETCHES; your route owns the storage and what to
1024
+ // retain and for how long. \`hydrated\` guards the mount-load from immediately
1025
+ // PUTting back what it just loaded, but still flips on a FAILED load — one
1026
+ // offline/CORS/DNS blip degrades to "start fresh, keep saving", not
1027
+ // "never save again".
1028
+ let hydrated = false;
1029
+ (async () => {
1030
+ try {
1031
+ const r = await fetch(${url}${c.userId ? `, { headers: { 'x-kai-user-id': ${JSON.stringify(c.userId)} } }` : ''});
1032
+ const saved: unknown = r.ok ? await r.json() : [];
1033
+ if (Array.isArray(saved)) {
1034
+ chat.setMessages(() => saved as ChatMessage[]);
1035
+ } else {
1036
+ console.warn('history endpoint returned a non-array body; ignoring and starting fresh');
1037
+ }
1038
+ } catch (err) {
1039
+ console.error('history endpoint GET failed; starting fresh (will keep saving)', err);
1040
+ } finally {
1041
+ hydrated = true;
1042
+ }
1043
+ })();
1044
+ createEffect(() => {
1045
+ const snapshot = chat.messages();
1046
+ if (!hydrated) return;
1047
+ fetch(${url}, {
1048
+ method: 'PUT',
1049
+ headers: { 'content-type': 'application/json'${emitUserIdHeaderEntry(c)} },
1050
+ body: JSON.stringify(snapshot),
1051
+ }).catch((err) => {
1052
+ console.error('history endpoint PUT failed; this change was not persisted', err);
1053
+ });
1054
+ });
1055
+ `;
1056
+ }
1057
+
1058
+ function emitProviderImports(c: Construct): string {
1059
+ if (c.provider.mode === 'mock') {
1060
+ return `import { createMockResponder } from '@kitn.ai/ui/state';
1061
+ import { readOpenAIStream } from '@kitn.ai/ui/wire';`;
1062
+ }
1063
+ const read = c.provider.wire === 'openai' ? 'readOpenAIStream' : 'readAnthropicStream';
1064
+ const encode = c.provider.wire === 'openai' ? 'toOpenAIMessages' : 'toAnthropicMessages';
1065
+ return `import { ${read}, ${encode} } from '@kitn.ai/ui/wire';`;
1066
+ }
1067
+
1068
+ function emitProviderSetup(c: Construct): string {
1069
+ // ChatThread owns its own composer draft (uncontrolled — no `value` prop
1070
+ // passed below) and clears it after submit itself; `onSubmit` hands back
1071
+ // the value directly, so there's no PromptInput-specific signal-reading
1072
+ // workaround to carry here any more.
1073
+ if (c.provider.mode === 'mock') {
1074
+ const cardsNote = c.cards
1075
+ ? `
1076
+ // Cards demo keylessly: createMockResponder() can already SCRIPT a tool call
1077
+ // (\`replies: [{ toolCalls: [...] }]\`, F-35), so a scripted turn calling
1078
+ // \`kai_<card name>\` renders exactly like a live model's would, below.`
1079
+ : '';
1080
+ return `// Provider seam: mock — keyless, streams locally, announces itself once.
1081
+ // Swap for provider.mode "endpoint" in the construct and re-run kai dev; the
1082
+ // generated fetch keeps this exact shape (the seam is the point).${cardsNote}
1083
+ const respond = createMockResponder();
1084
+ const chat = createKaiChat();
1085
+
1086
+ async function submit(detail: { value: string; attachments: AttachmentData[] }) {
1087
+ if (!detail.value.trim() || chat.loading()) return;
1088
+ chat.append({
1089
+ id: crypto.randomUUID(),
1090
+ role: 'user',
1091
+ parts: [
1092
+ { type: 'text', text: detail.value },
1093
+ ...detail.attachments.map((attachment) => ({ type: 'file' as const, attachment })),
1094
+ ],
1095
+ });
1096
+ const stream = chat.streamAssistant();
1097
+ try {
1098
+ await readOpenAIStream(respond(detail.value), stream);${emitApplyCardTools(c)}
1099
+ stream.done();
1100
+ } catch (err) {
1101
+ stream.abort(err instanceof Error ? err.message : String(err));
1102
+ }
1103
+ }`;
1104
+ }
1105
+
1106
+ const { url, wire } = c.provider;
1107
+ const read = wire === 'openai' ? 'readOpenAIStream' : 'readAnthropicStream';
1108
+ const encode = wire === 'openai' ? 'toOpenAIMessages' : 'toAnthropicMessages';
1109
+ // provider.url is an UNCONSTRAINED z.string() (schema.ts). It is NEVER
1110
+ // embedded in this comment — a `//` line comment is ended by a raw
1111
+ // U+2028/U+2029 line separator (a valid JS line terminator that
1112
+ // commentSafe's \r\n strip does not catch), so a url containing one of
1113
+ // those code points could close the comment early and let the rest of the
1114
+ // url execute as JS. commentSafe is a comment-escaping tool and this is a
1115
+ // hand-rolled-escaping trap for untrusted input by construction, so the
1116
+ // fix is to never hand-roll it here: the url appears ONLY on the fetch()
1117
+ // line below, via JSON.stringify, which is a real JS string literal (not a
1118
+ // comment) and immune to this class of bug.
1119
+ return `// Provider seam: YOUR endpoint (${wire} wire, see the fetch call below
1120
+ // for the URL). The kit PARSES, this component FETCHES — no key, no
1121
+ // provider SDK, no client in here. Your route holds the key and re-frames to
1122
+ // the provider; the kai MCP scaffold tool emits one for your framework.
1123
+ const chat = createKaiChat();
1124
+
1125
+ async function submit(detail: { value: string; attachments: AttachmentData[] }) {
1126
+ if (!detail.value.trim() || chat.loading()) return;
1127
+ chat.append({
1128
+ id: crypto.randomUUID(),
1129
+ role: 'user',
1130
+ parts: [
1131
+ { type: 'text', text: detail.value },
1132
+ ...detail.attachments.map((attachment) => ({ type: 'file' as const, attachment })),
1133
+ ],
1134
+ });
1135
+ const stream = chat.streamAssistant();
1136
+ try {
1137
+ const response = await fetch(${JSON.stringify(url)}, {
1138
+ method: 'POST',
1139
+ headers: { 'content-type': 'application/json'${emitUserIdHeaderEntry(c)} },
1140
+ body: JSON.stringify({ messages: ${encode}(chat.messages())${emitToolsField(c)} }),
1141
+ });
1142
+ if (!response.ok) throw new Error(\`endpoint responded \${response.status}\`);
1143
+ await ${read}(response, stream);${emitApplyCardTools(c)}
1144
+ stream.done();
1145
+ } catch (err) {
1146
+ stream.abort(err instanceof Error ? err.message : String(err));
1147
+ }
1148
+ }`;
1149
+ }
1150
+
1151
+ /** The layout-conditional named import spliced onto the `@kitn.ai/ui/solid`
1152
+ * import list in App.tsx: `Dock` only for `widget`, `PaneGroup` only for
1153
+ * `split` — nothing for `fullscreen`/`aside`, which are plain styled
1154
+ * containers (the kit has no dedicated fullscreen/docked-aside component;
1155
+ * see the emitLayoutOpen doc for why that's the honest choice here rather
1156
+ * than a hand-rolled component of our own). Gated so the generated
1157
+ * project's own `noUnusedLocals` never trips on an import used by a layout
1158
+ * that isn't this construct's. */
1159
+ function emitLayoutImport(c: Construct): string {
1160
+ switch (c.layout) {
1161
+ case 'widget':
1162
+ return ', Dock';
1163
+ case 'split':
1164
+ return ', WorkspaceShell';
1165
+ case 'fullscreen':
1166
+ case 'aside':
1167
+ return '';
1168
+ case 'custom':
1169
+ // Unreachable: emitApp special-cases 'custom' into emitCustomApp before
1170
+ // this is ever called (custom's spine is Thread + PromptInput, not
1171
+ // ChatThread, so there is no shared import line to splice onto). Kept
1172
+ // only so this switch stays exhaustive over the widened layout enum.
1173
+ return '';
1174
+ }
1175
+ }
1176
+
1177
+ /** Declared `slots`, one `<slot name="...">` per entry in declaration order.
1178
+ * Slot names are already schema-validated to `^[a-z][a-z0-9-]*$` (schema.ts)
1179
+ * — a closed character set with no quote/backslash, so they're interpolated
1180
+ * directly into the attribute rather than JSON.stringify'd like the
1181
+ * free-text construct-authored fields (starters, theme.accent, provider.url)
1182
+ * elsewhere in this file. `indent` matches the surrounding JSX depth. */
1183
+ function emitSlots(slots: readonly string[] | undefined, indent: string): string {
1184
+ if (!slots || slots.length === 0) return '';
1185
+ return slots.map((name) => `${indent}<slot name="${name}" />\n`).join('');
1186
+ }
1187
+
1188
+ /**
1189
+ * The layout shell wrapping ChatThread — one pair (open above, close below)
1190
+ * per `layout`, composing the kit's own layout primitives over a hand-rolled
1191
+ * div wherever the kit ships one:
1192
+ *
1193
+ * - `widget`: the kit's Dock (launcher + panel + focus contract) — unchanged
1194
+ * from before Task 12. No theming wrapper needed here — the accent lands
1195
+ * on the HOST element from element.tsx's facade (see emitElement), which
1196
+ * reaches both the launcher (a DOM sibling of this panel content, outside
1197
+ * Dock's own `children`) and everything below via normal custom-property
1198
+ * inheritance from :host down through the whole shadow tree.
1199
+ * - `fullscreen`: the kit has no dedicated "fill the viewport" component —
1200
+ * this genuinely is just sizing, not chrome — so a minimally-styled
1201
+ * `<div>` (100dvh, column flex) IS the honest composition, not a
1202
+ * restatement of something the kit already owns.
1203
+ * - `aside`: same reasoning — a persistent, single-edge docked panel is a
1204
+ * styled container (fixed inline-end column, the kit's own
1205
+ * `--kai-color-border` token for the divider), not a kit component. `dvh`/
1206
+ * logical properties (`inset-inline-end`, `border-inline-start`) keep it
1207
+ * correct under RTL and mobile viewport chrome the same way `100dvh` does
1208
+ * for fullscreen.
1209
+ * - `split`: composes the kit's real `WorkspaceShell` (components/
1210
+ * workspace-shell.tsx) for its frame -- chat in `children` (the main
1211
+ * region), the end pane's `<slot name="pane">` seam projected via `end`.
1212
+ * Superseded from Task 12's `PaneGroup` (recorded decision 2): `PaneGroup`
1213
+ * is an editor GROUP contract (src/ui/pane-group.tsx) -- a tab strip over
1214
+ * ONE content area -- so getting two SIMULTANEOUS panes out of it meant a
1215
+ * single always-active tab whose one body was a hand-rolled flex row doing
1216
+ * the actual two-column math; the "kit component" was supplying a frame
1217
+ * the composition didn't use for its defining feature (a resizable split)
1218
+ * at all. `WorkspaceShell` already IS a two-region layout with a REAL
1219
+ * draggable splitter between them (it composes `ResizablePanelGroup`
1220
+ * internally) -- start/end aside width props and collapse are left at
1221
+ * their defaults here (this construct wants exactly one fixed end pane,
1222
+ * not a full workspace chrome); `drawerBelow={480}` IS wired (Task 19d) so
1223
+ * split gets the kit's own mobile takeover at the same breakpoint every
1224
+ * other layout uses, so the split's math is the kit's
1225
+ * own, not a restatement.
1226
+ *
1227
+ * `custom` is handled entirely by `emitCustomApp` instead (its spine is
1228
+ * Thread + PromptInput composed by hand, not ChatThread, so there's no shared
1229
+ * chrome to open/close here) — `emitApp` special-cases it before either of
1230
+ * these is called. Both switches still carry a `case 'custom'` so they stay
1231
+ * exhaustive over the widened layout enum: TypeScript is what caught this
1232
+ * exact gap when Task 13 added the enum member, and a `default` would have
1233
+ * hidden it again for the next one.
1234
+ */
1235
+ /** widget.position -> Dock's own `position` prop. A closed DockPosition enum
1236
+ * (schema-constrained), so plain string interpolation is safe — no
1237
+ * construct-authored free text here, unlike launcherIcon below. */
1238
+ function emitDockPosition(c: Construct): string {
1239
+ const w = c.layout === 'widget' ? c.widget : undefined;
1240
+ return w?.position ? ` position="${w.position}"` : '';
1241
+ }
1242
+
1243
+ /** widget.launcherIcon -> Dock's `launcher` prop, replacing the built-in
1244
+ * closed-state glyph with an <img>. launcherIcon is construct-authored/
1245
+ * untrusted text (like theme.accent/provider.url elsewhere in this file),
1246
+ * so it is JSON.stringify'd into a real JS string-literal expression, never
1247
+ * interpolated into a raw JSX attribute string. */
1248
+ function emitDockLauncher(c: Construct): string {
1249
+ const w = c.layout === 'widget' ? c.widget : undefined;
1250
+ if (!w?.launcherIcon) return '';
1251
+ return ` launcher={<img src={${JSON.stringify(w.launcherIcon)}} alt="" style={{ width: '24px', height: '24px', 'border-radius': '9999px' }} />}`;
1252
+ }
1253
+
1254
+ /** widget.defaultOpen -> Dock's own `defaultOpen` prop. Only `true` costs a
1255
+ * byte — `false`/absent matches Dock's own default (closed), same
1256
+ * off-by-default convention as every other capability in this file. */
1257
+ function emitDockDefaultOpen(c: Construct): string {
1258
+ const w = c.layout === 'widget' ? c.widget : undefined;
1259
+ return w?.defaultOpen === true ? ' defaultOpen={true}' : '';
1260
+ }
1261
+
1262
+ function emitLayoutOpen(c: Construct): string {
1263
+ switch (c.layout) {
1264
+ case 'widget':
1265
+ return ` <Dock label="${c.name}"${emitDockPosition(c)}${emitDockLauncher(c)}${emitDockDefaultOpen(c)}${emitDockHideClose(c)}${emitDockControllerRef(c)}>\n`;
1266
+ case 'fullscreen':
1267
+ return ` <div style={{ height: '100dvh', display: 'flex', 'flex-direction': 'column' }}>\n`;
1268
+ case 'aside':
1269
+ return ` <aside data-kai-layout="aside" style={{ position: 'fixed', 'inset-block': '0', 'inset-inline-end': '0', width: '380px', display: 'flex', 'flex-direction': 'column', 'border-inline-start': '1px solid var(--kai-color-border)' }}>
1270
+ {/* Mirrors Dock's own narrow-viewport full-bleed rule (ui/dock.tsx:229-240)
1271
+ — aside has no dedicated kit component (see the emitLayoutOpen doc
1272
+ comment above), so this is the honest hand-rolled equivalent, not a
1273
+ new responsive strategy. */}
1274
+ <style>{\`@media (max-width: 480px) { [data-kai-layout="aside"] { inset: 0; width: auto; height: auto; border-inline-start: 0; } }\`}</style>
1275
+ `;
1276
+ case 'split':
1277
+ // drawerBelow: split's mobile takeover is the kit's OWN WorkspaceShell
1278
+ // capability (components/workspace-shell.tsx), not hand-rolled CSS — wiring
1279
+ // it here is composition-over-reauthoring, not a media-query duplicate. 480
1280
+ // matches Dock's own breakpoint (ui/dock.tsx:229) so every layout takes over
1281
+ // at the same viewport width.
1282
+ return ` <div style={{ height: '100dvh' }}>\n <WorkspaceShell class="h-full" drawerBelow={480} end={\n <div style={{ height: '100%', overflow: 'auto' }}>\n <slot name="pane" />\n </div>\n }>\n`;
1283
+ case 'custom':
1284
+ return ''; // unreachable — see the block comment above
1285
+ }
1286
+ }
1287
+
1288
+ function emitLayoutClose(c: Construct): string {
1289
+ switch (c.layout) {
1290
+ case 'widget':
1291
+ return ` </Dock>\n`;
1292
+ case 'fullscreen':
1293
+ return ` </div>\n`;
1294
+ case 'aside':
1295
+ return ` </aside>\n`;
1296
+ case 'split':
1297
+ // The end pane (WorkspaceShell's `end`, opened above) is a fixed,
1298
+ // always-present projection point for `split` specifically (Task 12) —
1299
+ // orthogonal to Task 13's generic `slots` field, which still emits
1300
+ // above the chat pane the same as every other non-custom layout (see
1301
+ // emitApp). WorkspaceShell supplies its own real draggable splitter
1302
+ // between the two, so there is no hand-rolled two-column math left to
1303
+ // close here.
1304
+ return ` </WorkspaceShell>\n </div>\n`;
1305
+ case 'custom':
1306
+ return ''; // unreachable — see the block comment above
1307
+ }
1308
+ }
1309
+
1310
+ // ── kai compile: the d.ts alongside the single .js ─────────────────────────
1311
+
1312
+ /** The declaration file `kai compile` writes beside the emitted .js — just
1313
+ * enough for a consumer's TS to know the tag and its one settable prop. */
1314
+ export function emitTypes(c: Construct): string {
1315
+ return `declare global {
1316
+ interface HTMLElementTagNameMap {
1317
+ '${c.name}': HTMLElement & { theme: 'light' | 'dark' | 'auto' };
1318
+ }
1319
+ }
1320
+ export {};
1321
+ `;
1322
+ }
1323
+
1324
+ // ── writing ──────────────────────────────────────────────────────────────────
1325
+
1326
+ const MANIFEST = '.kai-manifest.json';
1327
+
1328
+ /**
1329
+ * Write files; prune anything the PREVIOUS generation wrote that this one
1330
+ * didn't. Returns the paths that already existed on disk before this write
1331
+ * (i.e. were overwritten) — callers that decide loudly (the CLI's `eject`)
1332
+ * use it to say so instead of silently clobbering a file the caller may have
1333
+ * hand-edited.
1334
+ */
1335
+ export function writeProject(files: GeneratedFile[], dir: string): string[] {
1336
+ const manifestPath = join(dir, MANIFEST);
1337
+ const previous: string[] = existsSync(manifestPath)
1338
+ ? (JSON.parse(readFileSync(manifestPath, 'utf8')) as string[])
1339
+ : [];
1340
+ const current = new Set(files.map((f) => f.path));
1341
+ for (const stale of previous) {
1342
+ if (!current.has(stale)) rmSync(join(dir, stale), { force: true });
1343
+ }
1344
+ const overwritten: string[] = [];
1345
+ for (const f of files) {
1346
+ const abs = join(dir, f.path);
1347
+ if (existsSync(abs)) overwritten.push(f.path);
1348
+ mkdirSync(dirname(abs), { recursive: true });
1349
+ writeFileSync(abs, f.code);
1350
+ }
1351
+ writeFileSync(manifestPath, `${JSON.stringify([...current].sort(), null, 2)}\n`);
1352
+ return overwritten;
1353
+ }