context_drop 0.1.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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +337 -0
  4. data/Rakefile +20 -0
  5. data/SPEC.md +532 -0
  6. data/app/assets/javascripts/context_drop/context_drop.js +3508 -0
  7. data/app/assets/stylesheets/context_drop/application.css +2 -0
  8. data/app/controllers/context_drop/application_controller.rb +114 -0
  9. data/app/controllers/context_drop/attachments_controller.rb +31 -0
  10. data/app/controllers/context_drop/configs_controller.rb +20 -0
  11. data/app/controllers/context_drop/dashboard/feedbacks_controller.rb +107 -0
  12. data/app/controllers/context_drop/feedbacks_controller.rb +208 -0
  13. data/app/controllers/context_drop/health_controller.rb +9 -0
  14. data/app/controllers/context_drop/mcp_controller.rb +67 -0
  15. data/app/helpers/context_drop/widget_helper.rb +62 -0
  16. data/app/javascript/context_drop/__tests__/widget.test.ts +319 -0
  17. data/app/javascript/context_drop/api-client.ts +87 -0
  18. data/app/javascript/context_drop/element-picker.ts +1004 -0
  19. data/app/javascript/context_drop/icons.ts +35 -0
  20. data/app/javascript/context_drop/index.ts +11 -0
  21. data/app/javascript/context_drop/sanitizer.ts +454 -0
  22. data/app/javascript/context_drop/screenshot.ts +214 -0
  23. data/app/javascript/context_drop/styles.ts +690 -0
  24. data/app/javascript/context_drop/types.ts +110 -0
  25. data/app/javascript/context_drop/widget.ts +1120 -0
  26. data/app/models/context_drop/application_record.rb +7 -0
  27. data/app/models/context_drop/attachment.rb +30 -0
  28. data/app/models/context_drop/element_reference.rb +47 -0
  29. data/app/models/context_drop/feedback.rb +94 -0
  30. data/app/services/context_drop/metadata_sanitizer.rb +215 -0
  31. data/app/services/context_drop/upload_validator.rb +217 -0
  32. data/app/views/context_drop/dashboard/feedbacks/index.html.erb +86 -0
  33. data/app/views/context_drop/dashboard/feedbacks/show.html.erb +121 -0
  34. data/app/views/layouts/context_drop/application.html.erb +215 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20261109000001_create_context_drop_feedbacks.rb +23 -0
  37. data/db/migrate/20261109000002_create_context_drop_element_references.rb +29 -0
  38. data/db/migrate/20261109000003_create_context_drop_attachments.rb +24 -0
  39. data/lib/context_drop/base64_util.rb +15 -0
  40. data/lib/context_drop/configuration.rb +155 -0
  41. data/lib/context_drop/engine.rb +32 -0
  42. data/lib/context_drop/mcp/dispatcher.rb +197 -0
  43. data/lib/context_drop/mcp/prompts/investigate_feedback.rb +83 -0
  44. data/lib/context_drop/mcp/server.rb +25 -0
  45. data/lib/context_drop/mcp/stdio_runner.rb +62 -0
  46. data/lib/context_drop/mcp/tools/get_feedback.rb +133 -0
  47. data/lib/context_drop/mcp/tools/get_feedback_attachment.rb +71 -0
  48. data/lib/context_drop/mcp/tools/list_feedbacks.rb +127 -0
  49. data/lib/context_drop/mcp/tools/update_feedback_status.rb +72 -0
  50. data/lib/context_drop/version.rb +5 -0
  51. data/lib/context_drop.rb +32 -0
  52. data/lib/generators/context_drop/install/install_generator.rb +52 -0
  53. data/lib/generators/context_drop/install/templates/initializer.rb.tt +55 -0
  54. data/lib/tasks/context_drop_tasks.rake +14 -0
  55. metadata +246 -0
@@ -0,0 +1,214 @@
1
+ import type { BoundingRectSnapshot } from "./types.ts";
2
+ import { buildExcludedSelectorList, isElementExcluded } from "./sanitizer.ts";
3
+
4
+ export interface ScreenshotCaptureResult {
5
+ dataUrl: string;
6
+ blob: Blob;
7
+ width: number;
8
+ height: number;
9
+ redactedCount: number;
10
+ metadata: Record<string, unknown>;
11
+ }
12
+
13
+ export function collectRedactionRects(customExcludedSelectors: string[] = []): BoundingRectSnapshot[] {
14
+ if (typeof document === "undefined") return [];
15
+
16
+ const selectors = buildExcludedSelectorList(customExcludedSelectors);
17
+ const selectorQuery = [
18
+ ...selectors.filter((s) => s !== "context-drop-widget" && s !== "[data-context-drop-ui]"),
19
+ "iframe"
20
+ ].join(", ");
21
+
22
+ const rects: BoundingRectSnapshot[] = [];
23
+ let candidates: NodeListOf<Element>;
24
+ try {
25
+ candidates = document.querySelectorAll(selectorQuery);
26
+ } catch {
27
+ candidates = document.querySelectorAll(
28
+ "[data-feedback-ignore], [data-feedback-sensitive], input[type='password'], input[type='hidden'], iframe"
29
+ );
30
+ }
31
+
32
+ for (let i = 0; i < candidates.length; i += 1) {
33
+ const el = candidates[i];
34
+ if (el.closest("context-drop-widget")) continue;
35
+
36
+ // For iframes, redact cross-origin or excluded iframes
37
+ if (el.tagName.toLowerCase() === "iframe" && !isElementExcluded(el, customExcludedSelectors)) {
38
+ const src = el.getAttribute("src") || "";
39
+ const isCrossOrigin =
40
+ src.startsWith("http://") ||
41
+ src.startsWith("https://") ||
42
+ src.startsWith("//");
43
+ if (!isCrossOrigin) continue;
44
+ }
45
+
46
+ const r = el.getBoundingClientRect();
47
+ if (r.width > 0 && r.height > 0) {
48
+ rects.push({
49
+ x: r.x ?? r.left,
50
+ y: r.y ?? r.top,
51
+ width: r.width,
52
+ height: r.height
53
+ });
54
+ }
55
+ }
56
+
57
+ return rects;
58
+ }
59
+
60
+ function dataUrlToBlob(dataUrl: string): Blob {
61
+ const parts = dataUrl.split(";base64,");
62
+ const mime = (parts[0] || "data:image/png").replace("data:", "");
63
+ const b64 = parts[1] || "";
64
+ const binary = typeof atob === "function" ? atob(b64) : "";
65
+ const bytes = new Uint8Array(binary.length);
66
+ for (let i = 0; i < binary.length; i += 1) {
67
+ bytes[i] = binary.charCodeAt(i);
68
+ }
69
+ return new Blob([bytes], { type: mime });
70
+ }
71
+
72
+ export async function captureViewportScreenshot(
73
+ customExcludedSelectors: string[] = [],
74
+ cropRegion?: BoundingRectSnapshot
75
+ ): Promise<ScreenshotCaptureResult> {
76
+ if (typeof document === "undefined" || typeof window === "undefined") {
77
+ throw new Error("Screenshot capture is only available in a browser environment.");
78
+ }
79
+
80
+ const widgets = Array.from(document.querySelectorAll("context-drop-widget, [data-context-drop-ui]")) as HTMLElement[];
81
+ const previousVisibility = widgets.map((el) => el.style.visibility);
82
+
83
+ try {
84
+ // 1. Hide ContextDrop's own UI during capture
85
+ for (const w of widgets) {
86
+ w.style.visibility = "hidden";
87
+ }
88
+
89
+ const vw = Math.max(window.innerWidth || 1024, 1);
90
+ const vh = Math.max(window.innerHeight || 768, 1);
91
+ const region: BoundingRectSnapshot = cropRegion
92
+ ? {
93
+ x: Math.max(0, Math.min(cropRegion.x, vw - 1)),
94
+ y: Math.max(0, Math.min(cropRegion.y, vh - 1)),
95
+ width: Math.max(1, Math.min(cropRegion.width, vw - Math.max(0, cropRegion.x))),
96
+ height: Math.max(1, Math.min(cropRegion.height, vh - Math.max(0, cropRegion.y)))
97
+ }
98
+ : { x: 0, y: 0, width: vw, height: vh };
99
+
100
+ const redactionRects = collectRedactionRects(customExcludedSelectors);
101
+
102
+ const canvas = document.createElement("canvas");
103
+ canvas.width = Math.round(region.width);
104
+ canvas.height = Math.round(region.height);
105
+ const ctx = canvas.getContext("2d");
106
+
107
+ if (!ctx) {
108
+ throw new Error("Canvas 2D context is unavailable or restricted by browser policy.");
109
+ }
110
+
111
+ // Paint page background
112
+ const bodyBg =
113
+ window.getComputedStyle && document.body
114
+ ? window.getComputedStyle(document.body).backgroundColor
115
+ : "#ffffff";
116
+ ctx.fillStyle = bodyBg && bodyBg !== "rgba(0, 0, 0, 0)" ? bodyBg : "#ffffff";
117
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
118
+
119
+ // Paint visible non-excluded DOM elements approximation onto canvas
120
+ const visibleNodes = document.body ? Array.from(document.body.querySelectorAll("*")) : [];
121
+ for (const node of visibleNodes) {
122
+ if (node.closest("context-drop-widget") || node.hasAttribute("data-context-drop-ui")) continue;
123
+ if (isElementExcluded(node, customExcludedSelectors)) continue;
124
+
125
+ const r = node.getBoundingClientRect();
126
+ if (r.width <= 0 || r.height <= 0) continue;
127
+
128
+ const rx = (r.x ?? r.left) - region.x;
129
+ const ry = (r.y ?? r.top) - region.y;
130
+ if (rx + r.width < 0 || ry + r.height < 0 || rx > region.width || ry > region.height) {
131
+ continue;
132
+ }
133
+
134
+ const style = window.getComputedStyle ? window.getComputedStyle(node) : null;
135
+ if (style) {
136
+ if (style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)" && style.backgroundColor !== "transparent") {
137
+ ctx.fillStyle = style.backgroundColor;
138
+ ctx.fillRect(rx, ry, r.width, r.height);
139
+ }
140
+ if (style.borderWidth && parseFloat(style.borderWidth) > 0) {
141
+ ctx.strokeStyle = style.borderColor || "#cbd5e1";
142
+ ctx.lineWidth = Math.min(parseFloat(style.borderWidth), 4);
143
+ ctx.strokeRect(rx, ry, r.width, r.height);
144
+ }
145
+ }
146
+ }
147
+
148
+ // 2. Enforce pre-capture visual redaction of all sensitive/excluded elements
149
+ for (const red of redactionRects) {
150
+ const rx = red.x - region.x;
151
+ const ry = red.y - region.y;
152
+ if (rx + red.width < 0 || ry + red.height < 0 || rx > region.width || ry > region.height) {
153
+ continue;
154
+ }
155
+ ctx.fillStyle = "#0f172a";
156
+ ctx.fillRect(rx, ry, red.width, red.height);
157
+ ctx.fillStyle = "#94a3b8";
158
+ ctx.font = "11px sans-serif";
159
+ if (red.width >= 60 && red.height >= 16) {
160
+ ctx.fillText("[REDACTED]", rx + 6, ry + Math.min(red.height - 4, 14));
161
+ }
162
+ }
163
+
164
+ const dataUrl = canvas.toDataURL("image/png");
165
+ const blob = dataUrlToBlob(dataUrl);
166
+
167
+ return {
168
+ dataUrl,
169
+ blob,
170
+ width: canvas.width,
171
+ height: canvas.height,
172
+ redactedCount: redactionRects.length,
173
+ metadata: {
174
+ capture_type: cropRegion ? "element" : "viewport",
175
+ image_dimensions: { width: canvas.width, height: canvas.height },
176
+ viewport: { width: vw, height: vh },
177
+ redacted_elements_count: redactionRects.length,
178
+ captured_at: new Date().toISOString()
179
+ }
180
+ };
181
+ } finally {
182
+ // Always restore ContextDrop UI visibility
183
+ widgets.forEach((el, idx) => {
184
+ el.style.visibility = previousVisibility[idx] || "";
185
+ });
186
+ }
187
+ }
188
+
189
+ export async function captureElementScreenshot(
190
+ element: Element,
191
+ padding: number = 16,
192
+ customExcludedSelectors: string[] = [],
193
+ elementClientRef?: string
194
+ ): Promise<ScreenshotCaptureResult> {
195
+ if (isElementExcluded(element, customExcludedSelectors)) {
196
+ throw new Error("Cannot capture screenshot of an excluded or sensitive DOM element.");
197
+ }
198
+
199
+ const r = element.getBoundingClientRect();
200
+ const pad = Math.max(0, padding);
201
+ const cropRegion: BoundingRectSnapshot = {
202
+ x: (r.x ?? r.left) - pad,
203
+ y: (r.y ?? r.top) - pad,
204
+ width: r.width + pad * 2,
205
+ height: r.height + pad * 2
206
+ };
207
+
208
+ const result = await captureViewportScreenshot(customExcludedSelectors, cropRegion);
209
+ result.metadata.capture_type = "element";
210
+ if (elementClientRef) {
211
+ result.metadata.element_attachment_ref = elementClientRef;
212
+ }
213
+ return result;
214
+ }