@kitn.ai/ui 0.14.1

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 (370) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +394 -0
  3. package/dist/bash-InADTalH.js +1 -0
  4. package/dist/core-AYMC6_lb.js +12 -0
  5. package/dist/css-M7EaDHN_.js +1 -0
  6. package/dist/custom-elements.json +4739 -0
  7. package/dist/engine-javascript-vq0WuIJl.js +141 -0
  8. package/dist/github-dark-dimmed-DUshB20C.js +1 -0
  9. package/dist/github-light-JYsPkUQd.js +1 -0
  10. package/dist/host-embed.d.ts +33 -0
  11. package/dist/html-CPZ3oZQ7.js +1 -0
  12. package/dist/index.d.ts +2 -0
  13. package/dist/javascript-C25yR2R2.js +1 -0
  14. package/dist/json-DxJze_jm.js +1 -0
  15. package/dist/kitn-chat-provider.d.ts +2 -0
  16. package/dist/kitn-chat-provider.es.js +240 -0
  17. package/dist/kitn-chat.es.js +77 -0
  18. package/dist/llms/llms-full.txt +1018 -0
  19. package/dist/llms/llms.txt +104 -0
  20. package/dist/origin.d.ts +5 -0
  21. package/dist/provider-runtime.d.ts +27 -0
  22. package/dist/schemas/card-envelope.schema.json +14 -0
  23. package/dist/schemas/card-event.schema.json +12 -0
  24. package/dist/schemas/choice.schema.json +70 -0
  25. package/dist/schemas/confirm.schema.json +65 -0
  26. package/dist/schemas/embed.schema.json +65 -0
  27. package/dist/schemas/form.result.schema.json +7 -0
  28. package/dist/schemas/form.schema.json +33 -0
  29. package/dist/schemas/link.schema.json +56 -0
  30. package/dist/schemas/tasks.result.schema.json +16 -0
  31. package/dist/schemas/tasks.schema.json +78 -0
  32. package/dist/svelte--5p79yCD.js +1 -0
  33. package/dist/theme.tokens.css +176 -0
  34. package/dist/tsx-B8rCNbgL.js +1 -0
  35. package/dist/typescript-RycA9KXf.js +1 -0
  36. package/dist/validate.d.ts +4 -0
  37. package/dist/version.d.ts +4 -0
  38. package/dist/vue-BmIZj4XD.js +1 -0
  39. package/dist/wire.d.ts +53 -0
  40. package/frameworks/react/index.tsx +948 -0
  41. package/frameworks/react/runtime.tsx +94 -0
  42. package/llms-full.txt +1018 -0
  43. package/llms.txt +104 -0
  44. package/package.json +141 -0
  45. package/src/components/artifact.stories.tsx +138 -0
  46. package/src/components/artifact.tsx +743 -0
  47. package/src/components/attachments.stories.tsx +303 -0
  48. package/src/components/attachments.test.tsx +64 -0
  49. package/src/components/attachments.tsx +399 -0
  50. package/src/components/card-fallback.tsx +28 -0
  51. package/src/components/card-renderer.tsx +52 -0
  52. package/src/components/card.tsx +110 -0
  53. package/src/components/chain-of-thought.stories.tsx +211 -0
  54. package/src/components/chain-of-thought.tsx +139 -0
  55. package/src/components/chat-container.stories.tsx +187 -0
  56. package/src/components/chat-container.tsx +82 -0
  57. package/src/components/chat-scope-picker.tsx +47 -0
  58. package/src/components/chat-thread.test.tsx +58 -0
  59. package/src/components/chat-thread.tsx +249 -0
  60. package/src/components/checkpoint.stories.tsx +102 -0
  61. package/src/components/checkpoint.tsx +88 -0
  62. package/src/components/choice-card.tsx +569 -0
  63. package/src/components/code-block.stories.tsx +150 -0
  64. package/src/components/code-block.tsx +105 -0
  65. package/src/components/component-meta.json +3735 -0
  66. package/src/components/confirm-card.stories.tsx +74 -0
  67. package/src/components/confirm-card.tsx +315 -0
  68. package/src/components/context.stories.tsx +229 -0
  69. package/src/components/context.test.tsx +133 -0
  70. package/src/components/context.tsx +363 -0
  71. package/src/components/conversation-item.stories.tsx +125 -0
  72. package/src/components/conversation-item.tsx +16 -0
  73. package/src/components/conversation-list.stories.tsx +133 -0
  74. package/src/components/conversation-list.tsx +101 -0
  75. package/src/components/default-input.test.tsx +111 -0
  76. package/src/components/embed.tsx +196 -0
  77. package/src/components/empty.stories.tsx +434 -0
  78. package/src/components/empty.tsx +166 -0
  79. package/src/components/feedback-bar.stories.tsx +138 -0
  80. package/src/components/feedback-bar.test.tsx +103 -0
  81. package/src/components/feedback-bar.tsx +166 -0
  82. package/src/components/file-tree.stories.tsx +73 -0
  83. package/src/components/file-tree.tsx +383 -0
  84. package/src/components/file-upload.stories.tsx +156 -0
  85. package/src/components/file-upload.tsx +161 -0
  86. package/src/components/form-widgets.tsx +461 -0
  87. package/src/components/form.tsx +873 -0
  88. package/src/components/image.stories.tsx +89 -0
  89. package/src/components/image.tsx +67 -0
  90. package/src/components/link-preview.tsx +194 -0
  91. package/src/components/loader.stories.tsx +181 -0
  92. package/src/components/loader.tsx +333 -0
  93. package/src/components/markdown.stories.tsx +180 -0
  94. package/src/components/markdown.tsx +81 -0
  95. package/src/components/message-action-bar.test.tsx +128 -0
  96. package/src/components/message-narrow.stories.tsx +329 -0
  97. package/src/components/message-skills.stories.tsx +211 -0
  98. package/src/components/message-skills.tsx +36 -0
  99. package/src/components/message.stories.tsx +281 -0
  100. package/src/components/message.tsx +227 -0
  101. package/src/components/model-switcher.stories.tsx +123 -0
  102. package/src/components/model-switcher.test.tsx +65 -0
  103. package/src/components/model-switcher.tsx +87 -0
  104. package/src/components/prompt-input.stories.tsx +222 -0
  105. package/src/components/prompt-input.tsx +211 -0
  106. package/src/components/prompt-suggestion.stories.tsx +142 -0
  107. package/src/components/prompt-suggestion.tsx +115 -0
  108. package/src/components/reasoning.stories.tsx +140 -0
  109. package/src/components/reasoning.tsx +156 -0
  110. package/src/components/resizable.test.tsx +226 -0
  111. package/src/components/response-stream.tsx +103 -0
  112. package/src/components/scroll-button.stories.tsx +100 -0
  113. package/src/components/scroll-button.tsx +34 -0
  114. package/src/components/slash-command.stories.tsx +163 -0
  115. package/src/components/slash-command.tsx +232 -0
  116. package/src/components/source-list.test.tsx +152 -0
  117. package/src/components/source.stories.tsx +124 -0
  118. package/src/components/source.tsx +129 -0
  119. package/src/components/tasks-card.stories.tsx +78 -0
  120. package/src/components/tasks-card.tsx +432 -0
  121. package/src/components/text-shimmer.stories.tsx +87 -0
  122. package/src/components/text-shimmer.tsx +37 -0
  123. package/src/components/thinking-bar.stories.tsx +87 -0
  124. package/src/components/thinking-bar.tsx +50 -0
  125. package/src/components/tool.stories.tsx +153 -0
  126. package/src/components/tool.tsx +184 -0
  127. package/src/components/use-card-resolution.ts +28 -0
  128. package/src/components/voice-input.stories.tsx +83 -0
  129. package/src/components/voice-input.tsx +107 -0
  130. package/src/elements/artifact.stories.tsx +506 -0
  131. package/src/elements/artifact.tsx +137 -0
  132. package/src/elements/attachments.stories.tsx +190 -0
  133. package/src/elements/attachments.tsx +136 -0
  134. package/src/elements/card.stories.tsx +113 -0
  135. package/src/elements/card.tsx +40 -0
  136. package/src/elements/cards.stories.tsx +59 -0
  137. package/src/elements/cards.tsx +97 -0
  138. package/src/elements/catalog.stories.tsx +491 -0
  139. package/src/elements/chain-of-thought.declarative.test.tsx +153 -0
  140. package/src/elements/chain-of-thought.stories.tsx +113 -0
  141. package/src/elements/chain-of-thought.tsx +92 -0
  142. package/src/elements/chat-scope-picker.stories.tsx +73 -0
  143. package/src/elements/chat-scope-picker.tsx +36 -0
  144. package/src/elements/chat-types.ts +42 -0
  145. package/src/elements/chat-workspace.stories.tsx +288 -0
  146. package/src/elements/chat-workspace.tsx +161 -0
  147. package/src/elements/chat.stories.tsx +151 -0
  148. package/src/elements/chat.tsx +80 -0
  149. package/src/elements/checkpoint.stories.tsx +72 -0
  150. package/src/elements/checkpoint.tsx +47 -0
  151. package/src/elements/choice.stories.tsx +240 -0
  152. package/src/elements/choice.tsx +48 -0
  153. package/src/elements/code-block.stories.tsx +83 -0
  154. package/src/elements/code-block.tsx +42 -0
  155. package/src/elements/compiled.css +2 -0
  156. package/src/elements/composed-shell.stories.tsx +316 -0
  157. package/src/elements/confirm-card.stories.tsx +227 -0
  158. package/src/elements/confirm-card.tsx +51 -0
  159. package/src/elements/context-meter.stories.tsx +151 -0
  160. package/src/elements/context-meter.tsx +121 -0
  161. package/src/elements/conversation-list.declarative.test.tsx +137 -0
  162. package/src/elements/conversation-list.stories.tsx +243 -0
  163. package/src/elements/conversation-list.tsx +80 -0
  164. package/src/elements/css.ts +5 -0
  165. package/src/elements/default-input.tsx +247 -0
  166. package/src/elements/define.tsx +171 -0
  167. package/src/elements/element-meta.json +3314 -0
  168. package/src/elements/element-types.d.ts +710 -0
  169. package/src/elements/embed.stories.tsx +213 -0
  170. package/src/elements/embed.tsx +35 -0
  171. package/src/elements/empty.stories.tsx +111 -0
  172. package/src/elements/empty.tsx +29 -0
  173. package/src/elements/feedback-bar.stories.tsx +114 -0
  174. package/src/elements/feedback-bar.tsx +61 -0
  175. package/src/elements/file-tree.stories.tsx +134 -0
  176. package/src/elements/file-tree.tsx +52 -0
  177. package/src/elements/file-upload.stories.tsx +82 -0
  178. package/src/elements/file-upload.tsx +44 -0
  179. package/src/elements/form.stories.tsx +248 -0
  180. package/src/elements/form.tsx +43 -0
  181. package/src/elements/framework-usage.json +563 -0
  182. package/src/elements/image.stories.tsx +71 -0
  183. package/src/elements/image.tsx +32 -0
  184. package/src/elements/link-preview.stories.tsx +209 -0
  185. package/src/elements/link-preview.tsx +34 -0
  186. package/src/elements/loader.stories.tsx +88 -0
  187. package/src/elements/loader.tsx +25 -0
  188. package/src/elements/markdown.stories.tsx +76 -0
  189. package/src/elements/markdown.tsx +38 -0
  190. package/src/elements/message-skills.declarative.test.tsx +104 -0
  191. package/src/elements/message-skills.stories.tsx +114 -0
  192. package/src/elements/message-skills.tsx +85 -0
  193. package/src/elements/message.stories.tsx +218 -0
  194. package/src/elements/message.tsx +162 -0
  195. package/src/elements/model-switcher.declarative.test.tsx +130 -0
  196. package/src/elements/model-switcher.stories.tsx +198 -0
  197. package/src/elements/model-switcher.tsx +93 -0
  198. package/src/elements/popover.stories.tsx +80 -0
  199. package/src/elements/popover.tsx +59 -0
  200. package/src/elements/prompt-input-slash-command.test.tsx +128 -0
  201. package/src/elements/prompt-input-stoppable.test.tsx +88 -0
  202. package/src/elements/prompt-input-toolbar-actions.test.tsx +120 -0
  203. package/src/elements/prompt-input.stories.tsx +331 -0
  204. package/src/elements/prompt-input.tsx +185 -0
  205. package/src/elements/prompt-suggestions.declarative.test.tsx +164 -0
  206. package/src/elements/prompt-suggestions.stories.tsx +213 -0
  207. package/src/elements/prompt-suggestions.tsx +94 -0
  208. package/src/elements/reasoning.stories.tsx +77 -0
  209. package/src/elements/reasoning.tsx +50 -0
  210. package/src/elements/register.ts +56 -0
  211. package/src/elements/remote.stories.tsx +314 -0
  212. package/src/elements/remote.tsx +221 -0
  213. package/src/elements/resizable.d.ts +27 -0
  214. package/src/elements/resizable.stories.tsx +426 -0
  215. package/src/elements/resizable.tsx +553 -0
  216. package/src/elements/response-stream.stories.tsx +79 -0
  217. package/src/elements/response-stream.tsx +40 -0
  218. package/src/elements/scroll-button.stories.tsx +202 -0
  219. package/src/elements/scroll-button.test.tsx +148 -0
  220. package/src/elements/scroll-button.tsx +126 -0
  221. package/src/elements/source-list.stories.tsx +143 -0
  222. package/src/elements/source.stories.tsx +88 -0
  223. package/src/elements/source.tsx +120 -0
  224. package/src/elements/styles.css +199 -0
  225. package/src/elements/switch.stories.tsx +56 -0
  226. package/src/elements/switch.tsx +43 -0
  227. package/src/elements/tasks.stories.tsx +237 -0
  228. package/src/elements/tasks.tsx +46 -0
  229. package/src/elements/text-shimmer.stories.tsx +64 -0
  230. package/src/elements/text-shimmer.tsx +28 -0
  231. package/src/elements/thinking-bar.stories.tsx +72 -0
  232. package/src/elements/thinking-bar.tsx +34 -0
  233. package/src/elements/tool.stories.tsx +89 -0
  234. package/src/elements/tool.tsx +23 -0
  235. package/src/elements/voice-input.stories.tsx +88 -0
  236. package/src/elements/voice-input.tsx +41 -0
  237. package/src/index.ts +253 -0
  238. package/src/primitives/card-contract.ts +71 -0
  239. package/src/primitives/card-host.tsx +35 -0
  240. package/src/primitives/card-registry.tsx +67 -0
  241. package/src/primitives/card-resolution.ts +36 -0
  242. package/src/primitives/card-routing.ts +79 -0
  243. package/src/primitives/card-schemas/card-envelope.schema.json +14 -0
  244. package/src/primitives/card-schemas/card-event.schema.json +12 -0
  245. package/src/primitives/card-schemas/choice.schema.json +70 -0
  246. package/src/primitives/card-schemas/confirm.schema.json +65 -0
  247. package/src/primitives/card-schemas/embed.schema.json +65 -0
  248. package/src/primitives/card-schemas/form.result.schema.json +7 -0
  249. package/src/primitives/card-schemas/form.schema.json +33 -0
  250. package/src/primitives/card-schemas/link.schema.json +56 -0
  251. package/src/primitives/card-schemas/tasks.result.schema.json +16 -0
  252. package/src/primitives/card-schemas/tasks.schema.json +78 -0
  253. package/src/primitives/card-validate.ts +95 -0
  254. package/src/primitives/chat-config.tsx +76 -0
  255. package/src/primitives/controllable.test.ts +47 -0
  256. package/src/primitives/controllable.ts +21 -0
  257. package/src/primitives/embed-providers.ts +254 -0
  258. package/src/primitives/highlighter.ts +157 -0
  259. package/src/primitives/link-preview.ts +87 -0
  260. package/src/primitives/pdf-preview.ts +121 -0
  261. package/src/primitives/use-auto-resize.ts +31 -0
  262. package/src/primitives/use-resize-observer.ts +12 -0
  263. package/src/primitives/use-stick-to-bottom.ts +43 -0
  264. package/src/primitives/use-text-stream.ts +112 -0
  265. package/src/primitives/use-voice-recorder.ts +50 -0
  266. package/src/remote/host-embed.ts +345 -0
  267. package/src/remote/index.ts +2 -0
  268. package/src/remote/origin.ts +30 -0
  269. package/src/remote/provider-runtime.ts +259 -0
  270. package/src/remote/provider.ts +2 -0
  271. package/src/remote/validate.ts +22 -0
  272. package/src/remote/version.ts +12 -0
  273. package/src/remote/wire.ts +48 -0
  274. package/src/stories/chat-panel-layout.stories.tsx +145 -0
  275. package/src/stories/chat-scene.tsx +571 -0
  276. package/src/stories/checkpoint-restore.stories.tsx +256 -0
  277. package/src/stories/context-usage.stories.tsx +212 -0
  278. package/src/stories/conversation-with-reasoning.stories.tsx +182 -0
  279. package/src/stories/conversation-with-sources.stories.tsx +471 -0
  280. package/src/stories/docs/Accessibility.mdx +119 -0
  281. package/src/stories/docs/ChildElements.mdx +269 -0
  282. package/src/stories/docs/ForAIAgents.mdx +162 -0
  283. package/src/stories/docs/GettingStarted.mdx +65 -0
  284. package/src/stories/docs/Installation.mdx +83 -0
  285. package/src/stories/docs/Introduction.mdx +50 -0
  286. package/src/stories/docs/SolidJsAdvanced.mdx +17 -0
  287. package/src/stories/docs/Theming.mdx +85 -0
  288. package/src/stories/docs/element-controls.ts +121 -0
  289. package/src/stories/docs/frameworks/Angular.mdx +245 -0
  290. package/src/stories/docs/frameworks/Html.mdx +227 -0
  291. package/src/stories/docs/frameworks/Overview.mdx +47 -0
  292. package/src/stories/docs/frameworks/React.mdx +227 -0
  293. package/src/stories/docs/frameworks/Solid.mdx +173 -0
  294. package/src/stories/docs/frameworks/Svelte.mdx +194 -0
  295. package/src/stories/docs/frameworks/Vue.mdx +238 -0
  296. package/src/stories/docs/generative-ui-overview.mdx +186 -0
  297. package/src/stories/docs/recipes/SpeechToText.mdx +7 -0
  298. package/src/stories/docs/recipes/Streaming.mdx +68 -0
  299. package/src/stories/docs/recipes/TextToSpeech.mdx +35 -0
  300. package/src/stories/docs/theme-editor/canvas.tsx +32 -0
  301. package/src/stories/docs/theme-editor/inspector.tsx +66 -0
  302. package/src/stories/docs/theme-editor/presets.test.ts +32 -0
  303. package/src/stories/docs/theme-editor/presets.ts +64 -0
  304. package/src/stories/docs/theme-editor/theme-css.test.ts +19 -0
  305. package/src/stories/docs/theme-editor/theme-css.ts +15 -0
  306. package/src/stories/docs/theme-editor/theme-editor.tsx +146 -0
  307. package/src/stories/docs/theme-tokens.tsx +174 -0
  308. package/src/stories/examples/ChoosingComponents.mdx +106 -0
  309. package/src/stories/examples/sample-data.ts +79 -0
  310. package/src/stories/examples/usage/checkpoint-restore.ts +156 -0
  311. package/src/stories/examples/usage/context-usage.ts +591 -0
  312. package/src/stories/examples/usage/conversation-with-reasoning.ts +224 -0
  313. package/src/stories/examples/usage/conversation-with-sources.ts +589 -0
  314. package/src/stories/examples/usage/empty-state.ts +144 -0
  315. package/src/stories/examples/usage/full-chat-app.ts +277 -0
  316. package/src/stories/examples/usage/index.ts +63 -0
  317. package/src/stories/examples/usage/message-actions.ts +704 -0
  318. package/src/stories/examples/usage/pattern-centered-conversation.ts +446 -0
  319. package/src/stories/examples/usage/pattern-chat-panel-layout.ts +442 -0
  320. package/src/stories/examples/usage/pattern-docked-widget.ts +479 -0
  321. package/src/stories/examples/usage/prompt-input-variants.ts +1356 -0
  322. package/src/stories/examples/usage/streaming-response.ts +687 -0
  323. package/src/stories/examples/usage/types.ts +33 -0
  324. package/src/stories/full-chat.stories.tsx +18 -0
  325. package/src/stories/message-actions.stories.tsx +230 -0
  326. package/src/stories/pattern-centered-conversation.stories.tsx +93 -0
  327. package/src/stories/pattern-docked-widget.stories.tsx +93 -0
  328. package/src/stories/pattern-empty-state.stories.tsx +76 -0
  329. package/src/stories/prompt-input-variants.stories.tsx +456 -0
  330. package/src/stories/streaming-response.stories.tsx +438 -0
  331. package/src/stories/theme-editor.stories.tsx +16 -0
  332. package/src/stories/token-reference.stories.tsx +18 -0
  333. package/src/stories/typography.stories.tsx +78 -0
  334. package/src/types.ts +47 -0
  335. package/src/ui/action-icons.ts +52 -0
  336. package/src/ui/avatar.stories.tsx +103 -0
  337. package/src/ui/avatar.tsx +23 -0
  338. package/src/ui/badge.stories.tsx +86 -0
  339. package/src/ui/badge.tsx +21 -0
  340. package/src/ui/button.stories.tsx +145 -0
  341. package/src/ui/button.tsx +38 -0
  342. package/src/ui/collapsible.stories.tsx +69 -0
  343. package/src/ui/collapsible.tsx +125 -0
  344. package/src/ui/dropdown.stories.tsx +59 -0
  345. package/src/ui/dropdown.tsx +196 -0
  346. package/src/ui/hover-card.stories.tsx +77 -0
  347. package/src/ui/hover-card.test.tsx +30 -0
  348. package/src/ui/hover-card.tsx +172 -0
  349. package/src/ui/overlay.stories.tsx +115 -0
  350. package/src/ui/overlay.tsx +158 -0
  351. package/src/ui/popover.stories.tsx +81 -0
  352. package/src/ui/popover.test.tsx +99 -0
  353. package/src/ui/popover.tsx +92 -0
  354. package/src/ui/resizable.stories.tsx +236 -0
  355. package/src/ui/resizable.tsx +576 -0
  356. package/src/ui/scroll-area.stories.tsx +50 -0
  357. package/src/ui/scroll-area.tsx +15 -0
  358. package/src/ui/separator.stories.tsx +81 -0
  359. package/src/ui/separator.tsx +10 -0
  360. package/src/ui/skeleton.stories.tsx +337 -0
  361. package/src/ui/skeleton.tsx +16 -0
  362. package/src/ui/switch.stories.tsx +51 -0
  363. package/src/ui/switch.test.tsx +59 -0
  364. package/src/ui/switch.tsx +62 -0
  365. package/src/ui/textarea.stories.tsx +76 -0
  366. package/src/ui/textarea.tsx +21 -0
  367. package/src/ui/tooltip.stories.tsx +74 -0
  368. package/src/ui/tooltip.tsx +68 -0
  369. package/src/utils/cn.ts +24 -0
  370. package/theme.css +187 -0
@@ -0,0 +1,743 @@
1
+ import {
2
+ type JSX,
3
+ splitProps,
4
+ mergeProps,
5
+ createSignal,
6
+ createEffect,
7
+ createMemo,
8
+ on,
9
+ onMount,
10
+ onCleanup,
11
+ Show,
12
+ } from 'solid-js';
13
+ import { cn } from '../utils/cn';
14
+ import { Button } from '../ui/button';
15
+ import { CodeBlock, CodeBlockCode } from './code-block';
16
+ import { FileTree, type FileTreeFile } from './file-tree';
17
+ import { Loader } from './loader';
18
+ import { isPdfPreviewEnabled, renderPdfInto } from '../primitives/pdf-preview';
19
+ import {
20
+ ArrowLeft,
21
+ ArrowRight,
22
+ RotateCw,
23
+ House,
24
+ Eye,
25
+ Code as CodeIcon,
26
+ FileText,
27
+ ExternalLink,
28
+ Download,
29
+ Maximize2,
30
+ Minimize2,
31
+ } from 'lucide-solid';
32
+
33
+ export type ArtifactTab = 'preview' | 'code';
34
+
35
+ /** A file the artifact can preview + show source for. */
36
+ export type ArtifactFile = FileTreeFile;
37
+
38
+ export interface ArtifactProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'onSelect'> {
39
+ /** URL the preview iframe frames. */
40
+ src?: string;
41
+ /** Files for the Code tab's tree (+ each file's preview `url`). */
42
+ files?: ArtifactFile[];
43
+ /** Active tab. Default `preview`. */
44
+ tab?: ArtifactTab;
45
+ /** Selected file path (syncs tree highlight + Code source + preview). */
46
+ activeFile?: string;
47
+ /** iframe `sandbox` override. Default `allow-scripts allow-forms`. */
48
+ sandbox?: string;
49
+ /** Accessible iframe title. */
50
+ iframeTitle?: string;
51
+ /** Fired when the preview navigates (back/forward/reload/path-edit/file-click). */
52
+ onNavigate?: (url: string) => void;
53
+ /** Fired when the Preview|Code tab changes. */
54
+ onTabChange?: (tab: ArtifactTab) => void;
55
+ /** Fired when a file is selected in the tree. */
56
+ onFileSelect?: (path: string) => void;
57
+ // view-state
58
+ /** Controlled maximize view-state (drives the expand/restore button). */
59
+ maximized?: boolean;
60
+ /** Fired when the expand/restore button toggles the maximize view-state. */
61
+ onMaximizeChange?: (maximized: boolean) => void;
62
+ // toolbar composition — existing five default SHOWN (no-* flags invert in the facade)
63
+ /** Show the back/forward nav buttons. Default `true`. */
64
+ showNav?: boolean;
65
+ /** Show the reload button. Default `true`. */
66
+ showReload?: boolean;
67
+ /** Show the home button. Default `true`. */
68
+ showHome?: boolean;
69
+ /** Show the editable path/address field. Default `true`. */
70
+ showPathField?: boolean;
71
+ /** Show the Preview|Code tab toggle. Default `true`. */
72
+ showTabs?: boolean;
73
+ // new affordances — OPT-IN (default hidden; see resolved decision #2)
74
+ /** Show the expand-to-fill button. Default `false` (opt-in). */
75
+ expandable?: boolean;
76
+ /** Show the open-in-new-tab button. Default `false` (opt-in). */
77
+ openInTab?: boolean;
78
+ // chrome
79
+ /** Standalone chrome: rounded + bordered (default in-panel = square, borderless). */
80
+ standalone?: boolean;
81
+ /** Make the path field read-only (visible, nav-tracking, non-editable). */
82
+ readonlyPath?: boolean;
83
+ }
84
+
85
+ const DEFAULT_SANDBOX = 'allow-scripts allow-forms';
86
+
87
+ /** Resolve a file's preview URL: explicit `url`, else `<src-origin> + /path`. */
88
+ function resolveFileUrl(file: ArtifactFile, src: string | undefined): string {
89
+ if (file.url) return file.url;
90
+ if (!src) return file.path;
91
+ try {
92
+ return new URL(file.path, src).href;
93
+ } catch {
94
+ return file.path;
95
+ }
96
+ }
97
+
98
+ /** True when `url` should render as a PDF: a matching `files` entry is typed
99
+ * `'pdf'`, or the URL path (query/hash stripped) ends in `.pdf`. */
100
+ export function isPdfUrl(url: string, files: ArtifactFile[]): boolean {
101
+ if (!url) return false;
102
+ const match = files.find((f) => f.url === url || f.path === url);
103
+ if (match?.type === 'pdf') return true;
104
+ const path = url.split(/[?#]/)[0];
105
+ return /\.pdf$/i.test(path);
106
+ }
107
+
108
+ /**
109
+ * `Artifact` — a framed, switchable generated-artifact viewer. A functional nav
110
+ * toolbar (back · forward · reload · home + editable path field + Preview|Code
111
+ * toggle) over a sandboxed `<iframe>` (Preview) or a file-tree + `<kai-code-block>`
112
+ * (Code). The component self-navigates the iframe and emits `kai-navigate` /
113
+ * `kai-tab-change` / `kai-file-select` so a consumer can observe/sync.
114
+ */
115
+ export function Artifact(props: ArtifactProps): JSX.Element {
116
+ const merged = mergeProps(
117
+ {
118
+ files: [] as ArtifactFile[],
119
+ tab: 'preview' as ArtifactTab,
120
+ sandbox: DEFAULT_SANDBOX,
121
+ showNav: true,
122
+ showReload: true,
123
+ showHome: true,
124
+ showPathField: true,
125
+ showTabs: true,
126
+ expandable: false,
127
+ openInTab: false,
128
+ standalone: false,
129
+ readonlyPath: false,
130
+ maximized: false,
131
+ },
132
+ props,
133
+ );
134
+ const [local, rest] = splitProps(merged, [
135
+ 'src',
136
+ 'files',
137
+ 'tab',
138
+ 'activeFile',
139
+ 'sandbox',
140
+ 'iframeTitle',
141
+ 'onNavigate',
142
+ 'onTabChange',
143
+ 'onFileSelect',
144
+ 'maximized',
145
+ 'onMaximizeChange',
146
+ 'showNav',
147
+ 'showReload',
148
+ 'showHome',
149
+ 'showPathField',
150
+ 'showTabs',
151
+ 'expandable',
152
+ 'openInTab',
153
+ 'standalone',
154
+ 'readonlyPath',
155
+ 'class',
156
+ ]);
157
+
158
+ // A sandboxed iframe WITHOUT `allow-same-origin` makes the framed document's
159
+ // `contentWindow.history`/`location` opaque (a deliberate security property —
160
+ // see the spec). So the component keeps its OWN history stack for the
161
+ // navigations it initiates (path-edit, file-click, home, src changes) and
162
+ // back/forward/reload operate on that. (In-frame relative-link clicks navigate
163
+ // the iframe itself but can't be observed cross-origin — a known, accepted
164
+ // sandbox limitation; opt into `allow-same-origin` if the consumer trusts the
165
+ // artifact and wants those tracked.)
166
+ const [history, setHistory] = createSignal<string[]>(local.src ? [local.src] : []);
167
+ const [cursor, setCursor] = createSignal(local.src ? 0 : -1);
168
+ const currentUrl = () => history()[cursor()] ?? '';
169
+ const canBack = () => cursor() > 0;
170
+ const canForward = () => cursor() < history().length - 1;
171
+
172
+ const [tab, setTab] = createSignal<ArtifactTab>(local.tab);
173
+ const [activeFile, setActiveFile] = createSignal<string | undefined>(local.activeFile);
174
+ const [reloadKey, setReloadKey] = createSignal(0);
175
+
176
+ // Maximize view-state — controlled by `local.maximized`, toggled by the button.
177
+ const [maximized, setMaximized] = createSignal<boolean>(local.maximized ?? false);
178
+ createEffect(() => setMaximized(local.maximized ?? false));
179
+ const toggleMaximize = () => {
180
+ const next = !maximized();
181
+ setMaximized(next);
182
+ local.onMaximizeChange?.(next);
183
+ };
184
+
185
+ // Open-in-tab — only enabled when there's a concrete url to open.
186
+ const canOpenInTab = createMemo(() => {
187
+ const u = currentUrl();
188
+ return !!u && u !== 'about:blank';
189
+ });
190
+ const openInNewTab = () => {
191
+ if (!canOpenInTab()) return;
192
+ window.open(currentUrl(), '_blank', 'noopener,noreferrer');
193
+ };
194
+
195
+ // The expand button is suppressed in standalone (no enclosing resizable).
196
+ const showExpand = createMemo(() => local.expandable && !local.standalone);
197
+ // Omit the whole toolbar when nothing is shown.
198
+ const showAnyToolbar = createMemo(
199
+ () =>
200
+ local.showNav ||
201
+ local.showReload ||
202
+ local.showHome ||
203
+ local.showPathField ||
204
+ local.showTabs ||
205
+ showExpand() ||
206
+ local.openInTab,
207
+ );
208
+
209
+ let iframeEl: HTMLIFrameElement | undefined;
210
+
211
+ // Controlled syncing: when the consumer changes the props, follow them.
212
+ createEffect(() => setTab(local.tab));
213
+ createEffect(() => setActiveFile(local.activeFile));
214
+ // `src` change → navigate. Use `on(local.src, …)` so the effect tracks ONLY
215
+ // the `src` prop — NOT `currentUrl()` — otherwise navigating away (file click,
216
+ // path edit) would re-trigger it and snap the iframe back to `src`. Skip the
217
+ // initial run: history is already seeded with `src` and the iframe binds it.
218
+ createEffect(
219
+ on(
220
+ () => local.src,
221
+ (next) => {
222
+ if (next && next !== currentUrl()) navigate(next);
223
+ },
224
+ { defer: true },
225
+ ),
226
+ );
227
+
228
+ const fileFor = (path: string | undefined): ArtifactFile | undefined =>
229
+ path === undefined ? undefined : local.files.find((f) => f.path === path);
230
+
231
+ const activeFileObj = createMemo(() => fileFor(activeFile()));
232
+ // Code applies only to text-ish files that carry source.
233
+ const hasSource = createMemo(() => {
234
+ const f = activeFileObj();
235
+ return !!f && f.type !== 'image' && f.type !== 'pdf' && typeof f.code === 'string';
236
+ });
237
+
238
+ /** Push a new entry (truncating any forward history) and load it. */
239
+ function navigate(url: string) {
240
+ if (url === currentUrl()) {
241
+ reload();
242
+ return;
243
+ }
244
+ setHistory((h) => [...h.slice(0, cursor() + 1), url]);
245
+ setCursor((c) => c + 1);
246
+ loadCurrent();
247
+ }
248
+
249
+ /** Point the iframe at the current cursor entry + emit `navigate`. */
250
+ function loadCurrent() {
251
+ const url = currentUrl();
252
+ if (iframeEl) iframeEl.src = url || 'about:blank';
253
+ local.onNavigate?.(url);
254
+ }
255
+
256
+ function selectTab(next: ArtifactTab) {
257
+ if (next === tab()) return;
258
+ setTab(next);
259
+ local.onTabChange?.(next);
260
+ }
261
+
262
+ function selectFile(path: string, file: ArtifactFile) {
263
+ setActiveFile(path);
264
+ local.onFileSelect?.(path);
265
+ navigate(resolveFileUrl(file, local.src));
266
+ }
267
+
268
+ // Toolbar actions — operate on the component's own history stack (see above).
269
+ const goBack = () => {
270
+ if (!canBack()) return;
271
+ setCursor((c) => c - 1);
272
+ loadCurrent();
273
+ };
274
+ const goForward = () => {
275
+ if (!canForward()) return;
276
+ setCursor((c) => c + 1);
277
+ loadCurrent();
278
+ };
279
+ function reload() {
280
+ const url = currentUrl();
281
+ if (iframeEl) {
282
+ // Force a real reload even when the src is unchanged.
283
+ iframeEl.src = 'about:blank';
284
+ iframeEl.src = url || 'about:blank';
285
+ }
286
+ setReloadKey((k) => k + 1); // re-render the inline PDF viewer too
287
+ local.onNavigate?.(url);
288
+ }
289
+ const goHome = () => {
290
+ if (local.src) navigate(local.src);
291
+ };
292
+
293
+ // Best-effort: if the consumer opted into `allow-same-origin`, keep the path
294
+ // field truthful as the framed doc navigates itself. Cross-origin (the secure
295
+ // default) throws and we keep our own stack.
296
+ const onIframeLoad = () => {
297
+ try {
298
+ const href = iframeEl?.contentWindow?.location.href;
299
+ if (href && href !== 'about:blank' && href !== currentUrl()) {
300
+ setHistory((h) => [...h.slice(0, cursor() + 1), href]);
301
+ setCursor((c) => c + 1);
302
+ local.onNavigate?.(href);
303
+ }
304
+ } catch {
305
+ /* cross-origin (sandboxed without allow-same-origin): keep our own url */
306
+ }
307
+ };
308
+
309
+ const submitPath = (e: Event) => {
310
+ e.preventDefault();
311
+ const input = (e.currentTarget as HTMLFormElement).elements.namedItem(
312
+ 'kai-artifact-path',
313
+ ) as HTMLInputElement | null;
314
+ if (local.readonlyPath) {
315
+ // Submit is a no-op while read-only; keep the field reflecting currentUrl.
316
+ if (input) input.value = currentUrl();
317
+ return;
318
+ }
319
+ if (input && input.value) navigate(input.value);
320
+ };
321
+
322
+ return (
323
+ <div
324
+ class={cn(
325
+ 'flex h-full w-full flex-col overflow-hidden bg-card text-card-foreground',
326
+ local.standalone && 'rounded-xl border border-border',
327
+ local.class,
328
+ )}
329
+ {...rest}
330
+ >
331
+ <Show when={showAnyToolbar()}>
332
+ <ArtifactToolbar
333
+ url={currentUrl}
334
+ tab={tab}
335
+ canBack={canBack}
336
+ canForward={canForward}
337
+ canHome={() => !!local.src}
338
+ onBack={goBack}
339
+ onForward={goForward}
340
+ onReload={reload}
341
+ onHome={goHome}
342
+ onSubmitPath={submitPath}
343
+ onTab={selectTab}
344
+ showNav={() => local.showNav}
345
+ showReload={() => local.showReload}
346
+ showHome={() => local.showHome}
347
+ showPathField={() => local.showPathField}
348
+ showTabs={() => local.showTabs}
349
+ showExpand={showExpand}
350
+ showOpenInTab={() => local.openInTab}
351
+ maximized={maximized}
352
+ onToggleMaximize={toggleMaximize}
353
+ canOpenInTab={canOpenInTab}
354
+ onOpenInTab={openInNewTab}
355
+ readonlyPath={() => local.readonlyPath}
356
+ />
357
+ </Show>
358
+ <div class="relative min-h-0 flex-1">
359
+ <Show
360
+ when={tab() === 'preview'}
361
+ fallback={
362
+ <ArtifactCode
363
+ files={local.files}
364
+ activeFile={activeFile}
365
+ activeFileObj={activeFileObj}
366
+ hasSource={hasSource}
367
+ onSelect={selectFile}
368
+ />
369
+ }
370
+ >
371
+ <Show
372
+ when={isPdfUrl(currentUrl(), local.files)}
373
+ fallback={
374
+ <ArtifactPreview
375
+ ref={(el) => (iframeEl = el)}
376
+ src={currentUrl}
377
+ sandbox={local.sandbox}
378
+ title={local.iframeTitle ?? 'Artifact preview'}
379
+ onLoad={onIframeLoad}
380
+ />
381
+ }
382
+ >
383
+ <ArtifactPdfPreview url={currentUrl()} reloadKey={reloadKey()} />
384
+ </Show>
385
+ </Show>
386
+ </div>
387
+ </div>
388
+ );
389
+ }
390
+
391
+ // --- ArtifactToolbar (internal) -------------------------------------------
392
+
393
+ interface ToolbarProps {
394
+ url: () => string;
395
+ tab: () => ArtifactTab;
396
+ canBack: () => boolean;
397
+ canForward: () => boolean;
398
+ canHome: () => boolean;
399
+ onBack: () => void;
400
+ onForward: () => void;
401
+ onReload: () => void;
402
+ onHome: () => void;
403
+ onSubmitPath: (e: Event) => void;
404
+ onTab: (tab: ArtifactTab) => void;
405
+ showNav: () => boolean;
406
+ showReload: () => boolean;
407
+ showHome: () => boolean;
408
+ showPathField: () => boolean;
409
+ showTabs: () => boolean;
410
+ showExpand: () => boolean;
411
+ showOpenInTab: () => boolean;
412
+ maximized: () => boolean;
413
+ onToggleMaximize: () => void;
414
+ canOpenInTab: () => boolean;
415
+ onOpenInTab: () => void;
416
+ readonlyPath: () => boolean;
417
+ }
418
+
419
+ function ArtifactToolbar(props: ToolbarProps): JSX.Element {
420
+ return (
421
+ <div
422
+ data-artifact-toolbar
423
+ class="flex shrink-0 items-center gap-1.5 border-b border-border bg-muted/40 px-2 py-1.5"
424
+ >
425
+ <Show when={props.showNav()}>
426
+ <Button
427
+ variant="ghost"
428
+ size="icon-sm"
429
+ aria-label="Back"
430
+ disabled={!props.canBack()}
431
+ onClick={() => props.onBack()}
432
+ >
433
+ <ArrowLeft size={16} aria-hidden="true" />
434
+ </Button>
435
+ <Button
436
+ variant="ghost"
437
+ size="icon-sm"
438
+ aria-label="Forward"
439
+ disabled={!props.canForward()}
440
+ onClick={() => props.onForward()}
441
+ >
442
+ <ArrowRight size={16} aria-hidden="true" />
443
+ </Button>
444
+ </Show>
445
+ <Show when={props.showReload()}>
446
+ <Button variant="ghost" size="icon-sm" aria-label="Reload" onClick={() => props.onReload()}>
447
+ <RotateCw size={15} aria-hidden="true" />
448
+ </Button>
449
+ </Show>
450
+ <Show when={props.showHome()}>
451
+ <Button
452
+ variant="ghost"
453
+ size="icon-sm"
454
+ aria-label="Home"
455
+ disabled={!props.canHome()}
456
+ onClick={() => props.onHome()}
457
+ >
458
+ <House size={15} aria-hidden="true" />
459
+ </Button>
460
+ </Show>
461
+ <Show when={props.showPathField()}>
462
+ <form class="min-w-0 flex-1" onSubmit={(e) => props.onSubmitPath(e)}>
463
+ <label class="sr-only" for="kai-artifact-path">
464
+ Address
465
+ </label>
466
+ <input
467
+ id="kai-artifact-path"
468
+ name="kai-artifact-path"
469
+ type="text"
470
+ spellcheck={false}
471
+ autocomplete="off"
472
+ readonly={props.readonlyPath() || undefined}
473
+ aria-readonly={props.readonlyPath() ? 'true' : undefined}
474
+ value={props.url()}
475
+ class={cn(
476
+ 'h-7 w-full rounded-md border border-border px-2.5 text-xs text-foreground font-mono outline-none',
477
+ props.readonlyPath()
478
+ ? 'bg-muted/40 cursor-default'
479
+ : 'bg-background focus-visible:ring-2 focus-visible:ring-ring',
480
+ )}
481
+ placeholder="Enter a path or URL…"
482
+ />
483
+ </form>
484
+ </Show>
485
+ <Show when={!props.showPathField()}>
486
+ <div class="flex-1" aria-hidden="true" />
487
+ </Show>
488
+ <Show when={props.showExpand()}>
489
+ <Button
490
+ variant="ghost"
491
+ size="icon-sm"
492
+ aria-label={props.maximized() ? 'Collapse' : 'Expand'}
493
+ aria-expanded={props.maximized()}
494
+ onClick={() => props.onToggleMaximize()}
495
+ >
496
+ <Show when={props.maximized()} fallback={<Maximize2 size={15} aria-hidden="true" />}>
497
+ <Minimize2 size={15} aria-hidden="true" />
498
+ </Show>
499
+ </Button>
500
+ </Show>
501
+ <Show when={props.showOpenInTab()}>
502
+ <Button
503
+ variant="ghost"
504
+ size="icon-sm"
505
+ aria-label="Open in new tab"
506
+ disabled={!props.canOpenInTab()}
507
+ onClick={() => props.onOpenInTab()}
508
+ >
509
+ <ExternalLink size={15} aria-hidden="true" />
510
+ </Button>
511
+ </Show>
512
+ <Show when={props.showTabs()}>
513
+ <div
514
+ role="tablist"
515
+ aria-label="View"
516
+ class="flex shrink-0 items-center gap-0.5 rounded-md bg-muted p-0.5"
517
+ >
518
+ <SegmentButton
519
+ label="Preview"
520
+ icon={<Eye size={14} aria-hidden="true" />}
521
+ selected={props.tab() === 'preview'}
522
+ onClick={() => props.onTab('preview')}
523
+ />
524
+ <SegmentButton
525
+ label="Code"
526
+ icon={<CodeIcon size={14} aria-hidden="true" />}
527
+ selected={props.tab() === 'code'}
528
+ onClick={() => props.onTab('code')}
529
+ />
530
+ </div>
531
+ </Show>
532
+ </div>
533
+ );
534
+ }
535
+
536
+ function SegmentButton(props: {
537
+ label: string;
538
+ icon: JSX.Element;
539
+ selected: boolean;
540
+ onClick: () => void;
541
+ }): JSX.Element {
542
+ return (
543
+ <button
544
+ type="button"
545
+ role="tab"
546
+ aria-selected={props.selected}
547
+ class={cn(
548
+ 'inline-flex h-6 items-center gap-1.5 rounded px-2 text-xs font-medium transition-colors outline-none',
549
+ 'focus-visible:ring-2 focus-visible:ring-ring',
550
+ props.selected
551
+ ? 'bg-background text-foreground shadow-sm'
552
+ : 'text-muted-foreground hover:text-foreground',
553
+ )}
554
+ onClick={() => props.onClick()}
555
+ >
556
+ {props.icon}
557
+ {props.label}
558
+ </button>
559
+ );
560
+ }
561
+
562
+ // --- ArtifactPreview (internal) -------------------------------------------
563
+
564
+ interface PreviewProps {
565
+ ref: (el: HTMLIFrameElement) => void;
566
+ src: () => string;
567
+ sandbox: string;
568
+ title: string;
569
+ onLoad: () => void;
570
+ }
571
+
572
+ function ArtifactPreview(props: PreviewProps): JSX.Element {
573
+ return (
574
+ <iframe
575
+ ref={props.ref}
576
+ src={props.src() || 'about:blank'}
577
+ sandbox={props.sandbox}
578
+ title={props.title}
579
+ class="absolute inset-0 h-full w-full border-0 bg-white"
580
+ onLoad={() => props.onLoad()}
581
+ />
582
+ );
583
+ }
584
+
585
+ // --- ArtifactCode (internal) ----------------------------------------------
586
+
587
+ interface CodeProps {
588
+ files: ArtifactFile[];
589
+ activeFile: () => string | undefined;
590
+ activeFileObj: () => ArtifactFile | undefined;
591
+ hasSource: () => boolean;
592
+ onSelect: (path: string, file: ArtifactFile) => void;
593
+ }
594
+
595
+ function ArtifactCode(props: CodeProps): JSX.Element {
596
+ return (
597
+ <div class="absolute inset-0 flex">
598
+ <div class="w-56 shrink-0 overflow-auto border-r border-border bg-muted/20 py-1.5 scrollbar-thin">
599
+ <FileTree
600
+ files={props.files}
601
+ activeFile={props.activeFile()}
602
+ onSelect={(path, file) => props.onSelect(path, file)}
603
+ />
604
+ </div>
605
+ <div class="min-w-0 flex-1 overflow-auto scrollbar-thin">
606
+ <Show
607
+ when={props.activeFileObj()}
608
+ fallback={
609
+ <div class="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
610
+ Select a file to view its source.
611
+ </div>
612
+ }
613
+ >
614
+ <Show
615
+ when={props.hasSource()}
616
+ fallback={
617
+ <div class="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
618
+ No source — this file ({props.activeFileObj()!.type ?? 'binary'}) has no code view.
619
+ </div>
620
+ }
621
+ >
622
+ <CodeBlock class="h-full rounded-none border-0">
623
+ <CodeBlockCode
624
+ code={props.activeFileObj()!.code ?? ''}
625
+ language={props.activeFileObj()!.language}
626
+ />
627
+ </CodeBlock>
628
+ </Show>
629
+ </Show>
630
+ </div>
631
+ </div>
632
+ );
633
+ }
634
+
635
+ // --- ArtifactPdfFallback (internal) ---------------------------------------
636
+
637
+ /** Shown when inline PDF rendering is disabled or fails (CORS / load / parse). */
638
+ function ArtifactPdfFallback(props: { url: string }): JSX.Element {
639
+ const name = () => {
640
+ const path = props.url.split(/[?#]/)[0];
641
+ return path.slice(path.lastIndexOf('/') + 1) || 'document.pdf';
642
+ };
643
+ const linkClass =
644
+ 'inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-3 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
645
+ return (
646
+ <div
647
+ role="region"
648
+ aria-label="PDF preview unavailable"
649
+ class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-card p-6 text-center"
650
+ >
651
+ <FileText size={40} class="text-muted-foreground" aria-hidden="true" />
652
+ <div class="text-sm font-medium text-foreground">{name()}</div>
653
+ <div class="text-xs text-muted-foreground">Can't preview this PDF inline.</div>
654
+ <div class="flex flex-wrap items-center justify-center gap-2">
655
+ <a class={linkClass} href={props.url} target="_blank" rel="noopener noreferrer">
656
+ Open in new tab
657
+ <ExternalLink size={13} aria-hidden="true" />
658
+ </a>
659
+ <a class={linkClass} href={props.url} download="">
660
+ <Download size={13} aria-hidden="true" />
661
+ Download
662
+ </a>
663
+ </div>
664
+ </div>
665
+ );
666
+ }
667
+
668
+ // --- ArtifactPdfPreview (internal) ----------------------------------------
669
+
670
+ /**
671
+ * Renders a PDF inline via pdf.js (loaded on demand). Four states:
672
+ * disabled → fallback (no network); loading → spinner; success → stacked
673
+ * fit-width canvases; error (load/CORS/parse) → fallback card. Re-renders when
674
+ * the url or `reloadKey` changes, and (debounced) when the panel resizes.
675
+ */
676
+ function ArtifactPdfPreview(props: { url: string; reloadKey: number }): JSX.Element {
677
+ const [state, setState] = createSignal<'loading' | 'success' | 'error'>('loading');
678
+ let container: HTMLDivElement | undefined;
679
+ let token = 0;
680
+ let resizeTimer: ReturnType<typeof setTimeout> | undefined;
681
+
682
+ const renderNow = async () => {
683
+ if (!isPdfPreviewEnabled() || !container) {
684
+ setState('error');
685
+ return;
686
+ }
687
+ const mine = ++token;
688
+ setState('loading');
689
+ try {
690
+ const width = container.clientWidth || 600;
691
+ await renderPdfInto(props.url, container, width);
692
+ if (mine === token) setState('success');
693
+ } catch {
694
+ if (mine === token) setState('error');
695
+ }
696
+ };
697
+
698
+ createEffect(
699
+ on(
700
+ () => [props.url, props.reloadKey] as const,
701
+ () => {
702
+ void renderNow();
703
+ },
704
+ ),
705
+ );
706
+
707
+ onMount(() => {
708
+ if (!container || typeof ResizeObserver === 'undefined') return;
709
+ const ro = new ResizeObserver(() => {
710
+ if (state() !== 'success') return;
711
+ clearTimeout(resizeTimer);
712
+ resizeTimer = setTimeout(() => void renderNow(), 200);
713
+ });
714
+ ro.observe(container);
715
+ onCleanup(() => {
716
+ ro.disconnect();
717
+ clearTimeout(resizeTimer);
718
+ token++; // cancel any in-flight render
719
+ });
720
+ });
721
+
722
+ return (
723
+ <div class="absolute inset-0">
724
+ {/* Always present so clientWidth is the real panel width. */}
725
+ <div
726
+ ref={(el) => (container = el)}
727
+ role="region"
728
+ aria-label="PDF preview"
729
+ aria-busy={state() === 'loading'}
730
+ tabindex="0"
731
+ class="absolute inset-0 flex flex-col items-center gap-3 overflow-auto bg-muted/20 p-3 scrollbar-thin focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
732
+ />
733
+ <Show when={isPdfPreviewEnabled() && state() === 'loading'}>
734
+ <div class="pointer-events-none absolute inset-0 flex items-center justify-center">
735
+ <Loader variant="circular" size="md" />
736
+ </div>
737
+ </Show>
738
+ <Show when={!isPdfPreviewEnabled() || state() === 'error'}>
739
+ <ArtifactPdfFallback url={props.url} />
740
+ </Show>
741
+ </div>
742
+ );
743
+ }