unmagic-components 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (292) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/README.md +506 -1
  4. data/app/assets/icons/lucide/LICENSE +43 -0
  5. data/app/assets/icons/lucide/arrow-down.svg +1 -0
  6. data/app/assets/icons/lucide/arrow-left.svg +1 -0
  7. data/app/assets/icons/lucide/arrow-up.svg +1 -0
  8. data/app/assets/icons/lucide/ban.svg +1 -0
  9. data/app/assets/icons/lucide/brain.svg +1 -0
  10. data/app/assets/icons/lucide/check.svg +1 -0
  11. data/app/assets/icons/lucide/chevron-down.svg +1 -0
  12. data/app/assets/icons/lucide/chevron-left.svg +1 -0
  13. data/app/assets/icons/lucide/chevron-right.svg +1 -0
  14. data/app/assets/icons/lucide/circle-check.svg +1 -0
  15. data/app/assets/icons/lucide/circle-dashed.svg +1 -0
  16. data/app/assets/icons/lucide/circle-question-mark.svg +1 -0
  17. data/app/assets/icons/lucide/circle-stop.svg +1 -0
  18. data/app/assets/icons/lucide/circle-x.svg +1 -0
  19. data/app/assets/icons/lucide/clock.svg +1 -0
  20. data/app/assets/icons/lucide/copy.svg +1 -0
  21. data/app/assets/icons/lucide/ellipsis-vertical.svg +1 -0
  22. data/app/assets/icons/lucide/file.svg +1 -0
  23. data/app/assets/icons/lucide/folder.svg +1 -0
  24. data/app/assets/icons/lucide/grip-vertical.svg +1 -0
  25. data/app/assets/icons/lucide/info.svg +1 -0
  26. data/app/assets/icons/lucide/lightbulb.svg +1 -0
  27. data/app/assets/icons/lucide/list-checks.svg +1 -0
  28. data/app/assets/icons/lucide/loader-circle.svg +1 -0
  29. data/app/assets/icons/lucide/messages-square.svg +1 -0
  30. data/app/assets/icons/lucide/paperclip.svg +1 -0
  31. data/app/assets/icons/lucide/pencil.svg +1 -0
  32. data/app/assets/icons/lucide/plus.svg +1 -0
  33. data/app/assets/icons/lucide/rotate-cw.svg +1 -0
  34. data/app/assets/icons/lucide/shield-alert.svg +1 -0
  35. data/app/assets/icons/lucide/timer.svg +1 -0
  36. data/app/assets/icons/lucide/triangle-alert.svg +1 -0
  37. data/app/assets/icons/lucide/wrench.svg +1 -0
  38. data/app/assets/icons/lucide/x.svg +1 -0
  39. data/app/assets/javascripts/unmagic/components/ai_chat.js +142 -0
  40. data/app/assets/javascripts/unmagic/components/autoscroll.js +103 -0
  41. data/app/assets/javascripts/unmagic/components/board.js +65 -0
  42. data/app/assets/javascripts/unmagic/components/dropzone.js +206 -0
  43. data/app/assets/javascripts/unmagic/components/elapsed.js +64 -0
  44. data/app/assets/javascripts/unmagic/components/optimistic.js +71 -0
  45. data/app/assets/javascripts/unmagic/components/placement.js +30 -0
  46. data/app/assets/javascripts/unmagic/components/slash_menu.js +186 -0
  47. data/app/assets/javascripts/unmagic/components/sortable.js +6 -0
  48. data/app/assets/javascripts/unmagic/components/sortable_item.js +122 -0
  49. data/app/assets/javascripts/unmagic/components/sortable_list.js +135 -0
  50. data/app/assets/javascripts/unmagic/components/sortable_param.js +14 -0
  51. data/app/assets/javascripts/unmagic/components/sortable_session.js +502 -0
  52. data/app/assets/javascripts/unmagic/components/streaming_markdown.js +269 -0
  53. data/app/assets/javascripts/unmagic/components/toasts.js +14 -4
  54. data/app/assets/javascripts/unmagic/components/toolbar.js +76 -0
  55. data/app/assets/javascripts/unmagic/components/upsert.js +4 -20
  56. data/app/assets/javascripts/unmagic/components.js +10 -0
  57. data/app/assets/tailwind/unmagic_components/engine.css +1515 -0
  58. data/lib/unmagic/components/action_view_helpers.rb +558 -3
  59. data/lib/unmagic/components/ai_chat/action_bar.rb +70 -0
  60. data/lib/unmagic/components/ai_chat/attachments.rb +119 -0
  61. data/lib/unmagic/components/ai_chat/branch_picker.rb +65 -0
  62. data/lib/unmagic/components/ai_chat/citation.rb +89 -0
  63. data/lib/unmagic/components/ai_chat/composer.rb +160 -0
  64. data/lib/unmagic/components/ai_chat/failure.rb +80 -0
  65. data/lib/unmagic/components/ai_chat/message.rb +142 -0
  66. data/lib/unmagic/components/ai_chat/payload.rb +93 -0
  67. data/lib/unmagic/components/ai_chat/permission.rb +143 -0
  68. data/lib/unmagic/components/ai_chat/plan.rb +91 -0
  69. data/lib/unmagic/components/ai_chat/proposal.rb +102 -0
  70. data/lib/unmagic/components/ai_chat/reasoning.rb +75 -0
  71. data/lib/unmagic/components/ai_chat/request.rb +206 -0
  72. data/lib/unmagic/components/ai_chat/section.rb +53 -0
  73. data/lib/unmagic/components/ai_chat/slash_menu.rb +68 -0
  74. data/lib/unmagic/components/ai_chat/tool_call.rb +181 -0
  75. data/lib/unmagic/components/ai_chat/transcript.rb +68 -0
  76. data/lib/unmagic/components/ai_chat/welcome.rb +73 -0
  77. data/lib/unmagic/components/ai_chat/workspace.rb +74 -0
  78. data/lib/unmagic/components/ai_chat.rb +31 -0
  79. data/lib/unmagic/components/avatar.rb +134 -0
  80. data/lib/unmagic/components/board.rb +182 -0
  81. data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/application_controller.rb +80 -0
  82. data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/demos_controller.rb +137 -0
  83. data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/pages_controller.rb +40 -0
  84. data/lib/unmagic/components/browser/app/helpers/unmagic/components/browser/application_helper.rb +52 -0
  85. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/board_store.rb +88 -0
  86. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/catalog.rb +65 -0
  87. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/pager.rb +11 -0
  88. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/profile.rb +25 -0
  89. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/reply.rb +72 -0
  90. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/thing.rb +31 -0
  91. data/lib/unmagic/components/browser/app/views/layouts/unmagic/components/browser/application.html.erb +65 -0
  92. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_code.html.erb +7 -0
  93. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_crumb.html.erb +7 -0
  94. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_example.html.erb +37 -0
  95. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_nav.html.erb +17 -0
  96. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/demos/edit_profile.html.erb +9 -0
  97. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_conversation.html.erb +101 -0
  98. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_live.html.erb +53 -0
  99. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_thumbnail.html.erb +7 -0
  100. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_welcome.html.erb +12 -0
  101. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_always.html.erb +6 -0
  102. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_hover.html.erb +12 -0
  103. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_thumbnail.html.erb +5 -0
  104. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_dropzone.html.erb +17 -0
  105. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_sent.html.erb +11 -0
  106. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_thumbnail.html.erb +6 -0
  107. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_positions.html.erb +5 -0
  108. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_thumbnail.html.erb +3 -0
  109. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_compact.html.erb +10 -0
  110. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_quote.html.erb +11 -0
  111. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_thumbnail.html.erb +7 -0
  112. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_parts.html.erb +14 -0
  113. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_states.html.erb +8 -0
  114. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_thumbnail.html.erb +5 -0
  115. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_full.html.erb +11 -0
  116. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_plain.html.erb +3 -0
  117. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_thumbnail.html.erb +5 -0
  118. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_parts.html.erb +23 -0
  119. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_states.html.erb +9 -0
  120. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_thumbnail.html.erb +4 -0
  121. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_turns.html.erb +16 -0
  122. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_json.html.erb +4 -0
  123. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_text.html.erb +4 -0
  124. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_thumbnail.html.erb +3 -0
  125. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_answered.html.erb +12 -0
  126. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_thumbnail.html.erb +7 -0
  127. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_waiting.html.erb +12 -0
  128. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_empty.html.erb +3 -0
  129. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_states.html.erb +9 -0
  130. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_thumbnail.html.erb +7 -0
  131. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_decided.html.erb +11 -0
  132. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_inline.html.erb +18 -0
  133. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_thumbnail.html.erb +6 -0
  134. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_settled.html.erb +6 -0
  135. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thinking.html.erb +5 -0
  136. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thumbnail.html.erb +3 -0
  137. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_answered.html.erb +10 -0
  138. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_choices.html.erb +13 -0
  139. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_form.html.erb +9 -0
  140. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_thumbnail.html.erb +8 -0
  141. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_composer.html.erb +15 -0
  142. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_thumbnail.html.erb +9 -0
  143. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_made.html.erb +12 -0
  144. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_run.html.erb +25 -0
  145. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_states.html.erb +22 -0
  146. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_thumbnail.html.erb +7 -0
  147. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_suggestions.html.erb +11 -0
  148. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_thumbnail.html.erb +6 -0
  149. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_files.html.erb +12 -0
  150. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_thumbnail.html.erb +7 -0
  151. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_group.html.erb +5 -0
  152. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_sizes.html.erb +8 -0
  153. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_thumbnail.html.erb +5 -0
  154. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_tints.html.erb +7 -0
  155. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_in_context.html.erb +14 -0
  156. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_thumbnail.html.erb +5 -0
  157. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_tones.html.erb +8 -0
  158. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_handles.html.erb +13 -0
  159. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_live.html.erb +34 -0
  160. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_thumbnail.html.erb +10 -0
  161. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_sizes.html.erb +12 -0
  162. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_thumbnail.html.erb +4 -0
  163. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_variants.html.erb +6 -0
  164. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_neutral.html.erb +3 -0
  165. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_thumbnail.html.erb +5 -0
  166. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_tones.html.erb +14 -0
  167. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_actions_and_footer.html.erb +7 -0
  168. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_flush_table.html.erb +6 -0
  169. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_link.html.erb +6 -0
  170. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_thumbnail.html.erb +6 -0
  171. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_danger.html.erb +3 -0
  172. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_plain.html.erb +2 -0
  173. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_thumbnail.html.erb +7 -0
  174. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_from_element.html.erb +4 -0
  175. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_thumbnail.html.erb +4 -0
  176. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_with_label.html.erb +3 -0
  177. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_inline.html.erb +5 -0
  178. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_stacked.html.erb +7 -0
  179. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_thumbnail.html.erb +5 -0
  180. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_dialog_tag.html.erb +8 -0
  181. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_modal.html.erb +10 -0
  182. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_thumbnail.html.erb +4 -0
  183. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_countdown.html.erb +3 -0
  184. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_running.html.erb +8 -0
  185. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_thumbnail.html.erb +4 -0
  186. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_default.html.erb +1 -0
  187. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_thumbnail.html.erb +3 -0
  188. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_autogrow.html.erb +4 -0
  189. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_control_classes.html.erb +54 -0
  190. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_fields.html.erb +28 -0
  191. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_states.html.erb +11 -0
  192. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_thumbnail.html.erb +8 -0
  193. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_uuid_field.html.erb +9 -0
  194. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_absolute.html.erb +5 -0
  195. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_relative.html.erb +14 -0
  196. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_thumbnail.html.erb +14 -0
  197. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_actions.html.erb +10 -0
  198. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_labelled.html.erb +6 -0
  199. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_thumbnail.html.erb +14 -0
  200. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_default.html.erb +7 -0
  201. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_thumbnail.html.erb +5 -0
  202. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_with_leading.html.erb +14 -0
  203. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_blocking_out.html.erb +25 -0
  204. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_card.html.erb +7 -0
  205. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_detail_list.html.erb +15 -0
  206. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_page_header.html.erb +7 -0
  207. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_text_and_button.html.erb +17 -0
  208. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_thumbnail.html.erb +10 -0
  209. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_connected.html.erb +17 -0
  210. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_grid.html.erb +20 -0
  211. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_handles.html.erb +22 -0
  212. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_thumbnail.html.erb +7 -0
  213. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_reveal.html.erb +19 -0
  214. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_stopped.html.erb +31 -0
  215. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_thumbnail.html.erb +5 -0
  216. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_deferred.html.erb +6 -0
  217. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_empty.html.erb +5 -0
  218. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_sortable.html.erb +8 -0
  219. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_table_tag.html.erb +4 -0
  220. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_thumbnail.html.erb +4 -0
  221. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_links.html.erb +5 -0
  222. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_panels.html.erb +18 -0
  223. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_thumbnail.html.erb +5 -0
  224. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_above_dialog.html.erb +10 -0
  225. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_flash.html.erb +13 -0
  226. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_stream.html.erb +6 -0
  227. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_thumbnail.html.erb +10 -0
  228. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_placement.html.erb +8 -0
  229. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_term.html.erb +5 -0
  230. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_thumbnail.html.erb +5 -0
  231. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/component.html.erb +58 -0
  232. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/installation.html.erb +47 -0
  233. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/overview.html.erb +49 -0
  234. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/theming.html.erb +52 -0
  235. data/lib/unmagic/components/browser/assets/browser.css +2 -0
  236. data/lib/unmagic/components/browser/catalog/ai_chat.rb +21 -0
  237. data/lib/unmagic/components/browser/catalog/ai_chat_action_bar.rb +15 -0
  238. data/lib/unmagic/components/browser/catalog/ai_chat_attachments.rb +15 -0
  239. data/lib/unmagic/components/browser/catalog/ai_chat_branch_picker.rb +12 -0
  240. data/lib/unmagic/components/browser/catalog/ai_chat_citation.rb +15 -0
  241. data/lib/unmagic/components/browser/catalog/ai_chat_composer.rb +16 -0
  242. data/lib/unmagic/components/browser/catalog/ai_chat_failure.rb +15 -0
  243. data/lib/unmagic/components/browser/catalog/ai_chat_message.rb +17 -0
  244. data/lib/unmagic/components/browser/catalog/ai_chat_payload.rb +16 -0
  245. data/lib/unmagic/components/browser/catalog/ai_chat_permission.rb +15 -0
  246. data/lib/unmagic/components/browser/catalog/ai_chat_plan.rb +14 -0
  247. data/lib/unmagic/components/browser/catalog/ai_chat_proposal.rb +15 -0
  248. data/lib/unmagic/components/browser/catalog/ai_chat_reasoning.rb +14 -0
  249. data/lib/unmagic/components/browser/catalog/ai_chat_request.rb +17 -0
  250. data/lib/unmagic/components/browser/catalog/ai_chat_slash_menu.rb +14 -0
  251. data/lib/unmagic/components/browser/catalog/ai_chat_tool_call.rb +17 -0
  252. data/lib/unmagic/components/browser/catalog/ai_chat_welcome.rb +13 -0
  253. data/lib/unmagic/components/browser/catalog/ai_chat_workspace.rb +12 -0
  254. data/lib/unmagic/components/browser/catalog/avatar.rb +15 -0
  255. data/lib/unmagic/components/browser/catalog/badge.rb +11 -0
  256. data/lib/unmagic/components/browser/catalog/board.rb +18 -0
  257. data/lib/unmagic/components/browser/catalog/button.rb +14 -0
  258. data/lib/unmagic/components/browser/catalog/callout.rb +13 -0
  259. data/lib/unmagic/components/browser/catalog/card.rb +16 -0
  260. data/lib/unmagic/components/browser/catalog/confirm.rb +14 -0
  261. data/lib/unmagic/components/browser/catalog/copy_button.rb +15 -0
  262. data/lib/unmagic/components/browser/catalog/detail_list.rb +14 -0
  263. data/lib/unmagic/components/browser/catalog/dialog.rb +15 -0
  264. data/lib/unmagic/components/browser/catalog/elapsed.rb +14 -0
  265. data/lib/unmagic/components/browser/catalog/empty_state.rb +11 -0
  266. data/lib/unmagic/components/browser/catalog/forms.rb +21 -0
  267. data/lib/unmagic/components/browser/catalog/local_time.rb +13 -0
  268. data/lib/unmagic/components/browser/catalog/menu.rb +15 -0
  269. data/lib/unmagic/components/browser/catalog/page_header.rb +13 -0
  270. data/lib/unmagic/components/browser/catalog/skeleton.rb +18 -0
  271. data/lib/unmagic/components/browser/catalog/sortable_list.rb +17 -0
  272. data/lib/unmagic/components/browser/catalog/streaming_markdown.rb +17 -0
  273. data/lib/unmagic/components/browser/catalog/table.rb +17 -0
  274. data/lib/unmagic/components/browser/catalog/tabs.rb +15 -0
  275. data/lib/unmagic/components/browser/catalog/toast.rb +17 -0
  276. data/lib/unmagic/components/browser/catalog/tooltip.rb +14 -0
  277. data/lib/unmagic/components/browser/config/routes.rb +41 -0
  278. data/lib/unmagic/components/browser/tailwind/browser.css +33 -0
  279. data/lib/unmagic/components/browser.rb +39 -0
  280. data/lib/unmagic/components/configuration.rb +31 -1
  281. data/lib/unmagic/components/duration.rb +35 -0
  282. data/lib/unmagic/components/elapsed.rb +46 -0
  283. data/lib/unmagic/components/engine.rb +9 -0
  284. data/lib/unmagic/components/icons.rb +34 -26
  285. data/lib/unmagic/components/modal.rb +1 -1
  286. data/lib/unmagic/components/page_header.rb +8 -0
  287. data/lib/unmagic/components/sortable.rb +104 -0
  288. data/lib/unmagic/components/streaming_markdown.rb +34 -0
  289. data/lib/unmagic/components/turbo_stream_actions.rb +16 -0
  290. data/lib/unmagic/components/version.rb +1 -1
  291. data/lib/unmagic/components.rb +30 -1
  292. metadata +308 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c204e77700223b87a5c4a2d019bb285de3dd24f9a9c1530aa54dd4b798f171a7
4
- data.tar.gz: 91b9db1448f9a74a259a565e7d8ac0d4db6cb75927f3680a0914517666134011
3
+ metadata.gz: 9c4fbdc3dbc2a5dbba7758240b2c0fa271c81f46e00435e898ad179f23ce1937
4
+ data.tar.gz: 1ce6acc98aecc0153da190eca815d5ca469e6e46c0fe61bdd8b1fc75dd437e10
5
5
  SHA512:
6
- metadata.gz: af2099fb9686c1b87742f79a07ee892a7dc3b8c984f93b684eb3a51355c82b793369230857ff9f8e5eff2ee24e7fda85d482f215126eb6ba7df59a3c62cd7aa9
7
- data.tar.gz: a975d65b9b5575e6cbb2768dd22232f8c20291eecfcb7984fc075b6d6c2acefd1e56f558d3ca96a92329ae24588cc35042d780ecd1a2558d2fc7abe8e3aa3323
6
+ metadata.gz: 2cbcb4011c8504a0247e3a26d49042da521e22ccc658615049e7bdfc7ec9411fa86f984acb2abc9e59a99bc398c245dd03f99de30616f00c2a5ecc49ed688251
7
+ data.tar.gz: 6599c73d2d0c5846a8b5206e3df0071b31c8d958f88b3e21dcbdda761c48820c1f2a1854762c99ce09b6f360985be017a10c007be5887c26a5ae5c097ca4807a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,102 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-09-16
11
+
12
+ ### Added
13
+
14
+ - `Unmagic::Components::Browser::Engine`, a component browser a host mounts
15
+ (`mount Unmagic::Components::Browser::Engine => "/unmagic/components"`). It is
16
+ the preview app, moved into the gem: every component, its examples and their
17
+ source. It ships a prebuilt stylesheet and serves the components' JavaScript
18
+ and Turbo itself, so a host changes nothing else. It needs turbo-rails.
19
+ `bin/dev` now runs it from a one-file host.
20
+ - `trailing` on the page header builder puts markup beside the title as it is —
21
+ a status partial, a row of badges a helper already returned. `badge` wraps what
22
+ it is given, so an already-rendered one routed through it came out as a badge
23
+ inside a badge; this is the counterpart to `leading`, and the two interleave in
24
+ call order.
25
+ - `sortable_list` and `sortable_handle`: drag-and-drop ordering by pointer or
26
+ keyboard, extracted from hooops.
27
+ - Elements `<unmagic-sortable-list>`, `<unmagic-sortable-item>` and
28
+ `<unmagic-sortable-param>`.
29
+ - Keyboard reordering with announcements.
30
+ - Scrolling at the edges, Escape to cancel, and nested lists.
31
+ - A cancelable `unmagic-sortable:move` event.
32
+ - Touch: a long press lifts an item, so swipes still scroll and taps still
33
+ tap; a grip drags at once.
34
+ - `sortable-*` Tailwind variants.
35
+ - `config.sortable_item` and `config.sortable_url`, which the new
36
+ unmagic-sortable gem fills in.
37
+ - `board`, a Trello-style board of reorderable columns and cards, with add-card
38
+ and add-list forms (`board.js`).
39
+
40
+ - AI chat components, for rendering an agent's work. Designed from three
41
+ applications that each grew their own agent UI, and checked against
42
+ assistant-ui's primitives. Each has a design note in `docs/components/ai_chat`
43
+ and a page in the browser:
44
+ - `ai_chat`, the transcript: a polite log that follows new content while the
45
+ reader is at the bottom, with a jump-to-latest button and a welcome slot.
46
+ - `ai_chat_message`: a user's bubble or an assistant's prose, with reasoning,
47
+ actions, branches and attachments, and an optimistic template.
48
+ - `ai_chat_tool_call`: a row per tool call, joined into a timeline, with a
49
+ live clock, progress, partial-failure counts and folded payloads.
50
+ - `ai_chat_payload`, `ai_chat_reasoning`, `ai_chat_failure`, `ai_chat_plan`
51
+ and `ai_chat_workspace`.
52
+ - `ai_chat_request`, `ai_chat_permission` and `ai_chat_proposal`, for an agent
53
+ that stops to ask.
54
+ - `ai_chat_composer` (with `ai_chat_composer_action` and
55
+ `ai_chat_stop_form`), `ai_chat_slash_menu`, `ai_chat_welcome`,
56
+ `ai_chat_attachments` and `ai_chat_dropzone`.
57
+ - `ai_chat_citation`, `ai_chat_action_bar` and `ai_chat_branch_picker`.
58
+ - `streaming_markdown_tag` and `turbo_stream.stream_markdown`: server-rendered
59
+ HTML revealed at a steady pace as fuller renders arrive
60
+ (`<unmagic-streaming-markdown>`).
61
+ - `elapsed_tag`, a clock counting up from a time or down to one
62
+ (`<unmagic-elapsed>`).
63
+ - `avatar` and `avatar_group`, with initials on a tint picked from the name.
64
+ - `UnmagicProse`, styles for server-rendered Markdown.
65
+ - `config.code_block`, the seam payloads and prose code blocks render through,
66
+ for a host's highlighter.
67
+ - Custom elements `<unmagic-autoscroll>`, `<unmagic-optimistic>`,
68
+ `<unmagic-toolbar>`, `<unmagic-slash-menu>` and `<unmagic-dropzone>`.
69
+ - The browser groups components under headings, with the AI chat components in
70
+ their own group. Its composer example posts for real and streams a reply.
71
+
72
+ ### Changed
73
+
74
+ - The gem now depends on `unmagic-icon` and `unmagic-color`. Its glyphs are
75
+ Lucide SVGs shipped in `app/assets/icons/lucide` and rendered through
76
+ unmagic-icon, in place of the inline paths. The rendered `<svg>` gains
77
+ unmagic-icon's `unmagic-icon` class and `data-unmagic-icon` attribute, and the
78
+ modal's retry icon is Lucide's `rotate-cw`.
79
+ - The `upsert` stream action's placement rule moved to
80
+ `unmagic/components/placement`, shared with `<unmagic-optimistic>`. Its
81
+ behaviour is unchanged.
82
+
83
+ ### Fixed
84
+
85
+ - The browser's dialog page opens its modal examples in the modal again. The
86
+ "modal" example's section had the id `modal`, which shadowed the shared
87
+ modal's frame, so Turbo loaded the link as a full page. Example sections are
88
+ now named `<component>_<example>`. The composer examples' fields no longer
89
+ share the id `content` either.
90
+ - `duration="0"` on `<unmagic-toasts>` now keeps a toast up until it is
91
+ dismissed, so "don't take this one away" is sayable — and a test asserting on a
92
+ toast can stop racing the timer. It used to fall back to the default, because
93
+ the guard was `duration > 0` and `Number(null)` is also `0`, so an absent
94
+ attribute and a deliberate zero were indistinguishable. Absent, empty, negative
95
+ and unparseable all still fall back rather than silently pinning every toast to
96
+ the screen.
97
+
98
+ - The helpers reach `ActionView::TestCase` too, so a host helper that composes
99
+ one of them (`badge`, `button_classes`) can be exercised by the host's own
100
+ helper spec. The engine only ran the `:action_view` load hook, which mixes
101
+ into `ActionView::Base` — what a template renders through, and not what a
102
+ helper test calls its subject on. The symptom was a helper that worked in the
103
+ app and raised `NoMethodError` in its spec, which is a confusing place to send
104
+ someone.
105
+
10
106
  ## [0.3.0] - 2026-09-16
11
107
 
12
108
  ### Added
@@ -161,7 +257,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
161
257
  - Configurable empty-state, pagination and Pagy seams so the gem depends on neither
162
258
  Pagy nor any host helper.
163
259
 
164
- [Unreleased]: https://github.com/unreasonable-magic/unmagic-components/compare/v0.3.0...HEAD
260
+ [Unreleased]: https://github.com/unreasonable-magic/unmagic-components/compare/v0.4.0...HEAD
261
+ [0.4.0]: https://github.com/unreasonable-magic/unmagic-components/compare/v0.3.0...v0.4.0
165
262
  [0.3.0]: https://github.com/unreasonable-magic/unmagic-components/compare/v0.2.0...v0.3.0
166
263
  [0.2.0]: https://github.com/unreasonable-magic/unmagic-components/compare/v0.1.0...v0.2.0
167
264
  [0.1.0]: https://github.com/unreasonable-magic/unmagic-components/releases/tag/v0.1.0
data/README.md CHANGED
@@ -36,6 +36,14 @@ the spirit of `form_for`: describe the columns, get the chrome.
36
36
  the server with a skeleton, an error panel with retry, and a close that lands
37
37
  in the same render as the page's refresh; same-page dialogs; and a confirm
38
38
  dialog in place of `window.confirm`.
39
+ - **AI chat components** for rendering an agent's work: a transcript, streamed
40
+ replies revealed at a steady pace, tool calls strung into a timeline,
41
+ reasoning, plans, workspaces, permission gates, questions, inline proposals,
42
+ citations and a composer with Send and Stop. See [AI chat](#ai-chat).
43
+ - **Drag-and-drop ordering** by pointer or keyboard: sortable lists and a
44
+ Trello-style board, posting one-row moves that
45
+ [unmagic-sortable](https://github.com/unreasonable-magic/unmagic-sortable)
46
+ saves. See [Sortable lists and boards](#sortable-lists-and-boards).
39
47
  - **No hard dependency** on a pagination library or on any helper of yours.
40
48
 
41
49
  ## Installation
@@ -69,11 +77,42 @@ show unmagic-components` prints where the gem is installed:
69
77
  @import "/path/to/unmagic-components/app/assets/tailwind/unmagic_components/engine.css";
70
78
  ```
71
79
 
80
+ The gem brings two of its siblings with it: `unmagic-icon`, which renders the
81
+ glyphs, and `unmagic-color`, which picks avatar tints. The glyphs are a subset of
82
+ Lucide shipped inside this gem, so there is no icon set to download; under Rails
83
+ they are also available to your views as `unmagic_icon
84
+ "unmagic_components:lucide/<name>"`.
85
+
72
86
  The engine mixes the helpers into ActionView automatically — no initializer
73
87
  needed to get started. The interactive components need their JavaScript too; with
74
88
  importmap-rails the engine pins it for you, so add `import "unmagic/components"`
75
89
  to your application.js (see [Dialogs](#dialogs)).
76
90
 
91
+ ### Browsing components
92
+
93
+ `Unmagic::Components::Browser` is an engine for looking through every component,
94
+ its examples and their source inside your own app. Mount it in your routes:
95
+
96
+ ```ruby
97
+ mount Unmagic::Components::Browser::Engine => "/unmagic/components" if Rails.env.development?
98
+ ```
99
+
100
+ It brings its own stylesheet, prebuilt with Tailwind's default theme, and loads
101
+ the components' JavaScript and Turbo itself, so nothing in your CSS or JS
102
+ changes. It needs `turbo-rails` in your bundle. It renders through your
103
+ configuration, so a `control_class` pointed at your own classes leaves its form
104
+ examples unstyled.
105
+
106
+ Its controllers inherit `ActionController::Base`, so your authentication doesn't
107
+ cover it. Its demo endpoints only write to the visitor's session, but mount it
108
+ outside development only behind a constraint of your own:
109
+
110
+ ```ruby
111
+ authenticate :user, ->(user) { user.admin? } do
112
+ mount Unmagic::Components::Browser::Engine => "/unmagic/components"
113
+ end
114
+ ```
115
+
77
116
  ## Theming
78
117
 
79
118
  The components use Tailwind's own palette (neutral for surfaces, borders and
@@ -110,7 +149,7 @@ The gem's rules sit in `@layer components`, which Tailwind orders before
110
149
 
111
150
  ## Configuration
112
151
 
113
- Three seams, each with a working default. Point them at your own versions if you
152
+ Seams, each with a working default. Point them at your own versions if you
114
153
  already own these concerns:
115
154
 
116
155
  ```ruby
@@ -130,6 +169,16 @@ Unmagic::Components.configure do |config|
130
169
  # renderer only needs something answering previous/next/page_url, so
131
170
  # geared_pagination or your own object works just as well.
132
171
  config.pagy_for = ->(view, collection) { view.table_pagy(collection) }
172
+
173
+ # A block of code: a tool call's payload, a failure's backtrace. Called with
174
+ # (view, source, language), where language is :json, :plaintext or whatever
175
+ # you passed; return markup. The default is an unhighlighted <pre><code>.
176
+ config.code_block = ->(view, source, language) { view.highlight_code(source, language: language) }
177
+
178
+ # What a sortable item carries for a record, and where drops post. The
179
+ # unmagic-sortable gem sets both to its signed keys and endpoint.
180
+ config.sortable_item = ->(view, record) { { key: record.to_param, rank: record.sortable_rank } }
181
+ config.sortable_url = ->(view) { view.reorder_path }
133
182
  end
134
183
  ```
135
184
 
@@ -829,6 +878,462 @@ Needs `import "unmagic/components/toasts"`, which `import "unmagic/components"`
829
878
  includes. The dismiss button's label is `unmagic.components.toast.dismiss`
830
879
  ("Dismiss").
831
880
 
881
+ ## Sortable lists and boards
882
+
883
+ Drag-and-drop ordering, by pointer or keyboard. The server half — ranks, signed
884
+ keys and the endpoint — is the
885
+ [unmagic-sortable](https://github.com/unreasonable-magic/unmagic-sortable) gem,
886
+ which configures these components when it's installed:
887
+
888
+ ```ruby
889
+ class Card < ApplicationRecord
890
+ include Unmagic::Sortable
891
+ sortable_within :board_id
892
+ sortable_column :column_id
893
+ end
894
+ ```
895
+
896
+ ### `sortable_list(namespace: nil, params: {}, url: nil, orientation: :vertical, label: nil, **options, &block)`
897
+
898
+ ```erb
899
+ <%= sortable_list label: "Interview steps", class: "flex flex-col gap-2" do |list| %>
900
+ <% @steps.ordered.each do |step| %>
901
+ <%= list.item(step) do %>
902
+ <%= sortable_handle label: "Move #{step.name}" %>
903
+ <%= step.name %>
904
+ <% end %>
905
+ <% end %>
906
+ <% end %>
907
+ ```
908
+
909
+ - **Items:** `list.item(record)` takes its key and rank from
910
+ `config.sortable_item`; `key:` and `rank:` set them directly, and `label:` is
911
+ what a screen reader calls it.
912
+ - **Handles:** with a `sortable_handle` in an item, only the handle drags, so the
913
+ rest stays clickable, and the handle is the keyboard stop. Without one, the
914
+ whole item drags once the pointer moves, and the item takes focus.
915
+ - **Touch:** a finger lifts an item with a long press, so a swipe still scrolls
916
+ and a tap still taps. A `sortable_handle` grip drags at once.
917
+ - **Between lists:** lists sharing a `namespace:` exchange items. A drop posts
918
+ the destination list's `params:`.
919
+ - **Keyboard:** Space or Enter picks an item up. The arrows along the list move
920
+ it (`orientation:` decides which) and the arrows across move it to the next
921
+ list. Space drops it; Escape, or tabbing away, puts it back. Each step is
922
+ announced, and Escape also cancels a pointer drag.
923
+ - **Scrolling:** a drag near the edge of anything scrollable scrolls it.
924
+ - **The drop** fires a cancelable `unmagic-sortable:move` with
925
+ `{ key, original, prev, next, params }`, then PATCHes `url:`
926
+ (`config.sortable_url`) with `moved`, `original`, `prev`, `next` and the params.
927
+ Without a url, the event is all there is.
928
+ - **Styling:** `sortable-dragging:`, `sortable-placeholder:`, `sortable-lifted:`,
929
+ `sortable-active:` and `sortable-over:` variants decorate the drag states.
930
+
931
+ Needs `import "unmagic/components/sortable"`. I18n under
932
+ `unmagic.components.sortable`: `handle`, `instructions`, `picked`, `moved`,
933
+ `dropped` and `cancelled`, with `{item}`, `{list}`, `{position}` and `{count}`.
934
+
935
+ ### `board(id:, url: nil, columns_url: nil, sortable_columns: true, label: nil, column_height: nil, **options, &block)`
936
+
937
+ ```erb
938
+ <%= board id: "roadmap" do |board| %>
939
+ <% @columns.each do |column| %>
940
+ <% board.column column, title: column.name, params: { column_id: column.id } do |col| %>
941
+ <% col.actions { menu { |m| m.link "Rename", edit_column_path(column) } } %>
942
+ <% column.cards.ordered.each { |card| col.card(card) { render card } } %>
943
+ <% col.add url: cards_path, field: "card[title]", params: { "card[column_id]" => column.id } %>
944
+ <% end %>
945
+ <% end %>
946
+ <% board.add_column url: columns_path, field: "column[name]" %>
947
+ <% end %>
948
+ ```
949
+
950
+ - **Moving:** cards move within and between the board's columns, posting the
951
+ column's `params:`. Columns move along the board by their header, and the grip
952
+ in it is their keyboard stop. On a phone, press and hold a card or a header. `sortable_columns: false` fixes them in place.
953
+ - **Forms:** `col.add` and `board.add_column` open into a one-field form. Enter
954
+ submits it, Escape closes it, and it stays open after adding, so several can be
955
+ added in a row.
956
+ - **Size:** `column_height:` (default `75dvh`) is how tall a column grows before
957
+ its cards scroll.
958
+ - **Where drops go:** `url:` is where they post; `columns_url:` sends column drops
959
+ elsewhere.
960
+
961
+ Needs `import "unmagic/components/sortable"` and `"unmagic/components/board"`.
962
+ I18n under `unmagic.components.board`: `label`, `add_card`, `add_card_submit`,
963
+ `add_column`, `add_column_submit`, `cancel`, `move_column` and `cards`.
964
+
965
+ ## Avatars
966
+
967
+ ### `avatar(name, src: nil, size: :medium, shape: :circle, tint: true, skeleton: false, **options)`
968
+
969
+ ```erb
970
+ <%= avatar "Ada Lovelace" %>
971
+ <%= avatar @user.name, src: @user.avatar_url, size: :large %>
972
+ <%= avatar "Acme Ltd", shape: :square %>
973
+
974
+ <%= avatar_group max: 3, size: :small do |group| %>
975
+ <% @members.each { |member| group.avatar member.name, src: member.avatar_url } %>
976
+ <% end %>
977
+ ```
978
+
979
+ - **Initials** come from the first and last words ("Ada Lovelace" is "AL") and
980
+ always render. The image sits on top of them, so a broken image shows the
981
+ initials without any script.
982
+ - **Tint:** the initials sit on one of six palette tints picked from the name
983
+ with `unmagic-color`'s stable string hash, so a person keeps their colour on
984
+ every page and every server. `tint: false` is neutral. Override
985
+ `UnmagicAvatar--tint-1` … `-6` to recolour them.
986
+ - **Sizes:** `:small`, `:medium` and `:large` (1.5, 2 and 2.5rem).
987
+ `skeleton: true` renders a skeleton circle of the same size.
988
+ - **Accessibility:** an avatar is `role="img"` named for the person. Pass
989
+ `"aria-hidden": true` when the name is written beside it.
990
+ - **Groups:** `avatar_group` sets the size and shape for every avatar in it, and
991
+ collapses past `max:` into a "+N" whose title names the rest.
992
+
993
+ I18n: `unmagic.components.avatar.group` ("%{count} people") and
994
+ `unmagic.components.avatar.more` ("+%{count}").
995
+
996
+ ## Elapsed times
997
+
998
+ ### `elapsed_tag(time, direction: :up, **options)`
999
+
1000
+ ```erb
1001
+ <%= elapsed_tag tool_call.started_at %> <%# 3m 5s, and counting %>
1002
+ <%= elapsed_tag session.expires_at, direction: :down %> <%# 42s, and falling %>
1003
+ ```
1004
+
1005
+ A clock that counts up from a moment the server named, a second at a time, or
1006
+ down to one, stopping at zero and firing `unmagic-elapsed:end`. Work that takes a
1007
+ minute and work that has hung look the same behind a spinner; a number that keeps
1008
+ moving is the difference.
1009
+
1010
+ The server renders the current reading, so it is right before the script loads.
1011
+ Readings are whole seconds — `2s`, `3m 5s`, `1h 4m 2s` — and a settled duration
1012
+ under a second is milliseconds (`640ms`). The words are the
1013
+ `unmagic.components.elapsed.*` keys (`milliseconds`, `seconds`, `minutes`,
1014
+ `hours`); the element writes the English forms as it ticks. A blank time renders
1015
+ an em dash. Needs `import "unmagic/components/elapsed"`.
1016
+
1017
+ ## AI chat
1018
+
1019
+ Components for rendering an agent's work, in the spirit of
1020
+ [assistant-ui](https://www.assistant-ui.com) but server-rendered: the host renders
1021
+ each turn, Turbo Streams keep it live, and small custom elements pace the reveal.
1022
+
1023
+ ```erb
1024
+ <%= turbo_stream_from @chat %>
1025
+
1026
+ <div id="transcript" class="h-[70dvh] overflow-y-auto">
1027
+ <%= ai_chat id: "entries", scroller: "#transcript" do |chat| %>
1028
+ <% chat.welcome do %>
1029
+ <%= ai_chat_welcome heading: "What can I help with?" do |welcome| %>
1030
+ <% welcome.suggestion "Who hasn't replied yet?" %>
1031
+ <% end %>
1032
+ <% end %>
1033
+ <%= render @chat.entries %>
1034
+ <% end %>
1035
+ </div>
1036
+
1037
+ <%= form_with model: Message.new, url: chat_messages_path(@chat), id: "composer" do |form| %>
1038
+ <%= ai_chat_composer form: form, field: :content, state: @chat.run_state do |composer| %>
1039
+ <% composer.optimistic id: "message[client_id]", container: "#entries" %>
1040
+ <% end %>
1041
+ <% end %>
1042
+ <%= ai_chat_stop_form chat_stop_path(@chat) %>
1043
+ ```
1044
+
1045
+ ```erb
1046
+ <%# app/views/messages/_message.html.erb %>
1047
+ <%= ai_chat_message role: message.role.to_sym, id: dom_id(message), streaming: message.pending? do %>
1048
+ <%= message.user? ? message.content : Markdown.render(message.content) %>
1049
+ <% end %>
1050
+ ```
1051
+
1052
+ They need Turbo and `import "unmagic/components"`, or the modules named below.
1053
+
1054
+ ### How the pieces fit
1055
+
1056
+ - **Entries are upserted by id.** Broadcast each entry with the gem's `upsert`
1057
+ action into the transcript's id. A record already on the page is replaced in
1058
+ place; a new one is inserted in id order.
1059
+
1060
+ ```ruby
1061
+ broadcast_action_to chat, action: :upsert, target: "entries", partial: "messages/message", locals: { message: self }
1062
+ ```
1063
+
1064
+ - **The question is drawn before the server has it.** `composer.optimistic`
1065
+ renders an `<unmagic-uuid-input>` and a template of the question. On submit,
1066
+ `<unmagic-optimistic>` draws the question, dimmed, under the minted id. Create
1067
+ the record under that id (`message[client_id]`) and its broadcast replaces the
1068
+ placeholder.
1069
+ - **Replies stream as whole renders.** While a reply is being written, send the
1070
+ whole of it so far, rendered, to the body's id. `<unmagic-streaming-markdown>`
1071
+ works out what's new and reveals it at the model's own average pace, so bursts
1072
+ read as one stream. When the turn settles, upsert the whole message. The new
1073
+ element picks up the reveal where the old one left off.
1074
+
1075
+ ```ruby
1076
+ broadcast_action_to chat, action: :stream_markdown, target: "#{dom_id(self)}_content",
1077
+ html: Markdown.render(content)
1078
+ ```
1079
+
1080
+ - **The gem never parses Markdown.** Hand it your own rendered, sanitised HTML.
1081
+ `UnmagicProse` styles it, and code blocks, like tool payloads, go through
1082
+ `config.code_block`.
1083
+ - **Only the composer's button changes with the turn's state.** Redraw it with
1084
+ `ai_chat_composer_action` so a half-typed draft survives.
1085
+ - **Amber means waiting on a person.** A question, a permission request or a plan
1086
+ step parked on someone is the one state that won't move on its own, so it is
1087
+ the one in colour.
1088
+
1089
+ ### `ai_chat(id:, scroller: nil, follow: true, scroll_to_latest: true, **options, &block)`
1090
+
1091
+ The scrolling region turns are rendered into: a polite `role="log"`. It renders
1092
+ no entries of its own; use a partial per entry type.
1093
+
1094
+ - **Following:** it keeps the scroller (the page, or `scroller:`) at the bottom
1095
+ while the reader is there, and stops when they scroll up. A jump-to-latest
1096
+ button shows meanwhile (`scroll_to_latest: false` for none). `follow: false`
1097
+ renders a bare log.
1098
+ - **Spacing:** turns are separated by a gap that hidden entries don't earn. Mark
1099
+ hidden entries between two tool calls (a tool result's anchor) with
1100
+ `data-ai-chat-timeline="gap"` so the run stays joined.
1101
+ - **Welcome:** `chat.welcome` shows while there are no entries, and hides as the
1102
+ first one arrives.
1103
+
1104
+ Needs `import "unmagic/components/autoscroll"`. I18n:
1105
+ `unmagic.components.ai_chat.transcript.label` ("Conversation") and `.latest`
1106
+ ("Jump to latest"). The button's dock is sticky at `bottom-4`; lift it with
1107
+ `.UnmagicAIChat__latest { bottom: … }` if a sticky composer covers it.
1108
+
1109
+ ### `ai_chat_message(content = nil, role:, id: nil, streaming: false, final: false, optimistic: nil, **options, &block)`
1110
+
1111
+ One turn. A user's turn is a bubble of plain text with its line breaks kept; an
1112
+ assistant's is unbubbled prose.
1113
+
1114
+ - **The body** of an assistant turn with an id is an
1115
+ `<unmagic-streaming-markdown id="#{id}_content">`. `streaming: true` shows a
1116
+ thinking spinner until the first flush and marks the body busy. `final: true`
1117
+ refuses any later flush, for a stopped or failed reply.
1118
+ - **Empty:** a settled assistant turn with nothing in it is `hidden`, keeping its
1119
+ id for broadcasts.
1120
+ - **Parts:** `turn.reasoning(**options) { }` (see `ai_chat_reasoning`),
1121
+ `turn.actions { }`, `turn.branches { }` and `turn.attachments { }` (above a
1122
+ user's bubble).
1123
+ - **Optimistic:** `optimistic: { id:, text: }` renders the template a composer
1124
+ fills from those fields, dimmed.
1125
+
1126
+ I18n: `unmagic.components.ai_chat.message.user` ("You said"), `.assistant`
1127
+ ("Assistant said") and `.thinking` ("Thinking"), for screen readers.
1128
+
1129
+ ### `streaming_markdown_tag(content = nil, id:, final: false, streaming: false, **options, &block)`
1130
+
1131
+ The streaming body on its own. Send flushes with
1132
+ `turbo_stream.stream_markdown(target, html)`, each carrying the whole render so
1133
+ far. Under reduced motion each flush paints at once. It fires
1134
+ `unmagic-streaming-markdown:settle` when it has caught up. Needs
1135
+ `import "unmagic/components/streaming_markdown"`.
1136
+
1137
+ ### `ai_chat_reasoning(content = nil, title: nil, streaming: false, duration: nil, open: false, **options, &block)`
1138
+
1139
+ The model's thinking, collapsed above its reply. `duration:` titles it "Thought
1140
+ for 12s"; `streaming: true` shows "Thinking…". `reasoning.block { }` adds a
1141
+ block. Blank content renders nothing. I18n:
1142
+ `unmagic.components.ai_chat.reasoning.title`, `.thinking` and `.duration`.
1143
+
1144
+ ### `ai_chat_tool_call(name:, state:, id: nil, icon: nil, open: false, timeline: true, **options, &block)`
1145
+
1146
+ ```erb
1147
+ <%= ai_chat_tool_call name: call.name, state: call.state, id: dom_id(call), icon: call.category_icon do |tool| %>
1148
+ <% tool.summary call.summary %>
1149
+ <% tool.timing started_at: call.started_at, duration: call.duration %>
1150
+ <% tool.asked call.arguments %>
1151
+ <% tool.answered call.response %>
1152
+ <% end %>
1153
+ ```
1154
+
1155
+ - **State:** `:queued`, `:running`, `:waiting`, `:done` or `:failed`. A running
1156
+ call is busy, spins, and counts up with `elapsed_tag`. A done call shows
1157
+ `icon:`, what it was about, in place of a column of identical ticks.
1158
+ - **Timeline:** consecutive calls are joined by a line, which runs down an open
1159
+ call's body. `timeline: false` leaves a call out of the run.
1160
+ - **Parts:** `summary`, `timing`, `asked` and `answered` (payloads, in a
1161
+ `<details>`, so no script and no state to restore after a redraw), `failures`
1162
+ (a count for a batch that mostly worked), `progress` (a line with id
1163
+ `"#{id}_progress"` to replace as the tool reports), and `made { }` (output
1164
+ kept outside the fold).
1165
+
1166
+ I18n under `unmagic.components.ai_chat.tool_call`: `queued`, `running`,
1167
+ `waiting`, `done`, `failed`, `asked`, `answered`, `failures`, `partly_failed`,
1168
+ `running_for` and `took`.
1169
+
1170
+ ### `ai_chat_payload(payload, label: nil, language: nil, duration: nil, copy: false, **options)`
1171
+
1172
+ What went into a tool call or came back. A Hash, an Array, or a string holding
1173
+ JSON is laid out a key to a line as `:json`; anything else is `:plaintext`
1174
+ unless `language:` says otherwise. It renders through `config.code_block`,
1175
+ scrolls past a height, wraps long lines, and is reachable by keyboard. `copy:
1176
+ true` adds a copy button. `nil` renders nothing. I18n:
1177
+ `unmagic.components.ai_chat.payload.took` and `.label` ("Payload").
1178
+
1179
+ ### `ai_chat_composer(form:, field:, state: :idle, label: nil, stop_form: "stop_turn", placeholder: nil, rows: 2, **options, &block)`
1180
+
1181
+ - **The form needs an `id:`.** Send names it with `form=`, and Stop names
1182
+ `stop_form:`. Render `ai_chat_stop_form(url)` outside your form, since forms
1183
+ can't nest.
1184
+ - **State** — `:idle`, `:running`, `:stopping` or `:waiting` — changes only the
1185
+ button region (id `"#{form_id}_action"`, a polite live region). The field is
1186
+ never disabled. Redraw the region alone with
1187
+ `ai_chat_composer_action(form:, state:)`.
1188
+ - **Keys:** Enter sends and Shift+Enter makes a new line. Enter does nothing
1189
+ while there's no Send button. A successful submit resets the form and keeps
1190
+ focus in the field.
1191
+ - **Parts:** `composer.optimistic(id:, container:)`, `composer.attach { }`,
1192
+ `composer.actions { }` and `composer.menu { }` (an `ai_chat_slash_menu`).
1193
+ - **The field** is the composer's own chrome, not a `control_class` control: the
1194
+ box around it is the control and takes the focus ring.
1195
+
1196
+ Needs `import "unmagic/components/ai_chat"`. I18n under
1197
+ `unmagic.components.ai_chat.composer`: `send`, `stop`, `stopping`, `waiting`
1198
+ and `label`.
1199
+
1200
+ ### `ai_chat_slash_menu(for: nil, id: nil, trigger: "/", above: false, insert: nil, **options, &block)`
1201
+
1202
+ Commands offered on a slash. `menu.item(name, description:, arguments:)` for
1203
+ each; every item is rendered and typing filters them by prefix. Up and Down move,
1204
+ Enter or Tab takes one, Escape closes, and focus stays in the field (the combobox
1205
+ pattern). Picking writes `"/name "` at the cursor. `for:` defaults to the
1206
+ composer's field. Needs `import "unmagic/components/slash_menu"`. I18n:
1207
+ `unmagic.components.ai_chat.slash_menu.label` ("Commands").
1208
+
1209
+ ### `ai_chat_attachments(align: :start, **options, &block)` and `ai_chat_dropzone(input:, url: nil, field: nil, chips: nil, label: nil, **options, &block)`
1210
+
1211
+ `ai_chat_attachments` lists the files a sent turn carries
1212
+ (`files.file name, size:, url:, thumbnail:`; `align: :end` for a user's turn).
1213
+
1214
+ `ai_chat_dropzone` wraps a region that takes dropped and pasted files, with an
1215
+ overlay while dragging. `input:` is the file input the files join, which is also
1216
+ the way in for anyone who can't drag. Without `url:`, files wait on that input
1217
+ and post with the form. With `url:`, each file is POSTed on the spot as `file`,
1218
+ and the JSON response's `value` is posted with the message under `field:`.
1219
+ `chips:` is where attached files are listed: the composer's
1220
+ `[data-ai-chat-chips]`. It fires `unmagic-dropzone:attach` and `:error`. Needs
1221
+ `import "unmagic/components/dropzone"`. I18n under
1222
+ `unmagic.components.ai_chat.attachments`: `drop`, `attached`, `remove` and
1223
+ `failed`.
1224
+
1225
+ ### `ai_chat_welcome(heading:, heading_tag: :h2, composer: "composer", field: nil, **options, &block)`
1226
+
1227
+ What an empty conversation says. `welcome.body(text)` and
1228
+ `welcome.suggestion(label, icon:, fill:, value:)`. Pressing a suggestion puts it
1229
+ in the composer form with id `composer:` and sends it, unless `fill: true` or a
1230
+ turn is running. Needs `import "unmagic/components/ai_chat"`.
1231
+
1232
+ ### `ai_chat_request(state:, url: nil, method: :patch, prompt: nil, label: nil, scope: :response, live: false, **options, &block)`
1233
+
1234
+ ```erb
1235
+ <%= ai_chat_request state: :waiting, url: answer_path(@chat), live: true do |request| %>
1236
+ <% request.question "How should it sound?", header: "Tone" do |q| %>
1237
+ <% q.option "Friendly", description: "A short, warm check-in" %>
1238
+ <% q.option "Direct" %>
1239
+ <% end %>
1240
+ <% end %>
1241
+ ```
1242
+
1243
+ - **Ask with questions** (`multiple: true` for checkboxes; answers post as
1244
+ `answers[i][]`) **or with a form** (`request.form { |form| … }`), not both.
1245
+ `request.decline` adds a Decline that skips validation.
1246
+ - **Answered states** — `:accepted`, `:declined`, `:cancelled`, `:timed_out` —
1247
+ keep the questions and show what was picked (`question picked:`) or answered
1248
+ (`request.answer label, value`). Declined and dropped cards dim.
1249
+ - **`live: true`** adds `role="alert"` and, if nothing else has focus, focuses
1250
+ the first option.
1251
+
1252
+ I18n under `unmagic.components.ai_chat.request`: `waiting`, `asked`, `answer`,
1253
+ `decline`, `accepted`, `declined`, `cancelled`, `timed_out` and `unanswered`.
1254
+
1255
+ ### `ai_chat_permission(tool:, state:, url: nil, live: false, outcome: nil, **options, &block)`
1256
+
1257
+ ```erb
1258
+ <%= ai_chat_permission tool: "delete_files", state: :waiting, url: permission_path(@call) do |ask| %>
1259
+ <% ask.argument "paths", "drafts/*" %>
1260
+ <% ask.summary { markdown(@call.summary) } %>
1261
+ <% ask.allow confirm: "Delete these files? This can't be undone." %>
1262
+ <% ask.allow "Allow for any chat", params: { widened: true } %>
1263
+ <% ask.refuse %>
1264
+ <% end %>
1265
+ ```
1266
+
1267
+ - **The ask** — the tool's own name and its arguments — comes before the
1268
+ reasoning, because it's what the decision is about.
1269
+ - **Actions:** the first `allow` is the prominent, red, consequential choice; a
1270
+ later one is the wider grant. Offer that only when there is something to
1271
+ widen. `allow` POSTs and `refuse` DELETEs to `url:`, each with `params:` and
1272
+ `confirm:`.
1273
+ - **Focus** goes to the card when it arrives, never to Allow.
1274
+ - **Answered:** `:granted`, `:refused`, or `:lapsed` (answered in the chat,
1275
+ nothing granted), with `outcome:` to reword the sentence.
1276
+
1277
+ I18n under `unmagic.components.ai_chat.permission`: `waiting`, `asked`, `allow`,
1278
+ `refuse`, `granted`, `refused` and `lapsed`.
1279
+
1280
+ ### `ai_chat_proposal(state: :pending, icon: :lightbulb, id: nil, **options, &block)`
1281
+
1282
+ An offer the agent made in passing, which doesn't stop the work: `offer.claim`,
1283
+ `offer.meta`, `offer.accept { }` and `offer.reject { }` while `:pending`; the
1284
+ outcome (`"Saved"`, `"Dismissed"`, or `offer.outcome`) once `:accepted` or
1285
+ `:rejected`. It is an `<aside>` named by its claim, and `not-prose` so it can sit
1286
+ inside a reply. I18n: `unmagic.components.ai_chat.proposal.accepted` and
1287
+ `.rejected`.
1288
+
1289
+ ### `ai_chat_citation(compact: false, **options, &block)`
1290
+
1291
+ A quotation rendered from the record, not the model's paraphrase:
1292
+ `cite.avatar` (with no block, the gem's avatar for `who`), `cite.who`,
1293
+ `cite.when(time, url:)` and `cite.quote(text)`. The quote is escaped text with
1294
+ its line breaks kept. `compact: true` is one line, for a list of sources.
1295
+
1296
+ ### `ai_chat_failure(message = nil, live: false, **options, &block)`
1297
+
1298
+ A turn that fell over. The sentence is for whoever asked. `failure.cause` sits
1299
+ above the fold; `failure.detail(label, payload, language:)` rows fold away for
1300
+ whoever works on the assistant; `failure.retry { }` holds your control. Render
1301
+ it below a turn's body, never in its place. `live: true` adds `role="alert"`.
1302
+ I18n: `unmagic.components.ai_chat.failure.details`.
1303
+
1304
+ ### `ai_chat_plan(**options, &block)` and `ai_chat_workspace(**options, &block)`
1305
+
1306
+ Two sections of one side panel. Both are collapsible, open by default, and
1307
+ always render, empty or not, so a broadcast has something to replace.
1308
+
1309
+ - **`plan.step(title, state:)`:** `:pending`, `:in_progress`, `:waiting` or
1310
+ `:completed`, with an optional block for detail. The count reads
1311
+ completed/total, or `completed:`/`total:` when a panel shows only some steps.
1312
+ - **`workspace.file(path, size:, url:, icon:)`:** a flat list of paths. A file
1313
+ with a url is a link.
1314
+ - **Both take** `title:`, `open:`, `empty:`, `collapsible:` and `title_tag:`
1315
+ (`:h2`). The workspace also takes `count:`.
1316
+
1317
+ I18n under `unmagic.components.ai_chat`: `plan.title`, `plan.empty`,
1318
+ `plan.pending`, `plan.in_progress`, `plan.waiting`, `plan.completed`,
1319
+ `workspace.title` and `workspace.empty`.
1320
+
1321
+ ### `ai_chat_action_bar(for:, reveal: :hover, **options, &block)` and `ai_chat_branch_picker(index:, count:, previous: nil, next: nil, method: :get, **options)`
1322
+
1323
+ The controls under a turn. `bar.copy(text)`, `bar.action(label, url, icon:,
1324
+ method:, confirm:)` and `bar.control { }` make a toolbar with one Tab stop and
1325
+ arrow keys between controls (`<unmagic-toolbar>`). `reveal: :hover` shows it on
1326
+ hover or focus, and always on touch screens; it is never hidden from the
1327
+ keyboard.
1328
+
1329
+ `ai_chat_branch_picker` walks between versions of a turn: "Version 2 of 3",
1330
+ with a disabled end where there's nowhere to go. What a branch is stays your
1331
+ application's business, and one version renders nothing.
1332
+
1333
+ Needs `import "unmagic/components/toolbar"` and `"unmagic/components/ai_chat"`.
1334
+ I18n: `unmagic.components.ai_chat.action_bar.label` and
1335
+ `unmagic.components.ai_chat.branch_picker.label`, `.previous` and `.next`.
1336
+
832
1337
  ## Development
833
1338
 
834
1339
  ```sh