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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # The +<details>+-based dropdown menu: +dropdown_menu+ renders the trigger + panel (with a backdrop
6
+ # that closes it), and +dropdown_item+ renders each entry as a link or form button, with an +active+
7
+ # variant for filter-style menus.
8
+ module MenusHelper
9
+ # Render a +<details>+-based dropdown menu with a backdrop that closes it.
10
+ #
11
+ # @param label [String] the trigger text (defaults to a kebab +···+)
12
+ # @yield the menu items, rendered inside the dropdown panel
13
+ # @return [ActiveSupport::SafeBuffer] the dropdown markup
14
+ def dropdown_menu(label: '···', &block)
15
+ tag.details(class: 'relative') do
16
+ tag.summary(label, class: 'cursor-pointer list-none text-base font-bold select-none',
17
+ style: 'color: var(--text-faint)') +
18
+ tag.div(class: 'fixed inset-0 z-10', onclick: "this.closest('details').removeAttribute('open')") +
19
+ tag.div(class: 'absolute right-0 mt-1 w-36 rounded-lg py-1 shadow-lg z-20',
20
+ style: 'background: var(--surface); border: 1px solid var(--border)') { capture(&block) }
21
+ end
22
+ end
23
+
24
+ # Render a single dropdown entry, either a link or a form button. Pass +active: true+ to mark the
25
+ # current selection — the entry is emphasized and gets a trailing check (for filter-style menus).
26
+ #
27
+ # @param text [String] the entry label
28
+ # @param href [String] the entry target
29
+ # @param method [Symbol, nil] when set, render a form button using this verb
30
+ # @param active [Boolean] whether this entry is the current selection
31
+ # @param destructive [Boolean] whether to apply destructive (red) styling
32
+ # @return [ActiveSupport::SafeBuffer] the entry markup
33
+ def dropdown_item(text, href, method: nil, active: false, destructive: false)
34
+ text_class = destructive ? 'text-red-500 hover:text-red-700' : ''
35
+ weight = active ? 'font-semibold' : ''
36
+ base_class = 'flex w-full items-center justify-between gap-2 text-left text-sm px-3 py-1.5 ' \
37
+ "cursor-pointer #{text_class} #{weight}".squish
38
+ content = active ? safe_join([text, tag.span('✓', style: 'color: var(--color-amber-500, #f59e0b)')]) : text
39
+
40
+ if method
41
+ button_to content, href,
42
+ method:,
43
+ class: "#{base_class} bg-transparent border-0",
44
+ form: { class: 'w-full' }
45
+ else
46
+ link_to content, href, class: base_class
47
+ end
48
+ end
49
+
50
+ # Render a navigation link, highlighted when the current path is under it.
51
+ #
52
+ # @param text [String] the link text
53
+ # @param href [String] the link target
54
+ # @param current_path [String] the request path used to compute the active state
55
+ # @return [ActiveSupport::SafeBuffer] the nav-item markup
56
+ def nav_item(text, href, current_path)
57
+ active = current_path.start_with?(href)
58
+ classes = 'rounded-sm px-2.5 py-1 text-xs font-medium transition-colors'
59
+ style = if active
60
+ 'background: var(--surface-subtle); color: var(--text)'
61
+ else
62
+ 'color: var(--text-muted)'
63
+ end
64
+ link_to(text, href, class: classes, style:)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # Pagination for sidebar lists: +paginate+ slices a relation into a page of records plus the page
6
+ # metadata, and +sidebar_pagination+ renders that metadata as the sidebar's bottom bar — a prev
7
+ # arrow, the first/current/last page numbers, and a next arrow.
8
+ #
9
+ # Pagy supplies only the page math (count + page → offset/limit and the prev/next/last numbers); the
10
+ # markup is ours so it matches the sidebar styling. Colors use the same CSS custom properties as the
11
+ # rest of the layout (--text, --text-muted, --text-faint, --border) so dark mode follows along.
12
+ module PaginationHelper
13
+ # Paginate a relation for a sidebar list, returning the page metadata and the page of records.
14
+ #
15
+ # Wraps Pagy's standalone object (no +Pagy::Backend+ controller mixin needed): it counts the scope,
16
+ # resolves the requested page, and slices the relation with +offset+/+limit+. The page defaults to
17
+ # the +?page=+ query param; a +nil+ or non-numeric value falls back to page 1, and an overflowing
18
+ # one is clamped to the last page (the +:last_page+ overflow default set at boot).
19
+ #
20
+ # @param scope [ActiveRecord::Relation] the relation to page through
21
+ # @param page [String, Integer, nil] the requested page (defaults to +params[:page]+)
22
+ # @param limit [Integer] the page size
23
+ # @return [Array(Pagy, ActiveRecord::Relation)] the pagy metadata and the page of records
24
+ def paginate(scope, page: params[:page], limit: 15)
25
+ pagy = ::Pagy.new(count: scope.count, page: [page.to_i, 1].max, limit:)
26
+ [pagy, scope.offset(pagy.offset).limit(pagy.limit)]
27
+ end
28
+
29
+ # Render the sidebar's bottom pagination bar from a Pagy object — a prev arrow, the
30
+ # first/current/last page numbers, and a next arrow. Mirrors +sidebar_header+ (same bar shape, a
31
+ # top border) but pinned to the bottom of the sidebar. Renders nothing for a single page, so a
32
+ # sidebar can render it unconditionally and it only appears once the list outgrows one page.
33
+ #
34
+ # Links target the current path with a merged +:page+, so paging the sidebar keeps the main view
35
+ # (e.g. an open thread) in place and re-pages only the list.
36
+ #
37
+ # @param pagy [Pagy] the page metadata for the sidebar's collection
38
+ # @return [ActiveSupport::SafeBuffer] the footer markup, or an empty buffer for a single page
39
+ def sidebar_pagination(pagy)
40
+ return ''.html_safe if pagy.pages <= 1
41
+
42
+ tag.div(class: 'flex h-10 flex-shrink-0 items-center justify-between px-4 text-xs',
43
+ style: 'border-top: 1px solid var(--border)') do
44
+ [
45
+ sidebar_page_arrow('◀︎', pagy.prev),
46
+ sidebar_page_numbers(pagy),
47
+ sidebar_page_arrow('▶︎', pagy.next)
48
+ ].join.html_safe
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ # Render a pagination arrow — a link to +page+, or a faint inert span at the first/last boundary
55
+ # (when +page+ is +nil+).
56
+ #
57
+ # @param glyph [String] the arrow character
58
+ # @param page [Integer, nil] the target page, or nil to disable the arrow
59
+ # @return [ActiveSupport::SafeBuffer] the arrow markup
60
+ def sidebar_page_arrow(glyph, page)
61
+ classes = 'px-1'
62
+ return tag.span(glyph, class: classes, style: 'color: var(--text-faint)') unless page
63
+
64
+ link_to(glyph, sidebar_page_url(page), class: "#{classes} text-amber-600 hover:text-amber-700")
65
+ end
66
+
67
+ # Render the middle page numbers — the distinct first/current/last pages in order, dot-separated,
68
+ # with the current page emphasized and the others linked.
69
+ #
70
+ # @param pagy [Pagy] the page metadata
71
+ # @return [ActiveSupport::SafeBuffer] the page-number markup
72
+ def sidebar_page_numbers(pagy)
73
+ pages = [1, pagy.page, pagy.last].uniq.sort
74
+ separator = tag.span('·', class: 'px-1', style: 'color: var(--text-faint)')
75
+
76
+ safe_join(pages.map { |n| sidebar_page_number(n, current: n == pagy.page) }, separator)
77
+ end
78
+
79
+ # Render a single middle page number — an emphasized span for the current page, a link otherwise.
80
+ #
81
+ # @param page [Integer] the page number
82
+ # @param current [Boolean] whether this is the current page
83
+ # @return [ActiveSupport::SafeBuffer] the page-number markup
84
+ def sidebar_page_number(page, current:)
85
+ return tag.span(page, class: 'font-semibold', style: 'color: var(--text)') if current
86
+
87
+ ui_link(page.to_s, sidebar_page_url(page))
88
+ end
89
+
90
+ # Build the URL for a sidebar page — the current path with +:page+ merged into its query, so the
91
+ # main view stays put while the sidebar re-pages.
92
+ #
93
+ # @param page [Integer] the target page
94
+ # @return [String] the path with the merged page param
95
+ def sidebar_page_url(page)
96
+ "#{request.path}?#{request.query_parameters.merge('page' => page).to_query}"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # The console sidebar surface — the docked +<aside>+ shell and the header/list/footer elements that
6
+ # fill it. Split out of +LayoutHelper+ so the sidebar is a single focused concern: +sidebar_container+
7
+ # is the resizable outer shell, +sidebar_content+ the flex column inside it, and +sidebar_header+ /
8
+ # +sidebar_search_link+ / +sidebar_body+ / +sidebar_item+ / +sidebar_stream+ compose the list.
9
+ #
10
+ # Colors use the CSS custom properties (--surface, --border, --text, --text-muted, --text-faint)
11
+ # defined in tailwind/application.css so dark mode works via a single .dark class toggle.
12
+ module SidebarHelper
13
+ # Render the sidebar shell — the resizable, full-height +<aside>+ that docks the navigation sidebar
14
+ # on the left of the console. The user can drag its right edge to resize it (the general
15
+ # +resize_handle+ paired with +resize.js+), bounded by min/max and remembered across page loads. The
16
+ # yielded block is the sidebar's content (typically a +sidebar_content+ wrapper).
17
+ #
18
+ # @yield the sidebar contents
19
+ # @return [ActiveSupport::SafeBuffer] the aside markup
20
+ def sidebar_container(&block)
21
+ tag.aside(class: 'relative flex w-72 flex-shrink-0 flex-col overflow-hidden',
22
+ data: { resize: '', resize_edge: 'right', resize_min: 200,
23
+ resize_max: 480, resize_key: 'protege:sidebar-width' }) do
24
+ resize_handle(edge: :right) + capture(&block)
25
+ end
26
+ end
27
+
28
+ # Wrap a sidebar's contents (header, scrollable list, pagination footer) in the flex column that
29
+ # fills the sidebar shell — header/footer stay fixed while the list scrolls between them.
30
+ #
31
+ # Pass +frame:+ to render the column as a Turbo Frame (an update target a form/stream can replace)
32
+ # instead of a plain div; the same layout classes apply either way. A bare +<turbo-frame>+ is
33
+ # +display: inline+, so the classes (which set it +flex+) are what make the frame variant lay out at all.
34
+ #
35
+ # @param frame [String, nil] a Turbo Frame id to render as, or nil for a plain div
36
+ # @yield the sidebar contents
37
+ # @return [ActiveSupport::SafeBuffer] the column markup
38
+ def sidebar_content(frame: nil, &)
39
+ classes = 'flex min-h-0 flex-1 flex-col'
40
+ frame ? turbo_frame_tag(frame, class: classes, &) : tag.div(class: classes, &)
41
+ end
42
+
43
+ # Render the scrollable list area of a sidebar — the flexible middle that grows between the fixed
44
+ # header and pagination footer.
45
+ #
46
+ # Two forms:
47
+ # * +sidebar_body(collection, empty: '…') { |item| … }+ renders each item via the block, or a muted
48
+ # empty-state hint when the collection is empty — the common list sidebar.
49
+ # * +sidebar_body { … }+ (no collection) just wraps arbitrary content, for sidebars that own their
50
+ # own iteration/empty handling (e.g. the Inbox's Turbo Frame, the search results' two empty states).
51
+ #
52
+ # @param collection [Enumerable, nil] the items to render, or nil for the block-only form
53
+ # @param empty [String, nil] the empty-state message for the collection form
54
+ # @param id [String, nil] DOM id for the scroll container — the live-append broadcast target
55
+ # @yield [item] each item (collection form), or the raw body (block-only form)
56
+ # @return [ActiveSupport::SafeBuffer] the scrollable body markup
57
+ def sidebar_body(collection = nil, empty: nil, id: nil, &block)
58
+ tag.div(class: 'flex-1 overflow-y-auto', id:) do
59
+ if collection.nil?
60
+ capture(&block)
61
+ elsif collection.any?
62
+ safe_join(collection.map { |item| capture(item, &block) })
63
+ else
64
+ hint(empty, classes: 'py-8 text-center')
65
+ end
66
+ end
67
+ end
68
+
69
+ # Subscribe the sidebar to a Turbo Stream for live-appended rows — but only on the first page.
70
+ #
71
+ # New rows are broadcast (prepend/append) into +sidebar_body+'s +id+ target; gating the
72
+ # *subscription* to page 1 is how we keep live rows from landing on the wrong page — later pages
73
+ # simply aren't subscribed and reconcile on their next load. Row-replace updates are id-targeted,
74
+ # so they're unaffected by whether this subscription is present.
75
+ #
76
+ # @param streamables [Array] the Turbo stream identifier(s) (e.g. a string, or +[record, :scope]+)
77
+ # @param pagy [Pagy] the list's pagination — the subscription renders only when +pagy.page == 1+
78
+ # @return [ActiveSupport::SafeBuffer] the stream-source tag on page 1, else an empty buffer
79
+ def sidebar_stream(*streamables, pagy:)
80
+ return ''.html_safe unless pagy.page == 1
81
+
82
+ turbo_stream_from(*streamables)
83
+ end
84
+
85
+ # Render a fixed-height sidebar bar — the standard horizontal strip (bordered, faint, padded) used
86
+ # for a labeled control row such as the trace filter. Its contents are yielded and laid out with
87
+ # space-between, matching the +sidebar_header+/+sidebar_search_link+ bar shape.
88
+ #
89
+ # @yield the bar contents (typically a label span and a control)
90
+ # @return [ActiveSupport::SafeBuffer] the bar markup
91
+ def sidebar_bar(&)
92
+ tag.div(class: 'flex h-10 flex-shrink-0 items-center justify-between px-4 text-xs',
93
+ style: 'border-bottom: 1px solid var(--border); color: var(--text-faint)', &)
94
+ end
95
+
96
+ # Render a sidebar section header with an optional "new record" link.
97
+ #
98
+ # @param title [String] the section title
99
+ # @param new_path [String, nil] optional target for the new-record link
100
+ # @param new_label [String] the new-record link label
101
+ # @return [ActiveSupport::SafeBuffer] the sidebar header markup
102
+ def sidebar_header(title, new_path: nil, new_label: 'New')
103
+ tag.div(class: 'flex h-10 flex-shrink-0 items-center justify-between px-4',
104
+ style: 'border-bottom: 1px solid var(--border)') do
105
+ tag.span(title, class: 'text-xs font-semibold uppercase tracking-wider', style: 'color: var(--text-faint)') +
106
+ (new_path ? ui_link(new_label, new_path) : ''.html_safe)
107
+ end
108
+ end
109
+
110
+ # Render a sidebar search affordance — a faux search field that links to a search page.
111
+ #
112
+ # Styled like an input (magnifier + muted placeholder) but it is a plain link: clicking it
113
+ # navigates to +href+. Sits between the sidebar header and its list.
114
+ #
115
+ # @param href [String] the search page to open
116
+ # @param placeholder [String] the muted prompt text
117
+ # @return [ActiveSupport::SafeBuffer] the search-bar markup
118
+ def sidebar_search_link(href, placeholder: 'Search…')
119
+ classes = 'flex h-10 flex-shrink-0 items-center gap-2 px-4 text-xs transition-colors hover:opacity-80'
120
+ link_to(href, class: classes, style: 'border-bottom: 1px solid var(--border); color: var(--text-faint)') do
121
+ search_icon + tag.span(placeholder)
122
+ end
123
+ end
124
+
125
+ # Render a sidebar list entry — title, optional detail/subtitle, and an
126
+ # optional extra block — highlighted when +active+.
127
+ #
128
+ # @param title [String] the entry title
129
+ # @param href [String] the entry target
130
+ # @param detail [String, ActiveSupport::SafeBuffer, nil] right-aligned detail text or markup
131
+ # @param subtitle [String, nil] optional subtitle line
132
+ # @param active [Boolean] whether to highlight the entry as current
133
+ # @param attrs [Hash] extra HTML attributes forwarded to the link
134
+ # @yield optional extra content rendered beneath the subtitle
135
+ # @return [ActiveSupport::SafeBuffer] the sidebar entry markup
136
+ def sidebar_item(title, href, detail: nil, subtitle: nil, active: false, **attrs, &block)
137
+ link_to(href, class: 'block px-4 py-3 transition-colors', style: sidebar_item_style(active), **attrs) do
138
+ sidebar_item_header(title, detail) +
139
+ sidebar_item_subtitle(subtitle) +
140
+ (block ? tag.div(class: 'mt-1', &block) : ''.html_safe)
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ # The magnifier glyph for +sidebar_search_link+ — a small inline SVG (not an emoji) that inherits
147
+ # the link's color via +currentColor+, so it tracks the theme.
148
+ #
149
+ # @return [ActiveSupport::SafeBuffer] the inline SVG markup
150
+ def search_icon
151
+ raw <<~SVG
152
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0">
153
+ <circle cx="11" cy="11" r="7"></circle>
154
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
155
+ </svg>
156
+ SVG
157
+ end
158
+
159
+ # ── Sidebar item helpers ─────────────────────────────────────
160
+
161
+ # Build the inline style for a sidebar entry, highlighting it when active.
162
+ #
163
+ # @param active [Boolean] whether the entry is the current one
164
+ # @return [String] the entry's CSS style string
165
+ def sidebar_item_style(active)
166
+ if active
167
+ 'background: var(--surface-subtle); border-left: 2px solid var(--color-amber-500, #f59e0b)'
168
+ else
169
+ 'border-left: 2px solid transparent'
170
+ end
171
+ end
172
+
173
+ # Render a sidebar entry's title row with its right-aligned detail.
174
+ #
175
+ # A String detail is wrapped in a muted span; any other truthy value is
176
+ # rendered as-is (e.g. prebuilt markup), and nil renders nothing.
177
+ #
178
+ # @param title [String] the entry title
179
+ # @param detail [String, ActiveSupport::SafeBuffer, nil] the detail text or markup
180
+ # @return [ActiveSupport::SafeBuffer] the title row markup
181
+ def sidebar_item_header(title, detail)
182
+ tag.div(class: 'flex items-baseline justify-between gap-2') do
183
+ tag.span(title, class: 'truncate text-xs font-semibold') + sidebar_item_detail(detail)
184
+ end
185
+ end
186
+
187
+ # Render a sidebar entry's detail value.
188
+ #
189
+ # @param detail [String, ActiveSupport::SafeBuffer, nil] the detail text or markup
190
+ # @return [ActiveSupport::SafeBuffer] the detail markup, or an empty buffer
191
+ def sidebar_item_detail(detail)
192
+ if detail.is_a?(String)
193
+ return tag.span(detail, class: 'flex-shrink-0 text-xs',
194
+ style: 'color: var(--text-faint)')
195
+ end
196
+
197
+ detail || ''.html_safe
198
+ end
199
+
200
+ # Render a sidebar entry's subtitle line, or nothing.
201
+ #
202
+ # @param subtitle [String, nil] the subtitle text
203
+ # @return [ActiveSupport::SafeBuffer] the subtitle markup, or an empty buffer
204
+ def sidebar_item_subtitle(subtitle)
205
+ return ''.html_safe unless subtitle
206
+
207
+ tag.p(subtitle, class: 'mt-0.5 truncate text-xs', style: 'color: var(--text-muted)')
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # The simple data-table component: a header list + rows in, table markup out, with a built-in empty
6
+ # state so call sites never need their own empty-vs-populated branch. Theme colors come from CSS
7
+ # custom properties so the table tracks light/dark mode.
8
+ module TablesHelper
9
+ # A table's data model: column +headers+ and +rows+ (each row an array of cells). Built by view
10
+ # helpers and passed straight to +#table+, so a caller defines both halves in one place.
11
+ Table = Struct.new(:headers, :rows, keyword_init: true)
12
+
13
+ # Render a simple data table from a header list and a list of rows.
14
+ #
15
+ # Each row is an array of cell values, positionally aligned to +headers+; cell values may be plain
16
+ # strings or already-rendered markup (e.g. a +badge+ or +destructive_link+), which is emitted as-is.
17
+ # Theme colors come from CSS custom properties so the table tracks light/dark mode.
18
+ #
19
+ # When +rows+ is empty the table renders a centered, muted +empty+ message in place of the body, so
20
+ # call sites never need their own empty-vs-populated branch.
21
+ #
22
+ # @example
23
+ # table(headers: ["Kind", "Pattern", ""], rows: rules.map { |r| [badge(r.kind), r.pattern, remove_link(r)] })
24
+ #
25
+ # @param headers [Array<String>] the column headers (use +""+ for an unlabeled column)
26
+ # @param rows [Array<Array>] the rows, each an array of cells aligned to +headers+
27
+ # @param empty [String] the message shown, centered, when there are no rows
28
+ # @return [ActiveSupport::SafeBuffer] the table markup
29
+ def table(headers:, rows:, empty: 'No results.')
30
+ return tag.p(empty, class: 'text-center text-sm py-6', style: 'color: var(--text-faint)') if rows.empty?
31
+
32
+ tag.table(class: 'w-full text-sm') do
33
+ tag.thead do
34
+ tag.tr do
35
+ safe_join(headers.map do |header|
36
+ tag.th(header, class: 'text-left font-medium pb-2 pr-4', style: 'color: var(--text-faint)')
37
+ end)
38
+ end
39
+ end +
40
+ tag.tbody do
41
+ safe_join(rows.map { |cells| table_row(cells) })
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # Render one +table+ body row from its cells, divided from the row above by a top border.
49
+ #
50
+ # @param cells [Array] the row's cell values, emitted in order
51
+ # @return [ActiveSupport::SafeBuffer] the +<tr>+ markup
52
+ def table_row(cells)
53
+ tag.tr(style: 'border-top: 1px solid var(--border)') do
54
+ safe_join(cells.map { |cell| tag.td(cell, class: 'py-3 pr-4 align-middle') })
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # Text primitives: headings, muted hints, and verbatim/monospace blocks. Theme colors come from CSS
6
+ # custom properties (--text-faint, …) so type tracks light/dark mode via the single +.dark+ toggle.
7
+ module TypographyHelper
8
+ # Render a level-1 heading.
9
+ #
10
+ # @param text [String] the heading text
11
+ # @return [ActiveSupport::SafeBuffer] the +<h1>+ markup
12
+ def heading_1(text)
13
+ tag.h1(text, class: 'text-lg font-semibold')
14
+ end
15
+
16
+ # Render a level-2 heading styled as a muted, uppercase section label.
17
+ #
18
+ # @param text [String] the heading text
19
+ # @return [ActiveSupport::SafeBuffer] the +<h2>+ markup
20
+ def heading_2(text)
21
+ tag.h2(text, class: 'text-xs font-semibold uppercase tracking-wider', style: 'color: var(--text-faint)')
22
+ end
23
+
24
+ # Render a level-3 heading.
25
+ #
26
+ # @param text [String] the heading text
27
+ # @return [ActiveSupport::SafeBuffer] the +<h3>+ markup
28
+ def heading_3(text)
29
+ tag.h3(text, class: 'text-sm font-semibold')
30
+ end
31
+
32
+ # Render a line of muted helper text — a caption, hint, or inline empty-state note.
33
+ #
34
+ # @param text [String] the hint text
35
+ # @return [ActiveSupport::SafeBuffer] the muted paragraph markup
36
+ # @param [String] classes
37
+ def hint(text, classes: '')
38
+ tag.p(text, class: "text-xs #{classes}", style: 'color: var(--text-faint)')
39
+ end
40
+
41
+ # Render a block of verbatim body text — whitespace preserved, monospace — for content shown
42
+ # as-is, such as an email body or a persona's instructions. Escapes its input.
43
+ #
44
+ # @param text [String] the text to render
45
+ # @return [ActiveSupport::SafeBuffer] the preformatted text block
46
+ def body_text(text)
47
+ tag.div(text, class: 'text-sm whitespace-pre-wrap font-mono')
48
+ end
49
+
50
+ # Render a short code snippet as a boxed, monospace chip — a config line or command shown inline in
51
+ # a page (e.g. an empty state). Sizes to its content, matching the +badge+ surface/border styling.
52
+ # Click-to-copy: the +data-copy+ marker wires it to copy.js (copies the text on click) and the
53
+ # matching CSS rule shows the copy cursor on hover. Escapes its input.
54
+ #
55
+ # @param text [String] the code to display
56
+ # @return [ActiveSupport::SafeBuffer] the code-chip markup
57
+ def code_block(text)
58
+ tag.code(text, class: 'inline-block rounded-md px-3 py-1.5 text-xs font-mono break-all',
59
+ data: { copy: true },
60
+ style: 'background: var(--surface-subtle); border: 1px solid var(--border)')
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # View helpers for email-domain pages and the domains sidebar.
5
+ module EmailDomainsHelper
6
+ # Fetch every +EmailDomain+ ordered by name for sidebar listing.
7
+ #
8
+ # @return [ActiveRecord::Relation<Protege::EmailDomain>] domains ordered alphabetically
9
+ def all_email_domains
10
+ EmailDomain.order(:domain)
11
+ end
12
+
13
+ # Whether Protege is running the self-hosted MTA (as opposed to an email provider). Keys off the
14
+ # existing accessory setting via +Gateway.mail_accessory_host+ — no separate flag. Drives the
15
+ # mode-aware domain page: only self-hosting uses +EmailDomain+'s own DKIM key and can push to an MTA.
16
+ #
17
+ # @return [Boolean] true when a mail accessory is configured
18
+ def mail_self_hosted?
19
+ Gateway.mail_accessory_host.present?
20
+ end
21
+ end
22
+ end