rails-markup 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +304 -0
  4. data/app/assets/javascripts/rails_markup/toolbar.js +2225 -0
  5. data/app/controllers/rails_markup/annotations_controller.rb +244 -0
  6. data/app/controllers/rails_markup/application_controller.rb +7 -0
  7. data/app/controllers/rails_markup/dashboard_controller.rb +230 -0
  8. data/app/controllers/rails_markup/external/annotations_controller.rb +68 -0
  9. data/app/models/rails_markup/annotation.rb +176 -0
  10. data/app/views/layouts/rails_markup/application.html.erb +158 -0
  11. data/app/views/rails_markup/dashboard/_annotation_card.html.erb +25 -0
  12. data/app/views/rails_markup/dashboard/_annotation_list.html.erb +57 -0
  13. data/app/views/rails_markup/dashboard/_annotation_page.html.erb +13 -0
  14. data/app/views/rails_markup/dashboard/_filters.html.erb +75 -0
  15. data/app/views/rails_markup/dashboard/_stats.html.erb +22 -0
  16. data/app/views/rails_markup/dashboard/_styles.html.erb +115 -0
  17. data/app/views/rails_markup/dashboard/board.html.erb +135 -0
  18. data/app/views/rails_markup/dashboard/index.html.erb +38 -0
  19. data/app/views/rails_markup/dashboard/show.html.erb +129 -0
  20. data/app/views/rails_markup/shared/_toolbar.html.erb +28 -0
  21. data/bin/rails-markup +5 -0
  22. data/config/routes.rb +45 -0
  23. data/db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb +13 -0
  24. data/db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb +17 -0
  25. data/lib/generators/rails_markup/install/templates/auth_controller.rb.erb +17 -0
  26. data/lib/generators/rails_markup/install/templates/bin_markup.erb +5 -0
  27. data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +27 -0
  28. data/lib/generators/rails_markup/install/templates/initializer.rb.erb +54 -0
  29. data/lib/generators/rails_markup/install_generator.rb +167 -0
  30. data/lib/generators/rails_markup/uninstall_generator.rb +110 -0
  31. data/lib/rails_markup/cli/base.rb +8 -0
  32. data/lib/rails_markup/cli/initializer_writer.rb +54 -0
  33. data/lib/rails_markup/cli/setup_wizard.rb +229 -0
  34. data/lib/rails_markup/cli.rb +831 -0
  35. data/lib/rails_markup/client_uuid_maintenance.rb +174 -0
  36. data/lib/rails_markup/configuration.rb +160 -0
  37. data/lib/rails_markup/engine.rb +18 -0
  38. data/lib/rails_markup/http_server.rb +226 -0
  39. data/lib/rails_markup/http_store_proxy.rb +122 -0
  40. data/lib/rails_markup/mcp_config.rb +258 -0
  41. data/lib/rails_markup/mcp_server.rb +639 -0
  42. data/lib/rails_markup/server.rb +73 -0
  43. data/lib/rails_markup/store.rb +219 -0
  44. data/lib/rails_markup/version.rb +5 -0
  45. data/lib/rails_markup.rb +11 -0
  46. data/lib/tasks/rails_markup_client_uuids.rake +19 -0
  47. metadata +198 -0
@@ -0,0 +1,135 @@
1
+ <% content_for(:title) { "Board" } %>
2
+ <% content_for(:page_title) { "Annotations Board" } %>
3
+
4
+ <%= render "styles" %>
5
+
6
+ <%# View toggle %>
7
+ <div class="rm-filters">
8
+ <a href="<%= rails_markup.root_path %>" class="rm-pill">List</a>
9
+ <a href="<%= rails_markup.board_path %>" class="rm-pill rm-pill-active">Board</a>
10
+ </div>
11
+
12
+ <div class="rm-board">
13
+ <% column_config = {
14
+ pending: { label: "Pending", color: "#f59e0b", bg: "#fffbeb", border: "#fde68a" },
15
+ acknowledged: { label: "Acknowledged", color: "#3b82f6", bg: "#eff6ff", border: "#bfdbfe" },
16
+ resolved: { label: "Resolved", color: "#10b981", bg: "#ecfdf5", border: "#a7f3d0" },
17
+ dismissed: { label: "Dismissed", color: "#9ca3af", bg: "#f9fafb", border: "#e5e7eb" }
18
+ } %>
19
+
20
+ <% column_config.each do |status, config| %>
21
+ <div class="rm-board-column" data-status="<%= status %>"
22
+ style="border-top: 3px solid <%= config[:color] %>;">
23
+ <div class="rm-board-column-header" style="background: <%= config[:bg] %>; color: <%= config[:color] %>;">
24
+ <span><%= config[:label] %></span>
25
+ <span style="font-size:12px;font-weight:400;"><%= @columns[status].size %></span>
26
+ </div>
27
+ <div class="rm-board-cards">
28
+ <% @columns[status].each do |annotation| %>
29
+ <div class="rm-board-card" draggable="true" data-annotation-id="<%= annotation.id %>">
30
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
31
+ <span class="rm-badge rm-intent-<%= annotation.intent %>" style="font-size:10px;"><%= annotation.intent %></span>
32
+ <% if annotation.severity != "suggestion" %>
33
+ <span class="rm-badge rm-severity-<%= annotation.severity %>" style="font-size:10px;"><%= annotation.severity %></span>
34
+ <% end %>
35
+ <% if annotation.author_name.present? %>
36
+ <span style="font-size:10px;color:#9ca3af;margin-left:auto;"><%= annotation.author_name %></span>
37
+ <% end %>
38
+ </div>
39
+ <div style="font-size:13px;line-height:1.5;color:#374151;"><%= truncate(annotation.content, length: 120) %></div>
40
+ <div style="margin-top:6px;font-size:10px;color:#d1d5db;">
41
+ <code style="font-size:10px;"><%= truncate(annotation.page_url, length: 30) %></code>
42
+ &middot;
43
+ <span title="<%= annotation.created_at.strftime("%B %d, %Y at %H:%M") %>"><%= time_ago_in_words(annotation.created_at) %></span>
44
+ </div>
45
+ <%# Touch fallback for drag-and-drop: pick a status directly %>
46
+ <select class="rm-board-move" data-annotation-id="<%= annotation.id %>"
47
+ aria-label="Move annotation to status"
48
+ style="margin-top:8px;width:100%;font-size:11px;padding:4px;border:1px solid #e5e7eb;border-radius:6px;background:#fff;color:#6b7280;">
49
+ <% %w[pending acknowledged resolved dismissed].each do |s| %>
50
+ <option value="<%= s %>" <%= "selected" if annotation.status == s %>><%= s.capitalize %></option>
51
+ <% end %>
52
+ </select>
53
+ </div>
54
+ <% end %>
55
+ <% if @columns[status].empty? %>
56
+ <div style="text-align:center;padding:24px 8px;color:#d1d5db;font-size:12px;">No items</div>
57
+ <% end %>
58
+ </div>
59
+ </div>
60
+ <% end %>
61
+ </div>
62
+
63
+ <script>
64
+ (function() {
65
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
66
+
67
+ document.querySelectorAll('.rm-board-card').forEach(card => {
68
+ card.addEventListener('dragstart', (e) => {
69
+ e.dataTransfer.setData('text/plain', card.dataset.annotationId);
70
+ e.dataTransfer.effectAllowed = 'move';
71
+ card.style.opacity = '0.5';
72
+ });
73
+ card.addEventListener('dragend', () => { card.style.opacity = '1'; });
74
+ });
75
+
76
+ document.querySelectorAll('.rm-board-column').forEach(col => {
77
+ col.addEventListener('dragover', (e) => {
78
+ e.preventDefault();
79
+ e.dataTransfer.dropEffect = 'move';
80
+ col.classList.add('rm-drag-over');
81
+ });
82
+ col.addEventListener('dragleave', (e) => {
83
+ if (!col.contains(e.relatedTarget)) col.classList.remove('rm-drag-over');
84
+ });
85
+ col.addEventListener('drop', (e) => {
86
+ e.preventDefault();
87
+ col.classList.remove('rm-drag-over');
88
+ const id = e.dataTransfer.getData('text/plain');
89
+ moveCard(id, col.dataset.status);
90
+ });
91
+ });
92
+
93
+ // Touch fallback: status <select> on each card
94
+ document.querySelectorAll('.rm-board-move').forEach(select => {
95
+ select.addEventListener('change', () => {
96
+ moveCard(select.dataset.annotationId, select.value);
97
+ });
98
+ });
99
+
100
+ function moveCard(id, newStatus) {
101
+ const card = document.querySelector('[data-annotation-id="' + id + '"]');
102
+ if (!card || card.closest('[data-status]').dataset.status === newStatus) return;
103
+
104
+ // Optimistically move the card to the target column, then reconcile.
105
+ const targetCol = document.querySelector('.rm-board-column[data-status="' + newStatus + '"]');
106
+ if (targetCol) targetCol.querySelector('.rm-board-cards').appendChild(card);
107
+ const select = card.querySelector('.rm-board-move');
108
+ if (select) select.value = newStatus;
109
+ updateColumnCounts();
110
+
111
+ const basePath = window.location.pathname.replace(/\/board\/?$/, '');
112
+ fetch(basePath + '/annotations/' + id, {
113
+ method: 'PATCH',
114
+ headers: {
115
+ 'Content-Type': 'application/json',
116
+ 'X-CSRF-Token': csrfToken || ''
117
+ },
118
+ body: JSON.stringify({ action_type: 'transition', status: newStatus })
119
+ }).then((resp) => {
120
+ // A rejected transition (422/401/…) resolves without throwing — resync.
121
+ if (!resp.ok) window.location.reload();
122
+ }).catch(() => {
123
+ window.location.reload();
124
+ });
125
+ }
126
+
127
+ function updateColumnCounts() {
128
+ document.querySelectorAll('.rm-board-column').forEach(col => {
129
+ const count = col.querySelectorAll('.rm-board-card').length;
130
+ const countEl = col.querySelector('.rm-board-column-header span:last-child');
131
+ if (countEl) countEl.textContent = count;
132
+ });
133
+ }
134
+ })();
135
+ </script>
@@ -0,0 +1,38 @@
1
+ <% content_for(:title) { "Dashboard" } %>
2
+ <% content_for(:page_title) { "Annotations" } %>
3
+
4
+ <%= render "styles" %>
5
+
6
+
7
+ <div class="rm-layout">
8
+ <div class="rm-main">
9
+ <turbo-frame id="annotations-content">
10
+ <%= render "stats" %>
11
+ <%= render "filters" %>
12
+ <%= render "annotation_list" %>
13
+ </turbo-frame>
14
+ </div>
15
+
16
+ <turbo-frame id="detail-panel" class="rm-panel rm-panel-empty">
17
+ </turbo-frame>
18
+ </div>
19
+
20
+ <script>
21
+ document.addEventListener("turbo:frame-load", (e) => {
22
+ if (e.target.id !== "detail-panel") return;
23
+ // Show panel (hidden when empty)
24
+ e.target.classList.remove("rm-panel-empty");
25
+ // Highlight active card
26
+ document.querySelectorAll(".rm-card-active").forEach(c => c.classList.remove("rm-card-active"));
27
+ const src = e.target.getAttribute("src") || "";
28
+ const match = src.match(/annotations\/(\d+)/);
29
+ if (match) {
30
+ const card = document.querySelector(`[data-annotation-id="${match[1]}"]`);
31
+ if (card) card.classList.add("rm-card-active");
32
+ }
33
+ // Scroll to panel on mobile
34
+ if (window.innerWidth <= 900) {
35
+ e.target.scrollIntoView({ behavior: "smooth", block: "start" });
36
+ }
37
+ });
38
+ </script>
@@ -0,0 +1,129 @@
1
+ <% content_for(:title) { "Annotation ##{@annotation.id}" } %>
2
+
3
+ <%= render "styles" %>
4
+
5
+ <turbo-frame id="detail-panel">
6
+ <div class="rm-detail">
7
+ <%# Close button %>
8
+ <button type="button" class="rm-back" onclick="var f=this.closest('turbo-frame'); f.innerHTML=''; f.classList.add('rm-panel-empty'); document.querySelectorAll('.rm-card-active').forEach(c => c.classList.remove('rm-card-active'));">&#10005; Close</button>
9
+
10
+ <%# Header badges %>
11
+ <div class="rm-detail-header">
12
+ <span class="rm-dot rm-dot-<%= @annotation.status %>"></span>
13
+ <span class="rm-badge rm-status-<%= @annotation.status %>"><%= @annotation.status %></span>
14
+ <span class="rm-badge rm-intent-<%= @annotation.intent %>"><%= @annotation.intent %></span>
15
+ <span class="rm-badge rm-severity-<%= @annotation.severity %>"><%= @annotation.severity %></span>
16
+ </div>
17
+
18
+ <%# Content %>
19
+ <div class="rm-detail-content"><%= @annotation.content %></div>
20
+
21
+ <%# Metadata %>
22
+ <dl class="rm-detail-meta">
23
+ <dt>Page</dt>
24
+ <dd><code><%= @annotation.page_url %></code></dd>
25
+
26
+ <% if @annotation.selected_text.present? %>
27
+ <dt>Selected text</dt>
28
+ <dd>&ldquo;<%= truncate(@annotation.selected_text, length: 200) %>&rdquo;</dd>
29
+ <% end %>
30
+
31
+ <% if @annotation.target.present? && @annotation.target["selector"].present? %>
32
+ <dt>Element</dt>
33
+ <dd><code><%= @annotation.target["selector"] %></code></dd>
34
+ <% end %>
35
+
36
+ <% if @annotation.target.present? && @annotation.target["boundingBox"].present? %>
37
+ <dt>Position</dt>
38
+ <dd><%= @annotation.target["boundingBox"].values_at("x", "y", "width", "height").compact.join(" \u00D7 ") %></dd>
39
+ <% end %>
40
+
41
+ <% if @annotation.author_name.present? %>
42
+ <dt>Author</dt>
43
+ <dd><%= @annotation.author_name %></dd>
44
+ <% end %>
45
+
46
+ <dt>Created</dt>
47
+ <dd><span title="<%= @annotation.created_at.strftime("%B %d, %Y at %H:%M:%S") %>"><%= time_ago_in_words(@annotation.created_at) %> ago</span></dd>
48
+
49
+ <% if @annotation.updated_at != @annotation.created_at %>
50
+ <dt>Updated</dt>
51
+ <dd><span title="<%= @annotation.updated_at.strftime("%B %d, %Y at %H:%M:%S") %>"><%= time_ago_in_words(@annotation.updated_at) %> ago</span></dd>
52
+ <% end %>
53
+
54
+ <% if @annotation.metadata&.dig("screenshot").present? %>
55
+ <dt>Screenshot</dt>
56
+ <dd><img src="<%= @annotation.metadata["screenshot"] %>" style="max-width:100%;border:1px solid #e5e7eb;border-radius:8px;" alt="Element screenshot"></dd>
57
+ <% end %>
58
+ </dl>
59
+
60
+ <%# Actions (only for non-terminal states) %>
61
+ <% if @annotation.status.in?(%w[pending acknowledged]) %>
62
+ <div class="rm-actions">
63
+ <% if @annotation.status == "pending" %>
64
+ <%= form_tag(rails_markup.annotation_path(@annotation, action_type: "acknowledge"), method: :patch, style: "display:inline") do %>
65
+ <button type="submit" class="rm-btn rm-btn-primary">Acknowledge</button>
66
+ <% end %>
67
+ <% end %>
68
+
69
+ <button type="button" class="rm-btn rm-btn-success" onclick="document.getElementById('resolve-form-<%= @annotation.id %>').style.display='block'">Resolve</button>
70
+ <button type="button" class="rm-btn rm-btn-danger" onclick="document.getElementById('dismiss-form-<%= @annotation.id %>').style.display='block'">Dismiss</button>
71
+ </div>
72
+
73
+ <%# Resolve form %>
74
+ <div id="resolve-form-<%= @annotation.id %>" style="display:none; margin-top: 16px;">
75
+ <%= form_tag(rails_markup.annotation_path(@annotation, action_type: "resolve"), method: :patch) do %>
76
+ <div class="rm-form-group">
77
+ <label for="summary">Resolution summary (optional)</label>
78
+ <textarea name="summary" id="summary" class="rm-input" rows="2" placeholder="What was done to resolve this?"></textarea>
79
+ </div>
80
+ <button type="submit" class="rm-btn rm-btn-success">Confirm Resolve</button>
81
+ <button type="button" class="rm-btn" onclick="this.parentElement.parentElement.style.display='none'">Cancel</button>
82
+ <% end %>
83
+ </div>
84
+
85
+ <%# Dismiss form %>
86
+ <div id="dismiss-form-<%= @annotation.id %>" style="display:none; margin-top: 16px;">
87
+ <%= form_tag(rails_markup.annotation_path(@annotation, action_type: "dismiss"), method: :patch) do %>
88
+ <div class="rm-form-group">
89
+ <label for="reason">Dismiss reason (optional)</label>
90
+ <textarea name="reason" id="reason" class="rm-input" rows="2" placeholder="Why is this being dismissed?"></textarea>
91
+ </div>
92
+ <button type="submit" class="rm-btn rm-btn-danger">Confirm Dismiss</button>
93
+ <button type="button" class="rm-btn" onclick="this.parentElement.parentElement.style.display='none'">Cancel</button>
94
+ <% end %>
95
+ </div>
96
+ <% end %>
97
+
98
+ <%# Thread %>
99
+ <% if @annotation.thread.any? %>
100
+ <div class="rm-thread">
101
+ <h3>Thread (<%= @annotation.thread.size %>)</h3>
102
+ <% @annotation.thread.each do |entry| %>
103
+ <div class="rm-thread-entry rm-thread-<%= entry['role'] || 'user' %>">
104
+ <div class="rm-thread-role"><%= entry["role"] || "user" %></div>
105
+ <div><%= entry["message"] %></div>
106
+ <div class="rm-thread-time"><%= entry["timestamp"] %></div>
107
+ </div>
108
+ <% end %>
109
+ </div>
110
+ <% end %>
111
+
112
+ <%# Reply form %>
113
+ <div class="rm-thread" style="<%= @annotation.thread.empty? ? 'margin-top: 24px; border-top: 1px solid #e5e7eb; padding-top: 20px;' : '' %>">
114
+ <h3>Add reply</h3>
115
+ <%= form_tag(rails_markup.annotation_path(@annotation, action_type: "reply"), method: :patch) do %>
116
+ <div class="rm-form-group">
117
+ <textarea name="message" class="rm-input" rows="2" placeholder="Type a reply..." required></textarea>
118
+ </div>
119
+ <div class="rm-form-group">
120
+ <select name="role" class="rm-select">
121
+ <option value="agent">Agent</option>
122
+ <option value="user">User</option>
123
+ </select>
124
+ </div>
125
+ <button type="submit" class="rm-btn rm-btn-primary">Reply</button>
126
+ <% end %>
127
+ </div>
128
+ </div>
129
+ </turbo-frame>
@@ -0,0 +1,28 @@
1
+ <%# Rails Markup annotation toolbar — self-contained, no Tailwind/Stimulus dependency %>
2
+ <% if RailsMarkup.config.toolbar_enabled %>
3
+ <script>
4
+ <%== File.read(File.expand_path("../../../assets/javascripts/rails_markup/toolbar.js", __dir__)) %>
5
+ </script>
6
+ <script>
7
+ (function() {
8
+ if (window.__rmToolbarBound) return;
9
+ window.__rmToolbarBound = true;
10
+
11
+ var opts = {
12
+ endpoint: "<%= rails_markup.root_path.chomp("/") %>/api",
13
+ accent: "<%= j(RailsMarkup.config.toolbar_accent) %>",
14
+ position: "<%= j(RailsMarkup.config.toolbar_position) %>",
15
+ size: "<%= j(RailsMarkup.config.toolbar_size) %>",
16
+ fabVisible: <%= RailsMarkup.config.fab_visible ? "true" : "false" %>,
17
+ enableScreenshots: <%= RailsMarkup.config.enable_screenshots ? "true" : "false" %>,
18
+ healthInterval: <%= RailsMarkup.config.health_interval %>
19
+ };
20
+ // Init immediately (DOM is ready — script is at end of body)
21
+ RailsMarkupToolbar.init(opts);
22
+ // Re-init after Turbo Drive navigations (DOMContentLoaded won't fire again)
23
+ document.addEventListener("turbo:load", function() {
24
+ RailsMarkupToolbar.init(opts);
25
+ });
26
+ })();
27
+ </script>
28
+ <% end %>
data/bin/rails-markup ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/rails_markup/cli"
5
+ RailsMarkup::Cli.start(ARGV)
data/config/routes.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsMarkup::Engine.routes.draw do
4
+ # Dashboard
5
+ root to: "dashboard#index"
6
+ resources :annotations, only: [:show, :update], controller: "dashboard", constraints: { id: /\d+/ }
7
+ post "dismiss_all", to: "dashboard#dismiss_all"
8
+ get "load_more", to: "dashboard#load_more", as: :load_more
9
+ get "board", to: "dashboard#board"
10
+ get "export.csv", to: "dashboard#export_csv", as: :export_csv
11
+ get "export.json", to: "dashboard#export_json", as: :export_json
12
+
13
+ # Toolbar API (same-origin, used by browser toolbar)
14
+ scope :api, defaults: { format: :json } do
15
+ post "sessions", to: "annotations#create_session"
16
+ post "sessions/:session_id/annotations", to: "annotations#create"
17
+ get "health", to: "annotations#health"
18
+
19
+ get "annotations", to: "annotations#index"
20
+ put "annotations/:client_uuid", to: "annotations#upsert"
21
+ delete "annotations/:client_uuid", to: "annotations#destroy"
22
+
23
+ scope "annotations/:id", constraints: { id: /\d+/ } do
24
+ post "acknowledge", to: "annotations#acknowledge"
25
+ post "resolve", to: "annotations#resolve"
26
+ post "dismiss", to: "annotations#dismiss"
27
+ post "reply", to: "annotations#reply"
28
+ end
29
+ end
30
+
31
+ # External API (token-authenticated, used by MCP/CLI tools)
32
+ # Paths: /external/pending, /external/:id, /external/:id/{action}
33
+ # Full URL (with mount): /admin/annotations/external/pending
34
+ namespace :external, defaults: { format: :json } do
35
+ get "pending", to: "annotations#pending"
36
+ get ":id", to: "annotations#show", constraints: { id: /\d+/ }
37
+
38
+ scope ":id", constraints: { id: /\d+/ } do
39
+ patch "acknowledge", to: "annotations#acknowledge"
40
+ patch "resolve", to: "annotations#resolve"
41
+ patch "dismiss", to: "annotations#dismiss"
42
+ patch "reply", to: "annotations#reply"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddClientUuidToRailsMarkupAnnotations < ActiveRecord::Migration[7.0]
4
+ def change
5
+ # Honor a custom config.table_name — hardcoding the default silently skips
6
+ # the migration (and defeats create-dedup) on installs that renamed the table.
7
+ table = RailsMarkup.config.table_name.to_sym
8
+ return unless table_exists?(table)
9
+
10
+ add_column table, :client_uuid, :string, limit: 64 unless column_exists?(table, :client_uuid)
11
+ add_index table, :client_uuid, unique: true unless index_exists?(table, :client_uuid, unique: true)
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_markup/client_uuid_maintenance"
4
+
5
+ class BackfillRailsMarkupClientUuids < ActiveRecord::Migration[7.0]
6
+ def up
7
+ RailsMarkup::ClientUuidMaintenance.repair!(
8
+ connection:,
9
+ table_name: RailsMarkup.config.table_name
10
+ )
11
+ end
12
+
13
+ # The expand/backfill release deliberately leaves the column nullable while
14
+ # old application instances may still write rows. A later contract migration
15
+ # adds NOT NULL only after repair + verification and old-instance shutdown.
16
+ def down; end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base controller for Rails Markup engine.
4
+ # Inherits host authentication from <%= options[:base_controller] %> and denies
5
+ # access unless the conventional current_user.admin? contract is satisfied.
6
+ # Customize authorize_rails_markup! when your application uses another policy.
7
+ class RailsMarkupAuthController < <%= options[:base_controller] %>
8
+ before_action :authorize_rails_markup!
9
+
10
+ private
11
+
12
+ def authorize_rails_markup!
13
+ return if respond_to?(:current_user, true) && current_user&.respond_to?(:admin?) && current_user.admin?
14
+
15
+ head :forbidden
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rails_markup/cli"
5
+ RailsMarkup::Cli.start(ARGV)
@@ -0,0 +1,27 @@
1
+ class CreateRailsMarkupAnnotations < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ # jsonb on PostgreSQL; json elsewhere (SQLite/MySQL have no jsonb type).
4
+ json_type = connection.adapter_name.downcase.include?("postgres") ? :jsonb : :json
5
+
6
+ create_table :<%= options[:table_name] %> do |t|
7
+ t.bigint :user_id
8
+ t.string :page_url, null: false
9
+ t.send json_type, :target, default: {}
10
+ t.text :content, null: false
11
+ t.string :intent, null: false, default: "change"
12
+ t.string :severity, null: false, default: "suggestion"
13
+ t.string :status, null: false, default: "pending"
14
+ t.text :selected_text
15
+ t.send json_type, :metadata, default: {}
16
+ t.send json_type, :thread, default: []
17
+ t.string :client_uuid, limit: 64, null: false
18
+
19
+ t.timestamps
20
+ end
21
+
22
+ add_index :<%= options[:table_name] %>, [:status, :created_at]
23
+ add_index :<%= options[:table_name] %>, :page_url
24
+ add_index :<%= options[:table_name] %>, :user_id
25
+ add_index :<%= options[:table_name] %>, :client_uuid, unique: true
26
+ end
27
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsMarkup.configure do |config|
4
+ # Base controller class for the dashboard and toolbar API. This controller
5
+ # must enforce authentication and authorization; a public controller makes
6
+ # both interfaces public. The generated controller denies non-admin users by
7
+ # default and is the place to integrate your host application's policy.
8
+ config.base_controller_class = "RailsMarkupAuthController"
9
+
10
+ # Bearer token for the external API (used by MCP production tools).
11
+ # Set to enable the /external/* endpoints.
12
+ # config.api_token = Rails.application.credentials.dig(:rails_markup, :api_token)
13
+
14
+ # Database table name (default: "rails_markup_annotations")
15
+ # config.table_name = "rails_markup_annotations"
16
+
17
+ # Annotations per page on the dashboard (default: 25)
18
+ # config.per_page = 25
19
+
20
+ # Toolbar accent color: "indigo", "amber", "blue", "emerald", "rose"
21
+ # config.toolbar_accent = "indigo"
22
+
23
+ # Render the annotation toolbar system at all — FAB, panel, pins, sync (default: true)
24
+ # config.toolbar_enabled = true
25
+
26
+ # Show the floating action button specifically. When false the FAB is hidden
27
+ # but pins still render and the panel stays reachable (default: true)
28
+ # config.fab_visible = true
29
+
30
+ # FAB button position: "bl" (bottom-left), "br" (bottom-right),
31
+ # "tl" (top-left), "tr" (top-right). Default: "bl"
32
+ # config.toolbar_position = "bl"
33
+
34
+ # FAB button size: "default" (48px), "compact" (40px), "slim" (32px)
35
+ # config.toolbar_size = "default"
36
+
37
+ # "Back to app" link in the dashboard header.
38
+ # config.return_url = "/admin"
39
+
40
+ # How to resolve the author's display name from current_user.
41
+ # Symbol: calls the method on the user — e.g. :name, :email, :display_name
42
+ # Proc: receives user, returns string — e.g. ->(u) { "#{u.first_name} #{u.last_name}" }
43
+ # Default: :email
44
+ # config.author_name_method = :name
45
+
46
+ # Callback fired after each annotation is created.
47
+ # Use for Slack, email, or webhook notifications.
48
+ # config.on_create_callback = ->(annotation) {
49
+ # SlackNotifier.notify("New feedback on #{annotation.page_url}: #{annotation.content}")
50
+ # }
51
+
52
+ # Enable element screenshot capture in the toolbar (default: true)
53
+ # config.enable_screenshots = true
54
+ end