@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,133 @@
1
+ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
2
+ import '@testing-library/jest-dom/vitest';
3
+ import { render, cleanup, fireEvent, screen } from '@solidjs/testing-library';
4
+ import {
5
+ Context,
6
+ ContextTrigger,
7
+ ContextContent,
8
+ ContextContentHeader,
9
+ ContextContentBody,
10
+ ContextContentFooter,
11
+ computeSeverity,
12
+ DEFAULT_WARN_THRESHOLD,
13
+ DEFAULT_DANGER_THRESHOLD,
14
+ } from './context';
15
+
16
+ // jsdom does not implement PointerEvent — shim it so fireEvent.pointerEnter works.
17
+ if (typeof (globalThis as unknown as Record<string, unknown>).PointerEvent === 'undefined') {
18
+ (globalThis as unknown as Record<string, unknown>).PointerEvent = class PointerEvent extends MouseEvent {
19
+ constructor(type: string, params?: PointerEventInit) { super(type, params); }
20
+ };
21
+ }
22
+
23
+ beforeEach(() => vi.useFakeTimers());
24
+ afterEach(() => {
25
+ vi.useRealTimers();
26
+ cleanup();
27
+ });
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // computeSeverity — pure function unit tests
31
+ // ---------------------------------------------------------------------------
32
+
33
+ describe('computeSeverity', () => {
34
+ it('returns ok below the warn threshold (defaults)', () => {
35
+ expect(computeSeverity(0)).toBe('ok');
36
+ expect(computeSeverity(0.5)).toBe('ok');
37
+ expect(computeSeverity(DEFAULT_WARN_THRESHOLD)).toBe('ok'); // exactly at threshold → still ok
38
+ });
39
+
40
+ it('returns warn when usage exceeds the warn threshold but not the danger threshold', () => {
41
+ expect(computeSeverity(0.71)).toBe('warn');
42
+ expect(computeSeverity(DEFAULT_WARN_THRESHOLD + 0.001)).toBe('warn');
43
+ expect(computeSeverity(DEFAULT_DANGER_THRESHOLD)).toBe('warn'); // exactly at danger threshold → still warn
44
+ });
45
+
46
+ it('returns danger when usage exceeds the danger threshold', () => {
47
+ expect(computeSeverity(0.91)).toBe('danger');
48
+ expect(computeSeverity(1.0)).toBe('danger');
49
+ });
50
+
51
+ it('respects custom warnThreshold', () => {
52
+ expect(computeSeverity(0.49, 0.5, 0.9)).toBe('ok');
53
+ expect(computeSeverity(0.51, 0.5, 0.9)).toBe('warn');
54
+ expect(computeSeverity(0.91, 0.5, 0.9)).toBe('danger');
55
+ });
56
+
57
+ it('respects custom dangerThreshold', () => {
58
+ expect(computeSeverity(0.49, 0.5, 0.75)).toBe('ok');
59
+ expect(computeSeverity(0.6, 0.5, 0.75)).toBe('warn');
60
+ expect(computeSeverity(0.76, 0.5, 0.75)).toBe('danger');
61
+ });
62
+ });
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Context component — color class flips at configured thresholds
66
+ //
67
+ // HoverCardRoot uses openDelay={0} inside Context, so after a pointerEnter
68
+ // the card opens immediately; we advance fake timers by 0 to flush it.
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function FullMeter(props: {
72
+ usedTokens: number;
73
+ maxTokens: number;
74
+ warnThreshold?: number;
75
+ dangerThreshold?: number;
76
+ }) {
77
+ return (
78
+ <Context {...props}>
79
+ <ContextTrigger />
80
+ <ContextContent>
81
+ <ContextContentHeader />
82
+ <ContextContentBody />
83
+ <ContextContentFooter />
84
+ </ContextContent>
85
+ </Context>
86
+ );
87
+ }
88
+
89
+ /** Open the hover card so ContextContentHeader is in the DOM. */
90
+ function openCard() {
91
+ // ContextTrigger renders HoverCardTrigger; the host wrapper is the parent of the button.
92
+ const btn = screen.getByRole('button');
93
+ fireEvent.pointerEnter(btn.parentElement!);
94
+ vi.advanceTimersByTime(0); // openDelay=0 → card opens immediately
95
+ }
96
+
97
+ describe('Context color thresholds', () => {
98
+ it('renders bg-primary bar below default warn threshold (< 70%)', () => {
99
+ render(() => <FullMeter usedTokens={69000} maxTokens={100000} />);
100
+ openCard();
101
+ expect(document.querySelector('.bg-primary')).toBeInTheDocument();
102
+ });
103
+
104
+ it('renders bg-yellow-400 bar between default warn (70%) and danger (90%) thresholds', () => {
105
+ render(() => <FullMeter usedTokens={80000} maxTokens={100000} />);
106
+ openCard();
107
+ expect(document.querySelector('.bg-yellow-400')).toBeInTheDocument();
108
+ });
109
+
110
+ it('renders bg-red-400 bar above default danger threshold (> 90%)', () => {
111
+ render(() => <FullMeter usedTokens={91000} maxTokens={100000} />);
112
+ openCard();
113
+ expect(document.querySelector('.bg-red-400')).toBeInTheDocument();
114
+ });
115
+
116
+ it('flips to warn colour at a custom warnThreshold of 0.5', () => {
117
+ render(() => <FullMeter usedTokens={51000} maxTokens={100000} warnThreshold={0.5} dangerThreshold={0.9} />);
118
+ openCard();
119
+ expect(document.querySelector('.bg-yellow-400')).toBeInTheDocument();
120
+ });
121
+
122
+ it('flips to danger colour at custom thresholds (warn 0.5, danger 0.75)', () => {
123
+ render(() => <FullMeter usedTokens={76000} maxTokens={100000} warnThreshold={0.5} dangerThreshold={0.75} />);
124
+ openCard();
125
+ expect(document.querySelector('.bg-red-400')).toBeInTheDocument();
126
+ });
127
+
128
+ it('stays green at 50% when custom warnThreshold is 0.6', () => {
129
+ render(() => <FullMeter usedTokens={50000} maxTokens={100000} warnThreshold={0.6} />);
130
+ openCard();
131
+ expect(document.querySelector('.bg-primary')).toBeInTheDocument();
132
+ });
133
+ });
@@ -0,0 +1,363 @@
1
+ import { type JSX, Show, createContext, createMemo, useContext } from 'solid-js';
2
+ import { HoverCardRoot, HoverCardTrigger, HoverCardContent } from '../ui/hover-card';
3
+ import { cn } from '../utils/cn';
4
+ import { Button } from '../ui/button';
5
+
6
+ const ICON_RADIUS = 10;
7
+ const ICON_VIEWBOX = 24;
8
+ const ICON_CENTER = 12;
9
+ const ICON_STROKE_WIDTH = 2;
10
+ const PERCENT_MAX = 100;
11
+
12
+ /** Default fraction (0–1) at which the meter transitions to the warning colour. */
13
+ export const DEFAULT_WARN_THRESHOLD = 0.7;
14
+ /** Default fraction (0–1) at which the meter transitions to the danger colour. */
15
+ export const DEFAULT_DANGER_THRESHOLD = 0.9;
16
+
17
+ /**
18
+ * Compute the severity level for a given usage fraction and thresholds.
19
+ * `pct` is a 0–1 fraction (e.g. `usedTokens / maxTokens`).
20
+ */
21
+ export function computeSeverity(
22
+ pct: number,
23
+ warnThreshold = DEFAULT_WARN_THRESHOLD,
24
+ dangerThreshold = DEFAULT_DANGER_THRESHOLD,
25
+ ): ContextSeverity {
26
+ if (pct > dangerThreshold) return 'danger';
27
+ if (pct > warnThreshold) return 'warn';
28
+ return 'ok';
29
+ }
30
+
31
+ export type ContextSeverity = 'ok' | 'warn' | 'danger';
32
+
33
+ interface ContextSchema {
34
+ usedTokens: number;
35
+ maxTokens: number;
36
+ inputTokens?: number;
37
+ outputTokens?: number;
38
+ reasoningTokens?: number;
39
+ cacheTokens?: number;
40
+ estimatedCost?: number;
41
+ /** Fraction (0–1) above which the meter turns yellow. Default 0.7. */
42
+ warnThreshold: number;
43
+ /** Fraction (0–1) above which the meter turns red. Default 0.9. */
44
+ dangerThreshold: number;
45
+ }
46
+
47
+ const ContextCtx = createContext<ContextSchema>();
48
+
49
+ function useContextValue(): ContextSchema {
50
+ const ctx = useContext(ContextCtx);
51
+ if (!ctx) {
52
+ throw new Error('Context components must be used within Context');
53
+ }
54
+ return ctx;
55
+ }
56
+
57
+ const fmtCompact = new Intl.NumberFormat('en-US', { notation: 'compact' });
58
+ const fmtPercent = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, style: 'percent' });
59
+ const fmtCurrency = new Intl.NumberFormat('en-US', { currency: 'USD', style: 'currency' });
60
+
61
+ // --- Root provider ---
62
+
63
+ export interface ContextProps {
64
+ usedTokens: number;
65
+ maxTokens: number;
66
+ inputTokens?: number;
67
+ outputTokens?: number;
68
+ reasoningTokens?: number;
69
+ cacheTokens?: number;
70
+ estimatedCost?: number;
71
+ /**
72
+ * Fraction (0–1) above which the progress bar turns yellow.
73
+ * Defaults to `0.7` (70%).
74
+ */
75
+ warnThreshold?: number;
76
+ /**
77
+ * Fraction (0–1) above which the progress bar turns red.
78
+ * Defaults to `0.9` (90%).
79
+ */
80
+ dangerThreshold?: number;
81
+ children?: JSX.Element;
82
+ }
83
+
84
+ export function Context(props: ContextProps) {
85
+ const value = createMemo<ContextSchema>(() => ({
86
+ usedTokens: props.usedTokens,
87
+ maxTokens: props.maxTokens,
88
+ inputTokens: props.inputTokens,
89
+ outputTokens: props.outputTokens,
90
+ reasoningTokens: props.reasoningTokens,
91
+ cacheTokens: props.cacheTokens,
92
+ estimatedCost: props.estimatedCost,
93
+ warnThreshold: props.warnThreshold ?? DEFAULT_WARN_THRESHOLD,
94
+ dangerThreshold: props.dangerThreshold ?? DEFAULT_DANGER_THRESHOLD,
95
+ }));
96
+
97
+ return (
98
+ <ContextCtx.Provider value={value()}>
99
+ <HoverCardRoot openDelay={0}>
100
+ {props.children}
101
+ </HoverCardRoot>
102
+ </ContextCtx.Provider>
103
+ );
104
+ }
105
+
106
+ // --- Icon (internal) ---
107
+
108
+ function ContextIcon() {
109
+ const ctx = useContextValue();
110
+ const circumference = 2 * Math.PI * ICON_RADIUS;
111
+ const usedPercent = createMemo(() => ctx.usedTokens / ctx.maxTokens);
112
+ const dashOffset = createMemo(() => circumference * (1 - usedPercent()));
113
+
114
+ return (
115
+ <svg
116
+ aria-label="Model context usage"
117
+ height="20"
118
+ role="img"
119
+ style={{ color: 'currentcolor' }}
120
+ viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
121
+ width="20"
122
+ >
123
+ <circle
124
+ cx={ICON_CENTER}
125
+ cy={ICON_CENTER}
126
+ fill="none"
127
+ opacity="0.25"
128
+ r={ICON_RADIUS}
129
+ stroke="currentColor"
130
+ stroke-width={ICON_STROKE_WIDTH}
131
+ />
132
+ <circle
133
+ cx={ICON_CENTER}
134
+ cy={ICON_CENTER}
135
+ fill="none"
136
+ opacity="0.7"
137
+ r={ICON_RADIUS}
138
+ stroke="currentColor"
139
+ stroke-dasharray={`${circumference} ${circumference}`}
140
+ stroke-dashoffset={dashOffset()}
141
+ stroke-linecap="round"
142
+ stroke-width={ICON_STROKE_WIDTH}
143
+ style={{ transform: 'rotate(-90deg)', 'transform-origin': 'center' }}
144
+ />
145
+ </svg>
146
+ );
147
+ }
148
+
149
+ // --- Trigger ---
150
+
151
+ export interface ContextTriggerProps {
152
+ children?: JSX.Element;
153
+ class?: string;
154
+ }
155
+
156
+ export function ContextTrigger(props: ContextTriggerProps) {
157
+ const ctx = useContextValue();
158
+ const usedPercent = createMemo(() => ctx.usedTokens / ctx.maxTokens);
159
+ const renderedPercent = createMemo(() => fmtPercent.format(usedPercent()));
160
+
161
+ return (
162
+ <HoverCardTrigger>
163
+ <Show
164
+ when={!props.children}
165
+ fallback={props.children}
166
+ >
167
+ <Button type="button" variant="ghost" class={props.class}>
168
+ <span class="font-medium text-muted-foreground">{renderedPercent()}</span>
169
+ <ContextIcon />
170
+ </Button>
171
+ </Show>
172
+ </HoverCardTrigger>
173
+ );
174
+ }
175
+
176
+ // --- Content ---
177
+
178
+ export interface ContextContentProps {
179
+ class?: string;
180
+ children?: JSX.Element;
181
+ }
182
+
183
+ export function ContextContent(props: ContextContentProps) {
184
+ return (
185
+ <HoverCardContent
186
+ class={cn(
187
+ 'min-w-60 divide-y divide-border overflow-hidden',
188
+ props.class
189
+ )}
190
+ >
191
+ {props.children}
192
+ </HoverCardContent>
193
+ );
194
+ }
195
+
196
+ // --- Content Header ---
197
+
198
+ export interface ContextContentHeaderProps {
199
+ class?: string;
200
+ children?: JSX.Element;
201
+ }
202
+
203
+ export function ContextContentHeader(props: ContextContentHeaderProps) {
204
+ const ctx = useContextValue();
205
+ const usedPercent = createMemo(() => ctx.usedTokens / ctx.maxTokens);
206
+ const displayPct = createMemo(() => fmtPercent.format(usedPercent()));
207
+ const used = createMemo(() => fmtCompact.format(ctx.usedTokens));
208
+ const total = createMemo(() => fmtCompact.format(ctx.maxTokens));
209
+ const barWidth = createMemo(() => `${Math.min(usedPercent() * PERCENT_MAX, PERCENT_MAX)}%`);
210
+
211
+ const severity = createMemo<ContextSeverity>(() => {
212
+ const pct = usedPercent();
213
+ if (pct > ctx.dangerThreshold) return 'danger';
214
+ if (pct > ctx.warnThreshold) return 'warn';
215
+ return 'ok';
216
+ });
217
+
218
+ const colorClass = createMemo(() => {
219
+ const s = severity();
220
+ if (s === 'danger') return 'bg-red-400';
221
+ if (s === 'warn') return 'bg-yellow-400';
222
+ return 'bg-primary';
223
+ });
224
+
225
+ return (
226
+ <div class={cn('w-full space-y-2 p-3', props.class)}>
227
+ <Show when={!props.children} fallback={props.children}>
228
+ <div class="flex items-center justify-between gap-3 text-xs">
229
+ <p>{displayPct()}</p>
230
+ <p class="font-mono text-muted-foreground">
231
+ {used()} / {total()}
232
+ </p>
233
+ </div>
234
+ <div class="space-y-2">
235
+ <div class="h-1.5 w-full rounded-full bg-muted overflow-hidden">
236
+ <div
237
+ class={cn('h-full rounded-full transition-all', colorClass())}
238
+ style={{ width: barWidth() }}
239
+ />
240
+ </div>
241
+ </div>
242
+ </Show>
243
+ </div>
244
+ );
245
+ }
246
+
247
+ // --- Content Body ---
248
+
249
+ export interface ContextContentBodyProps {
250
+ class?: string;
251
+ children?: JSX.Element;
252
+ }
253
+
254
+ export function ContextContentBody(props: ContextContentBodyProps) {
255
+ return (
256
+ <div class={cn('w-full p-3', props.class)}>
257
+ {props.children}
258
+ </div>
259
+ );
260
+ }
261
+
262
+ // --- Content Footer ---
263
+
264
+ export interface ContextContentFooterProps {
265
+ class?: string;
266
+ children?: JSX.Element;
267
+ }
268
+
269
+ export function ContextContentFooter(props: ContextContentFooterProps) {
270
+ const ctx = useContextValue();
271
+ const totalCost = createMemo(() =>
272
+ fmtCurrency.format(ctx.estimatedCost ?? 0)
273
+ );
274
+
275
+ return (
276
+ <div
277
+ class={cn(
278
+ 'flex w-full items-center justify-between gap-3 bg-muted p-3 text-xs',
279
+ props.class
280
+ )}
281
+ >
282
+ <Show when={!props.children} fallback={props.children}>
283
+ <span class="text-muted-foreground">Total cost</span>
284
+ <span>{totalCost()}</span>
285
+ </Show>
286
+ </div>
287
+ );
288
+ }
289
+
290
+ // --- Token row helper ---
291
+
292
+ function TokensDisplay(props: { tokens?: number }) {
293
+ return (
294
+ <span>
295
+ {props.tokens === undefined
296
+ ? '\u2014'
297
+ : fmtCompact.format(props.tokens)}
298
+ </span>
299
+ );
300
+ }
301
+
302
+ // --- Specialized usage rows ---
303
+
304
+ export interface ContextUsageRowProps {
305
+ class?: string;
306
+ children?: JSX.Element;
307
+ }
308
+
309
+ export function ContextInputUsage(props: ContextUsageRowProps) {
310
+ const ctx = useContextValue();
311
+ return (
312
+ <Show when={props.children || ctx.inputTokens}>
313
+ <Show when={!props.children} fallback={props.children}>
314
+ <div class={cn('flex items-center justify-between text-xs', props.class)}>
315
+ <span class="text-muted-foreground">Input</span>
316
+ <TokensDisplay tokens={ctx.inputTokens} />
317
+ </div>
318
+ </Show>
319
+ </Show>
320
+ );
321
+ }
322
+
323
+ export function ContextOutputUsage(props: ContextUsageRowProps) {
324
+ const ctx = useContextValue();
325
+ return (
326
+ <Show when={props.children || ctx.outputTokens}>
327
+ <Show when={!props.children} fallback={props.children}>
328
+ <div class={cn('flex items-center justify-between text-xs', props.class)}>
329
+ <span class="text-muted-foreground">Output</span>
330
+ <TokensDisplay tokens={ctx.outputTokens} />
331
+ </div>
332
+ </Show>
333
+ </Show>
334
+ );
335
+ }
336
+
337
+ export function ContextReasoningUsage(props: ContextUsageRowProps) {
338
+ const ctx = useContextValue();
339
+ return (
340
+ <Show when={props.children || ctx.reasoningTokens}>
341
+ <Show when={!props.children} fallback={props.children}>
342
+ <div class={cn('flex items-center justify-between text-xs', props.class)}>
343
+ <span class="text-muted-foreground">Reasoning</span>
344
+ <TokensDisplay tokens={ctx.reasoningTokens} />
345
+ </div>
346
+ </Show>
347
+ </Show>
348
+ );
349
+ }
350
+
351
+ export function ContextCacheUsage(props: ContextUsageRowProps) {
352
+ const ctx = useContextValue();
353
+ return (
354
+ <Show when={props.children || ctx.cacheTokens}>
355
+ <Show when={!props.children} fallback={props.children}>
356
+ <div class={cn('flex items-center justify-between text-xs', props.class)}>
357
+ <span class="text-muted-foreground">Cache</span>
358
+ <TokensDisplay tokens={ctx.cacheTokens} />
359
+ </div>
360
+ </Show>
361
+ </Show>
362
+ );
363
+ }
@@ -0,0 +1,125 @@
1
+ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
2
+ import { fn } from 'storybook/test';
3
+ import { ConversationItem } from './conversation-item';
4
+ import { componentDescription } from '../stories/docs/element-controls';
5
+
6
+ const baseConversation = {
7
+ id: '1',
8
+ title: 'How to use SolidJS signals',
9
+ messageCount: 8,
10
+ lastMessageAt: '2026-04-10T12:00:00Z',
11
+ updatedAt: '2026-04-10T12:00:00Z',
12
+ scope: { type: 'document' as const },
13
+ };
14
+
15
+ /**
16
+ * A single selectable row in a conversation/chat list: title plus a message
17
+ * count, with an active (selected) state.
18
+ */
19
+ const meta = {
20
+ title: 'Solid (Advanced)/Elements/ConversationItem',
21
+ component: ConversationItem,
22
+ tags: ['autodocs'],
23
+ parameters: {
24
+ layout: 'padded',
25
+ docs: {
26
+ description: componentDescription([
27
+ 'A single conversation row — shows the title and message count, truncating long titles, with a highlighted active state.',
28
+ '**When to use:** as the leaf item inside a chat history sidebar. Usually rendered for you by `ConversationList`; use it directly to build a custom list.',
29
+ '**How to use:** pass a `conversation` summary, an `isActive` flag, and an `onSelect(id)` handler fired when the row is clicked.',
30
+ '**Placement:** inside a conversation/chat history sidebar list.',
31
+ ]),
32
+ controls: { exclude: ['use:eventListener'] },
33
+ },
34
+ },
35
+ argTypes: {
36
+ conversation: {
37
+ control: 'object',
38
+ description: 'The conversation summary (id, title, messageCount, scope, timestamps).',
39
+ },
40
+ isActive: {
41
+ control: 'boolean',
42
+ description: 'Whether this row is the currently selected conversation.',
43
+ },
44
+ onSelect: {
45
+ action: 'select',
46
+ description: 'Fired with the conversation id when the row is clicked.',
47
+ table: { category: 'Events' },
48
+ },
49
+ },
50
+ args: {
51
+ conversation: baseConversation,
52
+ isActive: false,
53
+ onSelect: fn(),
54
+ },
55
+ render: (args) => (
56
+ <div class="w-64">
57
+ <ConversationItem {...args} />
58
+ </div>
59
+ ),
60
+ } satisfies Meta<typeof ConversationItem>;
61
+
62
+ export default meta;
63
+ type Story = StoryObj<typeof meta>;
64
+
65
+ const IMPORT = `import { ConversationItem } from '@kitn.ai/ui';`;
66
+ const src = (code: string) => ({
67
+ parameters: { docs: { source: { code: `${IMPORT}\n\n${code}`, language: 'tsx' } } },
68
+ });
69
+
70
+ /** Interactive playground — toggle `isActive` and edit the conversation object. */
71
+ export const Playground: Story = {
72
+ ...src(`<ConversationItem conversation={conversation} isActive={false} onSelect={(id) => {}} />`),
73
+ };
74
+
75
+ export const Active: Story = {
76
+ args: { isActive: true },
77
+ ...src(`<ConversationItem conversation={conversation} isActive onSelect={(id) => {}} />`),
78
+ };
79
+
80
+ export const Inactive: Story = {
81
+ args: { isActive: false },
82
+ ...src(`<ConversationItem conversation={conversation} isActive={false} onSelect={(id) => {}} />`),
83
+ };
84
+
85
+ export const LongTitle: Story = {
86
+ args: {
87
+ conversation: {
88
+ ...baseConversation,
89
+ title: 'This is a very long conversation title that should be truncated with an ellipsis',
90
+ },
91
+ },
92
+ ...src(`<ConversationItem
93
+ conversation={{ ...conversation, title: 'A very long title…' }}
94
+ isActive={false}
95
+ onSelect={(id) => {}}
96
+ />`),
97
+ };
98
+
99
+ /** Several items stacked, one active — showcase. */
100
+ export const MultipleItems: Story = {
101
+ render: (args: { onSelect: (id: string) => void }) => (
102
+ <div class="w-64 space-y-0.5">
103
+ <ConversationItem
104
+ conversation={{ ...baseConversation, id: '1', title: 'SolidJS reactive primitives' }}
105
+ isActive={true}
106
+ onSelect={args.onSelect}
107
+ />
108
+ <ConversationItem
109
+ conversation={{ ...baseConversation, id: '2', title: 'TypeScript generics guide', messageCount: 12 }}
110
+ isActive={false}
111
+ onSelect={args.onSelect}
112
+ />
113
+ <ConversationItem
114
+ conversation={{ ...baseConversation, id: '3', title: 'Tailwind CSS tips and tricks', messageCount: 3 }}
115
+ isActive={false}
116
+ onSelect={args.onSelect}
117
+ />
118
+ </div>
119
+ ),
120
+ ...src(`<div class="space-y-0.5">
121
+ <ConversationItem conversation={c1} isActive onSelect={onSelect} />
122
+ <ConversationItem conversation={c2} isActive={false} onSelect={onSelect} />
123
+ <ConversationItem conversation={c3} isActive={false} onSelect={onSelect} />
124
+ </div>`),
125
+ };
@@ -0,0 +1,16 @@
1
+ import { splitProps } from 'solid-js';
2
+ import { cn } from '../utils/cn';
3
+ import type { ConversationSummary } from '../types';
4
+
5
+ export interface ConversationItemProps { conversation: ConversationSummary; isActive: boolean; onSelect: (id: string) => void; class?: string; }
6
+
7
+ export function ConversationItem(props: ConversationItemProps) {
8
+ const [local] = splitProps(props, ['conversation', 'isActive', 'onSelect', 'class']);
9
+ return (
10
+ <button data-conversation-id={local.conversation.id} onClick={() => local.onSelect(local.conversation.id)}
11
+ class={cn('w-full text-left rounded-lg px-2.5 py-2 transition-colors', local.isActive ? 'bg-muted' : 'hover:bg-muted/50', local.class)}>
12
+ <div class={cn('truncate text-sm', local.isActive ? 'text-foreground font-medium' : 'text-foreground/80')}>{local.conversation.title}</div>
13
+ <div class={cn('truncate mt-0.5 text-xs', local.isActive ? 'text-foreground/70' : 'text-muted-foreground')}>{local.conversation.messageCount} messages</div>
14
+ </button>
15
+ );
16
+ }