@kitn.ai/ui 0.28.0 → 0.29.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 (376) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-tooling/construct/public.d.ts +15 -0
  3. package/dist/agent-tooling/construct/schema.d.ts +111 -0
  4. package/dist/construct-cli.es.js +3 -3
  5. package/dist/construct.d.ts +5 -0
  6. package/dist/construct.js +327 -0
  7. package/dist/core-C8fzo39E.js +1 -12
  8. package/dist/engine-javascript-C1x7zo1_.js +1 -141
  9. package/dist/mcp.es.js +1 -1
  10. package/package.json +16 -16
  11. package/bin/route.test.js +0 -58
  12. package/src/agent-tooling/README.md +0 -192
  13. package/src/agent-tooling/archetypes.ts +0 -205
  14. package/src/agent-tooling/catalog/README.md +0 -676
  15. package/src/agent-tooling/catalog/catalog-types.ts +0 -231
  16. package/src/agent-tooling/catalog/fabrications.ts +0 -96
  17. package/src/agent-tooling/catalog/invariants.ts +0 -284
  18. package/src/agent-tooling/catalog/labs-titles.ts +0 -114
  19. package/src/agent-tooling/catalog/scenarios.ts +0 -87
  20. package/src/agent-tooling/catalog/surfaces.ts +0 -347
  21. package/src/agent-tooling/construct/cli-entry.ts +0 -8
  22. package/src/agent-tooling/construct/cli.ts +0 -147
  23. package/src/agent-tooling/construct/codegen.ts +0 -1635
  24. package/src/agent-tooling/construct/construct.v1.schema.json +0 -319
  25. package/src/agent-tooling/construct/dev.ts +0 -148
  26. package/src/agent-tooling/construct/fixtures/demo-widget.construct.json +0 -7
  27. package/src/agent-tooling/construct/fixtures/ops-console.construct.json +0 -44
  28. package/src/agent-tooling/construct/fixtures/owner-widget.construct.json +0 -32
  29. package/src/agent-tooling/construct/schema.ts +0 -452
  30. package/src/agent-tooling/integrations/anthropic.ts +0 -442
  31. package/src/agent-tooling/integrations/cloudflare.ts +0 -218
  32. package/src/agent-tooling/integrations/langgraph.ts +0 -133
  33. package/src/agent-tooling/integrations/mastra.ts +0 -182
  34. package/src/agent-tooling/integrations/mock.ts +0 -148
  35. package/src/agent-tooling/integrations/ollama.ts +0 -97
  36. package/src/agent-tooling/integrations/openai.ts +0 -95
  37. package/src/agent-tooling/integrations/openrouter.ts +0 -82
  38. package/src/agent-tooling/integrations/pi.ts +0 -176
  39. package/src/agent-tooling/integrations/pydantic-ai.ts +0 -108
  40. package/src/agent-tooling/integrations/vercel-ai-sdk.ts +0 -398
  41. package/src/agent-tooling/mcp/css-raw.d.ts +0 -12
  42. package/src/agent-tooling/mcp/manifest.ts +0 -422
  43. package/src/agent-tooling/mcp/server.ts +0 -116
  44. package/src/agent-tooling/mcp/stdio.ts +0 -18
  45. package/src/agent-tooling/mcp/tools/construct.ts +0 -130
  46. package/src/agent-tooling/mcp/tools/debug.ts +0 -621
  47. package/src/agent-tooling/mcp/tools/reference.ts +0 -980
  48. package/src/agent-tooling/mcp/tools/scaffold.ts +0 -6708
  49. package/src/agent-tooling/mcp/tools/theme.ts +0 -456
  50. package/src/agent-tooling/mcp/tools/types.ts +0 -14
  51. package/src/agent-tooling/mcp/validate-args.ts +0 -141
  52. package/src/agent-tooling/recipes/composed-thread.ts +0 -714
  53. package/src/agent-tooling/recipes/index.ts +0 -22
  54. package/src/agent-tooling/recipes/types.ts +0 -32
  55. package/src/agent-tooling/registry.ts +0 -132
  56. package/src/agent-tooling/route-emit.ts +0 -305
  57. package/src/agent-tooling/types.ts +0 -390
  58. package/src/components/artifact-card.tsx +0 -120
  59. package/src/components/artifact.tsx +0 -914
  60. package/src/components/attachment-types.ts +0 -66
  61. package/src/components/attachments.tsx +0 -536
  62. package/src/components/audio-visualizer/audio-visualizer.voice-fixture.ts +0 -257
  63. package/src/components/audio-visualizer/aurora.glsl.ts +0 -330
  64. package/src/components/audio-visualizer/fit-scale.ts +0 -112
  65. package/src/components/audio-visualizer/index.tsx +0 -528
  66. package/src/components/audio-visualizer/labs/lab-choreography.ts +0 -187
  67. package/src/components/audio-visualizer/labs/lab-shaders.ts +0 -363
  68. package/src/components/audio-visualizer/labs/lab-visualizer.tsx +0 -102
  69. package/src/components/audio-visualizer/shader-canvas.tsx +0 -876
  70. package/src/components/audio-visualizer/sizes.ts +0 -54
  71. package/src/components/audio-visualizer/variant-aurora.tsx +0 -305
  72. package/src/components/audio-visualizer/variant-bar.tsx +0 -175
  73. package/src/components/audio-visualizer/variant-custom.tsx +0 -344
  74. package/src/components/audio-visualizer/variant-grid.tsx +0 -175
  75. package/src/components/audio-visualizer/variant-radial.tsx +0 -163
  76. package/src/components/audio-visualizer/variant-wave.tsx +0 -130
  77. package/src/components/audio-visualizer/wave.glsl.ts +0 -130
  78. package/src/components/card-fallback.tsx +0 -60
  79. package/src/components/card-renderer.tsx +0 -220
  80. package/src/components/card.tsx +0 -212
  81. package/src/components/chain-of-thought.tsx +0 -348
  82. package/src/components/chat-container.tsx +0 -89
  83. package/src/components/chat-scope-picker.tsx +0 -57
  84. package/src/components/chat-thread.tsx +0 -1014
  85. package/src/components/checkpoint.tsx +0 -88
  86. package/src/components/choice-card.tsx +0 -743
  87. package/src/components/coachmark.tsx +0 -249
  88. package/src/components/code-block.tsx +0 -181
  89. package/src/components/composer-dom.ts +0 -159
  90. package/src/components/composer-highlight.ts +0 -242
  91. package/src/components/composer-history.ts +0 -82
  92. package/src/components/composer.tsx +0 -1075
  93. package/src/components/confirm-card.tsx +0 -402
  94. package/src/components/context.tsx +0 -377
  95. package/src/components/conversation-item.tsx +0 -241
  96. package/src/components/conversation-list.tsx +0 -429
  97. package/src/components/conversation-panel.tsx +0 -118
  98. package/src/components/dismissed-stub.tsx +0 -80
  99. package/src/components/embed.tsx +0 -200
  100. package/src/components/empty.tsx +0 -177
  101. package/src/components/feedback-bar.tsx +0 -166
  102. package/src/components/file-tree.tsx +0 -530
  103. package/src/components/file-upload.tsx +0 -167
  104. package/src/components/form-widgets.tsx +0 -525
  105. package/src/components/form.tsx +0 -1296
  106. package/src/components/home-panel.tsx +0 -159
  107. package/src/components/image.tsx +0 -65
  108. package/src/components/link-preview.tsx +0 -195
  109. package/src/components/loader.tsx +0 -367
  110. package/src/components/markdown.tsx +0 -162
  111. package/src/components/message-skills.tsx +0 -40
  112. package/src/components/message.tsx +0 -765
  113. package/src/components/model-switcher.tsx +0 -132
  114. package/src/components/prompt-input.tsx +0 -188
  115. package/src/components/prompt-suggestion.tsx +0 -150
  116. package/src/components/reasoning.tsx +0 -293
  117. package/src/components/response-compare-types.ts +0 -152
  118. package/src/components/response-compare.tsx +0 -455
  119. package/src/components/response-stream.tsx +0 -109
  120. package/src/components/screen.tsx +0 -181
  121. package/src/components/scroll-button.tsx +0 -93
  122. package/src/components/source.tsx +0 -192
  123. package/src/components/tasks-card.tsx +0 -801
  124. package/src/components/text-shimmer.tsx +0 -37
  125. package/src/components/thinking-bar.tsx +0 -50
  126. package/src/components/thread.tsx +0 -228
  127. package/src/components/toast.tsx +0 -503
  128. package/src/components/tool-classify.ts +0 -26
  129. package/src/components/tool-types.ts +0 -49
  130. package/src/components/tool.tsx +0 -190
  131. package/src/components/use-card-resolution.ts +0 -44
  132. package/src/components/voice-input.tsx +0 -222
  133. package/src/components/voice-output.tsx +0 -242
  134. package/src/components/widget-tab-bar.tsx +0 -73
  135. package/src/components/workspace-shell.tsx +0 -285
  136. package/src/diagnostics/hook.ts +0 -352
  137. package/src/diagnostics/index.ts +0 -82
  138. package/src/diagnostics/report-request.ts +0 -296
  139. package/src/elements/agent-card.tsx +0 -67
  140. package/src/elements/artifact.tsx +0 -194
  141. package/src/elements/attachments.tsx +0 -140
  142. package/src/elements/audio-visualizer.tsx +0 -227
  143. package/src/elements/autoloader.ts +0 -89
  144. package/src/elements/avatar.tsx +0 -33
  145. package/src/elements/badge.tsx +0 -32
  146. package/src/elements/button.tsx +0 -149
  147. package/src/elements/card.tsx +0 -133
  148. package/src/elements/cards.tsx +0 -266
  149. package/src/elements/chain-of-thought.tsx +0 -141
  150. package/src/elements/chat-scope-picker.tsx +0 -65
  151. package/src/elements/chat-types.ts +0 -102
  152. package/src/elements/chat-workspace.tsx +0 -188
  153. package/src/elements/chat.tsx +0 -304
  154. package/src/elements/checkbox-group.tsx +0 -164
  155. package/src/elements/checkbox.tsx +0 -134
  156. package/src/elements/checkpoint.tsx +0 -47
  157. package/src/elements/choice.tsx +0 -100
  158. package/src/elements/coachmark.tsx +0 -90
  159. package/src/elements/code-block.tsx +0 -60
  160. package/src/elements/command.tsx +0 -197
  161. package/src/elements/compare.tsx +0 -111
  162. package/src/elements/compiled.css +0 -2
  163. package/src/elements/composer.tsx +0 -162
  164. package/src/elements/confirm-card.tsx +0 -76
  165. package/src/elements/context-meter.tsx +0 -107
  166. package/src/elements/conversation-item.tsx +0 -120
  167. package/src/elements/conversation-list.tsx +0 -203
  168. package/src/elements/css.ts +0 -5
  169. package/src/elements/default-input.tsx +0 -392
  170. package/src/elements/define-entry.ts +0 -12
  171. package/src/elements/define.tsx +0 -563
  172. package/src/elements/diagnostic-events.ts +0 -114
  173. package/src/elements/dialog.tsx +0 -145
  174. package/src/elements/disclosure.ts +0 -95
  175. package/src/elements/dock.tsx +0 -215
  176. package/src/elements/dropdown.tsx +0 -123
  177. package/src/elements/editable-label.tsx +0 -140
  178. package/src/elements/element-data-types.ts +0 -152
  179. package/src/elements/element-diagnostics.ts +0 -392
  180. package/src/elements/element-manifest.json +0 -354
  181. package/src/elements/element-nonscalar.json +0 -187
  182. package/src/elements/element-types.d.ts +0 -4353
  183. package/src/elements/embed.tsx +0 -35
  184. package/src/elements/empty.tsx +0 -29
  185. package/src/elements/feedback-bar.tsx +0 -66
  186. package/src/elements/file-tree.tsx +0 -62
  187. package/src/elements/file-upload.tsx +0 -44
  188. package/src/elements/form.tsx +0 -94
  189. package/src/elements/hover-card.tsx +0 -80
  190. package/src/elements/icon.tsx +0 -41
  191. package/src/elements/image.tsx +0 -32
  192. package/src/elements/input.tsx +0 -492
  193. package/src/elements/kbd.tsx +0 -45
  194. package/src/elements/link-preview.tsx +0 -34
  195. package/src/elements/loader.tsx +0 -25
  196. package/src/elements/markdown.tsx +0 -38
  197. package/src/elements/menu.tsx +0 -241
  198. package/src/elements/message-skills.tsx +0 -83
  199. package/src/elements/message.tsx +0 -361
  200. package/src/elements/model-switcher.tsx +0 -121
  201. package/src/elements/nav.tsx +0 -87
  202. package/src/elements/notice.tsx +0 -53
  203. package/src/elements/pane-grid.tsx +0 -111
  204. package/src/elements/pane-group.tsx +0 -119
  205. package/src/elements/pane.tsx +0 -129
  206. package/src/elements/popover.tsx +0 -81
  207. package/src/elements/progress-bar.tsx +0 -39
  208. package/src/elements/prompt-dock.tsx +0 -82
  209. package/src/elements/prompt-input.tsx +0 -240
  210. package/src/elements/prompt-suggestions.tsx +0 -113
  211. package/src/elements/radio-group.tsx +0 -129
  212. package/src/elements/reasoning.tsx +0 -71
  213. package/src/elements/register-impl.ts +0 -148
  214. package/src/elements/register.ts +0 -76
  215. package/src/elements/remote.tsx +0 -225
  216. package/src/elements/resizable.globals.d.ts +0 -36
  217. package/src/elements/resizable.tsx +0 -704
  218. package/src/elements/response-stream.tsx +0 -40
  219. package/src/elements/screen.tsx +0 -90
  220. package/src/elements/scroll-area.tsx +0 -33
  221. package/src/elements/scroll-button.tsx +0 -179
  222. package/src/elements/search.tsx +0 -185
  223. package/src/elements/segmented.tsx +0 -129
  224. package/src/elements/select.tsx +0 -165
  225. package/src/elements/separator.tsx +0 -36
  226. package/src/elements/setting-item.tsx +0 -50
  227. package/src/elements/settings-group.tsx +0 -37
  228. package/src/elements/skeleton.tsx +0 -45
  229. package/src/elements/slider.tsx +0 -171
  230. package/src/elements/slot-text.ts +0 -73
  231. package/src/elements/slots.ts +0 -787
  232. package/src/elements/source.tsx +0 -126
  233. package/src/elements/status.tsx +0 -42
  234. package/src/elements/styles.css +0 -328
  235. package/src/elements/switch.tsx +0 -118
  236. package/src/elements/tabs.tsx +0 -95
  237. package/src/elements/tasks.tsx +0 -103
  238. package/src/elements/text-shimmer.tsx +0 -28
  239. package/src/elements/thinking-bar.tsx +0 -43
  240. package/src/elements/thread.tsx +0 -139
  241. package/src/elements/toast.tsx +0 -106
  242. package/src/elements/tool.tsx +0 -78
  243. package/src/elements/tooltip.tsx +0 -85
  244. package/src/elements/validate-messages.ts +0 -76
  245. package/src/elements/voice-input.tsx +0 -100
  246. package/src/elements/voice-output.tsx +0 -82
  247. package/src/index.ts +0 -430
  248. package/src/primitives/audio-bands.ts +0 -174
  249. package/src/primitives/card-component-types.ts +0 -63
  250. package/src/primitives/card-contract.ts +0 -80
  251. package/src/primitives/card-data-types.ts +0 -361
  252. package/src/primitives/card-host.tsx +0 -37
  253. package/src/primitives/card-recovery.ts +0 -160
  254. package/src/primitives/card-registry.tsx +0 -78
  255. package/src/primitives/card-resolution.ts +0 -36
  256. package/src/primitives/card-routing.ts +0 -85
  257. package/src/primitives/card-schemas/artifact.schema.json +0 -92
  258. package/src/primitives/card-schemas/card-envelope.schema.json +0 -14
  259. package/src/primitives/card-schemas/card-event.schema.json +0 -12
  260. package/src/primitives/card-schemas/choice.schema.json +0 -75
  261. package/src/primitives/card-schemas/confirm.schema.json +0 -65
  262. package/src/primitives/card-schemas/embed.schema.json +0 -63
  263. package/src/primitives/card-schemas/form.result.schema.json +0 -7
  264. package/src/primitives/card-schemas/form.schema.json +0 -53
  265. package/src/primitives/card-schemas/link.schema.json +0 -54
  266. package/src/primitives/card-schemas/tasks.result.schema.json +0 -16
  267. package/src/primitives/card-schemas/tasks.schema.json +0 -83
  268. package/src/primitives/card-tags.ts +0 -61
  269. package/src/primitives/card-validate-cards.ts +0 -255
  270. package/src/primitives/card-validate-schemas.ts +0 -58
  271. package/src/primitives/card-validate.ts +0 -131
  272. package/src/primitives/chat-config.tsx +0 -103
  273. package/src/primitives/composer-model.ts +0 -49
  274. package/src/primitives/composer-triggers.ts +0 -35
  275. package/src/primitives/controllable.ts +0 -21
  276. package/src/primitives/conversation-store.ts +0 -248
  277. package/src/primitives/create-kai-chat.ts +0 -66
  278. package/src/primitives/create-tween.ts +0 -266
  279. package/src/primitives/embed-providers.ts +0 -254
  280. package/src/primitives/field-mask.ts +0 -256
  281. package/src/primitives/field-semantics.ts +0 -115
  282. package/src/primitives/highlighter.ts +0 -157
  283. package/src/primitives/input-mask.ts +0 -853
  284. package/src/primitives/link-preview.ts +0 -87
  285. package/src/primitives/message-feedback.ts +0 -124
  286. package/src/primitives/pdf-preview.ts +0 -121
  287. package/src/primitives/toast-store.ts +0 -338
  288. package/src/primitives/url-scheme-policy.ts +0 -70
  289. package/src/primitives/use-audio-analysis.ts +0 -340
  290. package/src/primitives/use-auto-resize.ts +0 -31
  291. package/src/primitives/use-resize-observer.ts +0 -45
  292. package/src/primitives/use-sequencer.ts +0 -72
  293. package/src/primitives/use-speech-recognition.ts +0 -146
  294. package/src/primitives/use-stick-to-bottom.ts +0 -75
  295. package/src/primitives/use-text-stream.ts +0 -143
  296. package/src/primitives/use-voice-recorder.ts +0 -62
  297. package/src/primitives/visualizer-sequences.ts +0 -350
  298. package/src/remote/host-embed.ts +0 -345
  299. package/src/remote/index.ts +0 -2
  300. package/src/remote/origin.ts +0 -30
  301. package/src/remote/provider-runtime.ts +0 -262
  302. package/src/remote/provider.ts +0 -2
  303. package/src/remote/validate.ts +0 -22
  304. package/src/remote/version.ts +0 -12
  305. package/src/remote/wire.ts +0 -48
  306. package/src/schemas/from-tool-call.ts +0 -151
  307. package/src/schemas/index.ts +0 -341
  308. package/src/schemas/provider-subsets.ts +0 -538
  309. package/src/schemas/registry.ts +0 -355
  310. package/src/schemas/tool-defs.ts +0 -793
  311. package/src/solid.ts +0 -173
  312. package/src/state/index.ts +0 -44
  313. package/src/state/messages.ts +0 -53
  314. package/src/state/mock.ts +0 -297
  315. package/src/state/parts.ts +0 -295
  316. package/src/state/persistence.ts +0 -180
  317. package/src/state/stream.ts +0 -246
  318. package/src/state/suggestions.ts +0 -9
  319. package/src/state/threads.ts +0 -142
  320. package/src/test-utils/fake-clock.ts +0 -88
  321. package/src/types.ts +0 -95
  322. package/src/ui/action-icons.ts +0 -53
  323. package/src/ui/agent-card.tsx +0 -218
  324. package/src/ui/avatar.tsx +0 -23
  325. package/src/ui/badge.tsx +0 -21
  326. package/src/ui/button.tsx +0 -49
  327. package/src/ui/card.tsx +0 -245
  328. package/src/ui/checkbox-group.tsx +0 -153
  329. package/src/ui/checkbox.tsx +0 -63
  330. package/src/ui/collapsible.tsx +0 -163
  331. package/src/ui/command.tsx +0 -103
  332. package/src/ui/dialog.tsx +0 -223
  333. package/src/ui/dock.tsx +0 -630
  334. package/src/ui/dropdown.tsx +0 -636
  335. package/src/ui/editable-label.tsx +0 -112
  336. package/src/ui/hover-card.tsx +0 -345
  337. package/src/ui/icon.tsx +0 -167
  338. package/src/ui/input.tsx +0 -391
  339. package/src/ui/kbd.tsx +0 -118
  340. package/src/ui/nav.tsx +0 -282
  341. package/src/ui/notice.tsx +0 -84
  342. package/src/ui/overlay.tsx +0 -277
  343. package/src/ui/pane-grid.tsx +0 -117
  344. package/src/ui/pane-group.tsx +0 -291
  345. package/src/ui/pane.tsx +0 -193
  346. package/src/ui/popover.tsx +0 -106
  347. package/src/ui/progress-bar.tsx +0 -83
  348. package/src/ui/prompt-dock.tsx +0 -145
  349. package/src/ui/radio.tsx +0 -150
  350. package/src/ui/resizable.tsx +0 -693
  351. package/src/ui/scroll-area.tsx +0 -26
  352. package/src/ui/segmented.tsx +0 -109
  353. package/src/ui/select.tsx +0 -168
  354. package/src/ui/separator.tsx +0 -10
  355. package/src/ui/settings-group.tsx +0 -67
  356. package/src/ui/skeleton.tsx +0 -73
  357. package/src/ui/slider.tsx +0 -178
  358. package/src/ui/status.tsx +0 -48
  359. package/src/ui/switch.tsx +0 -119
  360. package/src/ui/tabs.tsx +0 -168
  361. package/src/ui/textarea.tsx +0 -21
  362. package/src/ui/tooltip.tsx +0 -118
  363. package/src/utils/cn.ts +0 -30
  364. package/src/wire/chunk.ts +0 -248
  365. package/src/wire/consume.ts +0 -599
  366. package/src/wire/diagnostics.ts +0 -727
  367. package/src/wire/encode-probe.ts +0 -214
  368. package/src/wire/encode.ts +0 -947
  369. package/src/wire/files.ts +0 -342
  370. package/src/wire/formats/anthropic.ts +0 -274
  371. package/src/wire/formats/openai.ts +0 -228
  372. package/src/wire/index.ts +0 -95
  373. package/src/wire/media-types.ts +0 -344
  374. package/src/wire/read.ts +0 -396
  375. package/src/wire/sink-helpers.ts +0 -50
  376. package/src/wire/sse.ts +0 -153
@@ -1,1635 +0,0 @@
1
- /**
2
- * construct → generated Solid mini-project. THE single generation path:
3
- * kai dev, kai compile and kai eject all call generateProject — the preview IS
4
- * the artifact (owner-picked option B; no interpreter to drift).
5
- *
6
- * Quality bar: the output is the EJECT artifact. Deterministic (no dates, no
7
- * randomness, object keys emitted in fixed order), idiomatic, readable.
8
- * Interior is pure Solid composing @kitn.ai/ui/solid; provider glue imports
9
- * @kitn.ai/ui/state + /wire (never a hand-rolled SSE reader); the one
10
- * defineWebComponent facade carries the tag, theme default and slots.
11
- * Styling: kit components + inline styles only — defineWebComponent injects
12
- * the compiled kit CSS into the shadow root, so the generated project needs no
13
- * Tailwind, no CSS build, nothing.
14
- */
15
- import { createRequire } from 'node:module';
16
- import { mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
17
- import { dirname, join } from 'node:path';
18
- import type { Construct } from './schema';
19
-
20
- export interface GeneratedFile {
21
- path: string;
22
- code: string;
23
- }
24
-
25
- export interface GenerateOptions {
26
- /** Dependency spec for @kitn.ai/ui in the generated package.json.
27
- * Default: `^<this package's version>` (self-name resolution, mcp/server.ts pattern).
28
- * The gates pass a local tarball path here. */
29
- uiSpec?: string;
30
- }
31
-
32
- function kitVersion(): string {
33
- const require = createRequire(import.meta.url);
34
- const pkg = require('@kitn.ai/ui/package.json') as { version: string };
35
- return pkg.version;
36
- }
37
-
38
- const themeMode = (c: Construct): 'light' | 'dark' | 'auto' =>
39
- c.theme?.mode === 'light' ? 'light' : c.theme?.mode === 'dark' ? 'dark' : 'auto';
40
-
41
- // ── accent contrast (codegen-time, static) ─────────────────────────────────
42
- // A light accent (e.g. yellow) paired with the kit's default near-white
43
- // foreground is unreadable, so the construct's accent needs a matching
44
- // --kai-color-primary-foreground computed at generation time (the accent is
45
- // static per construct — no reason to compute this at runtime for every
46
- // browser that can't do it natively). Parses only the numeric CSS color forms
47
- // (#rgb/#rrggbb/#rrggbbaa, rgb()/rgba(), hsl()/hsla()) — named colors, var(),
48
- // and anything else exotic are NOT guessed at; see resolveContrastForeground.
49
-
50
- /** sRGB channel (0-255) -> linear-light value, per the WCAG relative
51
- * luminance formula. */
52
- function srgbChannelToLinear(c: number): number {
53
- const cs = c / 255;
54
- return cs <= 0.03928 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4);
55
- }
56
-
57
- /** WCAG relative luminance, 0 (black) to 1 (white). */
58
- function relativeLuminance(r: number, g: number, b: number): number {
59
- return 0.2126 * srgbChannelToLinear(r) + 0.7152 * srgbChannelToLinear(g) + 0.0722 * srgbChannelToLinear(b);
60
- }
61
-
62
- function hslToRgb(h: number, s: number, l: number): [number, number, number] {
63
- const hue = ((h % 360) + 360) % 360;
64
- const chroma = (1 - Math.abs(2 * l - 1)) * s;
65
- const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
66
- const m = l - chroma / 2;
67
- const [r1, g1, b1] =
68
- hue < 60 ? [chroma, x, 0]
69
- : hue < 120 ? [x, chroma, 0]
70
- : hue < 180 ? [0, chroma, x]
71
- : hue < 240 ? [0, x, chroma]
72
- : hue < 300 ? [x, 0, chroma]
73
- : [chroma, 0, x];
74
- return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)];
75
- }
76
-
77
- /** Parse the numeric CSS color forms only. Returns null for anything this
78
- * can't resolve to concrete RGB without guessing (named colors, var(),
79
- * color-mix(), oklch(), etc.) — the caller must decide loudly rather than
80
- * silently picking a default for those. */
81
- function parseAccentRgb(accent: string): [number, number, number] | null {
82
- const s = accent.trim();
83
- const hex3 = /^#([0-9a-fA-F]{3})$/.exec(s);
84
- if (hex3) {
85
- const [r, g, b] = hex3[1].split('').map((ch) => parseInt(ch + ch, 16));
86
- return [r, g, b];
87
- }
88
- const hex6 = /^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/.exec(s);
89
- if (hex6) {
90
- const hex = hex6[1];
91
- return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
92
- }
93
- const rgbFn = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$/.exec(s);
94
- if (rgbFn) {
95
- const [r, g, b] = [rgbFn[1], rgbFn[2], rgbFn[3]].map(Number);
96
- return [r, g, b].every((v) => v >= 0 && v <= 255) ? [r, g, b] : null;
97
- }
98
- const hslFn = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*[\d.]+\s*)?\)$/.exec(s);
99
- if (hslFn) {
100
- return hslToRgb(Number(hslFn[1]), Number(hslFn[2]) / 100, Number(hslFn[3]) / 100);
101
- }
102
- return null;
103
- }
104
-
105
- /**
106
- * The paired --kai-color-primary-foreground for a parseable accent, or null
107
- * when the accent can't be resolved to concrete RGB without guessing.
108
- *
109
- * Threshold: white when it sits closer to white than to black on the WCAG
110
- * relative-luminance scale (L <= 0.5), else black — equivalently "contrast
111
- * against white (1 - L) >= contrast against black (L)". This is a deliberately
112
- * simpler comparison than the full WCAG *contrast-ratio* formula (which adds
113
- * a 0.05 offset to both sides): the ratio formula's asymmetric offset flips
114
- * the choice for at least one real accent this codegen ships in its own demo
115
- * fixture (#e91e63, L≈0.1915 — contrast-ratio picks black at 4.83:1 over
116
- * white's 4.35:1, but a straight luminance-distance comparison, and every
117
- * reference brand palette pairing that color with white text, picks white).
118
- * Verified by direct computation, not assumed — see the "accent contrast"
119
- * describe block in codegen.test.ts for the worked numbers.
120
- */
121
- export function resolveContrastForeground(accent: string): '#000000' | '#ffffff' | null {
122
- const rgb = parseAccentRgb(accent);
123
- if (!rgb) return null;
124
- const luminance = relativeLuminance(...rgb);
125
- return luminance <= 0.5 ? '#ffffff' : '#000000';
126
- }
127
-
128
- /**
129
- * Shallow-merge a model's card tool-call args onto a DECLARED form schema's
130
- * field defaults — "model proposes, user confirms" (CD-1, owner ruling
131
- * 2026-08-26). Only top-level keys the args and the schema BOTH name are
132
- * touched; the schema's own field shape (title/type/widget/validation) is
133
- * never altered, and a key the model sent that isn't a declared field is
134
- * ignored (the construct's vocabulary wins, not the model's). One level deep
135
- * only — a nested object field's own defaults are not recursed into; no
136
- * evidence of need yet (vocabulary-on-evidence).
137
- *
138
- * This is the real, module-level version used by this file's own tests
139
- * (imported directly — see codegen-cards.render.test.tsx). `emitCardsImport`
140
- * below emits an equivalent function VERBATIM as a string into the
141
- * construct's own generated App.tsx: that copy is construct-glue code the
142
- * eject artifact must own standalone (same as every other piece of logic
143
- * emitCardsImport/emitApplyCardTools already emit inline), not an import
144
- * from the kit — so the two are kept in sync by hand, not by import. Keep
145
- * them behaviorally identical if you change one.
146
- */
147
- export function mergeToolArgsIntoFormDefaults(
148
- schema: Record<string, unknown>,
149
- args: Record<string, unknown>,
150
- ): Record<string, unknown> {
151
- const declared = schema.properties;
152
- if (!declared || typeof declared !== 'object') return schema;
153
- const patched: Record<string, unknown> = {};
154
- for (const [key, value] of Object.entries(args)) {
155
- if (!(key in (declared as Record<string, unknown>))) continue;
156
- patched[key] = {
157
- ...((declared as Record<string, unknown>)[key] as Record<string, unknown>),
158
- default: value,
159
- };
160
- }
161
- return { ...schema, properties: { ...(declared as Record<string, unknown>), ...patched } };
162
- }
163
-
164
- /**
165
- * Neutralize characters that could break out of a comment before embedding
166
- * untrusted text in one. Two contexts reuse this: the CLI/dev notice line
167
- * (plain terminal text — newlines just garble it) and the CSS NOTICE comment
168
- * emitted into element.tsx, where the two-character close-comment sequence in
169
- * the accent would otherwise end the comment early and let the rest of the
170
- * accent's text land as live CSS inside the generated stylesheet.
171
- */
172
- function commentSafe(text: string): string {
173
- return text.replace(/[\r\n]/g, ' ').replace(/\*\//g, '* /');
174
- }
175
-
176
- const CONTRAST_COLOR_SUPPORTS = '@supports (color: contrast-color(red))';
177
-
178
- /**
179
- * One line for whichever host decides loudly about generation-time notices
180
- * (the CLI today; `dev`'s watch loop reuses it on every regen). Null when
181
- * there's nothing to say — no accent, or the accent parsed fine.
182
- */
183
- export function accentContrastNotice(construct: Construct): string | null {
184
- const accent = construct.theme?.accent;
185
- if (!accent || resolveContrastForeground(accent) !== null) return null;
186
- return `accent '${commentSafe(accent)}' not parseable for contrast; foreground left at theme default in browsers without CSS contrast-color() support`;
187
- }
188
-
189
- export function generateProject(construct: Construct, opts: GenerateOptions = {}): GeneratedFile[] {
190
- const uiSpec = opts.uiSpec ?? `^${kitVersion()}`;
191
- const files: GeneratedFile[] = [
192
- { path: 'package.json', code: emitPackageJson(construct, uiSpec) },
193
- { path: 'tsconfig.json', code: emitTsconfig() },
194
- { path: 'vite.config.ts', code: emitViteDev() },
195
- { path: 'vite.config.lib.ts', code: emitViteLib(construct) },
196
- { path: 'index.html', code: emitIndexHtml(construct) },
197
- { path: 'src/element.tsx', code: emitElement(construct) },
198
- { path: 'src/App.tsx', code: emitApp(construct) },
199
- ];
200
- if (construct.cards) files.push({ path: 'src/cards.ts', code: emitCardsRegistry(construct.cards) });
201
- return files;
202
- }
203
-
204
- // ── cards ────────────────────────────────────────────────────────────────
205
- // Named generative-UI card definitions the model can emit as tool calls.
206
- // Registration only — the projection into provider tool definitions is the
207
- // kit's OWN `cardTools`/`toOpenAITools`/`toAnthropicTools` (@kitn.ai/ui/schemas,
208
- // src/schemas/tool-defs.ts), never a second one authored here.
209
-
210
- /** `src/cards.ts` — the construct's card registry, verbatim from the
211
- * construct. Each schema is `JSON.stringify(schema, null, 2)`'d and reindented
212
- * under its key — deterministic because the construct's own key order (and
213
- * each schema's own JSON key order) is preserved; nothing here re-sorts. */
214
- function emitCardsRegistry(cards: NonNullable<Construct['cards']>): string {
215
- const entries = cards
216
- .map((card) => ` ${card.name}: ${JSON.stringify(card.schema, null, 2).split('\n').join('\n ')},`)
217
- .join('\n');
218
- return `// src/cards.ts — the construct's card registry, verbatim from the construct.
219
- // Tool definitions for YOUR backend derive from this same object via
220
- // @kitn.ai/ui/schemas (cardTools / toOpenAITools / toAnthropicTools) — one
221
- // projection, shared with the kit.
222
- export const cards = {
223
- ${entries}
224
- } as const;
225
- `;
226
- }
227
-
228
- /** `, BUILTIN_CARD_COMPONENTS` spliced onto the `@kitn.ai/ui/solid` named-import
229
- * list at the top of App.tsx (below) when cards are declared — the built-in
230
- * `.form` renderer every declared card routes to. Empty otherwise. */
231
- function emitCardComponentImport(c: Construct): string {
232
- return c.cards ? ', BUILTIN_CARD_COMPONENTS' : '';
233
- }
234
-
235
- /** The `import`s cards need in App.tsx: the registry itself, `BUILTIN_CARD_COMPONENTS`
236
- * (@kitn.ai/ui/solid re-exports it from the root entry) so every declared card can
237
- * route to the kit's own schema-driven form renderer, `cardFromToolCall` (turns a
238
- * settled tool-call ToolPart into a renderable `card` MessagePart — see
239
- * emitApplyCardTools) and, for an endpoint construct, the wire-matching tool
240
- * projection for the fetch body. Empty when the construct declares no cards at all
241
- * (format rule: undeclared -> no affordance, no import).
242
- *
243
- * RULING (supervisor, this task): v1 renders EVERY declared card as the kit's own
244
- * `form` card (`BUILTIN_CARD_COMPONENTS.form`, components/form.tsx) — it walks a
245
- * JSON-Schema-shaped `data` into real input fields and honors `x-kai-format`/
246
- * `x-kai-mask`/`x-kai-mask-guide` hints itself (field-mask.ts); no engine work is
247
- * needed for masks specifically. This is deliberately NOT the same precedent as
248
- * examples/apps/ops-console/shared/cards.ts's `createCardRegistry` (which maps
249
- * several DISTINCT built-in kinds — confirm/form/choice/tasks — onto an app's own
250
- * tool names): a construct's `cards` field carries only a `schema`, no `kind`, so
251
- * there is no vocabulary yet to route on. Adding a `kind`/`type` field to pick
252
- * confirm/choice/tasks is explicitly deferred to vocabulary-on-evidence, not done
253
- * here — every construct card is a form until a later task adds that field. */
254
- function emitCardsImport(c: Construct): string {
255
- if (!c.cards) return '';
256
- const toolsImport =
257
- c.provider.mode === 'endpoint' ? (c.provider.wire === 'openai' ? ', toOpenAITools' : ', toAnthropicTools') : '';
258
- return `import { cards } from './cards';
259
- // Generative-UI cards, v1: every declared card renders as the kit's own
260
- // schema-driven FORM (BUILTIN_CARD_COMPONENTS.form, components/form.tsx) — it
261
- // walks the card's JSON Schema into real input fields, honoring
262
- // x-kai-format/x-kai-mask/x-kai-mask-guide hints itself. ChatThread's own
263
- // MessageBody already matches \`part.type === 'card'\` in its part rendering and
264
- // draws it with the kit's own \`CardRenderer\` (components/card-renderer.tsx),
265
- // which picks the component from \`cardTypes\` (below) by envelope.type — so
266
- // there is nothing to hand-compose beyond that one map. Turning a model's tool
267
- // call into that renderable part is \`cardFromToolCall\` (the inverse of
268
- // \`cardTools\`), applied once per settled turn below; its data is then replaced
269
- // with the DECLARED card schema (not the model's call arguments) — the fields
270
- // on screen are the construct's own vocabulary, not whatever shape a model
271
- // happened to send.
272
- //
273
- // UPDATE (CD-1, owner ruling 2026-08-26, Task 19g): the field SHAPE (title/
274
- // type/widget/validation) still comes from the construct's own declared
275
- // schema, never the model's — that part is unchanged. But discarding the
276
- // model's call arguments wholesale also threw away any VALUE it wanted to
277
- // pre-fill, breaking "model proposes, user confirms" (kai_refund_approval
278
- // {amount:50} rendered an empty form). So the model's args are now
279
- // shallow-merged onto the declared schema's field \`default\`s below
280
- // (mergeToolArgsIntoFormDefaults) before the card is added — see
281
- // emitApplyCardTools.
282
- import { cardFromToolCall${toolsImport} } from '@kitn.ai/ui/schemas';
283
-
284
- // Every declared card name routes to the SAME form renderer — cardFromToolCall
285
- // makes envelope.type equal the card's own name (kai_refund_approval ->
286
- // 'refund_approval'), and CardRenderer resolves a type's component from this
287
- // map.
288
- //
289
- // Deliberately NOT also wiring ChatThread's \`cardSchemas\` prop to this
290
- // registry below. That prop validates envelope.data AGAINST the named schema,
291
- // and this card's data IS \`cards[name]\` itself (see emitApplyCardTools) — the
292
- // construct's declared field schema, not values shaped like it. Wiring it as
293
- // its own validator asks "does this FormDefinition itself have an \`amount\`
294
- // key" and a well-formed FormDefinition never does, so every card would
295
- // render the HARD validation-failure fallback instead of the form (caught
296
- // live: eject + kai dev showed exactly that "(root).amount: required"
297
- // failure before this comment existed). The construct's own schema.ts
298
- // already checks \`cards\` structurally at validate time; there is nothing
299
- // left for a second, self-referential check here to catch.
300
- const cardTypes = Object.fromEntries(Object.keys(cards).map((name) => [name, BUILTIN_CARD_COMPONENTS.form] as const));
301
-
302
- // CD-1 (owner ruling 2026-08-26, Task 19g): shallow-merge a model's card
303
- // tool-call args onto a DECLARED form schema's field defaults — "model
304
- // proposes, user confirms". Only top-level keys the args AND the schema both
305
- // name are touched; the schema's own field shape (title/type/widget/
306
- // validation) is never altered, and a key the model sent that isn't a
307
- // declared field is ignored (the construct's vocabulary wins, not the
308
- // model's). One level deep only — a nested object field's own defaults are
309
- // not recursed into; no evidence of need yet (vocabulary-on-evidence).
310
- function mergeToolArgsIntoFormDefaults(
311
- schema: Record<string, unknown> & { properties?: Record<string, unknown> },
312
- args: Record<string, unknown>,
313
- ): Record<string, unknown> {
314
- const declared = schema.properties;
315
- if (!declared || typeof declared !== 'object') return schema;
316
- const patched: Record<string, unknown> = {};
317
- for (const [key, value] of Object.entries(args)) {
318
- if (!(key in declared)) continue;
319
- patched[key] = { ...(declared[key] as Record<string, unknown>), default: value };
320
- }
321
- return { ...schema, properties: { ...schema.properties, ...patched } };
322
- }`;
323
- }
324
-
325
- /** `cardTypes={cardTypes}` on ChatThread — which component draws each
326
- * declared card name (see emitCardsImport for why `cardSchemas` is
327
- * deliberately NOT also registered). The host to emit card events off is
328
- * already supplied by the ChatThread/kai-chat path (F-26); nothing else to
329
- * thread through here. */
330
- function emitCardTypesProp(c: Construct): string {
331
- return c.cards ? ' cardTypes={cardTypes}' : '';
332
- }
333
-
334
- /** Settle a turn's tool calls into cards, called once after the read
335
- * resolves and before `stream.done()`/`stream.abort()`. `AssistantStream`
336
- * has no getter of its own, so the just-written parts are read back off
337
- * `chat.messages()` by the stream's own id — the same pattern the kit's own
338
- * `cardFromToolCall` doc comment (schemas/from-tool-call.ts) shows for a
339
- * tool loop. A `kai_`-prefixed call becomes a card; anything else is the
340
- * construct's own tool and is left as a plain `tool` part.
341
- *
342
- * `cardFromToolCall` supplies the envelope's `type`/`id` and `data` (the
343
- * model's raw tool-call `input`, verbatim). `data` is then set to the card's
344
- * own DECLARED schema off the registry (`cards[card.type]`) — the form
345
- * renders the construct author's fields, matching the supervisor ruling
346
- * that every declared card is a schema-driven form in v1 — with the
347
- * model's args (`card.data`, read before this replaces it) shallow-merged
348
- * onto that schema's field `default`s (CD-1, owner ruling 2026-08-26,
349
- * Task 19g: `mergeToolArgsIntoFormDefaults`, emitted above by
350
- * emitCardsImport) so "model proposes, user confirms" pre-fills the form
351
- * instead of discarding the model's values outright. The `card.type in
352
- * cards` guard only fires for a `kai_` call this construct never declared
353
- * (an off-vocabulary call slipping through); it is silently dropped rather
354
- * than rendered, matching cardFromToolCall's own "not every kai_ call is
355
- * renderable" boundary (see its module header). */
356
- function emitApplyCardTools(c: Construct): string {
357
- if (!c.cards) return '';
358
- return `
359
- for (const part of chat.messages().find((m) => m.id === stream.id)?.parts ?? []) {
360
- if (part.type !== 'tool' || part.tool.state !== 'input-available') continue;
361
- const card = cardFromToolCall(part.tool.type, part.tool.input, { id: part.tool.toolCallId ?? crypto.randomUUID() });
362
- if (card && card.type in cards) {
363
- const declared = cards[card.type as keyof typeof cards];
364
- const args = card.data as Record<string, unknown> | undefined;
365
- const merged = args && typeof args === 'object'
366
- ? mergeToolArgsIntoFormDefaults(declared, args)
367
- : declared;
368
- stream.addCard({ ...card, data: merged });
369
- }
370
- }`;
371
- }
372
-
373
- /** The endpoint fetch body's `tools` field — the projected tool defs for
374
- * every declared card, matching the construct's own wire. No cards, no
375
- * field: the format rule (undeclared capability's affordance is OFF) holds
376
- * for tools the same way it holds for suggestions/attach/reasoning above. */
377
- function emitToolsField(c: Construct): string {
378
- if (!c.cards || c.provider.mode !== 'endpoint') return '';
379
- const toolsFn = c.provider.wire === 'openai' ? 'toOpenAITools' : 'toAnthropicTools';
380
- return `, tools: ${toolsFn}(cards)`;
381
- }
382
-
383
- function emitPackageJson(c: Construct, uiSpec: string): string {
384
- return `${JSON.stringify(
385
- {
386
- name: c.name,
387
- private: true,
388
- type: 'module',
389
- scripts: {
390
- dev: 'vite',
391
- build: 'vite build --config vite.config.lib.ts',
392
- typecheck: 'tsc --noEmit',
393
- },
394
- dependencies: {
395
- '@kitn.ai/ui': uiSpec,
396
- 'solid-js': '^1.9.0',
397
- },
398
- devDependencies: {
399
- typescript: '^5.6.0',
400
- vite: '^6.0.0',
401
- 'vite-plugin-solid': '^2.11.0',
402
- },
403
- },
404
- null,
405
- 2,
406
- )}\n`;
407
- }
408
-
409
- function emitTsconfig(): string {
410
- return `${JSON.stringify(
411
- {
412
- compilerOptions: {
413
- target: 'ES2022',
414
- module: 'ESNext',
415
- moduleResolution: 'bundler',
416
- jsx: 'preserve',
417
- jsxImportSource: 'solid-js',
418
- strict: true,
419
- noUnusedLocals: true,
420
- skipLibCheck: true,
421
- types: ['vite/client'],
422
- },
423
- include: ['src'],
424
- },
425
- null,
426
- 2,
427
- )}\n`;
428
- }
429
-
430
- function emitViteDev(): string {
431
- return `import { defineConfig } from 'vite';
432
- import solid from 'vite-plugin-solid';
433
-
434
- export default defineConfig({ plugins: [solid()] });
435
- `;
436
- }
437
-
438
- function emitViteLib(c: Construct): string {
439
- return `import { defineConfig } from 'vite';
440
- import solid from 'vite-plugin-solid';
441
-
442
- // kai compile: ONE self-registering .js. Everything is inlined (no externals):
443
- // the consumer installs nothing but this output.
444
- export default defineConfig({
445
- plugins: [solid()],
446
- build: {
447
- lib: { entry: 'src/element.tsx', formats: ['es'], fileName: () => '${c.name}.js' },
448
- },
449
- });
450
- `;
451
- }
452
-
453
- function emitIndexHtml(c: Construct): string {
454
- // A demo host page, not the emitted widget: purely so a first-time preview
455
- // isn't a mystery blank tab with one small launcher in the corner. Outside
456
- // the custom element entirely (a sibling in <body>), inline-styled, and
457
- // worded so nobody mistakes it for the construct's own output. Keyed off
458
- // `layout`: the "bottom-right corner" wording is only true for `widget` (a
459
- // floating launcher) — `fullscreen`/`aside`/`split` (Task 12) fill or dock
460
- // the page themselves, so they get no hint at all rather than a wrong one.
461
- // Decide loudly by omission, not by a stale claim.
462
- const hint =
463
- c.layout === 'widget'
464
- ? `\n <p style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0; color: #94a3b8; font: 14px system-ui, sans-serif; text-align: center; max-width: 28rem; padding: 0 1rem;">This blank page stands in for your site. The chat widget is in the bottom-right corner.</p>`
465
- : '';
466
- // Task 13: when slots are declared, project real demo content into each one
467
- // in the copy's own <${c.name}> tag — so the preview shows the escape hatch
468
- // WORKING (light-DOM children of a custom element project into its shadow
469
- // <slot> natively) rather than a mystery about how to use it. Slot names are
470
- // already schema-validated to `^[a-z][a-z0-9-]*$` (schema.ts) — no
471
- // quote/backslash payload possible, so no HTML-escaping is needed here,
472
- // unlike a free-text construct-authored field.
473
- const slotDemo = (c.slots ?? [])
474
- .map(
475
- (name) =>
476
- `\n <div slot="${name}" style="padding: 0.5rem 1rem; font: 13px system-ui, sans-serif; color: #64748b;">Projected into slot "${name}" — replace with your own markup.</div>`,
477
- )
478
- .join('');
479
- const body = slotDemo ? `\n <${c.name}>${slotDemo}\n </${c.name}>` : `\n <${c.name}></${c.name}>`;
480
- return `<!doctype html>
481
- <html lang="en">
482
- <head>
483
- <meta charset="utf-8" />
484
- <meta name="viewport" content="width=device-width, initial-scale=1" />
485
- <title>${c.name} — construct preview</title>
486
- </head>
487
- <body style="margin: 0;">${hint}${body}
488
- <script type="module" src="/src/element.tsx"></script>
489
- </body>
490
- </html>
491
- `;
492
- }
493
-
494
- function emitElement(c: Construct): string {
495
- const accent = c.theme?.accent;
496
- const unreadColor = c.theme?.unreadColor;
497
- if (!accent && !unreadColor) {
498
- // `empty` (Task 14) composes straight into ChatThread's own `emptyContent`
499
- // prop now (see emitEmptyContentProp's doc) — a plain JSX value passed down
500
- // through App, not a Portal onto the host — so the facade needs no `ctx` and
501
- // every construct, `empty` declared or not, keeps this line byte-for-byte
502
- // unchanged.
503
- const facade = '() => <App />';
504
- return `import { defineWebComponent } from '@kitn.ai/ui/define';
505
- import { App } from './App';
506
-
507
- // The one facade. Interior stays pure Solid (no nested element registrations);
508
- // the kit CSS is injected into the shadow root by defineWebComponent itself.
509
- defineWebComponent('${c.name}', { theme: '${themeMode(c)}' as 'light' | 'dark' | 'auto' }, ${facade});
510
- `;
511
- }
512
-
513
- // `unreadColor` (owner ruling, 2026-08-26 — unread-indicator round) gets the
514
- // exact same setProperty treatment as `accent` below — construct-authored/
515
- // untrusted text, JSON.stringify'd at its one interpolation site, carried via
516
- // `ctx.element.style.setProperty` rather than interpolated into CSS text (so
517
- // it can never break out into a new declaration/rule) — but with NO paired
518
- // -foreground computation: unlike the primary accent, --color-unread never
519
- // sits behind text, only inside small filled dots (list-row/header/Dock
520
- // badge), so there is nothing to contrast-pair against. Emitted
521
- // unconditionally alongside accent's setProperty call when present, even if
522
- // `accent` itself is absent — this branch is reached whenever EITHER is set.
523
- const unreadColorSetProperty = unreadColor
524
- ? `\n ctx.element.style.setProperty('--kai-color-unread', ${JSON.stringify(unreadColor)});`
525
- : '';
526
-
527
- if (!accent) {
528
- // unreadColor-only construct: no accent, so none of the contrast-pairing
529
- // machinery below applies — just the one setProperty call and `ctx`.
530
- const facade = `(_props, ctx) => {${unreadColorSetProperty}
531
- return <App />;
532
- }`;
533
- return `import { defineWebComponent } from '@kitn.ai/ui/define';
534
- import { App } from './App';
535
-
536
- // The one facade. Interior stays pure Solid (no nested element registrations);
537
- // the kit CSS is injected into the shadow root by defineWebComponent itself.
538
- defineWebComponent('${c.name}', { theme: '${themeMode(c)}' as 'light' | 'dark' | 'auto' }, ${facade});
539
- `;
540
- }
541
-
542
- // The accent has to land on the HOST element, not anywhere inside this
543
- // shadow root. The kit's --color-primary token is resolved ONCE, by a rule
544
- // scoped to `:root, :host` (`@layer theme { :root, :host { --color-primary:
545
- // var(--kai-color-primary, <fallback>) } }`) — so --kai-color-primary has to
546
- // be set AT the host for that rule to see it; a descendant inside the
547
- // shadow tree can set --kai-color-primary on itself all day and it will
548
- // never flow back up into a value the :host rule already resolved. This is
549
- // why the theme accent rendered nowhere in the T5 demo despite being
550
- // "wired": an earlier version set it on a div INSIDE App's own render.
551
- //
552
- // `ctx.element` (the facade's second argument) IS the host, so this sets it
553
- // from the one place inside the shadow root that has a handle to it.
554
- // `style.setProperty` is also the safe way to carry the accent, distinct
555
- // from string-interpolating it into CSS text: a custom property's value is
556
- // an opaque token substituted via var(), so it can never break out into a
557
- // new declaration or rule the way raw CSS/JS text interpolation could.
558
- //
559
- // The PAIRED --kai-color-primary-foreground is a separate concern: a light
560
- // accent (yellow) next to the kit's default near-white foreground is
561
- // unreadable. That pairing DOES need to live in a CSS block rather than a
562
- // second setProperty call, because it has two layers that must be able to
563
- // override each other in order: (1) a black/white value computed HERE, at
564
- // generation time (the accent is static per construct), as the floor for
565
- // browsers without CSS contrast-color() (Baseline Newly Available April
566
- // 2026 — Chrome/Edge 147, Firefox 146, Safari 26; Widely Available is not
567
- // until ~2028, and this widget embeds in arbitrary sites), and (2) inside
568
- // `@supports (color: contrast-color(red))`, the NATIVE answer
569
- // (contrast-color(var(--kai-color-primary))), which wins where supported —
570
- // including for an accent this codegen couldn't parse. An inline
571
- // `style.setProperty` always beats a stylesheet rule (short of
572
- // `!important`), so achieving "the native answer wins where present" needs
573
- // both layers to be plain `:host {}` declarations in one stylesheet, base
574
- // rule first, @supports override after — ordinary cascade order, no
575
- // `!important` required.
576
- const foreground = resolveContrastForeground(accent);
577
- const foregroundCss =
578
- foreground !== null
579
- ? `:host { --kai-color-primary-foreground: ${foreground}; }\n`
580
- : // Not guessed at: an unparseable accent (var(), a named color, a
581
- // color-mix()/oklch() call, …) leaves NO base declaration, so the
582
- // kit's own theme default stands — except in a browser new enough to
583
- // resolve contrast-color() itself, where the @supports block below
584
- // still gets it right natively.
585
- `/* NOTICE: accent '${commentSafe(accent)}' not parseable for contrast at generation time; the paired foreground falls back to the theme default in browsers without CSS contrast-color() support. */\n`;
586
- const styleText =
587
- foregroundCss + `${CONTRAST_COLOR_SUPPORTS} {\n :host { --kai-color-primary-foreground: contrast-color(var(--kai-color-primary)); }\n}`;
588
-
589
- const facade = `(_props, ctx) => {
590
- ctx.element.style.setProperty('--kai-color-primary', ${JSON.stringify(accent)});${unreadColorSetProperty}
591
- return (
592
- <>
593
- <style>{${JSON.stringify(styleText)}}</style>
594
- <App />
595
- </>
596
- );
597
- }`;
598
- return `import { defineWebComponent } from '@kitn.ai/ui/define';
599
- import { App } from './App';
600
-
601
- // The one facade. Interior stays pure Solid (no nested element registrations);
602
- // the kit CSS is injected into the shadow root by defineWebComponent itself.
603
- defineWebComponent('${c.name}', { theme: '${themeMode(c)}' as 'light' | 'dark' | 'auto' }, ${facade});
604
- `;
605
- }
606
-
607
- // ── App interior ─────────────────────────────────────────────────────────────
608
- // The chat spine is IMPLIED: thread + input + streaming are always emitted and
609
- // wired; the construct declares deviations and additions only. Seams below are
610
- // where later tasks splice capability code; each is a pure string join, so the
611
- // determinism test keeps holding.
612
-
613
- function emitApp(c: Construct): string {
614
- if (c.layout === 'custom') return emitCustomApp(c);
615
- return `${emitSolidJsImport(c)}${emitConversationsSignalsImport(c)}import { ChatThread, createKaiChat${emitLayoutImport(c)}${emitCardComponentImport(c)}${emitEmptyComponentImport(c)}${emitHeaderCloseImport(c)} } from '@kitn.ai/ui/solid';
616
- import type { AttachmentData${emitHistoryTypeImport(c)}${emitConversationsResetTypeImport(c)} } from '@kitn.ai/ui/solid';
617
- ${emitProviderImports(c)}
618
- ${emitCardsImport(c)}
619
- ${emitConversationsImport(c)}
620
-
621
- ${emitProviderSetup(c)}
622
- ${emitHistorySetup(c)}
623
-
624
- // ChatThread is the kit's own MOST-INTEGRATED chat surface — the same
625
- // composition <kai-chat>'s facade renders (src/elements/chat.tsx). It owns
626
- // the message list, the composer (padding, focus ring, the send button) and
627
- // their layout AS ONE UNIT, so nothing here re-derives spacing, alignment or
628
- // focus styling by hand: every prior version of this file that hand-composed
629
- // Thread + PromptInput + Button was restating layout the kit already owns,
630
- // and every visual defect the owner hit (flush composer, a clipped focus
631
- // ring) traced back to that restatement. Composing ChatThread directly
632
- // leaves NOTHING here to restate it with.
633
- //
634
- // Capability gating (format rule: an undeclared capability's affordance must
635
- // be OFF). The construct schema carries ONE capability field so far
636
- // (capabilities.starters, Task 8) — every other affordance below is gated to
637
- // "off" unconditionally, not per-construct, until there's a field to gate ON.
638
- // - webSearch / voice: real ChatThreadProps booleans, default OFF when
639
- // omitted — set to \`false\` explicitly rather than left implicit, so the
640
- // gating decision is visible in the emitted source, not just inferred
641
- // from an absent prop.
642
- // - suggestions: ChatThread ALREADY owns starter prompts end to end — its
643
- // own \`suggestions\` prop renders the chips, hides them once
644
- // \`messages\` is non-empty, and (default \`suggestionMode="submit"\`)
645
- // calls \`onSubmit\` with the clicked text exactly like a typed submit.
646
- // So capabilities.starters threads straight into that prop; there is
647
- // nothing to hand-compose. Omitted (undefined) when no starters are
648
- // declared, same off-by-default effect as the booleans above.
649
- // - models: omitted (undefined) — no model switcher; no capabilities field yet.
650
- // - attachments (the paperclip): gated via ChatThread's \`attach\`/\`accept\`
651
- // props (kit gap closed — ChatThread forwards both to DefaultPromptInput,
652
- // mirroring webSearch/voice). ChatThread ALREADY owns the whole
653
- // round-trip end to end — the paperclip button, staged previews, staging
654
- // each file as a data URI (never a blob object URL; see
655
- // AttachmentData.url's doc in components/attachment-types.ts), and
656
- // handing the staged list back via onSubmit's \`attachments\` — and its
657
- // Message component ALREADY groups consecutive file parts into one
658
- // attachment row (message.tsx). So there is nothing to hand-compose
659
- // here, same lesson as suggestions above: hand-rolling a second picker or
660
- // a second file-part renderer would restate what ChatThread/Message
661
- // already own. capabilities.attachments threads straight into
662
- // attach/accept; the only App.tsx-owned piece is folding the picked
663
- // attachments into the outgoing message's parts at the submit site
664
- // (see emitProviderSetup) since createKaiChat's own append/streamAssistant
665
- // ops don't do that folding themselves.
666
- // - reasoning: gated via ChatThread's own \`reasoning\` prop (kit gap closed
667
- // — ChatThread forwards it to every MessageBody as \`reasoningMode\`,
668
- // mirroring attach/accept). \`'full'\` is both the schema default and
669
- // ChatThread's own default, so it and an absent field emit no prop at
670
- // all — the SAME off-by-default convention as every other capability
671
- // here, just anchored on the medium's existing default instead of an
672
- // "off" value, since a reasoning disclosure is normal chat behavior, not
673
- // an opt-in affordance like the paperclip or a starter chip.
674
- // - empty (the welcome-screen greeting, Task 14): gated via ChatThread's
675
- // own \`emptyContent\` prop, plain JSX rendered in the SAME shadow tree
676
- // this file's App already composes ChatThread inside of (see
677
- // emitEmptyContentProp's own doc for why that boundary needs no Portal
678
- // at all). \`capabilities.starters\`' chips and the composer still render
679
- // underneath it: ChatThread's own doc comment on \`emptyContent\` is
680
- // explicit that it replaces only the empty MESSAGE LIST.
681
- // - the widget close control (owner feedback on the live demo): a declared
682
- // \`header.title\` on a \`widget\` layout gets its close button threaded
683
- // into ChatThread's own header row via \`headerEndContent\`, wired back to
684
- // Dock's \`controllerRef\` seam through a local closure — see
685
- // emitDockCloseVar/emitHeaderEndContentProp's docs. No header means no
686
- // row for it to sit in, so that case is untouched and Dock's own built-in
687
- // mobile X keeps covering it.
688
- // - conversations (Task 5): gated via ChatThread's own \`conversations\`/
689
- // \`store\` props (kit-owned end to end — the prior-conversations list,
690
- // list/load/save, autosave on every \`chat.messages()\` change). Requires
691
- // capabilities.history persistence \`local\` or \`endpoint\` (schema
692
- // superRefine, C-4) and SUBSUMES this file's hand-rolled history effect
693
- // when on — see emitHistorySetup's own doc for the persistence-ownership
694
- // decision: the store prop is the ONLY persistence mechanism emitted,
695
- // never both. ChatThread never mutates \`messages\` itself, so
696
- // \`onConversationLoad\` is ALSO wired here (\`chat.setMessages(() =>
697
- // messages)\`) — without it, select/new/mount-restore all update
698
- // ChatThread's own internal view/list state while the rendered thread
699
- // never changes (see emitConversationsProps's own doc for the Task 6
700
- // live-browser bug this fixes).
701
- // - conversations + widget (owner follow-up): closing the widget while its
702
- // list view is open must not leave it there for the next open — see
703
- // widgetHasConversationsChrome/emitDockOnOpenChangeProp's docs. Wired only
704
- // for \`widget\`, the one layout with something that closes/reopens at all.
705
- export function App() {
706
- ${emitDockCloseVar(c, ' ')}${emitChatControllerVar(c, ' ')}${emitConversationsSignalsVar(c, ' ')} return (
707
- ${emitLayoutOpen(c)}${emitSlots(c.slots, ' ')} <ChatThread messages={chat.messages()} loading={chat.loading()} placeholder="Ask anything" onSubmit={submit} webSearch={false} voice={false}${emitHeaderProp(c)}${emitHeaderEndContentProp(c)}${emitAttachProps(c)}${emitStartersProp(c)}${emitReasoningProp(c)}${emitReasoningOpenProp(c)}${emitEmptyContentProp(c)}${emitCardTypesProp(c)}${emitHomeProp(c)}${emitConversationsProps(c)}${emitChatControllerRefProp(c)}${emitChatThreadUnreadProps(c)} />
708
- ${emitLayoutClose(c)} );
709
- }
710
- `;
711
- }
712
-
713
- /**
714
- * `layout: 'custom'` — the escape hatch's own layout: minimal/no chrome, just
715
- * the bare chat spine plus the declared `slots` positioned by the consumer.
716
- * Composed from `Thread` (the message-list primitive, no composer/header/
717
- * suggestions of its own — components/thread.tsx) + the `PromptInput`
718
- * compound primitive, NOT `ChatThread`: `ChatThread` bundles its composer
719
- * INSIDE itself (`DefaultPromptInput`, internal-only), which leaves no seam to
720
- * splice a slot between the thread and the input the way this layout's
721
- * placement rule needs. This is the one layout where hand-composing the spine
722
- * is correct rather than a restatement — every other layout in this file
723
- * wraps `ChatThread` precisely to avoid this hand-composition (see emitApp's
724
- * doc comment above).
725
- *
726
- * Slot placement is a fixed, deterministic rule, spelled out in the emitted
727
- * comment: the FIRST declared slot sits above the thread, every other
728
- * declared slot sits below the composer, in declaration order. There is no
729
- * vocabulary for a different arrangement (a `position` per slot, an
730
- * interleaved grain) — reordering means ejecting and rearranging the JSX by
731
- * hand, which this format's own rule already commits to (no code-in-JSON).
732
- *
733
- * Capability gating: only cards are wired here (`Thread` accepts `cardTypes`
734
- * natively, same as `ChatThread`). starters/attachments/reasoning/
735
- * reasoningOpen/header.title/empty/conversations are NOT wired for `custom`
736
- * in v1 — `Thread`/`PromptInput` don't carry the kit's own plumbing for
737
- * those (they live inside `ChatThread`'s composer, or — for conversations —
738
- * ChatThread itself owns the list/load/save wiring), and hand-rolling a
739
- * second copy here is exactly the restatement this file avoids everywhere
740
- * else. Decided loudly in the emitted comment below, not silently: this is
741
- * the eject artifact, so a construct author who needs one of them on
742
- * `custom` adds it directly to the plain Solid file they now own.
743
- * capabilities.history persistence itself IS still honored on `custom` (the
744
- * hand-rolled effect below), independent of whether conversations is also
745
- * set — see emitHistorySetup's own doc for why `custom` keeps it.
746
- */
747
- function emitCustomApp(c: Construct): string {
748
- const slots = c.slots ?? [];
749
- const [headerSlot, ...restSlots] = slots;
750
- const history = c.capabilities?.history;
751
- const solidJsNames = ['createSignal', ...(history && history.persistence !== 'none' ? ['createEffect'] : [])];
752
- return `import { ${solidJsNames.join(', ')} } from 'solid-js';
753
- import { Thread, PromptInput, PromptInputTextarea, PromptInputActions, Button, createKaiChat${emitCardComponentImport(c)} } from '@kitn.ai/ui/solid';
754
- import type { AttachmentData${emitHistoryTypeImport(c)} } from '@kitn.ai/ui/solid';
755
- ${emitProviderImports(c)}
756
- ${emitCardsImport(c)}
757
-
758
- ${emitProviderSetup(c)}
759
- ${emitHistorySetup(c)}
760
-
761
- // layout: custom — minimal chrome, no ChatThread/Dock/PaneGroup. The bare
762
- // spine (Thread + PromptInput) plus the declared slots, positioned by hand so
763
- // YOU own the surrounding DOM. Capabilities beyond the spine (starters,
764
- // attachments, reasoning display-mode, reasoningOpen, header.title, empty,
765
- // conversations) are NOT wired here in v1 — this file is the eject artifact; add them the
766
- // way ChatThread composes them (components/chat-thread.tsx in the kit's own
767
- // source) if this construct needs them on a custom layout.
768
- export function App() {
769
- const [value, setValue] = createSignal('');
770
-
771
- const handleSubmit = () => {
772
- const text = value();
773
- if (!text.trim() || chat.loading()) return;
774
- setValue('');
775
- void submit({ value: text, attachments: [] });
776
- };
777
-
778
- return (
779
- <div style={{ height: '100dvh', display: 'flex', 'flex-direction': 'column' }}>
780
- ${emitSlots(headerSlot ? [headerSlot] : undefined, ' ')} <Thread messages={chat.messages()} loading={chat.loading()} class="min-h-0 flex-1"${emitCardTypesProp(c)} />
781
- {/* Slot placement rule: first declared slot above the thread, every other
782
- declared slot below the composer, in declaration order. Reorder by
783
- ejecting — this is the whole grain dimmer. */}
784
- <PromptInput value={value()} onValueChange={setValue} isLoading={chat.loading()} onSubmit={handleSubmit}>
785
- <PromptInputTextarea placeholder="Ask anything" />
786
- <PromptInputActions>
787
- <Button onClick={handleSubmit}>Send</Button>
788
- </PromptInputActions>
789
- </PromptInput>
790
- ${emitSlots(restSlots, ' ')} </div>
791
- );
792
- }
793
- `;
794
- }
795
-
796
- /** header.title -> ChatThread's own \`chatTitle\` prop. Construct-authored/
797
- * untrusted text like starters/theme.accent/provider.url, so JSON.stringify'd
798
- * into a real JS string-literal expression. Omitted entirely (not even the
799
- * prop) when no header is declared — the same off-by-default gating as every
800
- * capability in this file, even though \`header\` isn't itself a capability. */
801
- function emitHeaderProp(c: Construct): string {
802
- const title = c.header?.title;
803
- if (!title) return '';
804
- return ` chatTitle={${JSON.stringify(title)}}`;
805
- }
806
-
807
- /** `empty` -> ChatThread's own `emptyContent` prop (chat-thread.tsx), a plain
808
- * JSX value rendered INSIDE ChatThread's own tree — fully covered by the
809
- * shadow root's adopted stylesheet, unlike the `empty`/`slot="empty"` boolean
810
- * pairing this used to go through. That boundary was LIGHT-DOM: `<Portal
811
- * mount={element}>` manufactured a real child of the host element tagged
812
- * `slot="empty"` so ChatThread's `<slot name="empty">` could redistribute it
813
- * — a detour needed only because a shadow `<slot>` redistributes light-DOM
814
- * children of the HOST, never a Solid sibling's own JSX. It worked, but light
815
- * DOM sits outside the shadow root's adopted stylesheet, so the Tailwind
816
- * utility classes the kit's own `Empty` composition is built from resolved to
817
- * nothing — the greeting rendered, unstyled (owner report against the live
818
- * widget: "doesn't look like the empty component"). `App` already composes
819
- * `ChatThread` directly as a plain Solid component in the SAME shadow tree
820
- * `defineWebComponent` attaches, so there is no boundary to cross here at
821
- * all — `emptyContent` just hands the JSX straight down, and it inherits the
822
- * same styling as the rest of `App`. No Portal, no host-element param on
823
- * `App`, no light-DOM indirection.
824
- *
825
- * `capabilities.starters`' chips and the composer still render underneath
826
- * it: ChatThread's own doc comment on `empty`/`emptyContent` is explicit
827
- * that this REPLACES only the empty MESSAGE LIST.
828
- *
829
- * Uses the kit's own `Empty`/`EmptyHeader`/`EmptyMedia`/`EmptyTitle`/
830
- * `EmptyDescription` composition (components/empty.tsx) rather than hand-
831
- * rolled markup — the same "don't restate the kit's own layout" rule
832
- * `emitApp`'s header comment states for ChatThread itself. `title`/
833
- * `description` are construct-authored/untrusted text, JSON.stringify'd into
834
- * real JS string-literal expressions like every other free-text field in
835
- * this file; `icon` is schema-validated by `isSafeUrl` (schema.ts) before
836
- * codegen ever sees it, the same policy `widget.launcherIcon` uses. */
837
- function emitEmptyContentProp(c: Construct): string {
838
- const empty = c.empty;
839
- if (!empty) return '';
840
- const title = `<EmptyTitle>{${JSON.stringify(empty.title)}}</EmptyTitle>`;
841
- const icon = empty.icon
842
- ? `<EmptyMedia><img src={${JSON.stringify(empty.icon)}} alt="" style={{ width: '40px', height: '40px', 'border-radius': '9999px' }} /></EmptyMedia>`
843
- : '';
844
- const description = empty.description
845
- ? `<EmptyDescription>{${JSON.stringify(empty.description)}}</EmptyDescription>`
846
- : '';
847
- return ` emptyContent={<Empty><EmptyHeader>${icon}${title}${description}</EmptyHeader></Empty>}`;
848
- }
849
-
850
- /** The `Empty` composition components `emitEmptyContentProp` needs, appended
851
- * onto the same `@kitn.ai/ui/solid` import ChatThread/createKaiChat already
852
- * use — never a second import statement for the same module. `EmptyMedia`/
853
- * `EmptyDescription` are named only when `icon`/`description` are actually
854
- * declared: `verify:scaffold` compiles emitted output with `tsc --strict
855
- * --noUnusedLocals`, so an always-imported-but-sometimes-unused name would
856
- * fail that gate the moment a construct omits one. */
857
- function emitEmptyComponentImport(c: Construct): string {
858
- if (!c.empty) return '';
859
- let names = ', Empty, EmptyHeader, EmptyTitle';
860
- if (c.empty.icon) names += ', EmptyMedia';
861
- if (c.empty.description) names += ', EmptyDescription';
862
- return names;
863
- }
864
-
865
- /** `widget` layout with a declared `header.title` gets its own close control
866
- * integrated INTO ChatThread's header row instead of relying solely on
867
- * Dock's own floating mobile X (see `dock.tsx`'s `hideClose` doc for the
868
- * "why": a header-row X and a floating X over that same row read as
869
- * unintentional together — owner feedback against the live widget). No
870
- * header means no row for a close control to sit in at all, so this stays
871
- * false and Dock's built-in fallback X keeps covering that case unchanged. */
872
- function widgetHasHeaderClose(c: Construct): boolean {
873
- return c.layout === 'widget' && !!c.header?.title;
874
- }
875
-
876
- /** `widget` layout with `capabilities.conversations` on: whether the widget
877
- * needs the close-resets-the-list-view wiring below (owner follow-up,
878
- * 2026-08-26 — closing the widget while its conversations list was open
879
- * left `ChatThread`'s internal `view` state at `'list'`, so the NEXT open
880
- * landed back on the list instead of the default chat screen). Only
881
- * `widget` has a `Dock` to close/reopen at all; every other layout renders
882
- * ChatThread inline with nothing that ever hides it, so the regression
883
- * can't occur there and nothing is emitted for them.
884
- *
885
- * ALSO gates unread indicators (owner round, 2026-08-26): the FAB's `Dock
886
- * unread` badge and `ChatThread`'s own `hostOpen` prop both need the exact
887
- * same `dockOpen` tracking this reset wiring already needs, and both need
888
- * the same "is there even a Dock to reflect this on" condition — one gate,
889
- * reused, rather than two nearly-identical predicates drifting apart.
890
- *
891
- * Widened (Task 5) to also fire on `home` alone, with no `conversations`
892
- * capability: closing the widget must return the view to Home exactly like
893
- * it must return to the default chat screen — same regression class, same
894
- * reset wiring, and `closeConversationsList()` is a safe no-op when there
895
- * never was a conversations list to close. */
896
- function widgetHasConversationsChrome(c: Construct): boolean {
897
- return c.layout === 'widget' && (!!c.capabilities?.conversations || !!c.home);
898
- }
899
-
900
- /** Declares the closure `emitChatControllerRefProp`/`emitDockOnOpenChangeProp`
901
- * share: `ChatThread`'s own `controllerRef` (chat-thread.tsx) hands back a
902
- * `ChatThreadController` — the existing imperative seam, not a new one — and
903
- * this captures it so `Dock`'s `onOpenChange` (a sibling prop on a sibling
904
- * element, not a ChatThread descendant) can call
905
- * `closeConversationsList()` on it. Declared inside `App()`, not at module
906
- * scope, for the same instance-isolation reason as `emitDockCloseVar`. */
907
- function emitChatControllerVar(c: Construct, indent: string): string {
908
- return widgetHasConversationsChrome(c) ? `${indent}let chatController: ChatThreadController | undefined;\n` : '';
909
- }
910
-
911
- /** Threads `emitChatControllerVar`'s closure onto `<ChatThread controllerRef>`. */
912
- function emitChatControllerRefProp(c: Construct): string {
913
- return widgetHasConversationsChrome(c) ? ' controllerRef={(api) => (chatController = api)}' : '';
914
- }
915
-
916
- /** `Dock`'s own `onOpenChange` (ui/dock.tsx) already fires on EVERY close
917
- * path — the header X, the launcher toggle, and Escape all resolve through
918
- * its single `setOpen` — so wiring it once here covers all three with no
919
- * per-path duplication (the alternative `emitDockCloseVar`/`dockClose`
920
- * closure above only covers the header-X path, which is why that one isn't
921
- * reused for this). The reset half fires ONLY on the close transition
922
- * (`!open`) — opening needs no action there, the list stays gone until the
923
- * visitor taps the toggle again, and a mid-open reset call would fight
924
- * anyone still viewing the list. `setDockOpen(open)` fires on BOTH
925
- * transitions unconditionally — it's a plain mirror of Dock's own state
926
- * into a signal `ChatThread`'s `hostOpen` prop can read (see
927
- * `emitConversationsSignalsVar`'s doc for why a signal, not a variable). */
928
- function emitDockOnOpenChangeProp(c: Construct): string {
929
- return widgetHasConversationsChrome(c)
930
- ? ' onOpenChange={(open) => { setDockOpen(open); if (!open) chatController?.closeConversationsList(); }}'
931
- : '';
932
- }
933
-
934
- /** `ChatThreadController` is needed only when `emitChatControllerVar` above
935
- * declares a variable of that type — appended onto the same `import type`
936
- * statement as `AttachmentData`/`ChatMessage`, never a second import
937
- * statement for the module (same convention as `emitHistoryTypeImport`). */
938
- function emitConversationsResetTypeImport(c: Construct): string {
939
- return widgetHasConversationsChrome(c) ? ', ChatThreadController' : '';
940
- }
941
-
942
- /** `createSignal` import for `emitConversationsSignalsVar`'s two signals.
943
- * Deliberately its OWN `import { createSignal } from 'solid-js'` line, not
944
- * folded onto `emitSolidJsImport`'s: that one imports `createEffect` for the
945
- * hand-rolled history-restore effect, which is UNEMITTED whenever
946
- * `capabilities.conversations` is on (conversations subsumes persistence —
947
- * see that function's own doc) — so for every construct this function
948
- * actually fires for, `emitSolidJsImport` is already returning `''` and
949
- * there's nothing to fold onto. */
950
- function emitConversationsSignalsImport(c: Construct): string {
951
- return widgetHasConversationsChrome(c) ? `import { createSignal } from 'solid-js';\n` : '';
952
- }
953
-
954
- /** Declares the two signals `Dock`'s `onOpenChange` (above) writes and
955
- * `ChatThread`'s `hostOpen`/`onUnreadChange` props (below) read/write:
956
- * `dockOpen` mirrors whether the panel is currently open — a SIGNAL, not a
957
- * plain closure variable like `dockClose`/`chatController` above, because
958
- * `ChatThread` reads it REACTIVELY every render (`hostOpen={dockOpen()}`),
959
- * not just calls it imperatively on an event; `anyUnread` is the reverse
960
- * direction, `ChatThread` writing outward via `onUnreadChange={setAnyUnread}`
961
- * so `Dock`'s own `unread` prop (ui/dock.tsx — already exists, already
962
- * tested, no change needed there) can mirror it onto the FAB.
963
- * `dockOpen`'s initial value matches `widget.defaultOpen` (mirroring
964
- * `emitDockDefaultOpen`'s own read of the same field) — a widget that opens
965
- * by default has to start "seen", or a message arriving before the first
966
- * `onOpenChange` fire would look like it happened while closed. */
967
- function emitConversationsSignalsVar(c: Construct, indent: string): string {
968
- if (!widgetHasConversationsChrome(c)) return '';
969
- const w = c.layout === 'widget' ? c.widget : undefined;
970
- const defaultOpen = w?.defaultOpen === true ? 'true' : 'false';
971
- return `${indent}const [dockOpen, setDockOpen] = createSignal(${defaultOpen});\n${indent}const [anyUnread, setAnyUnread] = createSignal(false);\n`;
972
- }
973
-
974
- /** Threads the two signals above onto `<ChatThread hostOpen>`/
975
- * `<ChatThread onUnreadChange>`. */
976
- function emitChatThreadUnreadProps(c: Construct): string {
977
- return widgetHasConversationsChrome(c) ? ' hostOpen={dockOpen()} onUnreadChange={setAnyUnread}' : '';
978
- }
979
-
980
- /** Threads `anyUnread` onto `Dock`'s own (pre-existing, unchanged) `unread`
981
- * prop — the FAB's badge while closed. */
982
- function emitDockUnreadProp(c: Construct): string {
983
- return widgetHasConversationsChrome(c) ? ' unread={anyUnread()}' : '';
984
- }
985
-
986
- /** The local closure `emitHeaderEndContentProp`/`emitDockControllerRef` share:
987
- * Dock's `controllerRef` hands back `{ open, setOpen }` (ui/dock.tsx) — the
988
- * existing imperative seam, not a new one — and this captures `setOpen`
989
- * behind a plain function so ChatThread's `headerEndContent` button (which
990
- * renders as a sibling, not a Dock descendant) can call it. Declared inside
991
- * `App()`, not at module scope: `App()` runs once per widget instance, and a
992
- * module-level variable would let one instance's close button reach into
993
- * another's Dock if two ever rendered on the same page. */
994
- function emitDockCloseVar(c: Construct, indent: string): string {
995
- return widgetHasHeaderClose(c) ? `${indent}let dockClose: (() => void) | undefined;\n` : '';
996
- }
997
-
998
- /** Threads `emitDockCloseVar`'s closure onto `<Dock controllerRef>`. */
999
- function emitDockControllerRef(c: Construct): string {
1000
- return widgetHasHeaderClose(c) ? ' controllerRef={(api) => (dockClose = () => api.setOpen(false))}' : '';
1001
- }
1002
-
1003
- /** Suppresses Dock's own built-in mobile close X (see its `hideClose` doc)
1004
- * when ChatThread's header row is carrying an equivalent control instead —
1005
- * otherwise the two stack, one floating over the other's row. */
1006
- function emitDockHideClose(c: Construct): string {
1007
- return widgetHasHeaderClose(c) ? ' hideClose={true}' : '';
1008
- }
1009
-
1010
- /** ChatThread's `headerEndContent` prop (chat-thread.tsx): the close button
1011
- * itself, sharing the header row with the title instead of floating as a
1012
- * second, visually unrelated control — the owner's stated preference over
1013
- * Dock's previous "reserved dead-row band" fix. Reuses the kit's own
1014
- * `DockCloseGlyph` (the same X Dock's built-in button draws) and `Button`
1015
- * (ghost/icon-sm, the same weight ChatThread's own header controls use —
1016
- * see `ModelSwitcher`'s trigger) rather than hand-rolling either, and calls
1017
- * back into `emitDockCloseVar`'s closure to actually close the panel —
1018
- * Dock's own `controllerRef` seam, not a new one. */
1019
- function emitHeaderEndContentProp(c: Construct): string {
1020
- if (!widgetHasHeaderClose(c)) return '';
1021
- return ` headerEndContent={<Button variant="ghost" size="icon-sm" aria-label="Close ${c.name}" onClick={() => dockClose?.()}><DockCloseGlyph /></Button>}`;
1022
- }
1023
-
1024
- /** `Button`/`DockCloseGlyph`, needed only by `emitHeaderEndContentProp` above
1025
- * — appended onto the same `@kitn.ai/ui/solid` import as `emitEmptyComponentImport`,
1026
- * never a second import statement for the module. */
1027
- function emitHeaderCloseImport(c: Construct): string {
1028
- return widgetHasHeaderClose(c) ? ', Button, DockCloseGlyph' : '';
1029
- }
1030
-
1031
- /** capabilities.attachments -> ChatThread's own \`attach\`/\`accept\` props.
1032
- * Undeclared keeps the explicit off-by-default gating (\`attach={false}\`,
1033
- * matching webSearch/voice above). Declared flips \`attach={true}\` and
1034
- * threads the accept list through — construct-authored/untrusted like
1035
- * \`starters\`/\`theme.accent\`/\`provider.url\`, so JSON.stringify'd into a
1036
- * real JS string-literal expression rather than a raw JSX attribute
1037
- * string (JSX attribute strings don't interpret escapes the way JS string
1038
- * literals do, so a raw \`accept="..."\` would be a breakout surface for a
1039
- * hostile media-type entry containing a \`"\`). */
1040
- function emitAttachProps(c: Construct): string {
1041
- const attachments = c.capabilities?.attachments;
1042
- if (!attachments) return ' attach={false}';
1043
- return ` attach={true} accept={${JSON.stringify(attachments.accept.join(','))}}`;
1044
- }
1045
-
1046
- /** capabilities.starters -> ChatThread's own \`suggestions\` prop. Starter
1047
- * strings are construct-authored (untrusted the same way theme.accent and
1048
- * provider.url are) — JSON.stringify produces a real JS array-of-string-
1049
- * literals expression, the same safe-interpolation convention used for the
1050
- * accent (element.tsx) and the endpoint url (fetch() above): no quote,
1051
- * backslash or line-separator payload can break out of it. Omitted
1052
- * entirely (not even the prop) when no starters are declared, matching the
1053
- * off-by-default gating for every other capability. */
1054
- function emitStartersProp(c: Construct): string {
1055
- const starters = c.capabilities?.starters;
1056
- if (!starters || starters.length === 0) return '';
1057
- return ` suggestions={${JSON.stringify(starters)}}`;
1058
- }
1059
-
1060
- /** capabilities.reasoning -> ChatThread's own `reasoning` prop. `'full'`
1061
- * and absent are the SAME thing (the schema default, matching ChatThread's
1062
- * own default) so both emit nothing at all — the off-by-default gating
1063
- * convention every other capability in this file follows: only a value that
1064
- * DEVIATES from the medium's default costs a byte in the emitted source.
1065
- * `'compact'`/`'off'` are plain string literals, not JSON.stringify'd like
1066
- * starters/accept/url — the schema already constrains this to one of three
1067
- * fixed enum members (schema.ts), so unlike those fields there is no
1068
- * construct-authored free text here to escape. */
1069
- function emitReasoningProp(c: Construct): string {
1070
- const reasoning = c.capabilities?.reasoning;
1071
- if (!reasoning || reasoning === 'full') return '';
1072
- return ` reasoning="${reasoning}"`;
1073
- }
1074
-
1075
- /** capabilities.reasoningOpen -> ChatThread's own `reasoningOpen` prop. Only
1076
- * `true` costs a byte (off-by-default, matching every capability here);
1077
- * false/absent matches the kit's own new default (closed chip). */
1078
- function emitReasoningOpenProp(c: Construct): string {
1079
- return c.capabilities?.reasoningOpen === true ? ' reasoningOpen={true}' : '';
1080
- }
1081
-
1082
- /** capabilities.conversations -> ChatThread's own `conversations`/`store`
1083
- * props (C-8: this is the ONLY logic codegen contributes — everything else
1084
- * is kit code behind ChatThread). `local` persistence wires
1085
- * localStorageStore(name[, userId]); `endpoint` wires fetchStore(url[,
1086
- * userId]) — both re-exported from @kitn.ai/ui/solid, never hand-rolled
1087
- * here (composition-over-reauthoring, same discipline as every other
1088
- * capability in this file). `c.name` is validated against TAG_RE
1089
- * (schema.ts — lowercase, hyphenated, a valid custom-element tag), so like
1090
- * the other `'${c.name}'` interpolation sites in this file (defineWebComponent,
1091
- * the preview HTML's element tag) it is embedded directly rather than
1092
- * JSON.stringify'd. userId/url ARE construct-authored/untrusted free text,
1093
- * JSON.stringify'd at their one interpolation site like theme.accent. The
1094
- * schema's superRefine (C-4) guarantees history is present with persistence
1095
- * 'local' or 'endpoint' whenever conversations is true, so no further guard
1096
- * is needed here.
1097
- *
1098
- * BUG FIX (Task 6 live-browser demo, both defects traced to one root cause):
1099
- * `conversations`/`store` alone only drive ChatThread's OWN internal
1100
- * view/list/activeId state machine — select/new/mount-restore all resolve
1101
- * through `store.load()`/a cleared array, but ChatThread never mutates
1102
- * `props.messages` itself (doc comment on `onConversationLoad`, chat-thread.tsx
1103
- * line ~144: "this component does not mutate props.messages itself"). Without
1104
- * `onConversationLoad` wired back to this app's own `chat` store, every
1105
- * load/new/restore updated ChatThread's internal bookkeeping (active id, list
1106
- * entry) while the actually-rendered `chat.messages()` never changed — the
1107
- * exact silent-drop class this codebase's CLAUDE.md calls out ("decide
1108
- * loudly"), except here the drop was an OMITTED wire, not a decision: "+ New
1109
- * conversation" appeared to do nothing (same messages kept rendering) and a
1110
- * reload's mount-time auto-restore updated the list's active row but left the
1111
- * chat view on the empty/welcome screen. `chat.setMessages(() => messages)`
1112
- * closes the loop — ChatThread already hands back a FRESH array reference on
1113
- * every call (`[...messages]` at both call sites in chat-thread.tsx), so no
1114
- * extra clone is needed here; the updater-returns-new-array form is what the
1115
- * reactivity contract (CLAUDE.md) requires and is what emitHistorySetup's
1116
- * own hand-rolled local/endpoint restore already did before conversations
1117
- * subsumed it. */
1118
- /** `home` -> ChatThread's own `home` prop (chat-thread.tsx, Task 1-4), plain
1119
- * data threaded straight through as ONE JSON.stringify'd object literal —
1120
- * vocabulary-never-logic (this format's binding rule): the construct only
1121
- * ever DECLARES the home screen's content, never wires a handler for it.
1122
- * `<kai-chat>`'s own `kai-home-link` CustomEvent (Task 2-3) is how a
1123
- * consumer of the EMITTED app would react to a link click; codegen never
1124
- * emits an `onHomeLink` listener here for the same reason it never emits
1125
- * handlers for any other capability in this file. Omitted entirely when no
1126
- * `home` is declared — the same off-by-default gating as `emitHeaderProp`/
1127
- * `emitEmptyContentProp` above. */
1128
- function emitHomeProp(c: Construct): string {
1129
- if (!c.home) return '';
1130
- return ` home={${JSON.stringify(c.home)}}`;
1131
- }
1132
-
1133
- function emitConversationsProps(c: Construct): string {
1134
- if (!c.capabilities?.conversations) return '';
1135
- const history = c.capabilities.history;
1136
- const storeCall =
1137
- history?.persistence === 'endpoint'
1138
- ? `fetchStore(${JSON.stringify(history.url)}${c.userId ? `, ${JSON.stringify(c.userId)}` : ''})`
1139
- : `localStorageStore('${c.name}'${c.userId ? `, ${JSON.stringify(c.userId)}` : ''})`;
1140
- return ` conversations={true} store={${storeCall}} onConversationLoad={(messages) => chat.setMessages(() => messages)}`;
1141
- }
1142
-
1143
- /** A dedicated named import for whichever store constructor
1144
- * emitConversationsProps used — its own statement (mirrors emitCardsImport's
1145
- * own `import { cards } from './cards'` line) rather than spliced onto the
1146
- * ChatThread import list, so noUnusedLocals never trips when conversations
1147
- * is absent. */
1148
- function emitConversationsImport(c: Construct): string {
1149
- if (!c.capabilities?.conversations) return '';
1150
- const name = c.capabilities.history?.persistence === 'endpoint' ? 'fetchStore' : 'localStorageStore';
1151
- return `import { ${name} } from '@kitn.ai/ui/solid';`;
1152
- }
1153
-
1154
- /** capabilities.history -> whether the App module needs `createEffect`
1155
- * (both persisted variants react to `chat.messages()` changing; `none`/
1156
- * absent needs no extra Solid import at all, matching the off-by-default
1157
- * gating everywhere else in this file).
1158
- *
1159
- * capabilities.conversations SUBSUMES this on every layout EXCEPT `custom`
1160
- * (CU-1: conversations is one of the capabilities excluded from the custom
1161
- * layout's escape hatch, so custom never wires the store-based path and
1162
- * must keep this hand-rolled one). Where it applies, ChatThread's own
1163
- * conversations feature owns persistence entirely through the `store` prop
1164
- * (see emitHistorySetup's doc for the full decision) and this file's
1165
- * hand-rolled effect is never emitted, so it needs no `createEffect` import
1166
- * either — gate the same way. */
1167
- function emitSolidJsImport(c: Construct): string {
1168
- const history = c.capabilities?.history;
1169
- if (!history || history.persistence === 'none' || (c.layout !== 'custom' && c.capabilities?.conversations)) return '';
1170
- return `import { createEffect } from 'solid-js';\n`;
1171
- }
1172
-
1173
- /** capabilities.history -> whether the AttachmentData type import also needs
1174
- * ChatMessage (only the persisted variants read/write full ChatMessage[]
1175
- * arrays). Gated off when capabilities.conversations subsumes persistence
1176
- * (see emitSolidJsImport's doc — same `layout !== 'custom'` condition) for
1177
- * the same reason: the hand-rolled block that used this type is never
1178
- * emitted there. */
1179
- function emitHistoryTypeImport(c: Construct): string {
1180
- const history = c.capabilities?.history;
1181
- if (!history || history.persistence === 'none' || (c.layout !== 'custom' && c.capabilities?.conversations)) return '';
1182
- // The enclosing statement is already `import type { ... }` (AttachmentData),
1183
- // so this must NOT repeat the `type` modifier inside the braces — `import
1184
- // type { AttachmentData, type ChatMessage }` is a TS syntax error.
1185
- return ', ChatMessage';
1186
- }
1187
-
1188
- /** capabilities.history -> the persistence block spliced after
1189
- * createKaiChat/submit (emitProviderSetup). `none`/absent emits nothing at
1190
- * all — the format rule (undeclared capability's affordance is OFF).
1191
- *
1192
- * `local`: keyed by the construct's own tag (one thread per construct, no
1193
- * cross-construct collision) — restoring on mount MUST hand createKaiChat's
1194
- * setMessages a NEW array reference (the kit's reactivity contract; see
1195
- * CLAUDE.md), which the updater-returns-parsed-array form does for free. A
1196
- * parsed value that is well-formed JSON but the WRONG SHAPE (an object, a
1197
- * number, ...) is just as dangerous as a storage exception — handing it to
1198
- * `chat.setMessages` would crash ChatThread's render — so it gets the same
1199
- * `Array.isArray` gate as the endpoint variant below, not just a try/catch
1200
- * around the parse.
1201
- * localStorage access is wrapped: it can throw in private mode or over
1202
- * quota, and a corrupt/foreign value under the key must not white-screen —
1203
- * neither failure is guessed at silently, both fall back to running
1204
- * in-memory (decide loudly: see the comment emitted alongside).
1205
- * Retention/eviction (how much, how long) is deliberately absent — an
1206
- * application-layer decision (component-scope-boundary), not this
1207
- * construct's to make.
1208
- *
1209
- * `endpoint`: the CONSUMER's own thread route — GET on mount (kit parses
1210
- * the response as ChatMessage[]; a non-OK response, a rejected fetch, or a
1211
- * non-array body all fall back to an empty thread rather than throwing or
1212
- * crashing render — matching the shape-check discipline above), PUT on
1213
- * every change. Both fetches are wrapped (try/catch around the GET chain,
1214
- * `.catch` on the PUT) and decide loudly on failure (`console.error`) —
1215
- * mirroring the adjacent provider-endpoint fetch's own try/catch +
1216
- * `stream.abort` pattern, not a silent swallow. The `hydrated` flag guards
1217
- * against the mount-load's own setMessages call immediately re-triggering a
1218
- * PUT that writes back exactly what was just read — but a FAILED GET must
1219
- * still flip it, in `finally`: a transient GET failure (offline/CORS/DNS)
1220
- * degrading to "start fresh, keep saving" is the recoverable failure mode;
1221
- * leaving `hydrated` false forever would permanently disable every future
1222
- * PUT for the tab's life over one blip. No retry/backoff — that belongs to
1223
- * the app, not this construct (component-scope-boundary). url is
1224
- * construct-authored/untrusted like theme.accent and provider.url, so it is
1225
- * JSON.stringify'd at both fetch call sites — never string-concatenated
1226
- * (see the endpoint-provider comment on this same class of bug). */
1227
- /** Top-level userId -> the `x-kai-user-id` header on every emitted fetch that
1228
- * talks to the consumer's own backend (the endpoint provider's chat POST, and
1229
- * history's endpoint GET/PUT) — so a route can tell which user's thread this
1230
- * is. `local` persistence folds it into THREAD_KEY instead (see
1231
- * emitHistorySetup) — no network call to header there. userId is
1232
- * construct-authored data (like theme.accent/provider.url), so it is
1233
- * JSON.stringify'd wherever it is interpolated — never string-concatenated. */
1234
- function emitUserIdHeaderEntry(c: Construct): string {
1235
- return c.userId ? `, 'x-kai-user-id': ${JSON.stringify(c.userId)}` : '';
1236
- }
1237
-
1238
- function emitHistorySetup(c: Construct): string {
1239
- const history = c.capabilities?.history;
1240
- if (!history || history.persistence === 'none') return '';
1241
-
1242
- // PERSISTENCE-OWNERSHIP DECISION (Task 5): capabilities.conversations
1243
- // SUBSUMES this hand-rolled block entirely. ChatThread's own conversations
1244
- // feature (chat-thread.tsx) already autosaves the current thread on every
1245
- // `chat.messages()` change through whatever ConversationStore its `store`
1246
- // prop wires — localStorageStore(name) for `local`, fetchStore(url) for
1247
- // `endpoint` (see emitConversationsProps below), the SAME "keyed by
1248
- // construct name, local browser storage or a fetch endpoint" mechanism this
1249
- // block hand-rolls for a single anonymous thread via THREAD_KEY/fetch. With
1250
- // conversations on there is a real conversation id to save under (the store
1251
- // contract), so the store path is strictly more correct, not just
1252
- // redundant. Emitting both would persist through two independent
1253
- // mechanisms on every change — not corrupting, but wasteful, confusing to
1254
- // read, and exactly the kind of restatement this file avoids elsewhere
1255
- // (see emitApp's header comment) — so this function contributes NOTHING
1256
- // when capabilities.conversations is set: the store prop is the ONE
1257
- // persistence mechanism in the emitted App. This does NOT apply to the
1258
- // `custom` layout — CU-1 excludes conversations from custom's escape
1259
- // hatch (emitCustomApp never calls emitConversationsProps), so custom
1260
- // must keep this hand-rolled block as its only persistence mechanism even
1261
- // when capabilities.conversations is set on the construct.
1262
- if (c.layout !== 'custom' && c.capabilities?.conversations) return '';
1263
-
1264
- if (history.persistence === 'local') {
1265
- const key = JSON.stringify(c.userId ? `kai:${c.name}:${c.userId}:thread` : `kai:${c.name}:thread`);
1266
- return `
1267
- // History: persisted locally in this browser, keyed by the element tag. What to
1268
- // retain and for how long is an app decision — clear the key to reset.
1269
- const THREAD_KEY = ${key};
1270
- try {
1271
- const saved = localStorage.getItem(THREAD_KEY);
1272
- if (saved) {
1273
- const parsed: unknown = JSON.parse(saved);
1274
- if (Array.isArray(parsed)) {
1275
- chat.setMessages(() => parsed as ChatMessage[]);
1276
- } else {
1277
- console.warn(\`[\${THREAD_KEY}] stored history was not an array; ignoring and starting fresh\`);
1278
- }
1279
- }
1280
- } catch { /* storage unavailable or corrupt: run in-memory */ }
1281
- createEffect(() => {
1282
- try {
1283
- localStorage.setItem(THREAD_KEY, JSON.stringify(chat.messages()));
1284
- } catch { /* storage unavailable: run in-memory */ }
1285
- });
1286
- `;
1287
- }
1288
-
1289
- const url = JSON.stringify(history.url);
1290
- return `
1291
- // History: persisted to your endpoint (GET on mount, PUT on every change) —
1292
- // the kit PARSES, this app FETCHES; your route owns the storage and what to
1293
- // retain and for how long. \`hydrated\` guards the mount-load from immediately
1294
- // PUTting back what it just loaded, but still flips on a FAILED load — one
1295
- // offline/CORS/DNS blip degrades to "start fresh, keep saving", not
1296
- // "never save again".
1297
- let hydrated = false;
1298
- (async () => {
1299
- try {
1300
- const r = await fetch(${url}${c.userId ? `, { headers: { 'x-kai-user-id': ${JSON.stringify(c.userId)} } }` : ''});
1301
- const saved: unknown = r.ok ? await r.json() : [];
1302
- if (Array.isArray(saved)) {
1303
- chat.setMessages(() => saved as ChatMessage[]);
1304
- } else {
1305
- console.warn('history endpoint returned a non-array body; ignoring and starting fresh');
1306
- }
1307
- } catch (err) {
1308
- console.error('history endpoint GET failed; starting fresh (will keep saving)', err);
1309
- } finally {
1310
- hydrated = true;
1311
- }
1312
- })();
1313
- createEffect(() => {
1314
- const snapshot = chat.messages();
1315
- if (!hydrated) return;
1316
- fetch(${url}, {
1317
- method: 'PUT',
1318
- headers: { 'content-type': 'application/json'${emitUserIdHeaderEntry(c)} },
1319
- body: JSON.stringify(snapshot),
1320
- }).catch((err) => {
1321
- console.error('history endpoint PUT failed; this change was not persisted', err);
1322
- });
1323
- });
1324
- `;
1325
- }
1326
-
1327
- function emitProviderImports(c: Construct): string {
1328
- if (c.provider.mode === 'mock') {
1329
- return `import { createMockResponder } from '@kitn.ai/ui/state';
1330
- import { readOpenAIStream } from '@kitn.ai/ui/wire';`;
1331
- }
1332
- const read = c.provider.wire === 'openai' ? 'readOpenAIStream' : 'readAnthropicStream';
1333
- const encode = c.provider.wire === 'openai' ? 'toOpenAIMessages' : 'toAnthropicMessages';
1334
- return `import { ${read}, ${encode} } from '@kitn.ai/ui/wire';`;
1335
- }
1336
-
1337
- function emitProviderSetup(c: Construct): string {
1338
- // ChatThread owns its own composer draft (uncontrolled — no `value` prop
1339
- // passed below) and clears it after submit itself; `onSubmit` hands back
1340
- // the value directly, so there's no PromptInput-specific signal-reading
1341
- // workaround to carry here any more.
1342
- if (c.provider.mode === 'mock') {
1343
- const cardsNote = c.cards
1344
- ? `
1345
- // Cards demo keylessly: createMockResponder() can already SCRIPT a tool call
1346
- // (\`replies: [{ toolCalls: [...] }]\`, F-35), so a scripted turn calling
1347
- // \`kai_<card name>\` renders exactly like a live model's would, below.`
1348
- : '';
1349
- return `// Provider seam: mock — keyless, streams locally, announces itself once.
1350
- // Swap for provider.mode "endpoint" in the construct and re-run kai dev; the
1351
- // generated fetch keeps this exact shape (the seam is the point).${cardsNote}
1352
- const respond = createMockResponder();
1353
- const chat = createKaiChat();
1354
-
1355
- async function submit(detail: { value: string; attachments: AttachmentData[] }) {
1356
- if (!detail.value.trim() || chat.loading()) return;
1357
- chat.append({
1358
- id: crypto.randomUUID(),
1359
- role: 'user',
1360
- parts: [
1361
- { type: 'text', text: detail.value },
1362
- ...detail.attachments.map((attachment) => ({ type: 'file' as const, attachment })),
1363
- ],
1364
- });
1365
- const stream = chat.streamAssistant();
1366
- try {
1367
- await readOpenAIStream(respond(detail.value), stream);${emitApplyCardTools(c)}
1368
- stream.done();
1369
- } catch (err) {
1370
- stream.abort(err instanceof Error ? err.message : String(err));
1371
- }
1372
- }`;
1373
- }
1374
-
1375
- const { url, wire } = c.provider;
1376
- const read = wire === 'openai' ? 'readOpenAIStream' : 'readAnthropicStream';
1377
- const encode = wire === 'openai' ? 'toOpenAIMessages' : 'toAnthropicMessages';
1378
- // provider.url is an UNCONSTRAINED z.string() (schema.ts). It is NEVER
1379
- // embedded in this comment — a `//` line comment is ended by a raw
1380
- // U+2028/U+2029 line separator (a valid JS line terminator that
1381
- // commentSafe's \r\n strip does not catch), so a url containing one of
1382
- // those code points could close the comment early and let the rest of the
1383
- // url execute as JS. commentSafe is a comment-escaping tool and this is a
1384
- // hand-rolled-escaping trap for untrusted input by construction, so the
1385
- // fix is to never hand-roll it here: the url appears ONLY on the fetch()
1386
- // line below, via JSON.stringify, which is a real JS string literal (not a
1387
- // comment) and immune to this class of bug.
1388
- return `// Provider seam: YOUR endpoint (${wire} wire, see the fetch call below
1389
- // for the URL). The kit PARSES, this component FETCHES — no key, no
1390
- // provider SDK, no client in here. Your route holds the key and re-frames to
1391
- // the provider; the kai MCP scaffold tool emits one for your framework.
1392
- const chat = createKaiChat();
1393
-
1394
- async function submit(detail: { value: string; attachments: AttachmentData[] }) {
1395
- if (!detail.value.trim() || chat.loading()) return;
1396
- chat.append({
1397
- id: crypto.randomUUID(),
1398
- role: 'user',
1399
- parts: [
1400
- { type: 'text', text: detail.value },
1401
- ...detail.attachments.map((attachment) => ({ type: 'file' as const, attachment })),
1402
- ],
1403
- });
1404
- const stream = chat.streamAssistant();
1405
- try {
1406
- const response = await fetch(${JSON.stringify(url)}, {
1407
- method: 'POST',
1408
- headers: { 'content-type': 'application/json'${emitUserIdHeaderEntry(c)} },
1409
- body: JSON.stringify({ messages: ${encode}(chat.messages())${emitToolsField(c)} }),
1410
- });
1411
- if (!response.ok) throw new Error(\`endpoint responded \${response.status}\`);
1412
- await ${read}(response, stream);${emitApplyCardTools(c)}
1413
- stream.done();
1414
- } catch (err) {
1415
- stream.abort(err instanceof Error ? err.message : String(err));
1416
- }
1417
- }`;
1418
- }
1419
-
1420
- /** The layout-conditional named import spliced onto the `@kitn.ai/ui/solid`
1421
- * import list in App.tsx: `Dock` only for `widget`, `PaneGroup` only for
1422
- * `split` — nothing for `fullscreen`/`aside`, which are plain styled
1423
- * containers (the kit has no dedicated fullscreen/docked-aside component;
1424
- * see the emitLayoutOpen doc for why that's the honest choice here rather
1425
- * than a hand-rolled component of our own). Gated so the generated
1426
- * project's own `noUnusedLocals` never trips on an import used by a layout
1427
- * that isn't this construct's. */
1428
- function emitLayoutImport(c: Construct): string {
1429
- switch (c.layout) {
1430
- case 'widget':
1431
- return `, Dock${hasLauncherIcon(c) ? ', DockLauncherImage' : ''}`;
1432
- case 'split':
1433
- return ', WorkspaceShell';
1434
- case 'fullscreen':
1435
- case 'aside':
1436
- return '';
1437
- case 'custom':
1438
- // Unreachable: emitApp special-cases 'custom' into emitCustomApp before
1439
- // this is ever called (custom's spine is Thread + PromptInput, not
1440
- // ChatThread, so there is no shared import line to splice onto). Kept
1441
- // only so this switch stays exhaustive over the widened layout enum.
1442
- return '';
1443
- }
1444
- }
1445
-
1446
- /** Declared `slots`, one `<slot name="...">` per entry in declaration order.
1447
- * Slot names are already schema-validated to `^[a-z][a-z0-9-]*$` (schema.ts)
1448
- * — a closed character set with no quote/backslash, so they're interpolated
1449
- * directly into the attribute rather than JSON.stringify'd like the
1450
- * free-text construct-authored fields (starters, theme.accent, provider.url)
1451
- * elsewhere in this file. `indent` matches the surrounding JSX depth. */
1452
- function emitSlots(slots: readonly string[] | undefined, indent: string): string {
1453
- if (!slots || slots.length === 0) return '';
1454
- return slots.map((name) => `${indent}<slot name="${name}" />\n`).join('');
1455
- }
1456
-
1457
- /**
1458
- * The layout shell wrapping ChatThread — one pair (open above, close below)
1459
- * per `layout`, composing the kit's own layout primitives over a hand-rolled
1460
- * div wherever the kit ships one:
1461
- *
1462
- * - `widget`: the kit's Dock (launcher + panel + focus contract) — unchanged
1463
- * from before Task 12. No theming wrapper needed here — the accent lands
1464
- * on the HOST element from element.tsx's facade (see emitElement), which
1465
- * reaches both the launcher (a DOM sibling of this panel content, outside
1466
- * Dock's own `children`) and everything below via normal custom-property
1467
- * inheritance from :host down through the whole shadow tree.
1468
- * - `fullscreen`: the kit has no dedicated "fill the viewport" component —
1469
- * this genuinely is just sizing, not chrome — so a minimally-styled
1470
- * `<div>` (100dvh, column flex) IS the honest composition, not a
1471
- * restatement of something the kit already owns.
1472
- * - `aside`: same reasoning — a persistent, single-edge docked panel is a
1473
- * styled container (fixed inline-end column, the kit's own
1474
- * `--kai-color-border` token for the divider), not a kit component. `dvh`/
1475
- * logical properties (`inset-inline-end`, `border-inline-start`) keep it
1476
- * correct under RTL and mobile viewport chrome the same way `100dvh` does
1477
- * for fullscreen.
1478
- * - `split`: composes the kit's real `WorkspaceShell` (components/
1479
- * workspace-shell.tsx) for its frame -- chat in `children` (the main
1480
- * region), the end pane's `<slot name="pane">` seam projected via `end`.
1481
- * Superseded from Task 12's `PaneGroup` (recorded decision 2): `PaneGroup`
1482
- * is an editor GROUP contract (src/ui/pane-group.tsx) -- a tab strip over
1483
- * ONE content area -- so getting two SIMULTANEOUS panes out of it meant a
1484
- * single always-active tab whose one body was a hand-rolled flex row doing
1485
- * the actual two-column math; the "kit component" was supplying a frame
1486
- * the composition didn't use for its defining feature (a resizable split)
1487
- * at all. `WorkspaceShell` already IS a two-region layout with a REAL
1488
- * draggable splitter between them (it composes `ResizablePanelGroup`
1489
- * internally) -- start/end aside width props and collapse are left at
1490
- * their defaults here (this construct wants exactly one fixed end pane,
1491
- * not a full workspace chrome); `drawerBelow={480}` IS wired (Task 19d) so
1492
- * split gets the kit's own mobile takeover at the same breakpoint every
1493
- * other layout uses, so the split's math is the kit's
1494
- * own, not a restatement.
1495
- *
1496
- * `custom` is handled entirely by `emitCustomApp` instead (its spine is
1497
- * Thread + PromptInput composed by hand, not ChatThread, so there's no shared
1498
- * chrome to open/close here) — `emitApp` special-cases it before either of
1499
- * these is called. Both switches still carry a `case 'custom'` so they stay
1500
- * exhaustive over the widened layout enum: TypeScript is what caught this
1501
- * exact gap when Task 13 added the enum member, and a `default` would have
1502
- * hidden it again for the next one.
1503
- */
1504
- /** widget.position -> Dock's own `position` prop. A closed DockPosition enum
1505
- * (schema-constrained), so plain string interpolation is safe — no
1506
- * construct-authored free text here, unlike launcherIcon below. */
1507
- function emitDockPosition(c: Construct): string {
1508
- const w = c.layout === 'widget' ? c.widget : undefined;
1509
- return w?.position ? ` position="${w.position}"` : '';
1510
- }
1511
-
1512
- /** Whether `widgetHasLauncherIcon` (see below) needs the extra import — its
1513
- * own named predicate so `emitLayoutImport`'s `widget` case reads plainly. */
1514
- function hasLauncherIcon(c: Construct): boolean {
1515
- return c.layout === 'widget' && !!c.widget?.launcherIcon;
1516
- }
1517
-
1518
- /** widget.launcherIcon -> Dock's `launcher` prop, via `DockLauncherImage`
1519
- * (ui/dock.tsx) rather than a hand-rolled `<img>`: a construct-authored URL
1520
- * is exactly as capable of 404ing as any other network fetch (this is what
1521
- * the fix report found — `kai dev`'s own `owner-widget` fixture pinned a
1522
- * `https://example.com/logo.png` placeholder that never resolved, so the
1523
- * FAB rendered a permanently broken image), and `DockLauncherImage` is the
1524
- * kit's own tested graceful-degradation component for exactly that,
1525
- * falling back to the built-in glyph on load failure. launcherIcon is
1526
- * construct-authored/untrusted text (like theme.accent/provider.url
1527
- * elsewhere in this file), so it is JSON.stringify'd into a real JS
1528
- * string-literal expression, never interpolated into a raw JSX attribute
1529
- * string. */
1530
- function emitDockLauncher(c: Construct): string {
1531
- const w = c.layout === 'widget' ? c.widget : undefined;
1532
- if (!w?.launcherIcon) return '';
1533
- return ` launcher={<DockLauncherImage src={${JSON.stringify(w.launcherIcon)}} />}`;
1534
- }
1535
-
1536
- /** widget.defaultOpen -> Dock's own `defaultOpen` prop. Only `true` costs a
1537
- * byte — `false`/absent matches Dock's own default (closed), same
1538
- * off-by-default convention as every other capability in this file. */
1539
- function emitDockDefaultOpen(c: Construct): string {
1540
- const w = c.layout === 'widget' ? c.widget : undefined;
1541
- return w?.defaultOpen === true ? ' defaultOpen={true}' : '';
1542
- }
1543
-
1544
- function emitLayoutOpen(c: Construct): string {
1545
- switch (c.layout) {
1546
- case 'widget':
1547
- return ` <Dock label="${c.name}"${emitDockPosition(c)}${emitDockLauncher(c)}${emitDockDefaultOpen(c)}${emitDockHideClose(c)}${emitDockControllerRef(c)}${emitDockOnOpenChangeProp(c)}${emitDockUnreadProp(c)}>\n`;
1548
- case 'fullscreen':
1549
- return ` <div style={{ height: '100dvh', display: 'flex', 'flex-direction': 'column' }}>\n`;
1550
- case 'aside':
1551
- return ` <aside data-kai-layout="aside" style={{ position: 'fixed', 'inset-block': '0', 'inset-inline-end': '0', width: '380px', display: 'flex', 'flex-direction': 'column', 'border-inline-start': '1px solid var(--kai-color-border)' }}>
1552
- {/* Mirrors Dock's own narrow-viewport full-bleed rule (ui/dock.tsx:229-240)
1553
- — aside has no dedicated kit component (see the emitLayoutOpen doc
1554
- comment above), so this is the honest hand-rolled equivalent, not a
1555
- new responsive strategy. */}
1556
- <style>{\`@media (max-width: 480px) { [data-kai-layout="aside"] { inset: 0; width: auto; height: auto; border-inline-start: 0; } }\`}</style>
1557
- `;
1558
- case 'split':
1559
- // drawerBelow: split's mobile takeover is the kit's OWN WorkspaceShell
1560
- // capability (components/workspace-shell.tsx), not hand-rolled CSS — wiring
1561
- // it here is composition-over-reauthoring, not a media-query duplicate. 480
1562
- // matches Dock's own breakpoint (ui/dock.tsx:229) so every layout takes over
1563
- // at the same viewport width.
1564
- return ` <div style={{ height: '100dvh' }}>\n <WorkspaceShell class="h-full" drawerBelow={480} end={\n <div style={{ height: '100%', overflow: 'auto' }}>\n <slot name="pane" />\n </div>\n }>\n`;
1565
- case 'custom':
1566
- return ''; // unreachable — see the block comment above
1567
- }
1568
- }
1569
-
1570
- function emitLayoutClose(c: Construct): string {
1571
- switch (c.layout) {
1572
- case 'widget':
1573
- return ` </Dock>\n`;
1574
- case 'fullscreen':
1575
- return ` </div>\n`;
1576
- case 'aside':
1577
- return ` </aside>\n`;
1578
- case 'split':
1579
- // The end pane (WorkspaceShell's `end`, opened above) is a fixed,
1580
- // always-present projection point for `split` specifically (Task 12) —
1581
- // orthogonal to Task 13's generic `slots` field, which still emits
1582
- // above the chat pane the same as every other non-custom layout (see
1583
- // emitApp). WorkspaceShell supplies its own real draggable splitter
1584
- // between the two, so there is no hand-rolled two-column math left to
1585
- // close here.
1586
- return ` </WorkspaceShell>\n </div>\n`;
1587
- case 'custom':
1588
- return ''; // unreachable — see the block comment above
1589
- }
1590
- }
1591
-
1592
- // ── kai compile: the d.ts alongside the single .js ─────────────────────────
1593
-
1594
- /** The declaration file `kai compile` writes beside the emitted .js — just
1595
- * enough for a consumer's TS to know the tag and its one settable prop. */
1596
- export function emitTypes(c: Construct): string {
1597
- return `declare global {
1598
- interface HTMLElementTagNameMap {
1599
- '${c.name}': HTMLElement & { theme: 'light' | 'dark' | 'auto' };
1600
- }
1601
- }
1602
- export {};
1603
- `;
1604
- }
1605
-
1606
- // ── writing ──────────────────────────────────────────────────────────────────
1607
-
1608
- const MANIFEST = '.kai-manifest.json';
1609
-
1610
- /**
1611
- * Write files; prune anything the PREVIOUS generation wrote that this one
1612
- * didn't. Returns the paths that already existed on disk before this write
1613
- * (i.e. were overwritten) — callers that decide loudly (the CLI's `eject`)
1614
- * use it to say so instead of silently clobbering a file the caller may have
1615
- * hand-edited.
1616
- */
1617
- export function writeProject(files: GeneratedFile[], dir: string): string[] {
1618
- const manifestPath = join(dir, MANIFEST);
1619
- const previous: string[] = existsSync(manifestPath)
1620
- ? (JSON.parse(readFileSync(manifestPath, 'utf8')) as string[])
1621
- : [];
1622
- const current = new Set(files.map((f) => f.path));
1623
- for (const stale of previous) {
1624
- if (!current.has(stale)) rmSync(join(dir, stale), { force: true });
1625
- }
1626
- const overwritten: string[] = [];
1627
- for (const f of files) {
1628
- const abs = join(dir, f.path);
1629
- if (existsSync(abs)) overwritten.push(f.path);
1630
- mkdirSync(dirname(abs), { recursive: true });
1631
- writeFileSync(abs, f.code);
1632
- }
1633
- writeFileSync(manifestPath, `${JSON.stringify([...current].sort(), null, 2)}\n`);
1634
- return overwritten;
1635
- }