protege 0.1.0.alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +24 -0
  3. data/Rakefile +12 -0
  4. data/app/assets/builds/protege.css +1128 -0
  5. data/app/assets/javascripts/protege/attachment_picker.js +163 -0
  6. data/app/assets/javascripts/protege/copy.js +80 -0
  7. data/app/assets/javascripts/protege/forms.js +28 -0
  8. data/app/assets/javascripts/protege/keys.js +212 -0
  9. data/app/assets/javascripts/protege/resize.js +149 -0
  10. data/app/assets/javascripts/protege/theme.js +25 -0
  11. data/app/assets/stylesheets/protege/application.css +15 -0
  12. data/app/assets/tailwind/application.css +137 -0
  13. data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
  14. data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
  15. data/app/controllers/protege/access_rules_controller.rb +72 -0
  16. data/app/controllers/protege/application_controller.rb +18 -0
  17. data/app/controllers/protege/archives_controller.rb +52 -0
  18. data/app/controllers/protege/email_domains_controller.rb +72 -0
  19. data/app/controllers/protege/home_controller.rb +15 -0
  20. data/app/controllers/protege/messages_controller.rb +46 -0
  21. data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
  22. data/app/controllers/protege/personas_controller.rb +118 -0
  23. data/app/controllers/protege/replies_controller.rb +64 -0
  24. data/app/controllers/protege/responsibilities_controller.rb +82 -0
  25. data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
  26. data/app/controllers/protege/threads_controller.rb +75 -0
  27. data/app/controllers/protege/traces/searches_controller.rb +19 -0
  28. data/app/controllers/protege/traces_controller.rb +52 -0
  29. data/app/helpers/protege/application_helper.rb +30 -0
  30. data/app/helpers/protege/components/badges_helper.rb +59 -0
  31. data/app/helpers/protege/components/buttons_helper.rb +141 -0
  32. data/app/helpers/protege/components/cards_helper.rb +48 -0
  33. data/app/helpers/protege/components/components_helper.rb +30 -0
  34. data/app/helpers/protege/components/dialogs_helper.rb +36 -0
  35. data/app/helpers/protege/components/forms_helper.rb +223 -0
  36. data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
  37. data/app/helpers/protege/components/icons_helper.rb +41 -0
  38. data/app/helpers/protege/components/introspection_helper.rb +162 -0
  39. data/app/helpers/protege/components/layout_helper.rb +169 -0
  40. data/app/helpers/protege/components/menus_helper.rb +68 -0
  41. data/app/helpers/protege/components/pagination_helper.rb +100 -0
  42. data/app/helpers/protege/components/sidebar_helper.rb +211 -0
  43. data/app/helpers/protege/components/tables_helper.rb +59 -0
  44. data/app/helpers/protege/components/typography_helper.rb +64 -0
  45. data/app/helpers/protege/email_domains_helper.rb +22 -0
  46. data/app/helpers/protege/messages_helper.rb +152 -0
  47. data/app/helpers/protege/personas_helper.rb +90 -0
  48. data/app/helpers/protege/responsibilities_helper.rb +63 -0
  49. data/app/helpers/protege/threads_helper.rb +118 -0
  50. data/app/helpers/protege/trace_helper.rb +22 -0
  51. data/app/hooks/protege/event_logger_hook.rb +109 -0
  52. data/app/jobs/protege/application_job.rb +41 -0
  53. data/app/jobs/protege/console_inference_job.rb +57 -0
  54. data/app/jobs/protege/inference_job.rb +60 -0
  55. data/app/jobs/protege/mail_provisioning_job.rb +21 -0
  56. data/app/jobs/protege/responsibility_job.rb +70 -0
  57. data/app/jobs/protege/responsibility_tick_job.rb +19 -0
  58. data/app/mailboxes/protege/agent_mailbox.rb +123 -0
  59. data/app/mailers/protege/application_mailer.rb +65 -0
  60. data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
  61. data/app/models/concerns/protege/broadcastable_message.rb +47 -0
  62. data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
  63. data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
  64. data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
  65. data/app/models/concerns/protege/tool_scoped.rb +88 -0
  66. data/app/models/protege/access_rule.rb +50 -0
  67. data/app/models/protege/application_record.rb +18 -0
  68. data/app/models/protege/current.rb +23 -0
  69. data/app/models/protege/email_domain.rb +136 -0
  70. data/app/models/protege/email_thread.rb +81 -0
  71. data/app/models/protege/message.rb +401 -0
  72. data/app/models/protege/persona.rb +229 -0
  73. data/app/models/protege/responsibility.rb +98 -0
  74. data/app/models/protege/responsibility_run.rb +68 -0
  75. data/app/models/protege/tool_use.rb +115 -0
  76. data/app/models/protege/trace.rb +71 -0
  77. data/app/providers/protege/open_router_provider.rb +317 -0
  78. data/app/resolvers/README.md +255 -0
  79. data/app/resolvers/protege/load_file_resolver.rb +86 -0
  80. data/app/resolvers/protege/load_record_resolver.rb +80 -0
  81. data/app/resolvers/protege/load_text_resolver.rb +40 -0
  82. data/app/resolvers/protege/thread_history_resolver.rb +116 -0
  83. data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
  84. data/app/services/protege/message_search.rb +114 -0
  85. data/app/services/protege/trace_search.rb +102 -0
  86. data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
  87. data/app/tools/protege/create_file_tool.rb +109 -0
  88. data/app/tools/protege/read_attachment_tool.rb +101 -0
  89. data/app/tools/protege/search_emails_tool.rb +129 -0
  90. data/app/tools/protege/send_email_tool.rb +240 -0
  91. data/app/tools/protege/web_fetch_tool.rb +224 -0
  92. data/app/tools/protege/web_search_tool.rb +131 -0
  93. data/app/views/layouts/protege/application.html.slim +19 -0
  94. data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
  95. data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
  96. data/app/views/protege/email_domains/_form.html.slim +10 -0
  97. data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
  98. data/app/views/protege/email_domains/index.html.slim +6 -0
  99. data/app/views/protege/email_domains/new.html.slim +7 -0
  100. data/app/views/protege/email_domains/show.html.slim +36 -0
  101. data/app/views/protege/home/show.html.slim +26 -0
  102. data/app/views/protege/messages/_message.html.slim +19 -0
  103. data/app/views/protege/messages/_sidebar.html.slim +13 -0
  104. data/app/views/protege/messages/index.html.slim +20 -0
  105. data/app/views/protege/personas/_access_rule.html.slim +7 -0
  106. data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
  107. data/app/views/protege/personas/_access_rules.html.slim +19 -0
  108. data/app/views/protege/personas/_actions.html.slim +15 -0
  109. data/app/views/protege/personas/_form.html.slim +29 -0
  110. data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
  111. data/app/views/protege/personas/_sidebar.html.slim +11 -0
  112. data/app/views/protege/personas/edit.html.slim +7 -0
  113. data/app/views/protege/personas/index.html.slim +6 -0
  114. data/app/views/protege/personas/new.html.slim +7 -0
  115. data/app/views/protege/personas/show.html.slim +37 -0
  116. data/app/views/protege/responsibilities/_form.html.slim +21 -0
  117. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
  118. data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
  119. data/app/views/protege/responsibilities/edit.html.slim +7 -0
  120. data/app/views/protege/responsibilities/index.html.slim +6 -0
  121. data/app/views/protege/responsibilities/new.html.slim +7 -0
  122. data/app/views/protege/responsibilities/show.html.slim +22 -0
  123. data/app/views/protege/responsibility_runs/show.html.slim +13 -0
  124. data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
  125. data/app/views/protege/shared/_header.html.slim +14 -0
  126. data/app/views/protege/shared/_hotkeys.html.slim +43 -0
  127. data/app/views/protege/threads/_composer_footer.html.slim +15 -0
  128. data/app/views/protege/threads/_introspection.html.slim +7 -0
  129. data/app/views/protege/threads/_reasoning.html.slim +4 -0
  130. data/app/views/protege/threads/_reply_form.html.slim +7 -0
  131. data/app/views/protege/threads/_run_marker.html.slim +3 -0
  132. data/app/views/protege/threads/_sidebar.html.slim +15 -0
  133. data/app/views/protege/threads/_thread.html.slim +6 -0
  134. data/app/views/protege/threads/_tool_call.html.slim +8 -0
  135. data/app/views/protege/threads/index.html.slim +6 -0
  136. data/app/views/protege/threads/new.html.slim +15 -0
  137. data/app/views/protege/threads/show.html.slim +25 -0
  138. data/app/views/protege/traces/_sidebar.html.slim +15 -0
  139. data/app/views/protege/traces/index.html.slim +11 -0
  140. data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
  141. data/app/views/protege/traces/searches/show.html.slim +22 -0
  142. data/app/views/protege/traces/show.html.slim +28 -0
  143. data/config/routes.rb +33 -0
  144. data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
  145. data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
  146. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
  147. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
  148. data/lib/generators/protege/extension_naming.rb +38 -0
  149. data/lib/generators/protege/hook/hook_generator.rb +33 -0
  150. data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
  151. data/lib/generators/protege/install/install_generator.rb +170 -0
  152. data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
  153. data/lib/generators/protege/persona/persona_generator.rb +35 -0
  154. data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
  155. data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
  156. data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
  157. data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
  158. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
  159. data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
  160. data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
  161. data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
  162. data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
  163. data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
  164. data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
  165. data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
  166. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
  167. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
  168. data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
  169. data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
  170. data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
  171. data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
  172. data/lib/generators/protege/provider/provider_generator.rb +44 -0
  173. data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
  174. data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
  175. data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
  176. data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
  177. data/lib/generators/protege/tool/tool_generator.rb +34 -0
  178. data/lib/protege/configuration.rb +171 -0
  179. data/lib/protege/engine.rb +103 -0
  180. data/lib/protege/errors/bad_request_error.rb +8 -0
  181. data/lib/protege/errors/contract_violation_error.rb +8 -0
  182. data/lib/protege/errors/error.rb +30 -0
  183. data/lib/protege/errors/generic_error.rb +7 -0
  184. data/lib/protege/errors/internal_error.rb +8 -0
  185. data/lib/protege/errors/invalid_model_error.rb +8 -0
  186. data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
  187. data/lib/protege/errors/rate_limited_error.rb +8 -0
  188. data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
  189. data/lib/protege/errors/response_parse_failed_error.rb +8 -0
  190. data/lib/protege/errors/timeout_error.rb +8 -0
  191. data/lib/protege/errors/tool_not_available_error.rb +12 -0
  192. data/lib/protege/errors/tool_not_found_error.rb +9 -0
  193. data/lib/protege/errors/unauthorized_error.rb +8 -0
  194. data/lib/protege/errors/unavailable_error.rb +8 -0
  195. data/lib/protege/errors/unsupported_capability_error.rb +8 -0
  196. data/lib/protege/events/event.rb +98 -0
  197. data/lib/protege/events/inference_chunk_event.rb +17 -0
  198. data/lib/protege/events/inference_completed_event.rb +12 -0
  199. data/lib/protege/events/inference_failed_event.rb +12 -0
  200. data/lib/protege/events/inference_generated_event.rb +19 -0
  201. data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
  202. data/lib/protege/events/inference_started_event.rb +11 -0
  203. data/lib/protege/events/loop_run_completed_event.rb +11 -0
  204. data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
  205. data/lib/protege/events/loop_run_failed_event.rb +12 -0
  206. data/lib/protege/events/loop_run_started_event.rb +11 -0
  207. data/lib/protege/events/tool_call_completed_event.rb +12 -0
  208. data/lib/protege/events/tool_call_failed_event.rb +12 -0
  209. data/lib/protege/events/tool_call_started_event.rb +11 -0
  210. data/lib/protege/events/tool_calls_received_event.rb +11 -0
  211. data/lib/protege/extensions/hook.rb +13 -0
  212. data/lib/protege/extensions/hook_mixin.rb +90 -0
  213. data/lib/protege/extensions/provider.rb +12 -0
  214. data/lib/protege/extensions/provider_mixin.rb +124 -0
  215. data/lib/protege/extensions/resolver.rb +12 -0
  216. data/lib/protege/extensions/resolver_mixin.rb +58 -0
  217. data/lib/protege/extensions/tool.rb +12 -0
  218. data/lib/protege/extensions/tool_mixin.rb +217 -0
  219. data/lib/protege/gateway/access_control.rb +66 -0
  220. data/lib/protege/gateway/access_policy.rb +94 -0
  221. data/lib/protege/gateway/attachment_policy.rb +115 -0
  222. data/lib/protege/gateway/mail/address.rb +145 -0
  223. data/lib/protege/gateway/mail/attachment.rb +17 -0
  224. data/lib/protege/gateway/mail/message_id.rb +104 -0
  225. data/lib/protege/gateway/mail/outbound.rb +181 -0
  226. data/lib/protege/gateway/mail/references.rb +107 -0
  227. data/lib/protege/gateway.rb +398 -0
  228. data/lib/protege/inference/provider/request.rb +110 -0
  229. data/lib/protege/inference/provider/response.rb +50 -0
  230. data/lib/protege/inference/provider/tool_call.rb +52 -0
  231. data/lib/protege/inference.rb +121 -0
  232. data/lib/protege/loop/schedule.rb +66 -0
  233. data/lib/protege/loop/scheduler.rb +42 -0
  234. data/lib/protege/loop.rb +37 -0
  235. data/lib/protege/orchestrator/context.rb +65 -0
  236. data/lib/protege/orchestrator/harness.rb +305 -0
  237. data/lib/protege/orchestrator/reply_context.rb +21 -0
  238. data/lib/protege/orchestrator/reply_harness.rb +68 -0
  239. data/lib/protege/orchestrator/resolver_chain.rb +93 -0
  240. data/lib/protege/orchestrator/responsibility_context.rb +22 -0
  241. data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
  242. data/lib/protege/orchestrator.rb +37 -0
  243. data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
  244. data/lib/protege/subscribers/tracing.rb +65 -0
  245. data/lib/protege/types/document_part.rb +13 -0
  246. data/lib/protege/types/image_part.rb +11 -0
  247. data/lib/protege/types/model_message.rb +40 -0
  248. data/lib/protege/types/result.rb +113 -0
  249. data/lib/protege/types/stored_file.rb +127 -0
  250. data/lib/protege/types/text_part.rb +8 -0
  251. data/lib/protege/types/tool_result.rb +16 -0
  252. data/lib/protege/version.rb +6 -0
  253. data/lib/protege.rb +47 -0
  254. data/lib/tasks/protege_tasks.rake +16 -0
  255. metadata +494 -0
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # Form-building UI elements shared across engine views.
6
+ #
7
+ # Split out of +ComponentsHelper+ so each module stays focused; +FormsHelper+
8
+ # owns the input styling constants and the field/select/checkbox builders that
9
+ # wrap Rails form helpers in Protege's design system.
10
+ #
11
+ # Colors use CSS custom properties (--surface, --border, --text, --text-faint)
12
+ # defined in tailwind/application.css so dark mode works via a single .dark
13
+ # class toggle.
14
+ module FormsHelper
15
+ # Tailwind classes applied to text inputs, selects, and textareas.
16
+ FORM_INPUT = 'block w-full rounded-sm px-2.5 py-1.5 text-sm shadow-sm ' \
17
+ 'focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500'
18
+ # Tailwind classes applied to field labels.
19
+ FORM_LABEL = 'block text-sm font-medium mb-1'
20
+ # Tailwind classes applied to checkboxes.
21
+ FORM_CHECKBOX = 'h-4 w-4 rounded text-amber-600 focus:ring-amber-500'
22
+
23
+ # Build the inline style applied to form controls so they track the theme.
24
+ #
25
+ # @return [String] the CSS custom-property-backed style string
26
+ def form_input_style
27
+ 'background: var(--surface); border: 1px solid var(--border); color: var(--text)'
28
+ end
29
+
30
+ # Render a record's validation errors as a styled list, or nothing when valid.
31
+ #
32
+ # @param record [ActiveModel::Validations] the record whose errors to render
33
+ # @return [ActiveSupport::SafeBuffer] the error block, or an empty buffer
34
+ def form_errors(record)
35
+ return ''.html_safe unless record.errors.any?
36
+
37
+ tag.div(class: 'rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700') do
38
+ tag.ul(class: 'list-disc pl-4 space-y-1') do
39
+ safe_join(record.errors.full_messages.map { |message| tag.li(message) })
40
+ end
41
+ end
42
+ end
43
+
44
+ # Render a labelled text field with an optional hint, wired to a form builder.
45
+ #
46
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
47
+ # @param field [Symbol] the attribute the field edits
48
+ # @param label [String, nil] the label text; defaults to the attribute's humanized name
49
+ # @param hint [String, nil] optional helper text shown beneath the field
50
+ # @param input_attrs [Hash] extra HTML attributes forwarded to the input
51
+ # @return [ActiveSupport::SafeBuffer] the field markup
52
+ def form_field(form, field, label: nil, hint: nil, **input_attrs)
53
+ tag.div do
54
+ form.label(field, label, class: FORM_LABEL) +
55
+ form.text_field(field, class: FORM_INPUT, style: form_input_style, **input_attrs) +
56
+ form_hint(hint)
57
+ end
58
+ end
59
+
60
+ # Render a labelled select wired to a form builder.
61
+ #
62
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
63
+ # @param field [Symbol] the attribute the select edits
64
+ # @param choices [Object] the option set passed to +FormBuilder#select+
65
+ # @param label [String, nil] the label text; defaults to the attribute's humanized name
66
+ # @param prompt [String, nil] optional blank-choice prompt
67
+ # @param selected [String, nil] the value to preselect — needed on a modelless form (e.g. a GET
68
+ # search form) where the builder has no object to read the current value from
69
+ # @param attrs [Hash] extra HTML attributes forwarded to the select
70
+ # @return [ActiveSupport::SafeBuffer] the select markup
71
+ def form_select(form, field, choices, label: nil, prompt: nil, selected: nil, **attrs)
72
+ tag.div do
73
+ form.label(field, label, class: FORM_LABEL) +
74
+ form.select(field, choices, { prompt:, selected: }, class: FORM_INPUT, style: form_input_style, **attrs)
75
+ end
76
+ end
77
+
78
+ # Render the split prefix/domain email-address control for a persona.
79
+ #
80
+ # When domains exist, render a prefix text box, an +@+ separator, and a domain
81
+ # select preselected to the record's current domain; otherwise prompt the
82
+ # operator to add a domain first.
83
+ #
84
+ # @param record [Protege::Persona] the persona whose email address to edit
85
+ # @param domains [Enumerable<Protege::EmailDomain>] the selectable domains
86
+ # @return [ActiveSupport::SafeBuffer] the email-address control markup
87
+ def form_email_address(record, domains)
88
+ tag.div do
89
+ tag.label('Email address', class: FORM_LABEL) +
90
+ tag.div(class: 'flex items-center gap-2') do
91
+ domains.any? ? email_address_fields(record, domains) : email_address_empty
92
+ end
93
+ end
94
+ end
95
+
96
+ # Render a labelled textarea with an optional hint, wired to a form builder.
97
+ #
98
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
99
+ # @param field [Symbol] the attribute the textarea edits
100
+ # @param label [String, nil] optional label text; omitted when nil
101
+ # @param hint [String, nil] optional helper text shown beneath the textarea
102
+ # @param fill [Boolean] when true, the wrapper and textarea flex to fill the height of a flex-column
103
+ # parent (e.g. a resizable reply composer) instead of using the default fixed-minimum, self-resizable size
104
+ # @param input_attrs [Hash] extra HTML attributes forwarded to the textarea
105
+ # @return [ActiveSupport::SafeBuffer] the textarea markup
106
+ def form_text_area(form, field, label: nil, hint: nil, fill: false, **input_attrs)
107
+ input_attrs[:rows] ||= 3
108
+ size_class = fill ? 'flex-1 min-h-0 resize-none' : 'resize-y min-h-[6rem]'
109
+ tag.div(class: fill ? 'flex min-h-0 flex-1 flex-col' : nil) do
110
+ (label ? form.label(field, label, class: FORM_LABEL) : ''.html_safe) +
111
+ form.text_area(field, class: "#{FORM_INPUT} font-mono #{size_class}",
112
+ style: form_input_style, **input_attrs) +
113
+ form_hint(hint)
114
+ end
115
+ end
116
+
117
+ # Render a labelled checkbox wired to a form builder.
118
+ #
119
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
120
+ # @param field [Symbol] the attribute the checkbox edits
121
+ # @param label [String, nil] the label text; defaults to the attribute's humanized name
122
+ # @return [ActiveSupport::SafeBuffer] the checkbox markup
123
+ def form_checkbox(form, field, label: nil)
124
+ tag.div(class: 'flex items-center gap-3') do
125
+ form.check_box(field, class: FORM_CHECKBOX, style: 'border: 1px solid var(--border)') +
126
+ form.label(field, label, class: 'text-sm font-medium')
127
+ end
128
+ end
129
+
130
+ # Render a labelled checklist — one checkbox per item, backed by an array attribute, wired to a
131
+ # form builder. Rails' +collection_check_boxes+ emits the hidden blank field for us, so unchecking
132
+ # every box submits an empty array (clearing the attribute) instead of omitting it.
133
+ #
134
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
135
+ # @param field [Symbol] the array attribute the checklist edits
136
+ # @param collection [Enumerable] the items, one checkbox each
137
+ # @param label [String, nil] the group label; omitted when nil
138
+ # @param hint [String, nil] optional helper text shown beneath the label
139
+ # @param value [Symbol, Proc] maps an item to its submitted value (default: +:to_s+)
140
+ # @param text [Symbol, Proc] maps an item to its checkbox label (default: +:to_s+)
141
+ # @return [ActiveSupport::SafeBuffer] the checklist markup
142
+ def form_checklist(form, field, collection, label: nil, hint: nil, value: :to_s, text: :to_s)
143
+ tag.div do
144
+ (label ? tag.label(label, class: FORM_LABEL) : ''.html_safe) +
145
+ form_hint(hint) +
146
+ tag.div(class: 'space-y-2 pt-1') do
147
+ form.collection_check_boxes(field, collection, value, text) do |b|
148
+ b.label(class: 'flex items-center gap-3 text-sm') do
149
+ b.check_box(class: FORM_CHECKBOX, style: 'border: 1px solid var(--border)') + b.text
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+ private
157
+
158
+ # Render the muted hint paragraph shown beneath a field, or nothing.
159
+ #
160
+ # @param hint [String, nil] the hint text
161
+ # @return [ActiveSupport::SafeBuffer] the hint paragraph, or an empty buffer
162
+ def form_hint(hint)
163
+ return ''.html_safe unless hint
164
+
165
+ tag.p(hint, class: 'mt-1 text-xs', style: 'color: var(--text-faint)')
166
+ end
167
+
168
+ # Render the prefix input, +@+ separator, and domain select for an address.
169
+ #
170
+ # @param record [Protege::Persona] the persona whose address to edit
171
+ # @param domains [Enumerable<Protege::EmailDomain>] the selectable domains
172
+ # @return [ActiveSupport::SafeBuffer] the prefix/separator/select markup
173
+ def email_address_fields(record, domains)
174
+ input_base = FORM_INPUT.sub('w-full ', '')
175
+
176
+ email_address_prefix(record, input_base) +
177
+ tag.span('@', class: 'text-sm select-none', style: 'color: var(--text-faint)') +
178
+ email_address_domain_select(record, domains, input_base)
179
+ end
180
+
181
+ # Render the local-part text input, seeded with the record's current prefix.
182
+ #
183
+ # @param record [Protege::Persona] the persona whose prefix to seed
184
+ # @param input_base [String] the shared input classes without the +w-full+ utility
185
+ # @return [ActiveSupport::SafeBuffer] the prefix input markup
186
+ def email_address_prefix(record, input_base)
187
+ tag.input(
188
+ type: 'text',
189
+ name: 'persona[email_prefix]',
190
+ value: record.email_address&.split('@')&.first,
191
+ placeholder: 'alice',
192
+ class: "block w-40 #{input_base}",
193
+ style: form_input_style
194
+ )
195
+ end
196
+
197
+ # Render the domain select, preselecting the record's current domain.
198
+ #
199
+ # @param record [Protege::Persona] the persona whose domain to preselect
200
+ # @param domains [Enumerable<Protege::EmailDomain>] the selectable domains
201
+ # @param input_base [String] the shared input classes without the +w-full+ utility
202
+ # @return [ActiveSupport::SafeBuffer] the domain select markup
203
+ def email_address_domain_select(record, domains, input_base)
204
+ options = safe_join(domains.map do |domain|
205
+ tag.option(domain.domain, value: domain.domain,
206
+ selected: record.email_address&.end_with?("@#{domain.domain}"))
207
+ end)
208
+
209
+ tag.select(options, name: 'persona[email_domain]', class: "block flex-1 #{input_base}",
210
+ style: form_input_style)
211
+ end
212
+
213
+ # Render the placeholder shown when no email domains are configured.
214
+ #
215
+ # @return [ActiveSupport::SafeBuffer] the "add a domain" prompt
216
+ def email_address_empty
217
+ tag.span(class: 'text-xs italic', style: 'color: var(--text-faint)') do
218
+ 'No domains configured — '.html_safe + ui_link('add one', new_email_domain_path)
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # Rendering for the keyboard-shortcuts overlay (see +shared/_hotkeys+ and +keys.js+): the header "?"
6
+ # button that opens it, plus the section dividers, rows, and key caps its static, hand-authored
7
+ # template is composed from. keys.js stays purely behavioral — it only toggles the dialog and
8
+ # activates +[data-hotkey]+ elements — so the overlay's content is built here from plain markup.
9
+ module HotkeysHelper
10
+ # Render the keycap-styled "?" button that opens the overlay. Looks like a key (matching the caps
11
+ # below) and calls +toggleHotkeyHelp()+ (exposed by keys.js) — the same open/close path as "?".
12
+ #
13
+ # @return [ActiveSupport::SafeBuffer] the button markup
14
+ def hotkey_help_button
15
+ cap_style = 'background: var(--surface-subtle); border: 1px solid var(--border); color: var(--text-muted)'
16
+ tag.button('?',
17
+ type: 'button',
18
+ 'aria-label': 'Keyboard shortcuts',
19
+ title: 'Keyboard shortcuts',
20
+ class: 'inline-flex px-1 h-5 w-5 items-center justify-center rounded text-xs font-mono ' \
21
+ 'cursor-pointer transition-colors',
22
+ style: cap_style,
23
+ onclick: 'toggleHotkeyHelp()')
24
+ end
25
+
26
+ # Render a titled section divider — the label followed by a rule filling the rest of the row
27
+ # ("Go to ————").
28
+ #
29
+ # @param title [String] the section label
30
+ # @return [ActiveSupport::SafeBuffer] the divider markup
31
+ def hotkey_section(title)
32
+ tag.div(class: 'flex items-center gap-2 pt-3 first:pt-0 text-xs font-semibold uppercase tracking-wider',
33
+ style: 'color: var(--text-faint)') do
34
+ tag.span(title) + tag.span('', class: 'flex-1', style: 'border-top: 1px solid var(--border)')
35
+ end
36
+ end
37
+
38
+ # Render one shortcut row: a label on the left, its key caps on the right.
39
+ #
40
+ # @param label [String] the shortcut's description
41
+ # @param keys [Array<String>] the keys to show as caps, in order (e.g. "g", "1")
42
+ # @return [ActiveSupport::SafeBuffer] the row markup
43
+ def hotkey_row(label, *keys)
44
+ tag.div(class: 'flex items-center justify-between gap-6 py-1') do
45
+ tag.span(label) +
46
+ tag.span(class: 'flex items-center gap-1') { safe_join(keys.map { |key| hotkey_cap(key) }) }
47
+ end
48
+ end
49
+
50
+ # Render a single keycap (+<kbd>+), styled to match the header's "?" button.
51
+ #
52
+ # @param key [String] the key label
53
+ # @return [ActiveSupport::SafeBuffer] the keycap markup
54
+ def hotkey_cap(key)
55
+ tag.kbd(key, class: 'rounded px-1.5 py-0.5 text-xs font-mono',
56
+ style: 'background: var(--surface-subtle); border: 1px solid var(--border)')
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # Inline SVG icons — a small registry of single-path, stroked glyphs (Lucide geometry). Each renders
6
+ # as +currentColor+ so it inherits the surrounding text color and theme. Add an entry to +ICONS+ to
7
+ # introduce a new glyph; render one with +icon(:name)+.
8
+ module IconsHelper
9
+ # Icon key => the +d+ attribute of its single +<path>+.
10
+ ICONS = {
11
+ paperclip: 'M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66' \
12
+ 'l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48'
13
+ }.freeze
14
+
15
+ # Render a named icon as an inline SVG that inherits the current text color.
16
+ #
17
+ # @param name [Symbol] the icon key (see +ICONS+)
18
+ # @param size [Integer] the pixel width and height
19
+ # @param attrs [Hash] extra attributes merged onto the +<svg>+ (e.g. +class:+)
20
+ # @return [ActiveSupport::SafeBuffer] the SVG markup
21
+ # @raise [ArgumentError] when +name+ is not a registered icon
22
+ def icon(name, size: 16, **attrs)
23
+ path = ICONS.fetch(name) { raise ArgumentError, "unknown icon #{name.inspect}" }
24
+
25
+ tag.svg(
26
+ tag.path(d: path),
27
+ xmlns: 'http://www.w3.org/2000/svg',
28
+ width: size,
29
+ height: size,
30
+ viewBox: '0 0 24 24',
31
+ fill: 'none',
32
+ stroke: 'currentColor',
33
+ 'stroke-width': 2,
34
+ 'stroke-linecap': 'round',
35
+ 'stroke-linejoin': 'round',
36
+ **attrs
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # View helpers for the thread **introspection panel** — the collapsible right sidebar in the console
6
+ # that surfaces the agent's reasoning and tool use for the open thread. Mirrors +LayoutHelper+'s
7
+ # sidebar builders: small composable pieces (panel shell, section, tool card, toggle) so the view
8
+ # stays declarative and the panel's markup lives in one place as it grows.
9
+ module IntrospectionHelper
10
+ # DOM id of the panel +<aside>+ — the toggle shows/hides this element.
11
+ INTROSPECTION_PANEL_ID = 'thread-introspection'
12
+
13
+ # Render the panel shell: a full-height right +<aside>+ (mirroring the left inbox aside) with a fixed
14
+ # header and a scrollable body that yields its content (the activity feed).
15
+ #
16
+ # The user can resize its width by dragging the left-edge handle (the general +resize_handle+ paired
17
+ # with the engine's self-contained +resize.js+), bounded by min/max and remembered across page loads.
18
+ # Collapsing (open/close) is handled by +introspection_toggle+ in the thread header.
19
+ #
20
+ # @yield the panel body (e.g. +introspection_feed+)
21
+ # @return [ActiveSupport::SafeBuffer] the panel markup
22
+ def introspection_panel(&block)
23
+ class_names = 'relative flex w-80 flex-shrink-0 flex-col overflow-hidden'
24
+
25
+ data = {
26
+ resize: '',
27
+ resize_edge: 'left',
28
+ resize_min: 240,
29
+ resize_max: 720,
30
+ resize_key: 'protege:introspection-width'
31
+ }
32
+
33
+ tag.aside(id: INTROSPECTION_PANEL_ID, class: class_names, data:) do
34
+ resize_handle(edge: :left) +
35
+ introspection_header +
36
+ tag.div(class: 'flex-1 overflow-y-auto px-4 py-3', &block)
37
+ end
38
+ end
39
+
40
+ # The toggle button (placed in the thread header) that shows/hides the panel.
41
+ #
42
+ # @return [ActiveSupport::SafeBuffer] the toggle button markup
43
+ def introspection_toggle
44
+ button('Introspection',
45
+ variant: :secondary,
46
+ type: 'button',
47
+ onclick: "document.getElementById('#{INTROSPECTION_PANEL_ID}').classList.toggle('hidden')")
48
+ end
49
+
50
+ # The live activity feed container — the single scrollable target the +IntrospectionBroadcaster+
51
+ # appends reasoning tokens and tool-call cards into, interleaved chronologically. Live-only: the
52
+ # +introspection-empty+ placeholder (see tailwind/application.css) shows until the next run streams in.
53
+ #
54
+ # Marked +data-turbo-permanent+ so the accumulated feed survives Turbo Drive navigations (the id is
55
+ # per-thread, so it is only preserved within the same thread). A hard page reload still starts it
56
+ # empty — permanence spans Drive visits only — keeping the feed live-only.
57
+ #
58
+ # @param thread [Protege::EmailThread] the open thread
59
+ # @return [ActiveSupport::SafeBuffer] the feed container markup
60
+ def introspection_feed(thread)
61
+ tag.div(id: dom_id(thread, :activity),
62
+ class: 'introspection-empty text-xs font-mono leading-relaxed',
63
+ style: 'color: var(--text-muted)',
64
+ data: { empty: 'Waiting for activity…', turbo_permanent: '' })
65
+ end
66
+
67
+ # A reasoning block — one paragraph of the model's streamed reasoning. The +IntrospectionBroadcaster+
68
+ # appends a fresh block after each tool call and streams that turn's tokens into it (by +id+), so
69
+ # reasoning is grouped into spaced, whitespace-preserving paragraphs rather than loose text nodes.
70
+ #
71
+ # @param id [String] the block's DOM id (the target the run's tokens append into)
72
+ # @param content [String, nil] the block's initial token text
73
+ # @return [ActiveSupport::SafeBuffer] the reasoning block markup
74
+ def introspection_reasoning(id:, content: nil)
75
+ tag.div(content, id:, class: 'my-3 whitespace-pre-wrap')
76
+ end
77
+
78
+ # A single tool-call card: a status glyph, the tool name, an optional one-line args summary, and an
79
+ # optional response preview (done) or error (failed). Streamed into the panel as a +:pending+ card
80
+ # the instant the model requests the call, then replaced in place with the outcome — so pass a stable
81
+ # +id:+ (the card's DOM id) that the replace can target.
82
+ #
83
+ # @param name [String] the tool name
84
+ # @param args [String, nil] a one-line argument summary
85
+ # @param status [Symbol] +:pending+, +:done+, or +:failed+
86
+ # @param preview [String, nil] a response preview or error string
87
+ # @param id [String, nil] the card's DOM id (so a pending card can be replaced by its outcome)
88
+ # @return [ActiveSupport::SafeBuffer] the card markup
89
+ def introspection_tool_card(name:, args: nil, status: :done, preview: nil, id: nil)
90
+ glyph, color, pulsing = tool_card_glyph(status)
91
+ tag.div(id:, class: 'my-3 pl-2 text-xs font-mono', style: 'border-left: 2px solid var(--border)') do
92
+ tag.div(class: 'flex items-center gap-2') do
93
+ tag.span(glyph, class: pulsing ? 'animate-pulse' : '', style: "color: #{color}") +
94
+ tag.span(name, class: 'font-medium', style: 'color: var(--text)')
95
+ end +
96
+ introspection_card_line(args, color: 'var(--text-muted)') +
97
+ introspection_card_line(preview.present? ? "→ #{preview}" : nil, color: 'var(--text-faint)')
98
+ end
99
+ end
100
+
101
+ # A run-lifecycle marker in the activity feed: a pulsing "Processing…" line while the run is in
102
+ # flight, or a red error when it fails. Appended on run start; on success it is removed, on failure
103
+ # replaced in place (by its +id+) with the error — so each run reads as processing → reasoning/tool
104
+ # use, then repeats for the next reply.
105
+ #
106
+ # @param id [String] the marker's DOM id (stable across the run so the outcome replaces it)
107
+ # @param status [Symbol] +:processing+ or +:failed+
108
+ # @param detail [String, nil] the error message, shown when +:failed+
109
+ # @return [ActiveSupport::SafeBuffer] the marker markup
110
+ def introspection_run_marker(id:, status: :processing, detail: nil)
111
+ if status == :failed
112
+ tag.div(id:, class: 'my-3 text-xs font-mono', style: 'color: #ef4444') do
113
+ tag.div('✗ Run failed', class: 'font-medium') +
114
+ introspection_card_line(detail, color: '#ef4444')
115
+ end
116
+ else
117
+ tag.div(id:, class: 'my-3 flex items-center gap-2 text-xs animate-pulse',
118
+ style: 'color: var(--text-faint)') do
119
+ tag.span('•') + tag.span('Processing…')
120
+ end
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ # The glyph, color, and pulse for a tool card's status.
127
+ #
128
+ # @param status [Symbol] +:pending+, +:done+, or +:failed+
129
+ # @return [Array(String, String, Boolean)] the glyph, its CSS color, and whether it pulses
130
+ def tool_card_glyph(status)
131
+ case status
132
+ when :failed then ['✗', '#ef4444', false]
133
+ when :pending then ['•', 'var(--text-faint)', true]
134
+ else ['✓', 'var(--text-faint)', false]
135
+ end
136
+ end
137
+
138
+ # The panel's fixed header row (matches +sidebar_header+'s styling).
139
+ #
140
+ # @return [ActiveSupport::SafeBuffer] the header markup
141
+ def introspection_header
142
+ tag.div(class: 'flex h-10 flex-shrink-0 items-center px-4',
143
+ style: 'border-bottom: 1px solid var(--border)') do
144
+ tag.span('Introspection',
145
+ class: 'text-xs font-semibold uppercase tracking-wider',
146
+ style: 'color: var(--text-faint)')
147
+ end
148
+ end
149
+
150
+ # One wrapping text line inside a tool card, or an empty buffer when blank.
151
+ #
152
+ # @param text [String, nil] the line text
153
+ # @param color [String] the CSS color value
154
+ # @return [ActiveSupport::SafeBuffer] the line markup, or an empty buffer
155
+ def introspection_card_line(text, color:)
156
+ return ''.html_safe if text.blank?
157
+
158
+ tag.div(text, class: 'mt-1 whitespace-pre-wrap break-all', style: "color: #{color}")
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # Page-scaffolding UI elements shared across engine views — the app shell (body,
6
+ # header, content region, main column), page headers and containers, navigation,
7
+ # and the theme toggle. A flat helper module among peers, all aggregated by
8
+ # +Protege::ApplicationHelper+ and mixed into the view together.
9
+ #
10
+ # Colors use CSS custom properties (--surface, --border, --text, --text-muted,
11
+ # --text-faint) defined in tailwind/application.css so dark mode works via a
12
+ # single .dark class toggle.
13
+ module LayoutHelper
14
+ # ── App shell ────────────────────────────────────────────────
15
+
16
+ # Render the app +<body>+ — a full-height flex column holding the fixed header and the content region
17
+ # (so the layout template carries no structural classes of its own). The block is the main content.
18
+ #
19
+ # @yield the main content
20
+ # @return [ActiveSupport::SafeBuffer] the body markup
21
+ def app_body(&main)
22
+ tag.body(class: 'h-full flex flex-col antialiased') { app_header + app_content(&main) }
23
+ end
24
+
25
+ # Render the app content region below the header — the full-height flex row holding the optional
26
+ # sidebar and the main column. The sidebar is drawn from the +:sidebar+ content buffer (a view's
27
+ # +content_for :sidebar+), so pages opt in just by defining it; the block is the main content.
28
+ #
29
+ # @yield the main content
30
+ # @return [ActiveSupport::SafeBuffer] the content-region markup
31
+ def app_content(&main)
32
+ tag.div(class: 'flex min-h-0 flex-1') { app_sidebar + app_main(&main) }
33
+ end
34
+
35
+ private
36
+
37
+ # Render the app's fixed top header (the shared header partial).
38
+ #
39
+ # @return [ActiveSupport::SafeBuffer] the header markup
40
+ def app_header
41
+ render('protege/shared/header')
42
+ end
43
+
44
+ # Render the docked sidebar from the +:sidebar+ content buffer, or nothing when a page defines none.
45
+ #
46
+ # @return [ActiveSupport::SafeBuffer] the sidebar shell, or an empty buffer
47
+ def app_sidebar
48
+ sidebar = content_for(:sidebar)
49
+ return ''.html_safe if sidebar.blank?
50
+
51
+ sidebar_container { sidebar }
52
+ end
53
+
54
+ # Render the main content column that fills the space beside the sidebar.
55
+ #
56
+ # @yield the main content
57
+ # @return [ActiveSupport::SafeBuffer] the +<main>+ markup
58
+ def app_main(&)
59
+ tag.main(class: 'flex min-w-0 flex-1 flex-col overflow-hidden', &)
60
+ end
61
+
62
+ # ── Page layout ──────────────────────────────────────────────
63
+
64
+ # Render a page header with a title, optional description, and optional actions.
65
+ #
66
+ # @param title [String] the page title
67
+ # @param description [String, nil] optional subtitle shown beneath the title
68
+ # @yield optional actions rendered to the right of the title
69
+ # @return [ActiveSupport::SafeBuffer] the header markup
70
+ def page_header(title, description = nil, &block)
71
+ tag.div(class: 'flex items-center justify-between mb-6') do
72
+ left = tag.div do
73
+ heading_1(title) +
74
+ (if description
75
+ tag.p(description, class: 'mt-1 text-sm',
76
+ style: 'color: var(--text-muted)')
77
+ else
78
+ ''.html_safe
79
+ end)
80
+ end
81
+
82
+ right = block ? capture(&block) : ''.html_safe
83
+
84
+ left + right
85
+ end
86
+ end
87
+
88
+ # Render the light/dark theme toggle switch. Carries the +g t+ hotkey directly (keys.js clicks it),
89
+ # so the shortcut lives on the real control rather than a hidden duplicate.
90
+ #
91
+ # @return [ActiveSupport::SafeBuffer] the toggle markup
92
+ def theme_toggle
93
+ tag.button(type: 'button',
94
+ class: 'relative inline-flex h-5 w-9 items-center rounded-full cursor-pointer transition-colors',
95
+ style: 'background: var(--surface-subtle); border: 1px solid var(--border)',
96
+ data: { hotkey: 'g t' },
97
+ onclick: 'toggleTheme()') do
98
+ dot_position = dark_mode? ? 'translate-x-4' : 'translate-x-1'
99
+ dot_class = 'toggle-dot inline-block h-3 w-3 rounded-full transition-transform bg-amber-500'
100
+ tag.span('', class: "#{dot_class} #{dot_position}")
101
+ end
102
+ end
103
+
104
+ # Report whether dark mode is the server-rendered default.
105
+ #
106
+ # Theme state lives client-side; this always returns false so the initial
107
+ # render is light and JS swaps the class as needed.
108
+ #
109
+ # @return [Boolean] always false
110
+ def dark_mode?
111
+ false
112
+ end
113
+
114
+ # Render the scrollable, centered page content wrapper.
115
+ #
116
+ # @yield the page body content
117
+ # @return [ActiveSupport::SafeBuffer] the container markup
118
+ def page_container(&block)
119
+ tag.div(class: 'overflow-y-auto flex-1') do
120
+ tag.div(class: 'p-6 pb-16 max-w-4xl mx-auto', &block)
121
+ end
122
+ end
123
+
124
+ # Render a centered empty-state container.
125
+ #
126
+ # @yield the empty-state message
127
+ # @return [ActiveSupport::SafeBuffer] the empty-state markup
128
+ def page_empty(&)
129
+ tag.div(class: 'flex h-full items-center justify-center text-sm', style: 'color: var(--text-faint)', &)
130
+ end
131
+
132
+ # Render a card summarizing a topic with an optional "Learn more" link.
133
+ #
134
+ # @param title [String] the card title
135
+ # @param description [String] the card body text
136
+ # @param href [String, nil] optional link target for "Learn more"
137
+ # @return [ActiveSupport::SafeBuffer] the card markup
138
+ def grid_card(title, description, href: nil)
139
+ tag.div(class: 'rounded-xl p-5',
140
+ style: 'background: var(--surface); border: 1px solid var(--border)') do
141
+ tag.div(class: 'mb-1') { heading_3(title) } +
142
+ tag.p(description, class: 'text-xs leading-relaxed') +
143
+ (href ? tag.div(class: 'mt-3') { ui_link('Learn more', href, target: '_blank') } : ''.html_safe)
144
+ end
145
+ end
146
+
147
+ # Render a responsive grid wrapper with one, two, or three columns.
148
+ #
149
+ # @param cols [Integer] the column count (1, 2, or 3)
150
+ # @yield the grid items
151
+ # @return [ActiveSupport::SafeBuffer] the grid markup
152
+ def grid_container(cols: 2, &)
153
+ col_class = { 1 => 'grid-cols-1', 2 => 'grid-cols-2', 3 => 'grid-cols-3' }[cols]
154
+ tag.div(class: "grid #{col_class} gap-4", &)
155
+ end
156
+
157
+ # A drag handle laid over a resizable container's edge. The +resize-handle+ class draws the divider
158
+ # line and its hover affordance (see tailwind/application.css); this element is the invisible grab
159
+ # strip +resize.js+ listens on. The container itself carries the +data-resize*+ attributes.
160
+ #
161
+ # @param edge [Symbol] which edge the handle sits on — +:left+ (default), +:right+, or +:top+
162
+ # @return [ActiveSupport::SafeBuffer] the handle markup
163
+ def resize_handle(edge: :left)
164
+ variant = { right: ' resize-handle--right', top: ' resize-handle--top' }[edge].to_s
165
+ tag.div('', class: "resize-handle#{variant}", data: { resize_handle: '' })
166
+ end
167
+ end
168
+ end
169
+ end