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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module Browser
6
+ module ApplicationHelper
7
+ EXAMPLES = "unmagic/components/browser/examples"
8
+
9
+ # The browser's own importmap: the names config/importmap.rb pins for a
10
+ # host, and Turbo's, resolved to the files the browser serves itself.
11
+ def browser_importmap_tags
12
+ javascripts = Browser.asset_roots.fetch("javascripts")
13
+ imports = {
14
+ "@hotwired/turbo-rails" => browser_asset_path("turbo", "turbo.min.js"),
15
+ "unmagic/components" => browser_asset_path("javascripts", "unmagic/components.js")
16
+ }
17
+ Dir[javascripts.join("unmagic/components/**/*.js")].sort.each do |file|
18
+ name = Pathname(file).relative_path_from(javascripts).to_s
19
+ imports[name.delete_suffix(".js")] = browser_asset_path("javascripts", name)
20
+ end
21
+
22
+ safe_join [
23
+ tag.script({ imports: imports }.to_json.html_safe, type: "importmap", nonce: content_security_policy_nonce), # rubocop:disable Rails/OutputSafety -- JSON of our own paths
24
+ tag.script('import "unmagic/components"'.html_safe, type: "module", nonce: content_security_policy_nonce) # rubocop:disable Rails/OutputSafety -- a constant
25
+ ], "\n"
26
+ end
27
+
28
+ def browser_stylesheet_tag
29
+ tag.link rel: "stylesheet", href: browser_asset_path("stylesheets", "browser.css")
30
+ end
31
+
32
+ # A file the browser serves, stamped so a changed file is fetched afresh
33
+ # and an unchanged one comes from cache. The modification time covers
34
+ # editing in development; the version (turbo-rails' own, for Turbo)
35
+ # covers an upgrade, because gem packaging gives every file the same time.
36
+ def browser_asset_path(kind, name)
37
+ file = Browser.asset_roots.fetch(kind).join(name)
38
+ mtime = File.exist?(file) ? File.mtime(file).to_i : 0
39
+ version = (Gem.loaded_specs["turbo-rails"]&.version if kind == "turbo") || Components::VERSION
40
+ "#{root_path}assets/#{kind}/#{name}?v=#{version}-#{mtime}"
41
+ end
42
+
43
+ def example_partial(component, key) = "#{EXAMPLES}/#{component.slug}/#{key}"
44
+
45
+ # What the Code tab shows: the example's partial, exactly as it's rendered.
46
+ def example_source(component, example)
47
+ Browser.root.join("app/views", EXAMPLES, component.slug, "_#{example.key}.html.erb").read.rstrip
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Unmagic
6
+ module Components
7
+ module Browser
8
+ # The board example's data, kept in the session. It takes drops the way
9
+ # unmagic-sortable's endpoint does — moved, prev, next and the column — with
10
+ # float ranks, which are plenty for one person's session.
11
+ class BoardStore
12
+ COLUMNS = [ %w[todo To\ do], %w[doing Doing], %w[review Review], %w[done Done] ].freeze
13
+
14
+ CARDS = [
15
+ [ "todo", "Write the job ad for the platform role", "Hiring", "Ada Lovelace" ],
16
+ [ "todo", "Book the room for Thursday's panel", "Ops", nil ],
17
+ [ "todo", "Ask Grace for interview feedback", "Hiring", "Grace Hopper" ],
18
+ [ "doing", "Shortlist the backend candidates", "Hiring", "Katherine Johnson" ],
19
+ [ "doing", "Draft the offer letter template", "Legal", "Alan Turing" ],
20
+ [ "review", "Check the salary bands against the market", "Finance", "Margaret Hamilton" ],
21
+ [ "done", "Post the role on the careers page", "Hiring", "Ada Lovelace" ],
22
+ [ "done", "Set up the take-home repository", "Eng", "Dennis Ritchie" ]
23
+ ].freeze
24
+
25
+ def initialize(session)
26
+ @session = session
27
+ @session[:board] ||= seed
28
+ end
29
+
30
+ def columns = data["columns"].sort_by { |column| column["rank"] }
31
+
32
+ def cards_in(column)
33
+ data["cards"].select { |card| card["column"] == column["key"] }.sort_by { |card| card["rank"] }
34
+ end
35
+
36
+ def move(key, prev:, following:, column: nil)
37
+ record = (data["columns"] + data["cards"]).find { |item| item["key"] == key }
38
+ return false unless record
39
+
40
+ record["rank"] = between(prev.presence&.to_f, following.presence&.to_f)
41
+ record["column"] = column if column.present? && record.key?("column")
42
+ true
43
+ end
44
+
45
+ # The session is a cookie, so the board stays small enough to fit in one.
46
+ LIMIT = 30
47
+
48
+ def add_card(title, column)
49
+ return if title.blank? || data["cards"].size >= LIMIT || data["columns"].none? { |c| c["key"] == column }
50
+
51
+ last = data["cards"].select { |card| card["column"] == column }.map { |card| card["rank"] }.max
52
+ data["cards"] << { "key" => "card-#{SecureRandom.hex(3)}", "title" => title.strip, "column" => column,
53
+ "tag" => nil, "owner" => nil, "rank" => between(last, nil) }
54
+ end
55
+
56
+ def add_column(title)
57
+ return if title.blank? || data["columns"].size >= 8
58
+
59
+ last = data["columns"].map { |column| column["rank"] }.max
60
+ data["columns"] << { "key" => "column-#{SecureRandom.hex(3)}", "title" => title.strip, "rank" => between(last, nil) }
61
+ end
62
+
63
+ def reset! = @session[:board] = seed
64
+
65
+ private
66
+
67
+ def data = @session[:board]
68
+
69
+ def between(lower, upper)
70
+ if lower && upper then (lower + upper) / 2
71
+ elsif upper then upper - 1
72
+ elsif lower then lower + 1
73
+ else 1.0
74
+ end
75
+ end
76
+
77
+ def seed
78
+ {
79
+ "columns" => COLUMNS.each_with_index.map { |(key, title), index| { "key" => key, "title" => title, "rank" => index + 1.0 } },
80
+ "cards" => CARDS.each_with_index.map do |(column, title, tag, owner), index|
81
+ { "key" => "card-#{index + 1}", "title" => title, "column" => column, "tag" => tag, "owner" => owner, "rank" => index + 1.0 }
82
+ end
83
+ }
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module Browser
6
+ # The components the browser shows, and the examples on each one's page.
7
+ #
8
+ # Each component describes itself in browser/catalog/<slug>.rb. Its examples
9
+ # are partials in app/views/unmagic/components/browser/examples/<slug>/,
10
+ # rendered live and shown as source, and _thumbnail is its card on the
11
+ # overview. In development the files are loaded again on every request, so a
12
+ # new example shows up without a restart.
13
+ module Catalog
14
+ Component = Data.define(:slug, :name, :description, :helper, :import, :group, :examples)
15
+ Example = Data.define(:key, :title, :description, :layout)
16
+
17
+ # :center sits natural-width content in the middle of the frame; :full lets a
18
+ # table, form or grid use the frame's whole width.
19
+ LAYOUTS = %i[center full].freeze
20
+
21
+ # The sidebar and the overview list ungrouped components first, under
22
+ # "Components", then each group under its own heading in this order.
23
+ GROUPS = [ nil, "AI chat" ].freeze
24
+
25
+ class << self
26
+ def component(slug, name:, description:, examples:, helper: nil, import: nil, group: nil)
27
+ raise ArgumentError, "unknown group #{group.inspect} for #{slug}" unless GROUPS.include?(group)
28
+
29
+ registry[slug.to_s] = Component.new(
30
+ slug: slug.to_s, name: name, description: description, helper: helper, import: import, group: group,
31
+ examples: examples.map { |example| build_example(slug, **example) }
32
+ )
33
+ end
34
+
35
+ def all = registry.values.sort_by { |component| [ GROUPS.index(component.group), component.name ] }
36
+
37
+ # [heading, components] pairs, in sidebar order.
38
+ def grouped = all.group_by(&:group).map { |group, components| [ group || "Components", components ] }
39
+
40
+ def find(slug) = registry[slug.to_s]
41
+
42
+ def reload!
43
+ @registry = {}
44
+ Dir[Browser.root.join("catalog/*.rb")].each { |file| load file }
45
+ end
46
+
47
+ private
48
+
49
+ def registry
50
+ reload! unless @registry
51
+ @registry
52
+ end
53
+
54
+ def build_example(slug, key:, title:, description: nil, layout: :center)
55
+ unless LAYOUTS.include?(layout)
56
+ raise ArgumentError, "unknown layout #{layout.inspect} for #{slug}/#{key} (expected one of #{LAYOUTS.inspect})"
57
+ end
58
+
59
+ Example.new(key: key.to_s, title: title, description: description, layout: layout)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module Browser
6
+ Pager = Struct.new(:previous, :next) do
7
+ def page_url(direction) = "?page=#{direction == :previous ? 1 : 3}"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Unmagic
6
+ module Components
7
+ module Browser
8
+ # What the dialog preview edits: one validated field, kept in the session so a
9
+ # save visibly changes the page the modal was opened from.
10
+ class Profile
11
+ include ActiveModel::Model
12
+ include ActiveModel::Attributes
13
+
14
+ attribute :name, :string
15
+ attribute :role, :string
16
+
17
+ validates :name, presence: true
18
+
19
+ def self.model_name = ActiveModel::Name.new(self, nil, "Profile")
20
+
21
+ def persisted? = true
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module Browser
6
+ # A canned assistant reply for the streaming examples, and the growing renders a
7
+ # server would broadcast while writing it. A host renders its own Markdown; the
8
+ # preview has none, so the reply is kept as blocks of HTML and a snapshot is the
9
+ # first n words of it, cut the way a half-written reply would be.
10
+ module Reply
11
+ BLOCKS = [
12
+ [ :p, "Three candidates haven't replied since the invitations went out on Monday." ],
13
+ [ :ul, [ "Ana Silva opened hers twice but hasn't answered.", "Grace Okafor hasn't opened hers.",
14
+ "Tom Reyes's bounced, so the address is probably wrong." ] ],
15
+ [ :p, "I'd send Ana a short nudge today, check Tom's address against his CV, and leave Grace until Thursday." ],
16
+ [ :pre, "invitations.unanswered.where(sent_at: ..3.days.ago)" ],
17
+ [ :p, "Want me to draft the nudge for Ana?" ]
18
+ ].freeze
19
+
20
+ class << self
21
+ include ERB::Util
22
+
23
+ def words = BLOCKS.sum { |_, content| Array(content).sum { |text| text.split.size } }
24
+
25
+ def html(limit = words)
26
+ remaining = limit
27
+ BLOCKS.filter_map do |tag, content|
28
+ next if remaining <= 0
29
+
30
+ case tag
31
+ when :ul
32
+ items = content.filter_map do |text|
33
+ next if remaining <= 0
34
+
35
+ "<li>#{take(text, remaining).tap { remaining -= text.split.size }}</li>"
36
+ end
37
+ "<ul>#{items.join}</ul>"
38
+ when :pre
39
+ text = take(content, remaining)
40
+ remaining -= content.split.size
41
+ "<pre><code>#{text}</code></pre>"
42
+ else
43
+ text = take(content, remaining)
44
+ remaining -= content.split.size
45
+ "<#{tag}>#{text}</#{tag}>"
46
+ end
47
+ end.join.html_safe # rubocop:disable Rails/OutputSafety -- the preview's own constant copy, escaped word by word
48
+ end
49
+
50
+ # Uneven bursts, as a model produces them.
51
+ def snapshots
52
+ sizes = [ 3, 1, 6, 2, 9, 4, 1, 7, 12, 3, 5, 2, 8 ]
53
+ limit = 0
54
+ result = []
55
+ sizes.cycle.each do |size|
56
+ limit = [ limit + size, words ].min
57
+ result << html(limit)
58
+ break if limit == words
59
+ end
60
+ result
61
+ end
62
+
63
+ private
64
+
65
+ def take(text, count)
66
+ html_escape(text.split.first([ count, 0 ].max).join(" "))
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Unmagic
6
+ module Components
7
+ module Browser
8
+ # A row for the preview tables. Not an Active Record — the preview has no
9
+ # database — but enough of a model for dom_id.
10
+ class Thing
11
+ include ActiveModel::Model
12
+ include ActiveModel::Conversion
13
+ extend ActiveModel::Naming
14
+
15
+ attr_accessor :id, :name, :role, :score, :note
16
+
17
+ def to_key = [ id ]
18
+ def persisted? = id.present?
19
+
20
+ def self.all
21
+ [
22
+ new(id: 1, name: "Ada Lovelace", role: "Engineer", score: 120, note: "Replied: happy to chat next week."),
23
+ new(id: 2, name: "Grace Hopper", role: "Engineer", score: 98),
24
+ new(id: 3, name: "Katherine Johnson", role: "Analyst", score: 87),
25
+ new(id: 4, name: "Radia Perlman", role: "Architect", score: 64)
26
+ ]
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,65 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="<%= dark_theme? ? "dark" : "light" %>" class="dark:[color-scheme:dark]">
3
+ <head>
4
+ <title><%= [ content_for(:title), "unmagic-components" ].compact.join(" · ") %></title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <link rel="icon" href="data:,">
7
+ <%= csrf_meta_tags %>
8
+ <%= turbo_refresh_method_tag :morph %>
9
+ <%= turbo_refresh_scroll_tag :preserve %>
10
+ <%= browser_stylesheet_tag %>
11
+ <%= browser_importmap_tags %>
12
+ </head>
13
+ <body class="min-h-screen bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100">
14
+ <aside class="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r border-neutral-200 bg-neutral-50/70 lg:flex dark:border-neutral-800 dark:bg-neutral-900/40">
15
+ <div class="flex h-14 shrink-0 items-center px-5">
16
+ <%= link_to root_path, class: "flex items-center gap-2.5 text-sm font-semibold" do %>
17
+ <span class="grid size-6 place-items-center rounded-md bg-neutral-900 text-[0.625rem] font-bold text-white dark:bg-white dark:text-neutral-900">um</span>
18
+ unmagic-components
19
+ <% end %>
20
+ </div>
21
+ <nav class="flex-1 overflow-y-auto px-3 pb-10" aria-label="Components">
22
+ <%= render "nav" %>
23
+ </nav>
24
+ </aside>
25
+
26
+ <div class="lg:pl-64">
27
+ <header class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-neutral-200 bg-white/85 px-4 backdrop-blur sm:px-8 dark:border-neutral-800 dark:bg-neutral-950/85">
28
+ <details class="relative lg:hidden">
29
+ <summary class="<%= button_classes(:icon) %> list-none [&::-webkit-details-marker]:hidden" aria-label="Menu">
30
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
31
+ </summary>
32
+ <nav class="absolute left-0 top-11 max-h-[75vh] w-64 overflow-y-auto rounded-xl border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-800 dark:bg-neutral-900" aria-label="Components">
33
+ <%= render "nav" %>
34
+ </nav>
35
+ </details>
36
+
37
+ <nav class="flex min-w-0 items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400" aria-label="Breadcrumb">
38
+ <%= yield :breadcrumbs %>
39
+ </nav>
40
+
41
+ <div class="ml-auto flex items-center gap-1">
42
+ <% switch_to = dark_theme? ? "light" : "dark" %>
43
+ <%= link_to "?theme=#{switch_to}", class: button_classes(:icon), "aria-label": "Switch to #{switch_to} theme", title: "Switch to #{switch_to} theme" do %>
44
+ <% if dark_theme? %>
45
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2m-7.07-2.93 1.41-1.41m11.32-11.32 1.41-1.41M2 12h2m16 0h2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41"/></svg>
46
+ <% else %>
47
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
48
+ <% end %>
49
+ <% end %>
50
+ <%= link_to "https://github.com/unreasonable-magic/unmagic-components", class: button_classes(:icon), "aria-label": "GitHub", title: "GitHub" do %>
51
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-5-2"/></svg>
52
+ <% end %>
53
+ </div>
54
+ </header>
55
+
56
+ <main class="mx-auto max-w-6xl px-4 py-10 sm:px-8 lg:py-14">
57
+ <%= yield %>
58
+ </main>
59
+ </div>
60
+
61
+ <%= modal_frame %>
62
+ <%= confirm_dialog_template %>
63
+ <%= flash_toasts %>
64
+ </body>
65
+ </html>
@@ -0,0 +1,7 @@
1
+ <%# A block of source with a copy button. id: names the <pre> the button copies. %>
2
+ <div class="relative overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-900">
3
+ <pre id="<%= id %>" class="m-0 overflow-x-auto p-4 pr-14 font-mono text-[0.8125rem] leading-6 text-neutral-800 dark:text-neutral-200"><code><%= source %></code></pre>
4
+ <div class="absolute right-2 top-2">
5
+ <%= copy_button from: id %>
6
+ </div>
7
+ </div>
@@ -0,0 +1,7 @@
1
+ <%# One breadcrumb: a link, or the current page when there's no path. %>
2
+ <% if local_assigns[:path] %>
3
+ <%= link_to label, path, class: "shrink-0 hover:text-neutral-900 dark:hover:text-neutral-100" %>
4
+ <svg class="size-3.5 shrink-0 text-neutral-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
5
+ <% else %>
6
+ <span class="truncate font-medium text-neutral-900 dark:text-neutral-100" aria-current="page"><%= label %></span>
7
+ <% end %>
@@ -0,0 +1,37 @@
1
+ <%# One example: its heading, then a Preview/Code switch between the partial
2
+ rendered live and that same partial's source. The switch is the gem's own
3
+ tabs, placed beside the heading by browser/tailwind/browser.css.
4
+
5
+ The section is named by component and example together, so an example key
6
+ can't take an id the layout already uses: the dialog's "modal" example would
7
+ otherwise shadow the shared modal's #modal frame. %>
8
+ <% id = "#{component.slug}_#{example.key}" %>
9
+ <% center = example.layout == :center %>
10
+
11
+ <section id="<%= id %>" class="PreviewExample scroll-mt-20">
12
+ <div class="mb-3 min-h-9 pr-40">
13
+ <h2 class="text-base font-semibold">
14
+ <a href="#<%= id %>" class="hover:underline"><%= example.title %></a>
15
+ </h2>
16
+ <% if example.description %>
17
+ <p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400"><%= example.description %></p>
18
+ <% end %>
19
+ </div>
20
+
21
+ <%= tabs id: "#{id}_view", class: "PreviewExample__tabs" do |tabs| %>
22
+ <% tabs.tab "Preview" %>
23
+ <% tabs.tab "Code" %>
24
+
25
+ <% tabs.panel do %>
26
+ <div class="rounded-xl border border-neutral-200 bg-white bg-[radial-gradient(var(--color-neutral-200)_1px,transparent_1px)] [background-size:16px_16px] p-6 sm:p-10 dark:border-neutral-800 dark:bg-neutral-950 dark:bg-[radial-gradient(var(--color-neutral-800)_1px,transparent_1px)] <%= "flex min-h-52 items-center justify-center" if center %>">
27
+ <div class="<%= center ? "max-w-full" : "w-full" %>">
28
+ <%= render partial: example_partial(component, example.key) %>
29
+ </div>
30
+ </div>
31
+ <% end %>
32
+
33
+ <% tabs.panel do %>
34
+ <%= render "code", id: "#{id}_source", source: example_source(component, example) %>
35
+ <% end %>
36
+ <% end %>
37
+ </section>
@@ -0,0 +1,17 @@
1
+ <%# The sidebar's links, shared by the fixed sidebar and the small-screen menu. %>
2
+ <% item = "block rounded-md px-2.5 py-1.5 text-sm text-neutral-600 hover:bg-neutral-200/60 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100" %>
3
+ <% current = "block rounded-md px-2.5 py-1.5 text-sm font-medium bg-neutral-200/70 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100" %>
4
+ <% heading = "px-2.5 pb-1.5 text-xs font-semibold text-neutral-500 dark:text-neutral-400" %>
5
+
6
+ <p class="<%= heading %> pt-4">Getting started</p>
7
+ <% [ [ "Overview", root_path ], [ "Installation", installation_path ], [ "Theming", theming_path ] ].each do |label, path| %>
8
+ <%= link_to label, path, class: current_page?(path) ? current : item, "aria-current": ("page" if current_page?(path)) %>
9
+ <% end %>
10
+
11
+ <% @component_groups.each do |group, components| %>
12
+ <p class="<%= heading %> pt-6"><%= group %></p>
13
+ <% components.each do |component| %>
14
+ <% path = component_path(component.slug) %>
15
+ <%= link_to component.name, path, class: current_page?(path) ? current : item, "aria-current": ("page" if current_page?(path)) %>
16
+ <% end %>
17
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <%= dialog title: "Edit profile",
2
+ form: { model: @profile, url: profile_dialog_path, method: :patch, builder: Unmagic::Components::FormBuilder } do |dialog, form| %>
3
+ <%= form.field :name, "Name", required: true, autofocus: true %>
4
+ <%= form.field :role, "Role", hint: "Shown beside the name." %>
5
+ <% dialog.footer do %>
6
+ <%= tag.button "Cancel", type: "button", class: button_classes, data: { unmagic_dialog_close: "" } %>
7
+ <%= form.submit "Save" %>
8
+ <% end %>
9
+ <% end %>
@@ -0,0 +1,101 @@
1
+ <div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_16rem]">
2
+ <div class="flex min-w-0 flex-col gap-4">
3
+ <div id="showcase_scroll" class="h-[38rem] overflow-y-auto pr-2">
4
+ <%= ai_chat id: "showcase_entries", scroller: "#showcase_scroll" do %>
5
+ <%= ai_chat_message "Who hasn't replied to the Platform Engineer invitations yet?", role: :user, id: "showcase_q1" %>
6
+
7
+ <%= ai_chat_message role: :assistant, id: "showcase_a1" do |turn| %>
8
+ <% turn.reasoning duration: 4 do %>
9
+ <p>The invitations were sent on Monday. I need the job first, then the invitations without a reply, then
10
+ anything that bounced.</p>
11
+ <% end %>
12
+ <p>I'll check the invitations sent for that job this week.</p>
13
+ <% end %>
14
+
15
+ <%= ai_chat_tool_call name: "find_job", state: :done, icon: :folder, id: "showcase_t1" do |tool| %>
16
+ <% tool.summary "Platform Engineer" %>
17
+ <% tool.timing duration: 0.21 %>
18
+ <% tool.asked({ query: "Platform Engineer", status: "open" }) %>
19
+ <% tool.answered({ id: "job_8f2", title: "Platform Engineer", company: "Acme", invitations: 12 }) %>
20
+ <% end %>
21
+ <div data-ai-chat-timeline="gap" hidden></div>
22
+ <%= ai_chat_tool_call name: "search_invitations", state: :done, icon: :messages_square, id: "showcase_t2", open: true do |tool| %>
23
+ <% tool.summary "unanswered · last 7 days" %>
24
+ <% tool.timing duration: 1.84 %>
25
+ <% tool.failures 1 %>
26
+ <% tool.asked({ job: "job_8f2", replied: false, since: "7 days ago" }) %>
27
+ <% tool.answered({ invitations: [ "Ana Silva", "Grace Okafor", "Tom Reyes" ], errors: [ { id: "inv_31", error: "timed out" } ] }) %>
28
+ <% end %>
29
+ <div data-ai-chat-timeline="gap" hidden></div>
30
+ <%= ai_chat_tool_call name: "check_email", state: :running, id: "showcase_t3" do |tool| %>
31
+ <% tool.summary "tom.reyes@example.com" %>
32
+ <% tool.timing started_at: @now - 12 %>
33
+ <% tool.progress "Waiting on the mail server" %>
34
+ <% end %>
35
+
36
+ <%= ai_chat_message role: :assistant, id: "showcase_a2" do |turn| %>
37
+ <% turn.actions do %>
38
+ <%= ai_chat_action_bar for: "showcase_a2" do |bar| %>
39
+ <% bar.copy "Three candidates haven't replied." %>
40
+ <% bar.action "Try again", "#", icon: :rotate_cw, method: :post %>
41
+ <% bar.control { ai_chat_branch_picker index: 2, count: 2, previous: "#" } %>
42
+ <% end %>
43
+ <% end %>
44
+ <p>Three candidates haven't replied since Monday:</p>
45
+ <ul>
46
+ <li><strong>Ana Silva</strong> opened hers twice but hasn't answered.</li>
47
+ <li><strong>Grace Okafor</strong> hasn't opened hers.</li>
48
+ <li><strong>Tom Reyes</strong>'s bounced — I'm checking the address now.</li>
49
+ </ul>
50
+ <p>Ana did say something relevant last week:</p>
51
+ <%= ai_chat_citation do |cite| %>
52
+ <% cite.avatar %>
53
+ <% cite.who "Ana Silva" %>
54
+ <% cite.when @now - 6.days, url: "#" %>
55
+ <% cite.quote "I'm keen, but I'm travelling until the 18th.\nCould we talk after that?" %>
56
+ <% end %>
57
+ <%= ai_chat_proposal id: "showcase_fact" do |offer| %>
58
+ <% offer.claim "Ana is travelling until the 18th" %>
59
+ <% offer.meta "About Ana Silva · from her message on the 10th" %>
60
+ <% offer.accept { button_to "Remember", "#", class: button_classes(:primary, size: :small) } %>
61
+ <% offer.reject { button_to "Dismiss", "#", class: button_classes(size: :small) } %>
62
+ <% end %>
63
+ <p>I'd nudge Grace today and leave Ana until after she's back.</p>
64
+ <% end %>
65
+
66
+ <%= ai_chat_permission tool: "send_email", state: :waiting, url: "#", id: "showcase_p1" do |ask| %>
67
+ <% ask.argument "to", "grace.okafor@example.com" %>
68
+ <% ask.summary { tag.p("Send Grace a short nudge about the Platform Engineer role.") } %>
69
+ <% ask.reason { tag.p("She hasn't opened the invitation, and the role closes on Friday.") } %>
70
+ <% ask.allow confirm: "Send this email to Grace?" %>
71
+ <% ask.refuse %>
72
+ <% end %>
73
+ <% end %>
74
+ </div>
75
+
76
+ <%= form_with url: "#", id: "showcase_composer" do |form| %>
77
+ <%= ai_chat_composer form: form, field: :content, state: :waiting, placeholder: "Reply, or / for a command…", stop_form: "showcase_stop" do |composer| %>
78
+ <% composer.attach do %>
79
+ <button type="button" class="<%= button_classes(:icon) %>" aria-label="Attach a file" title="Attach a file">
80
+ <%= Unmagic::Components::Icons.svg(self, :paperclip) %>
81
+ </button>
82
+ <% end %>
83
+ <% end %>
84
+ <% end %>
85
+ </div>
86
+
87
+ <div class="h-fit rounded-xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
88
+ <%= ai_chat_plan id: "showcase_plan" do |plan| %>
89
+ <% plan.step "Find the job", state: :completed %>
90
+ <% plan.step "List who hasn't replied", state: :completed %>
91
+ <% plan.step "Check the bounced address", state: :in_progress %>
92
+ <% plan.step "Nudge Grace", state: :waiting %>
93
+ <% plan.step "Remind me about Ana on the 19th" %>
94
+ <% end %>
95
+ <%= ai_chat_workspace id: "showcase_workspace" do |workspace| %>
96
+ <% workspace.file "unanswered.csv", size: 1_240 %>
97
+ <% workspace.file "drafts/nudge-grace.md", size: 812, icon: :pencil %>
98
+ <% workspace.file "captures/tom-bounce.eml", size: 18_400 %>
99
+ <% end %>
100
+ </div>
101
+ </div>