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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -1
- data/README.md +506 -1
- data/app/assets/icons/lucide/LICENSE +43 -0
- data/app/assets/icons/lucide/arrow-down.svg +1 -0
- data/app/assets/icons/lucide/arrow-left.svg +1 -0
- data/app/assets/icons/lucide/arrow-up.svg +1 -0
- data/app/assets/icons/lucide/ban.svg +1 -0
- data/app/assets/icons/lucide/brain.svg +1 -0
- data/app/assets/icons/lucide/check.svg +1 -0
- data/app/assets/icons/lucide/chevron-down.svg +1 -0
- data/app/assets/icons/lucide/chevron-left.svg +1 -0
- data/app/assets/icons/lucide/chevron-right.svg +1 -0
- data/app/assets/icons/lucide/circle-check.svg +1 -0
- data/app/assets/icons/lucide/circle-dashed.svg +1 -0
- data/app/assets/icons/lucide/circle-question-mark.svg +1 -0
- data/app/assets/icons/lucide/circle-stop.svg +1 -0
- data/app/assets/icons/lucide/circle-x.svg +1 -0
- data/app/assets/icons/lucide/clock.svg +1 -0
- data/app/assets/icons/lucide/copy.svg +1 -0
- data/app/assets/icons/lucide/ellipsis-vertical.svg +1 -0
- data/app/assets/icons/lucide/file.svg +1 -0
- data/app/assets/icons/lucide/folder.svg +1 -0
- data/app/assets/icons/lucide/grip-vertical.svg +1 -0
- data/app/assets/icons/lucide/info.svg +1 -0
- data/app/assets/icons/lucide/lightbulb.svg +1 -0
- data/app/assets/icons/lucide/list-checks.svg +1 -0
- data/app/assets/icons/lucide/loader-circle.svg +1 -0
- data/app/assets/icons/lucide/messages-square.svg +1 -0
- data/app/assets/icons/lucide/paperclip.svg +1 -0
- data/app/assets/icons/lucide/pencil.svg +1 -0
- data/app/assets/icons/lucide/plus.svg +1 -0
- data/app/assets/icons/lucide/rotate-cw.svg +1 -0
- data/app/assets/icons/lucide/shield-alert.svg +1 -0
- data/app/assets/icons/lucide/timer.svg +1 -0
- data/app/assets/icons/lucide/triangle-alert.svg +1 -0
- data/app/assets/icons/lucide/wrench.svg +1 -0
- data/app/assets/icons/lucide/x.svg +1 -0
- data/app/assets/javascripts/unmagic/components/ai_chat.js +142 -0
- data/app/assets/javascripts/unmagic/components/autoscroll.js +103 -0
- data/app/assets/javascripts/unmagic/components/board.js +65 -0
- data/app/assets/javascripts/unmagic/components/dropzone.js +206 -0
- data/app/assets/javascripts/unmagic/components/elapsed.js +64 -0
- data/app/assets/javascripts/unmagic/components/optimistic.js +71 -0
- data/app/assets/javascripts/unmagic/components/placement.js +30 -0
- data/app/assets/javascripts/unmagic/components/slash_menu.js +186 -0
- data/app/assets/javascripts/unmagic/components/sortable.js +6 -0
- data/app/assets/javascripts/unmagic/components/sortable_item.js +122 -0
- data/app/assets/javascripts/unmagic/components/sortable_list.js +135 -0
- data/app/assets/javascripts/unmagic/components/sortable_param.js +14 -0
- data/app/assets/javascripts/unmagic/components/sortable_session.js +502 -0
- data/app/assets/javascripts/unmagic/components/streaming_markdown.js +269 -0
- data/app/assets/javascripts/unmagic/components/toasts.js +14 -4
- data/app/assets/javascripts/unmagic/components/toolbar.js +76 -0
- data/app/assets/javascripts/unmagic/components/upsert.js +4 -20
- data/app/assets/javascripts/unmagic/components.js +10 -0
- data/app/assets/tailwind/unmagic_components/engine.css +1515 -0
- data/lib/unmagic/components/action_view_helpers.rb +558 -3
- data/lib/unmagic/components/ai_chat/action_bar.rb +70 -0
- data/lib/unmagic/components/ai_chat/attachments.rb +119 -0
- data/lib/unmagic/components/ai_chat/branch_picker.rb +65 -0
- data/lib/unmagic/components/ai_chat/citation.rb +89 -0
- data/lib/unmagic/components/ai_chat/composer.rb +160 -0
- data/lib/unmagic/components/ai_chat/failure.rb +80 -0
- data/lib/unmagic/components/ai_chat/message.rb +142 -0
- data/lib/unmagic/components/ai_chat/payload.rb +93 -0
- data/lib/unmagic/components/ai_chat/permission.rb +143 -0
- data/lib/unmagic/components/ai_chat/plan.rb +91 -0
- data/lib/unmagic/components/ai_chat/proposal.rb +102 -0
- data/lib/unmagic/components/ai_chat/reasoning.rb +75 -0
- data/lib/unmagic/components/ai_chat/request.rb +206 -0
- data/lib/unmagic/components/ai_chat/section.rb +53 -0
- data/lib/unmagic/components/ai_chat/slash_menu.rb +68 -0
- data/lib/unmagic/components/ai_chat/tool_call.rb +181 -0
- data/lib/unmagic/components/ai_chat/transcript.rb +68 -0
- data/lib/unmagic/components/ai_chat/welcome.rb +73 -0
- data/lib/unmagic/components/ai_chat/workspace.rb +74 -0
- data/lib/unmagic/components/ai_chat.rb +31 -0
- data/lib/unmagic/components/avatar.rb +134 -0
- data/lib/unmagic/components/board.rb +182 -0
- data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/application_controller.rb +80 -0
- data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/demos_controller.rb +137 -0
- data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/pages_controller.rb +40 -0
- data/lib/unmagic/components/browser/app/helpers/unmagic/components/browser/application_helper.rb +52 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/board_store.rb +88 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/catalog.rb +65 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/pager.rb +11 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/profile.rb +25 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/reply.rb +72 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/thing.rb +31 -0
- data/lib/unmagic/components/browser/app/views/layouts/unmagic/components/browser/application.html.erb +65 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_code.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_crumb.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_example.html.erb +37 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_nav.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/demos/edit_profile.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_conversation.html.erb +101 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_live.html.erb +53 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_welcome.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_always.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_hover.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_dropzone.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_sent.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_positions.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_compact.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_quote.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_parts.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_states.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_full.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_plain.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_parts.html.erb +23 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_states.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_turns.html.erb +16 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_json.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_text.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_answered.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_waiting.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_empty.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_states.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_decided.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_inline.html.erb +18 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_settled.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thinking.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_answered.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_choices.html.erb +13 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_form.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_thumbnail.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_composer.html.erb +15 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_thumbnail.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_made.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_run.html.erb +25 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_states.html.erb +22 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_suggestions.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_files.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_group.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_sizes.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_tints.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_in_context.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_tones.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_handles.html.erb +13 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_live.html.erb +34 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_thumbnail.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_sizes.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_variants.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_neutral.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_tones.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_actions_and_footer.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_flush_table.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_link.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_danger.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_plain.html.erb +2 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_from_element.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_with_label.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_inline.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_stacked.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_dialog_tag.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_modal.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_countdown.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_running.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_default.html.erb +1 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_autogrow.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_control_classes.html.erb +54 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_fields.html.erb +28 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_states.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_thumbnail.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_uuid_field.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_absolute.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_relative.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_thumbnail.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_actions.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_labelled.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_thumbnail.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_default.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_with_leading.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_blocking_out.html.erb +25 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_card.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_detail_list.html.erb +15 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_page_header.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_text_and_button.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_thumbnail.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_connected.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_grid.html.erb +20 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_handles.html.erb +22 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_reveal.html.erb +19 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_stopped.html.erb +31 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_deferred.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_empty.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_sortable.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_table_tag.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_links.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_panels.html.erb +18 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_above_dialog.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_flash.html.erb +13 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_stream.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_thumbnail.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_placement.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_term.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/component.html.erb +58 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/installation.html.erb +47 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/overview.html.erb +49 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/theming.html.erb +52 -0
- data/lib/unmagic/components/browser/assets/browser.css +2 -0
- data/lib/unmagic/components/browser/catalog/ai_chat.rb +21 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_action_bar.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_attachments.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_branch_picker.rb +12 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_citation.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_composer.rb +16 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_failure.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_message.rb +17 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_payload.rb +16 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_permission.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_plan.rb +14 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_proposal.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_reasoning.rb +14 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_request.rb +17 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_slash_menu.rb +14 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_tool_call.rb +17 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_welcome.rb +13 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_workspace.rb +12 -0
- data/lib/unmagic/components/browser/catalog/avatar.rb +15 -0
- data/lib/unmagic/components/browser/catalog/badge.rb +11 -0
- data/lib/unmagic/components/browser/catalog/board.rb +18 -0
- data/lib/unmagic/components/browser/catalog/button.rb +14 -0
- data/lib/unmagic/components/browser/catalog/callout.rb +13 -0
- data/lib/unmagic/components/browser/catalog/card.rb +16 -0
- data/lib/unmagic/components/browser/catalog/confirm.rb +14 -0
- data/lib/unmagic/components/browser/catalog/copy_button.rb +15 -0
- data/lib/unmagic/components/browser/catalog/detail_list.rb +14 -0
- data/lib/unmagic/components/browser/catalog/dialog.rb +15 -0
- data/lib/unmagic/components/browser/catalog/elapsed.rb +14 -0
- data/lib/unmagic/components/browser/catalog/empty_state.rb +11 -0
- data/lib/unmagic/components/browser/catalog/forms.rb +21 -0
- data/lib/unmagic/components/browser/catalog/local_time.rb +13 -0
- data/lib/unmagic/components/browser/catalog/menu.rb +15 -0
- data/lib/unmagic/components/browser/catalog/page_header.rb +13 -0
- data/lib/unmagic/components/browser/catalog/skeleton.rb +18 -0
- data/lib/unmagic/components/browser/catalog/sortable_list.rb +17 -0
- data/lib/unmagic/components/browser/catalog/streaming_markdown.rb +17 -0
- data/lib/unmagic/components/browser/catalog/table.rb +17 -0
- data/lib/unmagic/components/browser/catalog/tabs.rb +15 -0
- data/lib/unmagic/components/browser/catalog/toast.rb +17 -0
- data/lib/unmagic/components/browser/catalog/tooltip.rb +14 -0
- data/lib/unmagic/components/browser/config/routes.rb +41 -0
- data/lib/unmagic/components/browser/tailwind/browser.css +33 -0
- data/lib/unmagic/components/browser.rb +39 -0
- data/lib/unmagic/components/configuration.rb +31 -1
- data/lib/unmagic/components/duration.rb +35 -0
- data/lib/unmagic/components/elapsed.rb +46 -0
- data/lib/unmagic/components/engine.rb +9 -0
- data/lib/unmagic/components/icons.rb +34 -26
- data/lib/unmagic/components/modal.rb +1 -1
- data/lib/unmagic/components/page_header.rb +8 -0
- data/lib/unmagic/components/sortable.rb +104 -0
- data/lib/unmagic/components/streaming_markdown.rb +34 -0
- data/lib/unmagic/components/turbo_stream_actions.rb +16 -0
- data/lib/unmagic/components/version.rb +1 -1
- data/lib/unmagic/components.rb +30 -1
- 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
|
data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/demos_controller.rb
ADDED
|
@@ -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
|
data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/pages_controller.rb
ADDED
|
@@ -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
|