@kitn.ai/ui 0.25.2 → 0.26.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 (409) hide show
  1. package/README.md +2 -2
  2. package/dist/components/attachment-types.d.ts +46 -1
  3. package/dist/components/attachments.d.ts +31 -0
  4. package/dist/components/audio-visualizer/fit-scale.d.ts +50 -0
  5. package/dist/components/audio-visualizer/index.d.ts +15 -1
  6. package/dist/components/audio-visualizer/variant-bar.d.ts +24 -1
  7. package/dist/components/chat-thread.d.ts +3 -41
  8. package/dist/components/code-block.d.ts +22 -0
  9. package/dist/components/conversation-item.d.ts +54 -1
  10. package/dist/components/conversation-list.d.ts +65 -0
  11. package/dist/components/form.d.ts +42 -0
  12. package/dist/components/message.d.ts +7 -0
  13. package/dist/components/toast.d.ts +2 -1
  14. package/dist/components/voice-input.d.ts +8 -0
  15. package/dist/components/voice-output.d.ts +12 -1
  16. package/dist/components/workspace-shell.d.ts +87 -0
  17. package/dist/{create-tween-B5Y4Im8G.js → create-tween-BFT6c-Y8.js} +1 -1
  18. package/dist/{create-tween-4z1XYLkR.js → create-tween-CJNlzoaO.js} +1 -1
  19. package/dist/{create-tween-CUzOlaMS.js → create-tween-CQED4T0g.js} +1 -1
  20. package/dist/custom-elements.json +2931 -2572
  21. package/dist/diagnostics/hook.d.ts +81 -0
  22. package/dist/diagnostics/index.d.ts +7 -0
  23. package/dist/diagnostics/report-request.d.ts +61 -0
  24. package/dist/diagnostics.d.ts +5 -0
  25. package/dist/diagnostics.js +209 -0
  26. package/dist/elements/agent-card.js +1 -1
  27. package/dist/elements/artifact.js +1 -1
  28. package/dist/elements/attachments.js +1 -1
  29. package/dist/elements/audio-visualizer.js +1 -1
  30. package/dist/elements/autoloader.js +1 -1
  31. package/dist/elements/avatar.js +1 -1
  32. package/dist/elements/badge.js +1 -1
  33. package/dist/elements/button.js +1 -1
  34. package/dist/elements/card.js +1 -1
  35. package/dist/elements/cards.js +1 -1
  36. package/dist/elements/chain-of-thought.js +1 -1
  37. package/dist/elements/chat-scope-picker.js +1 -1
  38. package/dist/elements/chat-workspace.js +1 -1
  39. package/dist/elements/chat.js +1 -1
  40. package/dist/elements/checkpoint.js +1 -1
  41. package/dist/elements/choice.js +1 -1
  42. package/dist/elements/chunks/{Icon-b5A8hlHs.js → Icon--Y8vsFal.js} +1 -1
  43. package/dist/elements/chunks/action-icons-D9u3OzYW.js +1 -0
  44. package/dist/elements/chunks/arrow-left-CVh1TeN7.js +1 -0
  45. package/dist/elements/chunks/{artifact-BSuPYFHY.js → artifact-BkYcfIlZ.js} +1 -1
  46. package/dist/elements/chunks/attachments-DwfNlubq.js +1 -0
  47. package/dist/elements/chunks/audio-visualizer-D7lB6mvR.js +1 -0
  48. package/dist/elements/chunks/{badge-TIMiJFKc.js → badge-Q0-Cmwzz.js} +1 -1
  49. package/dist/elements/chunks/{button-B9okqG73.js → button-flUbeufF.js} +1 -1
  50. package/dist/elements/chunks/{card-renderer-CSoTL_e1.js → card-renderer-3Dyl-FpN.js} +1 -1
  51. package/dist/elements/chunks/check-B5kBPKeo.js +1 -0
  52. package/dist/elements/chunks/chevron-down-DZ1GS39h.js +1 -0
  53. package/dist/elements/chunks/chevron-right-CvP7OUd2.js +1 -0
  54. package/dist/elements/chunks/{choice-card-CGMc3kek.js → choice-card-D4QUIOWZ.js} +1 -1
  55. package/dist/elements/chunks/circle-CDf9g6MQ.js +1 -0
  56. package/dist/elements/chunks/circle-check-CA47Nqbz.js +1 -0
  57. package/dist/elements/chunks/{circle-x-mhH7pOK2.js → circle-x-CJ_HVbC8.js} +1 -1
  58. package/dist/elements/chunks/code-block-BdLiTitd.js +1 -0
  59. package/dist/elements/chunks/collapsible-BzbXpPzr.js +1 -0
  60. package/dist/elements/chunks/{composer-CZZmtYOT.js → composer-Byve-n8K.js} +2 -2
  61. package/dist/elements/chunks/{confirm-card-BWjf2-xo.js → confirm-card-B0flE3MN.js} +1 -1
  62. package/dist/elements/chunks/context-VLIL3sJ-.js +1 -0
  63. package/dist/elements/chunks/controllable-DTwTIlwp.js +1 -0
  64. package/dist/elements/chunks/conversation-item-BDztezOG.js +1 -0
  65. package/dist/elements/chunks/copy-Nf-kqxXH.js +1 -0
  66. package/dist/elements/chunks/{create-tween-CUrM6D_o.js → create-tween-c4Z8TDYz.js} +2 -2
  67. package/dist/elements/chunks/default-input-C2iLFq0b.js +1 -0
  68. package/dist/elements/chunks/define-wjHR1MkU.js +1 -0
  69. package/dist/elements/chunks/disclosure-CcNbyLy0.js +1 -0
  70. package/dist/elements/chunks/{download-DnCyWFJE.js → download-C4V7tBdb.js} +1 -1
  71. package/dist/elements/chunks/dropdown-BgWTthUx.js +1 -0
  72. package/dist/elements/chunks/{ellipsis-CVxqfsDZ.js → ellipsis-7gU33mL2.js} +1 -1
  73. package/dist/elements/chunks/embed-D3KewsIJ.js +1 -0
  74. package/dist/elements/chunks/{external-link-DG2sRdQL.js → external-link-CSOEsNlx.js} +1 -1
  75. package/dist/elements/chunks/{file-text-D0J9F6M7.js → file-text-D1G8khle.js} +1 -1
  76. package/dist/elements/chunks/{file-tree-yGWNK0Js.js → file-tree-zvB2i7P9.js} +1 -1
  77. package/dist/elements/chunks/{folder-NV1pi_ud.js → folder-WW2LGLid.js} +1 -1
  78. package/dist/elements/chunks/form-CQirdC4W.js +1 -0
  79. package/dist/elements/chunks/hover-card-CJJkfHTK.js +1 -0
  80. package/dist/elements/chunks/icon-Cfgmf4fa.js +1 -0
  81. package/dist/elements/chunks/{info-C8r5Q6cc.js → info-DX3YMOt6.js} +1 -1
  82. package/dist/elements/chunks/input-BtW9U6Sq.js +1 -0
  83. package/dist/elements/chunks/{kbd-OGJDUT-O.js → kbd-LGXwuT_V.js} +1 -1
  84. package/dist/elements/chunks/{link-BdsfLB7b.js → link-2My69BqT.js} +1 -1
  85. package/dist/elements/chunks/{link-preview-Cf5KOP-i.js → link-preview-OQ9IEq0f.js} +1 -1
  86. package/dist/elements/chunks/{loader-B5QzK_8_.js → loader-iOxc_Tu-.js} +1 -1
  87. package/dist/elements/chunks/{markdown-DbozyYqa.js → markdown-DgBFaGAz.js} +1 -1
  88. package/dist/elements/chunks/{message-DtXKPqiR.js → message-VMZohrXx.js} +1 -1
  89. package/dist/elements/chunks/message-circle-CpXMM-_n.js +1 -0
  90. package/dist/elements/chunks/message-square-DxcWtETQ.js +1 -0
  91. package/dist/elements/chunks/message-tZmV2A3p.js +1 -0
  92. package/dist/elements/chunks/{minimize-2-SegJ_oku.js → minimize-2-DPIUKTrT.js} +1 -1
  93. package/dist/elements/chunks/{model-switcher-D7JrFFHp.js → model-switcher-DsT8tFB0.js} +1 -1
  94. package/dist/elements/chunks/{overlay-CV7z2YuX.js → overlay-DuiwdXjm.js} +1 -1
  95. package/dist/elements/chunks/{paperclip-Bg0ITU3b.js → paperclip-CRZVp4z_.js} +1 -1
  96. package/dist/elements/chunks/{progress-bar-D1_rzt6N.js → progress-bar-DNy66e_E.js} +1 -1
  97. package/dist/elements/chunks/{prompt-suggestion-D5Sm8xF1.js → prompt-suggestion-Bn64Nl7L.js} +1 -1
  98. package/dist/elements/chunks/reasoning-Emn9N4-T.js +1 -0
  99. package/dist/elements/chunks/{resizable-Dpzjlvp4.js → resizable-D181b4fM.js} +1 -1
  100. package/dist/elements/chunks/{rotate-cw-DzVDJOA1.js → rotate-cw-CPWe37D9.js} +1 -1
  101. package/dist/elements/chunks/{scroll-area-93A1bbeG.js → scroll-area-ClZkZxIk.js} +1 -1
  102. package/dist/elements/chunks/scroll-button-DPE-t3x_.js +1 -0
  103. package/dist/elements/chunks/{separator-DH2r_ZSZ.js → separator-CeJHQqLW.js} +1 -1
  104. package/dist/elements/chunks/{settings-BFMOd6yB.js → settings-CqaHgJF9.js} +1 -1
  105. package/dist/elements/chunks/{settings-group-B3VZKIcz.js → settings-group-CjqjLCOn.js} +1 -1
  106. package/dist/elements/chunks/share-CyfnNEqp.js +1 -0
  107. package/dist/elements/chunks/{skeleton-N8BuWYmf.js → skeleton-D8akBnKS.js} +1 -1
  108. package/dist/elements/chunks/slots-DaNjX0rc.js +1 -0
  109. package/dist/elements/chunks/{source-w9V0NnBn.js → source-UWCvmVak.js} +1 -1
  110. package/dist/elements/chunks/{star-DHvACxtm.js → star-DoHFAikQ.js} +1 -1
  111. package/dist/elements/chunks/store-Cjq0qwYN.js +1 -0
  112. package/dist/elements/chunks/{tasks-card-lPxoodlA.js → tasks-card-CmZ5xh6v.js} +1 -1
  113. package/dist/elements/chunks/{text-shimmer-BY_rI3s5.js → text-shimmer-DYn09zrz.js} +1 -1
  114. package/dist/elements/chunks/textarea-skiGHURM.js +1 -0
  115. package/dist/elements/chunks/{thumbs-up-DQe0hvkZ.js → thumbs-up-DX2Ijk3H.js} +1 -1
  116. package/dist/elements/chunks/{toast-store-VUZZBhzH.js → toast-store-CFnQpThX.js} +1 -1
  117. package/dist/elements/chunks/{tool-C1YtxwPu.js → tool-DKQ2D1DP.js} +1 -1
  118. package/dist/elements/chunks/{tooltip-BqwnFWhn.js → tooltip-B0G99y8w.js} +1 -1
  119. package/dist/elements/chunks/{triangle-alert-B6or7F5u.js → triangle-alert-F9cyUEyy.js} +1 -1
  120. package/dist/elements/chunks/{use-card-resolution-DJg32jGH.js → use-card-resolution-DoA2f_EU.js} +1 -1
  121. package/dist/elements/chunks/{variant-aurora-QXTZPyST.js → variant-aurora-qt6Zct8M.js} +2 -2
  122. package/dist/elements/chunks/variant-custom-tJp22_BQ.js +1 -0
  123. package/dist/{variant-wave-BwzrHD1s.js → elements/chunks/variant-wave-CaQ5ti39.js} +2 -2
  124. package/dist/elements/chunks/x-B6UnOiLw.js +1 -0
  125. package/dist/elements/coachmark.js +1 -1
  126. package/dist/elements/code-block.js +1 -1
  127. package/dist/elements/command.js +1 -1
  128. package/dist/elements/compare.js +1 -1
  129. package/dist/elements/composer.js +1 -1
  130. package/dist/elements/confirm-card.js +1 -1
  131. package/dist/elements/context-meter.js +1 -1
  132. package/dist/elements/conversation-item.d.ts +1 -0
  133. package/dist/elements/conversation-item.js +1 -0
  134. package/dist/elements/conversation-list.js +1 -1
  135. package/dist/elements/default-input.d.ts +3 -3
  136. package/dist/elements/define.d.ts +27 -0
  137. package/dist/elements/diagnostic-events.d.ts +74 -0
  138. package/dist/elements/dialog.js +1 -1
  139. package/dist/elements/dock.d.ts +1 -0
  140. package/dist/elements/dock.js +149 -0
  141. package/dist/elements/dropdown.d.ts +1 -0
  142. package/dist/elements/dropdown.js +1 -0
  143. package/dist/elements/editable-label.js +1 -1
  144. package/dist/elements/element-diagnostics.d.ts +81 -0
  145. package/dist/elements/embed.js +1 -1
  146. package/dist/elements/empty.js +1 -1
  147. package/dist/elements/feedback-bar.js +1 -1
  148. package/dist/elements/file-tree.js +1 -1
  149. package/dist/elements/file-upload.js +1 -1
  150. package/dist/elements/form.js +1 -1
  151. package/dist/elements/hover-card.js +1 -1
  152. package/dist/elements/icon.js +1 -1
  153. package/dist/elements/image.js +1 -1
  154. package/dist/elements/input.js +1 -1
  155. package/dist/elements/kbd.js +1 -1
  156. package/dist/elements/link-preview.js +1 -1
  157. package/dist/elements/loader.js +1 -1
  158. package/dist/elements/markdown.js +1 -1
  159. package/dist/elements/menu.js +1 -1
  160. package/dist/elements/message-skills.js +1 -1
  161. package/dist/elements/message.js +1 -1
  162. package/dist/elements/model-switcher.js +1 -1
  163. package/dist/elements/nav.js +1 -1
  164. package/dist/elements/notice.js +1 -1
  165. package/dist/elements/pane-group.js +1 -1
  166. package/dist/elements/pane.js +1 -1
  167. package/dist/elements/popover.js +1 -1
  168. package/dist/elements/progress-bar.js +1 -1
  169. package/dist/elements/prompt-dock.js +1 -1
  170. package/dist/elements/prompt-input.js +1 -1
  171. package/dist/elements/prompt-suggestions.js +1 -1
  172. package/dist/elements/reasoning.js +1 -1
  173. package/dist/elements/register.d.ts +2 -0
  174. package/dist/elements/remote.js +1 -1
  175. package/dist/elements/resizable.js +1 -1
  176. package/dist/elements/response-stream.js +3 -3
  177. package/dist/elements/screen.js +1 -1
  178. package/dist/elements/scroll-area.js +1 -1
  179. package/dist/elements/scroll-button.js +1 -1
  180. package/dist/elements/search.js +1 -1
  181. package/dist/elements/segmented.js +1 -1
  182. package/dist/elements/separator.js +1 -1
  183. package/dist/elements/setting-item.js +1 -1
  184. package/dist/elements/settings-group.js +1 -1
  185. package/dist/elements/skeleton.js +1 -1
  186. package/dist/elements/slots.d.ts +35 -3
  187. package/dist/elements/source.js +1 -1
  188. package/dist/elements/status.js +1 -1
  189. package/dist/elements/switch.js +1 -1
  190. package/dist/elements/tabs.js +1 -1
  191. package/dist/elements/tasks.js +1 -1
  192. package/dist/elements/text-shimmer.js +1 -1
  193. package/dist/elements/thinking-bar.js +1 -1
  194. package/dist/elements/thread.js +1 -1
  195. package/dist/elements/toast.js +1 -1
  196. package/dist/elements/tool.js +1 -1
  197. package/dist/elements/tooltip.js +1 -1
  198. package/dist/elements/voice-input.js +1 -1
  199. package/dist/elements/voice-output.js +1 -1
  200. package/dist/elements.d.ts +232 -148
  201. package/dist/index.d.ts +2 -0
  202. package/dist/index.js +5456 -4931
  203. package/dist/index.server.js +4142 -3669
  204. package/dist/kai-provider.es.js +40 -40
  205. package/dist/kai.es.js +1 -1
  206. package/dist/llms/llms-full.txt +184 -69
  207. package/dist/llms/llms.txt +3 -3
  208. package/dist/mcp.es.js +2669 -546
  209. package/dist/primitives/use-resize-observer.d.ts +23 -0
  210. package/dist/primitives/use-speech-recognition.d.ts +8 -0
  211. package/dist/primitives/use-text-stream.d.ts +5 -0
  212. package/dist/react/index.d.ts +115 -266
  213. package/dist/react.js +247 -221
  214. package/dist/register-impl-DpE7icIb.js +293 -0
  215. package/dist/schemas/index.d.ts +1 -1
  216. package/dist/schemas/tool-defs.d.ts +35 -0
  217. package/dist/schemas.js +403 -310
  218. package/dist/{solid-BMuDmFo_.js → solid-BtdqNZI7.js} +8435 -7394
  219. package/dist/{solid-CGYNdLRR.js → solid-CnavwBye.js} +5987 -5035
  220. package/dist/solid.d.ts +5 -0
  221. package/dist/solid.js +210 -203
  222. package/dist/solid.server.js +210 -203
  223. package/dist/state/index.d.ts +4 -0
  224. package/dist/state/persistence.d.ts +51 -0
  225. package/dist/state/stream.d.ts +50 -0
  226. package/dist/state/threads.d.ts +71 -0
  227. package/dist/state.js +269 -169
  228. package/dist/types.d.ts +4 -2
  229. package/dist/ui/dock.d.ts +109 -0
  230. package/dist/ui/hover-card.d.ts +44 -0
  231. package/dist/{variant-aurora-DwSOfraQ.js → variant-aurora-B8II3-I1.js} +27 -27
  232. package/dist/{variant-aurora-wKvAQ5wp.js → variant-aurora-CMNWOSJR.js} +28 -28
  233. package/dist/{variant-aurora-XEkPT0_z.js → variant-aurora-CcuDMWwQ.js} +2 -2
  234. package/dist/{variant-custom-eFIg2XWQ.js → variant-custom-BdvYC7af.js} +40 -40
  235. package/dist/{variant-custom-BhS9wIpU.js → variant-custom-C714mqKl.js} +65 -65
  236. package/dist/variant-custom-L16JLLZy.js +1 -0
  237. package/dist/{elements/chunks/variant-wave-DDCwhLas.js → variant-wave-2S4La2Ka.js} +2 -2
  238. package/dist/{variant-wave-CbVsmgwT.js → variant-wave-D7ruiA1W.js} +25 -25
  239. package/dist/{variant-wave-CUWGx9Bn.js → variant-wave-ecwykeyL.js} +21 -21
  240. package/dist/wire/chunk.d.ts +47 -0
  241. package/dist/wire/diagnostics.d.ts +575 -0
  242. package/dist/wire/encode-probe.d.ts +70 -0
  243. package/dist/wire/encode.d.ts +24 -0
  244. package/dist/wire/index.d.ts +2 -0
  245. package/dist/wire/sse.d.ts +10 -2
  246. package/dist/wire.js +917 -480
  247. package/frameworks/react/index.tsx +132 -93
  248. package/llms-full.txt +184 -69
  249. package/llms.txt +3 -3
  250. package/package.json +26 -6
  251. package/src/agent-tooling/archetypes.ts +41 -9
  252. package/src/agent-tooling/catalog/README.md +676 -0
  253. package/src/agent-tooling/catalog/catalog-types.ts +231 -0
  254. package/src/agent-tooling/catalog/fabrications.ts +96 -0
  255. package/src/agent-tooling/catalog/invariants.ts +284 -0
  256. package/src/agent-tooling/catalog/labs-titles.ts +114 -0
  257. package/src/agent-tooling/catalog/scenarios.ts +87 -0
  258. package/src/agent-tooling/catalog/surfaces.ts +275 -0
  259. package/src/agent-tooling/integrations/anthropic.ts +7 -1
  260. package/src/agent-tooling/integrations/cloudflare.ts +43 -2
  261. package/src/agent-tooling/integrations/langgraph.ts +7 -1
  262. package/src/agent-tooling/integrations/mastra.ts +7 -1
  263. package/src/agent-tooling/integrations/mock.ts +92 -17
  264. package/src/agent-tooling/integrations/ollama.ts +7 -1
  265. package/src/agent-tooling/integrations/openai.ts +7 -1
  266. package/src/agent-tooling/integrations/openrouter.ts +7 -1
  267. package/src/agent-tooling/integrations/pi.ts +56 -1
  268. package/src/agent-tooling/integrations/vercel-ai-sdk.ts +7 -1
  269. package/src/agent-tooling/mcp/css-raw.d.ts +12 -0
  270. package/src/agent-tooling/mcp/manifest.ts +100 -1
  271. package/src/agent-tooling/mcp/server.ts +61 -5
  272. package/src/agent-tooling/mcp/tools/reference.ts +440 -4
  273. package/src/agent-tooling/mcp/tools/scaffold.ts +1834 -307
  274. package/src/agent-tooling/mcp/tools/theme.ts +146 -30
  275. package/src/agent-tooling/mcp/validate-args.ts +141 -0
  276. package/src/agent-tooling/registry.ts +19 -7
  277. package/src/agent-tooling/route-emit.ts +26 -2
  278. package/src/components/attachment-types.ts +47 -1
  279. package/src/components/attachments.tsx +169 -22
  280. package/src/components/audio-visualizer/fit-scale.ts +112 -0
  281. package/src/components/audio-visualizer/index.tsx +80 -3
  282. package/src/components/audio-visualizer/variant-aurora.tsx +10 -4
  283. package/src/components/audio-visualizer/variant-bar.tsx +38 -6
  284. package/src/components/audio-visualizer/variant-custom.tsx +8 -2
  285. package/src/components/audio-visualizer/variant-grid.tsx +16 -11
  286. package/src/components/audio-visualizer/variant-radial.tsx +15 -10
  287. package/src/components/audio-visualizer/variant-wave.tsx +7 -2
  288. package/src/components/chat-container.tsx +4 -1
  289. package/src/components/chat-thread.tsx +36 -7
  290. package/src/components/code-block.tsx +79 -3
  291. package/src/components/composer.tsx +8 -1
  292. package/src/components/conversation-item.tsx +114 -4
  293. package/src/components/conversation-list.tsx +212 -12
  294. package/src/components/embed.tsx +4 -0
  295. package/src/components/form.tsx +41 -14
  296. package/src/components/message.tsx +75 -5
  297. package/src/components/response-stream.tsx +13 -7
  298. package/src/components/screen.tsx +5 -0
  299. package/src/components/scroll-button.tsx +7 -0
  300. package/src/components/toast.tsx +11 -5
  301. package/src/components/voice-input.tsx +35 -3
  302. package/src/components/voice-output.tsx +46 -6
  303. package/src/components/workspace-shell.tsx +285 -0
  304. package/src/diagnostics/hook.ts +352 -0
  305. package/src/diagnostics/index.ts +82 -0
  306. package/src/diagnostics/report-request.ts +296 -0
  307. package/src/elements/attachments.tsx +6 -7
  308. package/src/elements/audio-visualizer.tsx +48 -4
  309. package/src/elements/chat-workspace.tsx +159 -287
  310. package/src/elements/chat.tsx +12 -9
  311. package/src/elements/code-block.tsx +19 -1
  312. package/src/elements/command.tsx +4 -1
  313. package/src/elements/compiled.css +1 -1
  314. package/src/elements/conversation-item.tsx +80 -0
  315. package/src/elements/conversation-list.tsx +60 -11
  316. package/src/elements/default-input.tsx +5 -5
  317. package/src/elements/define.tsx +168 -8
  318. package/src/elements/diagnostic-events.ts +114 -0
  319. package/src/elements/dialog.tsx +47 -0
  320. package/src/elements/disclosure.ts +34 -12
  321. package/src/elements/dock.tsx +204 -0
  322. package/src/elements/dropdown.tsx +123 -0
  323. package/src/elements/element-diagnostics.ts +392 -0
  324. package/src/elements/element-manifest.json +12 -0
  325. package/src/elements/element-meta.json +672 -439
  326. package/src/elements/element-nonscalar.json +170 -0
  327. package/src/elements/element-types.d.ts +232 -148
  328. package/src/elements/menu.tsx +13 -0
  329. package/src/elements/prompt-dock.tsx +2 -1
  330. package/src/elements/prompt-input.tsx +8 -8
  331. package/src/elements/register-impl.ts +46 -0
  332. package/src/elements/register.ts +28 -0
  333. package/src/elements/resizable.tsx +52 -25
  334. package/src/elements/scroll-button.tsx +5 -0
  335. package/src/elements/slots.ts +107 -12
  336. package/src/elements/styles.css +30 -6
  337. package/src/elements/thinking-bar.tsx +16 -7
  338. package/src/elements/toast.tsx +3 -0
  339. package/src/elements/tool.tsx +26 -5
  340. package/src/elements/voice-input.tsx +11 -2
  341. package/src/elements/voice-output.tsx +16 -3
  342. package/src/index.ts +2 -0
  343. package/src/primitives/use-resize-observer.ts +33 -0
  344. package/src/primitives/use-speech-recognition.ts +12 -1
  345. package/src/primitives/use-stick-to-bottom.ts +34 -2
  346. package/src/primitives/use-text-stream.ts +33 -2
  347. package/src/remote/provider-runtime.ts +6 -3
  348. package/src/schemas/index.ts +1 -0
  349. package/src/schemas/tool-defs.ts +297 -6
  350. package/src/solid.ts +5 -0
  351. package/src/state/index.ts +12 -0
  352. package/src/state/persistence.ts +180 -0
  353. package/src/state/stream.ts +71 -4
  354. package/src/state/threads.ts +142 -0
  355. package/src/types.ts +4 -2
  356. package/src/ui/dialog.tsx +6 -0
  357. package/src/ui/dock.tsx +445 -0
  358. package/src/ui/dropdown.tsx +67 -23
  359. package/src/ui/hover-card.tsx +156 -6
  360. package/src/ui/input.tsx +29 -10
  361. package/src/ui/resizable.tsx +11 -0
  362. package/src/wire/chunk.ts +47 -0
  363. package/src/wire/consume.ts +185 -26
  364. package/src/wire/diagnostics.ts +727 -0
  365. package/src/wire/encode-probe.ts +214 -0
  366. package/src/wire/encode.ts +267 -14
  367. package/src/wire/formats/anthropic.ts +8 -1
  368. package/src/wire/formats/openai.ts +5 -0
  369. package/src/wire/index.ts +22 -0
  370. package/src/wire/read.ts +250 -6
  371. package/src/wire/sse.ts +16 -3
  372. package/dist/elements/chunks/action-icons-DW9muWrY.js +0 -1
  373. package/dist/elements/chunks/arrow-left-iJVnQmxA.js +0 -1
  374. package/dist/elements/chunks/attachments-CL6TaqIb.js +0 -1
  375. package/dist/elements/chunks/audio-visualizer-BuUqQmaO.js +0 -1
  376. package/dist/elements/chunks/chat-thread-DxoKaStk.js +0 -1
  377. package/dist/elements/chunks/check-MdLzbxrm.js +0 -1
  378. package/dist/elements/chunks/chevron-down-MK7IzDqe.js +0 -1
  379. package/dist/elements/chunks/chevron-right-C5jXhmtV.js +0 -1
  380. package/dist/elements/chunks/circle-BG7VkPep.js +0 -1
  381. package/dist/elements/chunks/circle-check-GqvO2UBs.js +0 -1
  382. package/dist/elements/chunks/code-block-ClhQO9RW.js +0 -1
  383. package/dist/elements/chunks/collapsible-DBcBkFPh.js +0 -1
  384. package/dist/elements/chunks/context-hKjNqiyQ.js +0 -1
  385. package/dist/elements/chunks/conversation-list-CsGJjSKG.js +0 -1
  386. package/dist/elements/chunks/default-input-B1Mem5wu.js +0 -1
  387. package/dist/elements/chunks/define-B_tXMYRm.js +0 -1
  388. package/dist/elements/chunks/disclosure-DAoYOKew.js +0 -1
  389. package/dist/elements/chunks/dropdown-CmNna5lT.js +0 -1
  390. package/dist/elements/chunks/embed-CjAD2jSi.js +0 -1
  391. package/dist/elements/chunks/form-Cp8Y00Wn.js +0 -1
  392. package/dist/elements/chunks/hover-card-6KtXpLwT.js +0 -1
  393. package/dist/elements/chunks/icon-97R8SR2p.js +0 -1
  394. package/dist/elements/chunks/input-DrseEjhp.js +0 -1
  395. package/dist/elements/chunks/message-CE4k-qaj.js +0 -1
  396. package/dist/elements/chunks/message-square-C9ovmtOo.js +0 -1
  397. package/dist/elements/chunks/reasoning-BrxQfpfI.js +0 -1
  398. package/dist/elements/chunks/scroll-button-CrTo7wm4.js +0 -1
  399. package/dist/elements/chunks/share-DgNbkJKY.js +0 -1
  400. package/dist/elements/chunks/slots-CIw9RlAe.js +0 -1
  401. package/dist/elements/chunks/store-HIPGhNz9.js +0 -1
  402. package/dist/elements/chunks/textarea-DPfuhswM.js +0 -1
  403. package/dist/elements/chunks/variant-custom-D5ZlmziF.js +0 -1
  404. package/dist/elements/chunks/video-Bb86fVYI.js +0 -1
  405. package/dist/elements/chunks/x-BOhjBeOd.js +0 -1
  406. package/dist/primitives/card-validate-generator.testlib.d.ts +0 -20
  407. package/dist/register-impl-DGmGzrEM.js +0 -145
  408. package/dist/variant-custom-MkTNTycd.js +0 -1
  409. package/src/primitives/card-validate-generator.testlib.ts +0 -38
package/dist/mcp.es.js CHANGED
@@ -1,79 +1,80 @@
1
1
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
4
+ import { createRequire } from "node:module";
4
5
  import { z as z$1 } from "zod";
5
- import { readFileSync, existsSync } from "node:fs";
6
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
6
7
  import { fileURLToPath } from "node:url";
7
- import { join, resolve, dirname } from "node:path";
8
- var R = Object.defineProperty;
9
- var P = (e, t, i) => t in e ? R(e, t, { enumerable: true, configurable: true, writable: true, value: i }) : e[t] = i;
10
- var w = (e, t, i) => P(e, typeof t != "symbol" ? t + "" : t, i);
11
- const N = "https://json-schema.org/draft/2020-12/schema", _ = "https://kitn.ai/schemas/card/artifact.schema.json", D = "ArtifactCardData", z = "Generated-artifact viewer payload: a framed preview plus the source files behind it. A deliberately NARROW subset of the Artifact component's props — it says WHAT to show, never how the viewer behaves. Toolbar composition, view-state and the iframe `sandbox` are host-owned and stay off the wire, so a model cannot widen its own sandbox or hide the chrome the user needs to inspect what it built.", M = "object";
12
- const U = [{ required: ["src"] }, { required: ["files"] }], F = { src: { type: "string", format: "uri", description: "URL the preview iframe frames, and the target of the Home button. Omit for a source-only artifact (`tab: 'code'` with `files`).", "x-kai-format": "url" }, files: { type: "array", description: "Files behind the artifact: the Code tab's tree, each optionally carrying its own preview url and source.", maxItems: 500, items: { type: "object", required: ["path"], additionalProperties: false, properties: { path: { type: "string", description: "Tree label/key. Folders are built from `/`-delimited segments.", maxLength: 1024 }, url: { type: "string", format: "uri", description: "Where the preview loads this file. Defaults to `path` resolved against `src`.", "x-kai-format": "url" }, code: { type: "string", description: "Source shown in the Code tab. Omit for binary files (image/pdf), which have no code view." }, language: { type: "string", description: "Language id for syntax highlighting (e.g. 'html', 'css', 'tsx').", maxLength: 40 }, type: { type: "string", enum: ["html", "pdf", "image", "other"], description: "File kind. Drives the tree icon and whether the Code view applies; 'pdf' also routes the preview to the inline PDF viewer.", "x-kai-control": "select" }, additions: { type: "integer", minimum: 0, description: "Lines added vs the base. Rendered as a trailing '+N' stat." }, deletions: { type: "integer", minimum: 0, description: "Lines removed vs the base. Rendered as a trailing '-N' stat." }, status: { type: "string", enum: ["added", "modified", "deleted", "renamed", "untracked"], description: "Change status vs the base. Drives a trailing status letter in the conventional VCS hue.", "x-kai-control": "select" } } } }, tab: { type: "string", enum: ["preview", "code"], default: "preview", description: "Which view the card OPENS on. SEED ONLY: the user can switch freely afterwards and a revision of this card will NOT move them back, because the user's choice wins over the model's. A switch is reported back as a `state` patch.", "x-kai-control": "select" }, activeFile: { type: "string", description: "Path of the file selected in the tree when the card FIRST renders (must match a `files[].path`). Seeds the tree highlight, the Code source and the preview. SEED ONLY, like `tab`: revising this card will not move a user who has since selected a different file.", maxLength: 1024 }, displayUrl: { type: "string", description: "Friendly address shown in the path field INSTEAD of the real url, which also makes the field read-only. Use when `src` is not consumer-facing (e.g. a `data:` blob) so a clean address is shown rather than leaking it.", maxLength: 2048 }, height: { description: "Frame height. A bare number is px; a string is any CSS length. Defaults to 420px. REQUIRED CONCEPT, not decoration: the viewer is built to fill its container, and a message thread supplies no height, so the card sets one explicitly or the artifact collapses to zero and renders invisible.", oneOf: [{ type: "number", minimum: 1 }, { type: "string", maxLength: 32 }] } }, J = {
13
- $schema: N,
14
- $id: _,
15
- title: D,
16
- description: z,
17
- type: M,
8
+ import { join, dirname, resolve } from "node:path";
9
+ var z = Object.defineProperty;
10
+ var M = (e, t, i) => t in e ? z(e, t, { enumerable: true, configurable: true, writable: true, value: i }) : e[t] = i;
11
+ var w = (e, t, i) => M(e, typeof t != "symbol" ? t + "" : t, i);
12
+ const U = "https://json-schema.org/draft/2020-12/schema", F = "https://kitn.ai/schemas/card/artifact.schema.json", J = "ArtifactCardData", B = "Generated-artifact viewer payload: a framed preview plus the source files behind it. A deliberately NARROW subset of the Artifact component's props — it says WHAT to show, never how the viewer behaves. Toolbar composition, view-state and the iframe `sandbox` are host-owned and stay off the wire, so a model cannot widen its own sandbox or hide the chrome the user needs to inspect what it built.", Y = "object";
13
+ const H = [{ required: ["src"] }, { required: ["files"] }], K = { src: { type: "string", format: "uri", description: "URL the preview iframe frames, and the target of the Home button. Omit for a source-only artifact (`tab: 'code'` with `files`).", "x-kai-format": "url" }, files: { type: "array", description: "Files behind the artifact: the Code tab's tree, each optionally carrying its own preview url and source.", maxItems: 500, items: { type: "object", required: ["path"], additionalProperties: false, properties: { path: { type: "string", description: "Tree label/key. Folders are built from `/`-delimited segments.", maxLength: 1024 }, url: { type: "string", format: "uri", description: "Where the preview loads this file. Defaults to `path` resolved against `src`.", "x-kai-format": "url" }, code: { type: "string", description: "Source shown in the Code tab. Omit for binary files (image/pdf), which have no code view." }, language: { type: "string", description: "Language id for syntax highlighting (e.g. 'html', 'css', 'tsx').", maxLength: 40 }, type: { type: "string", enum: ["html", "pdf", "image", "other"], description: "File kind. Drives the tree icon and whether the Code view applies; 'pdf' also routes the preview to the inline PDF viewer.", "x-kai-control": "select" }, additions: { type: "integer", minimum: 0, description: "Lines added vs the base. Rendered as a trailing '+N' stat." }, deletions: { type: "integer", minimum: 0, description: "Lines removed vs the base. Rendered as a trailing '-N' stat." }, status: { type: "string", enum: ["added", "modified", "deleted", "renamed", "untracked"], description: "Change status vs the base. Drives a trailing status letter in the conventional VCS hue.", "x-kai-control": "select" } } } }, tab: { type: "string", enum: ["preview", "code"], default: "preview", description: "Which view the card OPENS on. SEED ONLY: the user can switch freely afterwards and a revision of this card will NOT move them back, because the user's choice wins over the model's. A switch is reported back as a `state` patch.", "x-kai-control": "select" }, activeFile: { type: "string", description: "Path of the file selected in the tree when the card FIRST renders (must match a `files[].path`). Seeds the tree highlight, the Code source and the preview. SEED ONLY, like `tab`: revising this card will not move a user who has since selected a different file.", maxLength: 1024 }, displayUrl: { type: "string", description: "Friendly address shown in the path field INSTEAD of the real url, which also makes the field read-only. Use when `src` is not consumer-facing (e.g. a `data:` blob) so a clean address is shown rather than leaking it.", maxLength: 2048 }, height: { description: "Frame height. A bare number is px; a string is any CSS length. Defaults to 420px. REQUIRED CONCEPT, not decoration: the viewer is built to fill its container, and a message thread supplies no height, so the card sets one explicitly or the artifact collapses to zero and renders invisible.", oneOf: [{ type: "number", minimum: 1 }, { type: "string", maxLength: 32 }] } }, W = {
14
+ $schema: U,
15
+ $id: F,
16
+ title: J,
17
+ description: B,
18
+ type: Y,
18
19
  additionalProperties: false,
19
- anyOf: U,
20
- properties: F
21
- }, re = "https://json-schema.org/draft/2020-12/schema", ae = "https://kitn.ai/schemas/card/choice.schema.json", pe = "ChoiceCardData", de = "Data payload for a `choice` card (CardEnvelope.data when type='choice'). A single-select 'pick one of N rich options' card; activating an option emits the `action` verb.", ce = "object", ue = ["options"], le = { prompt: { type: "string", description: "Optional question/body text rendered above the options. Distinct from CardEnvelope.title (the card chrome heading)." }, submitLabel: { type: "string", description: "Label for the Submit button; default 'Submit'." }, options: { type: "array", minItems: 1, description: "The selectable options, rendered in order. Exactly one can be chosen.", items: { type: "object", required: ["id", "label"], properties: { id: { type: "string", minLength: 1, description: "Emitted as CardEvent.action. Must be unique within `options`. (The reserved id '__other__' is used by the allowOther escape.)", "x-kai-unique": true }, label: { type: "string", minLength: 1, description: "Visible option label." }, description: { type: "string", description: "Optional secondary line under the label." }, media: { type: "object", description: "Optional leading media: an image URL or a named icon.", properties: { image: { type: "string", description: "Image URL rendered as a leading thumbnail in the row." }, imageAlt: { type: "string", description: "Accessible alt text for `image` (decorative if omitted)." }, icon: { type: "string", description: "Named icon rendered as a leading glyph." } } }, meta: { type: "string", description: "Trailing freeform text (e.g. a price or badge), right-aligned in the row." }, recommended: { type: "boolean", default: false, description: "Renders a small 'Recommended' pill near the label." }, disabled: { type: "boolean", default: false, description: "Option is shown but not selectable, and skipped in keyboard nav." }, payload: { description: "Optional opaque payload echoed back in CardEvent.payload (any JSON)." } } } }, allowOther: { description: "Optional free-text escape ('none of these / specify'). `true` appends a final 'Other…' option; an object customizes its `label`/`placeholder`. Selecting it reveals a text input; the shared Submit button then emits `{ kind:'action', action:'__other__', payload:{ text } }`.", "x-kai-control": "allow-other" }, dismissible: { type: "boolean", default: false, description: "Show a close affordance that emits the `dismiss` verb. The card collapses to a re-openable 'dismissed' stub; the host decides via `onReopen` whether it comes back live or `expired`." } }, me = {
22
- $schema: re,
23
- $id: ae,
24
- title: pe,
25
- description: de,
26
- type: ce,
27
- required: ue,
28
- properties: le
29
- }, he = "https://json-schema.org/draft/2020-12/schema", ye = "https://kitn.ai/schemas/card/confirm.schema.json", fe = "ConfirmCardData", ge = "Data payload for a `confirm` card (CardEnvelope.data when type='confirm').", be = "object", $e = ["actions"], ve = { heading: { type: "string", description: "Optional in-body heading. Distinct from CardEnvelope.title (the card chrome title)." }, body: { type: "string", description: "Body text. Plain text in v1 (rendered safely; no HTML injection)." }, tone: { type: "string", enum: ["default", "warning", "danger"], default: "default", description: "Overall card tone; 'danger' adds a warning icon + accent for destructive approvals.", "x-kai-control": "tone" }, actions: { type: "array", minItems: 1, maxItems: 4, description: "The choice set. Rendered as buttons in order; max 4 keeps it a decision, not a menu.", items: { type: "object", required: ["id", "label"], properties: { id: { type: "string", minLength: 1, description: "Emitted as CardEvent.action. Must be unique within `actions`.", "x-kai-unique": true }, label: { type: "string", minLength: 1, description: "Visible button label." }, style: { type: "string", enum: ["primary", "default", "destructive"], default: "default", description: "Button emphasis. 'destructive' = red/danger; 'primary' = filled accent.", "x-kai-control": "button-style" }, payload: { description: "Optional opaque payload echoed back in CardEvent.payload (any JSON)." }, default: { type: "boolean", default: false, description: "If true, this action is the keyboard default (Enter) and gets initial focus. At most one should be true; the card uses the first if several are.", "x-kai-default-action": true } } } }, dismissible: { type: "boolean", default: false, description: "Show a close affordance that emits the `dismiss` verb." } }, we = {
30
- $schema: he,
31
- $id: ye,
32
- title: fe,
33
- description: ge,
34
- type: be,
35
- required: $e,
36
- properties: ve
37
- }, xe = "https://json-schema.org/draft/2020-12/schema", ke = "https://kitn.ai/schemas/card/embed.schema.json", Oe = "EmbedCardData", je = "Lazy media-embed payload (YouTube / Vimeo / generic player URL).", Se = "object", Ae = ["provider"], Ie = false, qe = { provider: { type: "string", enum: ["youtube", "vimeo", "generic"], description: "Media provider. 'generic' frames `url` directly (must be an https embeddable player URL whose origin is app-allowlisted).", "x-kai-control": "select" }, id: { type: "string", description: "Provider video id (required for youtube/vimeo when `url` is absent). e.g. 'dQw4w9WgXcQ'.", maxLength: 64, pattern: "^[A-Za-z0-9_-]+$" }, url: { type: "string", format: "uri", description: "Full media/watch URL. For youtube/vimeo it is parsed to an id; for 'generic' it is the embeddable player src (https only).", "x-kai-format": "url" }, title: { type: "string", description: "Accessible title for the player iframe + the poster label. Strongly recommended for a11y.", maxLength: 300 }, poster: { type: "string", format: "uri", description: "Thumbnail shown before play. When omitted, youtube/vimeo derive a default thumbnail; 'generic' shows a neutral play placeholder.", "x-kai-format": "url" }, start: { type: "integer", minimum: 0, description: "Optional start offset in seconds.", "x-kai-unit": "seconds" }, aspectRatio: { type: "string", enum: ["16:9", "4:3", "1:1", "9:16"], default: "16:9", description: "Player box aspect ratio (CSS aspect-ratio).", "x-kai-control": "select" } }, Le = [{ if: { properties: { provider: { const: "generic" } } }, then: { required: ["url"] } }, { if: { properties: { provider: { enum: ["youtube", "vimeo"] } } }, then: { anyOf: [{ required: ["id"] }, { required: ["url"] }] } }], Ce = {
38
- $schema: xe,
39
- $id: ke,
40
- title: Oe,
41
- description: je,
42
- type: Se,
43
- required: Ae,
44
- additionalProperties: Ie,
45
- properties: qe,
46
- allOf: Le
47
- }, De = "https://json-schema.org/draft/2020-12/schema", ze = "https://kitn.ai/schemas/card/form.schema.json", Me = "kai-form data (a form definition)", Ue = "The CardEnvelope.data for a card of type 'form': a JSON Schema (type:'object') describing the fields kai-form renders, plus optional x-kai-* UI hints. The same schema validates the submission.", Fe = "object", Je = ["type", "properties"], Ye = { type: { const: "object" }, title: { type: "string" }, description: { type: "string" }, required: { type: "array", items: { type: "string" } }, properties: { type: "object" }, "x-kai-order": { type: "array", items: { type: "string" } }, "x-kai-inlineMax": { type: "integer", minimum: 1 }, "x-kai-submitLabel": { type: "string" }, "x-kai-dismissible": { type: "boolean" }, "x-kai-actions": { type: "array", items: { type: "object", required: ["id", "label"], properties: { id: { type: "string" }, label: { type: "string" }, variant: { enum: ["default", "ghost", "outline"] } } } } }, Be = {
48
- $schema: De,
49
- $id: ze,
50
- title: Me,
51
- description: Ue,
52
- type: Fe,
53
- required: Je,
54
- properties: Ye
55
- }, He = "https://json-schema.org/draft/2020-12/schema", Ke = "https://kitn.ai/schemas/card/link.schema.json", We = "LinkPreviewData", Ve = "Rich link / Open-Graph preview payload. The card renders from this; it never fetches.", Ge = "object", Qe = ["url"], Xe = false, Ze = { url: { type: "string", format: "uri", description: "Canonical destination. Opened via the contract `open` verb (target 'tab').", "x-kai-format": "url" }, title: { type: "string", description: "OG title (og:title). Falls back to the domain when absent.", maxLength: 300 }, description: { type: "string", description: "OG description (og:description). Clamped to 3 lines in the UI.", maxLength: 1e3 }, image: { type: "string", format: "uri", description: "Preview image (og:image). Optional; the card degrades gracefully when missing or it fails to load.", "x-kai-format": "url" }, imageAlt: { type: "string", description: "Alt text for the preview image. Defaults to the title (or empty = decorative) when omitted.", maxLength: 300 }, favicon: { type: "string", format: "uri", description: "Site favicon shown next to the domain.", "x-kai-format": "url" }, domain: { type: "string", description: "Display domain (e.g. 'example.com'). Derived from `url` when omitted.", maxLength: 253 }, siteName: { type: "string", description: "OG site name (og:site_name). Shown in place of the domain when present.", maxLength: 200 } }, et = {
56
- $schema: He,
57
- $id: Ke,
58
- title: We,
59
- description: Ve,
60
- type: Ge,
61
- required: Qe,
62
- additionalProperties: Xe,
63
- properties: Ze
64
- }, dt = "https://json-schema.org/draft/2020-12/schema", ct = "https://kitn.ai/schemas/card/tasks.schema.json", ut = "TasksCardData", lt = "Data payload for a `tasks` card (CardEnvelope.data when type='tasks').", mt = "object", ht = ["tasks"], yt = { mode: { type: "string", enum: ["select", "progress"], default: "select", description: "'select' (default) = toggle + a confirm button that emits `submit`. 'progress' = an onboarding-checklist look (header `done / total` count, circular indicators, per-item title/description, no confirm button, where checking a row IS the action). Both share the selection model.", "x-kai-mode": true }, heading: { type: "string", description: "Optional in-body heading; distinct from CardEnvelope.title." }, tasks: { type: "array", minItems: 1, description: "The selectable rows, rendered in order.", items: { type: "object", required: ["id", "label"], properties: { id: { type: "string", minLength: 1, description: "Stable id; the selected ids are returned in the result. Unique within `tasks`.", "x-kai-unique": true }, label: { type: "string", minLength: 1, description: "Row label." }, description: { type: "string", description: "Optional secondary line under the label." }, checked: { type: "boolean", default: false, description: "Initial checked state of the row." }, disabled: { type: "boolean", default: false, description: "Row is shown but not toggleable (and excluded from select-all)." } } } }, selectAll: { type: "boolean", default: false, description: "Render a master select-all checkbox above the list.", "x-kai-control": "select-all" }, confirmLabel: { type: "string", default: "Confirm", description: "Label for the confirm button." }, allowEmpty: { type: "boolean", default: false, description: "If true, confirm is enabled with zero selected (emits { selected: [] }). If false, confirm is disabled until >=1 selected." }, min: { type: "integer", minimum: 0, description: "Optional minimum number that must be selected to confirm.", "x-kai-select-min": true }, max: { type: "integer", minimum: 1, description: "Optional maximum selectable; further toggles are blocked once reached.", "x-kai-select-max": true }, dismissible: { type: "boolean", default: false, description: "Show a close affordance that emits the `dismiss` verb. The card collapses to a re-openable 'dismissed' stub; the host decides via `onReopen` whether it comes back live or `expired`." } }, ft = {
65
- $schema: dt,
66
- $id: ct,
67
- title: ut,
68
- description: lt,
69
- type: mt,
70
- required: ht,
71
- properties: yt
20
+ anyOf: H,
21
+ properties: K
22
+ }, ue = "https://json-schema.org/draft/2020-12/schema", le = "https://kitn.ai/schemas/card/choice.schema.json", me = "ChoiceCardData", he = "Data payload for a `choice` card (CardEnvelope.data when type='choice'). A single-select 'pick one of N rich options' card; activating an option emits the `action` verb.", fe = "object", ye = ["options"], ge = { prompt: { type: "string", description: "Optional question/body text rendered above the options. Distinct from CardEnvelope.title (the card chrome heading)." }, submitLabel: { type: "string", description: "Label for the Submit button; default 'Submit'." }, options: { type: "array", minItems: 1, description: "The selectable options, rendered in order. Exactly one can be chosen.", items: { type: "object", required: ["id", "label"], properties: { id: { type: "string", minLength: 1, description: "Emitted as CardEvent.action. Must be unique within `options`. (The reserved id '__other__' is used by the allowOther escape.)", "x-kai-unique": true }, label: { type: "string", minLength: 1, description: "Visible option label." }, description: { type: "string", description: "Optional secondary line under the label." }, media: { type: "object", description: "Optional leading media: an image URL or a named icon.", properties: { image: { type: "string", description: "Image URL rendered as a leading thumbnail in the row." }, imageAlt: { type: "string", description: "Accessible alt text for `image` (decorative if omitted)." }, icon: { type: "string", description: "Named icon rendered as a leading glyph." } } }, meta: { type: "string", description: "Trailing freeform text (e.g. a price or badge), right-aligned in the row." }, recommended: { type: "boolean", default: false, description: "Renders a small 'Recommended' pill near the label." }, disabled: { type: "boolean", default: false, description: "Option is shown but not selectable, and skipped in keyboard nav." }, payload: { description: "Optional opaque payload echoed back in CardEvent.payload (any JSON)." } } } }, allowOther: { description: "Optional free-text escape ('none of these / specify'). `true` appends a final 'Other…' option; an object customizes its `label`/`placeholder`. Selecting it reveals a text input; the shared Submit button then emits `{ kind:'action', action:'__other__', payload:{ text } }`.", "x-kai-control": "allow-other" }, dismissible: { type: "boolean", default: false, description: "Show a close affordance that emits the `dismiss` verb. The card collapses to a re-openable 'dismissed' stub; the host decides via `onReopen` whether it comes back live or `expired`." } }, be = {
23
+ $schema: ue,
24
+ $id: le,
25
+ title: me,
26
+ description: he,
27
+ type: fe,
28
+ required: ye,
29
+ properties: ge
30
+ }, ve = "https://json-schema.org/draft/2020-12/schema", $e = "https://kitn.ai/schemas/card/confirm.schema.json", we = "ConfirmCardData", xe = "Data payload for a `confirm` card (CardEnvelope.data when type='confirm').", ke = "object", Oe = ["actions"], je = { heading: { type: "string", description: "Optional in-body heading. Distinct from CardEnvelope.title (the card chrome title)." }, body: { type: "string", description: "Body text. Plain text in v1 (rendered safely; no HTML injection)." }, tone: { type: "string", enum: ["default", "warning", "danger"], default: "default", description: "Overall card tone; 'danger' adds a warning icon + accent for destructive approvals.", "x-kai-control": "tone" }, actions: { type: "array", minItems: 1, maxItems: 4, description: "The choice set. Rendered as buttons in order; max 4 keeps it a decision, not a menu.", items: { type: "object", required: ["id", "label"], properties: { id: { type: "string", minLength: 1, description: "Emitted as CardEvent.action. Must be unique within `actions`.", "x-kai-unique": true }, label: { type: "string", minLength: 1, description: "Visible button label." }, style: { type: "string", enum: ["primary", "default", "destructive"], default: "default", description: "Button emphasis. 'destructive' = red/danger; 'primary' = filled accent.", "x-kai-control": "button-style" }, payload: { description: "Optional opaque payload echoed back in CardEvent.payload (any JSON)." }, default: { type: "boolean", default: false, description: "If true, this action is the keyboard default (Enter) and gets initial focus. At most one should be true; the card uses the first if several are.", "x-kai-default-action": true } } } }, dismissible: { type: "boolean", default: false, description: "Show a close affordance that emits the `dismiss` verb." } }, Se = {
31
+ $schema: ve,
32
+ $id: $e,
33
+ title: we,
34
+ description: xe,
35
+ type: ke,
36
+ required: Oe,
37
+ properties: je
38
+ }, Ae = "https://json-schema.org/draft/2020-12/schema", qe = "https://kitn.ai/schemas/card/embed.schema.json", Ie = "EmbedCardData", Le = "Lazy media-embed payload (YouTube / Vimeo / generic player URL).", Ee = "object", Te = ["provider"], Ce = false, Re = { provider: { type: "string", enum: ["youtube", "vimeo", "generic"], description: "Media provider. 'generic' frames `url` directly (must be an https embeddable player URL whose origin is app-allowlisted).", "x-kai-control": "select" }, id: { type: "string", description: "Provider video id (required for youtube/vimeo when `url` is absent). e.g. 'dQw4w9WgXcQ'.", maxLength: 64, pattern: "^[A-Za-z0-9_-]+$" }, url: { type: "string", format: "uri", description: "Full media/watch URL. For youtube/vimeo it is parsed to an id; for 'generic' it is the embeddable player src (https only).", "x-kai-format": "url" }, title: { type: "string", description: "Accessible title for the player iframe + the poster label. Strongly recommended for a11y.", maxLength: 300 }, poster: { type: "string", format: "uri", description: "Thumbnail shown before play. When omitted, youtube/vimeo derive a default thumbnail; 'generic' shows a neutral play placeholder.", "x-kai-format": "url" }, start: { type: "integer", minimum: 0, description: "Optional start offset in seconds.", "x-kai-unit": "seconds" }, aspectRatio: { type: "string", enum: ["16:9", "4:3", "1:1", "9:16"], default: "16:9", description: "Player box aspect ratio (CSS aspect-ratio).", "x-kai-control": "select" } }, Ne = [{ if: { properties: { provider: { const: "generic" } } }, then: { required: ["url"] } }, { if: { properties: { provider: { enum: ["youtube", "vimeo"] } } }, then: { anyOf: [{ required: ["id"] }, { required: ["url"] }] } }], Pe = {
39
+ $schema: Ae,
40
+ $id: qe,
41
+ title: Ie,
42
+ description: Le,
43
+ type: Ee,
44
+ required: Te,
45
+ additionalProperties: Ce,
46
+ properties: Re,
47
+ allOf: Ne
48
+ }, Je = "https://json-schema.org/draft/2020-12/schema", Be = "https://kitn.ai/schemas/card/form.schema.json", Ye = "kai-form data (a form definition)", He = "The CardEnvelope.data for a card of type 'form': a JSON Schema (type:'object') describing the fields kai-form renders, plus optional x-kai-* UI hints. The same schema validates the submission.", Ke = "object", We = ["type", "properties"], Ve = { type: { const: "object" }, title: { type: "string" }, description: { type: "string" }, required: { type: "array", items: { type: "string" } }, properties: { type: "object" }, "x-kai-order": { type: "array", items: { type: "string" } }, "x-kai-inlineMax": { type: "integer", minimum: 1 }, "x-kai-submitLabel": { type: "string" }, "x-kai-dismissible": { type: "boolean" }, "x-kai-actions": { type: "array", items: { type: "object", required: ["id", "label"], properties: { id: { type: "string" }, label: { type: "string" }, variant: { enum: ["default", "ghost", "outline"] } } } } }, Ge = {
49
+ $schema: Je,
50
+ $id: Be,
51
+ title: Ye,
52
+ description: He,
53
+ type: Ke,
54
+ required: We,
55
+ properties: Ve
56
+ }, Xe = "https://json-schema.org/draft/2020-12/schema", Qe = "https://kitn.ai/schemas/card/link.schema.json", Ze = "LinkPreviewData", et = "Rich link / Open-Graph preview payload. The card renders from this; it never fetches.", tt = "object", it = ["url"], nt = false, ot = { url: { type: "string", format: "uri", description: "Canonical destination. Opened via the contract `open` verb (target 'tab').", "x-kai-format": "url" }, title: { type: "string", description: "OG title (og:title). Falls back to the domain when absent.", maxLength: 300 }, description: { type: "string", description: "OG description (og:description). Clamped to 3 lines in the UI.", maxLength: 1e3 }, image: { type: "string", format: "uri", description: "Preview image (og:image). Optional; the card degrades gracefully when missing or it fails to load.", "x-kai-format": "url" }, imageAlt: { type: "string", description: "Alt text for the preview image. Defaults to the title (or empty = decorative) when omitted.", maxLength: 300 }, favicon: { type: "string", format: "uri", description: "Site favicon shown next to the domain.", "x-kai-format": "url" }, domain: { type: "string", description: "Display domain (e.g. 'example.com'). Derived from `url` when omitted.", maxLength: 253 }, siteName: { type: "string", description: "OG site name (og:site_name). Shown in place of the domain when present.", maxLength: 200 } }, rt = {
57
+ $schema: Xe,
58
+ $id: Qe,
59
+ title: Ze,
60
+ description: et,
61
+ type: tt,
62
+ required: it,
63
+ additionalProperties: nt,
64
+ properties: ot
65
+ }, ht = "https://json-schema.org/draft/2020-12/schema", ft = "https://kitn.ai/schemas/card/tasks.schema.json", yt = "TasksCardData", gt = "Data payload for a `tasks` card (CardEnvelope.data when type='tasks').", bt = "object", vt = ["tasks"], $t = { mode: { type: "string", enum: ["select", "progress"], default: "select", description: "'select' (default) = toggle + a confirm button that emits `submit`. 'progress' = an onboarding-checklist look (header `done / total` count, circular indicators, per-item title/description, no confirm button, where checking a row IS the action). Both share the selection model.", "x-kai-mode": true }, heading: { type: "string", description: "Optional in-body heading; distinct from CardEnvelope.title." }, tasks: { type: "array", minItems: 1, description: "The selectable rows, rendered in order.", items: { type: "object", required: ["id", "label"], properties: { id: { type: "string", minLength: 1, description: "Stable id; the selected ids are returned in the result. Unique within `tasks`.", "x-kai-unique": true }, label: { type: "string", minLength: 1, description: "Row label." }, description: { type: "string", description: "Optional secondary line under the label." }, checked: { type: "boolean", default: false, description: "Initial checked state of the row." }, disabled: { type: "boolean", default: false, description: "Row is shown but not toggleable (and excluded from select-all)." } } } }, selectAll: { type: "boolean", default: false, description: "Render a master select-all checkbox above the list.", "x-kai-control": "select-all" }, confirmLabel: { type: "string", default: "Confirm", description: "Label for the confirm button." }, allowEmpty: { type: "boolean", default: false, description: "If true, confirm is enabled with zero selected (emits { selected: [] }). If false, confirm is disabled until >=1 selected." }, min: { type: "integer", minimum: 0, description: "Optional minimum number that must be selected to confirm.", "x-kai-select-min": true }, max: { type: "integer", minimum: 1, description: "Optional maximum selectable; further toggles are blocked once reached.", "x-kai-select-max": true }, dismissible: { type: "boolean", default: false, description: "Show a close affordance that emits the `dismiss` verb. The card collapses to a re-openable 'dismissed' stub; the host decides via `onReopen` whether it comes back live or `expired`." } }, wt = {
66
+ $schema: ht,
67
+ $id: ft,
68
+ title: yt,
69
+ description: gt,
70
+ type: bt,
71
+ required: vt,
72
+ properties: $t
72
73
  }, S = "kai_";
73
- function gt(e) {
74
+ function R(e) {
74
75
  return `${S}${e}`;
75
76
  }
76
- const L = (e) => Object.freeze(e), C = Object.freeze({
77
+ const N = (e) => Object.freeze(e), P = Object.freeze({
77
78
  id: "openai",
78
79
  label: "OpenAI strict",
79
80
  source: "https://developers.openai.com/api/docs/guides/structured-outputs",
@@ -82,7 +83,7 @@ const L = (e) => Object.freeze(e), C = Object.freeze({
82
83
  requireAllPropertiesRequired: true,
83
84
  allowRootAnyOf: false,
84
85
  allowRecursion: true,
85
- keywords: L({
86
+ keywords: N({
86
87
  // core
87
88
  type: { status: "supported", note: "String, Number, Boolean, Integer, Object, Array are the supported types" },
88
89
  properties: { status: "supported", note: "objects are a supported type" },
@@ -161,7 +162,7 @@ const L = (e) => Object.freeze(e), C = Object.freeze({
161
162
  dependentRequired: { status: "unsupported", note: "explicitly listed as not supported under Composition" },
162
163
  dependentSchemas: { status: "unsupported", note: "explicitly listed as not supported under Composition" }
163
164
  })
164
- }), E = Object.freeze({
165
+ }), _ = Object.freeze({
165
166
  id: "anthropic",
166
167
  label: "Anthropic strict",
167
168
  source: "https://platform.claude.com/docs/en/build-with-claude/structured-outputs#json-schema-limitations",
@@ -171,7 +172,7 @@ const L = (e) => Object.freeze(e), C = Object.freeze({
171
172
  requireAllPropertiesRequired: false,
172
173
  allowRootAnyOf: true,
173
174
  allowRecursion: false,
174
- keywords: L({
175
+ keywords: N({
175
176
  // core
176
177
  type: { status: "supported", note: "all basic types: object, array, string, integer, number, boolean, null" },
177
178
  properties: { status: "supported", note: "objects are a supported type" },
@@ -251,90 +252,90 @@ const L = (e) => Object.freeze(e), C = Object.freeze({
251
252
  dependentRequired: { status: "unsupported", note: "not in the supported list" },
252
253
  dependentSchemas: { status: "unsupported", note: "not in the supported list" }
253
254
  })
254
- }), bt = ["items", "if", "then", "else", "not", "contains", "propertyNames", "unevaluatedItems", "additionalItems"], $t = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"], vt = ["allOf", "anyOf", "oneOf", "prefixItems"], O = (e) => typeof e == "object" && e !== null && !Array.isArray(e);
255
- function wt(e, t) {
255
+ }), xt = ["items", "if", "then", "else", "not", "contains", "propertyNames", "unevaluatedItems", "additionalItems"], kt = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"], Ot = ["allOf", "anyOf", "oneOf", "prefixItems"], O = (e) => typeof e == "object" && e !== null && !Array.isArray(e);
256
+ function jt(e, t) {
256
257
  return e === "(root)" ? t : `${e}.${t}`;
257
258
  }
258
- function xt(e, t) {
259
+ function St(e, t) {
259
260
  const i = [];
260
261
  return x(e, t, "(root)", true, i), i;
261
262
  }
262
- function x(e, t, i, s, a) {
263
+ function x(e, t, i, n, r) {
263
264
  if (typeof e == "boolean") return;
264
265
  if (!O(e)) {
265
- a.push({ path: i, keyword: "(schema)", reason: `expected a schema object, got ${e === null ? "null" : typeof e}` });
266
+ r.push({ path: i, keyword: "(schema)", reason: `expected a schema object, got ${e === null ? "null" : typeof e}` });
266
267
  return;
267
268
  }
268
269
  for (const [p, c] of Object.entries(e)) {
269
- const u = t.keywords[p];
270
- if (!u) {
271
- a.push({
270
+ const l = t.keywords[p];
271
+ if (!l) {
272
+ r.push({
272
273
  path: i,
273
274
  keyword: p,
274
275
  reason: p.startsWith("x-") ? `vendor extension keyword, not in the ${t.label} subset` : `unknown keyword, not classified by the ${t.label} subset`
275
276
  });
276
277
  continue;
277
278
  }
278
- if (u.status === "unsupported") {
279
- a.push({ path: i, keyword: p, reason: `not supported (${u.note})` });
279
+ if (l.status === "unsupported") {
280
+ r.push({ path: i, keyword: p, reason: `not supported (${l.note})` });
280
281
  continue;
281
282
  }
282
- if (u.status === "supported" && u.value) {
283
- const h = u.value(c);
284
- h && a.push({ path: i, keyword: p, reason: h });
283
+ if (l.status === "supported" && l.value) {
284
+ const h = l.value(c);
285
+ h && r.push({ path: i, keyword: p, reason: h });
285
286
  }
286
287
  }
287
- const n = O(e.properties), r = e.type === "object" || n;
288
- if (r && t.requireAdditionalPropertiesFalse && e.additionalProperties !== false && a.push({
288
+ const o = O(e.properties), a = e.type === "object" || o;
289
+ if (a && t.requireAdditionalPropertiesFalse && e.additionalProperties !== false && r.push({
289
290
  path: i,
290
291
  keyword: "additionalProperties",
291
292
  reason: `missing; ${t.label} requires \`additionalProperties: false\` on every object`
292
- }), r && !n && a.push({
293
+ }), a && !o && r.push({
293
294
  path: i,
294
295
  keyword: "properties",
295
296
  reason: `a free-form object (\`type: "object"\` with no \`properties\`) has no grammar under ${t.label}; with the mandatory \`additionalProperties: false\` it can only ever be \`{}\`, which is not what the schema means`
296
- }), r && t.requireAllPropertiesRequired && n) {
297
- const p = Array.isArray(e.required) ? e.required.map(String) : [], c = Object.keys(e.properties).filter((u) => !p.includes(u));
298
- c.length > 0 && a.push({
297
+ }), a && t.requireAllPropertiesRequired && o) {
298
+ const p = Array.isArray(e.required) ? e.required.map(String) : [], c = Object.keys(e.properties).filter((l) => !p.includes(l));
299
+ c.length > 0 && r.push({
299
300
  path: i,
300
301
  keyword: "required",
301
- reason: `${t.label} requires every property in \`required\`; missing ${c.map((u) => `\`${u}\``).join(", ")} (emulate optional with a \`null\` union)`
302
+ reason: `${t.label} requires every property in \`required\`; missing ${c.map((l) => `\`${l}\``).join(", ")} (emulate optional with a \`null\` union)`
302
303
  });
303
304
  }
304
- s && !t.allowRootAnyOf && "anyOf" in e && a.push({
305
+ n && !t.allowRootAnyOf && "anyOf" in e && r.push({
305
306
  path: i,
306
307
  keyword: "anyOf",
307
308
  reason: `${t.label} forbids \`anyOf\` at the ROOT ("Root level object of a schema must be an object, and not use anyOf")`
308
309
  }), Object.keys(e).filter((p) => {
309
310
  var c;
310
311
  return ((c = t.keywords[p]) == null ? void 0 : c.status) !== "annotation";
311
- }).length === 0 && a.push({
312
+ }).length === 0 && r.push({
312
313
  path: i,
313
314
  keyword: "type",
314
315
  reason: `unconstrained subschema (annotations only); ${t.label} compiles the schema to a grammar and has no way to express "any JSON". Give it a \`type\``
315
316
  });
316
- const m = (p) => {
317
+ const u = (p) => {
317
318
  var c;
318
319
  return ((c = t.keywords[p]) == null ? void 0 : c.status) === "unsupported";
319
320
  };
320
- for (const p of $t) {
321
+ for (const p of kt) {
321
322
  const c = e[p];
322
- if (!(!O(c) || m(p)))
323
- for (const [u, h] of Object.entries(c))
324
- x(h, t, p === "properties" ? wt(i, u) : `${i}/${p}[${u}]`, false, a);
323
+ if (!(!O(c) || u(p)))
324
+ for (const [l, h] of Object.entries(c))
325
+ x(h, t, p === "properties" ? jt(i, l) : `${i}/${p}[${l}]`, false, r);
325
326
  }
326
- for (const p of bt) {
327
- if (!(p in e) || m(p)) continue;
327
+ for (const p of xt) {
328
+ if (!(p in e) || u(p)) continue;
328
329
  const c = e[p];
329
- typeof c != "boolean" && x(c, t, p === "items" ? `${i === "(root)" ? "(root)" : i}[]` : `${i}/${p}`, false, a);
330
+ typeof c != "boolean" && x(c, t, p === "items" ? `${i === "(root)" ? "(root)" : i}[]` : `${i}/${p}`, false, r);
330
331
  }
331
- O(e.additionalProperties) && x(e.additionalProperties, t, `${i}/additionalProperties`, false, a);
332
- for (const p of vt) {
332
+ O(e.additionalProperties) && x(e.additionalProperties, t, `${i}/additionalProperties`, false, r);
333
+ for (const p of Ot) {
333
334
  const c = e[p];
334
- !Array.isArray(c) || m(p) || c.forEach((u, h) => x(u, t, `${i}/${p}[${h}]`, false, a));
335
+ !Array.isArray(c) || u(p) || c.forEach((l, h) => x(l, t, `${i}/${p}[${h}]`, false, r));
335
336
  }
336
337
  }
337
- const kt = Object.freeze({
338
+ const At = Object.freeze({
338
339
  confirm: "Show the user an approval card and wait for their decision before you act. Use this whenever you are about to do something consequential, destructive or irreversible.",
339
340
  choice: "Ask the user to pick exactly one option from a list of rich choices. Use this instead of asking in prose when the options are known and finite.",
340
341
  tasks: "Show the user a checklist they can tick, either to select several items for you to act on or to track progress through a sequence of steps.",
@@ -343,125 +344,218 @@ const kt = Object.freeze({
343
344
  embed: "Embed a video player (YouTube, Vimeo, or a direct player URL) inline in the conversation.",
344
345
  artifact: "Show something you built (a page, an app, a set of files) in a framed preview with its source alongside, so the user can look at both."
345
346
  });
346
- class Ot extends Error {
347
- constructor(i, s) {
348
- super(jt(i, s));
347
+ class qt extends Error {
348
+ constructor(i, n) {
349
+ super(It(i, n));
349
350
  w(this, "name", "UnsupportedCardToolSchemaError");
350
351
  w(this, "provider");
351
352
  w(this, "source");
352
353
  w(this, "cards");
353
- this.provider = i.id, this.source = i.source, this.cards = s;
354
+ this.provider = i.id, this.source = i.source, this.cards = n;
354
355
  }
355
356
  /** Every distinct keyword named across every failing card, deduped, in order. */
356
357
  get keywords() {
357
358
  const i = /* @__PURE__ */ new Set();
358
- for (const s of this.cards) for (const a of s.violations) i.add(a.keyword);
359
+ for (const n of this.cards) for (const r of n.violations) i.add(r.keyword);
359
360
  return [...i];
360
361
  }
361
362
  }
362
- function jt(e, t) {
363
- const i = [], s = t.length === 1 ? "card type" : "card types";
364
- i.push(`${t.length} ${s} cannot be a STRICT tool definition under ${e.label}:`);
365
- for (const a of t) {
366
- i.push(""), i.push(` ${a.toolName} (card type "${a.cardType}") — ${a.violations.length} problem(s):`);
367
- for (const n of a.violations)
368
- i.push(` ${n.path}: \`${n.keyword}\` — ${n.reason}`);
363
+ function It(e, t) {
364
+ const i = [], n = t.length === 1 ? "card type" : "card types";
365
+ i.push(`${t.length} ${n} cannot be a STRICT tool definition under ${e.label}:`);
366
+ for (const r of t) {
367
+ i.push(""), i.push(` ${r.toolName} (card type "${r.cardType}") — ${r.violations.length} problem(s):`);
368
+ for (const o of r.violations)
369
+ i.push(` ${o.path}: \`${o.keyword}\` — ${o.reason}`);
369
370
  }
370
371
  return i.push(""), i.push(` subset source: ${e.source} (read ${e.checkedOn})`), i.push(
371
- " Fix: drop `strict: true`. Non-strict is the default and is the mode the kit's cards are proven in; both providers accept a loose schema there and ignore what they do not compile. Rewriting a built-in card schema to fit a strict subset would change what a valid card IS, which is a card-contract change, not a projection setting."
372
+ " Fix: drop `strict: true`. Non-strict mode projects a provider-valid schema (root combinators are relaxed with the constraint restated in the description and still enforced by card validation). Rewriting a built-in card schema to fit a strict subset would change what a valid card IS, which is a card-contract change, not a projection setting."
372
373
  ), i.join(`
373
374
  `);
374
375
  }
375
- const g = (e) => typeof e == "object" && e !== null && !Array.isArray(e);
376
- function St(e) {
376
+ const f = (e) => typeof e == "object" && e !== null && !Array.isArray(e), Lt = {
377
+ artifact: "Provide `src` (a preview URL) or `files` — at least one. An envelope with neither is rejected.",
378
+ embed: "For provider 'generic', include `url`. For 'youtube'/'vimeo', include `id` or `url`."
379
+ }, Et = "The root constraint of this schema was relaxed for this provider; consult the card registry for what a valid payload is.", Tt = ["anyOf", "allOf", "oneOf", "not", "enum", "const"];
380
+ function T(e) {
381
+ if (!f(e)) return null;
382
+ const t = {};
383
+ for (const [i, n] of Object.entries(e)) t[i] = { const: n };
384
+ return { type: "object", properties: t, required: Object.keys(e) };
385
+ }
386
+ function Ct(e, t) {
387
+ return e === "anyOf" || e === "oneOf" || e === "allOf" ? Array.isArray(t) ? t.filter(f) : [] : e === "const" ? [T(t)].filter((i) => i !== null) : e === "enum" ? Array.isArray(t) ? t.map(T).filter((i) => i !== null) : [] : [];
388
+ }
389
+ function Rt(e, t) {
390
+ const i = f(e.properties) ? { ...e.properties } : {}, n = new Set(
391
+ Array.isArray(e.required) ? e.required.map(String) : []
392
+ );
393
+ for (const o of t) {
394
+ const a = Ct(o, e[o]);
395
+ if (a.length === 0) continue;
396
+ for (const u of a)
397
+ if (f(u.properties))
398
+ for (const [p, c] of Object.entries(u.properties))
399
+ p in i || (i[p] = c);
400
+ const s = a.map(
401
+ (u) => new Set(Array.isArray(u.required) ? u.required.map(String) : [])
402
+ );
403
+ if (o === "allOf")
404
+ for (const u of s) for (const p of u) n.add(p);
405
+ else {
406
+ const [u, ...p] = s;
407
+ for (const c of u)
408
+ p.every((l) => l.has(c)) && n.add(c);
409
+ }
410
+ }
411
+ Object.keys(i).length > 0 && (e.properties = i);
412
+ const r = [...n].filter((o) => o in i);
413
+ r.length > 0 && (e.required = r);
414
+ }
415
+ function Nt(e, t) {
416
+ const i = e.type;
417
+ if (i !== void 0 && i !== "object")
418
+ throw new TypeError(
419
+ `cardTools: the '${t}' card schema declares root type '${String(i)}', but a provider tool schema root must be an object (OpenAI and Anthropic reject anything else with HTTP 400). Wrap the payload in an object, or project this card with provider 'jsonschema'.`
420
+ );
421
+ e.type = "object";
422
+ }
423
+ function Pt(e, t) {
424
+ const i = Lt[t], n = Tt.filter((r) => r in e);
425
+ if (Nt(e, t), n.length !== 0) {
426
+ Rt(e, n);
427
+ for (const r of n) delete e[r];
428
+ return console.warn(
429
+ `[kai-card-tools] relaxed root ${n.join("/")} on the projected '${R(t)}' tool schema for this provider (it would be refused with HTTP 400); the constraint now lives in the description and is still enforced by card validation.`
430
+ ), i ?? Et;
431
+ }
432
+ }
433
+ function _t(e, t, i) {
434
+ for (const n of i) {
435
+ let r = e;
436
+ if (n.path !== "")
437
+ for (const a of n.path.split(".")) {
438
+ const s = f(r.properties) ? r.properties : void 0, u = s == null ? void 0 : s[a];
439
+ if (!f(u))
440
+ throw new TypeError(
441
+ `cardTools: require on '${t}': path '${n.path}' does not resolve in the schema`
442
+ );
443
+ r = u;
444
+ }
445
+ const o = r.type === "array" || r.type === void 0 && "items" in r;
446
+ if (o && n.minItems !== void 0 && (r.minItems = n.minItems), !o && n.minItems !== void 0)
447
+ throw new TypeError(
448
+ `cardTools: minItems at require path '${n.path}' applies to array nodes only, but '${n.path}' in the '${t}' schema is not one`
449
+ );
450
+ if (n.required !== void 0) {
451
+ const a = o && f(r.items) ? r.items : r, s = f(a.properties) ? a.properties : void 0;
452
+ for (const p of n.required)
453
+ if (!s || !(p in s))
454
+ throw new TypeError(
455
+ `cardTools: require on '${t}' at path '${n.path}': '${p}' is not a property of that node — a required name with no property would make the tool unsatisfiable`
456
+ );
457
+ const u = Array.isArray(a.required) ? a.required.map(String) : [];
458
+ a.required = [.../* @__PURE__ */ new Set([...u, ...n.required])];
459
+ }
460
+ }
461
+ }
462
+ function Dt(e) {
377
463
  return e === "$schema" || e === "$id" || e.startsWith("x-");
378
464
  }
379
- const At = ["items", "if", "then", "else", "not", "contains", "propertyNames", "unevaluatedItems", "additionalItems"], It = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"], qt = ["allOf", "anyOf", "oneOf", "prefixItems"];
465
+ const zt = ["items", "if", "then", "else", "not", "contains", "propertyNames", "unevaluatedItems", "additionalItems"], Mt = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"], Ut = ["allOf", "anyOf", "oneOf", "prefixItems"];
380
466
  function k(e, t) {
381
- if (!g(e)) return e;
467
+ if (!f(e)) return e;
382
468
  const i = {};
383
- for (const [n, r] of Object.entries(e))
384
- St(n) || (i[n] = r);
385
- for (const n of It) {
386
- const r = i[n];
387
- if (!g(r)) continue;
388
- const o = {};
389
- for (const [m, p] of Object.entries(r)) o[m] = k(p, t);
390
- i[n] = o;
391
- }
392
- for (const n of At)
393
- !(n in i) || typeof i[n] == "boolean" || (i[n] = k(i[n], t));
394
- g(i.additionalProperties) && (i.additionalProperties = k(i.additionalProperties, t));
395
- for (const n of qt) {
396
- const r = i[n];
397
- Array.isArray(r) && (i[n] = r.map((o) => k(o, t)));
398
- }
399
- const s = g(i.properties) ? i.properties : void 0, a = i.type === "object" || s !== void 0;
400
- if (a && t.closeObjects && !("additionalProperties" in i) && (i.additionalProperties = false), a && t.requireEveryProperty && s) {
401
- const n = Array.isArray(i.required) ? i.required.map(String) : [], r = Object.keys(s);
402
- for (const o of r)
403
- n.includes(o) || (s[o] = Lt(s[o]));
404
- i.required = r;
469
+ for (const [o, a] of Object.entries(e))
470
+ Dt(o) || (i[o] = a);
471
+ for (const o of Mt) {
472
+ const a = i[o];
473
+ if (!f(a)) continue;
474
+ const s = {};
475
+ for (const [u, p] of Object.entries(a)) s[u] = k(p, t);
476
+ i[o] = s;
477
+ }
478
+ for (const o of zt)
479
+ !(o in i) || typeof i[o] == "boolean" || (i[o] = k(i[o], t));
480
+ f(i.additionalProperties) && (i.additionalProperties = k(i.additionalProperties, t));
481
+ for (const o of Ut) {
482
+ const a = i[o];
483
+ Array.isArray(a) && (i[o] = a.map((s) => k(s, t)));
484
+ }
485
+ const n = f(i.properties) ? i.properties : void 0, r = i.type === "object" || n !== void 0;
486
+ if (r && t.closeObjects && !("additionalProperties" in i) && (i.additionalProperties = false), r && t.requireEveryProperty && n) {
487
+ const o = Array.isArray(i.required) ? i.required.map(String) : [], a = Object.keys(n);
488
+ for (const s of a)
489
+ o.includes(s) || (n[s] = Ft(n[s]));
490
+ i.required = a;
405
491
  }
406
492
  return i;
407
493
  }
408
- function Lt(e) {
409
- if (!g(e)) return e;
494
+ function Ft(e) {
495
+ if (!f(e)) return e;
410
496
  const t = { ...e };
411
497
  if ("type" in t) {
412
498
  const i = Array.isArray(t.type) ? t.type.map(String) : [String(t.type)];
413
499
  i.includes("null") || (t.type = [...i, "null"]);
414
500
  } else if (Array.isArray(t.anyOf))
415
- t.anyOf.some((i) => g(i) && i.type === "null") || (t.anyOf = [...t.anyOf, { type: "null" }]);
501
+ t.anyOf.some((i) => f(i) && i.type === "null") || (t.anyOf = [...t.anyOf, { type: "null" }]);
416
502
  else
417
503
  return t;
418
504
  return Array.isArray(t.enum) && !t.enum.includes(null) && (t.enum = [...t.enum, null]), t;
419
505
  }
420
- function Ct(e) {
506
+ function Jt(e) {
421
507
  const t = e;
422
- return g(t.schemas) ? { schemas: t.schemas, descriptions: t.descriptions ?? {} } : { schemas: e, descriptions: {} };
508
+ return f(t.schemas) ? { schemas: t.schemas, descriptions: t.descriptions ?? {} } : { schemas: e, descriptions: {} };
423
509
  }
424
- function Et(e, t, i) {
425
- const s = i[e];
426
- if (typeof s == "string" && s.length > 0) return s;
427
- const a = kt[e];
428
- if (typeof a == "string" && a.length > 0) return a;
429
- const n = t.description;
430
- return typeof n == "string" && n.length > 0 ? n : `Render a \`${e}\` card.`;
510
+ function Bt(e, t, i) {
511
+ const n = i[e];
512
+ if (typeof n == "string" && n.length > 0) return n;
513
+ const r = At[e];
514
+ if (typeof r == "string" && r.length > 0) return r;
515
+ const o = t.description;
516
+ return typeof o == "string" && o.length > 0 ? o : `Render a \`${e}\` card.`;
431
517
  }
432
518
  function v(e, t) {
433
- const i = t === void 0, s = i ? e : t;
434
- if (!g(s) || typeof s.provider != "string")
519
+ var l;
520
+ const i = t === void 0, n = i ? e : t;
521
+ if (!f(n) || typeof n.provider != "string")
435
522
  throw new TypeError('cardTools: an options object with a `provider` is required, e.g. cardTools({ provider: "openai" })');
436
- const { schemas: a, descriptions: n } = Ct(
437
- i ? Tt() : e
438
- ), r = s.strict === true;
439
- if (s.provider === "jsonschema" && r)
523
+ const { schemas: r, descriptions: o } = Jt(
524
+ i ? Yt() : e
525
+ ), a = n.strict === true;
526
+ if (n.provider === "jsonschema" && a)
440
527
  throw new TypeError(
441
528
  'cardTools: `strict: true` is provider-specific and cannot be checked for `provider: "jsonschema"`, because the two documented strict subsets differ (see @kitn.ai/ui/schemas provider-subsets). Pass provider "openai" or "anthropic" to project and check strictly, or drop `strict`.'
442
529
  );
443
- const o = r ? s.provider === "openai" ? C : E : null, m = {
444
- closeObjects: o !== null,
445
- requireEveryProperty: o !== null && o.requireAllPropertiesRequired
530
+ const s = a ? n.provider === "openai" ? P : _ : null, u = {
531
+ closeObjects: s !== null,
532
+ requireEveryProperty: s !== null && s.requireAllPropertiesRequired
446
533
  }, p = [], c = [];
447
- for (const [u, h] of Object.entries(a)) {
448
- const b = gt(u), f = Et(u, h, n), $ = k(h, m);
449
- if (o) {
450
- const d = xt($, o);
451
- if (d.length > 0) {
452
- c.push({ cardType: u, toolName: b, violations: d });
534
+ if (n.require !== void 0) {
535
+ for (const h of Object.keys(n.require))
536
+ if (!(h in r))
537
+ throw new TypeError(
538
+ `cardTools: require names card type '${h}', which this call does not carry (carries: ${Object.keys(r).join(", ") || "none"})`
539
+ );
540
+ }
541
+ for (const [h, $] of Object.entries(r)) {
542
+ const g = R(h), b = Bt(h, $, o), d = k($, u), m = s === null && n.provider !== "jsonschema" ? Pt(d, h) : void 0, I = m ? `${b} ${m}` : b, L = (l = n.require) == null ? void 0 : l[h];
543
+ if (L !== void 0 && _t(d, h, L), s) {
544
+ const E = St(d, s);
545
+ if (E.length > 0) {
546
+ c.push({ cardType: h, toolName: g, violations: E });
453
547
  continue;
454
548
  }
455
549
  }
456
- s.provider === "openai" ? p.push({ type: "function", function: { name: b, description: f, parameters: $, ...r ? { strict: true } : {} } }) : s.provider === "anthropic" ? p.push({ name: b, description: f, input_schema: $, ...r ? { strict: true } : {} }) : p.push({ name: b, description: f, schema: $ });
550
+ n.provider === "openai" ? p.push({ type: "function", function: { name: g, description: I, parameters: d, ...a ? { strict: true } : {} } }) : n.provider === "anthropic" ? p.push({ name: g, description: I, input_schema: d, ...a ? { strict: true } : {} }) : p.push({ name: g, description: b, schema: d });
457
551
  }
458
- if (o && c.length > 0) throw new Ot(o, c);
552
+ if (s && c.length > 0) throw new qt(s, c);
459
553
  return p;
460
554
  }
461
- function Tt() {
555
+ function Yt() {
462
556
  return j;
463
557
  }
464
- const ei = {
558
+ const li = {
465
559
  form: "kai-form",
466
560
  confirm: "kai-confirm",
467
561
  tasks: "kai-tasks",
@@ -470,27 +564,28 @@ const ei = {
470
564
  embed: "kai-embed",
471
565
  artifact: "kai-artifact"
472
566
  }, y = (e) => e, j = Object.freeze({
473
- artifact: y(J),
474
- choice: y(me),
475
- confirm: y(we),
476
- embed: y(Ce),
477
- form: y(Be),
478
- link: y(et),
479
- tasks: y(ft)
480
- }), ii = Object.freeze(
567
+ artifact: y(W),
568
+ choice: y(be),
569
+ confirm: y(Se),
570
+ embed: y(Pe),
571
+ form: y(Ge),
572
+ link: y(rt),
573
+ tasks: y(wt)
574
+ }), hi = Object.freeze(
481
575
  Object.keys(j)
482
576
  );
483
- function Yt(e) {
577
+ function ii(e) {
484
578
  return Object.prototype.hasOwnProperty.call(j, e);
485
579
  }
486
- const PACKAGE_NAME = "@kitn.ai/ui";
580
+ const tags = { "kai-conversations": "conversation-list", "kai-conversation-item": "conversation-item", "kai-prompt-input": "prompt-input", "kai-chat": "chat", "kai-workspace": "chat-workspace", "kai-thread": "thread", "kai-thinking-bar": "thinking-bar", "kai-model-switcher": "model-switcher", "kai-attachments": "attachments", "kai-message": "message", "kai-markdown": "markdown", "kai-code-block": "code-block", "kai-reasoning": "reasoning", "kai-tool": "tool", "kai-context": "context-meter", "kai-feedback-bar": "feedback-bar", "kai-scope-picker": "chat-scope-picker", "kai-suggestions": "prompt-suggestions", "kai-file-upload": "file-upload", "kai-voice-input": "voice-input", "kai-audio-visualizer": "audio-visualizer", "kai-loader": "loader", "kai-text-shimmer": "text-shimmer", "kai-image": "image", "kai-checkpoint": "checkpoint", "kai-skills": "message-skills", "kai-source": "source", "kai-sources": "source", "kai-response-stream": "response-stream", "kai-empty": "empty", "kai-status": "status", "kai-nav": "nav", "kai-progress-bar": "progress-bar", "kai-coachmark": "coachmark", "kai-tabs": "tabs", "kai-voice-output": "voice-output", "kai-screen": "screen", "kai-chain-of-thought": "chain-of-thought", "kai-resizable": "resizable", "kai-resizable-item": "resizable", "kai-file-tree": "file-tree", "kai-artifact": "artifact", "kai-scroll-button": "scroll-button", "kai-popover": "popover", "kai-switch": "switch", "kai-button": "button", "kai-avatar": "avatar", "kai-badge": "badge", "kai-tooltip": "tooltip", "kai-notice": "notice", "kai-icon": "icon", "kai-separator": "separator", "kai-scroll-area": "scroll-area", "kai-hover-card": "hover-card", "kai-skeleton": "skeleton", "kai-toast-region": "toast", "kai-card": "card", "kai-form": "form", "kai-link-preview": "link-preview", "kai-embed": "embed", "kai-confirm": "confirm-card", "kai-tasks": "tasks", "kai-choice": "choice", "kai-cards": "cards", "kai-compare": "compare", "kai-composer": "composer", "kai-menu": "menu", "kai-dropdown": "dropdown", "kai-command": "command", "kai-prompt-dock": "prompt-dock", "kai-segmented": "segmented", "kai-settings-group": "settings-group", "kai-setting-item": "setting-item", "kai-pane": "pane", "kai-pane-group": "pane-group", "kai-agent-card": "agent-card", "kai-dialog": "dialog", "kai-dock": "dock", "kai-input": "input", "kai-search": "search", "kai-kbd": "kbd", "kai-editable-label": "editable-label" };
581
+ const PACKAGE_NAME$1 = "@kitn.ai/ui";
487
582
  const MANIFEST_FILE = "custom-elements.json";
488
583
  const SOURCE_TO_PACKAGE_ROOT = ["..", "..", ".."];
489
584
  function isThisPackage(root) {
490
585
  const manifest = join(root, "package.json");
491
586
  if (!existsSync(manifest)) return false;
492
587
  try {
493
- return JSON.parse(readFileSync(manifest, "utf-8")).name === PACKAGE_NAME;
588
+ return JSON.parse(readFileSync(manifest, "utf-8")).name === PACKAGE_NAME$1;
494
589
  } catch {
495
590
  return false;
496
591
  }
@@ -502,14 +597,14 @@ function resolveManifestPath(fromDir = dirname(fileURLToPath(import.meta.url)))
502
597
  const expected = join(packageRoot, "dist", MANIFEST_FILE);
503
598
  if (!isThisPackage(packageRoot)) {
504
599
  throw new Error(
505
- `[${PACKAGE_NAME}] Cannot locate the Custom Elements Manifest: ${packageRoot} is not the ${PACKAGE_NAME} package root, so ${expected} would not be this package's manifest even if it existed.
600
+ `[${PACKAGE_NAME$1}] Cannot locate the Custom Elements Manifest: ${packageRoot} is not the ${PACKAGE_NAME$1} package root, so ${expected} would not be this package's manifest even if it existed.
506
601
  Resolved from: ${fromDir}
507
602
  This module must live at <package>/src/agent-tooling/mcp/ (or be bundled beside ${MANIFEST_FILE} in dist/). Resolution deliberately does NOT search parent directories — finding some other checkout's manifest is worse than failing.`
508
603
  );
509
604
  }
510
605
  if (!existsSync(expected)) {
511
606
  throw new Error(
512
- `[${PACKAGE_NAME}] Missing build artifact: ${expected}
607
+ `[${PACKAGE_NAME$1}] Missing build artifact: ${expected}
513
608
  Resolved from: ${fromDir}
514
609
  The Custom Elements Manifest is generated by the build. Run \`nx build ui\` (or \`npm run build:api\` in packages/ui) and try again.
515
610
  Resolution deliberately does NOT search parent directories: binding to a neighbouring checkout's manifest would make this succeed against stale data.`
@@ -535,17 +630,51 @@ function getElement(tag) {
535
630
  function listElements() {
536
631
  return getDeclarations().filter((d) => d.tagName).map((d) => d.tagName).sort();
537
632
  }
633
+ function entryForTag(tag) {
634
+ return tags[tag];
635
+ }
636
+ function optInEntryForTag(tag) {
637
+ if (entryForTag(tag)) return void 0;
638
+ return optInEntries().get(tag);
639
+ }
640
+ const NON_ELEMENT_ENTRIES = /* @__PURE__ */ new Set(["index", "autoloader"]);
641
+ let _optInEntries;
642
+ function optInEntries() {
643
+ if (_optInEntries) return _optInEntries;
644
+ _optInEntries = /* @__PURE__ */ new Map();
645
+ const dir = join(dirname(resolveManifestPath()), "elements");
646
+ let candidates;
647
+ try {
648
+ const registered = new Set(Object.values(tags));
649
+ candidates = readdirSync(dir).filter((f2) => f2.endsWith(".js")).map((f2) => f2.slice(0, -".js".length)).filter((base) => !registered.has(base) && !NON_ELEMENT_ENTRIES.has(base));
650
+ } catch {
651
+ return _optInEntries;
652
+ }
653
+ const unclaimed = listElements().filter((t) => !entryForTag(t));
654
+ for (const base of candidates) {
655
+ let code;
656
+ try {
657
+ code = readFileSync(join(dir, `${base}.js`), "utf-8");
658
+ } catch {
659
+ continue;
660
+ }
661
+ for (const tag of unclaimed) {
662
+ if (!_optInEntries.has(tag) && code.includes(`"${tag}"`)) _optInEntries.set(tag, base);
663
+ }
664
+ }
665
+ return _optInEntries;
666
+ }
538
667
  let _cardTags;
539
668
  let _cardHosts;
540
669
  function cardTagForType(type) {
541
670
  if (!_cardTags) {
542
671
  const present = new Set(listElements());
543
- _cardTags = new Map(Object.entries(ei).filter(([, tag]) => present.has(tag)));
672
+ _cardTags = new Map(Object.entries(li).filter(([, tag]) => present.has(tag)));
544
673
  }
545
674
  return _cardTags.get(type);
546
675
  }
547
676
  function cardTypeForTag(tag) {
548
- for (const name of ii) {
677
+ for (const name of hi) {
549
678
  if (cardTagForType(name) === tag) return name;
550
679
  }
551
680
  return void 0;
@@ -560,6 +689,383 @@ function cardHostTags() {
560
689
  }
561
690
  return _cardHosts;
562
691
  }
692
+ const invariants = [
693
+ {
694
+ id: "reactivity-two-halves",
695
+ statement: "A new array reference NOTIFIES; a new object for each changed item makes the change VISIBLE. Editing an existing item needs both. Adds and removes need only the fresh array. Setting the same array back is a no-op even if an item inside it was swapped. The test pins how the KIT behaves — it will render stale unless both arrive — but nothing checks CONSUMER code, so this is a rule you apply, not a guarantee you will be warned about. Reorders follow the same rule as adds and removes; the test names reorders in its title but exercises only an add and a remove, so treat that half as reasoned rather than pinned.",
696
+ appliesTo: { tags: ["kai-chat", "kai-conversations"] },
697
+ enforcedBy: { kind: "test", paths: ["packages/ui/src/components/reactivity-contract.test.tsx"] },
698
+ status: "enforced",
699
+ diagnosis: [
700
+ {
701
+ symptom: "messages render once but never update while streaming",
702
+ cause: "the same array reference is being set back; the element is never notified"
703
+ },
704
+ {
705
+ symptom: "the list re-renders but an edited item shows stale content",
706
+ cause: "the array is new but the item object identity is unchanged; the reference-keyed <For> keeps the old row"
707
+ }
708
+ ],
709
+ examples: [
710
+ {
711
+ wrong: "chat.messages.push({ id, role: 'user', parts: [{ type: 'text', text }] });",
712
+ right: "chat.messages = [...chat.messages, { id, role: 'user', parts: [{ type: 'text', text }] }];",
713
+ note: "Mutating in place never notifies. Neither does assigning the same array reference back — the setter compares references."
714
+ },
715
+ {
716
+ wrong: "messages[last].parts.push(part);",
717
+ right: "chat.messages = messages.map((m, i) => (i === last ? { ...m, parts: [...m.parts, part] } : m));",
718
+ note: "This is the half that gets missed. A fresh array alone notifies, but the reference-keyed <For> keeps the old row until the EDITED ITEM is a new object too. createAssistantStream from @kitn.ai/ui/state already does both."
719
+ }
720
+ ]
721
+ },
722
+ {
723
+ id: "props-not-attributes",
724
+ statement: "Set arrays, objects and functions as JS PROPERTIES on the element instance. Only scalars (strings, numbers, booleans) belong in attributes, and the derived layer's scalar flag records which prop is which. What actually goes wrong, because the mechanism is not the obvious one: a framework template binding or String() stringifies an array to '[object Object]', which is not JSON, and the attribute path falls back to handing the element that raw STRING — so the prop is silently a string and the list renders nothing. A function cannot survive JSON at all: JSON.stringify({ onSubmit }) is '{}', so every callback is dropped. And an attribute re-set is not how updates are delivered — see reactivity-two-halves. A hand-written, valid-JSON attribute does happen to parse today, because a transitive dependency JSON.parses attributes whose declared default is not a string, but that is that dependency's behaviour and not this kit's contract; do not build on it. NOTHING IN THIS REPO ENFORCES THIS — it is a consumer contract, and the scalar flag in the derived layer is how the catalog SERVES the fact, which is not the same as checking it.",
725
+ appliesTo: {},
726
+ // kind:'none' after measurement, replacing a `structural` pointer at
727
+ // define.tsx that did not contain the claimed mechanism. What define.tsx
728
+ // actually does is install non-reflecting accessors for the handful of props
729
+ // colliding with reflected global IDL attributes (role/hidden/autofocus), in
730
+ // the OPPOSITE direction: it stops property writes reflecting TO attributes.
731
+ // The attribute->property path lives in component-register, a transitive
732
+ // dependency, and measured in the real jsdom project against the real
733
+ // element it PARSES a JSON attribute onto the property rather than rejecting
734
+ // it. Nothing here checks consumer code for the contract; S1's scoring
735
+ // exercises a property update on kai-chat.messages, but no scenario names
736
+ // this invariant, so it is not claimed as a measurement either.
737
+ enforcedBy: { kind: "none" },
738
+ status: "open",
739
+ diagnosis: [
740
+ {
741
+ symptom: "an element renders empty and the data looks right in devtools",
742
+ cause: "the property holds the STRING '[object Object]', not an array; it arrived through an attribute that was stringified rather than assigned"
743
+ },
744
+ {
745
+ symptom: "everything renders but no callback ever fires",
746
+ cause: "the object went through JSON into an attribute, and JSON drops functions silently"
747
+ }
748
+ ],
749
+ examples: [
750
+ {
751
+ wrong: "el.setAttribute('messages', String(messages));",
752
+ right: "el.messages = messages;",
753
+ note: "Measured against the real element: the attribute becomes '[object Object]', JSON.parse fails, and the fallback leaves a STRING on the prop. messages is scalar:false in the derived layer; placeholder, loading and theme are scalar:true and do belong in attributes."
754
+ },
755
+ {
756
+ wrong: "cards.setAttribute('policy', JSON.stringify({ onSubmit }));",
757
+ right: "cards.policy = { onSubmit };",
758
+ note: "Measured: JSON.stringify({ onSubmit }) is '{}', so the handler is gone before the attribute is even set. No attribute can carry a function."
759
+ }
760
+ ]
761
+ },
762
+ {
763
+ id: "events-non-bubbling",
764
+ statement: "Non-bubbling is the default: public kai-* events are dispatched through the one helper that hard-codes bubbles:false and composed:false, so listen on the element itself, never on a parent or document. The protocol exceptions (kai-maximize-intent, kai-maximize-state and kai-card) bubble or compose deliberately and are listed in the derived layer under eventExceptions — do not generalise from them to the rest.",
765
+ appliesTo: {},
766
+ enforcedBy: { kind: "structural", path: "packages/ui/src/elements/define.tsx" },
767
+ status: "enforced",
768
+ diagnosis: [
769
+ {
770
+ symptom: "a delegated listener on document or a parent never fires",
771
+ cause: "kai-* events do not bubble; attach the listener to the element that dispatches it"
772
+ },
773
+ {
774
+ symptom: "a listener on the element works, but the same one on a wrapper div does not",
775
+ cause: "same cause; only the three eventExceptions cross the element boundary"
776
+ }
777
+ ],
778
+ examples: [
779
+ {
780
+ wrong: "document.addEventListener('kai-submit', (e) => send(e.detail.value));",
781
+ right: "chat.addEventListener('kai-submit', (e) => send(e.detail.value));",
782
+ note: "The dispatch helper in src/elements/define.tsx passes { bubbles: false, composed: false }, so nothing above the host ever sees the event."
783
+ },
784
+ {
785
+ wrong: "wrapper.addEventListener('kai-message-action', handleAction);",
786
+ right: "chat.addEventListener('kai-message-action', handleAction);",
787
+ note: "Delegating from a wrapper is the most common shape of this bug, because it is the habit every DOM framework teaches."
788
+ }
789
+ ]
790
+ },
791
+ {
792
+ id: "host-coordinates",
793
+ statement: "There is no store. Data flows in via properties, out via events, and the host wires element A to element B. Solid context does not cross element boundaries, so nothing coordinates elements except the host application, and no element owns another. Placing two elements in the same subtree wires nothing.",
794
+ appliesTo: {},
795
+ enforcedBy: { kind: "none" },
796
+ status: "open",
797
+ diagnosis: [
798
+ {
799
+ symptom: "two elements are expected to sync but do not",
800
+ cause: "nothing auto-coordinates; the host must listen on one element and set properties on the other"
801
+ },
802
+ {
803
+ symptom: "a property assignment is silently ignored and the prop is not in the reference",
804
+ cause: "the data was put on the element that displays the conversation rather than the one that owns the list"
805
+ }
806
+ ],
807
+ examples: [
808
+ {
809
+ wrong: "chat.conversations = rows;",
810
+ right: "conversations.conversations = rows;",
811
+ note: "kai-chat has no conversations prop — the sidebar is its own element. One element never holds the whole app state."
812
+ },
813
+ {
814
+ wrong: "chat.addEventListener('kai-conversation-select', (e) => load(e.detail.id));",
815
+ right: "conversations.addEventListener('kai-conversation-select', (e) => { chat.messages = threadsById[e.detail.id]; });",
816
+ note: "Event out of A, property into B, wired by the host. The event is dispatched by the element that owns the list, so that is where the listener goes."
817
+ }
818
+ ]
819
+ },
820
+ {
821
+ id: "untrusted-model-output",
822
+ statement: "Everything the model produced is untrusted input: a MessagePart, card envelope or tool argument reaching innerHTML, an href or src, window.open or an iframe is a vulnerability. THE DEFECT IS NEVER A MISSING GUARD, IT IS WHICH PATH GOT IT — every one found so far sat on a path the CONSUMER controls while the model-controlled path beside it had none. So put a policy on the sink, and MATCH THE LIST TO THE SINK rather than reaching for one universal list: http:, https: and mailto: for anything navigable the user may click, resolved against the page so ordinary relative links still work; http: and https: ONLY for a model-supplied citation, which is a reference to a page on the public web and has no business being relative. Those are two lists because there are two sinks — it is the same split the kit makes internally between SAFE_SCHEMES and RENDERABLE_SCHEMES, not a variant invented here. Whichever you use, parse inside a try/catch and RETURN FALSE on an unparseable URL: new URL() throws, and a throw at a sink crashes the render. And render model text as TEXT. Escaping is the correct rendering: the source text must stay VISIBLE as well as inert, because a filter that deleted it would pass the security check and be a worse UI. COVERAGE, and read this before trusting CI here: the three XSS suites are tests and ONLY tests. They run in the required test job, so the vectors they pin cannot come back — but NOTHING structural stops a NEW sink landing unguarded. No lint script in the package is about sinks, and the coupling map's unenforced list has no entry for the class. A new sink is caught in review or not at all.",
823
+ appliesTo: {},
824
+ // WHAT THE THREE SUITES DO NOT CATCH: they pin the vectors that were FOUND
825
+ // (#246 markdown innerHTML, #247 the artifact's three URL sinks, and the
826
+ // hostile-stream path), so they are regression guards, not a guard over the
827
+ // CLASS. A newly written component that puts model text on a fresh
828
+ // unguarded sink adds no failing test anywhere. Verified against
829
+ // HANDOFF-2026-08-13 §13.2 ("They are tests and only tests... nothing
830
+ // structural stops a NEW sink landing unguarded"), and re-checked against
831
+ // the tree: none of packages/ui/scripts/lint-*.mjs concerns sinks, and
832
+ // docs/coupling-map.md has no row for the class. Kept as kind:'test'
833
+ // because the regression coverage is real; status is `partial` because the
834
+ // class is not covered, and the statement carries the gap.
835
+ enforcedBy: {
836
+ kind: "test",
837
+ paths: [
838
+ "packages/ui/tests/components/markdown-xss.test.tsx",
839
+ "packages/ui/tests/components/artifact-url-xss.test.tsx",
840
+ "packages/ui/tests/components/hostile-model-output.test.tsx"
841
+ ]
842
+ },
843
+ status: "partial",
844
+ diagnosis: [
845
+ {
846
+ symptom: "a custom renderer for a tool result or a card body executes markup the model emitted",
847
+ cause: "model text reached innerHTML on a hand-written path; the guarded path is the one every real message flows through, and this one bypassed it"
848
+ },
849
+ {
850
+ symptom: "a citation or card link navigates to javascript: or data:",
851
+ cause: "a model-supplied URL reached an href, a src or window.open with no scheme check"
852
+ }
853
+ ],
854
+ examples: [
855
+ {
856
+ wrong: "el.innerHTML = part.text;",
857
+ right: "el.textContent = part.text;",
858
+ note: "For rich text render the part through <kai-markdown>, which escapes rather than sanitizes (src/components/markdown.tsx) and filters link and image URLs. Never hand-roll a second markdown-to-innerHTML path."
859
+ },
860
+ {
861
+ wrong: "window.open(card.url, '_blank');",
862
+ right: "const isNavigable = (u) => { try { return ['http:', 'https:', 'mailto:'].includes(new URL(u, location.href).protocol); } catch { return false; } };\nif (isNavigable(card.url)) window.open(card.url, '_blank', 'noopener,noreferrer');",
863
+ note: "THE try/catch IS NOT OPTIONAL: new URL() THROWS on an unparseable input like 'http://[', and an uncaught throw here crashes the render instead of blocking the link — worse than the bug you are fixing. Returning false is the whole contract. Resolving against location.href is deliberate: a relative or fragment href inherits http: and is allowed, which is what makes ordinary links keep working. REPO-INTERNAL ONLY: contributors inside this package use isSafeUrl/SAFE_SCHEMES from src/primitives/card-routing.ts, which has this exact shape — neither is reachable from the published package, so never emit an import for them."
864
+ },
865
+ {
866
+ wrong: "<a href={source.url}>{source.title}</a>",
867
+ right: `const isCitationUrl = (u) => { try { return ['http:', 'https:'].includes(new URL(u).protocol); } catch { return false; } };
868
+ {isCitationUrl(source.url) ? <a href={source.url} rel="noopener noreferrer">{source.title}</a> : <span>{source.title}</span>}`,
869
+ note: "No base here, unlike the navigable case: a model-supplied citation is a reference to a page on the public web, so a relative path is not a citation and returns false. It must RETURN false, not throw — a throw escapes the ternary and the fallback never renders, which is exactly the deleted-text failure this invariant's own statement forbids. The <span> keeps the title VISIBLE. REPO-INTERNAL equivalent: isRenderableLink in src/primitives/link-preview.ts, also not exported."
870
+ }
871
+ ]
872
+ },
873
+ {
874
+ id: "kit-parses-consumer-fetches",
875
+ statement: "The kit parses; the consumer fetches. Two halves, covered differently. KIT SIDE: every MessagePart variant the wire encodes must be accounted for, or a variant is gone once the request leaves — that half is enforced by lint:silent-drops in CI. CONSUMER SIDE: never hand-roll an SSE reader; import readOpenAIStream, readAnthropicStream or readModelStream from @kitn.ai/ui/wire, and fetch from your own endpoint, because there is no client, no key handling and no provider SDK below wire/. NO CI CHECK COVERS THAT SECOND HALF — no guard reads consumer or scaffolded code for a hand-rolled reader; it is measured by the acceptance deck instead, at scenario S2, whose scoring line is 'imports readOpenAIStream from @kitn.ai/ui/wire; no hand-rolled SSE reader anywhere in the output'.",
876
+ appliesTo: {},
877
+ // WHAT lint:silent-drops DOES NOT CATCH: it analyzes src/wire, so it covers
878
+ // the kit-side half only. A consumer or a scaffold that hand-rolls its own
879
+ // SSE loop never enters its scan, and the lint stays green. That gap is
880
+ // stated in the statement above so no reader concludes CI catches it, and
881
+ // scenario S2 is what actually measures it. Recorded rather than closed:
882
+ // flipping this to kind:'none' would discard a real guard over a real half,
883
+ // which is what `status: 'partial'` exists to express.
884
+ enforcedBy: { kind: "lint", script: "lint:silent-drops" },
885
+ status: "partial",
886
+ diagnosis: [
887
+ {
888
+ symptom: "streaming works for one provider and silently drops parts for another",
889
+ cause: "a hand-rolled reader misses part variants the wire layer already handles; replace it with the wire import"
890
+ },
891
+ {
892
+ symptom: "tokens arrive glued together, or a multibyte character renders as garbage mid-stream",
893
+ cause: "a hand-rolled reader split on a data: prefix and assumed one frame per chunk; keep-alive comments, multi-line frames and codepoints split across a socket boundary are all real"
894
+ }
895
+ ],
896
+ examples: [
897
+ {
898
+ wrong: "text += JSON.parse(line.replace('data: ', '')).choices[0].delta.content;",
899
+ right: "const turn = await readOpenAIStream(res, stream);",
900
+ note: "import { readOpenAIStream } from '@kitn.ai/ui/wire'; createAssistantStream from '@kitn.ai/ui/state' owns the message, the reader fills it. readAnthropicStream and readModelStream are the other two entry points."
901
+ },
902
+ {
903
+ wrong: "await fetch('https://api.openai.com/v1/chat/completions', { headers: { Authorization: 'Bearer ' + apiKey } });",
904
+ right: "await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: toOpenAIMessages(history) }) });",
905
+ note: "The consumer fetches, from their own endpoint. A provider key in the browser is a leaked key; toOpenAIMessages/toAnthropicMessages encode the thread for the wire."
906
+ }
907
+ ]
908
+ },
909
+ {
910
+ id: "upgrade-race",
911
+ statement: "A property set before the element upgrades is lost. On script-tag targets, load order is not ours. Until issue #99 option B (upgrade-property preservation in defineWebComponent) lands, every script-tag recipe must state this race loudly and set properties only after registration — await customElements.whenDefined(tag), which is the guarantee. A timer, or DOMContentLoaded, is a guess about load order rather than a guarantee: registration can land later, from an async chunk or a dynamically inserted script.",
912
+ appliesTo: { targets: ["script-tag"] },
913
+ enforcedBy: { kind: "none", until: "issue #99 option B lands in defineWebComponent" },
914
+ status: "open",
915
+ diagnosis: [
916
+ {
917
+ symptom: "properties set in inline script are ignored on a CDN page",
918
+ cause: "the element had not upgraded yet; the set landed on a plain HTMLElement and was lost"
919
+ },
920
+ {
921
+ symptom: "the same code works under a bundler and not from a script tag",
922
+ cause: "the bundler happened to order the registration first; a script tag gives no such guarantee"
923
+ }
924
+ ],
925
+ examples: [
926
+ {
927
+ wrong: "setTimeout(() => { chat.messages = messages; }, 0);",
928
+ right: "customElements.whenDefined('kai-chat').then(() => { chat.messages = messages; });",
929
+ note: "A timer bets on load order. whenDefined resolves when the registry actually has the tag, which is the thing you need to be true."
930
+ },
931
+ {
932
+ wrong: "document.addEventListener('DOMContentLoaded', () => { chat.messages = messages; });",
933
+ right: "customElements.whenDefined('kai-chat').then(() => { chat.messages = messages; });",
934
+ note: "DOMContentLoaded is about the parser, not the registry. It says the markup is there, never that the element behind the tag has been defined."
935
+ }
936
+ ]
937
+ }
938
+ ];
939
+ const surfaceRecipes = [
940
+ {
941
+ id: "workspace-chat",
942
+ intent: "Full-screen chat with a conversations sidebar; assistant replies can open artifacts in a resizable side panel.",
943
+ archetypes: ["full-screen"],
944
+ targets: ["bundler"],
945
+ ingredients: ["kai-chat", "kai-conversations", "kai-resizable", "kai-artifact"],
946
+ backend: { endpoint: "consumer-owned", reader: "readModelStream" },
947
+ // WHERE the parts go, which the wiring edges below never said. Until this
948
+ // field existed a builder could read the whole recipe, the whole element
949
+ // reference and the whole scaffold and still not know whether the rail was a
950
+ // child of the chat or a sibling beside it — and the two answers lay out
951
+ // differently, so it is not a detail anyone can defer. One agent building
952
+ // from the MCP said so in as many words and then guessed.
953
+ //
954
+ // Slotted, and the tree decided it rather than taste: `<kai-chat>` declares a
955
+ // `sidebar` slot whose own description is "Left column (your nav /
956
+ // conversation list). Fixed width; use compose-your-own for resizable", and
957
+ // this recipe's own corpus story — chat-slots.stories.tsx — composes exactly
958
+ // `<kai-conversations slot="sidebar">` inside `<kai-chat>`.
959
+ //
960
+ // THE TREE ALSO PUBLISHES THE OTHER ANSWER, and an earlier version of this
961
+ // comment claimed it did not ("nothing in the tree composes them as
962
+ // siblings") — asserted without looking, which is the same defect as the
963
+ // over-generous `note` below it. The docs put the pair side by side in at
964
+ // least five places: guides/frameworks/html.mdx, svelte.mdx (a section
965
+ // titled "Add a conversation sidebar"), angular.mdx, vue.mdx, and
966
+ // examples/knowledge-base.mdx with its live demo. Every one of them is a
967
+ // layout the CONSUMER owns — a flex row they write, or `<kai-resizable>` —
968
+ // which is the compose-your-own path the slot's own description points at,
969
+ // and is what you want when the rail must resize. So the two are not in
970
+ // conflict about what is legal; this record says which one the RECIPE is,
971
+ // and the docs currently present the sibling form without saying that.
972
+ composition: [
973
+ {
974
+ child: "kai-conversations",
975
+ parent: "kai-chat",
976
+ slot: "sidebar",
977
+ // Every clause here is checked against chat-thread.tsx, not inferred
978
+ // from the slot's name. The shell's whole sidebar implementation is one
979
+ // `<aside part="sidebar" class="flex w-64 shrink-0 …">` around a
980
+ // `<slot name="sidebar" />`: fixed width, no responsive class in the
981
+ // file, no collapse logic, and `<kai-chat>` never listens for
982
+ // `kai-collapse-toggle`. Collapse belongs to the rail
983
+ // (`collapsed`/`collapse()`), and conversation-list.tsx says so in as
984
+ // many words: "the host owns the surrounding region". A note that
985
+ // credited the shell with collapse and a breakpoint would send a builder
986
+ // into a 16rem empty column holding a floating reopen button.
987
+ note: `the rail is a light-DOM child of <kai-chat> carrying slot="sidebar", not a sibling: the shell renders it into its own ::part(sidebar) aside, a FIXED-WIDTH column (w-64 in chat-thread.tsx, exposed as a part so you can restyle it) — and that is the whole of what the shell does. Collapse is the RAIL's own (collapsed / collapse() / kai-collapse-toggle) and <kai-chat> does not react to it, so a collapsed rail leaves the column at its fixed width; there is no responsive behaviour here either. Give the rail height (display:block;height:100%) and drive it through its own JS properties — being slotted changes where it renders, never how it is wired. If the COLUMN itself has to collapse, resize or respond to width, that is the <kai-workspace> layout shell (its start aside: startCollapsed / collapseBelow / drawer-below, widths via the --kai-workspace-start-* custom properties) or a layout you own`
988
+ }
989
+ ],
990
+ wiring: [
991
+ {
992
+ from: "kai-conversations",
993
+ event: "kai-conversation-select",
994
+ to: "kai-chat",
995
+ property: "messages",
996
+ note: "detail is {id}; the host looks the thread up and assigns it — a new array AND a new object per changed item (reactivity-two-halves)"
997
+ },
998
+ {
999
+ from: "kai-conversations",
1000
+ event: "kai-new-chat",
1001
+ to: "kai-chat",
1002
+ property: "messages",
1003
+ note: "detail is empty by design; the event is the whole signal and the host resets to an empty thread"
1004
+ },
1005
+ {
1006
+ from: "kai-chat",
1007
+ event: "kai-submit",
1008
+ to: "kai-chat",
1009
+ property: "messages",
1010
+ note: "host reads event.detail.value, appends the user turn, streams the reply through the wire reader onto parts[]"
1011
+ },
1012
+ {
1013
+ from: "kai-artifact",
1014
+ event: "kai-maximize-change",
1015
+ to: "kai-resizable",
1016
+ property: "maximizedIndex",
1017
+ note: "detail is {maximized}, NOT an index: the host knows which panel holds the artifact and mirrors the boolean onto that index (null to restore). Only needed when the panel is not an ancestor — an artifact INSIDE a kai-resizable-item already drives it through the bubbling kai-maximize-intent protocol, with no host code at all"
1018
+ }
1019
+ ],
1020
+ invariants: [
1021
+ "reactivity-two-halves",
1022
+ "props-not-attributes",
1023
+ "events-non-bubbling",
1024
+ "host-coordinates",
1025
+ "kit-parses-consumer-fetches",
1026
+ "untrusted-model-output"
1027
+ ],
1028
+ // No single story composes all four ingredients, so the corpus names what
1029
+ // each half is really proven by rather than one path that only half applies:
1030
+ // chat-slots composes kai-chat with kai-conversations, split-workspace
1031
+ // composes kai-resizable with kai-artifact, and surfaces.test.ts is where
1032
+ // the four wiring edges above are actually executed.
1033
+ corpus: [
1034
+ "packages/ui/src/elements/chat-slots.stories.tsx",
1035
+ "packages/ui/src/elements/split-workspace.stories.tsx",
1036
+ "packages/ui/src/agent-tooling/catalog/surfaces.test.ts"
1037
+ ]
1038
+ },
1039
+ {
1040
+ // The script-tag instance. Without it, DeliveryTarget 'script-tag' and the
1041
+ // whole upgrade-race invariant exist in the schema with no recipe using
1042
+ // them, and S5 has nothing to reconstruct from.
1043
+ id: "support-widget-script-tag",
1044
+ intent: "A docked support chat widget added to a page with a script tag and no build step, talking to an endpoint the site owner already runs. The CMS case.",
1045
+ archetypes: ["widget", "docked"],
1046
+ targets: ["script-tag"],
1047
+ ingredients: ["kai-chat"],
1048
+ backend: { endpoint: "consumer-owned", reader: "readOpenAIStream" },
1049
+ wiring: [
1050
+ {
1051
+ from: "kai-chat",
1052
+ event: "kai-submit",
1053
+ to: "kai-chat",
1054
+ property: "messages",
1055
+ note: "host reads event.detail.value, appends the user turn, streams the reply through the wire reader; on this target the host is an inline script, not a framework, so the listener must be attached after customElements.whenDefined (upgrade-race)"
1056
+ }
1057
+ ],
1058
+ invariants: [
1059
+ "upgrade-race",
1060
+ "reactivity-two-halves",
1061
+ "props-not-attributes",
1062
+ "events-non-bubbling",
1063
+ "kit-parses-consumer-fetches",
1064
+ "untrusted-model-output"
1065
+ ],
1066
+ corpus: ["packages/ui/README.md"]
1067
+ }
1068
+ ];
563
1069
  const JS_ONLY_TYPE_PATTERNS = /\[\]|\{|Record</;
564
1070
  function isJsOnlyType(typeText) {
565
1071
  return typeText ? JS_ONLY_TYPE_PATTERNS.test(typeText) : false;
@@ -591,7 +1097,7 @@ function loopWiring(example) {
591
1097
  return [
592
1098
  "",
593
1099
  "#### Wiring the loop",
594
- `- **Tool names are \`${S}<card type>\`.** \`${gt(example)}\` produces \`{ type: "${example}" }\`. Use \`isCardTool(name)\` to split card calls from your own tools; never match the prefix by hand.`,
1100
+ `- **Tool names are \`${S}<card type>\`.** \`${R(example)}\` produces \`{ type: "${example}" }\`. Use \`isCardTool(name)\` to split card calls from your own tools; never match the prefix by hand.`,
595
1101
  "- **`tool_call_id` becomes `CardEnvelope.id`, unchanged.** That is what makes a revision UPSERT: a model that re-sends the same tool call id replaces the card in place instead of rendering a second copy of it. Generating your own id breaks that silently.",
596
1102
  "- **`cardFromToolCall(name, input, { id: call.id })`** turns the call into a renderable envelope, or returns `null` when it is one of your own tools so the loop falls through to it. It never throws.",
597
1103
  `- **Everything above is one import:** \`import { cardTools, cardFromToolCall, isCardTool } from '@kitn.ai/ui/schemas'\`. The raw documents also ship as JSON (\`@kitn.ai/ui/schemas/${example}.schema.json\`) for a Python or Go backend.`
@@ -603,7 +1109,7 @@ function renderCardContract(tag, cardType, provider) {
603
1109
  const lines = [
604
1110
  "",
605
1111
  "### Card contract",
606
- `\`<${tag}>\` renders \`CardEnvelope.type: "${cardType}"\`. A model does not emit an envelope; it calls the tool \`${gt(cardType)}\`, and the kit turns that call back into one.`
1112
+ `\`<${tag}>\` renders \`CardEnvelope.type: "${cardType}"\`. A model does not emit an envelope; it calls the tool \`${R(cardType)}\`, and the kit turns that call back into one.`
607
1113
  ];
608
1114
  lines.push(
609
1115
  "",
@@ -623,25 +1129,185 @@ function renderCardContract(tag, cardType, provider) {
623
1129
  return lines;
624
1130
  }
625
1131
  function renderCardHost(tag) {
626
- const rows = ii.map((type) => {
1132
+ const rows = hi.map((type) => {
627
1133
  const cardTag = cardTagForType(type);
628
- return `- \`${type}\` → \`<${cardTag ?? "(no element)"}>\`, tool \`${gt(type)}\``;
1134
+ return `- \`${type}\` → \`<${cardTag ?? "(no element)"}>\`, tool \`${R(type)}\``;
629
1135
  });
630
1136
  return [
631
1137
  "",
632
1138
  "### Card contract",
633
1139
  `\`<${tag}>\` renders generative-UI cards inside the thread. It is a HOST: it draws whatever envelopes arrive, and carries the two props that say what a card may be.`,
634
1140
  "",
635
- `- **\`cardTypes\`** — \`Record<envelope type, custom element tag>\`. What DRAWS a card. Merged over the built-ins, so your entry overrides ours and the other ${ii.length - 1} still render.`,
636
- `- **\`cardSchemas\`** — \`Record<envelope type, JSON Schema>\`. What a VALID card looks like. This is the prop that carries YOUR OWN card schemas into the browser validator; the ${ii.length} built-ins are already known. Both are objects, so both are JS properties (\`el.cardSchemas = …\`), never HTML attributes.`,
1141
+ `- **\`cardTypes\`** — \`Record<envelope type, custom element tag>\`. What DRAWS a card. Merged over the built-ins, so your entry overrides ours and the other ${hi.length - 1} still render.`,
1142
+ `- **\`cardSchemas\`** — \`Record<envelope type, JSON Schema>\`. What a VALID card looks like. This is the prop that carries YOUR OWN card schemas into the browser validator; the ${hi.length} built-ins are already known. Both are objects, so both are JS properties (\`el.cardSchemas = …\`), never HTML attributes.`,
637
1143
  "",
638
1144
  "Write both once with `createCardRegistry({ use, custom })` from `@kitn.ai/ui/schemas` and thread it to both ends: `el.cardTypes = cards.tags` / `el.cardSchemas = cards.validationSchemas` on the client, `cardTools(cards, { provider })` on the route.",
639
1145
  "",
640
1146
  "Built-in card types (ask `component_reference` for one of these tags to get its schema and tool definition):",
641
1147
  ...rows,
642
- ...loopWiring(ii[0])
1148
+ ...loopWiring(hi[0])
643
1149
  ];
644
1150
  }
1151
+ function invariantsFor(tag) {
1152
+ return invariants.filter((i) => !i.appliesTo.tags || i.appliesTo.tags.includes(tag));
1153
+ }
1154
+ function compositionLines(r, noteSep) {
1155
+ if (!r.composition) return [];
1156
+ return [
1157
+ "- **Composition** — where the parts go (a slotted child, not a sibling):",
1158
+ ...r.composition.map(
1159
+ (c) => ` - \`<${c.child} slot="${c.slot}">\` goes INSIDE \`<${c.parent}>\`` + (noteSep && c.note ? `${noteSep}${c.note}` : "")
1160
+ )
1161
+ ];
1162
+ }
1163
+ function recipesFor(tag) {
1164
+ return surfaceRecipes.filter((r) => r.ingredients.includes(tag));
1165
+ }
1166
+ function coverageOf(inv) {
1167
+ const by = inv.enforcedBy;
1168
+ const guard = by.kind === "test" ? `the kit's own tests (${by.paths.join(", ")})` : by.kind === "lint" ? `the kit's \`${by.script}\` script, in required CI` : by.kind === "structural" ? `a structural guarantee in ${by.path}` : "";
1169
+ switch (inv.status) {
1170
+ case "enforced":
1171
+ return `enforced by ${guard}`;
1172
+ case "partial":
1173
+ return `PARTIALLY ENFORCED: one half is covered, by ${guard}; the statement says which half is not`;
1174
+ case "open":
1175
+ return "NOT ENFORCED: nothing in the kit checks this" + (by.kind === "none" && by.until ? `, until ${by.until}` : "") + ". A rule you apply, not a guarantee you will be warned about";
1176
+ }
1177
+ }
1178
+ function scopeOf(inv) {
1179
+ const { tags: tags2, targets, parts: parts2 } = inv.appliesTo;
1180
+ const bits = [];
1181
+ if (tags2) bits.push(`only ${tags2.join(", ")}`);
1182
+ if (targets) bits.push(`${targets.join(" / ")} delivery only`);
1183
+ if (parts2) bits.push(`parts ${parts2.join(", ")}`);
1184
+ return bits.join("; ");
1185
+ }
1186
+ function exampleLines(examples) {
1187
+ if (examples.length === 0) return [];
1188
+ const out = ["", "```js"];
1189
+ examples.forEach((ex, i) => {
1190
+ if (i > 0) out.push("");
1191
+ out.push("// WRONG", ex.wrong, "// RIGHT", ex.right);
1192
+ if (ex.note) out.push(...ex.note.split("\n").map((l) => `// ${l}`));
1193
+ });
1194
+ out.push("```");
1195
+ return out;
1196
+ }
1197
+ function coverageSummary(applicable) {
1198
+ const open = applicable.filter((i) => i.status === "open").length;
1199
+ const partial = applicable.filter((i) => i.status === "partial").length;
1200
+ if (open === 0 && partial === 0) return "";
1201
+ const total = applicable.length;
1202
+ const half = (n) => `only half of what ${n === 1 ? "it says" : "they say"}`;
1203
+ const clauses = [];
1204
+ if (open > 0) {
1205
+ clauses.push(`${open} of the ${total} below ${open === 1 ? "is" : "are"} enforced by NOTHING at all`);
1206
+ }
1207
+ if (partial > 0) {
1208
+ clauses.push(
1209
+ open > 0 ? `${partial} more by ${half(partial)}` : `${partial} of the ${total} below ${partial === 1 ? "is" : "are"} enforced by ${half(partial)}`
1210
+ );
1211
+ }
1212
+ return ` ${clauses.join(", and ")}.`;
1213
+ }
1214
+ function catalogSectionLines(tag) {
1215
+ const applicable = invariantsFor(tag);
1216
+ const lines = [];
1217
+ if (applicable.length > 0) {
1218
+ lines.push(
1219
+ "",
1220
+ "### Invariants",
1221
+ "Rules that have already broken real consumers of this kit. Each block says what enforces it — read that line rather than assuming CI catches a violation, because nothing here reads YOUR code." + coverageSummary(applicable) + ' Diagnosis and wrong/right examples for every invariant are served once, not repeated per element — call component_reference with { name: "invariants" }.'
1222
+ );
1223
+ for (const inv of applicable) {
1224
+ const scope = scopeOf(inv);
1225
+ lines.push("", `#### ${inv.id}${scope ? ` (${scope})` : ""} — ${coverageOf(inv)}`, inv.statement);
1226
+ }
1227
+ }
1228
+ const recipes = recipesFor(tag);
1229
+ if (recipes.length > 0) {
1230
+ lines.push(
1231
+ "",
1232
+ "### Appears in surface recipes",
1233
+ 'Compositions this element is part of, each with the whole ingredient list and the host wiring — nothing coordinates one element with another except your own code (host-coordinates). The WHY behind each wiring edge, and the caveats on the nesting below, are served once rather than repeated per ingredient — call component_reference with { name: "recipes" }.'
1234
+ );
1235
+ for (const r of recipes) {
1236
+ lines.push(
1237
+ "",
1238
+ `#### ${r.id} — ${r.intent}`,
1239
+ `- **Ingredients:** ${r.ingredients.map((t) => `\`<${t}>\``).join(", ")}`,
1240
+ `- **Delivery:** ${r.targets.join(", ")} · archetype: ${r.archetypes.join(", ")}`,
1241
+ `- **Backend:** your own endpoint, read with \`${r.backend.reader}\` from \`@kitn.ai/ui/wire\``,
1242
+ ...compositionLines(r, ""),
1243
+ // Bare ids, deliberately. This row is a DEPENDENCY list, not a coverage
1244
+ // claim, and it can only ever be read a few lines below the full
1245
+ // `### Invariants` section in the same response, where each of these ids
1246
+ // already carries its own coverage line. Repeating the status here would
1247
+ // print it a second (and, with two recipes, a third) time per element for
1248
+ // no fact a reader does not already have on the page.
1249
+ `- **Invariants it leans on:** ${r.invariants.join(", ")}`,
1250
+ // Compact: the edge (event out of A, property into B) with no `note`.
1251
+ // The note is the WHY — often a full sentence, sometimes several — and
1252
+ // repeating it is the same shape of duplication task 4 removed from
1253
+ // invariants: this recipe's ingredients (kai-chat, kai-conversations,
1254
+ // kai-resizable, kai-artifact) all print this identical wiring list.
1255
+ // `renderRecipeAppendix` carries the notes exactly once.
1256
+ "- **Wiring** — event out of A, property into B, wired by the host:"
1257
+ );
1258
+ for (const w2 of r.wiring) {
1259
+ lines.push(` - \`<${w2.from}>\` fires \`${w2.event}\` → host sets \`${w2.to}.${w2.property}\``);
1260
+ }
1261
+ }
1262
+ }
1263
+ return lines;
1264
+ }
1265
+ function renderRecipeAppendix() {
1266
+ const lines = [
1267
+ `## Surface recipe catalog (${surfaceRecipes.length} total) — wiring notes`,
1268
+ "",
1269
+ "Every element lookup that appears in a recipe lists its ingredients, delivery, backend and a compact wiring list (event out of A, property into B). This is the long form: the WHY behind each wiring edge, written once here rather than repeated on every ingredient."
1270
+ ];
1271
+ for (const r of surfaceRecipes) {
1272
+ lines.push(
1273
+ "",
1274
+ `#### ${r.id} — ${r.intent}`,
1275
+ `- **Ingredients:** ${r.ingredients.map((t) => `\`<${t}>\``).join(", ")}`,
1276
+ `- **Delivery:** ${r.targets.join(", ")} · archetype: ${r.archetypes.join(", ")}`,
1277
+ `- **Backend:** your own endpoint, read with \`${r.backend.reader}\` from \`@kitn.ai/ui/wire\``,
1278
+ `- **Invariants it leans on:** ${r.invariants.join(", ")}`,
1279
+ ...compositionLines(r, " — "),
1280
+ "- **Wiring** — event out of A, property into B, wired by the host:"
1281
+ );
1282
+ for (const w2 of r.wiring) {
1283
+ lines.push(
1284
+ ` - \`<${w2.from}>\` fires \`${w2.event}\` → host sets \`${w2.to}.${w2.property}\`` + (w2.note ? ` — ${w2.note}` : "")
1285
+ );
1286
+ }
1287
+ }
1288
+ return lines;
1289
+ }
1290
+ function renderInvariantAppendix() {
1291
+ const lines = [
1292
+ `## Invariant catalog (${invariants.length} total) — diagnosis and examples`,
1293
+ "",
1294
+ "Every element lookup lists the invariants that apply to it by id and one-line statement. This is the long form: full statement, diagnosis and wrong/right examples for each record, written once here rather than repeated on every element it applies to."
1295
+ ];
1296
+ for (const inv of invariants) {
1297
+ const scope = scopeOf(inv);
1298
+ lines.push("", `#### ${inv.id}${scope ? ` (${scope})` : ""} — ${coverageOf(inv)}`, inv.statement);
1299
+ if (inv.diagnosis.length > 0) {
1300
+ lines.push("", "If you are debugging:");
1301
+ for (const d of inv.diagnosis) lines.push(`- ${d.symptom} → ${d.cause}`);
1302
+ }
1303
+ lines.push(...exampleLines(inv.examples));
1304
+ }
1305
+ return lines;
1306
+ }
1307
+ function eventDetail(typeText) {
1308
+ const m = /^CustomEvent<([\s\S]*)>$/.exec(typeText?.trim() ?? "");
1309
+ return m ? m[1].trim() || void 0 : void 0;
1310
+ }
645
1311
  function formatReference(tag, provider) {
646
1312
  const el = getElement(tag);
647
1313
  if (!el) {
@@ -657,13 +1323,45 @@ Call component_reference with no name (or name: "list") to list all ${all.length
657
1323
  if (el.description) {
658
1324
  lines.push("", el.description.trim());
659
1325
  }
1326
+ const entry = entryForTag(tag);
1327
+ const iface = el.name;
1328
+ const neverUpgrades = `If you skip registering it the element never upgrades: nothing renders, **no error and no warning is logged**, a property you set assigns and reads back correctly, and \`customElements.whenDefined('${tag}')\` never resolves.`;
1329
+ lines.push("", "### Getting the element");
1330
+ if (entry) {
1331
+ lines.push(
1332
+ `Register it before you use it. ${neverUpgrades}`,
1333
+ "",
1334
+ "```ts",
1335
+ "import '@kitn.ai/ui/elements';",
1336
+ `import '@kitn.ai/ui/elements/${entry}'; // or just this one`,
1337
+ "```"
1338
+ );
1339
+ } else {
1340
+ const optIn = optInEntryForTag(tag);
1341
+ lines.push(
1342
+ `**\`${tag}\` is opt-in — it is not part of \`import '@kitn.ai/ui/elements'\` (register-all), and that import alone will NOT register it.** ` + (optIn ? `Import its own entry point instead. ${neverUpgrades}` : (
1343
+ // No built module claims this tag, so there is no specifier to name.
1344
+ // Stay honest rather than inventing one.
1345
+ `Find its specific \`@kitn.ai/ui/elements/<name>\` entry point in the package's published exports before using it. ${neverUpgrades}`
1346
+ ))
1347
+ );
1348
+ if (optIn) {
1349
+ lines.push("", "```ts", `import '@kitn.ai/ui/elements/${optIn}';`, "```");
1350
+ }
1351
+ }
1352
+ if (iface) {
1353
+ lines.push(
1354
+ "",
1355
+ `TypeScript: \`import type { ${iface} } from '@kitn.ai/ui/elements';\` — the element interface ships with the package; do not hand-roll a structural type.`
1356
+ );
1357
+ }
660
1358
  lines.push(
661
1359
  "",
662
1360
  "### AI/UI contract",
663
1361
  '`kai-*` elements accept **array and object data as JavaScript properties** (set in JavaScript via `el.property = value`, not as HTML attributes). Events are native CustomEvents — listen with `el.addEventListener("event-name", handler)` and read `event.detail` for the payload.'
664
1362
  );
665
1363
  const cardType = cardTypeForTag(tag);
666
- if (cardType !== void 0 && Yt(cardType)) {
1364
+ if (cardType !== void 0 && ii(cardType)) {
667
1365
  lines.push(...renderCardContract(tag, cardType, provider));
668
1366
  } else if (cardHostTags().includes(tag)) {
669
1367
  lines.push(...renderCardHost(tag));
@@ -701,7 +1399,10 @@ Call component_reference with no name (or name: "list") to list all ${all.length
701
1399
  lines.push("", "### Events (CustomEvent, listen via addEventListener)");
702
1400
  for (const ev of events) {
703
1401
  const desc = ev.description?.trim() ?? "";
704
- lines.push(`- **${ev.name}** — ${desc}`);
1402
+ const detail = eventDetail(ev.type?.text);
1403
+ lines.push(
1404
+ detail ? `- **${ev.name}** — ${desc} \`detail\`: \`${detail}\`` : `- **${ev.name}** — ${desc}`
1405
+ );
705
1406
  }
706
1407
  }
707
1408
  const methods = (el.members ?? []).filter(isPublicMethod);
@@ -752,13 +1453,16 @@ Call component_reference with no name (or name: "list") to list all ${all.length
752
1453
  }
753
1454
  }
754
1455
  }
1456
+ lines.push(...catalogSectionLines(tag));
755
1457
  return lines.join("\n");
756
1458
  }
757
1459
  const reference = {
758
1460
  name: "component_reference",
759
1461
  description: "Look up AI/UI (kai-*) web components: their tags, props, events, imperative methods, and usage examples. For a card-backed element it also returns the card's JSON Schema and a ready-to-send tool definition generated from it — pass `provider` to get that provider's envelope.",
760
1462
  inputSchema: z$1.object({
761
- name: z$1.string().optional(),
1463
+ name: z$1.string().optional().describe(
1464
+ 'The element tag, e.g. "kai-chat". Omit it (or pass "list") to get the index of every element with a one-line summary, then ask again for the one you want. Pass "invariants" for the full diagnosis and wrong/right examples behind every invariant id an element lookup shows. Pass "recipes" for the full wiring notes behind every surface recipe an element lookup names.'
1465
+ ),
762
1466
  provider: z$1.enum(PROVIDERS).optional().describe(
763
1467
  `Which tool-definition envelope to project for a card-backed element. Defaults to "${DEFAULT_PROVIDER}", the provider-neutral { name, description, schema } form.`
764
1468
  )
@@ -782,17 +1486,25 @@ No tool definition was returned, because guessing which envelope you meant would
782
1486
  const provider = rawProvider ?? DEFAULT_PROVIDER;
783
1487
  let text2;
784
1488
  if (!name || name === "list") {
785
- const tags = listElements();
786
- const cardRows = ii.map((t) => cardTagForType(t)).filter((t) => t !== void 0);
787
- text2 = `AI/UI elements (${tags.length} total):
1489
+ const tags2 = listElements();
1490
+ const cardRows = hi.map((t) => cardTagForType(t)).filter((t) => t !== void 0);
1491
+ text2 = `AI/UI elements (${tags2.length} total):
788
1492
 
789
- ` + tags.map((t) => ` ${t}`).join("\n") + `
1493
+ ` + tags2.map((t) => ` ${t}`).join("\n") + `
790
1494
 
791
1495
  Call component_reference with a specific name (e.g. { name: "kai-chat" }) for full API details.
792
1496
 
793
1497
  Card-backed elements — ask for one of these to get its JSON Schema and a generated \`${S}*\` tool definition:
794
1498
  ${cardRows.join(", ")}
795
- The elements that HOST them (and carry the \`cardTypes\` / \`cardSchemas\` props): ${cardHostTags().join(", ")}`;
1499
+ The elements that HOST them (and carry the \`cardTypes\` / \`cardSchemas\` props): ${cardHostTags().join(", ")}
1500
+
1501
+ Call component_reference with { name: "invariants" } for the full diagnosis and wrong/right examples behind every invariant id shown on an element lookup.
1502
+
1503
+ Call component_reference with { name: "recipes" } for the full wiring notes behind every surface recipe an element lookup names.`;
1504
+ } else if (name === "invariants") {
1505
+ text2 = renderInvariantAppendix().join("\n");
1506
+ } else if (name === "recipes") {
1507
+ text2 = renderRecipeAppendix().join("\n");
796
1508
  } else {
797
1509
  text2 = formatReference(name, provider);
798
1510
  }
@@ -1001,7 +1713,13 @@ const openai = {
1001
1713
  // Both model and tools come from the browser. \`tools\` is undefined unless the
1002
1714
  // front end declared any, and JSON.stringify drops it, so the same handler
1003
1715
  // serves a tool archetype and a plain chat.
1004
- const { model, messages, tools } = await readChatRequest(request);
1716
+ let chatBody: ChatRequestBody;
1717
+ try {
1718
+ chatBody = await readChatRequest(request);
1719
+ } catch (error) {
1720
+ return toChatErrorResponse(error);
1721
+ }
1722
+ const { model, messages, tools } = chatBody;
1005
1723
 
1006
1724
  const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
1007
1725
  method: 'POST',
@@ -1365,7 +2083,13 @@ function reframeToOpenAISse(body: ReadableStream<Uint8Array>): ReadableStream<Ui
1365
2083
  async function chatHandler(request: Request): Promise<Response> {
1366
2084
  // Both model and tools come from the browser. \`tools\` arrives in OpenAI
1367
2085
  // function form and is converted to this API's shape below.
1368
- const { model, messages, tools } = await readChatRequest(request);
2086
+ let chatBody: ChatRequestBody;
2087
+ try {
2088
+ chatBody = await readChatRequest(request);
2089
+ } catch (error) {
2090
+ return toChatErrorResponse(error);
2091
+ }
2092
+ const { model, messages, tools } = chatBody;
1369
2093
  const { system, messages: anthropicMessages } = toAnthropicBody(messages);
1370
2094
  const anthropicTools = toAnthropicTools(tools);
1371
2095
 
@@ -1492,7 +2216,13 @@ const openrouter = {
1492
2216
  webRoute: `async function chatHandler(request: Request): Promise<Response> {
1493
2217
  // tools is undefined unless the front end sent one; JSON.stringify drops it,
1494
2218
  // so the same handler serves a tool archetype and a plain chat.
1495
- const { model, messages, tools } = await readChatRequest(request);
2219
+ let chatBody: ChatRequestBody;
2220
+ try {
2221
+ chatBody = await readChatRequest(request);
2222
+ } catch (error) {
2223
+ return toChatErrorResponse(error);
2224
+ }
2225
+ const { model, messages, tools } = chatBody;
1496
2226
 
1497
2227
  const upstream = await fetch('https://openrouter.ai/api/v1/chat/completions', {
1498
2228
  method: 'POST',
@@ -1718,7 +2448,13 @@ const FINISH_REASONS: Record<string, string> = {
1718
2448
  };
1719
2449
 
1720
2450
  async function chatHandler(request: Request): Promise<Response> {
1721
- const { messages, tools } = await readChatRequest(request);
2451
+ let chatBody: ChatRequestBody;
2452
+ try {
2453
+ chatBody = await readChatRequest(request);
2454
+ } catch (error) {
2455
+ return toChatErrorResponse(error);
2456
+ }
2457
+ const { messages, tools } = chatBody;
1722
2458
  const toolSet = toToolSet(tools);
1723
2459
  const prompt = toModelMessages(messages);
1724
2460
 
@@ -1966,7 +2702,13 @@ const agent = createReactAgent({
1966
2702
 
1967
2703
  // Stream a compiled LangGraph agent to the browser as OpenAI-format SSE.
1968
2704
  async function chatHandler(request: Request): Promise<Response> {
1969
- const { messages } = await readChatRequest(request);
2705
+ let chatBody: ChatRequestBody;
2706
+ try {
2707
+ chatBody = await readChatRequest(request);
2708
+ } catch (error) {
2709
+ return toChatErrorResponse(error);
2710
+ }
2711
+ const { messages } = chatBody;
1970
2712
 
1971
2713
  // agent.stream() coerces plain {role, content} objects into BaseMessage
1972
2714
  // instances itself, including OpenAI-shaped tool_calls, so the wire messages
@@ -2082,9 +2824,44 @@ import type { OpenAIWireMessage } from '@kitn.ai/ui/wire';
2082
2824
  */
2083
2825
  type ChatRequestBody = { messages: OpenAIWireMessage[] };
2084
2826
 
2827
+ class ChatRequestError extends Error {
2828
+ constructor(readonly status: number, message: string) { super(message); }
2829
+ }
2830
+
2831
+ /** Narrow the JSON body once, at the edge. A bare GET, a malformed body, or a
2832
+ * missing messages array is a ChatRequestError with a status — NEVER an
2833
+ * unhandled SyntaxError (findings F-10). */
2834
+ async function readChatRequest(request: Request): Promise<ChatRequestBody> {
2835
+ if (request.method !== 'POST') {
2836
+ throw new ChatRequestError(405, \`Method \${request.method} not allowed — POST /api/chat.\`);
2837
+ }
2838
+ let parsed: unknown;
2839
+ try { parsed = await request.json(); } catch {
2840
+ throw new ChatRequestError(400, 'Request body is not valid JSON.');
2841
+ }
2842
+ const body = parsed as ChatRequestBody;
2843
+ if (!Array.isArray(body?.messages)) {
2844
+ throw new ChatRequestError(400, 'Request body must carry a messages array.');
2845
+ }
2846
+ return body;
2847
+ }
2848
+
2849
+ /** Map a guard rejection to the Response its status demands; rethrow anything
2850
+ * else — an unexpected error should be loud, not laundered into a 400. */
2851
+ function toChatErrorResponse(error: unknown): Response {
2852
+ if (error instanceof ChatRequestError) return Response.json({ error: error.message }, { status: error.status });
2853
+ throw error;
2854
+ }
2855
+
2085
2856
  export default {
2086
2857
  async fetch(req: Request, env: Env): Promise<Response> {
2087
- const { messages } = (await req.json()) as ChatRequestBody;
2858
+ let chatBody: ChatRequestBody;
2859
+ try {
2860
+ chatBody = await readChatRequest(req);
2861
+ } catch (error) {
2862
+ return toChatErrorResponse(error);
2863
+ }
2864
+ const { messages } = chatBody;
2088
2865
 
2089
2866
  let nativeStream: ReadableStream<Uint8Array>;
2090
2867
  try {
@@ -2159,7 +2936,13 @@ export default {
2159
2936
  },
2160
2937
  webRoute: `async function chatHandler(request: Request): Promise<Response> {
2161
2938
  // Proxy Workers AI over its OpenAI-compatible HTTP endpoint, token server-side.
2162
- const { messages } = await readChatRequest(request);
2939
+ let chatBody: ChatRequestBody;
2940
+ try {
2941
+ chatBody = await readChatRequest(request);
2942
+ } catch (error) {
2943
+ return toChatErrorResponse(error);
2944
+ }
2945
+ const { messages } = chatBody;
2163
2946
 
2164
2947
  const upstream = await fetch(
2165
2948
  \`https://api.cloudflare.com/client/v4/accounts/\${process.env.CF_ACCOUNT_ID}/ai/v1/chat/completions\`,
@@ -2250,7 +3033,13 @@ const ollama = {
2250
3033
  // The model is pinned here, not sent by the browser. tools IS forwarded:
2251
3034
  // it is undefined unless the front end declared any, and JSON.stringify
2252
3035
  // drops it, so the same handler serves a tool archetype and a plain chat.
2253
- const { messages, tools } = await readChatRequest(request);
3036
+ let chatBody: ChatRequestBody;
3037
+ try {
3038
+ chatBody = await readChatRequest(request);
3039
+ } catch (error) {
3040
+ return toChatErrorResponse(error);
3041
+ }
3042
+ const { messages, tools } = chatBody;
2254
3043
 
2255
3044
  const upstream = await fetch('http://localhost:11434/v1/chat/completions', {
2256
3045
  method: 'POST',
@@ -2344,7 +3133,13 @@ const mastra = new MastraClient({ baseUrl: MASTRA_URL });
2344
3133
 
2345
3134
  // Proxy a Mastra agent to the browser as OpenAI-format SSE.
2346
3135
  async function chatHandler(request: Request): Promise<Response> {
2347
- const { messages } = await readChatRequest(request);
3136
+ let chatBody: ChatRequestBody;
3137
+ try {
3138
+ chatBody = await readChatRequest(request);
3139
+ } catch (error) {
3140
+ return toChatErrorResponse(error);
3141
+ }
3142
+ const { messages } = chatBody;
2348
3143
 
2349
3144
  // MastraClient's Agent.stream() takes AI-SDK CoreMessage[], not the OpenAI
2350
3145
  // wire format: each message has ONE literal role (not a union) and content
@@ -2508,9 +3303,64 @@ import type { OpenAIWireMessage } from '@kitn.ai/ui/wire';
2508
3303
  const app = express();
2509
3304
  app.use(express.json());
2510
3305
 
3306
+ class ChatRequestError extends Error {
3307
+ constructor(readonly status: number, message: string) { super(message); }
3308
+ }
3309
+
3310
+ /** Narrow the JSON body once, at the edge. A bare GET or a missing messages
3311
+ * array is a ChatRequestError with a status — NEVER an unhandled throw
3312
+ * (findings F-10). Malformed JSON is rejected upstream by express.json(),
3313
+ * which the error handler below turns into a JSON 400. */
3314
+ function readChatRequest(req: express.Request): { messages: OpenAIWireMessage[] } {
3315
+ if (req.method !== 'POST') {
3316
+ throw new ChatRequestError(405, \`Method \${req.method} not allowed — POST /api/chat.\`);
3317
+ }
3318
+ const body = (req.body ?? {}) as { messages?: OpenAIWireMessage[] };
3319
+ if (!Array.isArray(body.messages)) {
3320
+ throw new ChatRequestError(400, 'Request body must carry a messages array.');
3321
+ }
3322
+ return body as { messages: OpenAIWireMessage[] };
3323
+ }
3324
+
3325
+ /** Map a guard rejection to the response its status demands; rethrow anything
3326
+ * else — an unexpected error should be loud, not laundered into a 400. */
3327
+ function toChatErrorResponse(error: unknown, res: express.Response): void {
3328
+ if (error instanceof ChatRequestError) {
3329
+ res.status(error.status).json({ error: { message: error.message } });
3330
+ return;
3331
+ }
3332
+ throw error;
3333
+ }
3334
+
3335
+ // express.json() rejects a malformed body at the MIDDLEWARE level, before any
3336
+ // handler runs — without this it surfaces as Express's default HTML error page.
3337
+ app.use(
3338
+ (
3339
+ err: { status?: number; message?: string },
3340
+ _req: express.Request,
3341
+ res: express.Response,
3342
+ _next: express.NextFunction,
3343
+ ) => {
3344
+ res.status(err?.status ?? 500).json({ error: { message: err?.message ?? 'Request failed.' } });
3345
+ },
3346
+ );
3347
+
3348
+ // A bare GET is a 405 with the method named, not Express's default 404 —
3349
+ // the resource exists, the verb is wrong.
3350
+ app.get('/api/chat', (_req, res) => {
3351
+ res.status(405).json({ error: { message: 'Method not allowed — POST /api/chat.' } });
3352
+ });
3353
+
2511
3354
  // POST /api/chat: bridge a Pi RPC session to the browser as SSE.
2512
3355
  app.post('/api/chat', (req, res) => {
2513
- const { messages } = req.body as { messages: OpenAIWireMessage[] };
3356
+ let chatBody: { messages: OpenAIWireMessage[] };
3357
+ try {
3358
+ chatBody = readChatRequest(req);
3359
+ } catch (error) {
3360
+ toChatErrorResponse(error, res);
3361
+ return;
3362
+ }
3363
+ const { messages } = chatBody;
2514
3364
  const last = messages.at(-1)?.content;
2515
3365
  // \`content\` is a plain string until the turn carries an attachment, at which
2516
3366
  // point it is an ARRAY of content parts. Pi's RPC mode takes a TEXT prompt and
@@ -2715,24 +3565,86 @@ const mock = {
2715
3565
  streamFormat: "native",
2716
3566
  envVars: [],
2717
3567
  routeTemplates: {},
2718
- streamMapping: "No backend and no provider, but a real wire. createMockResponder() from @kitn.ai/ui/state yields canned SSE frames and the scaffold reads them with readOpenAIStream from @kitn.ai/ui/wire, on the same code path a real route's response takes: same reader, same part folding, same abort handling. The frames carry the OpenAI chat-completions shape because the mock stands in for your /api/chat ROUTE, not for a provider (every other integration here re-frames its provider to that shape server-side), so going live replaces ONE expression: mockResponse(value) becomes the awaited response from your own chat route, and nothing else in the handler changes. Nothing here can be mistaken for a real turn: the stream opens with a ': kai-mock' SSE comment, every frame carries a _kai_mock field naming createMockResponder, model reports as 'kai-mock' (no provider serves it), and usage is all zeros.",
2719
- runNote: "No backend or API key needed: the reply is generated in the browser and parsed by the same reader a real route feeds. Run the front-end as-is; swap `integration` for a real provider (openai, anthropic, openrouter, ollama) when ready, and the emitted handler differs by one expression.",
3568
+ webRoute: `import { createMockResponder } from '@kitn.ai/ui/state';
3569
+
3570
+ // The kit's own mock responder — no provider, no key, no upstream. MODULE
3571
+ // scope, not per-request: the responder owns the cursor into its canned
3572
+ // replies, so rebuilding it per turn would answer with the first one forever.
3573
+ const mockResponse = createMockResponder();
3574
+
3575
+ async function chatHandler(request: Request): Promise<Response> {
3576
+ // The responder cycles its replies whatever you send, so the prompt is a
3577
+ // courtesy — but the body is still read exactly the way a real route reads
3578
+ // it, so swapping this handler for a provider's changes nothing upstream of
3579
+ // this file.
3580
+ let chatBody: ChatRequestBody;
3581
+ try {
3582
+ chatBody = await readChatRequest(request);
3583
+ } catch (error) {
3584
+ return toChatErrorResponse(error);
3585
+ }
3586
+ const { messages } = chatBody;
3587
+ const last = [...messages].reverse().find((m) => m.role === 'user');
3588
+ const prompt = last && typeof last.content === 'string' ? last.content : '';
3589
+
3590
+ // createMockResponder() yields COMPLETE OpenAI chat-completions SSE frames —
3591
+ // the same strings the front end's local preview streams — so this route only
3592
+ // writes them out verbatim. No framing is built here; the browser parses them
3593
+ // with readOpenAIStream from @kitn.ai/ui/wire, exactly as it would a real
3594
+ // route's response. Every frame is marked as a mock (a ': kai-mock' banner, a
3595
+ // _kai_mock field, model 'kai-mock', zero usage).
3596
+ const encoder = new TextEncoder();
3597
+ let open = true;
3598
+ const body = new ReadableStream<Uint8Array>({
3599
+ async start(controller) {
3600
+ for await (const frame of mockResponse(prompt)) {
3601
+ // The browser hangs up when the user asks a new question mid-answer;
3602
+ // stop producing frames rather than writing into a cancelled stream.
3603
+ if (!open) return;
3604
+ controller.enqueue(encoder.encode(frame));
3605
+ }
3606
+ controller.close();
3607
+ },
3608
+ cancel() {
3609
+ open = false;
3610
+ },
3611
+ });
3612
+
3613
+ return new Response(body, {
3614
+ status: 200,
3615
+ headers: {
3616
+ 'Content-Type': 'text/event-stream; charset=utf-8',
3617
+ // no-transform stops a proxy buffering the stream into one blob.
3618
+ 'Cache-Control': 'no-cache, no-transform',
3619
+ Connection: 'keep-alive',
3620
+ // A proxy in front of a preview build would otherwise buffer the whole
3621
+ // stream and the reply would land all at once instead of streaming.
3622
+ 'X-Accel-Buffering': 'no',
3623
+ },
3624
+ });
3625
+ }`,
3626
+ streamMapping: "No backend and no provider, but a real wire. createMockResponder() from @kitn.ai/ui/state yields canned SSE frames and the scaffold reads them with readOpenAIStream from @kitn.ai/ui/wire, on the same code path a real route's response takes: same reader, same part folding, same abort handling. The frames carry the OpenAI chat-completions shape because the mock stands in for your /api/chat ROUTE, not for a provider (every other integration here re-frames its provider to that shape server-side), so going live replaces ONE expression: mockResponse(value) becomes the awaited response from your own chat route, and nothing else in the handler changes. Nothing here can be mistaken for a real turn: the stream opens with a ': kai-mock' SSE comment, every frame carries a _kai_mock field naming createMockResponder, model reports as 'kai-mock' (no provider serves it), and usage is all zeros. Block (2) serves the same frames over HTTP as an OPTIONAL route, so the /api/chat seam can be stood up before any provider exists.",
3627
+ runNote: "No backend or API key needed: the reply is generated in the browser and parsed by the same reader a real route feeds. Run the front-end as-is; swap `integration` for a real provider (openai, anthropic, openrouter, ollama) when ready, and the emitted handler differs by one expression. Block (2) is the OPTIONAL server half: the same mock frames served over HTTP, for standing up the /api/chat seam before any provider exists.",
2720
3628
  docsSlug: "integrations/mock",
2721
- // Nothing: there is no HTTP request at all. The frames are produced in-process
2722
- // by createMockResponder(), so there is no body to forward a model or a tools
2723
- // array on, and no route that would read one.
3629
+ // Nothing: the front end makes no HTTP request at all, and the optional route
3630
+ // reads only `messages` and only as a courtesy prompt the responder is free
3631
+ // to ignore. There is no model to pick and no tools array anything would run.
2724
3632
  forwardsFromClient: [],
2725
- // Nothing to install: there is no route to install anything for.
3633
+ // Nothing to install: the route's one import is the kit itself, which every
3634
+ // scaffold already depends on (registry.test.ts pins deps.npm to the route's
3635
+ // imports minus @kitn.ai/ui).
2726
3636
  deps: { npm: [], pip: [] },
2727
- // The other 'frontend-safe' entry, and the only one that is true by absence:
2728
- // no routeTemplates, no webRoute, no envVars. There is no request, no upstream
2729
- // and no secret, so there is nothing a public bundle could give away. This is
2730
- // the one place where "declares nothing" genuinely means safe — which is
2731
- // precisely why it still has to SAY so rather than be left blank.
3637
+ // The other 'frontend-safe' entry. The route now exists (G-04) but changes
3638
+ // nothing here: it holds no credential, sends no auth header and reaches no
3639
+ // upstream, so there is still nothing a public bundle could give away. The
3640
+ // schema's own refinement re-checks that claim against the route source.
2732
3641
  keyExposure: "frontend-safe",
2733
- // Nothing at all, and here that is the literal truth rather than a shorthand:
2734
- // there is no route, no upstream and no process. This is the "No backend"
2735
- // group of create-kai's gateway prompt all by itself.
3642
+ // Nothing to supply out of band: no upstream, no process, no runtime. The
3643
+ // route is optional the emitted app streams locally without it which is
3644
+ // what keeps mock in the "No backend" group of create-kai's gateway prompt
3645
+ // (listGatewayGroups derives that from frontend-safe + outOfBand 'none', not
3646
+ // from the absence of a route, precisely because this route is one nobody
3647
+ // NEEDS).
2736
3648
  outOfBand: "none"
2737
3649
  };
2738
3650
  const archetypes$1 = [
@@ -2764,9 +3676,41 @@ const archetypes$1 = [
2764
3676
  defaultPlacement: "side",
2765
3677
  docsSlug: "examples/agentic-assistant"
2766
3678
  },
3679
+ /**
3680
+ * THE FIRST OFFICIAL BLOCK (recast spec 2026-08-20 § 3b; F-16).
3681
+ *
3682
+ * This preset used to carry `kai-artifact` + `kai-resizable` — an UNWIRED
3683
+ * artifact split — while omitting the conversation rail, the one thing its
3684
+ * name promised (rung-3 finding F-16). It now names the workspace BLOCK: the
3685
+ * chat-agnostic `kai-workspace` layout shell, a WIRED `kai-conversations`
3686
+ * rail, `kai-chat` in the main region, and the `@kitn.ai/ui/state` thread
3687
+ * helpers (bindThreadMessages / createThreadSessions / createSaveScheduler /
3688
+ * parseStoredThread) with the persistence POLICY left in consumer-owned
3689
+ * lines. `examples/apps/workspace/` is the block's reference implementation.
3690
+ *
3691
+ * The artifact split did not vanish — it moved to `artifact-split` below, so
3692
+ * its renderer branch keeps its cells in `verify:scaffold`.
3693
+ */
2767
3694
  {
2768
3695
  id: "workspace",
2769
3696
  title: "Agentic workspace",
3697
+ components: ["kai-chat", "kai-workspace", "kai-conversations"],
3698
+ defaultPlacement: "full-page",
3699
+ docsSlug: "examples/workspace"
3700
+ },
3701
+ /**
3702
+ * THE PRESET THAT KEEPS A CAPABILITY ON THE AXIS — the same reason the
3703
+ * `attachments` preset exists (see its comment below): `listCapabilityGroups`
3704
+ * derives the gate's surface axis from this table, so when the recast moved
3705
+ * the artifact pair off `workspace`, this entry is what kept
3706
+ * `kai-artifact`+`kai-resizable` compiling in `verify:scaffold` instead of
3707
+ * silently losing every cell. The pair is ONE capability because `isArtifactSplit`
3708
+ * in scaffold.ts requires both: a split with no preview pane is an empty
3709
+ * panel, and a preview with no split has nowhere to sit.
3710
+ */
3711
+ {
3712
+ id: "artifact-split",
3713
+ title: "Artifact preview split",
2770
3714
  components: ["kai-chat", "kai-artifact", "kai-resizable"],
2771
3715
  defaultPlacement: "side",
2772
3716
  docsSlug: "examples/workspace"
@@ -2868,9 +3812,33 @@ const CHAT_REQUEST_BODY_DECL = [
2868
3812
  ` tools?: unknown[];`,
2869
3813
  `};`,
2870
3814
  ``,
2871
- `/** Narrow the JSON body once, at the edge. */`,
3815
+ `class ChatRequestError extends Error {`,
3816
+ ` constructor(readonly status: number, message: string) { super(message); }`,
3817
+ `}`,
3818
+ ``,
3819
+ `/** Narrow the JSON body once, at the edge. A bare GET, a malformed body, or a`,
3820
+ ` * missing messages array is a ChatRequestError with a status — NEVER an`,
3821
+ ` * unhandled SyntaxError: one killed a Vite dev server (findings F-10). */`,
2872
3822
  `async function readChatRequest(request: Request): Promise<ChatRequestBody> {`,
2873
- ` return (await request.json()) as ChatRequestBody;`,
3823
+ ` if (request.method !== 'POST') {`,
3824
+ ` throw new ChatRequestError(405, \`Method \${request.method} not allowed — POST /api/chat.\`);`,
3825
+ ` }`,
3826
+ ` let parsed: unknown;`,
3827
+ ` try { parsed = await request.json(); } catch {`,
3828
+ ` throw new ChatRequestError(400, 'Request body is not valid JSON.');`,
3829
+ ` }`,
3830
+ ` const body = parsed as ChatRequestBody;`,
3831
+ ` if (!Array.isArray(body?.messages)) {`,
3832
+ ` throw new ChatRequestError(400, 'Request body must carry a messages array.');`,
3833
+ ` }`,
3834
+ ` return body;`,
3835
+ `}`,
3836
+ ``,
3837
+ `/** Map a guard rejection to the Response its status demands; rethrow anything`,
3838
+ ` * else — an unexpected error should be loud, not laundered into a 400. */`,
3839
+ `function toChatErrorResponse(error: unknown): Response {`,
3840
+ ` if (error instanceof ChatRequestError) return Response.json({ error: error.message }, { status: error.status });`,
3841
+ ` throw error;`,
2874
3842
  `}`
2875
3843
  ];
2876
3844
  const CONTENT_PARTS_DECL = [
@@ -3022,8 +3990,9 @@ const text = (s) => ({
3022
3990
  content: [{ type: "text", text: s }]
3023
3991
  });
3024
3992
  const FLEX_FILL = "flex: 1; min-height: 0;";
3993
+ const TOAST_LAYER_NOTE = "The z-index stays BELOW 100: the kit's kai-toast-region (a body-level sibling toast() mounts) paints at z-index var(--kai-toast-z, 100), so a wrapper at or above it buries every toast.";
3025
3994
  const FULL_PAGE = {
3026
- style: "position: fixed; inset: 0; display: flex; flex-direction: column; text-align: start; z-index: 1000;",
3995
+ style: "position: fixed; inset: 0; display: flex; flex-direction: column; text-align: start; z-index: 90;",
3027
3996
  chatFill: FLEX_FILL,
3028
3997
  note: "fills the viewport (fixed, inset 0)",
3029
3998
  altNote: [
@@ -3033,6 +4002,7 @@ const FULL_PAGE = {
3033
4002
  "TanStack Start starter wraps every route in a Header + Footer that pushed the composer 13px",
3034
4003
  "below the fold at 1280x800. Fixed positioning escapes both, `text-align: start` undoes the",
3035
4004
  "inherited centring, and z-index keeps a sticky header from painting over the thread.",
4005
+ TOAST_LAYER_NOTE,
3036
4006
  "This DOES cover the chrome around it (nav included) — that is what full-page means here.",
3037
4007
  'Want the chat to sit INSIDE your own layout instead? Use placement: "inline".'
3038
4008
  ]
@@ -3049,18 +4019,20 @@ function placementStyle(placement) {
3049
4019
  };
3050
4020
  case "side":
3051
4021
  return {
3052
- style: "position: fixed; top: 0; inset-inline-end: 0; height: 100dvh; width: 380px; border-inline-start: 1px solid var(--kai-color-border); display: flex; flex-direction: column; z-index: 1000;",
4022
+ style: "position: fixed; top: 0; inset-inline-end: 0; height: 100dvh; width: 380px; border-inline-start: 1px solid var(--kai-color-border); display: flex; flex-direction: column; z-index: 90;",
3053
4023
  chatFill: FLEX_FILL,
3054
4024
  note: "full-height side panel, docked to the trailing edge (100dvh)",
3055
4025
  altNote: [
4026
+ TOAST_LAYER_NOTE,
3056
4027
  "In-flow alternative (push content instead of overlay): drop `position`/`z-index` and make this a `flex: 0 0 380px` column inside a `display: flex` row at `height: 100dvh`."
3057
4028
  ]
3058
4029
  };
3059
4030
  case "docked-widget":
3060
4031
  return {
3061
- style: "position: fixed; bottom: 1.5rem; inset-inline-end: 1.5rem; width: 380px; height: 600px; max-height: calc(100dvh - 3rem); border-radius: 16px; overflow: hidden; box-shadow: 0 12px 32px var(--kai-shadow-color, rgba(0,0,0,0.18)); display: flex; flex-direction: column; z-index: 1000;",
4032
+ style: "position: fixed; bottom: 1.5rem; inset-inline-end: 1.5rem; width: 380px; height: 600px; max-height: calc(100dvh - 3rem); border-radius: 16px; overflow: hidden; box-shadow: 0 12px 32px var(--kai-shadow-color, rgba(0,0,0,0.18)); display: flex; flex-direction: column; z-index: 90;",
3062
4033
  chatFill: FLEX_FILL,
3063
- note: "fixed, floating bottom-right widget"
4034
+ note: "fixed, floating bottom-right widget",
4035
+ altNote: [TOAST_LAYER_NOTE]
3064
4036
  };
3065
4037
  default:
3066
4038
  return FULL_PAGE;
@@ -3122,8 +4094,10 @@ function realStreamBody(opts) {
3122
4094
  cards = false,
3123
4095
  valueSource = "e.detail.value",
3124
4096
  afterValue = [],
4097
+ afterTurn = [],
3125
4098
  mock: mock2 = false,
3126
- filesExpr
4099
+ filesExpr,
4100
+ fetchSignal
3127
4101
  } = opts;
3128
4102
  const asConst = strictRoles ? " as const" : "";
3129
4103
  const historyType = strictRoles ? ": ChatMessage[]" : "";
@@ -3156,6 +4130,11 @@ function realStreamBody(opts) {
3156
4130
  `${indent} method: 'POST',`,
3157
4131
  `${indent} headers: { 'Content-Type': 'application/json' },`,
3158
4132
  `${indent} body: JSON.stringify(${bodyPayload(threadExpr)}),`,
4133
+ ...fetchSignal ? [
4134
+ `${indent} // The turn's own AbortSignal (from sessions.begin above): switching`,
4135
+ `${indent} // threads or deleting this one can cancel the request itself.`,
4136
+ `${indent} signal: ${fetchSignal},`
4137
+ ] : [],
3159
4138
  `${indent}});`,
3160
4139
  `${indent}// The finished turn: text, reasoning, tool calls, stop reason, usage. An`,
3161
4140
  `${indent}// error FRAME inside a 200 stream lands on turn.error, and whatever`,
@@ -3180,17 +4159,29 @@ function realStreamBody(opts) {
3180
4159
  `${pad} if (turn.error) console.error('Model error:', turn.error.message);`
3181
4160
  ],
3182
4161
  `${pad}} catch (err) {`,
4162
+ ...fetchSignal && !mock2 ? [
4163
+ `${pad} // A DELIBERATE abort (the thread was deleted, or a new turn began on it)`,
4164
+ `${pad} // is not a failure and has no one left to report it to.`,
4165
+ `${pad} if (${fetchSignal}.aborted) return;`
4166
+ ] : [],
3183
4167
  `${pad} // Without this a bad key is a permanently blank assistant bubble plus an`,
3184
- `${pad} // unhandled rejection. abort() settles the message and flips any tool`,
3185
- `${pad} // panel still waiting on a result to output-error, so nothing spins`,
3186
- `${pad} // forever; text that already streamed stays put.`,
3187
- `${pad} stream.abort(err instanceof Error ? err.message : 'Request failed');`,
3188
- `${pad} console.error(err); // swap in your own error surface (a toast, a banner)`,
4168
+ `${pad} // unhandled rejection. abort(reason) settles the message AND puts the`,
4169
+ `${pad} // reason where the reader can see it: onto any tool panel still waiting`,
4170
+ `${pad} // on a result (output-error, so nothing spins forever), or — on a`,
4171
+ `${pad} // text-only turn, which has no panel to carry it — as a new text part in`,
4172
+ `${pad} // the thread. Text that already streamed stays put.`,
4173
+ `${pad} // The reason is SHOWN TO THE USER. err.message is the honest default;`,
4174
+ `${pad} // swap in a sentence a visitor can read once you know your failure modes.`,
4175
+ `${pad} // The && is load-bearing: an Error is free to carry an empty message, and`,
4176
+ `${pad} // an empty reason is nothing to show — back to the blank bubble.`,
4177
+ `${pad} stream.abort(err instanceof Error && err.message ? err.message : 'Request failed');`,
4178
+ `${pad} console.error(err); // the console is for you; the thread is for them`,
3189
4179
  `${pad}} finally {`,
3190
4180
  `${pad} // done() SETTLES the message: every sink call after it is dropped, which`,
3191
4181
  `${pad} // is why the whole loop runs above it and not after.`,
3192
4182
  `${pad} stream.done();`,
3193
4183
  `${pad} ${setLoading("false")}`,
4184
+ ...afterTurn.map((l) => `${pad} ${l}`),
3194
4185
  `${pad}}`
3195
4186
  ].join("\n");
3196
4187
  }
@@ -3276,12 +4267,14 @@ function assertCardToolFormat(integration) {
3276
4267
  return provider;
3277
4268
  }
3278
4269
  function wireImportLines(opts) {
3279
- const { pad = "", typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock: mock2 = false, attachments = false } = opts;
4270
+ const { pad = "", typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock: mock2 = false, attachments = false, workspaceBlock = false } = opts;
3280
4271
  const stateNames = [
4272
+ ...workspaceBlock ? ["bindThreadMessages"] : [],
3281
4273
  "createAssistantStream",
3282
4274
  // The mock's canned reply comes from the kit, not from a copy pasted into
3283
4275
  // this file. One implementation, shared with create-kai and the starters.
3284
4276
  ...mock2 ? ["createMockResponder"] : [],
4277
+ ...workspaceBlock ? ["createSaveScheduler", "createThreadSessions", "parseStoredThread"] : [],
3285
4278
  ...attachments ? ["type AttachmentData"] : [],
3286
4279
  ...typed ? ["type ChatMessage"] : [],
3287
4280
  ...typed && setMessagesType ? ["type SetMessages"] : []
@@ -3409,11 +4402,197 @@ const CARD_PROP_COMMENT = [
3409
4402
  function jsxComment(lines, pad) {
3410
4403
  return lines.map((l) => `${pad}{/* ${l.replace(/^\/\/ ?/, "")} */}`);
3411
4404
  }
4405
+ function indentEmitted(lines, by) {
4406
+ return lines.flatMap((l) => l.split("\n")).map((l) => l === "" ? l : `${by}${l}`);
4407
+ }
3412
4408
  const MESSAGE_EMBEDDED_TAGS = /* @__PURE__ */ new Set(["kai-tool", "kai-reasoning"]);
3413
- const WORKSPACE_STRUCTURAL_TAGS = /* @__PURE__ */ new Set(["kai-resizable", "kai-artifact"]);
3414
- function isWorkspace(components) {
4409
+ const ARTIFACT_SPLIT_TAGS = /* @__PURE__ */ new Set(["kai-resizable", "kai-artifact"]);
4410
+ function isArtifactSplit(components) {
3415
4411
  return components.includes("kai-resizable") && components.includes("kai-artifact");
3416
4412
  }
4413
+ const BLOCK_TAGS = /* @__PURE__ */ new Set(["kai-workspace", "kai-conversations"]);
4414
+ function isWorkspaceBlock(components) {
4415
+ return components.includes("kai-workspace") && components.includes("kai-conversations");
4416
+ }
4417
+ function blockThreadStoreLines(pad = "") {
4418
+ return [
4419
+ `${pad}// ── The workspace block's thread store — YOUR code, from here down ─────────`,
4420
+ `${pad}//`,
4421
+ `${pad}// The record shape is yours (title policy, timestamps, whatever you add);`,
4422
+ `${pad}// the kit's helpers only require { id, messages } (ThreadLike).`,
4423
+ `${pad}interface Thread {`,
4424
+ `${pad} id: string;`,
4425
+ `${pad} title: string;`,
4426
+ `${pad} updatedAt: string;`,
4427
+ `${pad} messages: ChatMessage[];`,
4428
+ `${pad}}`,
4429
+ ``,
4430
+ `${pad}const THREADS_KEY = 'kai-workspace/threads/v1';`,
4431
+ ``,
4432
+ `${pad}// PERSISTENCE IS YOURS. localStorage here is a starting policy, not kit`,
4433
+ `${pad}// behavior: the backend, the quota, what is retained and for how long are`,
4434
+ `${pad}// application decisions. Swap these two functions for your API and nothing`,
4435
+ `${pad}// else in the block changes.`,
4436
+ `${pad}function loadThreads(): Thread[] {`,
4437
+ `${pad} let raw: string | null = null;`,
4438
+ `${pad} // Private-mode Safari throws on ACCESS: no storage is a working app with`,
4439
+ `${pad} // no history, not a crash on first paint.`,
4440
+ `${pad} try { raw = localStorage.getItem(THREADS_KEY); } catch { return []; }`,
4441
+ `${pad} if (!raw) return [];`,
4442
+ `${pad} try {`,
4443
+ `${pad} const stored: unknown = JSON.parse(raw);`,
4444
+ `${pad} if (!Array.isArray(stored)) return [];`,
4445
+ `${pad} return stored.flatMap((record): Thread[] => {`,
4446
+ `${pad} if (typeof record !== 'object' || record === null) return [];`,
4447
+ `${pad} const r = record as { id?: unknown; title?: unknown; updatedAt?: unknown; messages?: unknown };`,
4448
+ `${pad} if (typeof r.id !== 'string' || r.id === '') return [];`,
4449
+ `${pad} // The kit validates the MESSAGES (the shape it owns — the MessagePart`,
4450
+ `${pad} // variant list is derived, not hand-typed) and reports the drops;`,
4451
+ `${pad} // deciding what a drop means is this line, and it is yours.`,
4452
+ `${pad} const { messages, dropped } = parseStoredThread(r.messages);`,
4453
+ `${pad} if (dropped.length > 0) console.warn('[threads] dropped unreadable stored entries', dropped);`,
4454
+ `${pad} return [{`,
4455
+ `${pad} id: r.id,`,
4456
+ `${pad} title: typeof r.title === 'string' ? r.title : 'New conversation',`,
4457
+ `${pad} updatedAt: typeof r.updatedAt === 'string' ? r.updatedAt : new Date().toISOString(),`,
4458
+ `${pad} messages,`,
4459
+ `${pad} }];`,
4460
+ `${pad} });`,
4461
+ `${pad} } catch {`,
4462
+ `${pad} // Unparseable history is ignored, loudly enough to notice in dev.`,
4463
+ `${pad} console.warn('[threads] stored history could not be parsed and was ignored');`,
4464
+ `${pad} return [];`,
4465
+ `${pad} }`,
4466
+ `${pad}}`,
4467
+ ``,
4468
+ `${pad}function saveThreads(threads: Thread[]): void {`,
4469
+ `${pad} // QuotaExceededError is the realistic failure. Warn-and-continue is a`,
4470
+ `${pad} // POLICY (yours): the alternative — throwing out of a save mid-stream —`,
4471
+ `${pad} // would take the thread down with it.`,
4472
+ `${pad} try { localStorage.setItem(THREADS_KEY, JSON.stringify(threads)); }`,
4473
+ `${pad} catch (err) { console.warn('[threads] history could not be saved', err); }`,
4474
+ `${pad}}`,
4475
+ ``,
4476
+ `${pad}// First line of the first user turn, clipped — the fallback titling policy.`,
4477
+ `${pad}// Titles are what the rail's built-in search matches, so this is also the`,
4478
+ `${pad}// search index; swap in your own (a summariser, a user rename).`,
4479
+ `${pad}function deriveTitle(text: string): string {`,
4480
+ `${pad} const firstLine = text.trim().split('\\n', 1)[0] ?? '';`,
4481
+ `${pad} if (firstLine === '') return 'New conversation';`,
4482
+ `${pad} return firstLine.length > 48 ? firstLine.slice(0, 47) + '…' : firstLine;`,
4483
+ `${pad}}`
4484
+ ];
4485
+ }
4486
+ const BLOCK_RAIL_NOTE = [
4487
+ `The rail renders the ROWS it is handed (batteries mode) — a view of your`,
4488
+ `records, no message bodies. To own the row markup instead, slot your own`,
4489
+ `<kai-conversation-item> loop as light-DOM children (item mode): data rows`,
4490
+ `stop rendering while any item child is present, and selection still arrives`,
4491
+ `as kai-conversation-select.`
4492
+ ];
4493
+ const BLOCK_RAIL_NOTE_SOLID = [
4494
+ `The rail renders the ROWS it is handed (batteries mode) — a view of your`,
4495
+ `records, no message bodies. To own the row markup instead, pass your own`,
4496
+ `SlottedConversationItem loop via the \`items\` prop (item mode): data rows`,
4497
+ `stop rendering while items are present, and selection still arrives through`,
4498
+ `onSelect.`
4499
+ ];
4500
+ const BLOCK_TARGET_ID_NOTE = [
4501
+ `The thread that was open when the user hit send — not whichever is open`,
4502
+ `when the tokens land. Everything below binds to this id, which is what`,
4503
+ `makes switching away mid-reply safe.`
4504
+ ];
4505
+ const BLOCK_BIND_NOTE = [
4506
+ `bindThreadMessages routes every stream delta to THIS thread, with the`,
4507
+ `reactivity two-halves (new array + new object) handled by the helper.`,
4508
+ `\`touch\` is your policy hook — here it stamps updatedAt on every delta.`
4509
+ ];
4510
+ const BLOCK_SESSIONS_NOTE = [
4511
+ `One in-flight turn per thread: begin() aborts a prior turn on the same`,
4512
+ `thread and hands back its AbortController. Deleting a thread mid-stream is`,
4513
+ `sessions.abort(id) — the aborted turn's late deltas are dropped by the`,
4514
+ `bound sink instead of resurrecting the record.`
4515
+ ];
4516
+ function blockLiveThread(readExpr) {
4517
+ return {
4518
+ open: ({ pad, userMessage }) => [
4519
+ `${pad}// The STORE is the thread: the id-bound sink writes each delta back into`,
4520
+ `${pad}// the record, so every round below re-encodes the live, current value.`,
4521
+ `${pad}setMessages(() => [...${readExpr}, ${userMessage}]);`
4522
+ ],
4523
+ live: readExpr,
4524
+ setter: "setMessages"
4525
+ };
4526
+ }
4527
+ function slotPlacementFor(tag) {
4528
+ for (const r of surfaceRecipes) {
4529
+ for (const c of r.composition ?? []) {
4530
+ if (c.child === tag) return { parent: c.parent, slot: c.slot };
4531
+ }
4532
+ }
4533
+ return null;
4534
+ }
4535
+ const ALWAYS_EMITTED_TAG = "kai-chat";
4536
+ function slottedInChat(components) {
4537
+ return components.flatMap((tag) => {
4538
+ const placement = slotPlacementFor(tag);
4539
+ if (!placement || placement.parent !== ALWAYS_EMITTED_TAG) return [];
4540
+ return [{ tag, slot: placement.slot }];
4541
+ });
4542
+ }
4543
+ function railSiblingNote(tag, placement) {
4544
+ const w2 = conversationsWiring();
4545
+ const handler = `on${toPascalCase(w2.event)}`;
4546
+ return [
4547
+ ` {/* NOT WIRED, and NOT SLOTTED — both deliberate, both fixable here.`,
4548
+ ` COMPOSITION: the kit composes this as <${tag} slot="${placement.slot}"> INSIDE`,
4549
+ ` <${placement.parent}>, which renders it into a fixed-width ::part(${placement.slot}) column.`,
4550
+ ` The generated React wrappers take no \`slot\` prop (see WebComponentProps`,
4551
+ ` in @kitn.ai/ui/react), so this renders as a sibling instead. Drop to the`,
4552
+ ` raw <${placement.parent}> custom element if you want the slotted layout.`,
4553
+ ` WIRING: set ${w2.property}={...} and ${handler}={...} — the handler`,
4554
+ ` reads event.detail.id and calls setMessages with that thread. */}`
4555
+ ];
4556
+ }
4557
+ function htmlComment(lines, pad) {
4558
+ return lines.map(
4559
+ (l, i) => `${pad}${i === 0 ? "<!-- " : " "}${l}${i === lines.length - 1 ? " -->" : ""}`
4560
+ );
4561
+ }
4562
+ function slottedChildMarkup(components, pad, opts) {
4563
+ const w2 = conversationsWiring();
4564
+ return slottedInChat(components).flatMap((s) => [
4565
+ // What the shell DOES, and — the half a comment gets wrong by being generous
4566
+ // — what it does not. `<kai-chat>`'s sidebar region is one fixed-width aside
4567
+ // (`chat-thread.tsx`), exposed as a part and nothing more: it does not listen
4568
+ // for the rail's collapse, and it has no responsive behaviour, so a rail that
4569
+ // collapses inside it leaves the column exactly as wide as it was.
4570
+ ...htmlComment(
4571
+ [
4572
+ `SLOTTED, not a sibling: <kai-chat> renders this into its own`,
4573
+ `::part(${s.slot}) aside — a fixed-width column, and that is the whole of`,
4574
+ `what the shell does with it. Collapsing the rail does NOT narrow the`,
4575
+ `column: use the <kai-workspace> layout shell (start aside: startCollapsed /`,
4576
+ `collapseBelow / drawer-below) or your own layout if it has to collapse or resize.${opts.wired ? " src/main.ts drives the rail." : ""}`
4577
+ ],
4578
+ pad
4579
+ ),
4580
+ ...opts.wired ? [] : htmlComment(notWiredNote(s.tag, w2.property, w2.event), pad),
4581
+ `${pad}<${s.tag}${opts.ids ? ` id="${s.tag.replace(/^kai-/, "")}"` : ""} slot="${s.slot}" style="display:block;height:100%"></${s.tag}>`
4582
+ ]);
4583
+ }
4584
+ function notWiredNote(tag, property, event) {
4585
+ return [
4586
+ `NOT WIRED: <${tag}> needs el.${property} (a JS property — arrays are never`,
4587
+ `attributes) and a ${event} listener that sets kai-chat.messages from your own`,
4588
+ `thread store. The html target emits that wiring in full; ask the`,
4589
+ `component_reference MCP tool for the workspace-chat recipe to see the shape.`
4590
+ ];
4591
+ }
4592
+ function conversationsWiring() {
4593
+ const edge = surfaceRecipes.flatMap((r) => r.wiring).find((w2) => w2.from === "kai-conversations" && w2.to === "kai-chat" && w2.property === "messages");
4594
+ return { property: "conversations", event: edge?.event ?? "kai-conversation-select" };
4595
+ }
3417
4596
  const ATTACHMENT_TAGS = /* @__PURE__ */ new Set(["kai-file-upload", "kai-attachments"]);
3418
4597
  function hasAttachments(components) {
3419
4598
  return components.includes("kai-file-upload") && components.includes("kai-attachments");
@@ -3512,9 +4691,20 @@ function sampleSeedComment(isMock, pad, decl) {
3512
4691
  ];
3513
4692
  }
3514
4693
  function componentTags(components, chatFill) {
4694
+ const block = isWorkspaceBlock(components);
4695
+ const slotted = slottedInChat(components);
4696
+ const slottedTags = new Set(slotted.map((s) => s.tag));
4697
+ const chatSlotted = block ? slotted.filter((s) => !BLOCK_TAGS.has(s.tag)) : slotted;
3515
4698
  const companionTags = components.filter(
3516
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(hasAttachments(components) && ATTACHMENT_TAGS.has(t))
4699
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !ARTIFACT_SPLIT_TAGS.has(t) && !(block && BLOCK_TAGS.has(t)) && !slottedTags.has(t) && !(hasAttachments(components) && ATTACHMENT_TAGS.has(t))
3517
4700
  );
4701
+ const chatBlock = (pad) => chatSlotted.length === 0 ? [`${pad}<kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`] : [
4702
+ `${pad}<kai-chat id="chat" suggestion-mode="submit" style="${chatFill}">`,
4703
+ // wired: `htmlModule` below emits the property, the two listeners and
4704
+ // the thread map. This is the one target that does.
4705
+ ...slottedChildMarkup(components, `${pad} `, { wired: true, ids: true }),
4706
+ `${pad}</kai-chat>`
4707
+ ];
3518
4708
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
3519
4709
  const hasStandaloneCompanions = companionTags.length > 0;
3520
4710
  const lines = [];
@@ -3529,13 +4719,13 @@ function componentTags(components, chatFill) {
3529
4719
  ` </div>`
3530
4720
  );
3531
4721
  }
3532
- if (isWorkspace(components)) {
4722
+ if (isArtifactSplit(components)) {
3533
4723
  lines.push(
3534
4724
  ` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
3535
4725
  ` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
3536
4726
  ` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
3537
4727
  ` <kai-resizable-item size="40%" min="240px">`,
3538
- ` <kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`,
4728
+ ...chatBlock(" "),
3539
4729
  ` </kai-resizable-item>`,
3540
4730
  ` <kai-resizable-item min="280px">`,
3541
4731
  ` <!-- Replace src with your artifact URL or set .files for multi-file preview. -->`,
@@ -3544,7 +4734,7 @@ function componentTags(components, chatFill) {
3544
4734
  ` </kai-resizable>`
3545
4735
  );
3546
4736
  } else {
3547
- lines.push(` <kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`);
4737
+ lines.push(...chatBlock(" "));
3548
4738
  }
3549
4739
  if (hasEmbedded) {
3550
4740
  lines.push(
@@ -3566,12 +4756,35 @@ function componentTags(components, chatFill) {
3566
4756
  if (hasStandaloneCompanions) {
3567
4757
  lines.push(` <!-- wire data props — see the component_reference MCP tool -->`);
3568
4758
  }
4759
+ if (block) {
4760
+ return [
4761
+ ` <!-- ── The workspace BLOCK: the shell + the rail + your thread records ── -->`,
4762
+ ...htmlComment(
4763
+ [
4764
+ `<kai-workspace> is the chat-agnostic layout shell: header/start/main/`,
4765
+ `end/footer slots, aside resize + collapse, and a mobile drawer below`,
4766
+ `the drawer-below width. It knows nothing about chat.`
4767
+ ],
4768
+ " "
4769
+ ),
4770
+ ` <kai-workspace drawer-below="720" style="display:block;width:100%;height:100%">`,
4771
+ ...htmlComment([...BLOCK_RAIL_NOTE, `src/main.ts drives the rail.`], " "),
4772
+ ` <kai-conversations id="conversations" slot="start" style="display:block;height:100%"></kai-conversations>`,
4773
+ ` <!-- Unnamed children project into the shell's MAIN region. -->`,
4774
+ ` <div style="display:flex;flex-direction:column;height:100%;min-height:0">`,
4775
+ ...lines.map((l) => l === "" ? l : ` ${l}`),
4776
+ ` </div>`,
4777
+ ` </kai-workspace>`
4778
+ ].join("\n");
4779
+ }
3569
4780
  return lines.join("\n");
3570
4781
  }
3571
4782
  function htmlModule(ctx, components) {
3572
4783
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
3573
4784
  const hasSources = components.includes("kai-sources");
3574
4785
  const attachments = hasAttachments(components);
4786
+ const block = isWorkspaceBlock(components);
4787
+ const conversations = !block && slottedInChat(components).some((s) => s.tag === "kai-conversations");
3575
4788
  const attachmentModuleLines = attachments ? [
3576
4789
  ...ATTACHMENT_WIRE_NOTE,
3577
4790
  `//`,
@@ -3616,6 +4829,152 @@ function htmlModule(ctx, components) {
3616
4829
  ` sourcesEl.sources = sampleSources;`,
3617
4830
  ``
3618
4831
  ] : [];
4832
+ const conversationsSetupLines = conversations ? [
4833
+ ` const conversationsEl = document.getElementById('conversations') as KaiConversationsElement;`,
4834
+ ` // Same upgrade rule as <kai-chat>: 'conversations' is an array, so it is a`,
4835
+ ` // PROPERTY, and a property set before the element upgrades is dropped on upgrade.`,
4836
+ ` await customElements.whenDefined('kai-conversations');`,
4837
+ ` // The row shape, DERIVED from the property it is assigned to — it cannot`,
4838
+ ` // drift out of step with the element the way a hand-written copy would.`,
4839
+ ` type Conversation = KaiConversationsElement['conversations'][number];`,
4840
+ ` // Replace this list with your own. Your store owns titles, grouping and order;`,
4841
+ ` // the rail renders what it is given, in the order it is given.`,
4842
+ ` const now = new Date().toISOString();`,
4843
+ ` let conversationRows: Conversation[] = [`,
4844
+ ` { id: 'c1', title: 'New chat', scope: { type: 'document' }, messageCount: 0, lastMessageAt: now, updatedAt: now },`,
4845
+ ` ];`,
4846
+ ` let activeId = conversationRows[0].id;`,
4847
+ ` // The threads you have open, keyed by conversation id. It starts EMPTY on`,
4848
+ ` // purpose: seeding it would put words in the user's mouth. Load a thread`,
4849
+ ` // from your backend in the select handler below and this fills itself.`,
4850
+ ` const threads: Record<string, KaiChatElement['messages']> = {};`,
4851
+ ` // A NEW array per write — the array reference is what notifies.`,
4852
+ ` const showConversations = () => {`,
4853
+ ` conversationsEl.conversations = [...conversationRows];`,
4854
+ ` conversationsEl.activeId = activeId;`,
4855
+ ` };`,
4856
+ ` showConversations();`,
4857
+ ``,
4858
+ ` // The row's title, derived the way every chat app derives it: the first`,
4859
+ ` // thing the user said, on one line. Swap in your own — a summariser, or`,
4860
+ ` // whatever the user renamed the thread to — this is the fallback, not a`,
4861
+ ` // policy. Returns undefined for an empty thread so the row keeps the`,
4862
+ ` // placeholder it was created with rather than losing its label.`,
4863
+ ` const TITLE_MAX = 48;`,
4864
+ ` const titleFrom = (messages: KaiChatElement['messages']): string | undefined => {`,
4865
+ ` const first = messages.find((message) => message.role === 'user');`,
4866
+ ` // flatMap, not filter: a message can open with file parts, and this`,
4867
+ ` // narrows the part union without needing a type predicate.`,
4868
+ ` const text = (first?.parts ?? [])`,
4869
+ ` .flatMap((part) => (part.type === 'text' ? [part.text] : []))`,
4870
+ ` .join(' ')`,
4871
+ ` .replace(/\\s+/g, ' ')`,
4872
+ ` .trim();`,
4873
+ ` if (!text) return undefined;`,
4874
+ ` return text.length > TITLE_MAX ? \`\${text.slice(0, TITLE_MAX).trimEnd()}…\` : text;`,
4875
+ ` };`,
4876
+ ``,
4877
+ ` // The row and the thread are two views of one conversation, and only the`,
4878
+ ` // HOST keeps them in step: <kai-conversations> renders what it is handed and`,
4879
+ ` // reports what was clicked, nothing more. Without this the rail reads`,
4880
+ ` // "New chat / 0 messages / just now" forever, whatever the user does.`,
4881
+ ` const syncActiveRow = () => {`,
4882
+ ` const messages = chat.messages ?? [];`,
4883
+ ` const at = new Date().toISOString();`,
4884
+ ` // BOTH HALVES OF THE CONTRACT, and the rail is silently stale without`,
4885
+ ` // either. A NEW ARRAY is what NOTIFIES — handing the same array back is a`,
4886
+ ` // no-op even when a row inside it changed. A NEW OBJECT for the row that`,
4887
+ ` // changed is what makes it VISIBLE, because the rail renders rows through`,
4888
+ ` // a reference-keyed list and never re-renders a row whose identity held.`,
4889
+ ` // So: map to a fresh row. Never \`row.messageCount = …\` — that mutation`,
4890
+ ` // updates the data and leaves the screen showing the old numbers.`,
4891
+ ` conversationRows = conversationRows.map((row) =>`,
4892
+ ` row.id === activeId`,
4893
+ ` ? {`,
4894
+ ` ...row,`,
4895
+ ` title: titleFrom(messages) ?? row.title,`,
4896
+ ` messageCount: messages.length,`,
4897
+ ` lastMessageAt: at,`,
4898
+ ` updatedAt: at,`,
4899
+ ` }`,
4900
+ ` : row,`,
4901
+ ` );`,
4902
+ ` showConversations();`,
4903
+ ` };`,
4904
+ ``,
4905
+ ` conversationsEl.addEventListener('kai-conversation-select', (event: Event) => {`,
4906
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
4907
+ ` // Keep what is on screen before switching away from it.`,
4908
+ ` threads[activeId] = chat.messages ?? [];`,
4909
+ ` activeId = id;`,
4910
+ ` // ...and load the one picked. Fetch it here if your store is remote.`,
4911
+ ` chat.messages = [...(threads[id] ?? [])];`,
4912
+ ` showConversations();`,
4913
+ ` });`,
4914
+ ` conversationsEl.addEventListener('kai-new-chat', () => {`,
4915
+ ` // The event carries no detail by design: it IS the whole signal.`,
4916
+ ` threads[activeId] = chat.messages ?? [];`,
4917
+ ` const at = new Date().toISOString();`,
4918
+ ` activeId = \`c\${conversationRows.length + 1}\`;`,
4919
+ ` conversationRows = [`,
4920
+ ` { id: activeId, title: 'New chat', scope: { type: 'document' }, messageCount: 0, lastMessageAt: at, updatedAt: at },`,
4921
+ ` ...conversationRows,`,
4922
+ ` ];`,
4923
+ ` chat.messages = [];`,
4924
+ ` showConversations();`,
4925
+ ` });`,
4926
+ ``
4927
+ ] : [];
4928
+ const blockSetupLines = block ? [
4929
+ ` // ── The block's state: your records + the kit's session/persistence helpers ─`,
4930
+ ` const conversationsEl = document.getElementById('conversations') as KaiConversationsElement;`,
4931
+ ` // Same upgrade rule as <kai-chat>: 'conversations' is an array, so it is a`,
4932
+ ` // PROPERTY, and a property set before the element upgrades is dropped on upgrade.`,
4933
+ ` await customElements.whenDefined('kai-conversations');`,
4934
+ ``,
4935
+ ` let threads: Thread[] = loadThreads();`,
4936
+ ` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
4937
+ ` // record is created on its first turn, so the rail never fills with empties.`,
4938
+ ` let activeId: string | null = threads[0]?.id ?? null;`,
4939
+ ...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
4940
+ ` const sessions = createThreadSessions(() => showWorkspace());`,
4941
+ ` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
4942
+ ` // the save target and what a failed save means are YOURS (see saveThreads).`,
4943
+ ` const saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
4944
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
4945
+ ` window.addEventListener('beforeunload', () => saver.flush());`,
4946
+ ` // Every write goes through this: schedule the save and re-project the views.`,
4947
+ ` const setThreads = (updater: (prev: Thread[]) => Thread[]): void => {`,
4948
+ ` threads = updater(threads);`,
4949
+ ` saver.schedule(threads);`,
4950
+ ` showWorkspace();`,
4951
+ ` };`,
4952
+ ``,
4953
+ ` // Project the records onto the elements. NEW arrays per write — the array`,
4954
+ ` // reference is what notifies (reactivity-two-halves; the changed objects`,
4955
+ ` // inside come from the helpers' folds).`,
4956
+ ` function showWorkspace(): void {`,
4957
+ ` // The rail's row shape is a VIEW of your record — titles and counts, no`,
4958
+ ` // message bodies. The rail renders the array in the order it is given.`,
4959
+ ` conversationsEl.conversations = threads.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt }));`,
4960
+ ` conversationsEl.activeId = activeId ?? undefined;`,
4961
+ ` chat.messages = [...(threads.find((t) => t.id === activeId)?.messages ?? [])];`,
4962
+ ` chat.loading = activeId !== null && sessions.isStreaming(activeId);`,
4963
+ ` }`,
4964
+ ` showWorkspace();`,
4965
+ ``,
4966
+ ` conversationsEl.addEventListener('kai-conversation-select', (event: Event) => {`,
4967
+ ` activeId = (event as CustomEvent<{ id: string }>).detail.id;`,
4968
+ ` showWorkspace();`,
4969
+ ` });`,
4970
+ ` conversationsEl.addEventListener('kai-new-chat', () => {`,
4971
+ ` // The event carries no detail by design: it IS the whole signal. A DRAFT,`,
4972
+ ` // not a record — see activeId above.`,
4973
+ ` activeId = null;`,
4974
+ ` showWorkspace();`,
4975
+ ` });`,
4976
+ ``
4977
+ ] : [];
3619
4978
  const modelLines = ctx.defaultModel ? [
3620
4979
  `// SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
3621
4980
  `const model = '${ctx.defaultModel}';`,
@@ -3633,9 +4992,13 @@ function htmlModule(ctx, components) {
3633
4992
  const elementTypes = [
3634
4993
  "KaiChatElement",
3635
4994
  ...hasSources ? ["KaiSourcesElement"] : [],
3636
- ...attachments ? ["KaiAttachmentsElement"] : []
4995
+ ...attachments ? ["KaiAttachmentsElement"] : [],
4996
+ // Same rule again: only when the rail is really on this surface. It types the
4997
+ // property assignments AND supplies the row type, so it is used twice over.
4998
+ // The block drives the same element from its own thread store.
4999
+ ...conversations || block ? ["KaiConversationsElement"] : []
3637
5000
  ].join(", ");
3638
- const annotatesChatMessage = !ctx.emitToolLoop;
5001
+ const annotatesChatMessage = !ctx.emitToolLoop || block;
3639
5002
  const head = [
3640
5003
  `// src/main.ts — the page's logic, in a module YOUR build type-checks.`,
3641
5004
  `//`,
@@ -3654,13 +5017,15 @@ function htmlModule(ctx, components) {
3654
5017
  cards: ctx.emitCards,
3655
5018
  cardTools: ctx.cardProvider !== null,
3656
5019
  mock: ctx.isMock,
3657
- attachments
5020
+ attachments,
5021
+ workspaceBlock: block
3658
5022
  }),
3659
5023
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
3660
5024
  ``,
3661
5025
  ...ctx.isMock ? [...mockResponderInit(), ``] : [],
3662
5026
  ...modelLines,
3663
5027
  ...attachmentModuleLines,
5028
+ ...block ? [...blockThreadStoreLines(""), ``] : [],
3664
5029
  ...cardsLines,
3665
5030
  ...toolsLines,
3666
5031
  ...runnerLines,
@@ -3677,7 +5042,9 @@ function htmlModule(ctx, components) {
3677
5042
  ...cardPropLines,
3678
5043
  ...seedLines,
3679
5044
  ...sourcesSetupLines,
3680
- ...attachmentSetupLines
5045
+ ...attachmentSetupLines,
5046
+ ...conversationsSetupLines,
5047
+ ...blockSetupLines
3681
5048
  ];
3682
5049
  const listenerOpen = [
3683
5050
  ` chat.addEventListener('kai-submit', async (event: Event) => {`,
@@ -3690,33 +5057,62 @@ function htmlModule(ctx, components) {
3690
5057
  `// A <script type="module"> is deferred, so the DOM is already parsed here.`,
3691
5058
  `void init();`
3692
5059
  ];
5060
+ const blockPrelude = block ? [
5061
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
5062
+ `const targetId = activeId ?? crypto.randomUUID();`,
5063
+ `// The record is born on its first turn — with YOUR title policy.`,
5064
+ `setThreads((prev) => prev.some((t) => t.id === targetId)`,
5065
+ ` ? prev`,
5066
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
5067
+ `activeId = targetId;`,
5068
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
5069
+ `const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
5070
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
5071
+ `});`,
5072
+ `const controller = sessions.begin(targetId);`,
5073
+ `showWorkspace();`
5074
+ ] : [];
3693
5075
  return [
3694
5076
  ...head,
3695
5077
  ` // messages is a JS PROPERTY (objects can't be HTML attributes)`,
3696
5078
  ...listenerOpen,
3697
5079
  realStreamBody({
3698
5080
  pad: " ",
3699
- read: "chat.messages",
3700
- commitSet: (expr) => `chat.messages = ${expr};`,
3701
- setterAdapter: "(fn) => { chat.messages = fn(chat.messages); }",
3702
- setLoading: (v2) => `chat.loading = ${v2};`,
5081
+ read: block ? `(threads.find((t) => t.id === targetId)?.messages ?? [])` : "chat.messages",
5082
+ commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => `chat.messages = ${expr};`,
5083
+ setterAdapter: block ? "setMessages" : "(fn) => { chat.messages = fn(chat.messages); }",
5084
+ setLoading: block ? (v2) => v2 === "true" ? `// loading for THIS thread now derives from the sessions map (showWorkspace).` : `sessions.end(targetId, controller); showWorkspace();` : (v2) => `chat.loading = ${v2};`,
3703
5085
  bodyPayload: realBodyPayload({ defaultModel: ctx.defaultModel, tools: ctx.emitTools }),
3704
5086
  strictRoles: true,
3705
5087
  toolLoop: ctx.emitToolLoop,
3706
5088
  cards: ctx.emitCards,
3707
- thread: liveThreadBinding(
5089
+ thread: block ? blockLiveThread(`(threads.find((t) => t.id === targetId)?.messages ?? [])`) : liveThreadBinding(
3708
5090
  "chat.messages",
3709
5091
  "(fn) => { chat.messages = fn(chat.messages); }",
3710
5092
  "chat.messages ?? []"
3711
5093
  ),
3712
5094
  mock: ctx.isMock,
5095
+ ...block ? { fetchSignal: "controller.signal", afterValue: blockPrelude } : {},
5096
+ // ONE call site, in the `finally`, and deliberately not also on submit. The
5097
+ // finally sees the WHOLE turn — the user's message and the assistant's — so
5098
+ // one call cannot leave the row disagreeing with the thread, and it runs on
5099
+ // the error path too, where the user's message is on the thread regardless.
5100
+ // The cost is that the title appears when the reply settles rather than the
5101
+ // instant the user hits send; call it right after `chat.messages = history`
5102
+ // as well if you want it sooner.
5103
+ afterTurn: conversations ? [`syncActiveRow(); // keep the rail's row in step with the thread`] : [],
3713
5104
  ...attachments ? {
3714
5105
  filesExpr: "files",
3715
- afterValue: attachmentTurnLines({
3716
- stagedExpr: "staged",
3717
- clear: ["staged = [];", "showStaged();"],
3718
- fromSubmitEvent: true
3719
- })
5106
+ // The attachment capture must read the staging list before anything
5107
+ // clears it, so it precedes the block's prelude.
5108
+ afterValue: [
5109
+ ...attachmentTurnLines({
5110
+ stagedExpr: "staged",
5111
+ clear: ["staged = [];", "showStaged();"],
5112
+ fromSubmitEvent: true
5113
+ }),
5114
+ ...blockPrelude
5115
+ ]
3720
5116
  } : {}
3721
5117
  }),
3722
5118
  ...footer
@@ -3755,14 +5151,17 @@ function toPascalCase(tag) {
3755
5151
  function renderJsx(components, ctx, framework) {
3756
5152
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
3757
5153
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
3758
- const workspace = isWorkspace(components);
3759
- const renderableTags = components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t));
5154
+ const workspace = isArtifactSplit(components);
5155
+ const block = isWorkspaceBlock(components);
5156
+ const renderableTags = [
5157
+ .../* @__PURE__ */ new Set([ALWAYS_EMITTED_TAG, ...components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t))])
5158
+ ];
3760
5159
  const importTags = workspace ? [.../* @__PURE__ */ new Set([...renderableTags.filter((t) => t !== "kai-resizable"), "kai-resizable", "kai-resizable-item"])] : renderableTags;
3761
5160
  const wrapperNames = importTags.map(toPascalCase);
3762
5161
  const importList = wrapperNames.join(", ");
3763
5162
  const attachments = hasAttachments(components);
3764
5163
  const standaloneCompanionTags = components.filter(
3765
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
5164
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !ARTIFACT_SPLIT_TAGS.has(t) && !(block && BLOCK_TAGS.has(t)) && !(attachments && ATTACHMENT_TAGS.has(t))
3766
5165
  );
3767
5166
  const companionJsxLines = [];
3768
5167
  if (hasEmbedded) {
@@ -3777,6 +5176,8 @@ function renderJsx(components, ctx, framework) {
3777
5176
  ` {/* Replace sampleSources with your real data. */}`,
3778
5177
  ` <Sources sources={sampleSources} />`
3779
5178
  );
5179
+ } else if (slotPlacementFor(t)) {
5180
+ companionJsxLines.push(...railSiblingNote(t, slotPlacementFor(t)), ` <${toPascalCase(t)} />`);
3780
5181
  } else {
3781
5182
  companionJsxLines.push(` {/* wire data props — see the component_reference MCP tool */}`);
3782
5183
  companionJsxLines.push(` <${toPascalCase(t)} />`);
@@ -3819,13 +5220,54 @@ function renderJsx(components, ctx, framework) {
3819
5220
  } : {};
3820
5221
  const submitParamType = attachments ? "CustomEvent<{ value: string; attachments: AttachmentData[] }>" : "CustomEvent<{ value: string }>";
3821
5222
  const mockInit = isMock ? mockResponderInit() : [];
3822
- const sampleMessagesInit = [
5223
+ const sampleMessagesInit = block ? [
5224
+ ` // ── The block's state: your records + the kit's session/persistence helpers ─`,
5225
+ ` const [threads, setThreads] = useState<Thread[]>(loadThreads);`,
5226
+ ` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
5227
+ ` // record is created on its first turn, so the rail never fills with empties.`,
5228
+ ` const [activeId, setActiveId] = useState<string | null>(threads[0]?.id ?? null);`,
5229
+ ` // One id per in-flight reply — a stream keeps running when you switch away,`,
5230
+ ` // so this cannot be a single boolean.`,
5231
+ ` const [streamingIds, setStreamingIds] = useState<string[]>([]);`,
5232
+ ...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
5233
+ ` const [sessions] = useState(() => createThreadSessions(setStreamingIds));`,
5234
+ ` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
5235
+ ` // the save target and what a failed save means are YOURS (see saveThreads).`,
5236
+ ` const [saver] = useState(() => createSaveScheduler(saveThreads, { delayMs: 250 }));`,
5237
+ ` useEffect(() => { saver.schedule(threads); }, [saver, threads]);`,
5238
+ ` useEffect(() => {`,
5239
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
5240
+ ` const flush = () => saver.flush();`,
5241
+ ` window.addEventListener('beforeunload', flush);`,
5242
+ ` return () => window.removeEventListener('beforeunload', flush);`,
5243
+ ` }, [saver]);`,
5244
+ ``,
5245
+ ` const active = threads.find((t) => t.id === activeId) ?? null;`,
5246
+ ` const messages = active?.messages ?? NO_MESSAGES;`,
5247
+ ` const loading = activeId !== null && streamingIds.includes(activeId);`,
5248
+ ` // The rail's row shape is a VIEW of your record — titles and counts, no`,
5249
+ ` // message bodies. Newest-first is your ordering; the rail renders the array`,
5250
+ ` // in the order it is given.`,
5251
+ ` const rows = threads.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt }));`
5252
+ ].join("\n") : [
3823
5253
  ...hasEmbedded ? sampleSeedComment(isMock, " ", (literal) => [
3824
5254
  `const sampleMessages: ChatMessage[] = [${literal}];`,
3825
5255
  `const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`
3826
5256
  ]) : [],
3827
5257
  ` const [messages, setMessages] = useState<ChatMessage[]>([]);`
3828
5258
  ].join("\n");
5259
+ const blockModuleInit = block ? [
5260
+ // One joined entry so the JSX branches' blank-line filter cannot squash
5261
+ // the store's own paragraph breaks.
5262
+ [
5263
+ `/** A stable reference for the empty thread. \`messages\` is diffed by`,
5264
+ ` * reference, so a fresh \`[]\` every render would re-notify for nothing. */`,
5265
+ `const NO_MESSAGES: ChatMessage[] = [];`,
5266
+ ``,
5267
+ ...blockThreadStoreLines("")
5268
+ ].join("\n")
5269
+ ] : [];
5270
+ const loadingInit = block ? [] : [` const [loading, setLoading] = useState(false);`];
3829
5271
  const sampleSourcesInit = standaloneCompanionTags.includes("kai-sources") ? [
3830
5272
  ` // Replace sampleSources with your real source data.`,
3831
5273
  ` const sampleSources = [`,
@@ -3840,21 +5282,109 @@ function renderJsx(components, ctx, framework) {
3840
5282
  const cardsInit = ctx.emitCards ? cardRegistryLines("") : [];
3841
5283
  const cardProps = (pad) => ctx.emitCards ? [`${pad}cardTypes={cards.tags}`, `${pad}cardSchemas={cards.validationSchemas}`] : [];
3842
5284
  const cardPropsNote = (pad) => ctx.emitCards ? jsxComment(CARD_PROP_COMMENT, pad) : [];
5285
+ const blockPrelude = block ? [
5286
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
5287
+ `const targetId = activeId ?? crypto.randomUUID();`,
5288
+ `// The record is born on its first turn — with YOUR title policy.`,
5289
+ `setThreads((prev) => prev.some((t) => t.id === targetId)`,
5290
+ ` ? prev`,
5291
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
5292
+ `setActiveId(targetId);`,
5293
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
5294
+ `const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
5295
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
5296
+ `});`,
5297
+ `const controller = sessions.begin(targetId);`
5298
+ ] : [];
5299
+ const blockReactThread = {
5300
+ open: ({ pad, userMessage }) => [
5301
+ `${pad}// THE TURN OWNS THE THREAD (same rule as the single-thread scaffold): the`,
5302
+ `${pad}// loop below re-encodes it every round, and React state cannot be read`,
5303
+ `${pad}// back mid-turn. setMessages (the id-bound sink) just projects it.`,
5304
+ `${pad}let thread: ChatMessage[] = [...(threads.find((t) => t.id === targetId)?.messages ?? []), ${userMessage}];`,
5305
+ `${pad}const set: SetMessages = (fn) => { thread = fn(thread); setMessages(() => thread); };`,
5306
+ `${pad}setMessages(() => thread);`
5307
+ ],
5308
+ live: "thread",
5309
+ setter: "set"
5310
+ };
3843
5311
  const onSubmitBody = realStreamBody({
3844
5312
  pad: " ",
3845
- read: "messages",
3846
- commitSet: (expr) => `setMessages(${expr});`,
3847
- // useState's setter IS a SetMessages: both are (updater) => void.
5313
+ read: block ? `(threads.find((t) => t.id === targetId)?.messages ?? [])` : "messages",
5314
+ commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => `setMessages(${expr});`,
5315
+ // useState's setter IS a SetMessages: both are (updater) => void — and in
5316
+ // block mode the id-bound sink from bindThreadMessages is one too.
3848
5317
  setterAdapter: "setMessages",
3849
- setLoading: (v2) => `setLoading(${v2});`,
5318
+ setLoading: block ? (v2) => v2 === "true" ? `// loading for THIS thread now derives from the sessions map (streamingIds).` : `sessions.end(targetId, controller);` : (v2) => `setLoading(${v2});`,
3850
5319
  bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
3851
5320
  strictRoles: true,
3852
5321
  toolLoop: emitToolLoop,
3853
5322
  cards: ctx.emitCards,
3854
- thread: REACT_THREAD,
5323
+ thread: block ? blockReactThread : REACT_THREAD,
3855
5324
  mock: isMock,
3856
- ...attachmentSubmitOpts
5325
+ ...block ? { fetchSignal: "controller.signal" } : {},
5326
+ ...attachmentSubmitOpts,
5327
+ ...block ? {
5328
+ afterValue: [
5329
+ ...attachmentSubmitOpts.afterValue ?? [],
5330
+ ...blockPrelude
5331
+ ]
5332
+ } : {}
3857
5333
  });
5334
+ const chatJsx = (pad) => [
5335
+ ...cardPropsNote(pad),
5336
+ `${pad}<Chat`,
5337
+ `${pad} messages={messages}`,
5338
+ `${pad} loading={loading}`,
5339
+ `${pad} suggestions={suggestions}`,
5340
+ `${pad} suggestionMode="submit"`,
5341
+ ...cardProps(`${pad} `),
5342
+ `${pad} onSubmit={onSubmit}`,
5343
+ `${pad} style={{ ${jsxStyle(p.chatFill)} }}`,
5344
+ `${pad}/>`
5345
+ ];
5346
+ const splitJsx = [
5347
+ ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
5348
+ ` {/* Resizable needs ResizableItem children to render panels. */}`,
5349
+ ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
5350
+ ` <ResizableItem size="40%" min="240px">`,
5351
+ ...chatJsx(" "),
5352
+ ` </ResizableItem>`,
5353
+ ` <ResizableItem min="280px">`,
5354
+ ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
5355
+ ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
5356
+ ` </ResizableItem>`,
5357
+ ` </Resizable>`
5358
+ ];
5359
+ const mainJsx = [
5360
+ ...attachmentJsx ? [attachmentJsx] : [],
5361
+ ...workspace ? [...splitJsx, companions] : [...chatJsx(" "), companions]
5362
+ ];
5363
+ const surfaceJsx = block ? [
5364
+ ` {/* ── The workspace BLOCK: the shell + the rail + your thread records ── */}`,
5365
+ ` {/* <Workspace> is the chat-agnostic layout shell (header/start/main/end/`,
5366
+ ` footer slots, aside resize + collapse, a mobile drawer below`,
5367
+ ` drawerBelow px). It knows nothing about chat. */}`,
5368
+ ` <Workspace drawerBelow={720} style={{ display: 'block', width: '100%', height: '100%' }}>`,
5369
+ ...jsxComment(BLOCK_RAIL_NOTE, " "),
5370
+ ` {/* The React wrappers forward no slot prop, so a plain div carries the`,
5371
+ ` slot attribute into the shell's start aside. */}`,
5372
+ ` <div slot="start" style={{ height: '100%' }}>`,
5373
+ ` {/* New chat = a DRAFT (activeId null): the record appears on the first turn. */}`,
5374
+ ` <Conversations`,
5375
+ ` conversations={rows}`,
5376
+ ` activeId={activeId ?? undefined}`,
5377
+ ` onConversationSelect={(e) => setActiveId(e.detail.id)}`,
5378
+ ` onNewChat={() => setActiveId(null)}`,
5379
+ ` style={{ display: 'block', height: '100%' }}`,
5380
+ ` />`,
5381
+ ` </div>`,
5382
+ ` {/* Unnamed children project into the shell's MAIN region. */}`,
5383
+ ` <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>`,
5384
+ ...indentEmitted(mainJsx, " "),
5385
+ ` </div>`,
5386
+ ` </Workspace>`
5387
+ ] : mainJsx;
3858
5388
  const useClientDirective = framework === "next" ? [`'use client';`, ``] : [];
3859
5389
  if (framework === "next") {
3860
5390
  const dynamicImports = wrapperNames.map(
@@ -3864,7 +5394,7 @@ function renderJsx(components, ctx, framework) {
3864
5394
  return [
3865
5395
  // 'use client' must be the very first line for Next.js App Router.
3866
5396
  ...useClientDirective,
3867
- `import { useState } from 'react';`,
5397
+ `import { ${block ? "useEffect, useState" : "useState"} } from 'react';`,
3868
5398
  `import dynamic from 'next/dynamic';`,
3869
5399
  // The adapter is pure parsing + pure state; both entries are SSR-import-safe,
3870
5400
  // so they stay static imports even though the ELEMENTS have to be dynamic.
@@ -3875,7 +5405,8 @@ function renderJsx(components, ctx, framework) {
3875
5405
  cards: ctx.emitCards,
3876
5406
  cardTools: ctx.cardProvider !== null,
3877
5407
  mock: isMock,
3878
- attachments
5408
+ attachments,
5409
+ workspaceBlock: block
3879
5410
  }),
3880
5411
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
3881
5412
  `// <kai-*> are client-only custom elements (the server has no customElements`,
@@ -3889,10 +5420,11 @@ function renderJsx(components, ctx, framework) {
3889
5420
  ...mockInit,
3890
5421
  ``,
3891
5422
  ...attachmentModuleInit,
5423
+ ...blockModuleInit,
3892
5424
  ...cardsInit,
3893
5425
  `export default function App() {`,
3894
5426
  sampleMessagesInit,
3895
- ` const [loading, setLoading] = useState(false);`,
5427
+ ...loadingInit,
3896
5428
  ` const suggestions = ${jsArray(suggestions)};`,
3897
5429
  ...attachmentStateInit ? [attachmentStateInit] : [],
3898
5430
  ...sampleSourcesInit ? [sampleSourcesInit] : [],
@@ -3906,46 +5438,7 @@ function renderJsx(components, ctx, framework) {
3906
5438
  ``,
3907
5439
  ` return (`,
3908
5440
  ` <div style={{ ${jsxStyle(p.style)} }}>`,
3909
- ...attachmentJsx ? [attachmentJsx] : [],
3910
- ...workspace ? [
3911
- ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
3912
- ` {/* Resizable needs ResizableItem children to render panels. */}`,
3913
- ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
3914
- ` <ResizableItem size="40%" min="240px">`,
3915
- ...cardPropsNote(" "),
3916
- ` <Chat`,
3917
- ` messages={messages}`,
3918
- ` loading={loading}`,
3919
- ` suggestions={suggestions}`,
3920
- ` suggestionMode="submit"`,
3921
- ...cardProps(" "),
3922
- ` onSubmit={onSubmit}`,
3923
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
3924
- ` />`,
3925
- ` </ResizableItem>`,
3926
- ` <ResizableItem min="280px">`,
3927
- ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
3928
- ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
3929
- ` </ResizableItem>`,
3930
- ` </Resizable>`,
3931
- // Standalone companions are siblings of the SPLIT, not of the chat:
3932
- // the split owns chat + artifact, and a sources panel or a voice input
3933
- // belongs beside it. Dropping them here is what the workspace branch
3934
- // used to do — see WORKSPACE_STRUCTURAL_TAGS.
3935
- companions
3936
- ] : [
3937
- ...cardPropsNote(" "),
3938
- ` <Chat`,
3939
- ` messages={messages}`,
3940
- ` loading={loading}`,
3941
- ` suggestions={suggestions}`,
3942
- ` suggestionMode="submit"`,
3943
- ...cardProps(" "),
3944
- ` onSubmit={onSubmit}`,
3945
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
3946
- ` />`,
3947
- companions
3948
- ],
5441
+ ...surfaceJsx,
3949
5442
  ` </div>`,
3950
5443
  ` );`,
3951
5444
  `}`
@@ -3958,7 +5451,7 @@ function renderJsx(components, ctx, framework) {
3958
5451
  // (1) REQUIRED: registers <kai-*> — the react wrappers do NOT auto-register.
3959
5452
  // Must come BEFORE importing the wrappers, or <kai-chat> renders empty.
3960
5453
  `import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
3961
- `import { useState } from 'react';`,
5454
+ `import { ${block ? "useEffect, useState" : "useState"} } from 'react';`,
3962
5455
  `import { ${importList} } from '@kitn.ai/ui/react';`,
3963
5456
  ...wireImportLines({
3964
5457
  typed: true,
@@ -3967,7 +5460,8 @@ function renderJsx(components, ctx, framework) {
3967
5460
  cards: ctx.emitCards,
3968
5461
  cardTools: ctx.cardProvider !== null,
3969
5462
  mock: isMock,
3970
- attachments
5463
+ attachments,
5464
+ workspaceBlock: block
3971
5465
  }),
3972
5466
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
3973
5467
  ``,
@@ -3977,10 +5471,11 @@ function renderJsx(components, ctx, framework) {
3977
5471
  ...mockInit,
3978
5472
  ``,
3979
5473
  ...attachmentModuleInit,
5474
+ ...blockModuleInit,
3980
5475
  ...cardsInit,
3981
5476
  `export default function App() {`,
3982
5477
  sampleMessagesInit,
3983
- ` const [loading, setLoading] = useState(false);`,
5478
+ ...loadingInit,
3984
5479
  ` const suggestions = ${jsArray(suggestions)};`,
3985
5480
  ...attachmentStateInit ? [attachmentStateInit] : [],
3986
5481
  ...sampleSourcesInit ? [sampleSourcesInit] : [],
@@ -3994,43 +5489,7 @@ function renderJsx(components, ctx, framework) {
3994
5489
  ``,
3995
5490
  ` return (`,
3996
5491
  ` <div style={{ ${jsxStyle(p.style)} }}>`,
3997
- ...attachmentJsx ? [attachmentJsx] : [],
3998
- ...workspace ? [
3999
- ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
4000
- ` {/* Resizable needs ResizableItem children to render panels. */}`,
4001
- ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
4002
- ` <ResizableItem size="40%" min="240px">`,
4003
- ...cardPropsNote(" "),
4004
- ` <Chat`,
4005
- ` messages={messages}`,
4006
- ` loading={loading}`,
4007
- ` suggestions={suggestions}`,
4008
- ` suggestionMode="submit"`,
4009
- ...cardProps(" "),
4010
- ` onSubmit={onSubmit}`,
4011
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
4012
- ` />`,
4013
- ` </ResizableItem>`,
4014
- ` <ResizableItem min="280px">`,
4015
- ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
4016
- ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
4017
- ` </ResizableItem>`,
4018
- ` </Resizable>`,
4019
- // Siblings of the SPLIT — see the same line in the other JSX branch.
4020
- companions
4021
- ] : [
4022
- ...cardPropsNote(" "),
4023
- ` <Chat`,
4024
- ` messages={messages}`,
4025
- ` loading={loading}`,
4026
- ` suggestions={suggestions}`,
4027
- ` suggestionMode="submit"`,
4028
- ...cardProps(" "),
4029
- ` onSubmit={onSubmit}`,
4030
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
4031
- ` />`,
4032
- companions
4033
- ],
5492
+ ...surfaceJsx,
4034
5493
  ` </div>`,
4035
5494
  ` );`,
4036
5495
  `}`
@@ -4038,10 +5497,13 @@ function renderJsx(components, ctx, framework) {
4038
5497
  }
4039
5498
  function renderVue(components, ctx) {
4040
5499
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
4041
- const workspace = isWorkspace(components);
5500
+ const workspace = isArtifactSplit(components);
5501
+ const block = isWorkspaceBlock(components);
4042
5502
  const attachments = hasAttachments(components);
5503
+ const slottedTags = new Set(slottedInChat(components).map((s) => s.tag));
5504
+ const vueSlotted = (pad) => block ? [] : slottedChildMarkup(components, pad, { wired: false, ids: false });
4043
5505
  const standaloneCompanionTags = components.filter(
4044
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
5506
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !ARTIFACT_SPLIT_TAGS.has(t) && !(block && BLOCK_TAGS.has(t)) && !slottedTags.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
4045
5507
  );
4046
5508
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
4047
5509
  const companionLines = [];
@@ -4096,19 +5558,41 @@ function renderVue(components, ctx) {
4096
5558
  fromSubmitEvent: true
4097
5559
  })
4098
5560
  } : {};
5561
+ const blockRead = `(threads.value.find((t) => t.id === targetId)?.messages ?? [])`;
5562
+ const blockPrelude = block ? [
5563
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
5564
+ `const targetId = activeId.value ?? crypto.randomUUID();`,
5565
+ `// The record is born on its first turn — with YOUR title policy.`,
5566
+ `setThreads((prev) => prev.some((t) => t.id === targetId)`,
5567
+ ` ? prev`,
5568
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
5569
+ `activeId.value = targetId;`,
5570
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
5571
+ `const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
5572
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
5573
+ `});`,
5574
+ `const controller = sessions.begin(targetId);`
5575
+ ] : [];
4099
5576
  const onSubmitBody = realStreamBody({
4100
5577
  pad: " ",
4101
- read: "messages.value",
4102
- commitSet: (expr) => `messages.value = ${expr};`,
4103
- setterAdapter: "(fn) => { messages.value = fn(messages.value); }",
4104
- setLoading: (v2) => `loading.value = ${v2};`,
5578
+ read: block ? blockRead : "messages.value",
5579
+ commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => `messages.value = ${expr};`,
5580
+ setterAdapter: block ? "setMessages" : "(fn) => { messages.value = fn(messages.value); }",
5581
+ setLoading: block ? (v2) => v2 === "true" ? `// loading for THIS thread now derives from the sessions map (streamingIds).` : `sessions.end(targetId, controller);` : (v2) => `loading.value = ${v2};`,
4105
5582
  bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
4106
5583
  strictRoles: true,
4107
5584
  toolLoop: emitToolLoop,
4108
5585
  cards: ctx.emitCards,
4109
- thread: liveThreadBinding("messages.value", "(fn) => { messages.value = fn(messages.value); }"),
5586
+ thread: block ? blockLiveThread(blockRead) : liveThreadBinding("messages.value", "(fn) => { messages.value = fn(messages.value); }"),
4110
5587
  mock: isMock,
4111
- ...attachmentSubmitOpts
5588
+ ...block ? { fetchSignal: "controller.signal" } : {},
5589
+ ...attachmentSubmitOpts,
5590
+ ...block ? {
5591
+ afterValue: [
5592
+ ...attachmentSubmitOpts.afterValue ?? [],
5593
+ ...blockPrelude
5594
+ ]
5595
+ } : {}
4112
5596
  });
4113
5597
  const mockInit = isMock ? mockResponderInit() : [];
4114
5598
  const modelInit = defaultModel ? [
@@ -4119,7 +5603,39 @@ function renderVue(components, ctx) {
4119
5603
  const cardPropAssign = ctx.emitCards ? [`cardTypes: cards.tags`, `cardSchemas: cards.validationSchemas`] : [];
4120
5604
  const toolsLines = emitTools ? toolSchemaLines("", ctx.cardProvider) : [];
4121
5605
  const runnerLines = emitToolLoop ? toolRunnerLines("") : [];
4122
- const sampleSeed = [
5606
+ const sampleSeed = block ? [
5607
+ `// ── The block's state: your records + the kit's session/persistence helpers ──`,
5608
+ `const threads = ref<Thread[]>(loadThreads());`,
5609
+ `// null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
5610
+ `// record is created on its first turn, so the rail never fills with empties.`,
5611
+ `const activeId = ref<string | null>(threads.value[0]?.id ?? null);`,
5612
+ `// One id per in-flight reply — a stream keeps running when you switch away.`,
5613
+ `const streamingIds = ref<string[]>([]);`,
5614
+ ...BLOCK_SESSIONS_NOTE.map((l) => `// ${l}`),
5615
+ `const sessions = createThreadSessions((ids) => { streamingIds.value = ids; });`,
5616
+ `// The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
5617
+ `// the save target and what a failed save means are YOURS (see saveThreads).`,
5618
+ `const saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
5619
+ `watch(threads, () => saver.schedule(threads.value));`,
5620
+ `// Every write goes through this — a NEW array per write is what notifies.`,
5621
+ `const setThreads = (updater: (prev: Thread[]) => Thread[]): void => {`,
5622
+ ` threads.value = updater(threads.value);`,
5623
+ `};`,
5624
+ `const active = computed(() => threads.value.find((t) => t.id === activeId.value) ?? null);`,
5625
+ `const messages = computed(() => active.value?.messages ?? []);`,
5626
+ `const loading = computed(() => { const id = activeId.value; return id !== null && streamingIds.value.includes(id); });`,
5627
+ `// The rail's row shape is a VIEW of your record — titles and counts, no`,
5628
+ `// message bodies. The rail renders the array in the order it is given.`,
5629
+ `const rows = computed(() => threads.value.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt })));`,
5630
+ ``,
5631
+ `function onSelect(event: Event) {`,
5632
+ ` activeId.value = (event as CustomEvent<{ id: string }>).detail.id;`,
5633
+ `}`,
5634
+ `function onNewChat() {`,
5635
+ ` // A DRAFT, not a record: it becomes a thread on its first turn.`,
5636
+ ` activeId.value = null;`,
5637
+ `}`
5638
+ ] : [
4123
5639
  ...hasEmbedded ? sampleSeedComment(isMock, "", (literal) => [
4124
5640
  `const messages = ref<ChatMessage[]>([${literal}]);`
4125
5641
  ]) : [],
@@ -4136,7 +5652,7 @@ function renderVue(components, ctx) {
4136
5652
  ` if (el) el.sources = sampleSources;`,
4137
5653
  `});`
4138
5654
  ] : [];
4139
- const vueImports = `import { ref, onMounted } from 'vue';`;
5655
+ const vueImports = block ? `import { computed, onMounted, ref, watch } from 'vue';` : `import { ref, onMounted } from 'vue';`;
4140
5656
  const workspaceTemplate = workspace ? [
4141
5657
  ` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
4142
5658
  ` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
@@ -4149,7 +5665,9 @@ function renderVue(components, ctx) {
4149
5665
  ` suggestion-mode="submit"`,
4150
5666
  ` style="${p.chatFill}"`,
4151
5667
  ` @kai-submit="onSubmit"`,
4152
- ` ></kai-chat>`,
5668
+ ` >`,
5669
+ ...vueSlotted(" "),
5670
+ ` </kai-chat>`,
4153
5671
  ` </kai-resizable-item>`,
4154
5672
  ` <kai-resizable-item min="280px">`,
4155
5673
  ` <!-- Replace src with your artifact URL or set .files for multi-file preview. -->`,
@@ -4166,7 +5684,9 @@ function renderVue(components, ctx) {
4166
5684
  ` suggestion-mode="submit"`,
4167
5685
  ` style="${p.chatFill}"`,
4168
5686
  ` @kai-submit="onSubmit"`,
4169
- ` ></kai-chat>`,
5687
+ ` >`,
5688
+ ...vueSlotted(" "),
5689
+ ` </kai-chat>`,
4170
5690
  companions
4171
5691
  ];
4172
5692
  return [
@@ -4184,16 +5704,18 @@ function renderVue(components, ctx) {
4184
5704
  cards: ctx.emitCards,
4185
5705
  cardTools: ctx.cardProvider !== null,
4186
5706
  mock: isMock,
4187
- attachments
5707
+ attachments,
5708
+ workspaceBlock: block
4188
5709
  }),
4189
5710
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
4190
5711
  vueImports,
4191
5712
  ``,
4192
5713
  ...mockInit,
4193
5714
  ``,
5715
+ ...block ? [blockThreadStoreLines("").join("\n"), ``] : [],
4194
5716
  ...cardsInit,
4195
5717
  ...sampleSeed,
4196
- `const loading = ref(false);`,
5718
+ ...block ? [] : [`const loading = ref(false);`],
4197
5719
  `const suggestions = ${jsArray(suggestions)};`,
4198
5720
  ...modelInit,
4199
5721
  ...toolsLines,
@@ -4209,6 +5731,14 @@ function renderVue(components, ctx) {
4209
5731
  ...ctx.emitCards ? CARD_PROP_COMMENT.map((l) => ` ${l}`) : [],
4210
5732
  ` const el = document.querySelector('kai-chat');`,
4211
5733
  ` if (el) Object.assign(el, { messages: messages.value, loading: loading.value, suggestions${cardPropAssign.length ? `, ${cardPropAssign.join(", ")}` : ""} });`,
5734
+ ...block ? [
5735
+ ` // The rail's array props follow the same upgrade rule.`,
5736
+ ` await customElements.whenDefined('kai-conversations');`,
5737
+ ` const rail = document.querySelector('kai-conversations');`,
5738
+ ` if (rail) Object.assign(rail, { conversations: rows.value, activeId: activeId.value ?? undefined });`,
5739
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
5740
+ ` window.addEventListener('beforeunload', saver.flush);`
5741
+ ] : [],
4212
5742
  `});`,
4213
5743
  ``,
4214
5744
  `async function onSubmit(e: ${attachments ? "CustomEvent<{ value: string; attachments: AttachmentData[] }>" : "CustomEvent<{ value: string }>"}) {`,
@@ -4218,18 +5748,49 @@ function renderVue(components, ctx) {
4218
5748
  ``,
4219
5749
  `<template>`,
4220
5750
  ` <div style="${p.style}">`,
4221
- ...attachmentTemplate ? [attachmentTemplate] : [],
4222
- ...workspaceTemplate,
5751
+ ...block ? [
5752
+ ` <!-- ── The workspace BLOCK: the shell + the rail + your thread records ── -->`,
5753
+ ...htmlComment(
5754
+ [
5755
+ `<kai-workspace> is the chat-agnostic layout shell: header/start/main/`,
5756
+ `end/footer slots, aside resize + collapse, and a mobile drawer below`,
5757
+ `the drawer-below width. It knows nothing about chat.`
5758
+ ],
5759
+ " "
5760
+ ),
5761
+ ` <kai-workspace drawer-below="720" style="display:block;width:100%;height:100%">`,
5762
+ ...htmlComment(BLOCK_RAIL_NOTE, " "),
5763
+ ` <!-- New chat = a DRAFT (activeId null): the record appears on the first turn. -->`,
5764
+ ` <kai-conversations`,
5765
+ ` slot="start"`,
5766
+ ` :conversations.prop="rows"`,
5767
+ ` :activeId.prop="activeId ?? undefined"`,
5768
+ ` style="display:block;height:100%"`,
5769
+ ` @kai-conversation-select="onSelect"`,
5770
+ ` @kai-new-chat="onNewChat"`,
5771
+ ` />`,
5772
+ ` <!-- Unnamed children project into the shell's MAIN region. -->`,
5773
+ ` <div style="display:flex;flex-direction:column;height:100%;min-height:0">`,
5774
+ ...indentEmitted(
5775
+ [...attachmentTemplate ? [attachmentTemplate] : [], ...workspaceTemplate],
5776
+ " "
5777
+ ),
5778
+ ` </div>`,
5779
+ ` </kai-workspace>`
5780
+ ] : [...attachmentTemplate ? [attachmentTemplate] : [], ...workspaceTemplate],
4223
5781
  ` </div>`,
4224
5782
  `</template>`
4225
5783
  ].filter((l) => l !== "").join("\n");
4226
5784
  }
4227
5785
  function renderSvelte(components, ctx) {
4228
5786
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
4229
- const workspace = isWorkspace(components);
5787
+ const workspace = isArtifactSplit(components);
5788
+ const block = isWorkspaceBlock(components);
4230
5789
  const attachments = hasAttachments(components);
5790
+ const slottedTags = new Set(slottedInChat(components).map((s) => s.tag));
5791
+ const svelteSlotted = (pad) => block ? [] : slottedChildMarkup(components, pad, { wired: false, ids: false });
4231
5792
  const standaloneCompanionTags = components.filter(
4232
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
5793
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !ARTIFACT_SPLIT_TAGS.has(t) && !(block && BLOCK_TAGS.has(t)) && !slottedTags.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
4233
5794
  );
4234
5795
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
4235
5796
  const hasSourcesCompanion = standaloneCompanionTags.includes("kai-sources");
@@ -4257,19 +5818,41 @@ function renderSvelte(components, ctx) {
4257
5818
  fromSubmitEvent: true
4258
5819
  })
4259
5820
  } : {};
5821
+ const blockRead = `(threads.find((t) => t.id === targetId)?.messages ?? [])`;
5822
+ const blockPrelude = block ? [
5823
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
5824
+ `const targetId = activeId ?? crypto.randomUUID();`,
5825
+ `// The record is born on its first turn — with YOUR title policy.`,
5826
+ `setThreads((prev) => prev.some((t) => t.id === targetId)`,
5827
+ ` ? prev`,
5828
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
5829
+ `activeId = targetId;`,
5830
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
5831
+ `const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
5832
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
5833
+ `});`,
5834
+ `const controller = sessions.begin(targetId);`
5835
+ ] : [];
4260
5836
  const onSubmitBody = realStreamBody({
4261
5837
  pad: " ",
4262
- read: "messages",
4263
- commitSet: (expr) => `messages = ${expr};`,
4264
- setterAdapter: "(fn) => { messages = fn(messages); }",
4265
- setLoading: (v2) => `loading = ${v2};`,
5838
+ read: block ? blockRead : "messages",
5839
+ commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => `messages = ${expr};`,
5840
+ setterAdapter: block ? "setMessages" : "(fn) => { messages = fn(messages); }",
5841
+ setLoading: block ? (v2) => v2 === "true" ? `// loading for THIS thread now derives from the sessions map (streamingIds).` : `sessions.end(targetId, controller);` : (v2) => `loading = ${v2};`,
4266
5842
  bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
4267
5843
  strictRoles: true,
4268
5844
  toolLoop: emitToolLoop,
4269
5845
  cards: ctx.emitCards,
4270
- thread: liveThreadBinding("messages", "(fn) => { messages = fn(messages); }"),
5846
+ thread: block ? blockLiveThread(blockRead) : liveThreadBinding("messages", "(fn) => { messages = fn(messages); }"),
4271
5847
  mock: isMock,
4272
- ...attachmentSubmitOpts
5848
+ ...block ? { fetchSignal: "controller.signal" } : {},
5849
+ ...attachmentSubmitOpts,
5850
+ ...block ? {
5851
+ afterValue: [
5852
+ ...attachmentSubmitOpts.afterValue ?? [],
5853
+ ...blockPrelude
5854
+ ]
5855
+ } : {}
4273
5856
  });
4274
5857
  const mockInit = isMock ? mockResponderInit(" ") : [];
4275
5858
  const modelInit = defaultModel ? [
@@ -4280,7 +5863,46 @@ function renderSvelte(components, ctx) {
4280
5863
  const cardPropEffect = ctx.emitCards ? ` chatEl.cardTypes = cards.tags; chatEl.cardSchemas = cards.validationSchemas;` : "";
4281
5864
  const toolsLines = emitTools ? toolSchemaLines(" ", ctx.cardProvider) : [];
4282
5865
  const runnerLines = emitToolLoop ? toolRunnerLines(" ") : [];
4283
- const sampleMessagesInit = [
5866
+ const sampleMessagesInit = block ? [
5867
+ ` // ── The block's state: your records + the kit's session/persistence helpers ─`,
5868
+ ` // $state.raw for the same reason messages was: the kit's contract is a NEW`,
5869
+ ` // array reference per write, which is exactly what raw state tracks.`,
5870
+ ` let threads = $state.raw<Thread[]>(loadThreads());`,
5871
+ ` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
5872
+ ` // record is created on its first turn, so the rail never fills with empties.`,
5873
+ ` let activeId = $state<string | null>(threads[0]?.id ?? null);`,
5874
+ ` // One id per in-flight reply — a stream keeps running when you switch away.`,
5875
+ ` let streamingIds = $state.raw<string[]>([]);`,
5876
+ ...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
5877
+ ` const sessions = createThreadSessions((ids) => { streamingIds = ids; });`,
5878
+ ` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
5879
+ ` // the save target and what a failed save means are YOURS (see saveThreads).`,
5880
+ ` const saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
5881
+ ` $effect(() => { saver.schedule(threads); });`,
5882
+ ` // Every write goes through this — a NEW array per write is what notifies.`,
5883
+ ` const setThreads = (updater: (prev: Thread[]) => Thread[]): void => {`,
5884
+ ` threads = updater(threads);`,
5885
+ ` };`,
5886
+ ` const active = $derived(threads.find((t) => t.id === activeId) ?? null);`,
5887
+ ` const messages = $derived(active?.messages ?? []);`,
5888
+ ` const loading = $derived.by(() => { const id = activeId; return id !== null && streamingIds.includes(id); });`,
5889
+ ` // The rail's row shape is a VIEW of your record — titles and counts, no`,
5890
+ ` // message bodies. The rail renders the array in the order it is given.`,
5891
+ ` const rows = $derived(threads.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt })));`,
5892
+ ` // \`bind:this\` writes to this binding, so under runes it must be $state.`,
5893
+ ` let railEl = $state<KaiConversationsElement | undefined>(undefined);`,
5894
+ ` $effect(() => {`,
5895
+ ` if (railEl && defined) { railEl.conversations = rows; railEl.activeId = activeId ?? undefined; }`,
5896
+ ` });`,
5897
+ ``,
5898
+ ` function onSelect(e: CustomEvent<{ id: string }>) {`,
5899
+ ` activeId = e.detail.id;`,
5900
+ ` }`,
5901
+ ` function onNewChat() {`,
5902
+ ` // A DRAFT, not a record: it becomes a thread on its first turn.`,
5903
+ ` activeId = null;`,
5904
+ ` }`
5905
+ ] : [
4284
5906
  ...hasEmbedded ? sampleSeedComment(isMock, " ", (literal) => [
4285
5907
  `let messages = $state.raw<ChatMessage[]>([${literal}]);`
4286
5908
  ]) : [],
@@ -4327,7 +5949,9 @@ function renderSvelte(components, ctx) {
4327
5949
  ` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
4328
5950
  ` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
4329
5951
  ` <kai-resizable-item size="40%" min="240px">`,
4330
- ` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}></kai-chat>`,
5952
+ ` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}>`,
5953
+ ...svelteSlotted(" "),
5954
+ ` </kai-chat>`,
4331
5955
  ` </kai-resizable-item>`,
4332
5956
  ` <kai-resizable-item min="280px">`,
4333
5957
  ` <!-- Replace src with your artifact URL or set .files for multi-file preview. -->`,
@@ -4337,7 +5961,9 @@ function renderSvelte(components, ctx) {
4337
5961
  // Siblings of the SPLIT — see the same line in renderJsx.
4338
5962
  companionLines
4339
5963
  ] : [
4340
- ` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}></kai-chat>`,
5964
+ ` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}>`,
5965
+ ...svelteSlotted(" "),
5966
+ ` </kai-chat>`,
4341
5967
  companionLines
4342
5968
  ];
4343
5969
  return [
@@ -4354,6 +5980,7 @@ function renderSvelte(components, ctx) {
4354
5980
  // on every archetype without kai-sources.
4355
5981
  ` import type { ${[
4356
5982
  "KaiChatElement",
5983
+ ...block ? ["KaiConversationsElement"] : [],
4357
5984
  ...hasSourcesCompanion ? ["KaiSourcesElement"] : [],
4358
5985
  ...attachments ? ["KaiAttachmentsElement"] : []
4359
5986
  ].join(", ")} } from '@kitn.ai/ui/elements';`,
@@ -4364,21 +5991,36 @@ function renderSvelte(components, ctx) {
4364
5991
  cards: ctx.emitCards,
4365
5992
  cardTools: ctx.cardProvider !== null,
4366
5993
  mock: isMock,
4367
- attachments
5994
+ attachments,
5995
+ workspaceBlock: block
4368
5996
  }),
4369
5997
  ` import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
4370
5998
  ` import { onMount } from 'svelte';`,
4371
5999
  ...mockInit,
6000
+ ...block ? [blockThreadStoreLines(" ").join("\n"), ``] : [],
4372
6001
  ` // \`bind:this\` writes to this binding, so under runes it must be $state.`,
4373
6002
  ` let chatEl = $state<KaiChatElement | undefined>(undefined);`,
4374
6003
  ` // SCAF-15: kai-* register via an async dynamic import (SSR-safety). Gate the`,
4375
6004
  ` // property $effect on the upgrade so the first application isn't dropped`,
4376
6005
  ` // (props set on a not-yet-upgraded element are lost on upgrade).`,
4377
6006
  ` let defined = $state(false);`,
4378
- ` onMount(async () => { await customElements.whenDefined('kai-chat'); defined = true; });`,
6007
+ ...block ? [
6008
+ ` onMount(() => {`,
6009
+ ` void Promise.all([`,
6010
+ ` customElements.whenDefined('kai-chat'),`,
6011
+ ` customElements.whenDefined('kai-conversations'),`,
6012
+ ` ]).then(() => { defined = true; });`,
6013
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
6014
+ ` const flush = () => saver.flush();`,
6015
+ ` window.addEventListener('beforeunload', flush);`,
6016
+ ` return () => window.removeEventListener('beforeunload', flush);`,
6017
+ ` });`
6018
+ ] : [
6019
+ ` onMount(async () => { await customElements.whenDefined('kai-chat'); defined = true; });`
6020
+ ],
4379
6021
  ...sourcesEl,
4380
6022
  ...sampleMessagesInit,
4381
- ` let loading = $state(false);`,
6023
+ ...block ? [] : [` let loading = $state(false);`],
4382
6024
  ` const suggestions: string[] = ${jsArray(suggestions)};`,
4383
6025
  ...modelInit,
4384
6026
  ...cardsInit,
@@ -4398,22 +6040,49 @@ function renderSvelte(components, ctx) {
4398
6040
  `<\/script>`,
4399
6041
  ``,
4400
6042
  `<div style="${p.style}">`,
4401
- ...attachmentMarkup,
4402
- ...workspaceMarkup,
6043
+ ...block ? [
6044
+ ` <!-- ── The workspace BLOCK: the shell + the rail + your thread records ── -->`,
6045
+ ...htmlComment(
6046
+ [
6047
+ `<kai-workspace> is the chat-agnostic layout shell: header/start/main/`,
6048
+ `end/footer slots, aside resize + collapse, and a mobile drawer below`,
6049
+ `the drawer-below width. It knows nothing about chat.`
6050
+ ],
6051
+ " "
6052
+ ),
6053
+ ` <kai-workspace drawer-below="720" style="display:block;width:100%;height:100%">`,
6054
+ ...htmlComment(BLOCK_RAIL_NOTE, " "),
6055
+ ` <!-- New chat = a DRAFT (activeId null): the record appears on the first turn. -->`,
6056
+ ` <kai-conversations`,
6057
+ ` bind:this={railEl}`,
6058
+ ` slot="start"`,
6059
+ ` style="display:block;height:100%"`,
6060
+ ` onkai-conversation-select={onSelect}`,
6061
+ ` onkai-new-chat={onNewChat}`,
6062
+ ` ></kai-conversations>`,
6063
+ ` <!-- Unnamed children project into the shell's MAIN region. -->`,
6064
+ ` <div style="display:flex;flex-direction:column;height:100%;min-height:0">`,
6065
+ ...indentEmitted([...attachmentMarkup, ...workspaceMarkup], " "),
6066
+ ` </div>`,
6067
+ ` </kai-workspace>`
6068
+ ] : [...attachmentMarkup, ...workspaceMarkup],
4403
6069
  `</div>`
4404
6070
  ].filter((l, i, arr) => !(l === "" && arr[i - 1] === "" && i === arr.length - 1)).join("\n");
4405
6071
  }
4406
6072
  function renderTanstackStart(components, ctx) {
4407
6073
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
4408
6074
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
4409
- const workspace = isWorkspace(components);
4410
- const renderableTags = components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t));
6075
+ const workspace = isArtifactSplit(components);
6076
+ const block = isWorkspaceBlock(components);
6077
+ const renderableTags = [
6078
+ .../* @__PURE__ */ new Set([ALWAYS_EMITTED_TAG, ...components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t))])
6079
+ ];
4411
6080
  const importTags = workspace ? [.../* @__PURE__ */ new Set([...renderableTags.filter((t) => t !== "kai-resizable"), "kai-resizable", "kai-resizable-item"])] : renderableTags;
4412
6081
  const wrapperNames = importTags.map(toPascalCase);
4413
6082
  const importList = wrapperNames.join(", ");
4414
6083
  const attachments = hasAttachments(components);
4415
6084
  const standaloneCompanionTags = components.filter(
4416
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
6085
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !ARTIFACT_SPLIT_TAGS.has(t) && !(block && BLOCK_TAGS.has(t)) && !(attachments && ATTACHMENT_TAGS.has(t))
4417
6086
  );
4418
6087
  const companionJsxLines = [];
4419
6088
  if (hasEmbedded) {
@@ -4428,6 +6097,8 @@ function renderTanstackStart(components, ctx) {
4428
6097
  ` {/* Replace sampleSources with your real data. */}`,
4429
6098
  ` <Sources sources={sampleSources} />`
4430
6099
  );
6100
+ } else if (slotPlacementFor(t)) {
6101
+ companionJsxLines.push(...railSiblingNote(t, slotPlacementFor(t)), ` <${toPascalCase(t)} />`);
4431
6102
  } else {
4432
6103
  companionJsxLines.push(` {/* wire data props — see the component_reference MCP tool */}`);
4433
6104
  companionJsxLines.push(` <${toPascalCase(t)} />`);
@@ -4470,13 +6141,52 @@ function renderTanstackStart(components, ctx) {
4470
6141
  } : {};
4471
6142
  const submitParamType = attachments ? "CustomEvent<{ value: string; attachments: AttachmentData[] }>" : "CustomEvent<{ value: string }>";
4472
6143
  const mockInit = isMock ? mockResponderInit() : [];
4473
- const sampleMessagesInit = [
6144
+ const sampleMessagesInit = block ? [
6145
+ ` // ── The block's state: your records + the kit's session/persistence helpers ─`,
6146
+ ` const [threads, setThreads] = useState<Thread[]>(loadThreads);`,
6147
+ ` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
6148
+ ` // record is created on its first turn, so the rail never fills with empties.`,
6149
+ ` const [activeId, setActiveId] = useState<string | null>(threads[0]?.id ?? null);`,
6150
+ ` // One id per in-flight reply — a stream keeps running when you switch away,`,
6151
+ ` // so this cannot be a single boolean.`,
6152
+ ` const [streamingIds, setStreamingIds] = useState<string[]>([]);`,
6153
+ ...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
6154
+ ` const [sessions] = useState(() => createThreadSessions(setStreamingIds));`,
6155
+ ` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
6156
+ ` // the save target and what a failed save means are YOURS (see saveThreads).`,
6157
+ ` const [saver] = useState(() => createSaveScheduler(saveThreads, { delayMs: 250 }));`,
6158
+ ` useEffect(() => { saver.schedule(threads); }, [saver, threads]);`,
6159
+ ` useEffect(() => {`,
6160
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
6161
+ ` const flush = () => saver.flush();`,
6162
+ ` window.addEventListener('beforeunload', flush);`,
6163
+ ` return () => window.removeEventListener('beforeunload', flush);`,
6164
+ ` }, [saver]);`,
6165
+ ``,
6166
+ ` const active = threads.find((t) => t.id === activeId) ?? null;`,
6167
+ ` const messages = active?.messages ?? NO_MESSAGES;`,
6168
+ ` const loading = activeId !== null && streamingIds.includes(activeId);`,
6169
+ ` // The rail's row shape is a VIEW of your record — titles and counts, no`,
6170
+ ` // message bodies. Newest-first is your ordering; the rail renders the array`,
6171
+ ` // in the order it is given.`,
6172
+ ` const rows = threads.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt }));`
6173
+ ].join("\n") : [
4474
6174
  ...hasEmbedded ? sampleSeedComment(isMock, " ", (literal) => [
4475
6175
  `const sampleMessages: ChatMessage[] = [${literal}];`,
4476
6176
  `const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`
4477
6177
  ]) : [],
4478
6178
  ` const [messages, setMessages] = useState<ChatMessage[]>([]);`
4479
6179
  ].join("\n");
6180
+ const blockModuleInit = block ? [
6181
+ [
6182
+ `/** A stable reference for the empty thread. \`messages\` is diffed by`,
6183
+ ` * reference, so a fresh \`[]\` every render would re-notify for nothing. */`,
6184
+ `const NO_MESSAGES: ChatMessage[] = [];`,
6185
+ ``,
6186
+ ...blockThreadStoreLines("")
6187
+ ].join("\n")
6188
+ ] : [];
6189
+ const loadingInit = block ? [] : [` const [loading, setLoading] = useState(false);`];
4480
6190
  const sampleSourcesInit = standaloneCompanionTags.includes("kai-sources") ? [
4481
6191
  ` // Replace sampleSources with your real source data.`,
4482
6192
  ` const sampleSources = [`,
@@ -4491,20 +6201,54 @@ function renderTanstackStart(components, ctx) {
4491
6201
  const cardsInit = ctx.emitCards ? cardRegistryLines("") : [];
4492
6202
  const cardProps = (pad) => ctx.emitCards ? [`${pad}cardTypes={cards.tags}`, `${pad}cardSchemas={cards.validationSchemas}`] : [];
4493
6203
  const cardPropsNote = (pad) => ctx.emitCards ? jsxComment(CARD_PROP_COMMENT, pad) : [];
6204
+ const blockPrelude = block ? [
6205
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
6206
+ `const targetId = activeId ?? crypto.randomUUID();`,
6207
+ `// The record is born on its first turn — with YOUR title policy.`,
6208
+ `setThreads((prev) => prev.some((t) => t.id === targetId)`,
6209
+ ` ? prev`,
6210
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
6211
+ `setActiveId(targetId);`,
6212
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
6213
+ `const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
6214
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
6215
+ `});`,
6216
+ `const controller = sessions.begin(targetId);`
6217
+ ] : [];
6218
+ const blockReactThread = {
6219
+ open: ({ pad, userMessage }) => [
6220
+ `${pad}// THE TURN OWNS THE THREAD (same rule as the single-thread scaffold): the`,
6221
+ `${pad}// loop below re-encodes it every round, and React state cannot be read`,
6222
+ `${pad}// back mid-turn. setMessages (the id-bound sink) just projects it.`,
6223
+ `${pad}let thread: ChatMessage[] = [...(threads.find((t) => t.id === targetId)?.messages ?? []), ${userMessage}];`,
6224
+ `${pad}const set: SetMessages = (fn) => { thread = fn(thread); setMessages(() => thread); };`,
6225
+ `${pad}setMessages(() => thread);`
6226
+ ],
6227
+ live: "thread",
6228
+ setter: "set"
6229
+ };
4494
6230
  const onSubmitBody = realStreamBody({
4495
6231
  pad: " ",
4496
- read: "messages",
4497
- commitSet: (expr) => `setMessages(${expr});`,
4498
- // useState's setter IS a SetMessages: both are (updater) => void.
6232
+ read: block ? `(threads.find((t) => t.id === targetId)?.messages ?? [])` : "messages",
6233
+ commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => `setMessages(${expr});`,
6234
+ // useState's setter IS a SetMessages: both are (updater) => void — and in
6235
+ // block mode the id-bound sink from bindThreadMessages is one too.
4499
6236
  setterAdapter: "setMessages",
4500
- setLoading: (v2) => `setLoading(${v2});`,
6237
+ setLoading: block ? (v2) => v2 === "true" ? `// loading for THIS thread now derives from the sessions map (streamingIds).` : `sessions.end(targetId, controller);` : (v2) => `setLoading(${v2});`,
4501
6238
  bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
4502
6239
  strictRoles: true,
4503
6240
  toolLoop: emitToolLoop,
4504
6241
  cards: ctx.emitCards,
4505
- thread: REACT_THREAD,
6242
+ thread: block ? blockReactThread : REACT_THREAD,
4506
6243
  mock: isMock,
4507
- ...attachmentSubmitOpts
6244
+ ...block ? { fetchSignal: "controller.signal" } : {},
6245
+ ...attachmentSubmitOpts,
6246
+ ...block ? {
6247
+ afterValue: [
6248
+ ...attachmentSubmitOpts.afterValue ?? [],
6249
+ ...blockPrelude
6250
+ ]
6251
+ } : {}
4508
6252
  });
4509
6253
  const filePathNote = [
4510
6254
  `// TanStack Start route file — save as: src/routes/chat.tsx`,
@@ -4520,11 +6264,65 @@ function renderTanstackStart(components, ctx) {
4520
6264
  `// server function: src/routes/api/chat.ts with server.handlers.POST — block (2).`,
4521
6265
  ``
4522
6266
  ];
6267
+ const chatJsx = (pad) => [
6268
+ ...cardPropsNote(pad),
6269
+ `${pad}<Chat`,
6270
+ `${pad} messages={messages}`,
6271
+ `${pad} loading={loading}`,
6272
+ `${pad} suggestions={suggestions}`,
6273
+ `${pad} suggestionMode="submit"`,
6274
+ ...cardProps(`${pad} `),
6275
+ `${pad} onSubmit={onSubmit}`,
6276
+ `${pad} style={{ ${jsxStyle(p.chatFill)} }}`,
6277
+ `${pad}/>`
6278
+ ];
6279
+ const splitJsx = [
6280
+ ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
6281
+ ` {/* Resizable needs ResizableItem children to render panels. */}`,
6282
+ ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
6283
+ ` <ResizableItem size="40%" min="240px">`,
6284
+ ...chatJsx(" "),
6285
+ ` </ResizableItem>`,
6286
+ ` <ResizableItem min="280px">`,
6287
+ ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
6288
+ ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
6289
+ ` </ResizableItem>`,
6290
+ ` </Resizable>`
6291
+ ];
6292
+ const mainJsx = [
6293
+ ...attachmentJsx ? [attachmentJsx] : [],
6294
+ ...workspace ? [...splitJsx, companions] : [...chatJsx(" "), companions]
6295
+ ];
6296
+ const surfaceJsx = block ? [
6297
+ ` {/* ── The workspace BLOCK: the shell + the rail + your thread records ── */}`,
6298
+ ` {/* <Workspace> is the chat-agnostic layout shell (header/start/main/end/`,
6299
+ ` footer slots, aside resize + collapse, a mobile drawer below`,
6300
+ ` drawerBelow px). It knows nothing about chat. */}`,
6301
+ ` <Workspace drawerBelow={720} style={{ display: 'block', width: '100%', height: '100%' }}>`,
6302
+ ...jsxComment(BLOCK_RAIL_NOTE, " "),
6303
+ ` {/* The React wrappers forward no slot prop, so a plain div carries the`,
6304
+ ` slot attribute into the shell's start aside. */}`,
6305
+ ` <div slot="start" style={{ height: '100%' }}>`,
6306
+ ` {/* New chat = a DRAFT (activeId null): the record appears on the first turn. */}`,
6307
+ ` <Conversations`,
6308
+ ` conversations={rows}`,
6309
+ ` activeId={activeId ?? undefined}`,
6310
+ ` onConversationSelect={(e) => setActiveId(e.detail.id)}`,
6311
+ ` onNewChat={() => setActiveId(null)}`,
6312
+ ` style={{ display: 'block', height: '100%' }}`,
6313
+ ` />`,
6314
+ ` </div>`,
6315
+ ` {/* Unnamed children project into the shell's MAIN region. */}`,
6316
+ ` <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>`,
6317
+ ...indentEmitted(mainJsx, " "),
6318
+ ` </div>`,
6319
+ ` </Workspace>`
6320
+ ] : mainJsx;
4523
6321
  return [
4524
6322
  ...filePathNote,
4525
6323
  // TanStack Start uses @tanstack/react-router's createFileRoute
4526
6324
  `import { createFileRoute } from '@tanstack/react-router'`,
4527
- `import { useState } from 'react'`,
6325
+ `import { ${block ? "useEffect, useState" : "useState"} } from 'react'`,
4528
6326
  // Elements registration: the library is SSR-import-safe; top-level import is safe here
4529
6327
  `import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
4530
6328
  `import { ${importList} } from '@kitn.ai/ui/react'`,
@@ -4535,7 +6333,8 @@ function renderTanstackStart(components, ctx) {
4535
6333
  cards: ctx.emitCards,
4536
6334
  cardTools: ctx.cardProvider !== null,
4537
6335
  mock: isMock,
4538
- attachments
6336
+ attachments,
6337
+ workspaceBlock: block
4539
6338
  }),
4540
6339
  `import '@kitn.ai/ui/theme.tokens.css' // compiled token defaults`,
4541
6340
  ``,
@@ -4544,6 +6343,7 @@ function renderTanstackStart(components, ctx) {
4544
6343
  ...mockInit,
4545
6344
  ``,
4546
6345
  ...attachmentModuleInit,
6346
+ ...blockModuleInit,
4547
6347
  ...cardsInit,
4548
6348
  `// ssr: false keeps the Solid-based web component client-only.`,
4549
6349
  `// Server HTML for /chat omits <kai-chat> → no hydration mismatch.`,
@@ -4554,7 +6354,7 @@ function renderTanstackStart(components, ctx) {
4554
6354
  ``,
4555
6355
  `function ChatPage() {`,
4556
6356
  sampleMessagesInit,
4557
- ` const [loading, setLoading] = useState(false);`,
6357
+ ...loadingInit,
4558
6358
  ` const suggestions = ${jsArray(suggestions)};`,
4559
6359
  ...attachmentStateInit ? [attachmentStateInit] : [],
4560
6360
  ...sampleSourcesInit ? [sampleSourcesInit] : [],
@@ -4568,43 +6368,7 @@ function renderTanstackStart(components, ctx) {
4568
6368
  ``,
4569
6369
  ` return (`,
4570
6370
  ` <main style={{ ${jsxStyle(p.style)} }}>`,
4571
- ...attachmentJsx ? [attachmentJsx] : [],
4572
- ...workspace ? [
4573
- ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
4574
- ` {/* Resizable needs ResizableItem children to render panels. */}`,
4575
- ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
4576
- ` <ResizableItem size="40%" min="240px">`,
4577
- ...cardPropsNote(" "),
4578
- ` <Chat`,
4579
- ` messages={messages}`,
4580
- ` loading={loading}`,
4581
- ` suggestions={suggestions}`,
4582
- ` suggestionMode="submit"`,
4583
- ...cardProps(" "),
4584
- ` onSubmit={onSubmit}`,
4585
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
4586
- ` />`,
4587
- ` </ResizableItem>`,
4588
- ` <ResizableItem min="280px">`,
4589
- ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
4590
- ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
4591
- ` </ResizableItem>`,
4592
- ` </Resizable>`,
4593
- // Siblings of the SPLIT — see the same line in the other JSX branch.
4594
- companions
4595
- ] : [
4596
- ...cardPropsNote(" "),
4597
- ` <Chat`,
4598
- ` messages={messages}`,
4599
- ` loading={loading}`,
4600
- ` suggestions={suggestions}`,
4601
- ` suggestionMode="submit"`,
4602
- ...cardProps(" "),
4603
- ` onSubmit={onSubmit}`,
4604
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
4605
- ` />`,
4606
- companions
4607
- ],
6371
+ ...surfaceJsx,
4608
6372
  ` </main>`,
4609
6373
  ` );`,
4610
6374
  `}`
@@ -4612,10 +6376,12 @@ function renderTanstackStart(components, ctx) {
4612
6376
  }
4613
6377
  function renderAngular(components, ctx) {
4614
6378
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
4615
- const workspace = isWorkspace(components);
6379
+ const workspace = isArtifactSplit(components);
6380
+ const block = isWorkspaceBlock(components);
4616
6381
  const attachments = hasAttachments(components);
6382
+ const slottedTags = new Set(slottedInChat(components).map((s) => s.tag));
4617
6383
  const standaloneCompanionTags = components.filter(
4618
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
6384
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !ARTIFACT_SPLIT_TAGS.has(t) && !(block && BLOCK_TAGS.has(t)) && !slottedTags.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
4619
6385
  );
4620
6386
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
4621
6387
  const hasSourcesCompanion = standaloneCompanionTags.includes("kai-sources");
@@ -4679,19 +6445,41 @@ function renderAngular(components, ctx) {
4679
6445
  const read = "this.messages()";
4680
6446
  const commit = (value) => `this.messages.set(${value});`;
4681
6447
  const setter = "(fn) => this.messages.set(fn(this.messages()))";
6448
+ const blockRead = `(this.threads().find((t) => t.id === targetId)?.messages ?? [])`;
6449
+ const blockPrelude = block ? [
6450
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
6451
+ `const targetId = this.activeId() ?? crypto.randomUUID();`,
6452
+ `// The record is born on its first turn — with YOUR title policy.`,
6453
+ `this.setThreads((prev) => prev.some((t) => t.id === targetId)`,
6454
+ ` ? prev`,
6455
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
6456
+ `this.activeId.set(targetId);`,
6457
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
6458
+ `const setMessages = bindThreadMessages<Thread>(this.setThreads, targetId, {`,
6459
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
6460
+ `});`,
6461
+ `const controller = this.sessions.begin(targetId);`
6462
+ ] : [];
4682
6463
  const onSubmitBody = realStreamBody({
4683
6464
  pad: " ",
4684
- read,
4685
- commitSet: (expr) => commit(expr),
4686
- setterAdapter: setter,
4687
- setLoading: (v2) => `this.loading.set(${v2});`,
6465
+ read: block ? blockRead : read,
6466
+ commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => commit(expr),
6467
+ setterAdapter: block ? "setMessages" : setter,
6468
+ setLoading: block ? (v2) => v2 === "true" ? `// loading for THIS thread now derives from the sessions map (streamingIds).` : `this.sessions.end(targetId, controller);` : (v2) => `this.loading.set(${v2});`,
4688
6469
  bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
4689
6470
  strictRoles: true,
4690
6471
  toolLoop: emitToolLoop,
4691
6472
  cards: ctx.emitCards,
4692
- thread: accessorThreadBinding(read, commit, setter),
6473
+ thread: block ? blockLiveThread(blockRead) : accessorThreadBinding(read, commit, setter),
4693
6474
  mock: isMock,
4694
- ...attachmentSubmitOpts
6475
+ ...block ? { fetchSignal: "controller.signal" } : {},
6476
+ ...attachmentSubmitOpts,
6477
+ ...block ? {
6478
+ afterValue: [
6479
+ ...attachmentSubmitOpts.afterValue ?? [],
6480
+ ...blockPrelude
6481
+ ]
6482
+ } : {}
4695
6483
  });
4696
6484
  const modelInit = defaultModel ? [
4697
6485
  `// SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
@@ -4706,7 +6494,31 @@ function renderAngular(components, ctx) {
4706
6494
  ` cardTypes: cards.tags,`,
4707
6495
  ` cardSchemas: cards.validationSchemas,`
4708
6496
  ] : [];
4709
- const sampleSeed = [
6497
+ const sampleSeed = block ? [
6498
+ ` // ── The block's state: your records + the kit's session/persistence helpers ─`,
6499
+ ` readonly threads = signal<Thread[]>(loadThreads());`,
6500
+ ` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
6501
+ ` // record is created on its first turn, so the rail never fills with empties.`,
6502
+ ` readonly activeId = signal<string | null>(this.threads()[0]?.id ?? null);`,
6503
+ ` // One id per in-flight reply — a stream keeps running when you switch away.`,
6504
+ ` readonly streamingIds = signal<string[]>([]);`,
6505
+ ...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
6506
+ ` private readonly sessions = createThreadSessions((ids) => this.streamingIds.set(ids));`,
6507
+ ` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
6508
+ ` // the save target and what a failed save means are YOURS (see saveThreads).`,
6509
+ ` private readonly saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
6510
+ ` // Every write goes through this — a NEW array per write is what notifies.`,
6511
+ ` private readonly setThreads = (updater: (prev: Thread[]) => Thread[]): void =>`,
6512
+ ` this.threads.update(updater);`,
6513
+ ` readonly active = computed(() => this.threads().find((t) => t.id === this.activeId()) ?? null);`,
6514
+ ` readonly messages = computed(() => this.active()?.messages ?? []);`,
6515
+ ` readonly loading = computed(() => { const id = this.activeId(); return id !== null && this.streamingIds().includes(id); });`,
6516
+ ` // The rail's row shape is a VIEW of your record — titles and counts, no`,
6517
+ ` // message bodies. The rail renders the array in the order it is given.`,
6518
+ ` readonly rows = computed(() => this.threads().map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt })));`,
6519
+ ` // The template cannot spell \`undefined\` portably, so the ?? lives here.`,
6520
+ ` readonly activeIdOrUndefined = computed(() => this.activeId() ?? undefined);`
6521
+ ] : [
4710
6522
  ...hasEmbedded ? sampleSeedComment(isMock, " ", (literal) => [
4711
6523
  `readonly messages = signal<ChatMessage[]>([${literal}]);`
4712
6524
  ]) : [],
@@ -4721,17 +6533,20 @@ function renderAngular(components, ctx) {
4721
6533
  ` private readonly sourcesEl = viewChild.required<ElementRef<KaiSourcesElement>>('sources');`
4722
6534
  ] : [];
4723
6535
  const sourcesReapply = hasSourcesCompanion ? [` Object.assign(this.sourcesEl().nativeElement, { sources: this.sampleSources });`] : [];
4724
- const chatTag = (pad) => [
4725
- `<kai-chat`,
4726
- ` #chat`,
4727
- ` [messages]="messages()"`,
4728
- ` [loading]="loading()"`,
4729
- ` [suggestions]="suggestions"`,
4730
- ` suggestion-mode="submit"`,
4731
- ` style="${p.chatFill}"`,
4732
- ` (kai-submit)="onSubmit($event)"`,
4733
- `></kai-chat>`
4734
- ].map((l) => `${pad}${l}`);
6536
+ const chatTag = (pad) => {
6537
+ const slotted = block ? [] : slottedChildMarkup(components, " ", { wired: false, ids: false });
6538
+ return [
6539
+ `<kai-chat`,
6540
+ ` #chat`,
6541
+ ` [messages]="messages()"`,
6542
+ ` [loading]="loading()"`,
6543
+ ` [suggestions]="suggestions"`,
6544
+ ` suggestion-mode="submit"`,
6545
+ ` style="${p.chatFill}"`,
6546
+ ` (kai-submit)="onSubmit($event)"`,
6547
+ ...slotted.length === 0 ? [`></kai-chat>`] : [`>`, ...slotted, `</kai-chat>`]
6548
+ ].map((l) => `${pad}${l}`);
6549
+ };
4735
6550
  const templateBody = workspace ? [
4736
6551
  ` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
4737
6552
  ` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
@@ -4747,7 +6562,39 @@ function renderAngular(components, ctx) {
4747
6562
  // Siblings of the SPLIT — see the same line in renderJsx.
4748
6563
  ...companionLines
4749
6564
  ] : [...chatTag(" "), ...companionLines];
4750
- const elementTypes = hasSourcesCompanion ? "KaiChatElement, KaiSourcesElement" : "KaiChatElement";
6565
+ const elementTypes = [
6566
+ "KaiChatElement",
6567
+ ...block ? ["KaiConversationsElement"] : [],
6568
+ ...hasSourcesCompanion ? ["KaiSourcesElement"] : []
6569
+ ].join(", ");
6570
+ const surfaceTemplate = block ? [
6571
+ ` <!-- ── The workspace BLOCK: the shell + the rail + your thread records ── -->`,
6572
+ ...htmlComment(
6573
+ [
6574
+ `<kai-workspace> is the chat-agnostic layout shell: header/start/main/`,
6575
+ `end/footer slots, aside resize + collapse, and a mobile drawer below`,
6576
+ `the drawer-below width. It knows nothing about chat.`
6577
+ ],
6578
+ " "
6579
+ ),
6580
+ ` <kai-workspace drawer-below="720" style="display:block;width:100%;height:100%">`,
6581
+ ...htmlComment(BLOCK_RAIL_NOTE, " "),
6582
+ ` <!-- New chat = a DRAFT (activeId null): the record appears on the first turn. -->`,
6583
+ ` <kai-conversations`,
6584
+ ` #rail`,
6585
+ ` slot="start"`,
6586
+ ` [conversations]="rows()"`,
6587
+ ` [activeId]="activeIdOrUndefined()"`,
6588
+ ` style="display:block;height:100%"`,
6589
+ ` (kai-conversation-select)="onSelect($event)"`,
6590
+ ` (kai-new-chat)="onNewChat()"`,
6591
+ ` ></kai-conversations>`,
6592
+ ` <!-- Unnamed children project into the shell's MAIN region. -->`,
6593
+ ` <div style="display:flex;flex-direction:column;height:100%;min-height:0">`,
6594
+ ...indentEmitted([...attachmentTemplate, ...templateBody], " "),
6595
+ ` </div>`,
6596
+ ` </kai-workspace>`
6597
+ ] : [...attachmentTemplate, ...templateBody];
4751
6598
  return [
4752
6599
  `// Angular standalone component — save as: src/app/chat.component.ts`,
4753
6600
  `// Render it: put <app-chat /> in your root template and add ChatComponent to`,
@@ -4759,7 +6606,7 @@ function renderAngular(components, ctx) {
4759
6606
  `// "styles": ["node_modules/@kitn.ai/ui/dist/theme.tokens.css", "src/styles.css"]`,
4760
6607
  `// (@kitn.ai/ui/theme.tokens.css is the compiled token file; theme.css is`,
4761
6608
  `// Tailwind source and is only for apps that compile Tailwind themselves.)`,
4762
- `import { CUSTOM_ELEMENTS_SCHEMA, Component, ElementRef, afterNextRender, signal, viewChild } from '@angular/core';`,
6609
+ `import { CUSTOM_ELEMENTS_SCHEMA, Component, ElementRef, afterNextRender, ${block ? "computed, effect, " : ""}signal, viewChild } from '@angular/core';`,
4763
6610
  `import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
4764
6611
  `import type { ${elementTypes} } from '@kitn.ai/ui/elements';`,
4765
6612
  ...wireImportLines({
@@ -4768,7 +6615,8 @@ function renderAngular(components, ctx) {
4768
6615
  cards: ctx.emitCards,
4769
6616
  cardTools: ctx.cardProvider !== null,
4770
6617
  mock: isMock,
4771
- attachments
6618
+ attachments,
6619
+ workspaceBlock: block
4772
6620
  }),
4773
6621
  ``,
4774
6622
  `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
@@ -4777,6 +6625,7 @@ function renderAngular(components, ctx) {
4777
6625
  ``,
4778
6626
  ...modelInit,
4779
6627
  ...attachmentModuleInit,
6628
+ ...block ? [...blockThreadStoreLines(""), ``] : [],
4780
6629
  ...cardsInit,
4781
6630
  ...toolsLines,
4782
6631
  ...runnerLines,
@@ -4788,8 +6637,7 @@ function renderAngular(components, ctx) {
4788
6637
  ` schemas: [CUSTOM_ELEMENTS_SCHEMA],`,
4789
6638
  ` template: \``,
4790
6639
  ` <div style="${p.style}">`,
4791
- ...attachmentTemplate,
4792
- ...templateBody,
6640
+ ...surfaceTemplate,
4793
6641
  ` </div>`,
4794
6642
  ` \`,`,
4795
6643
  `})`,
@@ -4797,12 +6645,17 @@ function renderAngular(components, ctx) {
4797
6645
  ` // Every write assigns a NEW array. That reference change is what re-renders`,
4798
6646
  ` // <kai-chat> — mutating the array in place does nothing.`,
4799
6647
  ...sampleSeed,
4800
- ` readonly loading = signal(false);`,
6648
+ ...block ? [] : [` readonly loading = signal(false);`],
4801
6649
  ` readonly suggestions = ${jsArray(suggestions)};`,
4802
6650
  ...sourcesField,
4803
6651
  ` private readonly chatEl = viewChild.required<ElementRef<KaiChatElement>>('chat');`,
6652
+ ...block ? [` private readonly railEl = viewChild.required<ElementRef<KaiConversationsElement>>('rail');`] : [],
4804
6653
  ``,
4805
6654
  ` constructor() {`,
6655
+ ...block ? [
6656
+ ` // The save scheduler rides an effect: every threads() write schedules a save.`,
6657
+ ` effect(() => this.saver.schedule(this.threads()));`
6658
+ ] : [],
4806
6659
  ` // SCAF-15: kai-* register via an async dynamic import (SSR-safety), so the`,
4807
6660
  ` // element may not be upgraded when Angular first applies the bindings above —`,
4808
6661
  ` // and a property set on a not-yet-upgraded element is dropped on upgrade.`,
@@ -4817,10 +6670,30 @@ function renderAngular(components, ctx) {
4817
6670
  ` suggestions: this.suggestions,`,
4818
6671
  ...cardPropAssign,
4819
6672
  ` });`,
6673
+ ...block ? [
6674
+ ` // The rail's array props follow the same upgrade rule.`,
6675
+ ` await customElements.whenDefined('kai-conversations');`,
6676
+ ` Object.assign(this.railEl().nativeElement, {`,
6677
+ ` conversations: this.rows(),`,
6678
+ ` activeId: this.activeIdOrUndefined(),`,
6679
+ ` });`,
6680
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
6681
+ ` window.addEventListener('beforeunload', () => this.saver.flush());`
6682
+ ] : [],
4820
6683
  ...sourcesReapply,
4821
6684
  ` });`,
4822
6685
  ` }`,
4823
6686
  ``,
6687
+ ...block ? [
6688
+ ` onSelect(event: Event) {`,
6689
+ ` this.activeId.set((event as CustomEvent<{ id: string }>).detail.id);`,
6690
+ ` }`,
6691
+ ` onNewChat() {`,
6692
+ ` // A DRAFT, not a record: it becomes a thread on its first turn.`,
6693
+ ` this.activeId.set(null);`,
6694
+ ` }`,
6695
+ ``
6696
+ ] : [],
4824
6697
  ...attachmentFields,
4825
6698
  ``,
4826
6699
  ` // \`Event\`, not \`CustomEvent\`: under strictTemplates Angular types \`$event\` on`,
@@ -4835,13 +6708,17 @@ function renderAngular(components, ctx) {
4835
6708
  }
4836
6709
  function renderSolid(components, ctx) {
4837
6710
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
4838
- const workspace = isWorkspace(components);
6711
+ const workspace = isArtifactSplit(components);
6712
+ const block = isWorkspaceBlock(components);
4839
6713
  const attachments = hasAttachments(components);
4840
6714
  const standaloneCompanionTags = components.filter(
4841
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
6715
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !ARTIFACT_SPLIT_TAGS.has(t) && !(block && BLOCK_TAGS.has(t)) && !(attachments && ATTACHMENT_TAGS.has(t))
4842
6716
  );
4843
6717
  const hasSources = standaloneCompanionTags.includes("kai-sources");
4844
6718
  const hasVoice = standaloneCompanionTags.includes("kai-voice-input");
6719
+ const unrenderedCompanions = standaloneCompanionTags.filter(
6720
+ (t) => t !== "kai-sources" && t !== "kai-voice-input"
6721
+ );
4845
6722
  const componentImports = [
4846
6723
  "Attachment",
4847
6724
  "AttachmentInfo",
@@ -4869,6 +6746,9 @@ function renderSolid(components, ctx) {
4869
6746
  "SourceTrigger",
4870
6747
  "Tool",
4871
6748
  ...workspace ? ["Artifact", "ResizableHandle", "ResizablePanel", "ResizablePanelGroup"] : [],
6749
+ // The BLOCK's structural pair: the Solid shell + the Solid rail (this target
6750
+ // renders components, not <kai-*> tags — same rule as everything above).
6751
+ ...block ? ["ConversationList", "WorkspaceShell"] : [],
4872
6752
  ...hasVoice ? ["VoiceInput"] : [],
4873
6753
  // `Attachments`/`Attachment`/`AttachmentPreview`/`AttachmentInfo` are already
4874
6754
  // unconditional above — `renderPart` draws `file` PARTS with them whatever the
@@ -4880,17 +6760,32 @@ function renderSolid(components, ctx) {
4880
6760
  const read = "messages()";
4881
6761
  const commit = (value) => `setMessages(${value});`;
4882
6762
  const setter = "(fn) => setMessages((prev) => fn(prev))";
6763
+ const blockRead = `(threads().find((t) => t.id === targetId)?.messages ?? [])`;
6764
+ const blockPrelude = block ? [
6765
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
6766
+ `const targetId = activeId() ?? crypto.randomUUID();`,
6767
+ `// The record is born on its first turn — with YOUR title policy.`,
6768
+ `setThreads((prev) => prev.some((t) => t.id === targetId)`,
6769
+ ` ? prev`,
6770
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
6771
+ `setActiveId(targetId);`,
6772
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
6773
+ `const setMessages = bindThreadMessages<Thread>((fn) => { setThreads((prev) => fn(prev)); }, targetId, {`,
6774
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
6775
+ `});`,
6776
+ `const controller = sessions.begin(targetId);`
6777
+ ] : [];
4883
6778
  const onSubmitBody = realStreamBody({
4884
6779
  pad: " ",
4885
- read,
4886
- commitSet: (expr) => commit(expr),
4887
- setterAdapter: setter,
4888
- setLoading: (v2) => `setLoading(${v2});`,
6780
+ read: block ? blockRead : read,
6781
+ commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => commit(expr),
6782
+ setterAdapter: block ? "setMessages" : setter,
6783
+ setLoading: block ? (v2) => v2 === "true" ? `// loading for THIS thread now derives from the sessions map (streamingIds).` : `sessions.end(targetId, controller);` : (v2) => `setLoading(${v2});`,
4889
6784
  bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
4890
6785
  strictRoles: true,
4891
6786
  toolLoop: emitToolLoop,
4892
6787
  cards: ctx.emitCards,
4893
- thread: accessorThreadBinding(read, commit, setter),
6788
+ thread: block ? blockLiveThread(blockRead) : accessorThreadBinding(read, commit, setter),
4894
6789
  valueSource: "input()",
4895
6790
  // The staged files are captured BEFORE either list is cleared. `setInput('')`
4896
6791
  // stays last so the order reads the way it executes.
@@ -4900,9 +6795,11 @@ function renderSolid(components, ctx) {
4900
6795
  clear: ["setStaged([]);"],
4901
6796
  fromSubmitEvent: false
4902
6797
  }) : [],
6798
+ ...blockPrelude,
4903
6799
  `setInput('');`
4904
6800
  ],
4905
6801
  ...attachments ? { filesExpr: "files" } : {},
6802
+ ...block ? { fetchSignal: "controller.signal" } : {},
4906
6803
  mock: isMock
4907
6804
  });
4908
6805
  const modelInit = defaultModel ? [
@@ -5082,6 +6979,21 @@ function renderSolid(components, ctx) {
5082
6979
  return [
5083
6980
  `// SolidJS + Vite — save as: src/App.tsx`,
5084
6981
  `//`,
6982
+ // What this target DROPS, said out loud. It renders Solid components for the
6983
+ // companions it knows (sources, voice input) and nothing at all for the rest,
6984
+ // so a `components` list naming one of those used to produce a file with no
6985
+ // trace of it — a silent drop, and the quiet half of the same defect the
6986
+ // conversation rail had. Naming them is not a fix; it is the honest record
6987
+ // until this target grows a branch for them.
6988
+ ...unrenderedCompanions.length > 0 ? [
6989
+ `// NOT WIRED, and not rendered: ${unrenderedCompanions.join(", ")}.`,
6990
+ `// You asked for ${unrenderedCompanions.length === 1 ? "it" : "them"} and this target has no branch for`,
6991
+ `// ${unrenderedCompanions.length === 1 ? "it" : "them"} — see the note below on why this file renders Solid`,
6992
+ `// components rather than <kai-*> elements. Compose the Solid component`,
6993
+ `// yourself (ask the component_reference MCP tool for what it exports), or`,
6994
+ `// scaffold the html target, which drives the custom elements directly.`,
6995
+ `//`
6996
+ ] : [],
5085
6997
  `// This target does NOT use the <kai-*> custom elements, and that is deliberate:`,
5086
6998
  `// the kit is AUTHORED in SolidJS, so a Solid app renders the real components`,
5087
6999
  `// with real props and real fine-grained reactivity. Going through the`,
@@ -5098,7 +7010,7 @@ function renderSolid(components, ctx) {
5098
7010
  `// The @source line is NOT optional: without it Tailwind scans only src/, strips`,
5099
7011
  `// every kit utility class as unused, and the whole UI renders unstyled.`,
5100
7012
  `// (theme.css here, not theme.tokens.css: this app compiles Tailwind itself.)`,
5101
- `import { For, Index, Match, Show, Switch, createMemo, createSignal } from 'solid-js';`,
7013
+ `import { For, Index, Match, Show, Switch, ${block ? "createEffect, " : ""}createMemo, createSignal${block ? ", onCleanup" : ""} } from 'solid-js';`,
5102
7014
  // '@kitn.ai/ui/solid', NOT the root '@kitn.ai/ui', and the difference is
5103
7015
  // invisible to every compiler on this repo's critical path: src/solid.ts is
5104
7016
  // `export * from './index'`, so ./solid is a strict SUPERSET of the root and
@@ -5123,13 +7035,15 @@ function renderSolid(components, ctx) {
5123
7035
  toolLoop: emitToolLoop,
5124
7036
  cards: ctx.emitCards,
5125
7037
  cardTools: ctx.cardProvider !== null,
5126
- mock: isMock
7038
+ mock: isMock,
7039
+ workspaceBlock: block
5127
7040
  }),
5128
7041
  ``,
5129
7042
  `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
5130
7043
  ...(p.altNote ?? []).map((l) => `// ${l}`),
5131
7044
  ``,
5132
7045
  ...isMock ? [...mockResponderInit(), ``] : [],
7046
+ ...block ? [...blockThreadStoreLines(""), ``] : [],
5133
7047
  ...cardsInit,
5134
7048
  `// Narrow a part to ONE variant, or false. One read, one cast — and the JSX`,
5135
7049
  `// below re-runs it on every delta, which is what keeps a growing text or`,
@@ -5303,8 +7217,34 @@ function renderSolid(components, ctx) {
5303
7217
  ` // streaming message as a NEW OBJECT on every delta: a new reference IS the`,
5304
7218
  ` // re-render signal. Which is exactly why the thread below is keyed by message`,
5305
7219
  ` // id rather than by the message objects — see messageKeys.`,
5306
- ` const [messages, setMessages] = createSignal<ChatMessage[]>([]);`,
5307
- ` const [loading, setLoading] = createSignal(false);`,
7220
+ ...block ? [
7221
+ ` // ── The block's state: your records + the kit's session/persistence helpers ─`,
7222
+ ` const [threads, setThreads] = createSignal<Thread[]>(loadThreads());`,
7223
+ ` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
7224
+ ` // record is created on its first turn, so the rail never fills with empties.`,
7225
+ ` const [activeId, setActiveId] = createSignal<string | null>(threads()[0]?.id ?? null);`,
7226
+ ` // One id per in-flight reply — a stream keeps running when you switch away.`,
7227
+ ` const [streamingIds, setStreamingIds] = createSignal<string[]>([]);`,
7228
+ ...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
7229
+ ` const sessions = createThreadSessions((ids) => setStreamingIds(ids));`,
7230
+ ` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
7231
+ ` // the save target and what a failed save means are YOURS (see saveThreads).`,
7232
+ ` const saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
7233
+ ` createEffect(() => saver.schedule(threads()));`,
7234
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
7235
+ ` const flushOnUnload = () => saver.flush();`,
7236
+ ` window.addEventListener('beforeunload', flushOnUnload);`,
7237
+ ` onCleanup(() => window.removeEventListener('beforeunload', flushOnUnload));`,
7238
+ ` const active = createMemo(() => threads().find((t) => t.id === activeId()) ?? null);`,
7239
+ ` const messages = createMemo(() => active()?.messages ?? []);`,
7240
+ ` const loading = createMemo(() => { const id = activeId(); return id !== null && streamingIds().includes(id); });`,
7241
+ ` // The rail's row shape is a VIEW of your record — titles and counts, no`,
7242
+ ` // message bodies. The rail renders the array in the order it is given.`,
7243
+ ` const rows = createMemo(() => threads().map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt })));`
7244
+ ] : [
7245
+ ` const [messages, setMessages] = createSignal<ChatMessage[]>([]);`,
7246
+ ` const [loading, setLoading] = createSignal(false);`
7247
+ ],
5308
7248
  ` // PromptInput is CONTROLLED here, so this signal — not a kai-submit event — is`,
5309
7249
  ` // where the submitted text comes from.`,
5310
7250
  ` const [input, setInput] = createSignal('');`,
@@ -5332,7 +7272,31 @@ function renderSolid(components, ctx) {
5332
7272
  ` // component below it. Mount it once, at the top.`,
5333
7273
  ` <ChatConfig>`,
5334
7274
  ` <div style={{ ${solidStyle(p.style)} }}>`,
5335
- ...tree,
7275
+ ...block ? [
7276
+ ` {/* ── The workspace BLOCK: the shell + the rail + your thread records ── */}`,
7277
+ ` {/* WorkspaceShell is the chat-agnostic layout shell (header/start/main/`,
7278
+ ` end/footer regions, aside resize + collapse, a mobile drawer below`,
7279
+ ` drawerBelow px). It knows nothing about chat. */}`,
7280
+ ` <WorkspaceShell`,
7281
+ ` drawerBelow={720}`,
7282
+ ` class="h-full w-full"`,
7283
+ ` start={`,
7284
+ // A JS line comment, not a {/* */} pair: inside an attribute's
7285
+ // expression container the braces form is a parse error.
7286
+ ...BLOCK_RAIL_NOTE_SOLID.map((l) => ` // ${l}`),
7287
+ ` <ConversationList`,
7288
+ ` groups={[]}`,
7289
+ ` conversations={rows()}`,
7290
+ ` activeId={activeId() ?? undefined}`,
7291
+ ` onSelect={(id) => setActiveId(id)}`,
7292
+ ` onNewChat={() => setActiveId(null)}`,
7293
+ ` class="h-full"`,
7294
+ ` />`,
7295
+ ` }`,
7296
+ ` >`,
7297
+ ...indentEmitted(tree, " "),
7298
+ ` </WorkspaceShell>`
7299
+ ] : tree,
5336
7300
  ` </div>`,
5337
7301
  ` </ChatConfig>`,
5338
7302
  ` );`,
@@ -5480,17 +7444,38 @@ function viteMiddlewareAdapter(plugin) {
5480
7444
  ` name: 'chat-api',`,
5481
7445
  ` configureServer(server) {`,
5482
7446
  ` server.middlewares.use('/api/chat', async (req, res) => {`,
7447
+ ` // The METHOD has to survive the bridge, like the status does below.`,
7448
+ ` // Hard-coding 'POST' here meant a bare GET /api/chat arrived at`,
7449
+ ` // readChatRequest as an empty POST and came back 400 "not valid JSON":`,
7450
+ ` // the 405 guard was emitted, compiled, and unreachable on this host.`,
7451
+ ` const method = req.method ?? 'POST';`,
7452
+ ` // undici THROWS on \`new Request(url, { method: 'GET', body })\`, so only`,
7453
+ ` // read a body from the methods that can carry one.`,
7454
+ ` const hasBody = method !== 'GET' && method !== 'HEAD';`,
5483
7455
  ` let body = '';`,
5484
- ` req.setEncoding('utf8');`,
5485
- ` for await (const chunk of req) body += chunk;`,
7456
+ ` if (hasBody) {`,
7457
+ ` req.setEncoding('utf8');`,
7458
+ ` for await (const chunk of req) body += chunk;`,
7459
+ ` }`,
5486
7460
  ``,
5487
- ` const response = await chatHandler(`,
5488
- ` new Request('http://localhost/api/chat', {`,
5489
- ` method: 'POST',`,
5490
- ` headers: { 'Content-Type': 'application/json' },`,
5491
- ` body,`,
5492
- ` }),`,
5493
- ` );`,
7461
+ ` let response: Response;`,
7462
+ ` try {`,
7463
+ ` response = await chatHandler(`,
7464
+ ` new Request('http://localhost/api/chat', {`,
7465
+ ` method,`,
7466
+ ` headers: { 'Content-Type': 'application/json' },`,
7467
+ ` body: hasBody ? body : undefined,`,
7468
+ ` }),`,
7469
+ ` );`,
7470
+ ` } catch (error) {`,
7471
+ ` // An unhandled rejection in an async connect middleware EXITS Node 22 —`,
7472
+ ` // this catch is the guard findings F-10 exists for. The browser gets`,
7473
+ ` // eight generic words, so the TERMINAL has to get the real thing:`,
7474
+ ` // swallowing it here would make a dev server strictly less`,
7475
+ ` // diagnosable than the crash it replaced.`,
7476
+ ` console.error('[chat-api] chatHandler failed:', error);`,
7477
+ ` response = Response.json({ error: 'Chat handler failed.' }, { status: 500 });`,
7478
+ ` }`,
5494
7479
  ``,
5495
7480
  ` // The STATUS has to survive the bridge: a 401 from the provider that`,
5496
7481
  ` // arrives at the browser as a 200 is a blank bubble and no error.`,
@@ -5807,7 +7792,13 @@ function cannotHostWarning(integration, route, framework) {
5807
7792
  // and reaches localhost, so the page can call it. That advice was emitted
5808
7793
  // identically for every integration because nothing read the flag that
5809
7794
  // distinguishes them.
5810
- ...integration.keyExposure === "frontend-safe" ? [
7795
+ ...integration.id === "mock" ? [
7796
+ // For mock the honest first option is to do nothing: the block is
7797
+ // already labelled OPTIONAL, and "call it directly" would be advice
7798
+ // about an upstream that does not exist.
7799
+ `# • or ignore it: the front end above already streams the mock locally, so`,
7800
+ `# nothing in this scaffold needs the route to run.`
7801
+ ] : integration.keyExposure === "frontend-safe" ? [
5811
7802
  `# • or drop the route entirely: this integration needs no server hop — see the`,
5812
7803
  `# run note — so the page may call it directly (CORS applies).`
5813
7804
  ] : [],
@@ -5816,7 +7807,8 @@ function cannotHostWarning(integration, route, framework) {
5816
7807
  `# • it is a separate SERVICE either way: run it (uvicorn main:app) and proxy`,
5817
7808
  `# /api/chat to it, or point the fetch at http://localhost:8000/api/chat.`
5818
7809
  ] : [`# • or run it where it belongs: framework: "${route.framework}".`],
5819
- `# • or use integration: "mock" for a zero-config local stream (no backend, no key).`
7810
+ // Not emitted for mock itself: "use mock" is where the reader already is.
7811
+ ...integration.id === "mock" ? [] : [`# • or use integration: "mock" for a zero-config local stream (no backend, no key).`]
5820
7812
  ];
5821
7813
  return [
5822
7814
  `#`,
@@ -5922,7 +7914,7 @@ function compose(components, integration, placement, framework, suggestions, aud
5922
7914
  audience
5923
7915
  });
5924
7916
  const isMock = integration.id === "mock";
5925
- const route = isMock ? void 0 : chooseRoute(integration, framework);
7917
+ const route = chooseRoute(integration, framework);
5926
7918
  const header = [
5927
7919
  `# AI/UI scaffold — ${preset ? preset.title : surfaceLabel(components)} × ${integration.title}`,
5928
7920
  `combo: ${preset ? preset.id : components.join("+")} × ${integration.id} × ${placement} × ${framework}`,
@@ -5936,11 +7928,17 @@ function compose(components, integration, placement, framework, suggestions, aud
5936
7928
  const block2Parts = [`=== (2) BACKEND ROUTE ===`, ``];
5937
7929
  if (isMock) {
5938
7930
  block2Parts.push(
5939
- `# No backend or API key needed replies stream locally for preview (see the`,
5940
- `# front-end onSubmit above). Swap \`integration\` for a real provider (openrouter,`,
5941
- `# ollama, vercel-ai-sdk, ) when ready, and this block becomes its route handler.`
7931
+ `# OPTIONAL the front end above streams its reply locally, so this scaffold`,
7932
+ `# RUNS with no backend and no key. The route below is the mock's server half:`,
7933
+ `# the same createMockResponder() frames served over HTTP, so you can stand up`,
7934
+ `# the real /api/chat seam before any provider exists. To use it, swap the`,
7935
+ `# onSubmit's \`const res = mockResponse(value);\` for a request to /api/chat and`,
7936
+ `# hand its response body to the same reader. Going live later then means`,
7937
+ `# editing THIS file only — replace the responder with a provider call, or`,
7938
+ `# re-scaffold with a real integration and this block becomes its route.`
5942
7939
  );
5943
- } else if (route) {
7940
+ }
7941
+ if (route) {
5944
7942
  if (!route.exact) {
5945
7943
  block2Parts.push(
5946
7944
  `# Note: ${integration.title} has no route for "${framework}". Emitting its native`,
@@ -6142,8 +8140,8 @@ function rejectUseCase(id) {
6142
8140
  // so the rejection that teaches the id list is the right place to say so.
6143
8141
  `These are PRESETS over the real axis, which is \`components\`. To compose a surface no`,
6144
8142
  `preset names, pass the list directly, e.g. components: ["kai-chat", "kai-tool",`,
6145
- `"kai-reasoning", "kai-artifact", "kai-resizable"] for a workspace that also renders its`,
6146
- `tool calls. Pick a preset id or pass \`components\`, then call scaffold again.`
8143
+ `"kai-reasoning", "kai-artifact", "kai-resizable"] for an artifact split that also renders`,
8144
+ `its tool calls. Pick a preset id or pass \`components\`, then call scaffold again.`
6147
8145
  ].join("\n");
6148
8146
  }
6149
8147
  const scaffold = {
@@ -6207,60 +8205,99 @@ const scaffold = {
6207
8205
  );
6208
8206
  }
6209
8207
  };
8208
+ const themeCss = "/* @kitn.ai/ui design system — import after `@import \"tailwindcss\"`.\n Defines ONLY namespaced (--color-*) tokens, component keyframes, animation\n utilities, and the .chat-markdown styles. Contains NO global base/reset rules,\n so importing it restyles nothing on its own — a host opts in via the kit's\n components + classes. Rebrand by overriding the --color-* values (optionally\n scoped to a subtree).\n NOTE: This file is Tailwind-v4 source (requires tailwindcss as a peer). For\n bundler-based apps that don't use Tailwind, import @kitn.ai/ui/theme.tokens.css\n instead — it is plain compiled CSS with no toolchain requirements. */\n\n@custom-variant dark (&:is(.dark *));\n\n@theme {\n --color-background: var(--kai-color-background, hsl(0 0% 100%));\n --color-foreground: var(--kai-color-foreground, hsl(240 10% 3.9%));\n --color-card: var(--kai-color-card, hsl(0 0% 100%));\n --color-card-foreground: var(--kai-color-card-foreground, hsl(240 10% 3.9%));\n --color-popover: var(--kai-color-popover, hsl(0 0% 100%));\n --color-popover-foreground: var(--kai-color-popover-foreground, hsl(240 10% 3.9%));\n --color-primary: var(--kai-color-primary, hsl(240 5.9% 10%));\n --color-primary-foreground: var(--kai-color-primary-foreground, hsl(0 0% 98%));\n --color-secondary: var(--kai-color-secondary, hsl(240 4.8% 95.9%));\n --color-secondary-foreground: var(--kai-color-secondary-foreground, hsl(240 5.9% 10%));\n --color-muted: var(--kai-color-muted, hsl(240 4.8% 95.9%));\n --color-muted-foreground: var(--kai-color-muted-foreground, hsl(240 3.8% 43%));\n /* Subtle SURFACE — an opaque \"muted panel\" for things that sit OVER content\n (the prompt input, message bubbles, artifact/card headers, chips). CALCULATED\n from existing tokens — `--color-muted` blended 40% onto `--color-background`\n — so it tracks the theme (incl. dark) automatically and needs no hand-picked\n value. SOLID (no alpha), so nothing behind it bleeds through. This replaces\n ad-hoc `bg-muted/40` surfaces. Retint via the --kai-color-surface knob (or a\n bare value); --color-surface-strong is the stronger step (hover/raised) and\n --color-surface-sunken is recessed BELOW the base surface (wells, or a banner\n that sits behind a card). Sunken is theme-specific: \"darker than the card\"\n means a LOWER muted% in dark but a HIGHER one in light, so the two scopes\n can't share a number — see the .dark block. */\n --color-surface: var(--kai-color-surface, color-mix(in srgb, var(--color-muted) 40%, var(--color-background)));\n --color-surface-strong: var(--kai-color-surface-strong, color-mix(in srgb, var(--color-muted) 60%, var(--color-background)));\n --color-surface-sunken: var(--kai-color-surface-sunken, color-mix(in srgb, var(--color-muted) 85%, var(--color-background)));\n --color-accent: var(--kai-color-accent, hsl(240 4.8% 95.9%));\n --color-accent-foreground: var(--kai-color-accent-foreground, hsl(240 5.9% 10%));\n --color-destructive: var(--kai-color-destructive, hsl(0 72% 45%));\n --color-destructive-foreground: var(--kai-color-destructive-foreground, hsl(0 0% 98%));\n /* Semantic status colors - solid fills with a legible foreground, matched to\n the kit's tool-green / -amber / -blue hues so they read on-brand. Use\n bg-success / bg-warning / bg-info with the matching -foreground for badges,\n banners, and buttons; or text-success / -warning / -info on a soft tint\n (below). destructive (above) is the danger/error member of the same family.\n Light uses darker hues so the color carries a light foreground AND stays\n legible as text on a pale tint; dark (see .dark) flips to brighter hues over\n a near-black foreground. Override via --kai-color-success / -warning / -info\n (+ -foreground). */\n --color-success: var(--kai-color-success, hsl(142 71% 30%));\n --color-success-foreground: var(--kai-color-success-foreground, hsl(0 0% 98%));\n --color-warning: var(--kai-color-warning, hsl(38 92% 30%));\n --color-warning-foreground: var(--kai-color-warning-foreground, hsl(0 0% 98%));\n --color-info: var(--kai-color-info, hsl(217 91% 42%));\n --color-info-foreground: var(--kai-color-info-foreground, hsl(0 0% 98%));\n /* Soft tinted status surfaces - OPAQUE, theme-tracking fills derived as ~14% of\n the status hue blended onto the background. Use these for subtle callouts /\n banners instead of an eyeballed bg-success/10; pair with text-success /\n -warning / -info / -destructive. Derived, so declared in BOTH light and dark\n (a color-mix resolves where it's declared - see the surface tokens above). A\n bare bg- utility for a color-mix token is added at the bottom (see bg-surface). */\n --color-success-soft: var(--kai-color-success-soft, color-mix(in oklab, var(--color-success) 14%, var(--color-background)));\n --color-warning-soft: var(--kai-color-warning-soft, color-mix(in oklab, var(--color-warning) 14%, var(--color-background)));\n --color-info-soft: var(--kai-color-info-soft, color-mix(in oklab, var(--color-info) 14%, var(--color-background)));\n --color-destructive-soft: var(--kai-color-destructive-soft, color-mix(in oklab, var(--color-destructive) 14%, var(--color-background)));\n /* Interaction-state backgrounds - opaque, theme-tracking fills for row/control\n states so consumers stop eyeballing /10 opacities. --color-hover is a subtle\n neutral (a little --color-muted on the background); --color-selected is a\n faint --color-primary tint so a selected row tracks the brand. Use bg-hover /\n bg-selected. Derived, so declared in BOTH light and dark; bare bg- utilities\n added at the bottom. */\n --color-hover: var(--kai-color-hover, color-mix(in oklab, var(--color-muted) 60%, var(--color-background)));\n --color-selected: var(--kai-color-selected, color-mix(in oklab, var(--color-primary) 8%, var(--color-background)));\n --color-border: var(--kai-color-border, hsl(240 5.9% 90%));\n --color-input: var(--kai-color-input, hsl(240 5.9% 90%));\n /* Focus ring — a deliberate blue (not a neutral) so keyboard focus is always\n obvious and on-brand in both modes. Light uses a strong blue; dark uses a\n brighter one for contrast on dark surfaces. Both clear WCAG 2.1 non-text\n contrast (≥3:1) against the kit's backgrounds. Override via --kai-color-ring. */\n --color-ring: var(--kai-color-ring, hsl(217 91% 53%));\n /* Custom scrollbars — thin, rounded, subtle; thumb strengthens on hover. A\n consistent cross-platform look (the kit styles its OWN scroll regions inside\n the shadow root; the track stays transparent so nothing is forced where no\n scrollbar is needed). Override via --kai-color-scrollbar-thumb(-hover). */\n --color-scrollbar-thumb: var(--kai-color-scrollbar-thumb, hsl(240 5% 80%));\n --color-scrollbar-thumb-hover: var(--kai-color-scrollbar-thumb-hover, hsl(240 4% 64%));\n --color-sidebar: var(--kai-color-sidebar, hsl(0 0% 100%));\n\n /* Inline `code` accent. Blue text on a translucent blue chip. */\n --color-code-foreground: var(--kai-color-code-foreground, hsl(224.3 76.3% 48%));\n\n /* Tool/status chip hues. Each chip is hue text over a 15% translucent fill of\n the SAME hue (set in tool.tsx). The bare hue is too light to reach WCAG AA\n (4.5:1) on the faint fill in LIGHT mode, so the light values are darkened;\n dark mode keeps brighter hues for AA on the dark fill. Override via\n --kai-color-tool-* to retheme. */\n --color-tool-blue: var(--kai-color-tool-blue, hsl(217 91% 38%));\n --color-tool-amber: var(--kai-color-tool-amber, hsl(38 92% 28%));\n --color-tool-green: var(--kai-color-tool-green, hsl(142 71% 26%));\n --color-tool-red: var(--kai-color-tool-red, hsl(0 72% 42%));\n\n --radius: var(--kai-radius, 0.6rem);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n\n /* Typography scale — semantic sizes shared by all components. Each generates a\n Tailwind utility (text-caption / text-meta / text-body / text-title). To\n restyle the kit's typography, override the namespaced --kai-text-* token on\n :root (e.g. `--kai-text-body: 0.9375rem`) — it pierces the Shadow DOM via the\n var() fallback, exactly like the --kai-color-* tokens. The bare --text-*\n names stay internal so a host's own --text-* can't collide. (Message/markdown/\n input reading size also scales with the `proseSize` prop; these tokens cover\n the fixed chrome & controls.) */\n --text-caption: var(--kai-text-caption, 0.6875rem); --text-caption--line-height: 1rem; /* 11px — micro labels, badges, sub-counts */\n --text-meta: var(--kai-text-meta, 0.75rem); --text-meta--line-height: 1.1rem; /* 12px — controls, toggles, switchers, captions */\n --text-body: var(--kai-text-body, 0.875rem); --text-body--line-height: 1.45rem; /* 14px — primary reading text */\n --text-title: var(--kai-text-title, 1rem); --text-title--line-height: 1.5rem; /* 16px — emphasis / headers */\n\n @keyframes typing { 0%,100% { transform: translateY(0); opacity: .5 } 50% { transform: translateY(-2px); opacity: 1 } }\n @keyframes loading-dots { 0%,100% { opacity: 0 } 50% { opacity: 1 } }\n @keyframes wave { 0%,100% { transform: scaleY(1) } 50% { transform: scaleY(.6) } }\n @keyframes blink { 0%,100% { opacity: 1 } 50% { opacity: 0 } }\n @keyframes text-blink { 0%,100% { color: var(--color-primary) } 50% { color: var(--color-muted-foreground) } }\n @keyframes bounce-dots { 0%,100% { transform: scale(.8); opacity: .5 } 50% { transform: scale(1.2); opacity: 1 } }\n @keyframes thin-pulse { 0%,100% { transform: scale(.95); opacity: .8 } 50% { transform: scale(1.05); opacity: .4 } }\n @keyframes pulse-dot { 0%,100% { transform: scale(1); opacity: .8 } 50% { transform: scale(1.5); opacity: 1 } }\n @keyframes shimmer-text { 0% { background-position: 150% center } 100% { background-position: -150% center } }\n @keyframes wave-bars { 0%,100% { transform: scaleY(1); opacity: .5 } 50% { transform: scaleY(.6); opacity: 1 } }\n @keyframes shimmer { 0% { background-position: 200% 50% } 100% { background-position: -200% 50% } }\n @keyframes spinner-fade { 0% { opacity: 0 } 100% { opacity: 1 } }\n @keyframes collapsible-down { from { height: 0; opacity: 0 } to { height: var(--kb-collapsible-content-height); opacity: 1 } }\n @keyframes collapsible-up { from { height: var(--kb-collapsible-content-height); opacity: 1 } to { height: 0; opacity: 0 } }\n}\n\n.dark {\n --color-background: var(--kai-color-background, hsl(50 2% 9%));\n --color-foreground: var(--kai-color-foreground, hsl(0 0% 98%));\n --color-card: var(--kai-color-card, hsl(45 4% 12%));\n --color-card-foreground: var(--kai-color-card-foreground, hsl(0 0% 98%));\n --color-popover: var(--kai-color-popover, hsl(45 4% 12%));\n --color-popover-foreground: var(--kai-color-popover-foreground, hsl(0 0% 98%));\n --color-primary: var(--kai-color-primary, hsl(0 0% 98%));\n --color-primary-foreground: var(--kai-color-primary-foreground, hsl(45 4% 11%));\n --color-secondary: var(--kai-color-secondary, hsl(45 4% 17%));\n --color-secondary-foreground: var(--kai-color-secondary-foreground, hsl(0 0% 98%));\n --color-muted: var(--kai-color-muted, hsl(45 4% 17%));\n --color-muted-foreground: var(--kai-color-muted-foreground, hsl(45 4% 64%));\n /* Re-declared in the dark scope so the color-mix recomputes with the DARK\n muted/background (a derived token is computed where it's declared, not where\n it's used — see the light --color-surface for the full note). */\n --color-surface: var(--kai-color-surface, color-mix(in srgb, var(--color-muted) 40%, var(--color-background)));\n --color-surface-strong: var(--kai-color-surface-strong, color-mix(in srgb, var(--color-muted) 60%, var(--color-background)));\n /* Sunken: in dark a LOWER muted% is darker (blends toward the dark background). */\n --color-surface-sunken: var(--kai-color-surface-sunken, color-mix(in srgb, var(--color-muted) 18%, var(--color-background)));\n --color-accent: var(--kai-color-accent, hsl(45 4% 17%));\n --color-accent-foreground: var(--kai-color-accent-foreground, hsl(0 0% 98%));\n --color-destructive: var(--kai-color-destructive, hsl(0 62.8% 30.6%));\n --color-destructive-foreground: var(--kai-color-destructive-foreground, hsl(0 0% 98%));\n /* Semantic status colors - dark mode flips to the brighter tool-* dark hues\n (matching the chip values) carried over a near-black foreground, so the color\n both reads as text on a dark soft tint AND backs a legible foreground. See\n the light @theme note. */\n --color-success: var(--kai-color-success, hsl(142 71% 45%));\n --color-success-foreground: var(--kai-color-success-foreground, hsl(45 4% 11%));\n --color-warning: var(--kai-color-warning, hsl(38 92% 50%));\n --color-warning-foreground: var(--kai-color-warning-foreground, hsl(45 4% 11%));\n --color-info: var(--kai-color-info, hsl(217 91% 70%));\n --color-info-foreground: var(--kai-color-info-foreground, hsl(45 4% 11%));\n /* Soft status surfaces + interaction states - re-declared so each color-mix\n recomputes with the DARK hues/background (a derived token resolves where it's\n declared; see the light block for the full note). */\n --color-success-soft: var(--kai-color-success-soft, color-mix(in oklab, var(--color-success) 14%, var(--color-background)));\n --color-warning-soft: var(--kai-color-warning-soft, color-mix(in oklab, var(--color-warning) 14%, var(--color-background)));\n --color-info-soft: var(--kai-color-info-soft, color-mix(in oklab, var(--color-info) 14%, var(--color-background)));\n --color-destructive-soft: var(--kai-color-destructive-soft, color-mix(in oklab, var(--color-destructive) 14%, var(--color-background)));\n --color-hover: var(--kai-color-hover, color-mix(in oklab, var(--color-muted) 60%, var(--color-background)));\n --color-selected: var(--kai-color-selected, color-mix(in oklab, var(--color-primary) 8%, var(--color-background)));\n --color-border: var(--kai-color-border, hsl(45 4% 17%));\n --color-input: var(--kai-color-input, hsl(45 4% 17%));\n --color-ring: var(--kai-color-ring, hsl(217 91% 68%));\n --color-scrollbar-thumb: var(--kai-color-scrollbar-thumb, hsl(45 3% 30%));\n --color-scrollbar-thumb-hover: var(--kai-color-scrollbar-thumb-hover, hsl(45 3% 42%));\n --color-sidebar: var(--kai-color-sidebar, hsl(50 2% 7%));\n --color-code-foreground: var(--kai-color-code-foreground, hsl(213 94% 78%));\n\n /* Tool/status chip hues — dark mode. Brighter hues reach AA on the dark fill. */\n --color-tool-blue: var(--kai-color-tool-blue, hsl(217 91% 70%));\n --color-tool-amber: var(--kai-color-tool-amber, hsl(38 92% 50%));\n --color-tool-green: var(--kai-color-tool-green, hsl(142 71% 45%));\n --color-tool-red: var(--kai-color-tool-red, hsl(0 84% 70%));\n}\n\n/* Self-contained markdown styling — replaces the typography plugin's `prose`.\n Keyed on --color-* tokens so it themes with the rest of the kit. */\n.chat-markdown { color: var(--color-foreground); line-height: 1.6; }\n.chat-markdown > div:first-child > :first-child { margin-top: 0; }\n.chat-markdown > div:last-child > :last-child { margin-bottom: 0; }\n.chat-markdown p { margin: 0 0 0.75em; }\n.chat-markdown p:last-child { margin-bottom: 0; }\n.chat-markdown h1, .chat-markdown h2, .chat-markdown h3, .chat-markdown h4 {\n margin: 1.2em 0 0.5em; font-weight: 600; line-height: 1.3; color: var(--color-foreground);\n}\n.chat-markdown h1 { font-size: 1.4em; } .chat-markdown h2 { font-size: 1.25em; }\n.chat-markdown h3 { font-size: 1.1em; } .chat-markdown h4 { font-size: 1em; }\n.chat-markdown ul, .chat-markdown ol { margin: 0 0 0.75em; padding-left: 1.4em; }\n.chat-markdown li { margin: 0.2em 0; }\n.chat-markdown ul { list-style: disc; } .chat-markdown ol { list-style: decimal; }\n.chat-markdown a { color: var(--color-primary); text-decoration: underline; text-underline-offset: 2px; }\n.chat-markdown strong { font-weight: 600; color: var(--color-foreground); }\n.chat-markdown em { font-style: italic; }\n.chat-markdown blockquote {\n margin: 0 0 0.75em; padding-left: 0.9em; border-left: 2px solid var(--color-border);\n color: var(--color-muted-foreground);\n}\n.chat-markdown hr { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border); }\n.chat-markdown code:not(pre code) {\n background: color-mix(in oklab, var(--color-code-foreground) 15%, transparent);\n color: var(--color-code-foreground);\n border-radius: 4px; padding: 0.1em 0.35em; font-size: 0.875em;\n font-family: var(--kai-font-code, ui-monospace, \"SF Mono\", Menlo, monospace);\n}\n.chat-markdown table { width: 100%; border-collapse: collapse; margin: 0 0 0.75em; font-size: 0.9em; }\n.chat-markdown th, .chat-markdown td { border: 1px solid var(--color-border); padding: 0.4em 0.6em; text-align: left; }\n.chat-markdown th { font-weight: 600; background: color-mix(in oklab, var(--color-muted-foreground) 8%, transparent); }\n\n/* Themeable elevation. The kit is mostly flat (borders), but a few surfaces lift\n off the page — popovers, dropdowns, hover cards, cards. The shadow COLOR is a\n token (--kai-shadow-color, default near-black); set it transparent for a fully\n flat look, or tint it to match a theme. Defined here (not as a Tailwind shadow\n utility) so the color stays overridable and works in both the web-component and\n SolidJS layers. */\n.kai-elevation {\n box-shadow:\n 0 4px 12px -2px color-mix(in oklab, var(--kai-shadow-color, oklch(0 0 0)) 14%, transparent),\n 0 2px 6px -2px color-mix(in oklab, var(--kai-shadow-color, oklch(0 0 0)) 10%, transparent);\n}\n.kai-elevation-sm {\n box-shadow: 0 1px 2px 0 color-mix(in oklab, var(--kai-shadow-color, oklch(0 0 0)) 8%, transparent);\n}\n\n/* Cross-platform thin scrollbar utility (Firefox + WebKit). Used by ScrollArea\n and any scroll region in the SolidJS-component build (web components also get\n this globally inside their shadow root — see src/elements/styles.css). */\n.scrollbar-thin {\n scrollbar-width: thin;\n scrollbar-color: var(--color-scrollbar-thumb) transparent;\n}\n.scrollbar-thin::-webkit-scrollbar { width: 8px; height: 8px; }\n.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }\n.scrollbar-thin::-webkit-scrollbar-thumb {\n background-color: var(--color-scrollbar-thumb);\n border-radius: 9999px;\n border: 2px solid transparent;\n background-clip: padding-box;\n}\n.scrollbar-thin::-webkit-scrollbar-thumb:hover { background-color: var(--color-scrollbar-thumb-hover); }\n.scrollbar-thin::-webkit-scrollbar-corner { background: transparent; }\n\n/* Surface utilities — `bg-surface` / `bg-surface-strong` paint the opaque,\n theme-derived surface tokens (see --color-surface above). Defined explicitly\n because a color-mix-valued @theme token does NOT emit a bare `bg-` utility on\n its own (only opacity variants). Use these for panels that sit OVER content\n instead of `bg-muted/NN`. */\n@utility bg-surface {\n background-color: var(--color-surface);\n}\n@utility bg-surface-strong {\n background-color: var(--color-surface-strong);\n}\n@utility bg-surface-sunken {\n background-color: var(--color-surface-sunken);\n}\n\n/* Soft-status + interaction-state utilities. Same reason as bg-surface above: a\n color-mix-valued @theme token does NOT emit a bare `bg-` utility on its own, so\n bg-success-soft / bg-warning-soft / bg-info-soft / bg-destructive-soft and\n bg-hover / bg-selected are declared here. (The SOLID tokens - success / warning\n / info + their -foreground - are plain hsl values, so bg-success / text-success\n etc. ARE auto-generated by @theme and need no explicit utility.) */\n@utility bg-success-soft {\n background-color: var(--color-success-soft);\n}\n@utility bg-warning-soft {\n background-color: var(--color-warning-soft);\n}\n@utility bg-info-soft {\n background-color: var(--color-info-soft);\n}\n@utility bg-destructive-soft {\n background-color: var(--color-destructive-soft);\n}\n@utility bg-hover {\n background-color: var(--color-hover);\n}\n@utility bg-selected {\n background-color: var(--color-selected);\n}\n";
8209
+ function declaredKaiTokens(css) {
8210
+ const names = /* @__PURE__ */ new Set();
8211
+ for (const m of css.matchAll(/var\(\s*(--kai-[a-zA-Z0-9-]+)\s*[,)]/g)) names.add(m[1]);
8212
+ return names;
8213
+ }
8214
+ const DECLARED_TOKENS = declaredKaiTokens(themeCss);
8215
+ function colorToken(suffix) {
8216
+ const name = `--kai-color-${suffix}`;
8217
+ if (!DECLARED_TOKENS.has(name)) {
8218
+ throw new Error(
8219
+ `[kai mcp: theme] theme.css declares no \`${name}\`. The tool brands that token, so emitting it would hand the caller CSS that themes nothing. Either the token was renamed in packages/ui/theme.css (update the BRAND_TOKENS suffix in src/agent-tooling/mcp/tools/theme.ts to match) or it was removed (drop the entry). Known --kai-color-* names: ${[...DECLARED_TOKENS].filter((n) => n.startsWith("--kai-color-")).sort().join(", ")}`
8220
+ );
8221
+ }
8222
+ return name;
8223
+ }
8224
+ const BRAND_TOKENS = [
8225
+ { key: "primary", suffix: "primary", purpose: "primary brand color" },
8226
+ {
8227
+ key: "primaryFg",
8228
+ suffix: "primary-foreground",
8229
+ purpose: "AA-contrast foreground on primary (auto-selected black/white)"
8230
+ },
8231
+ { key: "ring", suffix: "ring", purpose: "keyboard focus ring" },
8232
+ { key: "accent", suffix: "accent", purpose: "tinted accent surface" },
8233
+ { key: "accentFg", suffix: "accent-foreground", purpose: "text on accent surface" }
8234
+ ];
8235
+ let memo;
8236
+ function emittedTokens() {
8237
+ if (!memo) {
8238
+ const tokens = BRAND_TOKENS.map((t) => ({
8239
+ key: t.key,
8240
+ name: colorToken(t.suffix),
8241
+ purpose: t.purpose
8242
+ }));
8243
+ memo = { tokens, valueColumn: Math.max(...tokens.map((t) => t.name.length + 1)) + 2 };
8244
+ }
8245
+ return memo;
8246
+ }
6210
8247
  function parseHex(hex) {
6211
8248
  const s = hex.trim().replace(/^#/, "");
6212
8249
  if (s.length === 3) {
6213
8250
  const r = parseInt(s[0] + s[0], 16);
6214
- const g2 = parseInt(s[1] + s[1], 16);
8251
+ const g = parseInt(s[1] + s[1], 16);
6215
8252
  const b = parseInt(s[2] + s[2], 16);
6216
- if (isNaN(r) || isNaN(g2) || isNaN(b)) return null;
6217
- return { r, g: g2, b };
8253
+ if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
8254
+ return { r, g, b };
6218
8255
  }
6219
8256
  if (s.length === 6) {
6220
8257
  const r = parseInt(s.slice(0, 2), 16);
6221
- const g2 = parseInt(s.slice(2, 4), 16);
8258
+ const g = parseInt(s.slice(2, 4), 16);
6222
8259
  const b = parseInt(s.slice(4, 6), 16);
6223
- if (isNaN(r) || isNaN(g2) || isNaN(b)) return null;
6224
- return { r, g: g2, b };
8260
+ if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
8261
+ return { r, g, b };
6225
8262
  }
6226
8263
  return null;
6227
8264
  }
6228
- function toHex(r, g2, b) {
6229
- return "#" + [r, g2, b].map((v2) => Math.max(0, Math.min(255, Math.round(v2))).toString(16).padStart(2, "0")).join("");
8265
+ function toHex(r, g, b) {
8266
+ return "#" + [r, g, b].map((v2) => Math.max(0, Math.min(255, Math.round(v2))).toString(16).padStart(2, "0")).join("");
6230
8267
  }
6231
- function relativeLuminance(r, g2, b) {
8268
+ function relativeLuminance(r, g, b) {
6232
8269
  const lin = (c) => {
6233
8270
  const s = c / 255;
6234
8271
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
6235
8272
  };
6236
- return 0.2126 * lin(r) + 0.7152 * lin(g2) + 0.0722 * lin(b);
8273
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
6237
8274
  }
6238
- function foreground(r, g2, b) {
6239
- const lum = relativeLuminance(r, g2, b);
8275
+ function foreground(r, g, b) {
8276
+ const lum = relativeLuminance(r, g, b);
6240
8277
  const contrastWhite = 1.05 / (lum + 0.05);
6241
8278
  const contrastBlack = (lum + 0.05) / 0.05;
6242
8279
  return contrastWhite >= contrastBlack ? "#ffffff" : "#000000";
6243
8280
  }
6244
- function lighten(r, g2, b, ratio) {
8281
+ function lighten(r, g, b, ratio) {
6245
8282
  return toHex(
6246
8283
  r + (255 - r) * ratio,
6247
- g2 + (255 - g2) * ratio,
8284
+ g + (255 - g) * ratio,
6248
8285
  b + (255 - b) * ratio
6249
8286
  );
6250
8287
  }
6251
- function darken(r, g2, b, ratio) {
6252
- return toHex(r * (1 - ratio), g2 * (1 - ratio), b * (1 - ratio));
8288
+ function darken(r, g, b, ratio) {
8289
+ return toHex(r * (1 - ratio), g * (1 - ratio), b * (1 - ratio));
6253
8290
  }
6254
- function accentSurface(r, g2, b) {
6255
- return lighten(r, g2, b, 0.88);
8291
+ function accentSurface(r, g, b) {
8292
+ return lighten(r, g, b, 0.88);
6256
8293
  }
6257
- function darkModeBrand(r, g2, b) {
6258
- const lum = relativeLuminance(r, g2, b);
6259
- if (lum > 0.25) return toHex(r, g2, b);
6260
- return lighten(r, g2, b, 0.35);
8294
+ function darkModeBrand(r, g, b) {
8295
+ const lum = relativeLuminance(r, g, b);
8296
+ if (lum > 0.25) return toHex(r, g, b);
8297
+ return lighten(r, g, b, 0.35);
6261
8298
  }
6262
- function darkAccentSurface(r, g2, b) {
6263
- return darken(r, g2, b, 0.75);
8299
+ function darkAccentSurface(r, g, b) {
8300
+ return darken(r, g, b, 0.75);
6264
8301
  }
6265
8302
  const KEYWORD_COLORS = {
6266
8303
  blue: "#3b82f6",
@@ -6328,10 +8365,10 @@ function resolveBrand(brand, description) {
6328
8365
  note: `No brand color or description provided — defaulted to **${DEFAULT_BRAND}** (indigo, the kit's own brand). Pass \`brand: '#rrggbb'\` to use your color.`
6329
8366
  };
6330
8367
  }
6331
- function buildTokenSet(r, g2, b) {
6332
- const primary = toHex(r, g2, b);
6333
- const primaryFg = foreground(r, g2, b);
6334
- const accent = accentSurface(r, g2, b);
8368
+ function buildTokenSet(r, g, b) {
8369
+ const primary = toHex(r, g, b);
8370
+ const primaryFg = foreground(r, g, b);
8371
+ const accent = accentSurface(r, g, b);
6335
8372
  const accentFgColor = foreground(
6336
8373
  ...[accent].map((h) => parseHex(h)).flatMap((c) => [c.r, c.g, c.b])
6337
8374
  );
@@ -6343,12 +8380,12 @@ function buildTokenSet(r, g2, b) {
6343
8380
  accentFg: accentFgColor
6344
8381
  };
6345
8382
  }
6346
- function buildDarkTokenSet(r, g2, b) {
6347
- const darkBrand = darkModeBrand(r, g2, b);
8383
+ function buildDarkTokenSet(r, g, b) {
8384
+ const darkBrand = darkModeBrand(r, g, b);
6348
8385
  const parsed = parseHex(darkBrand);
6349
8386
  const primary = darkBrand;
6350
8387
  const primaryFg = foreground(parsed.r, parsed.g, parsed.b);
6351
- const accent = darkAccentSurface(r, g2, b);
8388
+ const accent = darkAccentSurface(r, g, b);
6352
8389
  const accentFgParsed = parseHex(accent);
6353
8390
  return {
6354
8391
  primary,
@@ -6358,14 +8395,12 @@ function buildDarkTokenSet(r, g2, b) {
6358
8395
  accentFg: foreground(accentFgParsed.r, accentFgParsed.g, accentFgParsed.b)
6359
8396
  };
6360
8397
  }
6361
- function cssBlock(selector, tokens) {
8398
+ function cssBlock(selector, values, emitted) {
6362
8399
  return [
6363
8400
  `${selector} {`,
6364
- ` --kai-color-primary: ${tokens.primary};`,
6365
- ` --kai-color-primary-foreground: ${tokens.primaryFg};`,
6366
- ` --kai-color-ring: ${tokens.ring};`,
6367
- ` --kai-color-accent: ${tokens.accent};`,
6368
- ` --kai-color-accent-foreground: ${tokens.accentFg};`,
8401
+ ...emitted.tokens.map(
8402
+ (t) => ` ${`${t.name}:`.padEnd(emitted.valueColumn)}${values[t.key]};`
8403
+ ),
6369
8404
  `}`
6370
8405
  ].join("\n");
6371
8406
  }
@@ -6387,21 +8422,24 @@ const theme = {
6387
8422
  description,
6388
8423
  mode = "light"
6389
8424
  } = args;
6390
- const { hex, r, g: g2, b, note } = resolveBrand(brand, description);
6391
- const lightTokens = buildTokenSet(r, g2, b);
6392
- const darkTokens = buildDarkTokenSet(r, g2, b);
8425
+ const emitted = emittedTokens();
8426
+ const { hex, r, g, b, note } = resolveBrand(brand, description);
8427
+ const lightTokens = buildTokenSet(r, g, b);
8428
+ const darkTokens = buildDarkTokenSet(r, g, b);
6393
8429
  const blocks = [];
6394
8430
  if (mode === "light" || mode === "both") {
6395
- blocks.push(cssBlock(":root", lightTokens));
8431
+ blocks.push(cssBlock(":root", lightTokens, emitted));
6396
8432
  }
6397
8433
  if (mode === "dark" || mode === "both") {
6398
- blocks.push(cssBlock(".dark", darkTokens));
8434
+ blocks.push(cssBlock(".dark", darkTokens, emitted));
6399
8435
  }
6400
8436
  const cssOutput = blocks.join("\n\n");
6401
8437
  const noteLabel = note.startsWith("Could not parse") ? "Note" : "Assumption";
6402
8438
  const noteSection = note ? `
6403
8439
  > **${noteLabel}:** ${note}
6404
8440
  ` : "";
8441
+ const tokenList = emitted.tokens.map((t) => `- \`${t.name}\` — ${t.purpose}`).join("\n");
8442
+ const firstToken = emitted.tokens[0].name;
6405
8443
  const text2 = `## AI/UI theme override — \`${hex}\`
6406
8444
  ${noteSection}
6407
8445
  Paste this block into your **global stylesheet** (before the \`@import\` of \`@kitn.ai/ui\` or after — order doesn't matter since \`--kai-*\` tokens are resolved at runtime via \`var()\` fallbacks). For \`mode:'dark'\` overrides, the \`.dark\` class must be on a parent element (e.g. \`<html class="dark">\`) for them to take effect.
@@ -6412,14 +8450,10 @@ ${cssOutput}
6412
8450
 
6413
8451
  ### How it works
6414
8452
 
6415
- The \`--kai-color-*\` tokens pierce the Shadow DOM via CSS custom-property inheritance. Every \`kai-*\` element reads them through a \`var(--kai-color-primary, <default>)\` fallback chain defined in \`theme.css\`. Setting them on \`:root\` (or a wrapper element) is enough to rebrand everything globally.
8453
+ The \`--kai-color-*\` tokens pierce the Shadow DOM via CSS custom-property inheritance. Every \`kai-*\` element reads them through a \`var(${firstToken}, <default>)\` fallback chain defined in \`theme.css\`. Setting them on \`:root\` (or a wrapper element) is enough to rebrand everything globally.
6416
8454
 
6417
- **Tokens emitted** (verified names from \`theme.css\`):
6418
- - \`--kai-color-primary\` — primary brand color
6419
- - \`--kai-color-primary-foreground\` — AA-contrast foreground on primary (auto-selected black/white)
6420
- - \`--kai-color-ring\` — keyboard focus ring
6421
- - \`--kai-color-accent\` — tinted accent surface
6422
- - \`--kai-color-accent-foreground\` — text on accent surface
8455
+ **Tokens emitted** (names read from \`theme.css\`):
8456
+ ${tokenList}
6423
8457
 
6424
8458
  ### Apply to a subtree only
6425
8459
 
@@ -6620,12 +8654,96 @@ const debug = {
6620
8654
  };
6621
8655
  }
6622
8656
  };
8657
+ function editDistance(a, b) {
8658
+ const prev = new Array(b.length + 1);
8659
+ for (let j2 = 0; j2 <= b.length; j2++) prev[j2] = j2;
8660
+ for (let i = 1; i <= a.length; i++) {
8661
+ let diag = prev[0];
8662
+ prev[0] = i;
8663
+ for (let j2 = 1; j2 <= b.length; j2++) {
8664
+ const tmp = prev[j2];
8665
+ prev[j2] = Math.min(
8666
+ prev[j2] + 1,
8667
+ prev[j2 - 1] + 1,
8668
+ diag + (a[i - 1] === b[j2 - 1] ? 0 : 1)
8669
+ );
8670
+ diag = tmp;
8671
+ }
8672
+ }
8673
+ return prev[b.length];
8674
+ }
8675
+ function typeWord(prop) {
8676
+ if (!prop) return "value";
8677
+ if (Array.isArray(prop.enum)) return prop.enum.map((v2) => JSON.stringify(v2)).join(" | ");
8678
+ if (prop.type === "array") {
8679
+ const items = prop.items;
8680
+ return `array of ${typeof items?.type === "string" ? items.type : "values"}`;
8681
+ }
8682
+ return typeof prop.type === "string" ? prop.type : "value";
8683
+ }
8684
+ function suggestKey(unknownKey, value, shape, supplied) {
8685
+ const keys = Object.keys(shape);
8686
+ let best;
8687
+ let bestDistance = 3;
8688
+ for (const key of keys) {
8689
+ const d = editDistance(unknownKey.toLowerCase(), key.toLowerCase());
8690
+ if (d < bestDistance) {
8691
+ bestDistance = d;
8692
+ best = key;
8693
+ }
8694
+ }
8695
+ if (best !== void 0) return best;
8696
+ const byValue = keys.filter(
8697
+ (key) => !supplied.includes(key) && z$1.safeParse(shape[key], value).success
8698
+ );
8699
+ return byValue.length === 1 ? byValue[0] : void 0;
8700
+ }
8701
+ function validateToolArgs(toolName, schema, args) {
8702
+ const shape = schema.shape;
8703
+ const keys = Object.keys(shape);
8704
+ const supplied = Object.keys(args);
8705
+ const problems = [];
8706
+ for (const key of supplied) {
8707
+ if (keys.includes(key)) continue;
8708
+ const suggestion = suggestKey(key, args[key], shape, supplied);
8709
+ problems.push(
8710
+ `unknown argument ${JSON.stringify(key)}` + (suggestion ? ` — did you mean ${JSON.stringify(suggestion)}?` : "")
8711
+ );
8712
+ }
8713
+ for (const key of keys) {
8714
+ if (!(key in args) && !z$1.safeParse(shape[key], void 0).success) {
8715
+ problems.push(`missing required argument ${JSON.stringify(key)}`);
8716
+ }
8717
+ }
8718
+ if (problems.length === 0) return void 0;
8719
+ const json = z$1.toJSONSchema(schema);
8720
+ const required = new Set(json.required ?? []);
8721
+ const argLines = keys.map((key) => {
8722
+ const kind = required.has(key) ? "required" : "optional";
8723
+ return ` ${key} (${kind}) — ${typeWord(json.properties?.[key])}`;
8724
+ });
8725
+ return `${toolName}: ${problems.join("; ")}.
8726
+
8727
+ Nothing was returned for this call — answering anyway could look like a reply to the question you meant to ask.
8728
+
8729
+ Expected arguments for ${toolName}:
8730
+ ${argLines.join("\n")}`;
8731
+ }
6623
8732
  const tools = [reference, scaffold, theme, debug];
8733
+ const PACKAGE_NAME = "@kitn.ai/ui";
8734
+ function packageIdentity() {
8735
+ const specifier = `${PACKAGE_NAME}/package.json`;
8736
+ const require2 = createRequire(import.meta.url);
8737
+ const pkg = require2(specifier);
8738
+ if (pkg.name !== PACKAGE_NAME || typeof pkg.version !== "string") {
8739
+ throw new Error(
8740
+ `[${PACKAGE_NAME}] Refusing to report a serverInfo that is not this package's: ${specifier} resolved to ${require2.resolve(specifier)}, whose name is ${JSON.stringify(pkg.name)} and version ${JSON.stringify(pkg.version)}.`
8741
+ );
8742
+ }
8743
+ return { name: pkg.name, version: pkg.version };
8744
+ }
6624
8745
  function createServer() {
6625
- const server = new Server(
6626
- { name: "@kitn.ai/ui", version: "0.15.0" },
6627
- { capabilities: { tools: {} } }
6628
- );
8746
+ const server = new Server(packageIdentity(), { capabilities: { tools: {} } });
6629
8747
  const byName = new Map(tools.map((t) => [t.name, t]));
6630
8748
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
6631
8749
  tools: tools.map((t) => ({
@@ -6645,7 +8763,12 @@ function createServer() {
6645
8763
  content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }]
6646
8764
  };
6647
8765
  }
6648
- return tool.handler(request.params.arguments ?? {});
8766
+ const args = request.params.arguments ?? {};
8767
+ const problem = validateToolArgs(tool.name, tool.inputSchema, args);
8768
+ if (problem) {
8769
+ return { isError: true, content: [{ type: "text", text: problem }] };
8770
+ }
8771
+ return tool.handler(args);
6649
8772
  });
6650
8773
  server.__listToolsForTest = () => tools.map((t) => t.name);
6651
8774
  return server;