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
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # The files an agent has put aside while it works. A flat list for now: a
7
+ # nested tree waits on the gem's tree_view, rather than the gem growing two
8
+ # tree implementations. See ActionViewHelpers#ai_chat_workspace.
9
+ class Workspace
10
+ File = Struct.new(:path, :size, :url, :icon)
11
+
12
+ def initialize(view, title: nil, count: nil, open: true, empty: nil, collapsible: true, title_tag: :h2, **options)
13
+ @view = view
14
+ @title = title || AIChat.t("workspace.title", default: "Workspace")
15
+ @count = count
16
+ @open = open
17
+ @empty = empty
18
+ @collapsible = collapsible
19
+ @title_tag = title_tag
20
+ @options = options
21
+ @files = []
22
+ end
23
+
24
+ def file(path, size: nil, url: nil, icon: :file)
25
+ @files << File.new(path.to_s, size, url, icon)
26
+ nil
27
+ end
28
+
29
+ def render
30
+ count = @count || (@files.size if @files.any?)
31
+
32
+ Section.new(view, block: "UnmagicAIChatWorkspace", icon: :folder, title: @title, count: count,
33
+ open: @open, collapsible: @collapsible, title_tag: @title_tag, options: @options).render(body)
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :view
39
+
40
+ delegate :tag, :safe_join, to: :view, private: true
41
+
42
+ def body
43
+ if @files.empty?
44
+ tag.p(@empty || AIChat.t("workspace.empty", default: "No files yet."), class: "UnmagicAIChatWorkspace__empty")
45
+ else
46
+ tag.ul(safe_join(@files.map { |file| item(file) }), class: "UnmagicAIChatWorkspace__files")
47
+ end
48
+ end
49
+
50
+ # Not a link without a url: a workspace is usually read-only, and a link
51
+ # that goes nowhere is worse than plain text.
52
+ def item(file)
53
+ name = ::File.basename(file.path)
54
+ directory = ::File.dirname(file.path)
55
+
56
+ row = safe_join [
57
+ Icons.svg(view, file.icon, class: "UnmagicAIChatWorkspace__icon"),
58
+ tag.span(class: "UnmagicAIChatWorkspace__name") do
59
+ safe_join [
60
+ (tag.span("#{directory}/", class: "UnmagicAIChatWorkspace__directory") unless directory == "."),
61
+ name
62
+ ].compact
63
+ end,
64
+ (tag.span(view.number_to_human_size(file.size), class: "UnmagicAIChatWorkspace__size") if file.size)
65
+ ].compact
66
+
67
+ tag.li(class: "UnmagicAIChatWorkspace__file", title: file.path) do
68
+ file.url ? view.link_to(row, file.url, class: "UnmagicAIChatWorkspace__link") : row
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Unmagic
6
+ module Components
7
+ # The components for rendering an agent's work: a transcript of turns, the
8
+ # tools it reached for, the plan it is following, the questions it stopped to
9
+ # ask, and the box a person answers in. ai_chat is the root; the rest are
10
+ # ai_chat_* helpers. See docs/components/ai_chat.
11
+ module AIChat
12
+ # Validates an enumerated option the way every component in the gem does.
13
+ def self.validate!(component, option, value, allowed)
14
+ return if allowed.include?(value)
15
+
16
+ raise ArgumentError, "unknown #{component} #{option} #{value.inspect} (expected one of #{allowed.inspect})"
17
+ end
18
+
19
+ # A word the gem prints, under the family's I18n scope.
20
+ def self.t(key, **options)
21
+ I18n.t("unmagic.components.ai_chat.#{key}", **options)
22
+ end
23
+
24
+ # A base for element ids that must reference each other when the caller
25
+ # gave none.
26
+ def self.random_id(name)
27
+ "unmagic_ai_chat_#{name}_#{SecureRandom.hex(4)}"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/filters"
4
+ require "unmagic/color"
5
+
6
+ module Unmagic
7
+ module Components
8
+ # A person's or organisation's picture, falling back to their initials. See
9
+ # ActionViewHelpers#avatar.
10
+ class Avatar
11
+ SIZES = %i[small medium large].freeze
12
+ SHAPES = %i[circle square].freeze
13
+ TINTS = 6
14
+
15
+ # The skeleton for each size, so a loading avatar and a loaded one line up.
16
+ DIMENSIONS = { small: "1.5rem", medium: "2rem", large: "2.5rem" }.freeze
17
+
18
+ class << self
19
+ # "Ada Lovelace" → "AL", "Plato" → "P".
20
+ def initials(name)
21
+ words = name.to_s.squish.split
22
+ return "" if words.empty?
23
+
24
+ [ words.first, (words.last if words.size > 1) ].compact.map { |word| word[0] }.join.upcase
25
+ end
26
+
27
+ # One of six palette tints, the same for a name on every page and every
28
+ # server. Ruby's String#hash is seeded per process, so it can't be used here;
29
+ # unmagic-color's string hash is stable.
30
+ def tint(name)
31
+ normalised = name.to_s.squish.downcase
32
+ return if normalised.empty?
33
+
34
+ (Unmagic::Color::String::HashFunction.call(normalised) % TINTS) + 1
35
+ end
36
+
37
+ def validate!(size, shape)
38
+ unless SIZES.include?(size)
39
+ raise ArgumentError, "unknown avatar size #{size.inspect} (expected one of #{SIZES.inspect})"
40
+ end
41
+ return if SHAPES.include?(shape)
42
+
43
+ raise ArgumentError, "unknown avatar shape #{shape.inspect} (expected one of #{SHAPES.inspect})"
44
+ end
45
+ end
46
+
47
+ def initialize(view, name, src: nil, size: :medium, shape: :circle, tint: true, skeleton: false, **options)
48
+ self.class.validate!(size, shape)
49
+
50
+ @view = view
51
+ @name = name.to_s.squish
52
+ @src = src
53
+ @size = size
54
+ @shape = shape
55
+ @tint = tint
56
+ @skeleton = skeleton
57
+ @options = options
58
+ end
59
+
60
+ # The initials always render and the image sits on top of them, so an image
61
+ # that fails to load shows the initials through it without any script.
62
+ def render
63
+ return Skeleton.new(view).circle(size: DIMENSIONS.fetch(@size), **@options) if @skeleton
64
+
65
+ tint = self.class.tint(@name) if @tint
66
+ view.content_tag(:span, **@options,
67
+ role: "img",
68
+ "aria-label": @name.presence,
69
+ title: @name.presence,
70
+ class: view.class_names("UnmagicAvatar", "UnmagicAvatar--#{@size}",
71
+ { "UnmagicAvatar--square" => @shape == :square, "UnmagicAvatar--tint-#{tint}" => tint }, @options[:class])) do
72
+ safe_join [
73
+ tag.span(@name.present? ? self.class.initials(@name) : "—", class: "UnmagicAvatar__initials", "aria-hidden": "true"),
74
+ (tag.img(src: @src, alt: "", loading: "lazy", decoding: "async", class: "UnmagicAvatar__image") if @src.present?)
75
+ ].compact
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ attr_reader :view
82
+
83
+ delegate :tag, :safe_join, to: :view, private: true
84
+ end
85
+
86
+ # A stack of avatars, collapsing past max: into a "+N" counter. See
87
+ # ActionViewHelpers#avatar_group.
88
+ class AvatarGroup
89
+ def initialize(view, max: nil, size: :medium, shape: :circle, **options)
90
+ Avatar.validate!(size, shape)
91
+
92
+ @view = view
93
+ @max = max
94
+ @size = size
95
+ @shape = shape
96
+ @options = options
97
+ @people = []
98
+ end
99
+
100
+ # The group sets the size, so a stack never mixes them.
101
+ def avatar(name, **options)
102
+ raise ArgumentError, "an avatar in a group takes the group's size" if options.key?(:size)
103
+ raise ArgumentError, "an avatar in a group takes the group's shape" if options.key?(:shape)
104
+
105
+ @people << [ name, options ]
106
+ nil
107
+ end
108
+
109
+ def render
110
+ shown = @max ? @people.first(@max) : @people
111
+ hidden = @people.drop(shown.size)
112
+ label = I18n.t("unmagic.components.avatar.group", count: @people.size, default: "%{count} people")
113
+
114
+ view.content_tag(:div, **@options, role: "group", "aria-label": label,
115
+ class: view.class_names("UnmagicAvatarGroup", "UnmagicAvatarGroup--#{@size}", @options[:class])) do
116
+ view.safe_join [
117
+ *shown.map { |name, options| Avatar.new(view, name, size: @size, shape: @shape, **options).render },
118
+ (more(hidden) if hidden.any?)
119
+ ].compact
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ attr_reader :view
126
+
127
+ def more(hidden)
128
+ view.tag.span(I18n.t("unmagic.components.avatar.more", count: hidden.size, default: "+%{count}"),
129
+ title: hidden.map(&:first).join(", "),
130
+ class: view.class_names("UnmagicAvatarGroup__more", { "UnmagicAvatar--square" => @shape == :square }))
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # A Trello-style board: columns of cards, both reorderable, with a way to add
6
+ # to each. Built from sortable lists, so it posts the same drops. See
7
+ # ActionViewHelpers#board.
8
+ class Board
9
+ def initialize(view, id:, url: nil, columns_url: nil, sortable_columns: true, label: nil, column_height: nil,
10
+ **options)
11
+ raise ArgumentError, "board needs an id: (it names the board's lists)" if id.blank?
12
+
13
+ @view = view
14
+ @id = id.to_s
15
+ @url = url
16
+ @columns_url = columns_url.nil? ? url : columns_url
17
+ @sortable_columns = sortable_columns
18
+ @label = label || I18n.t("unmagic.components.board.label", default: "Board")
19
+ @column_height = column_height
20
+ @options = options
21
+ @columns = []
22
+ @add_column = nil
23
+ end
24
+
25
+ def column(record = nil, title:, key: nil, rank: nil, params: {}, count: nil, &block)
26
+ builder = Column.new(view, self, record: record, title: title, key: key, rank: rank, params: params, count: count)
27
+ view.capture(builder, &block) if block
28
+ @columns << builder
29
+ nil
30
+ end
31
+
32
+ # The tile at the end of the board that adds a column.
33
+ def add_column(url:, field:, label: nil, placeholder: nil, params: {}, method: :post)
34
+ label ||= I18n.t("unmagic.components.board.add_column", default: "Add a list")
35
+ @add_column = Board.add_form(view, id: "#{@id}_add_column", url: url, field: field, label: label,
36
+ placeholder: placeholder,
37
+ params: params, method: method, submit: I18n.t("unmagic.components.board.add_column_submit", default: "Add list"),
38
+ class: "UnmagicBoard__addColumn")
39
+ nil
40
+ end
41
+
42
+ attr_reader :id, :url
43
+
44
+ def cards_namespace = "#{id}-cards"
45
+
46
+ def render
47
+ # --unmagic-board-column-height is the board's one knob: how tall a column
48
+ # grows before its cards scroll.
49
+ style = [ @options[:style], ("--unmagic-board-column-height: #{@column_height}" if @column_height) ].compact.join("; ")
50
+
51
+ view.content_tag(:div, **@options, id: @id, role: "region", "aria-label": @label, style: style.presence,
52
+ class: view.class_names("UnmagicBoard", @options[:class])) do
53
+ if @sortable_columns
54
+ list = Sortable::List.new(view, namespace: "#{@id}-columns", url: @columns_url, orientation: :horizontal,
55
+ label: @label, class: "UnmagicBoard__columns")
56
+ @columns.each { |column| column.add_to(list) }
57
+ list.render(@add_column && view.tag.div(@add_column, class: "UnmagicBoard__tail", "data-sortable-tail": ""))
58
+ else
59
+ view.tag.div(class: "UnmagicBoard__columns") do
60
+ view.safe_join [ *@columns.map { |column| view.tag.div(column.panel(handle: false), class: "UnmagicBoard__column") },
61
+ (view.tag.div(@add_column, class: "UnmagicBoard__tail") if @add_column) ].compact
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ # A <details> that opens into a one-field form: Trello's "Add a card". Enter
68
+ # submits, Escape closes, and it stays open after adding so several can be
69
+ # added in a row (board.js).
70
+ #
71
+ # The field has a stable id so a morph refresh keeps the same element, and with
72
+ # it the focus, after each add.
73
+ def self.add_form(view, id:, url:, field:, label:, placeholder:, params:, method:, submit:, **options)
74
+ view.tag.details(**options, class: view.class_names("UnmagicBoard__add", options[:class])) do
75
+ view.safe_join [
76
+ view.tag.summary(class: "UnmagicBoard__addToggle") do
77
+ view.safe_join [ Icons.svg(view, :plus), label ]
78
+ end,
79
+ view.form_with(url: url, method: method, class: "UnmagicBoard__addForm", data: { board_add: "" }) do
80
+ view.safe_join [
81
+ *params.map { |name, value| view.hidden_field_tag(name, value, id: nil) },
82
+ Autogrow.wrap(view, view.text_area_tag(field, nil, Control.merge(view, {
83
+ id: id, rows: 2, required: true, placeholder: placeholder || label, "aria-label": label,
84
+ "data-board-add-field": "", class: "UnmagicBoard__addField"
85
+ }, :text_area))),
86
+ view.tag.div(class: "UnmagicBoard__addActions") do
87
+ cancel = I18n.t("unmagic.components.board.cancel", default: "Cancel")
88
+ view.safe_join [
89
+ view.tag.button(submit, type: "submit", class: Button.classes(:primary, size: :small)),
90
+ view.tag.button(Icons.svg(view, :x), type: "button", "data-board-add-cancel": "",
91
+ "aria-label": cancel, title: cancel, class: Button.classes(:icon))
92
+ ]
93
+ end
94
+ ]
95
+ end
96
+ ]
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ attr_reader :view
103
+
104
+ # One column: a header that is its drag handle, a sortable list of cards, and
105
+ # an add-card form.
106
+ class Column
107
+ def initialize(view, board, record:, title:, key:, rank:, params:, count:)
108
+ @view = view
109
+ @board = board
110
+ @record = record
111
+ @title = title
112
+ @key = key
113
+ @rank = rank
114
+ @params = params
115
+ @count = count
116
+ @cards = Sortable::List.new(view, namespace: board.cards_namespace, params: params, url: board.url,
117
+ label: title, class: "UnmagicBoard__cards")
118
+ @card_count = 0
119
+ @actions = nil
120
+ @add = nil
121
+ # Stable across renders, so a morph matches the column's elements up.
122
+ @dom_id = "#{board.id}_column_#{(key || record&.to_param || title).to_s.parameterize(separator: "_")}"
123
+ end
124
+
125
+ def card(record = nil, key: nil, rank: nil, label: nil, **options, &block)
126
+ @card_count += 1
127
+ @cards.item(record, key: key, rank: rank, label: label, **options,
128
+ class: view.class_names("UnmagicBoard__card", options[:class]), &block)
129
+ end
130
+
131
+ def actions(content = nil, &block)
132
+ @actions = block ? view.capture(&block) : content
133
+ nil
134
+ end
135
+
136
+ def add(url:, field:, label: nil, placeholder: nil, params: {}, method: :post)
137
+ label ||= I18n.t("unmagic.components.board.add_card", default: "Add a card")
138
+ @add = Board.add_form(view, id: "#{@dom_id}_add", url: url, field: field, label: label,
139
+ placeholder: placeholder, params: params,
140
+ method: method, submit: I18n.t("unmagic.components.board.add_card_submit", default: "Add card"))
141
+ nil
142
+ end
143
+
144
+ def add_to(list)
145
+ list.item(@record, key: @key, rank: @rank, label: @title, class: "UnmagicBoard__column") { panel(handle: true) }
146
+ end
147
+
148
+ # The header is where a column is picked up by pointer; the grip inside it is
149
+ # its keyboard stop. The header's own buttons stay clickable.
150
+ def panel(handle:)
151
+ view.tag.section(class: "UnmagicBoard__panel", "aria-labelledby": "#{@dom_id}_title") do
152
+ view.safe_join [
153
+ view.tag.header(class: "UnmagicBoard__head", "data-sortable-handle": (handle ? "" : nil)) do
154
+ view.safe_join [
155
+ (Sortable.handle(view, label: I18n.t("unmagic.components.board.move_column", title: @title,
156
+ default: "Move %{title}"), class: "UnmagicBoard__grip") if handle),
157
+ view.tag.h3(@title, id: "#{@dom_id}_title", class: "UnmagicBoard__title"),
158
+ count,
159
+ (view.tag.div(@actions, class: "UnmagicBoard__actions") if @actions.present?)
160
+ ].compact
161
+ end,
162
+ @cards.render,
163
+ @add
164
+ ].compact
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ attr_reader :view
171
+
172
+ def count
173
+ number = @count || @card_count
174
+ noun = I18n.t("unmagic.components.board.cards", count: number, default: number == 1 ? "card" : "cards")
175
+ view.tag.span(class: "UnmagicBoard__count") do
176
+ view.safe_join [ number.to_s, view.tag.span(" #{noun}", class: "UnmagicVisuallyHidden") ]
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module Browser
6
+ # Inherits ActionController::Base rather than the host's ApplicationController,
7
+ # so the host's authentication, layout and callbacks stay out. Access is the
8
+ # host's to decide where it mounts the browser.
9
+ class ApplicationController < ActionController::Base
10
+ include Unmagic::Components::DialogResponder
11
+
12
+ THEMES = %w[light dark].freeze
13
+
14
+ # Everything the browser keeps in the host's session sits under keys with
15
+ # this prefix, so none collides with the host's own.
16
+ SESSION_PREFIX = "unmagic_components_browser"
17
+
18
+ # A frame request gets no layout, as turbo-rails arranges for an app's
19
+ # default one; naming a layout here would otherwise override that.
20
+ layout -> { turbo_frame_request? ? false : "unmagic/components/browser/application" }
21
+
22
+ helper Unmagic::Components::Browser::ApplicationHelper
23
+ helper_method :dark_theme?
24
+
25
+ before_action :require_turbo, :remember_theme, :load_catalog
26
+
27
+ private
28
+
29
+ # Checked per request rather than at load, because a host that eager loads
30
+ # the browser's classes without mounting it may not have Turbo at all.
31
+ def require_turbo
32
+ return if defined?(::Turbo::Engine)
33
+
34
+ raise "Unmagic::Components::Browser needs turbo-rails: add it to your Gemfile"
35
+ end
36
+
37
+ # ?theme=dark or ?theme=light switches the browser and sticks for the
38
+ # session, so links and redirects don't each have to carry it.
39
+ def remember_theme
40
+ browser_session[:theme] = params[:theme] if THEMES.include?(params[:theme])
41
+ end
42
+
43
+ def dark_theme? = browser_session[:theme] == "dark"
44
+
45
+ def load_catalog
46
+ Catalog.reload! if Rails.env.development?
47
+ @components = Catalog.all
48
+ @component_groups = Catalog.grouped
49
+ end
50
+
51
+ def browser_session = BrowserSession.new(session)
52
+
53
+ def stored_profile
54
+ Profile.new(browser_session[:profile] || { name: "Ada Lovelace", role: "Engineer" })
55
+ end
56
+
57
+ def board_store = @board_store ||= BoardStore.new(browser_session)
58
+
59
+ # The host's session, seen through prefixed keys.
60
+ class BrowserSession
61
+ def initialize(session)
62
+ @session = session
63
+ end
64
+
65
+ def [](key) = @session[name(key)]
66
+
67
+ def []=(key, value)
68
+ @session[name(key)] = value
69
+ end
70
+
71
+ def delete(key) = @session.delete(name(key))
72
+
73
+ private
74
+
75
+ def name(key) = "#{SESSION_PREFIX}_#{key}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module Browser
6
+ # What the interactive examples talk to: the dialogs, the toasts, the board
7
+ # and the AI chat composer. Everything they change lives in the visitor's
8
+ # session.
9
+ class DemosController < ApplicationController
10
+ FLASH_TYPES = %w[notice alert warning info].freeze
11
+
12
+ def edit_profile
13
+ @profile = stored_profile
14
+ end
15
+
16
+ def update_profile
17
+ @profile = Profile.new(params.require(:profile).permit(:name, :role))
18
+
19
+ if @profile.valid?
20
+ browser_session[:profile] = @profile.attributes
21
+ refresh_or_redirect component_path("dialog"), notice: "Profile saved."
22
+ else
23
+ render :edit_profile, status: 422
24
+ end
25
+ end
26
+
27
+ def destroy_profile
28
+ browser_session.delete(:profile)
29
+ redirect_to component_path("confirm"), status: :see_other, notice: "Profile reset."
30
+ end
31
+
32
+ # Slow enough to watch the skeleton before the form arrives.
33
+ def slow_dialog
34
+ sleep 1.5
35
+ @profile = stored_profile
36
+ render :edit_profile
37
+ end
38
+
39
+ # An empty error response: the case Turbo ignores unless the modal catches it.
40
+ def forbidden_dialog
41
+ head :forbidden
42
+ end
43
+
44
+ def flash_toast
45
+ type = FLASH_TYPES.include?(params[:type]) ? params[:type] : "notice"
46
+ flash[type] = params[:message]
47
+ redirect_to component_path("toast"), status: :see_other
48
+ end
49
+
50
+ def stream_toast
51
+ tone = Toast::TONES.map(&:to_s).include?(params[:tone]) ? params[:tone].to_sym : :good
52
+ render turbo_stream: turbo_stream.toast(params[:message], tone: tone)
53
+ end
54
+
55
+ # The board example's server, answering as unmagic-sortable's endpoint does:
56
+ # a refresh, which morphs the page to the order that was saved.
57
+ def board_order
58
+ moved = board_store.move(params[:moved], prev: params[:prev], following: params[:next], column: params[:column])
59
+ board_refresh(moved ? :ok : :not_found)
60
+ end
61
+
62
+ def board_card
63
+ board_store.add_card(params.dig(:card, :title), params.dig(:card, :column))
64
+ board_refresh
65
+ end
66
+
67
+ def board_column
68
+ board_store.add_column(params.dig(:column, :title))
69
+ board_refresh
70
+ end
71
+
72
+ def board_reset
73
+ board_store.reset!
74
+ redirect_to component_path("board"), status: :see_other
75
+ end
76
+
77
+ # The composer example's server: confirm the question under the id the form
78
+ # minted, then hand the page a reply to play back as a model would stream
79
+ # it. The browser has no Action Cable, so the flushes ride along in the
80
+ # response and a small driver in the example appends them one at a time;
81
+ # each is the real stream_markdown action, and the last is the settled
82
+ # turn's upsert.
83
+ def ai_chat_message
84
+ sleep 0.8 # long enough to see the question drawn before the server confirms it
85
+
86
+ question = params.dig(:message, :content).to_s
87
+ question_id = params.dig(:message, :client_id).presence || SecureRandom.uuid_v7
88
+ reply_id = SecureRandom.uuid_v7
89
+ browser_session[:ai_chat_reply] = reply_id
90
+
91
+ render turbo_stream: [
92
+ turbo_stream.action(:upsert, "demo_entries", helpers.ai_chat_message(question, role: :user, id: question_id)),
93
+ turbo_stream.action(:upsert, "demo_entries", helpers.ai_chat_message(role: :assistant, id: reply_id, streaming: true)),
94
+ turbo_stream.replace("demo_composer_action",
95
+ helpers.ai_chat_composer_action(form: "demo_composer", state: :running, stop_form: "demo_stop")),
96
+ turbo_stream.append("demo_driver", flushes(reply_id))
97
+ ]
98
+ end
99
+
100
+ # Stop swaps in a settled note, which refuses the flushes still on their way.
101
+ def ai_chat_stop
102
+ reply_id = browser_session.delete(:ai_chat_reply)
103
+ streams = [ turbo_stream.replace("demo_composer_action", helpers.ai_chat_composer_action(form: "demo_composer", state: :idle)) ]
104
+ if reply_id
105
+ stopped = helpers.streaming_markdown_tag(id: "#{reply_id}_content", final: true,
106
+ class: "UnmagicAIChatMessage__body UnmagicProse") { helpers.tag.p("Response stopped.", class: "text-neutral-500") }
107
+ streams << turbo_stream.replace("#{reply_id}_content", stopped)
108
+ end
109
+
110
+ render turbo_stream: streams
111
+ end
112
+
113
+ private
114
+
115
+ def board_refresh(status = :ok)
116
+ render turbo_stream: turbo_stream.refresh(request_id: nil), status: status
117
+ end
118
+
119
+ def flushes(reply_id)
120
+ target = "#{reply_id}_content"
121
+ frames = Reply.snapshots.map do |html|
122
+ helpers.tag.template(turbo_stream.stream_markdown(target, html), data: { delay: rand(40..260), target: target })
123
+ end
124
+
125
+ settled = helpers.ai_chat_message(role: :assistant, id: reply_id) { Reply.html }
126
+ idle = helpers.ai_chat_composer_action(form: "demo_composer", state: :idle, stop_form: "demo_stop")
127
+ frames << helpers.tag.template(
128
+ helpers.safe_join([ turbo_stream.action(:upsert, "demo_entries", settled), turbo_stream.replace("demo_composer_action", idle) ]),
129
+ data: { delay: 400, target: target }
130
+ )
131
+
132
+ helpers.tag.template(helpers.safe_join(frames), data: { flushes: "" })
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module Browser
6
+ # The browser's pages: the overview, the getting-started pages, and one page
7
+ # per component with every example it lists, live and as source.
8
+ class PagesController < ApplicationController
9
+ before_action :load_fixtures, only: %i[overview component]
10
+
11
+ def overview
12
+ end
13
+
14
+ def installation
15
+ end
16
+
17
+ def theming
18
+ end
19
+
20
+ def component
21
+ @component = Catalog.find(params[:slug])
22
+ raise ActionController::RoutingError, "No component named #{params[:slug]}" unless @component
23
+ end
24
+
25
+ private
26
+
27
+ # The data examples and thumbnails render from, set for every page that
28
+ # shows either, so any example can use any of it.
29
+ def load_fixtures
30
+ @things = Thing.all
31
+ @pager = Pager.new(1, 3)
32
+ @profile = stored_profile
33
+ @invalid_profile = Profile.new(name: "", role: "Engineer").tap(&:validate)
34
+ @now = Time.current
35
+ @board = board_store
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end