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,2225 @@
1
+ /**
2
+ * Rails Markup Toolbar — self-contained annotation UI
3
+ * No dependencies (no Stimulus, no importmap). Works in any Rails app.
4
+ *
5
+ * Usage: include via <script> tag, then call:
6
+ * RailsMarkupToolbar.init({ endpoint: "/feedback/api", accent: "indigo" })
7
+ */
8
+ (function(global) {
9
+ "use strict";
10
+
11
+ if (global.RailsMarkupToolbar) return;
12
+
13
+ const RailsMarkupToolbar = {
14
+ // State
15
+ annotations: [],
16
+ outbox: {},
17
+ legacyMigrations: {},
18
+ nextId: 1,
19
+ active: false,
20
+ serverOnline: false,
21
+ sessionId: null,
22
+ sseSource: null,
23
+ healthInterval: null,
24
+ hoveredElement: null,
25
+ selectedText: null,
26
+ clickedElement: null,
27
+ activeFilter: "all",
28
+ editingId: null,
29
+ _currentScreenshot: null,
30
+ _storageError: null,
31
+ _outboxFlushScheduled: false,
32
+ _outboxFlushTimer: null,
33
+ _flushPromise: null,
34
+ _flushAfterPullPromise: null,
35
+ _healthPromise: null,
36
+ _pullPromise: null,
37
+ _pullPageUrl: null,
38
+ _pullNeeded: true,
39
+ _syncRetryTimer: null,
40
+ _syncRetryAttempt: 0,
41
+ _syncRetryDelay: 0,
42
+ _syncBaseRetryDelay: 1000,
43
+ _syncMaxRetryDelay: 30000,
44
+ _syncMalformedLimit: 3,
45
+ _syncUnavailable: null,
46
+
47
+ // Drawing state
48
+ drawingMode: null, // null | "arrow" | "rect" | "highlight"
49
+ drawingCanvas: null,
50
+ drawingCtx: null,
51
+ drawingStart: null,
52
+ drawingHistory: [],
53
+ _screenshotImg: null,
54
+
55
+ // Config
56
+ endpoint: "/feedback/api",
57
+ accent: "indigo",
58
+ position: "bl",
59
+ size: "default",
60
+ fabVisible: true,
61
+ enableScreenshots: true,
62
+
63
+ // DOM refs (set in init)
64
+ root: null,
65
+
66
+ init(opts = {}) {
67
+ const previousPathname = this._currentPathname;
68
+ const previousPageUrl = this._currentPageUrl;
69
+ const currentPageUrl = this._pageUrl();
70
+ this.endpoint = opts.endpoint || "/feedback/api";
71
+ this.accent = opts.accent || "indigo";
72
+ this.position = opts.position || "bl";
73
+ this.size = opts.size || "default";
74
+ this.fabVisible = opts.fabVisible !== false;
75
+ this.enableScreenshots = opts.enableScreenshots !== false;
76
+ this.healthIntervalMs = (opts.healthInterval || 60) * 1000;
77
+
78
+ if (document.getElementById("rm-toolbar-root")) {
79
+ if (currentPageUrl !== this._currentPageUrl) this._onTurboNavigate();
80
+ return;
81
+ }
82
+
83
+ if (previousPathname && previousPathname !== window.location.pathname) this._deactivateMode();
84
+ if (previousPageUrl && previousPageUrl !== currentPageUrl && previousPathname === window.location.pathname) this._deactivateMode();
85
+ this._currentPathname = window.location.pathname;
86
+ this._currentPageUrl = currentPageUrl;
87
+ this._injectStyles();
88
+ this._injectDOM();
89
+ this._bindEvents();
90
+ this._loadFromStorage();
91
+ this._checkHealth();
92
+ if (!this.healthInterval) this.healthInterval = setInterval(() => this._checkHealth(), this.healthIntervalMs);
93
+ if (!this._boundVisibilityChange) {
94
+ this._boundVisibilityChange = () => this._onVisibilityChange();
95
+ document.addEventListener("visibilitychange", this._boundVisibilityChange);
96
+ }
97
+ if (!this._boundOnline) {
98
+ this._boundOnline = () => this._onOnline();
99
+ window.addEventListener("online", this._boundOnline);
100
+ }
101
+ this._renderPins();
102
+ this._updateCount();
103
+ if (previousPageUrl && previousPageUrl !== this._currentPageUrl && this.serverOnline) this._initSession();
104
+ },
105
+
106
+ destroy() {
107
+ this._deactivateMode();
108
+ if (this.sseSource) { this.sseSource.close(); this.sseSource = null; }
109
+ if (this.healthInterval) { clearInterval(this.healthInterval); this.healthInterval = null; }
110
+ if (this._outboxFlushTimer) { clearTimeout(this._outboxFlushTimer); this._outboxFlushTimer = null; }
111
+ if (this._syncRetryTimer) { clearTimeout(this._syncRetryTimer); this._syncRetryTimer = null; }
112
+ this._outboxFlushScheduled = false;
113
+ if (this._boundVisibilityChange) {
114
+ document.removeEventListener("visibilitychange", this._boundVisibilityChange);
115
+ this._boundVisibilityChange = null;
116
+ }
117
+ if (this._boundOnline) {
118
+ window.removeEventListener("online", this._boundOnline);
119
+ this._boundOnline = null;
120
+ }
121
+ if (this._onResize) window.removeEventListener("resize", this._onResize);
122
+ if (this._onScroll) window.removeEventListener("scroll", this._onScroll);
123
+ if (this._boundTurboFrame) {
124
+ document.removeEventListener("turbo:frame-render", this._boundTurboFrame);
125
+ this._boundTurboFrame = null;
126
+ }
127
+ this._onResize = null;
128
+ this._onScroll = null;
129
+ const root = document.getElementById("rm-toolbar-root");
130
+ if (root) root.remove();
131
+ const pins = document.getElementById("rm-pins-container");
132
+ if (pins) pins.remove();
133
+ const styles = document.getElementById("rm-toolbar-styles");
134
+ if (styles) styles.remove();
135
+ },
136
+
137
+ // ---- DOM injection ----
138
+
139
+ _injectStyles() {
140
+ if (document.getElementById("rm-toolbar-styles")) return;
141
+ const style = document.createElement("style");
142
+ style.id = "rm-toolbar-styles";
143
+ style.textContent = `
144
+ @keyframes rm-pulse { 0%,100%{box-shadow:0 2px 8px rgba(0,0,0,0.2)} 50%{box-shadow:0 2px 12px rgba(0,0,0,0.3),0 0 0 4px rgba(99,102,241,0.15)} }
145
+ @keyframes rm-toast-in { from{opacity:0;transform:translateY(16px) scale(0.95)} to{opacity:1;transform:translateY(0) scale(1)} }
146
+ @keyframes rm-toast-out { from{opacity:1;transform:translateY(0) scale(1)} to{opacity:0;transform:translateY(16px) scale(0.95)} }
147
+ #rm-toolbar-root { position:fixed; inset:0; pointer-events:none; z-index:9979; }
148
+ #rm-toolbar-root * { box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",sans-serif; }
149
+ .rm-fab, .rm-panel-toggle, .rm-panel, .rm-popup { pointer-events:auto; }
150
+ .rm-fab { position:fixed; z-index:9980; border-radius:50%; border:none; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:all 0.2s; box-shadow:0 4px 12px rgba(0,0,0,0.15); }
151
+ .rm-fab svg { width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
152
+ .rm-fab-badge { position:absolute; top:-4px; right:-4px; min-width:20px; height:20px; padding:0 4px; border-radius:10px; background:#ef4444; color:#fff; font-size:11px; font-weight:700; display:none; align-items:center; justify-content:center; }
153
+ .rm-panel-toggle { position:fixed; z-index:9980; width:32px; height:32px; border-radius:50%; border:1px solid #e5e7eb; background:rgba(255,255,255,0.9); cursor:pointer; display:none; align-items:center; justify-content:center; color:#6b7280; transition:all 0.2s; backdrop-filter:blur(8px); }
154
+ .rm-panel-toggle:hover { color:#4361ee; }
155
+ .rm-panel-toggle svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
156
+ .rm-toast-container { position:fixed; z-index:9983; display:flex; flex-direction:column; gap:8px; pointer-events:none; }
157
+ .rm-pins-container { position:absolute; top:0; left:0; width:100%; z-index:9979; pointer-events:none; }
158
+ .rm-pin { pointer-events:auto; }
159
+ .rm-popup { display:none; position:fixed; z-index:9982; width:360px; max-width:calc(100vw - 24px); background:rgba(255,255,255,0.95); backdrop-filter:blur(12px); border-radius:16px; box-shadow:0 25px 50px rgba(0,0,0,0.1); border:1px solid rgba(229,231,235,0.8); padding:16px; }
160
+ .rm-popup textarea { width:100%; font-size:13px; border:1px solid #e5e7eb; border-radius:12px; padding:12px; resize:none; outline:none; font-family:inherit; transition:border-color 0.15s,box-shadow 0.15s; }
161
+ .rm-popup textarea:focus { border-color:#818cf8; box-shadow:0 0 0 3px rgba(99,102,241,0.1); }
162
+ .rm-popup select { font-size:11px; font-weight:500; border:1px solid #e5e7eb; border-radius:8px; padding:6px 24px 6px 8px; background:#fff; appearance:none; cursor:pointer; }
163
+ .rm-popup select:focus { outline:none; border-color:#818cf8; box-shadow:0 0 0 3px rgba(99,102,241,0.1); }
164
+ .rm-popup-el { font-size:11px; color:#9ca3af; font-family:monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; line-height:1.4; }
165
+ .rm-popup-text { font-size:12px; color:#6b7280; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-top:2px; line-height:1.4; }
166
+ .rm-popup-actions { display:flex; align-items:center; gap:8px; margin-top:8px; }
167
+ .rm-popup-actions .rm-count { font-size:10px; color:#d1d5db; margin-left:auto; font-variant-numeric:tabular-nums; }
168
+ .rm-btn-cancel { padding:6px 12px; font-size:12px; color:#9ca3af; background:none; border:none; cursor:pointer; border-radius:8px; }
169
+ .rm-btn-cancel:hover { color:#6b7280; }
170
+ .rm-btn-submit { padding:6px 16px; font-size:12px; font-weight:500; color:#fff; border:none; border-radius:8px; cursor:pointer; display:inline-flex; align-items:center; gap:6px; }
171
+ .rm-btn-submit kbd { font-size:9px; opacity:0.6; font-family:sans-serif; }
172
+ .rm-panel { display:none; position:fixed; z-index:9981; width:380px; max-width:calc(100vw - 48px); max-height:60vh; background:rgba(255,255,255,0.95); backdrop-filter:blur(12px); border-radius:16px; box-shadow:0 25px 50px rgba(0,0,0,0.1); border:1px solid rgba(229,231,235,0.8); flex-direction:column; }
173
+ .rm-panel-header { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-bottom:1px solid #f3f4f6; }
174
+ .rm-panel-header h3 { font-size:14px; font-weight:600; color:#1f2937; }
175
+ .rm-panel-count { min-width:20px; text-align:center; padding:2px 6px; font-size:10px; font-weight:600; border-radius:10px; }
176
+ .rm-panel-close { padding:6px; color:#d1d5db; background:none; border:none; cursor:pointer; border-radius:8px; }
177
+ .rm-panel-close:hover { color:#6b7280; }
178
+ .rm-panel-close svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
179
+ .rm-filter-chips { display:flex; gap:6px; padding:8px 16px; border-bottom:1px solid #f9fafb; }
180
+ .rm-chip { padding:4px 8px; font-size:10px; font-weight:500; border-radius:10px; cursor:pointer; transition:all 0.15s; border:none; }
181
+ .rm-chip-active { color:#fff; }
182
+ .rm-chip-inactive { background:#f9fafb; color:#9ca3af; }
183
+ .rm-chip-inactive:hover { color:#6b7280; }
184
+ .rm-panel-list { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:8px; }
185
+ .rm-panel-footer { display:flex; align-items:center; gap:8px; padding:10px 16px; border-top:1px solid #f3f4f6; font-size:11px; color:#9ca3af; }
186
+ .rm-status-dot { width:8px; height:8px; border-radius:50%; background:#d1d5db; }
187
+ .rm-card { padding:12px; background:#fff; border-radius:8px; border:1px solid #f3f4f6; border-left:3px solid; cursor:pointer; transition:all 0.15s; }
188
+ .rm-card:hover { box-shadow:0 2px 8px rgba(0,0,0,0.05); }
189
+ .rm-card-top { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
190
+ .rm-card-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
191
+ .rm-card-id { font-size:10px; font-weight:600; color:#9ca3af; }
192
+ .rm-card-badge { padding:2px 6px; font-size:10px; font-weight:500; border-radius:10px; }
193
+ .rm-card-body { margin-top:6px; font-size:13px; line-height:1.5; color:#1f2937; }
194
+ .rm-card-path { margin-top:4px; font-size:10px; color:#d1d5db; font-family:monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
195
+ .rm-card-thread { margin-top:8px; padding:8px; background:rgba(249,250,251,0.8); border-radius:8px; font-size:12px; color:#6b7280; border-left:2px solid; }
196
+ .rm-card-thread-role { font-size:10px; font-weight:500; color:#9ca3af; text-transform:uppercase; letter-spacing:0.05em; }
197
+ .rm-empty { text-align:center; padding:32px 16px; color:#9ca3af; }
198
+ .rm-empty-icon { font-size:32px; margin-bottom:8px; }
199
+ .rm-empty-text { font-size:13px; }
200
+ .rm-pin { position:absolute; display:flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%; color:#fff; font-size:10px; font-weight:700; cursor:pointer; transition:transform 0.2s; z-index:9979; box-shadow:0 2px 8px rgba(0,0,0,0.2); }
201
+ .rm-pin:hover { transform:scale(1.25); }
202
+ .rm-pin-active { animation:rm-pulse 2s ease-in-out infinite; }
203
+ .rm-toast { padding:8px 12px; border-radius:8px; border:1px solid; font-size:12px; font-weight:500; box-shadow:0 2px 8px rgba(0,0,0,0.05); animation:rm-toast-in 0.3s ease; }
204
+ `;
205
+ document.head.appendChild(style);
206
+ },
207
+
208
+ _injectDOM() {
209
+ const root = document.createElement("div");
210
+ root.id = "rm-toolbar-root";
211
+
212
+ const accentBg = this._accentBg();
213
+ const accentBgHover = this._accentBgHover();
214
+ const accentLight = this._accentLight();
215
+ const accentText = this._accentText();
216
+
217
+ // Position: bl (bottom-left), br (bottom-right), tl (top-left), tr (top-right)
218
+ const posMap = { bl: "bottom:24px;left:24px;", br: "bottom:24px;right:24px;", tl: "top:24px;left:24px;", tr: "top:24px;right:24px;" };
219
+ const fabPos = posMap[this.position] || posMap.bl;
220
+
221
+ // Size: default (48px), compact (40px), slim (32px)
222
+ const sizeMap = { "default": { dim: 48, icon: 20 }, compact: { dim: 40, icon: 18 }, slim: { dim: 32, icon: 16 } };
223
+ const fabSize = sizeMap[this.size] || sizeMap["default"];
224
+
225
+ // Panel offset matches FAB position
226
+ const isRight = this.position === "br" || this.position === "tr";
227
+ const isTop = this.position === "tl" || this.position === "tr";
228
+ const panelToggleStyle = isRight ? `${isTop ? 'top' : 'bottom'}:24px;right:${fabSize.dim + 8 + 24}px;` : `${isTop ? 'top' : 'bottom'}:24px;left:${fabSize.dim + 8 + 24}px;`;
229
+ const panelStyle = isRight
230
+ ? `${isTop ? 'top' : 'bottom'}:${fabSize.dim + 32}px;right:24px;`
231
+ : `${isTop ? 'top' : 'bottom'}:${fabSize.dim + 32}px;left:24px;`;
232
+ const toastStyle = isRight
233
+ ? `${isTop ? 'top' : 'bottom'}:${fabSize.dim + 32}px;right:24px;`
234
+ : `${isTop ? 'top' : 'bottom'}:${fabSize.dim + 32}px;left:24px;`;
235
+
236
+ root.innerHTML = `
237
+ <button class="rm-fab" id="rm-fab" style="${fabPos}width:${fabSize.dim}px;height:${fabSize.dim}px;background:${accentBg};color:#fff;${this.fabVisible ? "" : "display:none;"}" title="Toggle annotation mode" aria-label="Toggle annotation mode" aria-expanded="false" aria-controls="rm-panel">
238
+ <svg viewBox="0 0 24 24" style="width:${fabSize.icon}px;height:${fabSize.icon}px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
239
+ <span class="rm-fab-badge" id="rm-fab-badge"></span>
240
+ </button>
241
+ <button class="rm-panel-toggle" id="rm-panel-toggle" style="${panelToggleStyle}" title="View annotations" aria-label="View annotations" aria-controls="rm-panel">
242
+ <svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h7"/></svg>
243
+ </button>
244
+ <div class="rm-toast-container" id="rm-toast-container" style="${toastStyle}"></div>
245
+ <div class="rm-popup" id="rm-popup" role="dialog" aria-label="Add annotation" aria-modal="false">
246
+ <div style="margin-bottom:12px">
247
+ <p class="rm-popup-el" id="rm-popup-el"></p>
248
+ <p class="rm-popup-text" id="rm-popup-text"></p>
249
+ </div>
250
+ <textarea id="rm-popup-input" rows="3" placeholder="What should change?"></textarea>
251
+ <div style="display:flex;align-items:center;gap:8px;margin-top:12px">
252
+ <select id="rm-intent-select">
253
+ <option value="fix">Fix</option>
254
+ <option value="change" selected>Change</option>
255
+ <option value="question">Question</option>
256
+ <option value="approve">Approve</option>
257
+ </select>
258
+ <select id="rm-severity-select">
259
+ <option value="suggestion" selected>Suggestion</option>
260
+ <option value="important">Important</option>
261
+ <option value="blocking">Blocking</option>
262
+ </select>
263
+ <span class="rm-count" id="rm-char-count"></span>
264
+ </div>
265
+ <div class="rm-popup-actions">
266
+ <button class="rm-btn-cancel" id="rm-btn-cancel">Cancel</button>
267
+ <button class="rm-btn-submit" id="rm-btn-submit" style="background:${accentBg}">
268
+ <span id="rm-submit-label">Add</span>
269
+ <kbd>⌘↩</kbd>
270
+ </button>
271
+ </div>
272
+ </div>
273
+ <div class="rm-panel" id="rm-panel" style="${panelStyle}" role="dialog" aria-label="Annotations panel">
274
+ <div class="rm-panel-header">
275
+ <div style="display:flex;align-items:center;gap:8px">
276
+ <h3>Feedback</h3>
277
+ <span class="rm-panel-count" id="rm-panel-count" style="background:${accentLight};color:${accentText}">0</span>
278
+ </div>
279
+ <button class="rm-panel-close" id="rm-panel-close" aria-label="Close annotations panel">
280
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 18L18 6M6 6l12 12"/></svg>
281
+ </button>
282
+ </div>
283
+ <div class="rm-filter-chips" id="rm-filter-chips">
284
+ <button class="rm-chip rm-chip-active" data-filter="all" style="background:${accentBg}">All</button>
285
+ <button class="rm-chip rm-chip-inactive" data-filter="pending">Pending</button>
286
+ <button class="rm-chip rm-chip-inactive" data-filter="resolved">Resolved</button>
287
+ </div>
288
+ <div class="rm-panel-list" id="rm-panel-list"></div>
289
+ <div class="rm-panel-footer">
290
+ <span class="rm-status-dot" id="rm-status-dot"></span>
291
+ <span id="rm-status-text">Offline</span>
292
+ </div>
293
+ </div>
294
+ `;
295
+
296
+ document.body.appendChild(root);
297
+ this.root = root;
298
+ // Pins container lives on body (not inside fixed root); scroll listener keeps them stuck to target elements
299
+ const pinsContainer = document.createElement("div");
300
+ pinsContainer.className = "rm-pins-container";
301
+ pinsContainer.id = "rm-pins-container";
302
+ document.body.appendChild(pinsContainer);
303
+ if (!this._onResize) {
304
+ this._onResize = this._debouncedRepositionPins(250);
305
+ this._onScroll = this._debouncedRepositionPins(50);
306
+ window.addEventListener("resize", this._onResize);
307
+ window.addEventListener("scroll", this._onScroll, { passive: true });
308
+ }
309
+ },
310
+
311
+ _bindEvents() {
312
+ const self = this;
313
+ document.getElementById("rm-fab").addEventListener("click", () => self.toggleMode());
314
+ document.getElementById("rm-panel-toggle").addEventListener("click", () => self.togglePanel());
315
+ document.getElementById("rm-panel-close").addEventListener("click", () => self.togglePanel());
316
+ document.getElementById("rm-btn-cancel").addEventListener("click", () => self._closePopup());
317
+ document.getElementById("rm-btn-submit").addEventListener("click", (e) => self.submitAnnotation(e));
318
+ document.getElementById("rm-popup-input").addEventListener("input", () => self._updateCharCount());
319
+ document.getElementById("rm-filter-chips").addEventListener("click", (e) => {
320
+ const chip = e.target.closest("[data-filter]");
321
+ if (chip) self._filterAnnotations(chip.dataset.filter);
322
+ });
323
+
324
+ // Event delegation for cards (status change, edit, delete, or click scrolls to element)
325
+ const panelList = document.getElementById("rm-panel-list");
326
+ panelList.addEventListener("change", (e) => {
327
+ const select = e.target.closest("[data-status-id]");
328
+ if (!select) return;
329
+ e.stopPropagation();
330
+ const id = parseInt(select.dataset.statusId, 10);
331
+ self._changeStatus(id, select.value);
332
+ });
333
+ panelList.addEventListener("click", (e) => {
334
+ const retryBtn = e.target.closest("[data-retry-client-id]");
335
+ if (retryBtn) {
336
+ e.stopPropagation();
337
+ self._retrySync(retryBtn.dataset.retryClientId);
338
+ return;
339
+ }
340
+ // Status dropdown click — don't scroll
341
+ if (e.target.closest("[data-status-id]")) { e.stopPropagation(); return; }
342
+ // Edit button
343
+ const editBtn = e.target.closest("[data-edit-id]");
344
+ if (editBtn) {
345
+ e.stopPropagation();
346
+ const id = parseInt(editBtn.dataset.editId, 10);
347
+ self._editAnnotation(id);
348
+ return;
349
+ }
350
+ // Delete button
351
+ const deleteBtn = e.target.closest("[data-delete-id]");
352
+ if (deleteBtn) {
353
+ e.stopPropagation();
354
+ const id = parseInt(deleteBtn.dataset.deleteId, 10);
355
+ self._deleteAnnotation(id);
356
+ return;
357
+ }
358
+ // Card click scrolls to element
359
+ const card = e.target.closest("[data-card-id]");
360
+ if (!card) return;
361
+ const id = parseInt(card.dataset.cardId, 10);
362
+ const annotation = self.annotations.find(a => a.id === id);
363
+ if (!annotation) return;
364
+ const el = self._findElement(annotation);
365
+ if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
366
+ });
367
+
368
+ // Event delegation for pins (click opens panel + scrolls to card)
369
+ document.getElementById("rm-pins-container").addEventListener("click", (e) => {
370
+ const pin = e.target.closest("[data-pin-id]");
371
+ if (!pin) return;
372
+ const panel = document.getElementById("rm-panel");
373
+ if (panel.style.display !== "flex") self.togglePanel();
374
+ const card = document.querySelector('[data-card-id="' + pin.dataset.pinId + '"]');
375
+ if (card) card.scrollIntoView({ behavior: "smooth", block: "center" });
376
+ });
377
+
378
+ this._boundMouseMove = (e) => self._handleMouseMove(e);
379
+ this._boundMouseDown = (e) => self._handleMouseDown(e);
380
+ this._boundMouseUp = (e) => self._handleMouseUp(e);
381
+ this._boundClick = (e) => self._handleClick(e);
382
+ this._boundKeyDown = (e) => self._handleKeyDown(e);
383
+ this._boundTouchStart = (e) => { if (self.active && e.touches[0]) { const t = e.touches[0]; self._handleMouseDown({ clientX: t.clientX, clientY: t.clientY }); } };
384
+ this._boundTouchEnd = (e) => { if (self.active && e.changedTouches[0]) { const t = e.changedTouches[0]; const el = document.elementFromPoint(t.clientX, t.clientY); if (el && !self._isToolbar(el)) { e.preventDefault(); self._handleMouseUp({ clientX: t.clientX, clientY: t.clientY, preventDefault(){}, stopPropagation(){} }); } } };
385
+
386
+ // Turbo Frames — partial DOM update, reposition pins
387
+ if (!this._boundTurboFrame) {
388
+ this._boundTurboFrame = () => self._onTurboFrameRender();
389
+ document.addEventListener("turbo:frame-render", this._boundTurboFrame);
390
+ }
391
+ },
392
+
393
+ // ---- Turbo integration ----
394
+
395
+ _currentPathname: null,
396
+ _currentPageUrl: null,
397
+
398
+ _onTurboNavigate() {
399
+ const newPageUrl = this._pageUrl();
400
+ if (newPageUrl === this._currentPageUrl) return; // same page (anchor change, etc.)
401
+ this._currentPathname = window.location.pathname;
402
+ this._currentPageUrl = newPageUrl;
403
+
404
+ // Deactivate crosshair mode
405
+ this._deactivateMode();
406
+
407
+ // Close popup
408
+ const popup = document.getElementById("rm-popup");
409
+ if (popup && popup.style.display === "block") this._closePopup();
410
+
411
+ // Close panel
412
+ const panel = document.getElementById("rm-panel");
413
+ if (panel) panel.style.display = "none";
414
+
415
+ // Rerender pins for current page (annotations are global, pins are page-specific)
416
+ this._renderPins();
417
+ this._updateCount();
418
+ this._rebuildList();
419
+
420
+ // Re-init session for new URL
421
+ this._pullNeeded = true;
422
+ if (this.serverOnline) {
423
+ this._synchronizeCurrentPage(true).catch(error => console.warn("[rails-markup] page sync failed:", error));
424
+ }
425
+ },
426
+
427
+ _onTurboFrameRender() {
428
+ // Frame content changed — DOM elements may have moved, reposition pins
429
+ this._repositionPins();
430
+ },
431
+
432
+ // ---- Mode ----
433
+
434
+ toggleMode() {
435
+ this.active = !this.active;
436
+ if (this.active) {
437
+ this._activateMode();
438
+ // Always open panel when activating annotation mode
439
+ if (document.getElementById("rm-panel").style.display !== "flex") {
440
+ this.togglePanel();
441
+ }
442
+ } else {
443
+ this._deactivateMode();
444
+ }
445
+ },
446
+
447
+ _activateMode() {
448
+ document.body.style.cursor = "crosshair";
449
+ const fab = document.getElementById("rm-fab");
450
+ const iconSize = this._fabIconSize();
451
+ fab.style.transform = "scale(0.9)";
452
+ fab.style.boxShadow = `0 0 0 3px ${this._accentBg()}, 0 0 0 6px rgba(99,102,241,0.2)`;
453
+ fab.innerHTML = `<svg viewBox="0 0 24 24" style="width:${iconSize}px;height:${iconSize}px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"><path d="M6 18L18 6M6 6l12 12"/></svg><span class="rm-fab-badge" id="rm-fab-badge">${document.getElementById("rm-fab-badge")?.textContent || ""}</span>`;
454
+ document.addEventListener("mousemove", this._boundMouseMove, true);
455
+ document.addEventListener("mousedown", this._boundMouseDown, true);
456
+ document.addEventListener("mouseup", this._boundMouseUp, true);
457
+ document.addEventListener("click", this._boundClick, true);
458
+ document.addEventListener("keydown", this._boundKeyDown, true);
459
+ document.addEventListener("touchstart", this._boundTouchStart, true);
460
+ document.addEventListener("touchend", this._boundTouchEnd, true);
461
+ },
462
+
463
+ _deactivateMode() {
464
+ this.active = false;
465
+ document.body.style.cursor = "";
466
+ const fab = document.getElementById("rm-fab");
467
+ if (fab) {
468
+ const iconSize = this._fabIconSize();
469
+ fab.style.transform = "";
470
+ fab.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)";
471
+ fab.innerHTML = `<svg viewBox="0 0 24 24" style="width:${iconSize}px;height:${iconSize}px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg><span class="rm-fab-badge" id="rm-fab-badge">${this.annotations.length || ""}</span>`;
472
+ this._updateCount();
473
+ }
474
+ document.removeEventListener("mousemove", this._boundMouseMove, true);
475
+ document.removeEventListener("mousedown", this._boundMouseDown, true);
476
+ document.removeEventListener("mouseup", this._boundMouseUp, true);
477
+ document.removeEventListener("click", this._boundClick, true);
478
+ document.removeEventListener("keydown", this._boundKeyDown, true);
479
+ document.removeEventListener("touchstart", this._boundTouchStart, true);
480
+ document.removeEventListener("touchend", this._boundTouchEnd, true);
481
+ this._removeHighlight();
482
+ },
483
+
484
+ // ---- Panel ----
485
+
486
+ togglePanel() {
487
+ const panel = document.getElementById("rm-panel");
488
+ const fab = document.getElementById("rm-fab");
489
+ if (panel.style.display === "flex") {
490
+ panel.style.display = "none";
491
+ if (fab) fab.setAttribute("aria-expanded", "false");
492
+ } else {
493
+ panel.style.display = "flex";
494
+ if (fab) fab.setAttribute("aria-expanded", "true");
495
+ }
496
+ },
497
+
498
+ // ---- Mouse handlers ----
499
+
500
+ _handleMouseMove(event) {
501
+ if (!this.active) return;
502
+ const el = document.elementFromPoint(event.clientX, event.clientY);
503
+ if (!el || this._isToolbar(el)) { this._removeHighlight(); return; }
504
+ if (el === this.hoveredElement) return;
505
+ this._removeHighlight();
506
+ this.hoveredElement = el;
507
+ el.dataset.rmOrigOutline = el.style.outline || "";
508
+ el.style.outline = `2px solid ${this._accentBg()}`;
509
+ el.style.outlineOffset = "2px";
510
+ },
511
+
512
+ _handleMouseDown(event) {
513
+ const el = document.elementFromPoint(event.clientX, event.clientY);
514
+ if (el && !this._isToolbar(el)) this.clickedElement = el;
515
+ },
516
+
517
+ async _handleMouseUp(event) {
518
+ if (!this.active) return;
519
+ const el = this.clickedElement || document.elementFromPoint(event.clientX, event.clientY);
520
+ if (!el || this._isToolbar(el)) return;
521
+ event.preventDefault();
522
+ event.stopPropagation();
523
+ const sel = window.getSelection();
524
+ this.selectedText = (sel && sel.toString().trim().length > 0) ? sel.toString().trim() : null;
525
+ this._currentElement = this._identify(el);
526
+ this._currentScreenshot = null;
527
+ if (this.enableScreenshots) {
528
+ this._currentScreenshot = await this._captureElement(el);
529
+ }
530
+ this._showPopup(event.clientX, event.clientY);
531
+ this.clickedElement = null;
532
+ },
533
+
534
+ _handleClick(event) {
535
+ if (!this.active) return;
536
+ const el = event.target;
537
+ if (this._isToolbar(el)) return;
538
+ // Block link navigation and Turbo visits while annotating
539
+ event.preventDefault();
540
+ event.stopPropagation();
541
+ },
542
+
543
+ _handleKeyDown(event) {
544
+ if (event.key === "Escape") {
545
+ const popup = document.getElementById("rm-popup");
546
+ if (popup && popup.style.display === "block") {
547
+ this._closePopup();
548
+ } else {
549
+ this._deactivateMode();
550
+ }
551
+ }
552
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
553
+ const popup = document.getElementById("rm-popup");
554
+ if (popup && popup.style.display === "block") {
555
+ this.submitAnnotation();
556
+ }
557
+ }
558
+ },
559
+
560
+ // ---- Element identification ----
561
+
562
+ _identify(el) {
563
+ const tag = el.tagName.toLowerCase();
564
+ const id = el.id ? "#" + el.id : "";
565
+ const cls = Array.from(el.classList).filter(c => !c.startsWith("rm-")).slice(0, 5).map(c => "." + c).join("");
566
+ const text = (el.textContent || "").trim().slice(0, 80);
567
+ const rect = el.getBoundingClientRect();
568
+ return {
569
+ selector: tag + id + cls,
570
+ cssPath: this._cssPath(el),
571
+ nearbyText: text,
572
+ boundingBox: { top: Math.round(rect.top + window.scrollY), left: Math.round(rect.left + window.scrollX), width: Math.round(rect.width), height: Math.round(rect.height) }
573
+ };
574
+ },
575
+
576
+ _cssPath(el) {
577
+ const parts = [];
578
+ let cur = el;
579
+ while (cur && cur !== document.body && parts.length < 5) {
580
+ let sel = cur.tagName.toLowerCase();
581
+ if (cur.id) { sel += "#" + cur.id; parts.unshift(sel); break; }
582
+ const parent = cur.parentElement;
583
+ if (parent) {
584
+ const sibs = Array.from(parent.children).filter(c => c.tagName === cur.tagName);
585
+ if (sibs.length > 1) sel += ":nth-of-type(" + (sibs.indexOf(cur) + 1) + ")";
586
+ }
587
+ parts.unshift(sel);
588
+ cur = cur.parentElement;
589
+ }
590
+ return parts.join(" > ");
591
+ },
592
+
593
+ // ---- Popup ----
594
+
595
+ _showPopup(x, y) {
596
+ const popup = document.getElementById("rm-popup");
597
+ // Clean up previous drawing elements
598
+ const oldContainer = popup.querySelector(".rm-drawing-container");
599
+ if (oldContainer) oldContainer.remove();
600
+ const oldTools = popup.querySelector("[data-draw]");
601
+ if (oldTools) { const p = oldTools.parentElement; if (p && !p.classList.contains("rm-popup-actions")) p.remove(); }
602
+ this.drawingCanvas = null;
603
+ this.drawingCtx = null;
604
+ this.drawingHistory = [];
605
+ this.drawingMode = null;
606
+ this._screenshotImg = null;
607
+
608
+ popup.style.display = "block";
609
+ popup.style.opacity = "0";
610
+ popup.style.left = "-9999px";
611
+ popup.style.top = "-9999px";
612
+ popup.style.width = this._currentScreenshot ? "480px" : "360px";
613
+ const pw = popup.offsetWidth || (this._currentScreenshot ? 480 : 360);
614
+ const ph = popup.offsetHeight || 300;
615
+ let left = Math.min(x + 10, window.innerWidth - pw - 20);
616
+ let top = Math.min(y + 10, window.innerHeight - ph - 20);
617
+ left = Math.max(10, left);
618
+ top = Math.max(10, top);
619
+ popup.style.left = left + "px";
620
+ popup.style.top = top + "px";
621
+ requestAnimationFrame(() => {
622
+ popup.style.transition = "opacity 0.2s ease";
623
+ popup.style.opacity = "1";
624
+ });
625
+ document.getElementById("rm-popup-el").textContent = this._currentElement.selector;
626
+ document.getElementById("rm-popup-text").textContent = this.selectedText
627
+ ? '"' + this.selectedText.slice(0, 60) + '"'
628
+ : this._currentElement.nearbyText.slice(0, 60);
629
+ const input = document.getElementById("rm-popup-input");
630
+ input.value = "";
631
+ document.getElementById("rm-intent-select").value = "change";
632
+ document.getElementById("rm-severity-select").value = "suggestion";
633
+ document.getElementById("rm-char-count").textContent = "";
634
+ document.getElementById("rm-submit-label").textContent = "Add";
635
+
636
+ // Show screenshot preview with drawing tools
637
+ if (this._currentScreenshot) {
638
+ this._initDrawing(this._currentScreenshot);
639
+ }
640
+
641
+ setTimeout(() => input.focus(), 50);
642
+ },
643
+
644
+ _closePopup() {
645
+ const popup = document.getElementById("rm-popup");
646
+ popup.style.transition = "opacity 0.15s ease";
647
+ popup.style.opacity = "0";
648
+ setTimeout(() => { popup.style.display = "none"; popup.style.transition = ""; popup.style.opacity = ""; popup.style.width = ""; }, 150);
649
+ document.getElementById("rm-popup-input").value = "";
650
+ this.selectedText = null;
651
+ this._currentElement = null;
652
+ this._currentScreenshot = null;
653
+ this.drawingCanvas = null;
654
+ this.drawingCtx = null;
655
+ this.drawingHistory = [];
656
+ this.drawingMode = null;
657
+ this._screenshotImg = null;
658
+ this.editingId = null;
659
+ document.getElementById("rm-submit-label").textContent = "Add";
660
+ },
661
+
662
+ _updateCharCount() {
663
+ const len = document.getElementById("rm-popup-input").value.length;
664
+ const el = document.getElementById("rm-char-count");
665
+ el.textContent = len > 0 ? len : "";
666
+ el.style.color = len > 500 ? "#f87171" : "#d1d5db";
667
+ },
668
+
669
+ // ---- Submit ----
670
+
671
+ submitAnnotation(event) {
672
+ if (event) event.preventDefault();
673
+ const comment = document.getElementById("rm-popup-input").value.trim();
674
+ if (!comment) return;
675
+ const intent = document.getElementById("rm-intent-select").value;
676
+ const severity = document.getElementById("rm-severity-select").value;
677
+
678
+ // If drawing canvas exists, merge drawings onto screenshot
679
+ const screenshot = this._mergeDrawing() || this._currentScreenshot;
680
+
681
+ // Edit existing annotation
682
+ if (this.editingId) {
683
+ const existing = this.annotations.find(a => a.id === this.editingId);
684
+ if (existing) {
685
+ const dirtyFields = [];
686
+ if (existing.comment !== comment) dirtyFields.push("content");
687
+ if (existing.intent !== intent) dirtyFields.push("intent");
688
+ if (existing.severity !== severity) dirtyFields.push("severity");
689
+ if (screenshot && existing.screenshot !== screenshot) dirtyFields.push("metadata");
690
+ const committed = dirtyFields.length === 0 || this._persistLocalMutation("upsert", dirtyFields, () => {
691
+ existing.comment = comment;
692
+ existing.intent = intent;
693
+ existing.severity = severity;
694
+ if (screenshot) existing.screenshot = screenshot;
695
+ return existing;
696
+ });
697
+ if (!committed) return;
698
+ this._rebuildList();
699
+ this._closePopup();
700
+ return;
701
+ }
702
+ }
703
+
704
+ // New annotation
705
+ const annotation = {
706
+ id: this.nextId,
707
+ clientId: this._newClientId(),
708
+ serverId: null,
709
+ syncState: "pending",
710
+ serverUpdatedAt: null,
711
+ dirtyFields: [],
712
+ revision: 0,
713
+ comment, intent, severity,
714
+ element: this._currentElement,
715
+ selectedText: this.selectedText || null,
716
+ screenshot: screenshot || null,
717
+ url: window.location.href,
718
+ pathname: this._pageUrl(),
719
+ pageUrl: this._pageUrl(),
720
+ timestamp: new Date().toISOString(),
721
+ status: "pending",
722
+ thread: []
723
+ };
724
+
725
+ const committed = this._persistLocalMutation("upsert", this._browserCreateFields(), () => {
726
+ this.nextId += 1;
727
+ this.annotations.push(annotation);
728
+ return annotation;
729
+ });
730
+ if (!committed) return;
731
+ this._renderPin(annotation);
732
+ // Rebuild (not append) so a new card honors the active panel filter —
733
+ // e.g. a pending annotation must not show while "Resolved" is selected.
734
+ this._rebuildList();
735
+ this._updateCount();
736
+ this._closePopup();
737
+ },
738
+
739
+ // ---- Filters ----
740
+
741
+ _filterAnnotations(filter) {
742
+ this.activeFilter = filter;
743
+ this._updateFilterChips();
744
+ this._rebuildList();
745
+ },
746
+
747
+ _updateFilterChips() {
748
+ const chips = document.querySelectorAll("#rm-filter-chips [data-filter]");
749
+ chips.forEach(chip => {
750
+ if (chip.dataset.filter === this.activeFilter) {
751
+ chip.className = "rm-chip rm-chip-active";
752
+ chip.style.background = this._accentBg();
753
+ chip.style.color = "#fff";
754
+ } else {
755
+ chip.className = "rm-chip rm-chip-inactive";
756
+ chip.style.background = "#f9fafb";
757
+ chip.style.color = "#9ca3af";
758
+ }
759
+ });
760
+ },
761
+
762
+ _filteredAnnotations() {
763
+ if (this.activeFilter === "all") return this.annotations;
764
+ if (this.activeFilter === "pending") return this.annotations.filter(a => a.status === "pending" || a.status === "acknowledged");
765
+ if (this.activeFilter === "resolved") return this.annotations.filter(a => a.status === "resolved" || a.status === "dismissed");
766
+ return this.annotations;
767
+ },
768
+
769
+ // ---- Panel cards ----
770
+
771
+ _renderCard(annotation) {
772
+ const list = document.getElementById("rm-panel-list");
773
+ const card = document.createElement("div");
774
+ card.className = "rm-card";
775
+ card.dataset.cardId = annotation.id;
776
+
777
+ const borderColor = annotation.status === "resolved" ? "#10b981" : annotation.status === "dismissed" ? "#d1d5db" : this._accentBg();
778
+ card.style.borderLeftColor = borderColor;
779
+
780
+ const dotColor = { pending: "#3b82f6", acknowledged: "#f59e0b", resolved: "#10b981", dismissed: "#d1d5db" }[annotation.status] || "#3b82f6";
781
+ const intentColors = { fix: { bg: "#fef2f2", text: "#dc2626" }, change: { bg: "#eff6ff", text: "#2563eb" }, question: { bg: "#f5f3ff", text: "#7c3aed" }, approve: { bg: "#ecfdf5", text: "#059669" } };
782
+ const ic = intentColors[annotation.intent] || intentColors.change;
783
+
784
+ let threadHtml = "";
785
+ const thread = annotation.thread || [];
786
+ if (thread.length > 0) {
787
+ const last = thread[thread.length - 1];
788
+ threadHtml = `<div class="rm-card-thread" style="border-left-color:${this._accentBg()}"><span class="rm-card-thread-role">${this._esc(last.role || "agent")}</span><div style="margin-top:2px">${this._esc(last.message)}</div></div>`;
789
+ }
790
+
791
+ card.innerHTML = `
792
+ <div class="rm-card-top">
793
+ <span class="rm-card-dot" style="background:${dotColor}"></span>
794
+ <span class="rm-card-id">#${annotation.id}</span>
795
+ <span class="rm-card-badge" style="background:${ic.bg};color:${ic.text}">${annotation.intent}</span>
796
+ ${annotation.severity !== "suggestion" ? '<span class="rm-card-badge" style="background:#fff7ed;color:#9a3412">' + annotation.severity + '</span>' : ''}
797
+ <span style="margin-left:auto;display:flex;gap:2px;align-items:center;">
798
+ <select data-status-id="${annotation.id}" title="Change status" style="font-size:10px;padding:2px 4px;border:1px solid #e5e7eb;border-radius:4px;background:#fff;color:#6b7280;cursor:pointer;appearance:auto;">
799
+ <option value="pending"${annotation.status === "pending" ? " selected" : ""}>Pending</option>
800
+ <option value="acknowledged"${annotation.status === "acknowledged" ? " selected" : ""}>Acknowledged</option>
801
+ <option value="resolved"${annotation.status === "resolved" ? " selected" : ""}>Resolved</option>
802
+ <option value="dismissed"${annotation.status === "dismissed" ? " selected" : ""}>Dismissed</option>
803
+ </select>
804
+ <button data-edit-id="${annotation.id}" title="Edit" style="padding:2px 4px;background:none;border:none;cursor:pointer;color:#d1d5db;border-radius:4px;display:flex;align-items:center;" onmouseover="this.style.color='#6b7280'" onmouseout="this.style.color='#d1d5db'">
805
+ <svg viewBox="0 0 24 24" style="width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><path d="M17 3a2.85 2.85 0 114 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
806
+ </button>
807
+ <button data-delete-id="${annotation.id}" title="Delete" style="padding:2px 4px;background:none;border:none;cursor:pointer;color:#d1d5db;border-radius:4px;display:flex;align-items:center;" onmouseover="this.style.color='#ef4444'" onmouseout="this.style.color='#d1d5db'">
808
+ <svg viewBox="0 0 24 24" style="width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
809
+ </button>
810
+ </span>
811
+ </div>
812
+ <div class="rm-card-body">${this._esc(annotation.comment)}</div>
813
+ <div class="rm-card-path">${this._esc(annotation.pathname || "")} &rsaquo; ${this._esc(annotation.element?.selector || "")}</div>
814
+ ${annotation.selectedText ? '<div class="rm-card-path" style="font-style:italic">"' + this._esc(annotation.selectedText.slice(0, 60)) + '"</div>' : ''}
815
+ ${threadHtml}
816
+ `;
817
+
818
+ list.appendChild(card);
819
+ },
820
+
821
+ _rebuildList() {
822
+ const list = document.getElementById("rm-panel-list");
823
+ if (!list) return;
824
+ list.innerHTML = "";
825
+ this._renderStorageError(list);
826
+ this._renderFailedSync(list);
827
+ const filtered = this._filteredAnnotations();
828
+ if (filtered.length === 0) {
829
+ const empty = document.createElement("div");
830
+ empty.className = "rm-empty";
831
+ empty.innerHTML = '<div class="rm-empty-icon">&#9670;</div><div class="rm-empty-text">No annotations yet</div>';
832
+ list.appendChild(empty);
833
+ return;
834
+ }
835
+ filtered.forEach(a => this._renderCard(a));
836
+ },
837
+
838
+ _renderStorageError(list) {
839
+ if (!this._storageError) return;
840
+ const error = document.createElement("div");
841
+ error.className = "rm-storage-error";
842
+ error.setAttribute("role", "alert");
843
+ error.style.cssText = "padding:8px;margin-bottom:8px;border:1px solid #fecaca;border-radius:8px;background:#fef2f2;color:#991b1b;font-size:11px;";
844
+ error.textContent = this._storageError;
845
+ list.appendChild(error);
846
+ },
847
+
848
+ _renderFailedSync(list) {
849
+ const failed = Object.values(this.outbox || {}).filter(entry => entry?.syncState === "failed");
850
+ if (failed.length === 0) return;
851
+ const section = document.createElement("div");
852
+ section.className = "rm-failed-sync";
853
+ section.setAttribute("role", "status");
854
+ failed.forEach(entry => {
855
+ const item = document.createElement("div");
856
+ item.style.cssText = "display:flex;align-items:center;gap:8px;padding:8px;margin-bottom:8px;border:1px solid #fecaca;border-radius:8px;background:#fef2f2;color:#991b1b;font-size:11px;";
857
+ const label = entry.type === "delete" ? "Delete failed" : "Sync failed";
858
+ item.innerHTML = `<span style="flex:1">${label}</span><button type="button" data-retry-client-id="${this._esc(entry.clientId)}" style="border:1px solid #fca5a5;border-radius:6px;background:#fff;padding:3px 7px;color:#991b1b;cursor:pointer">Retry</button>`;
859
+ section.appendChild(item);
860
+ });
861
+ list.appendChild(section);
862
+ },
863
+
864
+ _editAnnotation(id) {
865
+ const annotation = this.annotations.find(a => a.id === id);
866
+ if (!annotation) return;
867
+ this.editingId = id;
868
+ this._currentElement = annotation.element;
869
+ this.selectedText = annotation.selectedText;
870
+ this._currentScreenshot = annotation.screenshot || null;
871
+
872
+ // Pre-fill popup fields
873
+ document.getElementById("rm-popup-el").textContent = annotation.element?.selector || "";
874
+ document.getElementById("rm-popup-text").textContent = annotation.selectedText
875
+ ? '"' + annotation.selectedText.slice(0, 60) + '"'
876
+ : (annotation.element?.nearbyText || "").slice(0, 60);
877
+ document.getElementById("rm-popup-input").value = annotation.comment;
878
+ document.getElementById("rm-intent-select").value = annotation.intent;
879
+ document.getElementById("rm-severity-select").value = annotation.severity;
880
+ document.getElementById("rm-submit-label").textContent = "Save";
881
+ this._updateCharCount();
882
+
883
+ // Clean up previous drawing elements
884
+ const popup = document.getElementById("rm-popup");
885
+ const oldContainer = popup.querySelector(".rm-drawing-container");
886
+ if (oldContainer) oldContainer.remove();
887
+ const oldTools = popup.querySelector("[data-draw]");
888
+ if (oldTools) { const p = oldTools.parentElement; if (p && !p.classList.contains("rm-popup-actions")) p.remove(); }
889
+ this.drawingCanvas = null;
890
+ this.drawingCtx = null;
891
+ this.drawingHistory = [];
892
+ this.drawingMode = null;
893
+ this._screenshotImg = null;
894
+
895
+ // Position popup centered on screen
896
+ popup.style.display = "block";
897
+ popup.style.width = this._currentScreenshot ? "480px" : "360px";
898
+ const pw = popup.offsetWidth || 360;
899
+ const ph = popup.offsetHeight || 300;
900
+ popup.style.left = Math.max(10, Math.round((window.innerWidth - pw) / 2)) + "px";
901
+ popup.style.top = Math.max(10, Math.round((window.innerHeight - ph) / 2)) + "px";
902
+ popup.style.opacity = "1";
903
+
904
+ if (this._currentScreenshot) {
905
+ this._initDrawing(this._currentScreenshot);
906
+ }
907
+
908
+ setTimeout(() => document.getElementById("rm-popup-input").focus(), 50);
909
+ },
910
+
911
+ _changeStatus(id, newStatus) {
912
+ const annotation = this.annotations.find(a => a.id === id);
913
+ if (!annotation) return;
914
+ const committed = this._persistLocalMutation("upsert", ["status"], () => {
915
+ annotation.status = newStatus;
916
+ return annotation;
917
+ });
918
+ if (!committed) return;
919
+ this._renderPins();
920
+ this._rebuildList();
921
+ const label = newStatus.charAt(0).toUpperCase() + newStatus.slice(1);
922
+ this._showToast(`#${id} marked as ${label}`, newStatus === "resolved" ? "resolved" : "dismissed");
923
+ },
924
+
925
+ _deleteAnnotation(id) {
926
+ const idx = this.annotations.findIndex(a => a.id === id);
927
+ if (idx === -1) return;
928
+ const committed = this._persistLocalMutation("delete", [], () => this.annotations.splice(idx, 1)[0]);
929
+ if (!committed) return;
930
+ this._renderPins();
931
+ this._rebuildList();
932
+ this._updateCount();
933
+ },
934
+
935
+ _updateCount() {
936
+ const count = this.annotations.length;
937
+ const countEl = document.getElementById("rm-panel-count");
938
+ if (countEl) countEl.textContent = count;
939
+ const badge = document.getElementById("rm-fab-badge");
940
+ if (badge) {
941
+ if (count > 0) { badge.textContent = count; badge.style.display = "flex"; }
942
+ else { badge.style.display = "none"; }
943
+ }
944
+ const toggle = document.getElementById("rm-panel-toggle");
945
+ if (toggle) toggle.style.display = "flex";
946
+ },
947
+
948
+ // ---- Pins ----
949
+
950
+ _renderPin(annotation) {
951
+ if (!annotation.element?.boundingBox) return;
952
+ const { top, left, width } = annotation.element.boundingBox;
953
+ const container = document.getElementById("rm-pins-container");
954
+ if (!container) return;
955
+ const isResolved = annotation.status === "resolved" || annotation.status === "dismissed";
956
+ const pin = document.createElement("div");
957
+ pin.className = "rm-pin" + (isResolved ? "" : " rm-pin-active");
958
+ pin.dataset.pinId = annotation.id;
959
+ pin.style.top = (top - 10) + "px";
960
+ pin.style.left = (left + width - 10) + "px";
961
+ pin.style.background = isResolved ? "#d1d5db" : this._accentBg();
962
+ if (isResolved) pin.style.opacity = "0.6";
963
+ pin.textContent = annotation.id;
964
+ pin.title = "#" + annotation.id + ": " + annotation.comment.slice(0, 50);
965
+ container.appendChild(pin);
966
+ },
967
+
968
+ _renderPins() {
969
+ const container = document.getElementById("rm-pins-container");
970
+ if (container) container.innerHTML = "";
971
+ // Only render pins for annotations on the current page
972
+ const currentPath = this._pageUrl();
973
+ this.annotations
974
+ .filter(a => (a.pageUrl || a.pathname) === currentPath)
975
+ .forEach(a => this._renderPin(a));
976
+ },
977
+
978
+ _findElement(annotation) {
979
+ if (!annotation.element) return null;
980
+ const { cssPath, selector } = annotation.element;
981
+ if (cssPath) { try { const el = document.querySelector(cssPath); if (el) return el; } catch {} }
982
+ if (selector) { try { const el = document.querySelector(selector); if (el) return el; } catch {} }
983
+ return null;
984
+ },
985
+
986
+ _repositionPins() {
987
+ this.annotations.forEach(annotation => {
988
+ const el = this._findElement(annotation);
989
+ if (!el) return;
990
+ const rect = el.getBoundingClientRect();
991
+ annotation.element.boundingBox = { top: Math.round(rect.top + window.scrollY), left: Math.round(rect.left + window.scrollX), width: Math.round(rect.width), height: Math.round(rect.height) };
992
+ const pin = document.querySelector('[data-pin-id="' + annotation.id + '"]');
993
+ if (pin) { pin.style.top = (annotation.element.boundingBox.top - 10) + "px"; pin.style.left = (annotation.element.boundingBox.left + annotation.element.boundingBox.width - 10) + "px"; }
994
+ });
995
+ },
996
+
997
+ _debouncedRepositionPins(delay = 250) {
998
+ let timer = null;
999
+ return () => { if (timer) clearTimeout(timer); timer = setTimeout(() => this._repositionPins(), delay); };
1000
+ },
1001
+
1002
+ // ---- Highlight ----
1003
+
1004
+ _removeHighlight() {
1005
+ if (this.hoveredElement) {
1006
+ this.hoveredElement.style.outline = this.hoveredElement.dataset.rmOrigOutline || "";
1007
+ this.hoveredElement.style.outlineOffset = "";
1008
+ delete this.hoveredElement.dataset.rmOrigOutline;
1009
+ this.hoveredElement = null;
1010
+ }
1011
+ },
1012
+
1013
+ _isToolbar(el) {
1014
+ const root = document.getElementById("rm-toolbar-root");
1015
+ return root && root.contains(el);
1016
+ },
1017
+
1018
+ // ---- Toast ----
1019
+
1020
+ _showToast(message, type) {
1021
+ const container = document.getElementById("rm-toast-container");
1022
+ if (!container) return;
1023
+ const toast = document.createElement("div");
1024
+ toast.className = "rm-toast";
1025
+ const colors = { resolved: { bg: "#ecfdf5", border: "#a7f3d0", text: "#065f46" }, dismissed: { bg: "#f3f4f6", border: "#e5e7eb", text: "#6b7280" } };
1026
+ const c = colors[type] || { bg: this._accentLight(), border: this._accentBg(), text: this._accentText() };
1027
+ toast.style.background = c.bg;
1028
+ toast.style.borderColor = c.border;
1029
+ toast.style.color = c.text;
1030
+ toast.textContent = message;
1031
+ container.appendChild(toast);
1032
+ setTimeout(() => { toast.style.animation = "rm-toast-out 0.3s ease forwards"; setTimeout(() => toast.remove(), 300); }, 4000);
1033
+ },
1034
+
1035
+ // ---- Storage ----
1036
+
1037
+ _storageKey() { return "rm-annotations"; },
1038
+ _pageUrl() { return window.location.pathname + window.location.search; },
1039
+ _pageStorageKey() { return "rm-annotations:" + this._pageUrl(); },
1040
+
1041
+ _saveToStorage() {
1042
+ try {
1043
+ // Save all annotations to a single global key
1044
+ localStorage.setItem(this._storageKey(), JSON.stringify({
1045
+ annotations: this.annotations,
1046
+ nextId: this.nextId,
1047
+ outbox: this.outbox,
1048
+ legacyMigrations: this.legacyMigrations
1049
+ }));
1050
+ return true;
1051
+ }
1052
+ catch (e) {
1053
+ console.warn("[rails-markup] save failed:", e);
1054
+ return false;
1055
+ }
1056
+ },
1057
+
1058
+ _persistLocalMutation(type, dirtyFields, mutate) {
1059
+ return this._commitLocalStateChange(() => {
1060
+ const annotation = mutate();
1061
+ this._queueLocalMutation(type, annotation, dirtyFields);
1062
+ });
1063
+ },
1064
+
1065
+ _queueLocalMutation(type, annotation, dirtyFields) {
1066
+ const currentEntry = this.outbox[annotation.clientId];
1067
+ const revision = Math.max(annotation.revision || 0, currentEntry?.revision || 0) + 1;
1068
+
1069
+ if (type === "delete") {
1070
+ this.outbox[annotation.clientId] = {
1071
+ type: "delete",
1072
+ clientId: annotation.clientId,
1073
+ revision,
1074
+ syncState: "pending"
1075
+ };
1076
+ } else {
1077
+ const existingDirtyFields = currentEntry?.type === "upsert" ? currentEntry.dirtyFields : [];
1078
+ annotation.dirtyFields = this._mergeDirtyFields(annotation.dirtyFields, existingDirtyFields, dirtyFields);
1079
+ annotation.syncState = "pending";
1080
+ annotation.revision = revision;
1081
+ this.outbox[annotation.clientId] = {
1082
+ type: "upsert",
1083
+ clientId: annotation.clientId,
1084
+ revision,
1085
+ syncState: "pending",
1086
+ annotation: this._desiredState(annotation),
1087
+ dirtyFields: annotation.dirtyFields.slice()
1088
+ };
1089
+ }
1090
+ },
1091
+
1092
+ _commitLocalStateChange(mutate) {
1093
+ const snapshot = this._localStateSnapshot();
1094
+ mutate();
1095
+ if (this._saveToStorage()) {
1096
+ this._storageError = null;
1097
+ this._scheduleOutboxFlush();
1098
+ return true;
1099
+ }
1100
+
1101
+ this.annotations = snapshot.annotations;
1102
+ this.outbox = snapshot.outbox;
1103
+ this.nextId = snapshot.nextId;
1104
+ this.legacyMigrations = snapshot.legacyMigrations;
1105
+ this._storageError = "Changes could not be saved in this browser. Free storage space and try again.";
1106
+ this._renderPins();
1107
+ this._rebuildList();
1108
+ this._updateCount();
1109
+ const panel = document.getElementById("rm-panel");
1110
+ if (panel) panel.style.display = "flex";
1111
+ const fab = document.getElementById("rm-fab");
1112
+ if (fab) fab.setAttribute("aria-expanded", "true");
1113
+ return false;
1114
+ },
1115
+
1116
+ _localStateSnapshot() {
1117
+ return JSON.parse(JSON.stringify({
1118
+ annotations: this.annotations,
1119
+ outbox: this.outbox,
1120
+ nextId: this.nextId,
1121
+ legacyMigrations: this.legacyMigrations
1122
+ }));
1123
+ },
1124
+
1125
+ _mergeDirtyFields(...collections) {
1126
+ const merged = [];
1127
+ collections.flat().forEach(field => {
1128
+ const canonical = field === "selectedText" ? "selected_text" : field;
1129
+ if (canonical && !merged.includes(canonical)) merged.push(canonical);
1130
+ });
1131
+ return merged;
1132
+ },
1133
+
1134
+ _scheduleOutboxFlush() {
1135
+ if (this._outboxFlushScheduled) return;
1136
+ this._outboxFlushScheduled = true;
1137
+ this._outboxFlushTimer = setTimeout(() => {
1138
+ this._outboxFlushScheduled = false;
1139
+ this._outboxFlushTimer = null;
1140
+ Promise.resolve(this._flushOutbox()).catch(error => console.warn("[rails-markup] outbox flush failed:", error));
1141
+ }, 0);
1142
+ },
1143
+
1144
+ _flushOutbox() {
1145
+ if (this._flushPromise) return this._flushPromise;
1146
+ if (this._pullPromise) {
1147
+ if (!this._flushAfterPullPromise) {
1148
+ this._flushAfterPullPromise = this._pullPromise.then(() => {
1149
+ this._flushAfterPullPromise = null;
1150
+ return this._flushOutbox();
1151
+ });
1152
+ }
1153
+ return this._flushAfterPullPromise;
1154
+ }
1155
+ if (!navigator.onLine || !this.serverOnline) return Promise.resolve();
1156
+ if (this._syncRetryTimer) return Promise.resolve();
1157
+
1158
+ this._flushPromise = this._runOutboxFlush().finally(() => {
1159
+ this._flushPromise = null;
1160
+ });
1161
+ return this._flushPromise;
1162
+ },
1163
+
1164
+ async _runOutboxFlush() {
1165
+ const clientIds = Object.keys(this.outbox);
1166
+ for (const clientId of clientIds) {
1167
+ const current = this.outbox[clientId];
1168
+ if (!current || current.syncState === "failed") continue;
1169
+
1170
+ const snapshot = this._immutableCopy(current);
1171
+ let result;
1172
+ try {
1173
+ result = await this._sendOutboxEntry(snapshot);
1174
+ } catch (error) {
1175
+ result = { kind: "retryable", error };
1176
+ }
1177
+
1178
+ if (result.kind === "success") {
1179
+ if (!this._outboxEntryMatches(snapshot)) {
1180
+ clientIds.push(clientId);
1181
+ continue;
1182
+ }
1183
+ this._handleSyncSuccess(snapshot, result.data);
1184
+ this._resetSyncRetry();
1185
+ continue;
1186
+ }
1187
+ if (result.kind === "auth") {
1188
+ this._setSyncUnavailable(result.message || "Authentication required");
1189
+ break;
1190
+ }
1191
+ if (result.kind === "terminal") {
1192
+ if (!this._outboxEntryMatches(snapshot)) {
1193
+ clientIds.push(clientId);
1194
+ continue;
1195
+ }
1196
+ this._markSyncFailed(snapshot);
1197
+ continue;
1198
+ }
1199
+ if (result.kind === "malformed") {
1200
+ if (!this._outboxEntryMatches(snapshot)) {
1201
+ clientIds.push(clientId);
1202
+ continue;
1203
+ }
1204
+ if (this._recordMalformedResponse(snapshot)) continue;
1205
+ break;
1206
+ }
1207
+
1208
+ this.serverOnline = false;
1209
+ this._updateStatus();
1210
+ this._scheduleSyncRetry(result.retryAfter);
1211
+ break;
1212
+ }
1213
+ },
1214
+
1215
+ async _sendOutboxEntry(snapshot) {
1216
+ const request = this._sameOriginMutationRequest(`/annotations/${encodeURIComponent(snapshot.clientId)}`);
1217
+ if (!request) return { kind: "auth", message: "Sync unavailable: endpoint must be same-origin" };
1218
+
1219
+ const options = {
1220
+ method: snapshot.type === "delete" ? "DELETE" : "PUT",
1221
+ headers: request.headers,
1222
+ credentials: "same-origin",
1223
+ redirect: "manual",
1224
+ signal: AbortSignal.timeout(5000)
1225
+ };
1226
+ if (snapshot.type === "upsert") {
1227
+ options.body = JSON.stringify(Object.assign({}, snapshot.annotation, { dirtyFields: snapshot.dirtyFields || [] }));
1228
+ }
1229
+
1230
+ const response = await fetch(request.url, options);
1231
+ return this._classifySyncResponse(snapshot, response);
1232
+ },
1233
+
1234
+ _sameOriginMutationRequest(path) {
1235
+ const url = this._sameOriginEndpointUrl(path);
1236
+ if (!url) return null;
1237
+ const headers = { "Content-Type": "application/json", "Accept": "application/json" };
1238
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
1239
+ if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
1240
+ return { url, headers };
1241
+ },
1242
+
1243
+ _sameOriginEndpointUrl(path) {
1244
+ try {
1245
+ const rawUrl = `${this.endpoint.replace(/\/$/, "")}/${String(path).replace(/^\//, "")}`;
1246
+ const resolved = new URL(rawUrl, window.location.href);
1247
+ if (resolved.origin !== window.location.origin) return null;
1248
+ return this.endpoint.startsWith("/") && !this.endpoint.startsWith("//")
1249
+ ? resolved.pathname + resolved.search
1250
+ : resolved.href;
1251
+ } catch {
1252
+ return null;
1253
+ }
1254
+ },
1255
+
1256
+ async _classifySyncResponse(snapshot, response) {
1257
+ const status = response.status;
1258
+ if (status >= 300 && status < 400) return { kind: "auth", message: "Authentication required" };
1259
+ if (status === 401 || status === 403 || response.type === "opaqueredirect") {
1260
+ return { kind: "auth", message: "Authentication required" };
1261
+ }
1262
+ if ([408, 425, 429].includes(status) || status >= 500) {
1263
+ return { kind: "retryable", retryAfter: this._retryAfterDelay(response) };
1264
+ }
1265
+ if (status >= 400) return { kind: "terminal" };
1266
+ if (!response.ok) return { kind: "retryable" };
1267
+ if (snapshot.type === "delete") return { kind: "success", data: null };
1268
+
1269
+ const contentType = response.headers.get("Content-Type") || "";
1270
+ if (!contentType.toLowerCase().includes("application/json")) {
1271
+ return { kind: "auth", message: "Sync unavailable: expected JSON" };
1272
+ }
1273
+
1274
+ try {
1275
+ const data = await response.json();
1276
+ if (!this._validServerAnnotation(data, snapshot.clientId)) return { kind: "malformed" };
1277
+ return { kind: "success", data };
1278
+ } catch {
1279
+ return { kind: "malformed" };
1280
+ }
1281
+ },
1282
+
1283
+ _validServerAnnotation(data, expectedClientId) {
1284
+ if (!this._plainObject(data)) return false;
1285
+ const required = [
1286
+ "id", "clientId", "userId", "authorName", "content", "intent", "severity",
1287
+ "status", "selectedText", "pageUrl", "target", "metadata", "thread",
1288
+ "createdAt", "updatedAt"
1289
+ ];
1290
+ if (!required.every(key => Object.prototype.hasOwnProperty.call(data, key))) return false;
1291
+ if (typeof data.id !== "string" || data.id.length === 0) return false;
1292
+ if (data.clientId !== expectedClientId) return false;
1293
+ if (!(data.userId === null || Number.isInteger(data.userId))) return false;
1294
+ if (!(data.authorName === null || typeof data.authorName === "string")) return false;
1295
+ if (typeof data.content !== "string") return false;
1296
+ if (!["fix", "change", "question", "approve"].includes(data.intent)) return false;
1297
+ if (!["suggestion", "important", "blocking"].includes(data.severity)) return false;
1298
+ if (!["pending", "acknowledged", "resolved", "dismissed"].includes(data.status)) return false;
1299
+ if (!(data.selectedText === null || typeof data.selectedText === "string")) return false;
1300
+ if (typeof data.pageUrl !== "string" || data.pageUrl.length === 0) return false;
1301
+ if (!this._plainObject(data.target) || !this._plainObject(data.metadata)) return false;
1302
+ if (!Array.isArray(data.thread)) return false;
1303
+ if (!this._validServerTimestamp(data.createdAt) || !this._validServerTimestamp(data.updatedAt)) return false;
1304
+ return true;
1305
+ },
1306
+
1307
+ _plainObject(value) {
1308
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1309
+ },
1310
+
1311
+ _validServerTimestamp(value) {
1312
+ return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
1313
+ },
1314
+
1315
+ _handleSyncSuccess(snapshot, data) {
1316
+ if (!this._outboxEntryMatches(snapshot)) return;
1317
+ this._commitLocalStateChange(() => {
1318
+ if (!this._outboxEntryMatches(snapshot)) return;
1319
+ if (snapshot.type === "upsert") {
1320
+ const annotation = this.annotations.find(candidate => candidate.clientId === snapshot.clientId);
1321
+ if (annotation) this._mergeSuccessfulUpsert(annotation, data, snapshot.dirtyFields || []);
1322
+ }
1323
+ delete this.outbox[snapshot.clientId];
1324
+ });
1325
+ this._renderPins();
1326
+ this._rebuildList();
1327
+ this._updateCount();
1328
+ },
1329
+
1330
+ _mergeSuccessfulUpsert(annotation, server, sentDirtyFields) {
1331
+ if (this._serverRepresentationIsStale(annotation, server)) {
1332
+ annotation.dirtyFields = (annotation.dirtyFields || []).filter(field => !sentDirtyFields.includes(field));
1333
+ annotation.syncState = "synced";
1334
+ return;
1335
+ }
1336
+ annotation.serverId = server.id;
1337
+ annotation.userId = server.userId;
1338
+ annotation.authorName = server.authorName;
1339
+ annotation.createdAt = server.createdAt;
1340
+ annotation.serverUpdatedAt = server.updatedAt || annotation.serverUpdatedAt;
1341
+ annotation.comment = server.content;
1342
+ annotation.intent = server.intent;
1343
+ annotation.severity = server.severity;
1344
+ annotation.status = server.status;
1345
+ annotation.selectedText = server.selectedText;
1346
+ annotation.element = server.target || {};
1347
+ annotation.metadata = server.metadata || {};
1348
+ annotation.thread = server.thread || [];
1349
+ annotation.pageUrl = server.pageUrl || annotation.pageUrl;
1350
+ annotation.pathname = server.pageUrl || annotation.pathname;
1351
+ annotation.dirtyFields = (annotation.dirtyFields || []).filter(field => !sentDirtyFields.includes(field));
1352
+ annotation.syncState = "synced";
1353
+ },
1354
+
1355
+ _markSyncFailed(snapshot) {
1356
+ if (!this._outboxEntryMatches(snapshot)) return;
1357
+ this._commitLocalStateChange(() => {
1358
+ const entry = this.outbox[snapshot.clientId];
1359
+ if (!entry || !this._outboxEntryMatches(snapshot)) return;
1360
+ entry.syncState = "failed";
1361
+ const annotation = this.annotations.find(candidate => candidate.clientId === snapshot.clientId);
1362
+ if (annotation) annotation.syncState = "failed";
1363
+ });
1364
+ this._rebuildList();
1365
+ },
1366
+
1367
+ _recordMalformedResponse(snapshot) {
1368
+ if (!this._outboxEntryMatches(snapshot)) return true;
1369
+ const attempts = (this.outbox[snapshot.clientId].malformedAttempts || 0) + 1;
1370
+ if (attempts >= this._syncMalformedLimit) {
1371
+ this._markSyncFailed(snapshot);
1372
+ return true;
1373
+ }
1374
+ this._commitLocalStateChange(() => {
1375
+ if (this._outboxEntryMatches(snapshot)) this.outbox[snapshot.clientId].malformedAttempts = attempts;
1376
+ });
1377
+ this._scheduleSyncRetry();
1378
+ return false;
1379
+ },
1380
+
1381
+ _outboxEntryMatches(snapshot) {
1382
+ const current = this.outbox[snapshot.clientId];
1383
+ return Boolean(current) && JSON.stringify(current) === JSON.stringify(snapshot);
1384
+ },
1385
+
1386
+ _immutableCopy(value) {
1387
+ return JSON.parse(JSON.stringify(value));
1388
+ },
1389
+
1390
+ _retryAfterDelay(response) {
1391
+ const value = response.headers.get("Retry-After");
1392
+ if (!value) return null;
1393
+ const seconds = Number(value);
1394
+ const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now();
1395
+ if (!Number.isFinite(delay)) return null;
1396
+ return Math.min(this._syncMaxRetryDelay, Math.max(0, delay));
1397
+ },
1398
+
1399
+ _scheduleSyncRetry(requestedDelay) {
1400
+ if (this._syncRetryTimer) return;
1401
+ this._syncRetryAttempt += 1;
1402
+ const exponential = this._syncBaseRetryDelay * (2 ** (this._syncRetryAttempt - 1));
1403
+ this._syncRetryDelay = Math.min(this._syncMaxRetryDelay, requestedDelay ?? exponential);
1404
+ this._syncRetryTimer = setTimeout(async () => {
1405
+ this._syncRetryTimer = null;
1406
+ await this._checkHealth();
1407
+ }, this._syncRetryDelay);
1408
+ },
1409
+
1410
+ _resetSyncRetry() {
1411
+ this._syncRetryAttempt = 0;
1412
+ this._syncRetryDelay = 0;
1413
+ if (this._syncRetryTimer) clearTimeout(this._syncRetryTimer);
1414
+ this._syncRetryTimer = null;
1415
+ this._syncUnavailable = null;
1416
+ },
1417
+
1418
+ _setSyncUnavailable(message) {
1419
+ this.serverOnline = false;
1420
+ this._syncUnavailable = message;
1421
+ this._updateStatus();
1422
+ },
1423
+
1424
+ _retrySync(clientId) {
1425
+ const entry = this.outbox[clientId];
1426
+ if (!entry || entry.syncState !== "failed") return;
1427
+ const committed = this._commitLocalStateChange(() => {
1428
+ entry.syncState = "pending";
1429
+ const annotation = this.annotations.find(candidate => candidate.clientId === clientId);
1430
+ if (annotation) annotation.syncState = "pending";
1431
+ });
1432
+ if (!committed) return;
1433
+ this._rebuildList();
1434
+ },
1435
+
1436
+ _loadFromStorage() {
1437
+ try {
1438
+ // Load from global key first
1439
+ let raw = localStorage.getItem(this._storageKey());
1440
+ if (raw) {
1441
+ const data = JSON.parse(raw);
1442
+ const validDocument = data && typeof data === "object" && !Array.isArray(data);
1443
+ this.annotations = validDocument && Array.isArray(data.annotations) ? data.annotations : [];
1444
+ this.nextId = validDocument && Number.isInteger(data.nextId) && data.nextId > 0 ? data.nextId : (this.annotations.length + 1);
1445
+ this.outbox = validDocument && data.outbox && typeof data.outbox === "object" && !Array.isArray(data.outbox) ? data.outbox : {};
1446
+ this.legacyMigrations = validDocument && data.legacyMigrations && typeof data.legacyMigrations === "object" && !Array.isArray(data.legacyMigrations)
1447
+ ? data.legacyMigrations
1448
+ : {};
1449
+ }
1450
+ // Migrate any old per-page annotations into global store
1451
+ const migratedKeys = this._migratePageAnnotations();
1452
+ this._normalizeStoredState();
1453
+ this._recordLegacyMigrations();
1454
+ if (this._saveToStorage()) this._cleanupMigratedKeys(migratedKeys);
1455
+ this._rebuildList();
1456
+ this._updateCount();
1457
+ } catch (e) { console.warn("[rails-markup] load failed:", e); }
1458
+ },
1459
+
1460
+ _migratePageAnnotations() {
1461
+ // Find and merge any legacy per-page annotation keys
1462
+ const prefix = "rm-annotations:";
1463
+ const migratedKeys = [];
1464
+ const seenIds = new Set(this.annotations.map(a => a.id));
1465
+ const consolidatedClientIds = new Set(this.annotations.map(a => a.clientId).filter(clientId => this._validClientId(clientId)));
1466
+ for (let i = 0; i < localStorage.length; i++) {
1467
+ const key = localStorage.key(i);
1468
+ if (key && key.startsWith(prefix)) {
1469
+ try {
1470
+ const data = JSON.parse(localStorage.getItem(key));
1471
+ if (data && Array.isArray(data.annotations)) {
1472
+ data.annotations.forEach((a, index) => {
1473
+ const fingerprint = this._legacyMigrationFingerprint(key, index, a);
1474
+ const migratedClientId = this.legacyMigrations[fingerprint];
1475
+ if (this._validClientId(migratedClientId) && consolidatedClientIds.has(migratedClientId)) return;
1476
+ if (this._validClientId(migratedClientId)) a.clientId = migratedClientId;
1477
+ Object.defineProperty(a, "_legacyMigrationFingerprint", { configurable: true, value: fingerprint });
1478
+ // Legacy ids were per-page counters, so different pages can reuse
1479
+ // the same id. Reassign a fresh id on collision instead of
1480
+ // dropping the annotation (which silently lost data).
1481
+ if (seenIds.has(a.id)) a.id = this.nextId++;
1482
+ seenIds.add(a.id);
1483
+ this.annotations.push(a);
1484
+ if (a.id >= this.nextId) this.nextId = a.id + 1;
1485
+ });
1486
+ migratedKeys.push(key);
1487
+ }
1488
+ } catch {}
1489
+ }
1490
+ }
1491
+ return migratedKeys;
1492
+ },
1493
+
1494
+ _legacyMigrationFingerprint(key, index, annotation) {
1495
+ const input = `${key}\u0000${index}\u0000${JSON.stringify(annotation)}`;
1496
+ let hash = 2166136261;
1497
+ for (let i = 0; i < input.length; i++) hash = Math.imul(hash ^ input.charCodeAt(i), 16777619);
1498
+ return `${key}:${index}:${(hash >>> 0).toString(16)}`;
1499
+ },
1500
+
1501
+ _recordLegacyMigrations() {
1502
+ this.annotations.forEach(annotation => {
1503
+ if (!annotation._legacyMigrationFingerprint) return;
1504
+ this.legacyMigrations[annotation._legacyMigrationFingerprint] = annotation.clientId;
1505
+ delete annotation._legacyMigrationFingerprint;
1506
+ });
1507
+ },
1508
+
1509
+ _cleanupMigratedKeys(keys) {
1510
+ keys.forEach(key => {
1511
+ try { localStorage.removeItem(key); }
1512
+ catch (e) { console.warn("[rails-markup] legacy cleanup failed:", e); }
1513
+ });
1514
+ },
1515
+
1516
+ _normalizeStoredState() {
1517
+ if (!this.outbox || typeof this.outbox !== "object" || Array.isArray(this.outbox)) this.outbox = {};
1518
+
1519
+ const usedClientIds = new Set(this.annotations.map(annotation => annotation.clientId).filter(clientId => this._validClientId(clientId)));
1520
+ const invalidOutboxOwners = this._invalidOutboxOwners();
1521
+ const normalized = this.annotations.map((annotation, index) => {
1522
+ const originalClientId = annotation.clientId;
1523
+ if (!this._validClientId(originalClientId)) {
1524
+ let replacementClientId;
1525
+ do { replacementClientId = this._newClientId(); } while (usedClientIds.has(replacementClientId));
1526
+ usedClientIds.add(replacementClientId);
1527
+ annotation.clientId = replacementClientId;
1528
+ if (invalidOutboxOwners.get(originalClientId) === annotation) this._rekeyOutbox(originalClientId, replacementClientId);
1529
+ }
1530
+ if (annotation.serverId == null) annotation.serverId = annotation.server_id ?? null;
1531
+ if (annotation.serverUpdatedAt == null) annotation.serverUpdatedAt = annotation.server_updated_at ?? null;
1532
+ if (!Array.isArray(annotation.dirtyFields)) annotation.dirtyFields = [];
1533
+ annotation.pageUrl = annotation.pageUrl || annotation.pathname || this._pageUrl();
1534
+ annotation.pathname = annotation.pageUrl;
1535
+
1536
+ return { annotation, index };
1537
+ });
1538
+
1539
+ const byClientId = new Map();
1540
+ normalized.forEach(candidate => {
1541
+ const current = byClientId.get(candidate.annotation.clientId);
1542
+ if (!current || this._isNewerLocalRecord(candidate, current)) byClientId.set(candidate.annotation.clientId, candidate);
1543
+ });
1544
+ this.annotations = Array.from(byClientId.values())
1545
+ .sort((left, right) => left.index - right.index)
1546
+ .map(candidate => candidate.annotation);
1547
+
1548
+ this._assignDisplayIds();
1549
+ this.annotations.forEach(annotation => {
1550
+ const mapped = annotation.serverId != null;
1551
+ const queued = Boolean(this.outbox[annotation.clientId]);
1552
+ annotation.syncState = (queued && annotation.syncState === "failed")
1553
+ ? "failed"
1554
+ : ((queued || !mapped) ? "pending" : "synced");
1555
+ if (!this.outbox[annotation.clientId] && !mapped) {
1556
+ annotation.dirtyFields = this._legacyDirtyFields(annotation);
1557
+ this.outbox[annotation.clientId] = {
1558
+ type: "upsert",
1559
+ annotation: this._desiredState(annotation),
1560
+ dirtyFields: annotation.dirtyFields.slice()
1561
+ };
1562
+ }
1563
+ });
1564
+ },
1565
+
1566
+ _isNewerLocalRecord(candidate, current) {
1567
+ const timestamp = value => {
1568
+ const parsed = Date.parse(value || "");
1569
+ return Number.isNaN(parsed) ? -Infinity : parsed;
1570
+ };
1571
+ const candidateUpdatedAt = timestamp(candidate.annotation.updatedAt || candidate.annotation.updated_at);
1572
+ const currentUpdatedAt = timestamp(current.annotation.updatedAt || current.annotation.updated_at);
1573
+ if (candidateUpdatedAt !== currentUpdatedAt) return candidateUpdatedAt > currentUpdatedAt;
1574
+ return candidate.index > current.index;
1575
+ },
1576
+
1577
+ _assignDisplayIds() {
1578
+ const validIds = this.annotations
1579
+ .map(annotation => annotation.id)
1580
+ .filter(id => Number.isInteger(id) && id > 0);
1581
+ let candidateId = Math.max(1, Number.isInteger(this.nextId) ? this.nextId : 1, ...validIds.map(id => id + 1));
1582
+ const usedIds = new Set();
1583
+
1584
+ this.annotations.forEach(annotation => {
1585
+ if (!Number.isInteger(annotation.id) || annotation.id <= 0 || usedIds.has(annotation.id)) {
1586
+ while (usedIds.has(candidateId)) candidateId += 1;
1587
+ annotation.id = candidateId++;
1588
+ }
1589
+ usedIds.add(annotation.id);
1590
+ });
1591
+
1592
+ this.nextId = Math.max(candidateId, ...Array.from(usedIds, id => id + 1), 1);
1593
+ },
1594
+
1595
+ _legacyDirtyFields(annotation) {
1596
+ const fields = this._browserCreateFields();
1597
+ if (annotation.status && annotation.status !== "pending") fields.push("status");
1598
+ return fields;
1599
+ },
1600
+
1601
+ _browserCreateFields() {
1602
+ return ["content", "intent", "severity", "selected_text", "target", "page_url", "metadata"];
1603
+ },
1604
+
1605
+ _desiredState(annotation) {
1606
+ const sourceMetadata = annotation.metadata && typeof annotation.metadata === "object" ? annotation.metadata : {};
1607
+ const metadata = {};
1608
+ ["tool", "url", "localId", "sessionId", "screenshot"].forEach(key => {
1609
+ if (sourceMetadata[key] != null) metadata[key] = sourceMetadata[key];
1610
+ });
1611
+ if (metadata.tool == null) metadata.tool = "rails-markup";
1612
+ if (metadata.url == null && annotation.url) metadata.url = annotation.url;
1613
+ if (metadata.localId == null && annotation.id != null) metadata.localId = annotation.id;
1614
+ if (metadata.sessionId == null && this.sessionId != null) metadata.sessionId = this.sessionId;
1615
+ if (metadata.screenshot == null && annotation.screenshot) metadata.screenshot = annotation.screenshot;
1616
+
1617
+ return JSON.parse(JSON.stringify({
1618
+ clientId: annotation.clientId,
1619
+ page_url: annotation.pageUrl || annotation.pathname || this._pageUrl(),
1620
+ content: annotation.comment ?? annotation.content ?? "",
1621
+ intent: annotation.intent,
1622
+ severity: annotation.severity,
1623
+ selected_text: annotation.selectedText ?? annotation.selected_text ?? null,
1624
+ target: annotation.element || annotation.target || {},
1625
+ metadata,
1626
+ status: annotation.status || "pending"
1627
+ }));
1628
+ },
1629
+
1630
+ _validClientId(clientId) {
1631
+ return typeof clientId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(clientId);
1632
+ },
1633
+
1634
+ _invalidOutboxOwners() {
1635
+ const owners = new Map();
1636
+ this.annotations.forEach(annotation => {
1637
+ const clientId = annotation.clientId;
1638
+ if (!clientId || this._validClientId(clientId) || owners.has(clientId) || !this.outbox[clientId]) return;
1639
+
1640
+ const peers = this.annotations.filter(candidate => candidate.clientId === clientId);
1641
+ const desired = this.outbox[clientId].annotation || this.outbox[clientId];
1642
+ const desiredId = desired.id ?? desired.metadata?.localId;
1643
+ const desiredComment = desired.comment ?? desired.content;
1644
+ const owner = peers.find(candidate => desiredId != null && candidate.id === desiredId)
1645
+ || peers.find(candidate => desiredComment != null && (candidate.comment ?? candidate.content) === desiredComment)
1646
+ || peers[0];
1647
+ owners.set(clientId, owner);
1648
+ });
1649
+ return owners;
1650
+ },
1651
+
1652
+ _rekeyOutbox(oldClientId, newClientId) {
1653
+ if (!oldClientId || !this.outbox[oldClientId]) return;
1654
+ const entry = this.outbox[oldClientId];
1655
+ delete this.outbox[oldClientId];
1656
+ if (entry.annotation) entry.annotation.clientId = newClientId;
1657
+ if (entry.clientId) entry.clientId = newClientId;
1658
+ this.outbox[newClientId] = entry;
1659
+ },
1660
+
1661
+ _newClientId() {
1662
+ if (global.crypto && typeof global.crypto.randomUUID === "function") return global.crypto.randomUUID();
1663
+ const bytes = new Uint8Array(16);
1664
+ if (global.crypto && typeof global.crypto.getRandomValues === "function") global.crypto.getRandomValues(bytes);
1665
+ else bytes.forEach((_, index) => { bytes[index] = Math.floor(Math.random() * 256); });
1666
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
1667
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
1668
+ const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
1669
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1670
+ },
1671
+
1672
+ // ---- Server sync ----
1673
+
1674
+ _pullAnnotations() {
1675
+ const pageUrl = this._pageUrl();
1676
+ if (this._pullPromise && this._pullPageUrl === pageUrl) return this._pullPromise;
1677
+
1678
+ const previousPull = this._pullPromise;
1679
+ const operation = previousPull
1680
+ ? previousPull.then(() => this._performAnnotationPull(pageUrl))
1681
+ : this._performAnnotationPull(pageUrl);
1682
+ let trackedPull;
1683
+ trackedPull = operation.finally(() => {
1684
+ if (this._pullPromise !== trackedPull) return;
1685
+ this._pullPromise = null;
1686
+ this._pullPageUrl = null;
1687
+ });
1688
+ this._pullPageUrl = pageUrl;
1689
+ this._pullPromise = trackedPull;
1690
+ return trackedPull;
1691
+ },
1692
+
1693
+ _performAnnotationPull(pageUrl) {
1694
+ return this._runAnnotationPull(pageUrl)
1695
+ .then(complete => {
1696
+ this._pullNeeded = !complete;
1697
+ return complete;
1698
+ })
1699
+ .catch(() => {
1700
+ this._pullNeeded = true;
1701
+ return false;
1702
+ });
1703
+ },
1704
+
1705
+ async _runAnnotationPull(pageUrl) {
1706
+ const url = this._sameOriginEndpointUrl(`/annotations?page_url=${encodeURIComponent(pageUrl)}`);
1707
+ if (!url) {
1708
+ this._setSyncUnavailable("Sync unavailable: endpoint must be same-origin");
1709
+ return false;
1710
+ }
1711
+
1712
+ const response = await fetch(url, {
1713
+ method: "GET",
1714
+ headers: { "Accept": "application/json" },
1715
+ credentials: "same-origin",
1716
+ redirect: "manual",
1717
+ signal: AbortSignal.timeout(5000)
1718
+ });
1719
+ if (!response.ok || response.status >= 300 || response.type === "opaqueredirect") return false;
1720
+ const contentType = response.headers.get("Content-Type") || "";
1721
+ if (!contentType.toLowerCase().includes("application/json")) return false;
1722
+
1723
+ let records;
1724
+ try { records = await response.json(); }
1725
+ catch { return false; }
1726
+ if (pageUrl !== this._pageUrl() || !this._validPullRecords(records, pageUrl)) return false;
1727
+ return this._reconcilePull(pageUrl, records);
1728
+ },
1729
+
1730
+ _validPullRecords(records, pageUrl) {
1731
+ if (!Array.isArray(records)) return false;
1732
+ const clientIds = new Set();
1733
+ return records.every(record => {
1734
+ const clientId = record?.clientId;
1735
+ if (!this._validClientId(clientId) || clientIds.has(clientId)) return false;
1736
+ clientIds.add(clientId);
1737
+ return record.pageUrl === pageUrl && this._validServerAnnotation(record, clientId);
1738
+ });
1739
+ },
1740
+
1741
+ _reconcilePull(pageUrl, records) {
1742
+ const committed = this._commitLocalStateChange(() => {
1743
+ const presentClientIds = new Set(records.map(record => record.clientId));
1744
+ records.forEach(server => {
1745
+ const outboxEntry = this.outbox[server.clientId];
1746
+ if (outboxEntry?.type === "delete") return;
1747
+
1748
+ const annotation = this.annotations.find(candidate => candidate.clientId === server.clientId);
1749
+ if (annotation) this._mergePulledAnnotation(annotation, server, outboxEntry);
1750
+ else this.annotations.push(this._annotationFromServer(server));
1751
+ });
1752
+
1753
+ this.annotations = this.annotations.filter(annotation => {
1754
+ const annotationPage = annotation.pageUrl || annotation.pathname;
1755
+ if (annotationPage !== pageUrl || presentClientIds.has(annotation.clientId)) return true;
1756
+ return Boolean(this.outbox[annotation.clientId]) || annotation.syncState !== "synced";
1757
+ });
1758
+
1759
+ this._assignDisplayIds();
1760
+ Object.entries(this.outbox).forEach(([clientId, entry]) => {
1761
+ if (entry?.type !== "upsert") return;
1762
+ const annotation = this.annotations.find(candidate => candidate.clientId === clientId);
1763
+ if (!annotation) return;
1764
+ entry.annotation = this._desiredState(annotation);
1765
+ entry.dirtyFields = (annotation.dirtyFields || []).slice();
1766
+ });
1767
+ });
1768
+ if (!committed) return false;
1769
+ this._renderPins();
1770
+ this._rebuildList();
1771
+ this._updateCount();
1772
+ return true;
1773
+ },
1774
+
1775
+ _mergePulledAnnotation(annotation, server, outboxEntry) {
1776
+ if (this._serverRepresentationIsStale(annotation, server)) return;
1777
+ const dirtyFields = new Set(this._mergeDirtyFields(annotation.dirtyFields || [], outboxEntry?.dirtyFields || []));
1778
+ const browserFields = [
1779
+ ["content", "comment"],
1780
+ ["intent", "intent"],
1781
+ ["severity", "severity"],
1782
+ ["selected_text", "selectedText"],
1783
+ ["target", "element"],
1784
+ ["metadata", "metadata"],
1785
+ ["status", "status"]
1786
+ ];
1787
+ browserFields.forEach(([dirtyField, localField]) => {
1788
+ if (dirtyFields.has(dirtyField)) return;
1789
+ const serverField = {
1790
+ content: "content", selected_text: "selectedText", target: "target"
1791
+ }[dirtyField] || dirtyField;
1792
+ annotation[localField] = this._immutableCopy(server[serverField]);
1793
+ });
1794
+ if (!dirtyFields.has("page_url")) {
1795
+ annotation.pageUrl = server.pageUrl;
1796
+ annotation.pathname = server.pageUrl;
1797
+ }
1798
+ annotation.serverId = server.id;
1799
+ annotation.userId = server.userId;
1800
+ annotation.authorName = server.authorName;
1801
+ annotation.createdAt = server.createdAt;
1802
+ annotation.serverUpdatedAt = server.updatedAt;
1803
+ annotation.thread = this._immutableCopy(server.thread);
1804
+ annotation.dirtyFields = Array.from(dirtyFields);
1805
+ annotation.syncState = outboxEntry ? (outboxEntry.syncState || "pending") : "synced";
1806
+ if (!dirtyFields.has("metadata") && server.metadata?.url) annotation.url = server.metadata.url;
1807
+ },
1808
+
1809
+ _annotationFromServer(server) {
1810
+ return {
1811
+ id: null,
1812
+ clientId: server.clientId,
1813
+ serverId: server.id,
1814
+ userId: server.userId,
1815
+ authorName: server.authorName,
1816
+ syncState: "synced",
1817
+ serverUpdatedAt: server.updatedAt,
1818
+ dirtyFields: [],
1819
+ revision: 0,
1820
+ comment: server.content,
1821
+ intent: server.intent,
1822
+ severity: server.severity,
1823
+ status: server.status,
1824
+ selectedText: server.selectedText,
1825
+ element: this._immutableCopy(server.target),
1826
+ metadata: this._immutableCopy(server.metadata),
1827
+ pathname: server.pageUrl,
1828
+ pageUrl: server.pageUrl,
1829
+ url: server.metadata?.url || server.pageUrl,
1830
+ thread: this._immutableCopy(server.thread),
1831
+ createdAt: server.createdAt
1832
+ };
1833
+ },
1834
+
1835
+ _serverRepresentationIsStale(annotation, server) {
1836
+ const localTimestamp = Date.parse(annotation.serverUpdatedAt || "");
1837
+ const serverTimestamp = Date.parse(server.updatedAt || "");
1838
+ return Number.isFinite(localTimestamp) && Number.isFinite(serverTimestamp) && serverTimestamp < localTimestamp;
1839
+ },
1840
+
1841
+ _onVisibilityChange() {
1842
+ if (document.hidden) {
1843
+ // Tab hidden — pause health checks to save resources
1844
+ if (this.healthInterval) { clearInterval(this.healthInterval); this.healthInterval = null; }
1845
+ } else {
1846
+ // Tab visible — resume health checks immediately
1847
+ if (!this.healthInterval) {
1848
+ this._checkHealth();
1849
+ this.healthInterval = setInterval(() => this._checkHealth(), this.healthIntervalMs);
1850
+ }
1851
+ }
1852
+ },
1853
+
1854
+ _onOnline() {
1855
+ return this._checkHealth();
1856
+ },
1857
+
1858
+ _checkHealth() {
1859
+ if (this._healthPromise) return this._healthPromise;
1860
+ this._healthPromise = this._runHealthCheck().finally(() => { this._healthPromise = null; });
1861
+ return this._healthPromise;
1862
+ },
1863
+
1864
+ async _runHealthCheck() {
1865
+ try {
1866
+ const healthUrl = this._sameOriginEndpointUrl("/health");
1867
+ if (!healthUrl) {
1868
+ this._setSyncUnavailable("Sync unavailable: endpoint must be same-origin");
1869
+ return;
1870
+ }
1871
+ const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) });
1872
+ const was = this.serverOnline;
1873
+ this.serverOnline = resp.ok;
1874
+ this._updateStatus();
1875
+ if (!was && this.serverOnline) await this._initSession();
1876
+ if (this.serverOnline && (!was || this._pullNeeded)) await this._pullAnnotations();
1877
+ if (this.serverOnline) await this._flushOutbox();
1878
+ } catch {
1879
+ this.serverOnline = false;
1880
+ this._updateStatus();
1881
+ }
1882
+ },
1883
+
1884
+ async _initSession() {
1885
+ if (!this.serverOnline) return;
1886
+ try {
1887
+ const request = this._sameOriginMutationRequest("/sessions");
1888
+ if (!request) {
1889
+ this._setSyncUnavailable("Sync unavailable: endpoint must be same-origin");
1890
+ return;
1891
+ }
1892
+ const resp = await fetch(request.url, {
1893
+ method: "POST", headers: request.headers, credentials: "same-origin",
1894
+ body: JSON.stringify({ url: window.location.href, metadata: { tool: "rails-markup" } }),
1895
+ signal: AbortSignal.timeout(5000)
1896
+ });
1897
+ if (resp.ok) {
1898
+ const data = await resp.json();
1899
+ this.sessionId = data.id;
1900
+ }
1901
+ } catch (e) { console.warn("[rails-markup] session init failed:", e); }
1902
+ },
1903
+
1904
+ async _synchronizeCurrentPage(initializeSession) {
1905
+ if (!this.serverOnline) return;
1906
+ if (initializeSession) await this._initSession();
1907
+ await this._pullAnnotations();
1908
+ await this._flushOutbox();
1909
+ },
1910
+
1911
+ async _pushToServer(annotation) {
1912
+ if (!this.serverOnline) return;
1913
+ try {
1914
+ const request = this._sameOriginMutationRequest(`/sessions/${encodeURIComponent(this.sessionId || "local")}/annotations`);
1915
+ if (!request) {
1916
+ this._setSyncUnavailable("Sync unavailable: endpoint must be same-origin");
1917
+ return;
1918
+ }
1919
+ await fetch(request.url, {
1920
+ method: "POST", headers: request.headers, credentials: "same-origin",
1921
+ body: JSON.stringify({
1922
+ page_url: this._pageUrl(),
1923
+ clientId: annotation.clientId,
1924
+ content: annotation.comment,
1925
+ intent: annotation.intent,
1926
+ severity: annotation.severity,
1927
+ selected_text: annotation.selectedText || null,
1928
+ target: annotation.element || {},
1929
+ metadata: Object.assign(
1930
+ { localId: annotation.id, url: annotation.url },
1931
+ annotation.screenshot ? { screenshot: annotation.screenshot } : {}
1932
+ )
1933
+ }),
1934
+ signal: AbortSignal.timeout(5000)
1935
+ });
1936
+ } catch (e) { console.warn("[rails-markup] push failed:", e); }
1937
+ },
1938
+
1939
+ _updateStatus() {
1940
+ const dot = document.getElementById("rm-status-dot");
1941
+ const text = document.getElementById("rm-status-text");
1942
+ if (dot) {
1943
+ dot.style.background = this.serverOnline ? "#4ade80" : "#d1d5db";
1944
+ dot.style.boxShadow = this.serverOnline ? "0 0 0 2px rgba(74,222,128,0.2)" : "";
1945
+ }
1946
+ if (text) text.textContent = this.serverOnline ? "Connected" : (this._syncUnavailable || "Offline");
1947
+ },
1948
+
1949
+ // ---- Screenshot capture ----
1950
+
1951
+ async _captureElement(element) {
1952
+ try {
1953
+ const rect = element.getBoundingClientRect();
1954
+ const width = Math.min(Math.round(rect.width), 800);
1955
+ const height = Math.min(Math.round(rect.height), 600);
1956
+ if (width < 10 || height < 10) return null;
1957
+
1958
+ const clone = element.cloneNode(true);
1959
+ // Strip scripts and event handlers
1960
+ clone.querySelectorAll("script").forEach(s => s.remove());
1961
+ // Remove cross-origin images to avoid tainting the canvas
1962
+ const origin = location.origin;
1963
+ clone.querySelectorAll("img").forEach(img => {
1964
+ try { if (img.src && !img.src.startsWith(origin) && !img.src.startsWith("data:")) img.removeAttribute("src"); } catch {}
1965
+ });
1966
+
1967
+ const svgNS = "http://www.w3.org/2000/svg";
1968
+ const svg = `<svg xmlns="${svgNS}" width="${width}" height="${height}">
1969
+ <foreignObject width="100%" height="100%">
1970
+ <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:14px;">${clone.outerHTML}</div>
1971
+ </foreignObject>
1972
+ </svg>`;
1973
+
1974
+ const canvas = document.createElement("canvas");
1975
+ const scale = Math.min(window.devicePixelRatio || 1, 2);
1976
+ canvas.width = width * scale;
1977
+ canvas.height = height * scale;
1978
+ const ctx = canvas.getContext("2d");
1979
+ ctx.scale(scale, scale);
1980
+
1981
+ const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
1982
+ const url = URL.createObjectURL(blob);
1983
+
1984
+ return new Promise((resolve) => {
1985
+ const img = new Image();
1986
+ img.onload = () => {
1987
+ ctx.drawImage(img, 0, 0, width, height);
1988
+ URL.revokeObjectURL(url);
1989
+ try { resolve(canvas.toDataURL("image/png", 0.7)); } catch { resolve(null); }
1990
+ };
1991
+ img.onerror = () => {
1992
+ URL.revokeObjectURL(url);
1993
+ resolve(null);
1994
+ };
1995
+ img.src = url;
1996
+ });
1997
+ } catch {
1998
+ return null;
1999
+ }
2000
+ },
2001
+
2002
+ // ---- Drawing tools ----
2003
+
2004
+ _initDrawing(screenshotDataUrl) {
2005
+ const popup = document.getElementById("rm-popup");
2006
+ let container = popup.querySelector(".rm-drawing-container");
2007
+ if (container) container.remove();
2008
+
2009
+ container = document.createElement("div");
2010
+ container.className = "rm-drawing-container";
2011
+ container.style.cssText = "position:relative;margin-bottom:12px;border-radius:8px;overflow:hidden;border:1px solid #e5e7eb;";
2012
+
2013
+ const img = new Image();
2014
+ img.src = screenshotDataUrl;
2015
+ img.style.cssText = "display:block;max-width:100%;border-radius:8px;";
2016
+ container.appendChild(img);
2017
+
2018
+ const canvas = document.createElement("canvas");
2019
+ canvas.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;cursor:crosshair;";
2020
+ container.appendChild(canvas);
2021
+
2022
+ // Tool buttons
2023
+ const tools = document.createElement("div");
2024
+ tools.style.cssText = "display:flex;gap:4px;padding:6px 0;";
2025
+ tools.innerHTML = `
2026
+ <button type="button" data-draw="arrow" class="rm-pill" style="font-size:11px;padding:4px 10px;">Arrow</button>
2027
+ <button type="button" data-draw="rect" class="rm-pill" style="font-size:11px;padding:4px 10px;">Rect</button>
2028
+ <button type="button" data-draw="highlight" class="rm-pill" style="font-size:11px;padding:4px 10px;">Highlight</button>
2029
+ <button type="button" data-draw="undo" class="rm-pill" style="font-size:11px;padding:4px 10px;margin-left:auto;">Undo</button>
2030
+ `;
2031
+
2032
+ const textarea = popup.querySelector("textarea");
2033
+ popup.insertBefore(tools, textarea);
2034
+ popup.insertBefore(container, tools);
2035
+
2036
+ img.onload = () => {
2037
+ canvas.width = img.naturalWidth || img.offsetWidth;
2038
+ canvas.height = img.naturalHeight || img.offsetHeight;
2039
+ this.drawingCanvas = canvas;
2040
+ this.drawingCtx = canvas.getContext("2d");
2041
+ this._screenshotImg = img;
2042
+ this.drawingHistory = [];
2043
+ this.drawingMode = null;
2044
+ this._bindDrawingEvents(canvas, tools);
2045
+ };
2046
+ },
2047
+
2048
+ _bindDrawingEvents(canvas, tools) {
2049
+ const self = this;
2050
+ let isDrawing = false;
2051
+ let points = [];
2052
+
2053
+ tools.addEventListener("click", (e) => {
2054
+ const btn = e.target.closest("[data-draw]");
2055
+ if (!btn) return;
2056
+ const mode = btn.dataset.draw;
2057
+ if (mode === "undo") {
2058
+ self._undoDrawing();
2059
+ return;
2060
+ }
2061
+ self.drawingMode = mode;
2062
+ tools.querySelectorAll("[data-draw]").forEach(b => {
2063
+ b.style.background = b.dataset.draw === mode ? self._accentBg() : "#fff";
2064
+ b.style.color = b.dataset.draw === mode ? "#fff" : "#374151";
2065
+ });
2066
+ });
2067
+
2068
+ const getPos = (e) => {
2069
+ const rect = canvas.getBoundingClientRect();
2070
+ return {
2071
+ x: (e.clientX - rect.left) * (canvas.width / rect.width),
2072
+ y: (e.clientY - rect.top) * (canvas.height / rect.height)
2073
+ };
2074
+ };
2075
+
2076
+ canvas.addEventListener("mousedown", (e) => {
2077
+ if (!self.drawingMode) return;
2078
+ e.stopPropagation();
2079
+ isDrawing = true;
2080
+ self.drawingStart = getPos(e);
2081
+ points = [self.drawingStart];
2082
+ });
2083
+
2084
+ canvas.addEventListener("mousemove", (e) => {
2085
+ if (!isDrawing || !self.drawingMode) return;
2086
+ e.stopPropagation();
2087
+ const pos = getPos(e);
2088
+ if (self.drawingMode === "highlight") {
2089
+ points.push(pos);
2090
+ self._redrawCanvas();
2091
+ self._drawHighlightPath(points);
2092
+ } else {
2093
+ self._redrawCanvas();
2094
+ self._drawShape(self.drawingMode, self.drawingStart, pos);
2095
+ }
2096
+ });
2097
+
2098
+ canvas.addEventListener("mouseup", (e) => {
2099
+ if (!isDrawing || !self.drawingMode) return;
2100
+ e.stopPropagation();
2101
+ isDrawing = false;
2102
+ const end = getPos(e);
2103
+ if (self.drawingMode === "highlight") {
2104
+ self.drawingHistory.push({ type: "highlight", points: [...points] });
2105
+ } else {
2106
+ self.drawingHistory.push({ type: self.drawingMode, start: self.drawingStart, end: end });
2107
+ }
2108
+ self._redrawCanvas();
2109
+ points = [];
2110
+ });
2111
+ },
2112
+
2113
+ _drawShape(type, start, end) {
2114
+ const ctx = this.drawingCtx;
2115
+ if (!ctx) return;
2116
+ ctx.lineWidth = 3;
2117
+
2118
+ if (type === "arrow") {
2119
+ ctx.strokeStyle = "#ef4444";
2120
+ ctx.beginPath();
2121
+ ctx.moveTo(start.x, start.y);
2122
+ ctx.lineTo(end.x, end.y);
2123
+ ctx.stroke();
2124
+ // Arrowhead
2125
+ const angle = Math.atan2(end.y - start.y, end.x - start.x);
2126
+ const headLen = 12;
2127
+ ctx.beginPath();
2128
+ ctx.moveTo(end.x, end.y);
2129
+ ctx.lineTo(end.x - headLen * Math.cos(angle - Math.PI / 6), end.y - headLen * Math.sin(angle - Math.PI / 6));
2130
+ ctx.moveTo(end.x, end.y);
2131
+ ctx.lineTo(end.x - headLen * Math.cos(angle + Math.PI / 6), end.y - headLen * Math.sin(angle + Math.PI / 6));
2132
+ ctx.stroke();
2133
+ } else if (type === "rect") {
2134
+ ctx.strokeStyle = "#ef4444";
2135
+ ctx.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y);
2136
+ }
2137
+ },
2138
+
2139
+ _drawHighlightPath(points) {
2140
+ const ctx = this.drawingCtx;
2141
+ if (!ctx || points.length < 2) return;
2142
+ ctx.strokeStyle = "rgba(250,204,21,0.5)";
2143
+ ctx.lineWidth = 16;
2144
+ ctx.lineCap = "round";
2145
+ ctx.lineJoin = "round";
2146
+ ctx.beginPath();
2147
+ ctx.moveTo(points[0].x, points[0].y);
2148
+ for (let i = 1; i < points.length; i++) {
2149
+ ctx.lineTo(points[i].x, points[i].y);
2150
+ }
2151
+ ctx.stroke();
2152
+ ctx.lineWidth = 3;
2153
+ },
2154
+
2155
+ _redrawCanvas() {
2156
+ const ctx = this.drawingCtx;
2157
+ if (!ctx) return;
2158
+ ctx.clearRect(0, 0, this.drawingCanvas.width, this.drawingCanvas.height);
2159
+ this.drawingHistory.forEach(shape => {
2160
+ if (shape.type === "highlight") {
2161
+ this._drawHighlightPath(shape.points);
2162
+ } else {
2163
+ this._drawShape(shape.type, shape.start, shape.end);
2164
+ }
2165
+ });
2166
+ },
2167
+
2168
+ _undoDrawing() {
2169
+ if (this.drawingHistory.length === 0) return;
2170
+ this.drawingHistory.pop();
2171
+ this._redrawCanvas();
2172
+ },
2173
+
2174
+ _mergeDrawing() {
2175
+ if (!this.drawingCanvas || !this._screenshotImg || this.drawingHistory.length === 0) return null;
2176
+ try {
2177
+ const merged = document.createElement("canvas");
2178
+ merged.width = this.drawingCanvas.width;
2179
+ merged.height = this.drawingCanvas.height;
2180
+ const ctx = merged.getContext("2d");
2181
+ ctx.drawImage(this._screenshotImg, 0, 0, merged.width, merged.height);
2182
+ ctx.drawImage(this.drawingCanvas, 0, 0);
2183
+ return merged.toDataURL("image/png", 0.7);
2184
+ } catch {
2185
+ return null;
2186
+ }
2187
+ },
2188
+
2189
+ // ---- Size helpers ----
2190
+
2191
+ _fabIconSize() {
2192
+ const map = { "default": 20, compact: 18, slim: 16 };
2193
+ return map[this.size] || 20;
2194
+ },
2195
+
2196
+ // ---- Color helpers ----
2197
+
2198
+ _accentBg() {
2199
+ const map = { indigo: "#4f46e5", amber: "#f59e0b", blue: "#2563eb", emerald: "#059669", rose: "#e11d48" };
2200
+ return map[this.accent] || map.indigo;
2201
+ },
2202
+ _accentBgHover() {
2203
+ const map = { indigo: "#4338ca", amber: "#d97706", blue: "#1d4ed8", emerald: "#047857", rose: "#be123c" };
2204
+ return map[this.accent] || map.indigo;
2205
+ },
2206
+ _accentLight() {
2207
+ const map = { indigo: "#e0e7ff", amber: "#fef3c7", blue: "#dbeafe", emerald: "#d1fae5", rose: "#ffe4e6" };
2208
+ return map[this.accent] || map.indigo;
2209
+ },
2210
+ _accentText() {
2211
+ const map = { indigo: "#3730a3", amber: "#92400e", blue: "#1e40af", emerald: "#065f46", rose: "#9f1239" };
2212
+ return map[this.accent] || map.indigo;
2213
+ },
2214
+
2215
+ // ---- Helpers ----
2216
+
2217
+ _esc(str) {
2218
+ const div = document.createElement("div");
2219
+ div.textContent = str || "";
2220
+ return div.innerHTML;
2221
+ }
2222
+ };
2223
+
2224
+ global.RailsMarkupToolbar = RailsMarkupToolbar;
2225
+ })(typeof window !== "undefined" ? window : this);