rails-markup 1.4.7 → 1.5.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.
@@ -12,6 +12,8 @@
12
12
  this.drawingMode = null;
13
13
  this._screenshotImg = null;
14
14
 
15
+ document.getElementById("rm-image-options").open = Boolean(this._currentScreenshot);
16
+ this._resetScreenshotStatus();
15
17
  popup.style.display = "block";
16
18
  popup.style.opacity = "0";
17
19
  popup.style.left = "-9999px";
@@ -70,7 +72,7 @@
70
72
  const len = document.getElementById("rm-popup-input").value.length;
71
73
  const el = document.getElementById("rm-char-count");
72
74
  el.textContent = len > 0 ? len : "";
73
- el.style.color = len > 500 ? "#f87171" : "#d1d5db";
75
+ el.style.color = len > 500 ? "#b91c1c" : "#596579";
74
76
  },
75
77
  submitAnnotation(event) {
76
78
  if (event) event.preventDefault();
@@ -155,7 +157,7 @@
155
157
  } else {
156
158
  chip.className = "rm-chip rm-chip-inactive";
157
159
  chip.style.background = "#f9fafb";
158
- chip.style.color = "#9ca3af";
160
+ chip.style.color = "#596579";
159
161
  }
160
162
  });
161
163
  },
@@ -199,10 +201,10 @@
199
201
  options: this._statusOptions(),
200
202
  compact: true
201
203
  })}
202
- <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'">
204
+ <button data-edit-id="${annotation.id}" title="Edit" style="padding:2px 4px;background:none;border:none;cursor:pointer;color:#64748b;border-radius:4px;display:flex;align-items:center;" onmouseover="this.style.color='#6b7280'" onmouseout="this.style.color='#64748b'">
203
205
  <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>
204
206
  </button>
205
- <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'">
207
+ <button data-delete-id="${annotation.id}" title="Delete" style="padding:2px 4px;background:none;border:none;cursor:pointer;color:#64748b;border-radius:4px;display:flex;align-items:center;" onmouseover="this.style.color='#ef4444'" onmouseout="this.style.color='#64748b'">
206
208
  <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>
207
209
  </button>
208
210
  </span>
@@ -346,14 +348,22 @@
346
348
  const pin = document.createElement("div");
347
349
  pin.className = "rm-pin" + (isResolved ? "" : " rm-pin-active");
348
350
  pin.dataset.pinId = annotation.id;
349
- pin.style.top = (top - 10) + "px";
350
- pin.style.left = (left + width - 10) + "px";
351
+ this._positionPin(pin, { top, left, width });
351
352
  pin.style.background = isResolved ? "#d1d5db" : this._accentBg();
352
353
  if (isResolved) pin.style.opacity = "0.6";
353
354
  pin.textContent = annotation.id;
354
355
  pin.title = "#" + annotation.id + ": " + annotation.comment.slice(0, 50);
355
356
  container.appendChild(pin);
356
357
  },
358
+ _positionPin(pin, { top, left, width }) {
359
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
360
+ const scrollLeft = window.scrollX || 0;
361
+ // The 20px pin must fit inside the viewport, including its focus/hover space.
362
+ const minLeft = scrollLeft + 4;
363
+ const maxLeft = Math.max(minLeft, scrollLeft + viewportWidth - 24);
364
+ pin.style.left = Math.max(minLeft, Math.min(left + width - 10, maxLeft)) + "px";
365
+ pin.style.top = Math.max(4, top - 10) + "px";
366
+ },
357
367
  _renderPins() {
358
368
  const container = document.getElementById("rm-pins-container");
359
369
  if (container) container.innerHTML = "";
@@ -377,7 +387,7 @@
377
387
  const rect = el.getBoundingClientRect();
378
388
  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) };
379
389
  const pin = document.querySelector('[data-pin-id="' + annotation.id + '"]');
380
- if (pin) { pin.style.top = (annotation.element.boundingBox.top - 10) + "px"; pin.style.left = (annotation.element.boundingBox.left + annotation.element.boundingBox.width - 10) + "px"; }
390
+ if (pin) this._positionPin(pin, annotation.element.boundingBox);
381
391
  });
382
392
  },
383
393
  _debouncedRepositionPins(delay = 250) {
@@ -466,8 +476,8 @@
466
476
  case "accent": return "Accent";
467
477
  case "position": return "Position";
468
478
  case "size": return "Size";
469
- case "fabVisible": return "Show FAB";
470
- case "enableScreenshots": return "Screenshots";
479
+ case "fabVisible": return "Show floating toolbar";
480
+ case "enableScreenshots": return "Image attachments";
471
481
  default: return setting;
472
482
  }
473
483
  },
@@ -496,17 +506,19 @@
496
506
  _toolbarSettingsRowMarkup(setting) {
497
507
  const current = this._toolbarSettingCurrentValue(setting);
498
508
  const options = this._toolbarSettingOptions(setting);
499
- const buttons = options.map(([value, label]) => {
509
+ const label = this._toolbarSettingLabel(setting);
510
+ if (typeof current === "boolean") {
511
+ return `<div class="rm-settings-group rm-settings-switch-row"><span class="rm-settings-label">${this._esc(label)}</span><button type="button" class="rm-setting-switch" role="switch" aria-label="${this._esc(label)}" aria-checked="${current}" data-setting="${setting}" data-value="${!current}"><span></span></button></div>`;
512
+ }
513
+ const colors = { indigo: "#4f46e5", amber: "#f59e0b", blue: "#2563eb", emerald: "#059669", rose: "#e11d48" };
514
+ const corners = { bl: "◱", br: "◲", tl: "◰", tr: "◳" };
515
+ const buttons = options.map(([value, optionLabel]) => {
500
516
  const active = String(value) === String(current);
501
- const valueAttr = this._toolbarSettingDisplayValue(value);
502
- return `<button type="button" class="rm-chip${active ? " rm-chip-active" : " rm-chip-inactive"} rm-setting-chip" data-setting="${setting}" data-value="${this._esc(valueAttr)}"${active ? ` style="background:${this._accentBg()}"` : ""}>${this._esc(label)}</button>`;
517
+ const swatch = setting === "accent";
518
+ const content = swatch ? (active ? "✓" : "") : setting === "position" ? corners[value] : this._esc(optionLabel);
519
+ return `<button type="button" class="rm-setting-choice${swatch ? " rm-color-swatch" : ""}${setting === "position" ? " rm-corner-choice" : ""}" data-setting="${setting}" data-value="${this._esc(this._toolbarSettingDisplayValue(value))}" aria-label="${this._esc(optionLabel)}" title="${this._esc(optionLabel)}" aria-pressed="${active}"${swatch ? ` style="background:${colors[value]}"` : ""}>${content}</button>`;
503
520
  }).join("");
504
- return `
505
- <div class="rm-settings-group">
506
- <div class="rm-settings-label">${this._esc(this._toolbarSettingLabel(setting))}</div>
507
- <div class="rm-settings-options">${buttons}</div>
508
- </div>
509
- `;
521
+ return `<div class="rm-settings-group"><div class="rm-settings-label">${this._esc(label)}</div><div class="rm-settings-options" role="group" aria-label="${this._esc(label)}">${buttons}</div></div>`;
510
522
  },
511
523
  _toolbarSettingValue(setting, rawValue) {
512
524
  if (setting === "fabVisible" || setting === "enableScreenshots") {
@@ -520,66 +532,107 @@
520
532
  const normalized = this._normalizeToolbarSettings(next);
521
533
  if (JSON.stringify(normalized) === JSON.stringify(this.toolbarSettings)) return;
522
534
  if (!this._saveToolbarSettings(normalized)) return;
535
+ const settingsOpen = this._settingsPanelOpen;
523
536
  this.toolbarSettings = normalized;
524
537
  this.destroy();
525
538
  this.init(this._bootstrapOptions || {});
539
+ if (settingsOpen && this.fabVisible) {
540
+ this._toggleSettings();
541
+ this.root.querySelector(`[data-setting="${setting}"][aria-pressed="true"], [data-setting="${setting}"][role="switch"]`)?.focus();
542
+ }
526
543
  },
527
544
  _toggleSettings() {
528
545
  const panel = document.getElementById("rm-settings-panel");
529
546
  const toggle = document.getElementById("rm-settings-toggle");
530
547
  if (!panel) return;
531
- panel.hidden = !panel.hidden;
532
- this._settingsPanelOpen = !panel.hidden;
533
- if (toggle) toggle.setAttribute("aria-expanded", String(!panel.hidden));
534
- },
535
- async _captureElement(element) {
548
+ this._expandDock();
549
+ const feedback = document.getElementById("rm-panel");
550
+ const opening = panel.hidden || feedback.style.display !== "flex";
551
+ feedback.style.display = opening ? "flex" : "none";
552
+ feedback.dataset.view = opening ? "settings" : "feedback";
553
+ feedback.querySelector("h3").textContent = opening ? "Toolbar settings" : "Feedback";
554
+ panel.hidden = !opening;
555
+ this._settingsPanelOpen = opening;
556
+ document.getElementById("rm-panel-toggle").setAttribute("aria-expanded", "false");
557
+ if (toggle) toggle.setAttribute("aria-expanded", String(opening));
558
+ },
559
+ _resetScreenshotStatus(message = "Optional · capture, upload, or paste an image") {
560
+ document.getElementById("rm-screenshot-status").textContent = message;
561
+ document.getElementById("rm-remove-screenshot").hidden = !this._currentScreenshot;
562
+ },
563
+ _removeScreenshot() {
564
+ this._currentScreenshot = null;
565
+ this.root.querySelector(".rm-drawing-container")?.remove();
566
+ const tool = this.root.querySelector("[data-draw]");
567
+ tool?.parentElement.remove();
568
+ this.drawingCanvas = null;
569
+ this.drawingCtx = null;
570
+ this.drawingHistory = [];
571
+ this._screenshotImg = null;
572
+ document.getElementById("rm-screenshot-file").value = "";
573
+ this._resetScreenshotStatus();
574
+ },
575
+ _useScreenshot(dataUrl) {
576
+ if (document.getElementById("rm-popup").style.display !== "block") return;
577
+ this._removeScreenshot();
578
+ this._currentScreenshot = dataUrl;
579
+ document.getElementById("rm-image-options").open = true;
580
+ this._initDrawing(dataUrl);
581
+ this._resetScreenshotStatus("Image attached · review before submitting");
582
+ },
583
+ async _attachScreenshotFile(file) {
584
+ if (!file) return;
585
+ if (!["image/png", "image/jpeg", "image/webp"].includes(file.type) || file.size > 5 * 1024 * 1024) {
586
+ this._resetScreenshotStatus("Choose a PNG, JPEG, or WebP under 5 MB.");
587
+ return;
588
+ }
589
+ const popup = document.getElementById("rm-popup");
590
+ const reader = new FileReader();
591
+ reader.onload = () => { if (popup === document.getElementById("rm-popup")) this._useScreenshot(reader.result); };
592
+ reader.onerror = () => this._resetScreenshotStatus("Could not read this image. Try another file.");
593
+ reader.readAsDataURL(file);
594
+ },
595
+ async _attachScreen() {
596
+ if (!navigator.mediaDevices?.getDisplayMedia) {
597
+ this._resetScreenshotStatus("Tab capture is unavailable here. Upload or paste an image instead.");
598
+ return;
599
+ }
600
+ let stream;
601
+ let video;
602
+ let frameTimeout;
603
+ const popup = document.getElementById("rm-popup");
604
+ const button = document.getElementById("rm-attach-screen");
605
+ button.disabled = true;
606
+ this._resetScreenshotStatus("Choose a tab or window in the browser prompt. You can also upload an image.");
536
607
  try {
537
- const rect = element.getBoundingClientRect();
538
- const width = Math.min(Math.round(rect.width), 800);
539
- const height = Math.min(Math.round(rect.height), 600);
540
- if (width < 10 || height < 10) return null;
541
-
542
- const clone = element.cloneNode(true);
543
- // Strip scripts and event handlers
544
- clone.querySelectorAll("script").forEach(s => s.remove());
545
- // Remove cross-origin images to avoid tainting the canvas
546
- const origin = location.origin;
547
- clone.querySelectorAll("img").forEach(img => {
548
- try { if (img.src && !img.src.startsWith(origin) && !img.src.startsWith("data:")) img.removeAttribute("src"); } catch {}
549
- });
550
-
551
- const svgNS = "http://www.w3.org/2000/svg";
552
- const svg = `<svg xmlns="${svgNS}" width="${width}" height="${height}">
553
- <foreignObject width="100%" height="100%">
554
- <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:14px;">${clone.outerHTML}</div>
555
- </foreignObject>
556
- </svg>`;
557
-
608
+ // Must run directly from the user's click to retain browser activation.
609
+ stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false, preferCurrentTab: true });
610
+ video = document.createElement("video");
611
+ video.srcObject = stream;
612
+ video.muted = true;
613
+ await Promise.race([
614
+ video.play(),
615
+ new Promise((_, reject) => { frameTimeout = setTimeout(() => reject(new Error("Capture timed out")), 8000); })
616
+ ]);
617
+ if (popup !== document.getElementById("rm-popup") || popup.style.display !== "block") return;
618
+ this._removeHighlight();
619
+ this.root.style.visibility = "hidden";
620
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
621
+ const scale = Math.min(1, 1600 / Math.max(video.videoWidth, video.videoHeight));
558
622
  const canvas = document.createElement("canvas");
559
- const scale = Math.min(window.devicePixelRatio || 1, 2);
560
- canvas.width = width * scale;
561
- canvas.height = height * scale;
562
- const ctx = canvas.getContext("2d");
563
- ctx.scale(scale, scale);
564
-
565
- const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
566
- const url = URL.createObjectURL(blob);
567
-
568
- return new Promise((resolve) => {
569
- const img = new Image();
570
- img.onload = () => {
571
- ctx.drawImage(img, 0, 0, width, height);
572
- URL.revokeObjectURL(url);
573
- try { resolve(canvas.toDataURL("image/png", 0.7)); } catch { resolve(null); }
574
- };
575
- img.onerror = () => {
576
- URL.revokeObjectURL(url);
577
- resolve(null);
578
- };
579
- img.src = url;
580
- });
581
- } catch {
582
- return null;
623
+ canvas.width = Math.round(video.videoWidth * scale);
624
+ canvas.height = Math.round(video.videoHeight * scale);
625
+ if (!canvas.width || !canvas.height) throw new Error("No video frame");
626
+ canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
627
+ this._useScreenshot(canvas.toDataURL("image/png"));
628
+ } catch (error) {
629
+ this._resetScreenshotStatus(error.name === "NotAllowedError" ? "Capture cancelled. You can upload or paste an image." : "Could not capture this tab. Upload or paste an image instead.");
630
+ } finally {
631
+ clearTimeout(frameTimeout);
632
+ stream?.getTracks().forEach(track => track.stop());
633
+ if (video) video.srcObject = null;
634
+ this.root.style.visibility = "";
635
+ button.disabled = false;
583
636
  }
584
637
  },
585
638
  _initDrawing(screenshotDataUrl) {
@@ -593,7 +646,7 @@
593
646
 
594
647
  const img = new Image();
595
648
  img.src = screenshotDataUrl;
596
- img.style.cssText = "display:block;max-width:100%;border-radius:8px;";
649
+ img.style.cssText = "display:block;width:100%;height:auto;border-radius:8px;";
597
650
  container.appendChild(img);
598
651
 
599
652
  const canvas = document.createElement("canvas");
@@ -602,6 +655,7 @@
602
655
 
603
656
  // Tool buttons
604
657
  const tools = document.createElement("div");
658
+ tools.className = "rm-drawing-tools";
605
659
  tools.style.cssText = "display:flex;gap:4px;padding:6px 0;";
606
660
  tools.innerHTML = `
607
661
  <button type="button" data-draw="arrow" class="rm-pill" style="font-size:11px;padding:4px 10px;">Arrow</button>
@@ -614,6 +668,11 @@
614
668
  popup.insertBefore(tools, textarea);
615
669
  popup.insertBefore(container, tools);
616
670
 
671
+ this._resetScreenshotStatus("Image attached · review before submitting");
672
+ img.onerror = () => {
673
+ this._removeScreenshot();
674
+ this._resetScreenshotStatus("Could not open this image. Try another file.");
675
+ };
617
676
  img.onload = () => {
618
677
  canvas.width = img.naturalWidth || img.offsetWidth;
619
678
  canvas.height = img.naturalHeight || img.offsetHeight;
@@ -44,11 +44,24 @@
44
44
  this._boundOnline = () => this._onOnline();
45
45
  window.addEventListener("online", this._boundOnline);
46
46
  }
47
+ if (!this._boundSettingsStorage) {
48
+ this._boundSettingsStorage = event => {
49
+ if (event.key !== this._toolbarSettingsKey()) return;
50
+ const options = this._bootstrapOptions;
51
+ this.destroy();
52
+ this.init(options);
53
+ };
54
+ window.addEventListener("storage", this._boundSettingsStorage);
55
+ }
47
56
  this._renderPins();
48
57
  this._updateCount();
49
58
  if (previousPageUrl && previousPageUrl !== this._currentPageUrl && this.serverOnline) this._initSession();
50
59
  },
51
60
  destroy() {
61
+ if (this._boundSettingsStorage) {
62
+ window.removeEventListener("storage", this._boundSettingsStorage);
63
+ this._boundSettingsStorage = null;
64
+ }
52
65
  this._closeAllMenus();
53
66
  this._deactivateMode();
54
67
  if (this.sseSource) { this.sseSource.close(); this.sseSource = null; }
@@ -6,132 +6,23 @@
6
6
  <%= csrf_meta_tags %>
7
7
  <title><%= content_for?(:title) ? "#{yield(:title)} — Rails Markup" : "Rails Markup" %></title>
8
8
  <style>
9
- /* Reset */
10
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f8f9fa; color: #1a1a2e; line-height: 1.5; }
12
- a { color: #4361ee; text-decoration: none; }
9
+ *, *::before, *::after { box-sizing: border-box; }
10
+ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #fafbfc; color: #25272d; line-height: 1.55; }
11
+ a { color: #4f46e5; text-decoration: none; }
13
12
  a:hover { text-decoration: underline; }
14
-
15
- /* Layout */
16
- .rm-header { background: #1a1a2e; color: #fff; padding: 0 24px; height: 56px; display: flex; align-items: center; justify-content: space-between; }
17
- .rm-header h1 { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; }
18
- .rm-header h1 span { color: #818cf8; }
19
- .rm-header nav { display: flex; gap: 16px; font-size: 13px; }
20
- .rm-header nav a { color: #a5b4fc; }
21
- .rm-header nav a:hover { color: #fff; text-decoration: none; }
22
- .rm-container { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
23
-
24
- /* Flash */
25
- .rm-flash { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; }
13
+ .rm-header { background: #fff; color: #25272d; min-height: 52px; border-bottom:1px solid #e8ebef; padding: 8px max(24px, calc((100vw - 1080px) / 2)); display: flex; align-items: center; justify-content: space-between; gap: 16px; }
14
+ .rm-header h1 { margin: 0; font-size: 14px; font-weight: 650; letter-spacing: -.025em; }
15
+ .rm-header h1 span { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 7px; background: #4f46e5; color: white; margin-right: 8px; }
16
+ .rm-header nav { display: flex; gap: 20px; flex-wrap: wrap; font-size: 13px; }
17
+ .rm-header nav a { color: #64748b; padding: 6px 0; }
18
+ .rm-header nav a:hover { color: #25272d; }
19
+ .rm-container { max-width: 1128px; margin: 0 auto; padding: 28px 24px; }
20
+ .rm-flash { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; }
26
21
  .rm-flash-notice { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
27
22
  .rm-flash-alert { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
28
-
29
- /* Stats bar */
30
- .rm-stats { display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; }
31
- .rm-stat { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 16px 20px; flex: 1; min-width: 120px; }
32
- .rm-stat-value { font-size: 28px; font-weight: 700; line-height: 1; }
33
- .rm-stat-label { font-size: 12px; color: #6b7280; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
34
-
35
- /* Filters */
36
- .rm-filters { display: flex; gap: 8px; margin-bottom: 20px; align-items: center; flex-wrap: wrap; }
37
- .rm-pill { display: inline-flex; align-items: center; padding: 6px 14px; border-radius: 20px; font-size: 12px; font-weight: 500; cursor: pointer; border: 1px solid #e5e7eb; background: #fff; color: #6b7280; transition: all 0.15s; }
38
- .rm-pill:hover { border-color: #818cf8; color: #4361ee; text-decoration: none; }
39
- .rm-pill-active { background: #4361ee; color: #fff; border-color: #4361ee; }
40
- .rm-pill-active:hover { background: #3730a3; color: #fff; }
41
- .rm-select { padding: 6px 12px; border-radius: 8px; border: 1px solid #e5e7eb; font-size: 12px; background: #fff; color: #374151; cursor: pointer; max-width: 240px; }
42
-
43
- /* Cards */
44
- .rm-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 16px 20px; margin-bottom: 10px; transition: border-color 0.15s; position: relative; }
45
- .rm-card:hover { border-color: #818cf8; }
46
- .rm-card-link { display: block; color: inherit; }
47
- .rm-card-link:hover { text-decoration: none; }
48
- .rm-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
49
- .rm-card-content { font-size: 14px; color: #374151; line-height: 1.6; }
50
- .rm-card-meta { display: flex; align-items: center; gap: 12px; margin-top: 10px; font-size: 11px; color: #9ca3af; }
51
- .rm-card-meta code { font-size: 11px; background: #f3f4f6; padding: 2px 6px; border-radius: 4px; color: #6b7280; }
52
-
53
- /* Badges */
54
- .rm-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; text-transform: capitalize; }
55
- .rm-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
56
- .rm-status-pending { background: #fef3c7; color: #92400e; }
57
- .rm-status-acknowledged { background: #dbeafe; color: #1e40af; }
58
- .rm-status-resolved { background: #d1fae5; color: #065f46; }
59
- .rm-status-dismissed { background: #f3f4f6; color: #6b7280; }
60
- .rm-dot-pending { background: #f59e0b; }
61
- .rm-dot-acknowledged { background: #3b82f6; }
62
- .rm-dot-resolved { background: #10b981; }
63
- .rm-dot-dismissed { background: #9ca3af; }
64
- .rm-intent-fix { background: #fef2f2; color: #991b1b; }
65
- .rm-intent-change { background: #eff6ff; color: #1e40af; }
66
- .rm-intent-question { background: #f5f3ff; color: #5b21b6; }
67
- .rm-intent-approve { background: #ecfdf5; color: #065f46; }
68
- .rm-severity-blocking { background: #fef2f2; color: #991b1b; font-weight: 700; }
69
- .rm-severity-important { background: #fff7ed; color: #9a3412; }
70
- .rm-severity-suggestion { background: #f3f4f6; color: #6b7280; }
71
-
72
- /* Detail page */
73
- .rm-detail { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 24px; }
74
- .rm-detail-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
75
- .rm-detail-content { font-size: 15px; line-height: 1.7; margin-bottom: 24px; white-space: pre-wrap; }
76
- .rm-detail-meta { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; font-size: 13px; margin-bottom: 24px; padding: 16px; background: #f9fafb; border-radius: 8px; }
77
- .rm-detail-meta dt { color: #6b7280; font-weight: 500; }
78
- .rm-detail-meta dd { color: #374151; }
79
- .rm-detail-meta dd code { font-size: 12px; background: #e5e7eb; padding: 1px 5px; border-radius: 3px; }
80
-
81
- /* Thread */
82
- .rm-thread { margin-top: 24px; border-top: 1px solid #e5e7eb; padding-top: 20px; }
83
- .rm-thread h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #374151; }
84
- .rm-thread-entry { padding: 12px 16px; border-radius: 8px; margin-bottom: 8px; font-size: 13px; line-height: 1.5; }
85
- .rm-thread-agent { background: #eff6ff; border-left: 3px solid #3b82f6; }
86
- .rm-thread-user { background: #f9fafb; border-left: 3px solid #e5e7eb; }
87
- .rm-thread-role { font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
88
- .rm-thread-time { font-size: 11px; color: #9ca3af; margin-top: 4px; }
89
-
90
- /* Actions */
91
- .rm-actions { display: flex; gap: 8px; margin-top: 24px; flex-wrap: wrap; }
92
- .rm-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; border: 1px solid #e5e7eb; background: #fff; color: #374151; cursor: pointer; transition: all 0.15s; }
93
- .rm-btn:hover { border-color: #818cf8; color: #4361ee; text-decoration: none; }
94
- .rm-btn-primary { background: #4361ee; color: #fff; border-color: #4361ee; }
95
- .rm-btn-primary:hover { background: #3730a3; color: #fff; }
96
- .rm-btn-success { background: #10b981; color: #fff; border-color: #10b981; }
97
- .rm-btn-success:hover { background: #059669; color: #fff; }
98
- .rm-btn-danger { background: #ef4444; color: #fff; border-color: #ef4444; }
99
- .rm-btn-danger:hover { background: #dc2626; color: #fff; }
100
-
101
- /* Forms */
102
- .rm-form-group { margin-bottom: 12px; }
103
- .rm-form-group label { display: block; font-size: 12px; font-weight: 500; color: #6b7280; margin-bottom: 4px; }
104
- .rm-input { width: 100%; padding: 8px 12px; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 13px; font-family: inherit; resize: vertical; }
105
- .rm-input:focus { outline: none; border-color: #818cf8; box-shadow: 0 0 0 3px rgba(99,102,241,0.1); }
106
-
107
- /* Back link */
108
- .rm-back { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: #6b7280; margin-bottom: 16px; }
109
- .rm-back:hover { color: #4361ee; text-decoration: none; }
110
-
111
- /* Empty state */
112
- .rm-empty { text-align: center; padding: 48px 24px; color: #9ca3af; }
113
- .rm-empty-icon { font-size: 36px; margin-bottom: 12px; }
114
- .rm-empty-text { font-size: 14px; }
115
-
116
- /* Two-column layout */
117
- .rm-layout { display: flex; gap: 24px; }
118
- .rm-main { flex: 1; min-width: 0; }
119
- .rm-panel { width: 420px; flex-shrink: 0; position: sticky; top: 80px; max-height: calc(100vh - 100px); overflow-y: auto; background: #f8f9fa; }
120
- .rm-panel .rm-detail { border-left: 3px solid #818cf8; }
121
- .rm-card-active { border-color: #818cf8 !important; background: #f5f3ff; }
122
- .rm-panel-empty { display: none; }
123
-
124
- /* Responsive */
125
- @media (max-width: 900px) {
126
- .rm-layout { flex-direction: column; }
127
- .rm-panel { width: 100%; position: static; max-height: none; background: #f8f9fa; }
128
- }
129
- @media (max-width: 640px) {
130
- .rm-stats { flex-direction: column; }
131
- .rm-stat { min-width: unset; }
132
- .rm-filters { flex-direction: column; align-items: stretch; }
133
- .rm-detail-header { flex-direction: column; align-items: flex-start; }
134
- }
23
+ .rm-footer { max-width: 1128px; margin: 0 auto; padding: 0 24px 28px; color: #596579; font-size: 11px; text-align: left; }
24
+ :focus-visible { outline: 3px solid #6366f1; outline-offset: 3px; }
25
+ @media (max-width: 640px) { .rm-header { padding: 12px 16px; } .rm-container { padding: 24px 16px; } .rm-header nav { gap: 12px; } }
135
26
  </style>
136
27
  </head>
137
28
  <body>
@@ -155,5 +46,8 @@
155
46
 
156
47
  <%= yield %>
157
48
  </main>
49
+ <footer class="rm-footer">
50
+ Built with love in Sydney by <a href="https://nauman.one">Nauman Tariq</a> and Pavelabs.
51
+ </footer>
158
52
  </body>
159
53
  </html>
@@ -2,16 +2,11 @@
2
2
  <a href="<%= rails_markup.annotation_path(annotation) %>"
3
3
  data-turbo-frame="detail-panel"
4
4
  class="rm-card-link">
5
- <div class="rm-card-header">
6
- <span class="rm-dot rm-dot-<%= annotation.status %>"></span>
7
- <span class="rm-badge rm-status-<%= annotation.status %>"><%= annotation.status %></span>
8
- <span class="rm-badge rm-intent-<%= annotation.intent %>"><%= annotation.intent %></span>
9
- <% if annotation.severity != "suggestion" %>
10
- <span class="rm-badge rm-severity-<%= annotation.severity %>"><%= annotation.severity %></span>
11
- <% end %>
12
- </div>
13
5
  <div class="rm-card-content"><%= truncate(annotation.content, length: 200) %></div>
14
6
  <div class="rm-card-meta">
7
+ <% unless local_assigns[:board] %><span class="rm-row-status"><span class="rm-dot rm-dot-<%= annotation.status %>"></span><%= annotation.status.capitalize %></span><% end %>
8
+ <% if annotation.intent != "change" %><span class="rm-badge rm-intent-<%= annotation.intent %>"><%= annotation.intent %></span><% end %>
9
+ <% if annotation.severity != "suggestion" %><span class="rm-badge rm-severity-<%= annotation.severity %>"><%= annotation.severity %></span><% end %>
15
10
  <code><%= truncate(annotation.page_url, length: 50) %></code>
16
11
  <% if annotation.author_name.present? %>
17
12
  <span class="rm-author"><%= annotation.author_name %></span>
@@ -20,6 +15,7 @@
20
15
  <% if annotation.thread.any? %>
21
16
  <span><%= annotation.thread.size %> replies</span>
22
17
  <% end %>
18
+ <span class="rm-card-number">#<%= annotation.id %></span>
23
19
  </div>
24
20
  </a>
25
21
  </div>
@@ -47,8 +47,12 @@
47
47
  <% elsif @current_author.present? %>
48
48
  No annotations by <%= @current_author %>.
49
49
  <a href="<%= rails_markup.root_path(status: @current_status, page_url: @current_page_url) %>">Clear filter</a>
50
+ <% elsif @total_count == 0 %>
51
+ <h3 class="rm-empty-title">Your next improvement starts on the page.</h3>
52
+ <p class="rm-empty-help">Open a page in your app, expand the markup toolbar, and select an element to leave feedback. Your annotations will appear here.</p>
50
53
  <% elsif @current_status != "all" %>
51
54
  No <%= @current_status %> annotations.
55
+ <p class="rm-empty-help"><a href="<%= rails_markup.root_path(status: 'all', page_url: @current_page_url) %>">View all feedback</a> or use the toolbar in your app to add a new annotation.</p>
52
56
  <% else %>
53
57
  No annotations yet.<br>Use the toolbar to start annotating.
54
58
  <% end %>
@@ -0,0 +1,4 @@
1
+ <dialog class="rm-feedback-modal" aria-label="Feedback details">
2
+ <turbo-frame id="detail-panel" class="rm-modal-frame"></turbo-frame>
3
+ </dialog>
4
+ <%= render "modal_navigation" %>
@@ -1,37 +1,31 @@
1
- <div class="rm-filters">
1
+ <div class="rm-filters rm-status-tabs" role="group" aria-label="Filter by status">
2
2
  <%
3
3
  statuses = [
4
- ["all", "All"],
5
- ["pending", "Pending"],
6
- ["acknowledged", "Acknowledged"],
7
- ["resolved", "Resolved"],
8
- ["dismissed", "Dismissed"]
4
+ ["all", "All", @total_count],
5
+ ["pending", "Pending", @pending_count],
6
+ ["acknowledged", "Acknowledged", @acknowledged_count],
7
+ ["resolved", "Resolved", @resolved_count],
8
+ ["dismissed", "Dismissed", @dismissed_count]
9
9
  ]
10
10
  %>
11
- <% statuses.each do |value, label| %>
11
+ <% statuses.each do |value, label, count| %>
12
12
  <a href="<%= rails_markup.root_path(status: value, page_url: @current_page_url, q: @current_query, author: @current_author) %>"
13
- class="rm-pill <%= 'rm-pill-active' if @current_status == value %>">
14
- <%= label %>
13
+ class="rm-pill <%= 'rm-pill-active' if @current_status == value %>" <%= 'aria-current=page' if @current_status == value %>>
14
+ <%= label %> <span class="rm-tab-count"><%= count %></span>
15
15
  </a>
16
16
  <% end %>
17
17
 
18
- <% if @current_status.in?(%w[pending acknowledged]) && @annotations.any? %>
19
- <%= form_tag(rails_markup.dismiss_all_path(status: @current_status), method: :post, style: "display:inline", data: { turbo_confirm: "Dismiss all #{@current_status} annotations?" }) do %>
20
- <button type="submit" class="rm-pill" style="color: #ef4444; border-color: #fecaca;">Dismiss all <%= @current_status %></button>
21
- <% end %>
22
- <% end %>
23
-
24
- <div style="margin-left: auto; display: flex; gap: 4px; align-items: center;">
18
+ <div class="rm-filter-tools" role="group" aria-label="View">
25
19
  <a href="<%= rails_markup.root_path(status: @current_status, page_url: @current_page_url, q: @current_query, author: @current_author) %>"
26
20
  class="rm-pill <%= 'rm-pill-active' unless action_name == 'board' %>">List</a>
27
21
  <a href="<%= rails_markup.board_path %>" class="rm-pill <%= 'rm-pill-active' if action_name == 'board' %>">Board</a>
28
22
  </div>
29
23
  </div>
30
24
 
31
- <div class="rm-filters">
25
+ <div class="rm-filters rm-filter-row">
32
26
  <%# Search %>
33
- <form action="<%= rails_markup.root_path %>" method="get" style="display:flex;gap:6px;align-items:center;">
34
- <input type="text" name="q" value="<%= @current_query %>" placeholder="Search annotations..." class="rm-select" style="min-width:180px;">
27
+ <form action="<%= rails_markup.root_path %>" method="get" class="rm-search-form" role="search">
28
+ <input type="search" name="q" value="<%= @current_query %>" placeholder="Search annotations..." aria-label="Search annotations" class="rm-select">
35
29
  <input type="hidden" name="status" value="<%= @current_status %>">
36
30
  <input type="hidden" name="page_url" value="<%= @current_page_url %>">
37
31
  <input type="hidden" name="author" value="<%= @current_author %>">
@@ -41,9 +35,13 @@
41
35
  <% end %>
42
36
  </form>
43
37
 
38
+ <details class="rm-options" <%= 'open' if @current_page_url.present? || @current_author.present? %>>
39
+ <summary>Options<%= ' · filtered' if @current_page_url.present? || @current_author.present? %></summary>
40
+ <div class="rm-options-content">
41
+ <div class="rm-options-heading">Filter feedback</div>
44
42
  <%# Page URL filter %>
45
43
  <% if @page_urls.any? %>
46
- <select class="rm-select" onchange="document.getElementById('annotations-content').src=this.value">
44
+ <select id="rm-page-filter" class="rm-select" aria-label="Filter by page" onchange="window.location=this.value">
47
45
  <option value="<%= rails_markup.root_path(status: @current_status == 'all' ? nil : @current_status, q: @current_query, author: @current_author) %>">All pages</option>
48
46
  <% @page_urls.each do |url| %>
49
47
  <option value="<%= rails_markup.root_path(status: @current_status == 'all' ? nil : @current_status, page_url: url, q: @current_query, author: @current_author) %>"
@@ -56,7 +54,7 @@
56
54
 
57
55
  <%# Author filter %>
58
56
  <% if @authors.any? %>
59
- <select class="rm-select" onchange="window.location=this.value">
57
+ <select class="rm-select" aria-label="Filter by author" onchange="window.location=this.value">
60
58
  <option value="<%= rails_markup.root_path(status: @current_status, page_url: @current_page_url, q: @current_query) %>">All authors</option>
61
59
  <% @authors.each do |author| %>
62
60
  <option value="<%= rails_markup.root_path(status: @current_status, page_url: @current_page_url, q: @current_query, author: author) %>"
@@ -67,9 +65,20 @@
67
65
  </select>
68
66
  <% end %>
69
67
 
68
+ <div class="rm-options-heading">Export</div>
70
69
  <%# Export %>
71
- <div style="margin-left:auto;display:flex;gap:4px;">
70
+ <div class="rm-filter-tools" role="group" aria-label="Export annotations">
72
71
  <a href="<%= rails_markup.export_csv_path(status: @current_status, page_url: @current_page_url, q: @current_query, author: @current_author) %>" class="rm-pill">CSV</a>
73
72
  <a href="<%= rails_markup.export_json_path(status: @current_status, page_url: @current_page_url, q: @current_query, author: @current_author) %>" class="rm-pill">JSON</a>
74
73
  </div>
74
+ <div class="rm-options-heading">Toolbar</div>
75
+ <%= render "toolbar_preferences" %>
76
+ <% if @current_status.in?(%w[pending acknowledged]) && @annotations.any? %>
77
+ <%= form_tag(rails_markup.dismiss_all_path(status: @current_status), method: :post, style: "display:inline", data: { turbo_confirm: "Dismiss all #{@current_status} annotations?" }) do %>
78
+ <button type="submit" class="rm-pill" style="color: #ef4444; border-color: #fecaca;">Dismiss all <%= @current_status %></button>
79
+ <% end %>
80
+ <% end %>
81
+
82
+ </div>
83
+ </details>
75
84
  </div>