@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,21 @@
1
+ import { createSignal, type Accessor } from 'solid-js';
2
+
3
+ /**
4
+ * The controlled/uncontrolled state pattern, as a reusable signal.
5
+ *
6
+ * `controlled` is an accessor for the controlling prop: when it returns a value,
7
+ * that value wins (controlled mode); when it returns `undefined`, the component
8
+ * manages its own state seeded from `initial` (uncontrolled mode). The returned
9
+ * setter always writes the internal value — harmless (masked) while controlled,
10
+ * authoritative while uncontrolled — so a single call site works in both modes.
11
+ *
12
+ * Mirrors `props.value ?? internal()` used elsewhere in the kit, without effects.
13
+ */
14
+ export function createControllableSignal(
15
+ controlled: Accessor<boolean | undefined>,
16
+ initial: boolean,
17
+ ): [Accessor<boolean>, (value: boolean) => void] {
18
+ const [internal, setInternal] = createSignal(initial);
19
+ const value: Accessor<boolean> = () => controlled() ?? internal();
20
+ return [value, (v: boolean) => setInternal(v)];
21
+ }
@@ -0,0 +1,254 @@
1
+ // src/primitives/embed-providers.ts
2
+ // Pure provider resolution for <kai-embed>: map an EmbedCardData → an embeddable
3
+ // player URL + poster + iframe sandbox/allow. Covers youtube (privacy-enhanced
4
+ // youtube-nocookie), vimeo (dnt=1), and generic (https-only, ORIGIN-ALLOWLISTED).
5
+ // No network, no DOM. See docs/superpowers/specs/2026-06-13-kc-link-embed-cards-design.md.
6
+ import type { CardEnvelope } from './card-contract';
7
+
8
+ /** Media provider for an embed card. */
9
+ export type EmbedProvider = 'youtube' | 'vimeo' | 'generic';
10
+
11
+ /** Lazy media-embed payload (YouTube / Vimeo / generic player URL). */
12
+ export interface EmbedCardData {
13
+ /** Media provider. 'generic' frames `url` directly. */
14
+ provider: EmbedProvider;
15
+ /** Provider video id (youtube/vimeo) when not parsing from `url`. */
16
+ id?: string;
17
+ /** Full media/watch/embed URL. */
18
+ url?: string;
19
+ /** Accessible iframe title + poster label. */
20
+ title?: string;
21
+ /** Thumbnail before play; derived for youtube/vimeo when omitted. */
22
+ poster?: string;
23
+ /** Start offset, seconds. */
24
+ start?: number;
25
+ /** Player aspect ratio. Default '16:9'. */
26
+ aspectRatio?: '16:9' | '4:3' | '1:1' | '9:16';
27
+ }
28
+
29
+ /** The full envelope an agent/server emits for an embed card. */
30
+ export type EmbedCardEnvelope = CardEnvelope<'embed', EmbedCardData>;
31
+
32
+ /** The `type` discriminator for embed cards. */
33
+ export const EMBED_CARD_TYPE = 'embed' as const;
34
+
35
+ export interface ResolvedEmbed {
36
+ /** The iframe src loaded on play (already including autoplay/start params). */
37
+ embedUrl: string;
38
+ /** Poster/thumbnail to show before play (derived when not supplied). */
39
+ posterUrl?: string;
40
+ /** sandbox attribute for the player iframe. */
41
+ sandbox: string;
42
+ /** allow attribute (fullscreen, encrypted-media, picture-in-picture, …). */
43
+ allow: string;
44
+ }
45
+
46
+ // A provider player NEEDS allow-scripts + allow-same-origin (its OWN origin) to run.
47
+ // That is safe here because the framed origin is a KNOWN, trusted video provider on a
48
+ // DIFFERENT origin than the host — same-origin policy still isolates the host page from
49
+ // the provider. allow-popups(-to-escape-sandbox) lets "watch on YouTube" work. The
50
+ // `allow` attribute (not sandbox) governs autoplay/fullscreen/encrypted-media.
51
+ // Contrast <kai-artifact> (allow-scripts allow-forms, NO allow-same-origin) which frames
52
+ // arbitrary consumer HTML and so trusts nothing.
53
+ const PLAYER_SANDBOX =
54
+ 'allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox';
55
+ const PLAYER_ALLOW =
56
+ 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen';
57
+
58
+ // --- generic origin allowlist (security decision) -------------------------
59
+ //
60
+ // A `generic` embed frames an ARBITRARY https URL. An agent-supplied generic URL is a
61
+ // supply-chain risk, so it is REJECTED unless the app has explicitly allowlisted its
62
+ // origin. The allowlist defaults to EMPTY: out of the box, `generic` embeds are blocked.
63
+ const allowedGenericOrigins = new Set<string>();
64
+
65
+ /**
66
+ * App opt-in: allow `generic` embeds whose origin is in this list. Origins are
67
+ * normalized via the URL parser (scheme + host + port). Only https origins are
68
+ * accepted (a non-https origin is ignored). Call once at app startup.
69
+ */
70
+ export function configureEmbedAllowlist(origins: string[]): void {
71
+ for (const o of origins) {
72
+ const origin = normalizeOrigin(o);
73
+ if (origin) allowedGenericOrigins.add(origin);
74
+ }
75
+ }
76
+
77
+ /** True when `url`'s origin has been allowlisted for `generic` embeds. */
78
+ export function isGenericOriginAllowed(url: string): boolean {
79
+ const origin = originOf(url);
80
+ return origin !== undefined && allowedGenericOrigins.has(origin);
81
+ }
82
+
83
+ /** Test-only: clear the generic allowlist so tests stay isolated. */
84
+ export function __resetEmbedAllowlistForTests(): void {
85
+ allowedGenericOrigins.clear();
86
+ }
87
+
88
+ /** Normalize an allowlist entry (a URL or bare origin) to a canonical https origin, or undefined. */
89
+ function normalizeOrigin(input: string): string | undefined {
90
+ const origin = originOf(input) ?? originOf(`https://${input}`);
91
+ if (!origin) return undefined;
92
+ return origin.startsWith('https://') ? origin : undefined;
93
+ }
94
+
95
+ /** The `scheme://host[:port]` origin of a URL, or undefined when unparseable. */
96
+ function originOf(url: string): string | undefined {
97
+ try {
98
+ return new URL(url).origin;
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ }
103
+
104
+ // --- id parsing -----------------------------------------------------------
105
+
106
+ /** Extract a YouTube id from a watch / youtu.be / shorts / embed URL. */
107
+ export function parseYouTubeId(url: string): string | undefined {
108
+ let parsed: URL;
109
+ try {
110
+ parsed = new URL(url);
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ const host = parsed.hostname.replace(/^www\./, '');
115
+ if (host === 'youtu.be') {
116
+ return cleanId(parsed.pathname.slice(1));
117
+ }
118
+ if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtube-nocookie.com') {
119
+ const v = parsed.searchParams.get('v');
120
+ if (v) return cleanId(v);
121
+ // /shorts/<id>, /embed/<id>, /v/<id>
122
+ const m = parsed.pathname.match(/^\/(?:shorts|embed|v)\/([^/?#]+)/);
123
+ if (m) return cleanId(m[1]);
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ /** Extract a Vimeo id from a vimeo.com/<id> (or player.vimeo.com/video/<id>) URL. */
129
+ export function parseVimeoId(url: string): string | undefined {
130
+ let parsed: URL;
131
+ try {
132
+ parsed = new URL(url);
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ const host = parsed.hostname.replace(/^www\./, '');
137
+ if (host !== 'vimeo.com' && host !== 'player.vimeo.com') return undefined;
138
+ const m = parsed.pathname.match(/(?:^|\/)(\d+)(?:\/|$)/);
139
+ return m ? m[1] : undefined;
140
+ }
141
+
142
+ /** A provider video id must be the [A-Za-z0-9_-] alphabet (matches the schema pattern). */
143
+ function cleanId(id: string): string | undefined {
144
+ return /^[A-Za-z0-9_-]+$/.test(id) ? id : undefined;
145
+ }
146
+
147
+ // --- resolution -----------------------------------------------------------
148
+
149
+ /**
150
+ * Resolve an EmbedCardData to an embeddable player URL + poster + sandbox/allow.
151
+ * Throws (with a human message) on a missing/invalid provider id, a non-https
152
+ * generic URL, or a generic origin not in the app allowlist — the card turns these
153
+ * into an inline error + an `error` event.
154
+ */
155
+ export function resolveEmbed(data: EmbedCardData): ResolvedEmbed {
156
+ const start = data.start ? `&start=${data.start}` : '';
157
+ switch (data.provider) {
158
+ case 'youtube': {
159
+ const id = data.id ?? (data.url ? parseYouTubeId(data.url) : undefined);
160
+ if (!id) throw new Error('youtube embed: missing or unparseable id/url');
161
+ return {
162
+ embedUrl: `https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0${start}`,
163
+ posterUrl: data.poster ?? `https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
164
+ sandbox: PLAYER_SANDBOX,
165
+ allow: PLAYER_ALLOW,
166
+ };
167
+ }
168
+ case 'vimeo': {
169
+ const id = data.id ?? (data.url ? parseVimeoId(data.url) : undefined);
170
+ if (!id) throw new Error('vimeo embed: missing or unparseable id/url');
171
+ return {
172
+ embedUrl: `https://player.vimeo.com/video/${id}?autoplay=1&dnt=1${
173
+ data.start ? `#t=${data.start}s` : ''
174
+ }`,
175
+ // Vimeo has no static thumbnail URL; rely on a supplied poster (or placeholder).
176
+ posterUrl: data.poster,
177
+ sandbox: PLAYER_SANDBOX,
178
+ allow: PLAYER_ALLOW,
179
+ };
180
+ }
181
+ case 'generic': {
182
+ if (!data.url) throw new Error('generic embed: missing url');
183
+ assertHttpsEmbeddable(data.url);
184
+ if (!isGenericOriginAllowed(data.url)) {
185
+ throw new Error(
186
+ `generic embed: origin not allowlisted — call configureEmbedAllowlist([...]) to permit ${
187
+ originOf(data.url) ?? data.url
188
+ }`,
189
+ );
190
+ }
191
+ return { embedUrl: data.url, posterUrl: data.poster, sandbox: PLAYER_SANDBOX, allow: PLAYER_ALLOW };
192
+ }
193
+ }
194
+ }
195
+
196
+ /** Reject non-https or javascript:/data: player URLs (defense in depth). */
197
+ function assertHttpsEmbeddable(url: string): void {
198
+ let protocol: string;
199
+ try {
200
+ protocol = new URL(url).protocol;
201
+ } catch {
202
+ throw new Error(`generic embed: invalid url "${url}"`);
203
+ }
204
+ if (protocol !== 'https:') {
205
+ throw new Error(`generic embed: only https player URLs are allowed (got ${protocol})`);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * The canonical "watch on the provider" URL for the optional fallback affordance
211
+ * (emitted as `open`/target:'tab' when an embed is blocked). Returns `undefined`
212
+ * when there is nothing useful to link to.
213
+ */
214
+ export function watchUrl(data: EmbedCardData): string | undefined {
215
+ switch (data.provider) {
216
+ case 'youtube': {
217
+ const id = data.id ?? (data.url ? parseYouTubeId(data.url) : undefined);
218
+ return id ? `https://www.youtube.com/watch?v=${id}` : data.url;
219
+ }
220
+ case 'vimeo': {
221
+ const id = data.id ?? (data.url ? parseVimeoId(data.url) : undefined);
222
+ return id ? `https://vimeo.com/${id}` : data.url;
223
+ }
224
+ case 'generic':
225
+ return data.url;
226
+ }
227
+ }
228
+
229
+ /** Human-readable provider label for the fallback affordance ("Open on YouTube"). */
230
+ export function providerLabel(provider: EmbedProvider): string {
231
+ switch (provider) {
232
+ case 'youtube':
233
+ return 'YouTube';
234
+ case 'vimeo':
235
+ return 'Vimeo';
236
+ case 'generic':
237
+ return 'site';
238
+ }
239
+ }
240
+
241
+ /** CSS aspect-ratio value for a card's aspectRatio (default 16:9). */
242
+ export function aspectRatioValue(aspectRatio: EmbedCardData['aspectRatio']): string {
243
+ switch (aspectRatio) {
244
+ case '4:3':
245
+ return '4 / 3';
246
+ case '1:1':
247
+ return '1 / 1';
248
+ case '9:16':
249
+ return '9 / 16';
250
+ case '16:9':
251
+ default:
252
+ return '16 / 9';
253
+ }
254
+ }
@@ -0,0 +1,157 @@
1
+ // On-demand code highlighter built on Shiki's fine-grained core.
2
+ //
3
+ // Nothing here loads until `highlight()` is first called with highlighting
4
+ // enabled — so a component set that never renders a code block ships and runs
5
+ // with ZERO Shiki bytes. When a code block does appear, only the core, the
6
+ // JavaScript regex engine (no WASM), the one theme, and the one language grammar
7
+ // it needs are dynamically imported, each as its own small lazy chunk.
8
+ //
9
+ // Hosts extend or disable this via `configureCodeHighlighting()`.
10
+
11
+ import type { HighlighterCore } from 'shiki/core';
12
+
13
+ type Loader = () => Promise<unknown>;
14
+
15
+ /**
16
+ * Minimal default language set — each a separate lazy chunk, loaded only on use.
17
+ * Kept deliberately small to keep the bundle lean; hosts add more at runtime via
18
+ * `configureCodeHighlighting({ languages })` (no rebuild needed — see that fn).
19
+ */
20
+ const DEFAULT_LANGUAGES: Record<string, Loader> = {
21
+ bash: () => import('@shikijs/langs/bash'),
22
+ javascript: () => import('@shikijs/langs/javascript'),
23
+ typescript: () => import('@shikijs/langs/typescript'),
24
+ tsx: () => import('@shikijs/langs/tsx'),
25
+ html: () => import('@shikijs/langs/html'),
26
+ css: () => import('@shikijs/langs/css'),
27
+ json: () => import('@shikijs/langs/json'),
28
+ vue: () => import('@shikijs/langs/vue'),
29
+ svelte: () => import('@shikijs/langs/svelte'),
30
+ };
31
+
32
+ const DEFAULT_THEMES: Record<string, Loader> = {
33
+ 'github-dark-dimmed': () => import('@shikijs/themes/github-dark-dimmed'),
34
+ 'github-light': () => import('@shikijs/themes/github-light'),
35
+ };
36
+
37
+ const DEFAULT_ALIASES: Record<string, string> = {
38
+ js: 'javascript',
39
+ ts: 'typescript',
40
+ jsx: 'tsx',
41
+ sh: 'bash',
42
+ shell: 'bash',
43
+ };
44
+
45
+ const FALLBACK_THEME = 'github-dark-dimmed';
46
+
47
+ export interface CodeHighlightingOptions {
48
+ /** Turn highlighting on/off globally. When false, code renders as plain text. */
49
+ enabled?: boolean;
50
+ /** Register/override language loaders, e.g. `{ ruby: () => import('@shikijs/langs/ruby') }`. */
51
+ languages?: Record<string, Loader>;
52
+ /** Register/override theme loaders. */
53
+ themes?: Record<string, Loader>;
54
+ /** Map short names to canonical language keys, e.g. `{ vue: 'html' }`. */
55
+ aliases?: Record<string, string>;
56
+ }
57
+
58
+ let enabled = true;
59
+ let langLoaders: Record<string, Loader> = { ...DEFAULT_LANGUAGES };
60
+ let themeLoaders: Record<string, Loader> = { ...DEFAULT_THEMES };
61
+ let aliases: Record<string, string> = { ...DEFAULT_ALIASES };
62
+
63
+ let highlighterPromise: Promise<HighlighterCore> | null = null;
64
+ const loadedLangs = new Set<string>();
65
+ const loadedThemes = new Set<string>();
66
+
67
+ function getHighlighter(): Promise<HighlighterCore> {
68
+ if (!highlighterPromise) {
69
+ highlighterPromise = (async () => {
70
+ const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] = await Promise.all([
71
+ import('shiki/core'),
72
+ import('shiki/engine/javascript'),
73
+ ]);
74
+ return createHighlighterCore({
75
+ themes: [],
76
+ langs: [],
77
+ engine: createJavaScriptRegexEngine(),
78
+ });
79
+ })();
80
+ }
81
+ return highlighterPromise;
82
+ }
83
+
84
+ function resolveLang(lang: string): string {
85
+ return aliases[lang] ?? lang;
86
+ }
87
+
88
+ async function ensureLang(hl: HighlighterCore, lang: string): Promise<boolean> {
89
+ const name = resolveLang(lang);
90
+ if (loadedLangs.has(name)) return true;
91
+ const loader = langLoaders[name];
92
+ if (!loader) return false;
93
+ await hl.loadLanguage(loader() as never);
94
+ loadedLangs.add(name);
95
+ return true;
96
+ }
97
+
98
+ async function ensureTheme(hl: HighlighterCore, theme: string): Promise<boolean> {
99
+ if (loadedThemes.has(theme)) return true;
100
+ const loader = themeLoaders[theme];
101
+ if (!loader) return false;
102
+ await hl.loadTheme(loader() as never);
103
+ loadedThemes.add(theme);
104
+ return true;
105
+ }
106
+
107
+ function escapeHtml(s: string): string {
108
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
109
+ }
110
+
111
+ function plain(code: string): string {
112
+ return `<pre><code>${escapeHtml(code)}</code></pre>`;
113
+ }
114
+
115
+ /**
116
+ * Highlight `code` as `lang` with `theme`, returning HTML. Loads only what's
117
+ * needed, on demand. Falls back to escaped plain `<pre><code>` when highlighting
118
+ * is disabled, the language has no registered loader, or anything fails.
119
+ */
120
+ export async function highlight(code: string, lang: string, theme: string): Promise<string> {
121
+ if (!enabled || !code) return plain(code);
122
+ try {
123
+ const hl = await getHighlighter();
124
+ const hasLang = await ensureLang(hl, lang);
125
+ if (!hasLang) return plain(code);
126
+ const useTheme = (await ensureTheme(hl, theme))
127
+ ? theme
128
+ : (await ensureTheme(hl, FALLBACK_THEME)) ? FALLBACK_THEME : null;
129
+ if (!useTheme) return plain(code);
130
+ return hl.codeToHtml(code, { lang: resolveLang(lang), theme: useTheme });
131
+ } catch {
132
+ return plain(code);
133
+ }
134
+ }
135
+
136
+ /** Register additional languages/themes/aliases, or disable highlighting entirely. */
137
+ export function configureCodeHighlighting(options: CodeHighlightingOptions): void {
138
+ if (options.enabled !== undefined) enabled = options.enabled;
139
+ if (options.languages) langLoaders = { ...langLoaders, ...options.languages };
140
+ if (options.themes) themeLoaders = { ...themeLoaders, ...options.themes };
141
+ if (options.aliases) aliases = { ...aliases, ...options.aliases };
142
+ }
143
+
144
+ export function isCodeHighlightingEnabled(): boolean {
145
+ return enabled;
146
+ }
147
+
148
+ /** Test helper — reset the singleton and registries to defaults. */
149
+ export function __resetCodeHighlightingForTests(): void {
150
+ enabled = true;
151
+ langLoaders = { ...DEFAULT_LANGUAGES };
152
+ themeLoaders = { ...DEFAULT_THEMES };
153
+ aliases = { ...DEFAULT_ALIASES };
154
+ highlighterPromise = null;
155
+ loadedLangs.clear();
156
+ loadedThemes.clear();
157
+ }
@@ -0,0 +1,87 @@
1
+ // src/primitives/link-preview.ts
2
+ // The optional, app-supplied bare-URL → metadata hook for <kai-link-preview>, plus the
3
+ // `link` card's data type. The card stays PURE: it renders from supplied metadata
4
+ // and never touches the network. CORS forbids reading cross-origin HTML in the
5
+ // browser, so there is intentionally NO built-in network implementation here — an
6
+ // app opts in with `configureLinkPreview({ fetchMetadata })` pointing at its OWN
7
+ // backend/proxy. See docs/superpowers/specs/2026-06-13-kc-link-embed-cards-design.md.
8
+ import type { CardEnvelope } from './card-contract';
9
+
10
+ /** Rich link / Open-Graph preview payload. The card renders from this; it never fetches. */
11
+ export interface LinkPreviewData {
12
+ /** Canonical destination; opened via the contract `open` verb. */
13
+ url: string;
14
+ /** og:title — falls back to the domain. */
15
+ title?: string;
16
+ /** og:description — clamped to 3 lines. */
17
+ description?: string;
18
+ /** og:image — degrades gracefully when missing/broken. */
19
+ image?: string;
20
+ /** Alt for the preview image (defaults to title / decorative). */
21
+ imageAlt?: string;
22
+ /** Site favicon. */
23
+ favicon?: string;
24
+ /** Display domain; derived from `url` when omitted. */
25
+ domain?: string;
26
+ /** og:site_name; preferred over `domain` when present. */
27
+ siteName?: string;
28
+ }
29
+
30
+ /** The full envelope an agent/server emits for a link preview card. */
31
+ export type LinkPreviewEnvelope = CardEnvelope<'link', LinkPreviewData>;
32
+
33
+ /** The `type` discriminator for link preview cards. */
34
+ export const LINK_PREVIEW_TYPE = 'link' as const;
35
+
36
+ /** App-supplied resolver: a bare URL → (partial) OG metadata. Usually hits YOUR backend. */
37
+ export type LinkMetadataFetcher = (url: string) => Promise<Partial<LinkPreviewData>>;
38
+
39
+ let fetcher: LinkMetadataFetcher | undefined;
40
+
41
+ /**
42
+ * App opt-in: supply a function (usually hitting YOUR backend/proxy) that resolves
43
+ * a bare URL to OG metadata. CORS forbids reading cross-origin HTML in the browser,
44
+ * so there is intentionally NO default network implementation.
45
+ */
46
+ export function configureLinkPreview(opts: { fetchMetadata: LinkMetadataFetcher }): void {
47
+ fetcher = opts.fetchMetadata;
48
+ }
49
+
50
+ /** True when an app has registered a fetcher (the bare-URL path is available). */
51
+ export function hasLinkPreviewFetcher(): boolean {
52
+ return fetcher !== undefined;
53
+ }
54
+
55
+ /**
56
+ * Used by LinkPreview ONLY when the envelope lacks metadata AND a fetcher is set.
57
+ * Returns the merged metadata or throws (card shows its fallback/error state).
58
+ */
59
+ export async function resolveLinkMetadata(url: string): Promise<Partial<LinkPreviewData>> {
60
+ if (!fetcher) throw new Error('No link-preview fetcher configured');
61
+ return fetcher(url);
62
+ }
63
+
64
+ /** Test-only: clear the configured fetcher so tests stay isolated. */
65
+ export function __resetLinkPreviewForTests(): void {
66
+ fetcher = undefined;
67
+ }
68
+
69
+ /** Derive a clean display domain from a URL (strips a leading `www.`). `undefined` if unparseable. */
70
+ export function deriveDomain(url: string): string | undefined {
71
+ try {
72
+ return new URL(url).hostname.replace(/^www\./, '');
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ const RENDERABLE_SCHEMES = ['http:', 'https:'];
79
+
80
+ /** True when `url` is a syntactically valid http(s) URL (the only renderable link schemes). */
81
+ export function isRenderableLink(url: string): boolean {
82
+ try {
83
+ return RENDERABLE_SCHEMES.includes(new URL(url).protocol);
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
@@ -0,0 +1,121 @@
1
+ // src/primitives/pdf-preview.ts
2
+ // On-demand PDF renderer built on pdf.js, loaded from a CDN only when a PDF is
3
+ // actually shown — so a component set that never renders a PDF ships and runs with
4
+ // ZERO pdf.js bytes (the ~482 KB gzip library is never fetched). When a PDF does
5
+ // appear, pdf.js is dynamically imported from the pinned CDN build and every page
6
+ // is rendered to a <canvas>. Hosts override the loader (self-host / CSP / pin) or
7
+ // disable inline rendering via configurePdfPreview(). Mirrors highlighter.ts.
8
+
9
+ /** Minimal shape of the pdf.js module + objects we rely on. */
10
+ export interface PdfViewportLike {
11
+ width: number;
12
+ height: number;
13
+ }
14
+ export interface PdfPageLike {
15
+ getViewport(opts: { scale: number }): PdfViewportLike;
16
+ render(opts: {
17
+ canvasContext: CanvasRenderingContext2D | null;
18
+ viewport: PdfViewportLike;
19
+ }): { promise: Promise<void> };
20
+ }
21
+ export interface PdfDocumentLike {
22
+ numPages: number;
23
+ getPage(n: number): Promise<PdfPageLike>;
24
+ }
25
+ export interface PdfjsLike {
26
+ getDocument(src: { url: string }): { promise: Promise<PdfDocumentLike> };
27
+ GlobalWorkerOptions: { workerSrc: string };
28
+ }
29
+
30
+ export interface PdfPreviewOptions {
31
+ /** Turn inline PDF rendering on/off globally. When false, always show the card. */
32
+ enabled?: boolean;
33
+ /** Override the pdf.js module loader (self-host / CSP / version pin). */
34
+ load?: () => Promise<PdfjsLike>;
35
+ /** Worker URL. Default = the matching jsDelivr worker for the pinned version. */
36
+ workerSrc?: string;
37
+ }
38
+
39
+ /** Pinned, exact (reproducible) — NOT a range. */
40
+ const PDFJS_VERSION = '6.0.227';
41
+ const CDN = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${PDFJS_VERSION}/build`;
42
+ const DEFAULT_LOAD = (): Promise<PdfjsLike> =>
43
+ // Template literal + @vite-ignore keeps this a runtime fetch (never bundled).
44
+ import(/* @vite-ignore */ `${CDN}/pdf.min.mjs`) as Promise<PdfjsLike>;
45
+ const DEFAULT_WORKER_SRC = `${CDN}/pdf.worker.min.mjs`;
46
+
47
+ let enabled = true;
48
+ let loader: () => Promise<PdfjsLike> = DEFAULT_LOAD;
49
+ let workerSrc = DEFAULT_WORKER_SRC;
50
+
51
+ export function configurePdfPreview(options: PdfPreviewOptions): void {
52
+ if (options.enabled !== undefined) enabled = options.enabled;
53
+ if (options.workerSrc !== undefined) workerSrc = options.workerSrc;
54
+ if (options.load !== undefined) {
55
+ loader = options.load;
56
+ pdfjsPromise = null;
57
+ }
58
+ }
59
+
60
+ export function isPdfPreviewEnabled(): boolean {
61
+ return enabled;
62
+ }
63
+
64
+ export function __resetPdfPreviewForTests(): void {
65
+ enabled = true;
66
+ loader = DEFAULT_LOAD;
67
+ workerSrc = DEFAULT_WORKER_SRC;
68
+ pdfjsPromise = null;
69
+ }
70
+
71
+ let pdfjsPromise: Promise<PdfjsLike> | null = null;
72
+
73
+ /** Load pdf.js once (singleton); set the worker src. Re-loads if the loader changed. */
74
+ function loadPdfjs(): Promise<PdfjsLike> {
75
+ if (!pdfjsPromise) {
76
+ const active = loader;
77
+ pdfjsPromise = (async () => {
78
+ const mod = await active();
79
+ const pdfjs = ((mod as unknown as { default?: PdfjsLike }).default ?? mod) as PdfjsLike;
80
+ pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
81
+ return pdfjs;
82
+ })();
83
+ }
84
+ return pdfjsPromise;
85
+ }
86
+
87
+ /**
88
+ * Render EVERY page of the PDF at `url` into `container` as stacked <canvas>
89
+ * elements fit to `pxWidth` CSS pixels (rendered at devicePixelRatio for
90
+ * crispness). Clears `container` first. Resolves `{ pages }`. THROWS on
91
+ * load / CORS / parse failure — the caller catches and shows the fallback card.
92
+ */
93
+ export async function renderPdfInto(
94
+ url: string,
95
+ container: HTMLElement,
96
+ pxWidth: number,
97
+ ): Promise<{ pages: number }> {
98
+ const pdfjs = await loadPdfjs();
99
+ const doc = await pdfjs.getDocument({ url }).promise;
100
+ container.replaceChildren();
101
+ const dpr =
102
+ typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 1;
103
+ for (let n = 1; n <= doc.numPages; n++) {
104
+ const page = await doc.getPage(n);
105
+ const base = page.getViewport({ scale: 1 });
106
+ const scale = base.width > 0 ? pxWidth / base.width : 1;
107
+ const viewport = page.getViewport({ scale: scale * dpr });
108
+ const canvas = document.createElement('canvas');
109
+ canvas.width = Math.max(1, Math.floor(viewport.width));
110
+ canvas.height = Math.max(1, Math.floor(viewport.height));
111
+ canvas.setAttribute('role', 'img');
112
+ canvas.setAttribute('aria-label', `Page ${n}`);
113
+ canvas.style.width = '100%';
114
+ canvas.style.height = 'auto';
115
+ canvas.style.display = 'block';
116
+ const ctx = canvas.getContext('2d');
117
+ container.appendChild(canvas);
118
+ await page.render({ canvasContext: ctx, viewport }).promise;
119
+ }
120
+ return { pages: doc.numPages };
121
+ }
@@ -0,0 +1,31 @@
1
+ import { onCleanup } from 'solid-js';
2
+
3
+ interface UseAutoResizeOptions {
4
+ maxHeight?: number;
5
+ }
6
+
7
+ export function useAutoResize(options: UseAutoResizeOptions = {}) {
8
+ let textareaEl: HTMLTextAreaElement | undefined;
9
+
10
+ function resize() {
11
+ if (!textareaEl) return;
12
+ textareaEl.style.height = 'auto';
13
+ const scrollHeight = textareaEl.scrollHeight;
14
+ if (options.maxHeight && scrollHeight > options.maxHeight) {
15
+ textareaEl.style.height = `${options.maxHeight}px`;
16
+ textareaEl.style.overflowY = 'auto';
17
+ } else {
18
+ textareaEl.style.height = `${scrollHeight}px`;
19
+ textareaEl.style.overflowY = 'hidden';
20
+ }
21
+ }
22
+
23
+ function ref(el: HTMLTextAreaElement) {
24
+ textareaEl = el;
25
+ el.addEventListener('input', resize);
26
+ requestAnimationFrame(resize);
27
+ onCleanup(() => el.removeEventListener('input', resize));
28
+ }
29
+
30
+ return { ref, resize };
31
+ }
@@ -0,0 +1,12 @@
1
+ /** Observe an element's content height; invoke `onHeight` when it changes by more than
2
+ * THRESHOLD px (hysteresis kills sub-pixel oscillation — H-J). Returns a disposer. */
3
+ const THRESHOLD = 1;
4
+ export function observeContentHeight(el: Element, onHeight: (height: number) => void): () => void {
5
+ let last = -1;
6
+ const ro = new ResizeObserver((entries) => {
7
+ const h = entries[entries.length - 1]?.contentRect.height ?? el.getBoundingClientRect().height;
8
+ if (last < 0 || Math.abs(h - last) > THRESHOLD) { last = h; onHeight(h); }
9
+ });
10
+ ro.observe(el);
11
+ return () => ro.disconnect();
12
+ }