diff --git a/docs/website/security.mdx b/docs/website/security.mdx
index 6a37ca2..c6c202f 100644
--- a/docs/website/security.mdx
+++ b/docs/website/security.mdx
@@ -78,10 +78,10 @@ BugDrop is built with a privacy-first approach:
### Screenshot masking
-You can mark sensitive elements so BugDrop visually covers them in supported screenshot modes. Add the `data-bugdrop-mask` attribute to any element you want covered:
+You can mark sensitive elements so BugDrop visually covers them in supported screenshot modes. Add `data-bugdrop-redact` or `data-bugdrop-mask` to any element you want covered:
```html
-
+
Customer name
@@ -89,9 +89,15 @@ You can mark sensitive elements so BugDrop visually covers them in supported scr
```
-When a user submits feedback, BugDrop paints an opaque rectangle over each tagged
-element's bounding box on the captured PNG before showing the user the annotator
-preview. The user sees what is masked and can audit it before submitting.
+Supported explicit attributes are `data-bugdrop-redact`, `data-bd-redact`,
+`data-bugdrop-redacted`, and `data-bugdrop-mask`.
+
+When a user submits feedback, BugDrop plans redactions from matching DOM
+elements, then paints an opaque rectangle over each target's measured bounding
+box on supported captured PNGs. In manual screenshot flows, the masked image is
+shown in the annotator preview so the user can audit it before submitting. In
+automatic screenshot mode, BugDrop applies supported masks but submits without
+showing the preview step.
Masking is best-effort visual coverage, not a data-loss-prevention or security
boundary. Users should still review screenshots before submitting when the manual
@@ -108,8 +114,14 @@ prevents gaps from CSS `gap` or non-masked siblings inside a masked container.
**Known limitations:**
-- Elements inside Shadow DOM and cross-origin iframes are not traversed in this
- iteration.
+- Elements inside open Shadow DOM are traversed when the browser exposes the
+ shadow root. Closed Shadow DOM cannot be traversed; mark the host element if
+ the whole custom control should be covered.
+- Iframe contents are not traversed. Mark the iframe element itself if the whole
+ embedded frame should be visually covered.
+- BugDrop does not inspect pixels or text inside canvas, image, video, plugin,
+ or iframe content. Mark the containing DOM element if that entire region should
+ be visually covered.
- Mask rectangles are collected at the start of capture. If the page reflows or reveals
sensitive elements between collection and the moment `html-to-image` finishes
rendering, the mask may not cover the final pixels. Keep masked content stable during
diff --git a/e2e/widget.spec.ts b/e2e/widget.spec.ts
index 92c8ee2..54826fd 100644
--- a/e2e/widget.spec.ts
+++ b/e2e/widget.spec.ts
@@ -2855,6 +2855,39 @@ test.describe('Screenshot Crash Prevention (#67)', () => {
await expect(errorText).not.toBeVisible({ timeout: 3000 });
});
+ test('privacy masking failure shows a dedicated modal and submits without a screenshot', async ({
+ page,
+ }) => {
+ const payloads = await trackFeedbackPayloads(page);
+ // toPng resolves with a data URL that fails Image.onload, so applyMaskToImage
+ // rejects with MaskApplicationError. Use /test/redaction.html so the page has
+ // a [data-bugdrop-redact] element — otherwise applyMaskToImage early-returns
+ // when rects.length === 0 and the failure path never fires.
+ await mockHtmlToImage(
+ page,
+ "function() { return Promise.resolve('data:image/png;base64,not-a-real-image'); }"
+ );
+ await page.goto('/test/redaction.html');
+ await navigateToFullPageCapture(page);
+
+ const host = page.locator('#bugdrop-host');
+ const modalTitle = host.locator('css=.bd-title');
+ await expect(modalTitle).toHaveText('Privacy masking failed', { timeout: 5000 });
+
+ await expect(host.locator('css=.bd-error-message__text')).toContainText(
+ 'Automatic redaction of private fields could not be applied'
+ );
+ // Retry must NOT be offered for masking failures — retrying would fail the
+ // same way and a user might be tempted to send unredacted output.
+ await expect(host.locator('css=[data-action="retry"]')).not.toBeAttached();
+
+ await host.locator('css=[data-action="skip"]').click();
+
+ await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 5000 });
+ expect(payloads).toHaveLength(1);
+ expect(payloads[0].screenshot).toBeNull();
+ });
+
test('retry button on error modal re-attempts capture', async ({ page }) => {
// First call fails, second call succeeds
await mockHtmlToImage(
@@ -3252,7 +3285,7 @@ test.describe('Screenshot Crash Prevention (#67)', () => {
});
test('remembers complex-page skip after element capture failure skip', async ({ page }) => {
- await trackFeedbackSubmissions(page);
+ const payloads = await trackFeedbackPayloads(page);
await mockHtmlToImage(page, "function() { return Promise.reject(new Error('mock failure')); }");
await page.goto('/test/complex-dom.html?nodes=12000');
@@ -3271,6 +3304,8 @@ test.describe('Screenshot Crash Prevention (#67)', () => {
await expect(host.locator('css=.bd-error-message__text')).toBeVisible({ timeout: 5000 });
await host.locator('css=[data-action="skip"]').click();
await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 5000 });
+ expect(payloads).toHaveLength(1);
+ expect((payloads[0].metadata as { elementSelector?: string }).elementSelector).toContain('h1');
await host.locator('css=.bd-close').click();
await host.locator('css=.bd-trigger').click();
@@ -3278,6 +3313,27 @@ test.describe('Screenshot Crash Prevention (#67)', () => {
await expect(host.locator('css=#include-screenshot')).not.toBeChecked();
});
+ test('does not remember complex-page skip after element picker cancellation', async ({
+ page,
+ }) => {
+ const payloads = await trackFeedbackPayloads(page);
+ await page.goto('/test/complex-dom.html?nodes=12000');
+
+ const host = await navigateToScreenshotOptions(page);
+ await host.locator('css=[data-action="element"]').click();
+ await expect(page.locator('#bugdrop-element-picker-tooltip')).toBeVisible({ timeout: 5000 });
+ await page.keyboard.press('Escape');
+
+ await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 5000 });
+ expect(payloads).toHaveLength(1);
+ expect(payloads[0].screenshot).toBeNull();
+
+ await host.locator('css=.bd-close').click();
+ await host.locator('css=.bd-trigger').click();
+ await expect(host.locator('css=#title')).toBeVisible({ timeout: 5000 });
+ await expect(host.locator('css=#include-screenshot')).toBeChecked();
+ });
+
test('shows all buttons on pages below 10k nodes', async ({ page }) => {
await mockHtmlToImage(page, spyToPng());
await page.goto('/test/complex-dom.html?nodes=4000');
@@ -4318,6 +4374,57 @@ test.describe('Screenshot Masking', () => {
expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
});
+ test('full-page capture masks transformed redaction targets', async ({ page }) => {
+ const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(
+ page,
+ '/test/masking-layout-edge.html'
+ );
+
+ const rect = await docRectOf(page, '#transformed-mask');
+ const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio);
+ const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio);
+
+ expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
+ });
+
+ test('full-page capture masks sticky redaction targets after scrolling', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.addEventListener('DOMContentLoaded', () => {
+ requestAnimationFrame(() => window.scrollTo(0, 420));
+ });
+ });
+
+ const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(
+ page,
+ '/test/masking-layout-edge.html'
+ );
+
+ const rect = await docRectOf(page, '#sticky-mask');
+ const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio);
+ const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio);
+
+ expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
+ });
+
+ test('full-page capture masks fixed redaction targets after scrolling', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.addEventListener('DOMContentLoaded', () => {
+ requestAnimationFrame(() => window.scrollTo(0, 420));
+ });
+ });
+
+ const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(
+ page,
+ '/test/masking-layout-edge.html'
+ );
+
+ const rect = await docRectOf(page, '#fixed-mask');
+ const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio);
+ const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio);
+
+ expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
+ });
+
// Walk the element-picker flow and capture the chosen element.
//
// `selector` identifies the element to click in the picker. Because the picker
diff --git a/public/test/masking-layout-edge.html b/public/test/masking-layout-edge.html
new file mode 100644
index 0000000..21af5a3
--- /dev/null
+++ b/public/test/masking-layout-edge.html
@@ -0,0 +1,68 @@
+
+
+
+
+ BugDrop - Masking Layout Edge Cases
+
+
+
+ Masking Layout Edge Cases
+ Fixture used by Playwright for transformed and sticky redaction geometry.
+
+
+ transformed private account id
+
+
+
+
+ sticky private session id
+
+
+
+ fixed private token
+
+
+
+
diff --git a/src/widget/annotation-flow.ts b/src/widget/annotation-flow.ts
new file mode 100644
index 0000000..e521627
--- /dev/null
+++ b/src/widget/annotation-flow.ts
@@ -0,0 +1,89 @@
+import { createAnnotator, type Tool } from './annotator';
+import { createModal, redactionNoteHtml } from './ui';
+
+export function showAnnotationStep(
+ root: HTMLElement,
+ screenshot: string,
+ redactionCount = 0,
+ opts?: { redactionUnavailable?: boolean }
+): Promise {
+ return new Promise(resolve => {
+ let redactionNote = '';
+ if (opts?.redactionUnavailable) {
+ redactionNote = redactionNoteHtml(
+ 'This browser viewport capture could not apply automatic private-field masks. Review and cover any sensitive areas before sending.'
+ );
+ } else if (redactionCount > 0) {
+ redactionNote = redactionNoteHtml(
+ `${redactionCount} private ${redactionCount === 1 ? 'item was' : 'items were'} marked for redaction in this screenshot. Review before sending.`
+ );
+ }
+ const modal = createModal(
+ root,
+ 'Review Screenshot',
+ `
+ ${redactionNote}
+
+ Check that no sensitive information is visible before sending. Cover sensitive areas before submitting. Redactions are baked into the uploaded image.
+
+
+ ✏️ Draw
+ ➡️ Arrow
+ ▢ Rectangle
+ Redact
+ ↶ Undo
+
+
+
+ Retake
+ Submit Feedback
+
+ `,
+ false,
+ 'bd-modal--annotator'
+ );
+
+ const canvasContainer = modal.querySelector('#annotation-canvas') as HTMLElement;
+ const annotator = createAnnotator(canvasContainer, screenshot);
+
+ const toolButtons = modal.querySelectorAll('[data-tool]');
+ toolButtons.forEach(btn => {
+ btn.addEventListener('click', e => {
+ const target = e.currentTarget as HTMLElement;
+ const tool = target.dataset.tool;
+
+ if (tool) {
+ toolButtons.forEach(b => b.classList.remove('active'));
+ target.classList.add('active');
+ annotator.setTool(tool as Tool);
+ }
+ });
+ });
+
+ const undoBtn = modal.querySelector('[data-action="undo"]') as HTMLElement | null;
+ undoBtn?.addEventListener('click', () => annotator.undo());
+
+ const closeBtn = modal.querySelector('.bd-close') as HTMLElement;
+ const retakeBtn = modal.querySelector('[data-action="retake"]') as HTMLElement;
+ const doneBtn = modal.querySelector('[data-action="done"]') as HTMLElement;
+
+ closeBtn?.addEventListener('click', () => {
+ annotator.destroy();
+ modal.remove();
+ resolve('cancel');
+ });
+
+ retakeBtn?.addEventListener('click', () => {
+ annotator.destroy();
+ modal.remove();
+ resolve('retake');
+ });
+
+ doneBtn?.addEventListener('click', () => {
+ const annotated = annotator.getImageData();
+ annotator.destroy();
+ modal.remove();
+ resolve(annotated);
+ });
+ });
+}
diff --git a/src/widget/capture-flow.ts b/src/widget/capture-flow.ts
new file mode 100644
index 0000000..a659d45
--- /dev/null
+++ b/src/widget/capture-flow.ts
@@ -0,0 +1,314 @@
+import { createAreaPicker } from './area-picker';
+import { showAnnotationStep } from './annotation-flow';
+import {
+ captureAreaWithLoading,
+ capturePromiseWithLoading,
+ captureWithLoading,
+} from './capture-loading';
+import { createElementPicker } from './picker';
+import { beginViewportCapture, getRedactionCount, isFullPageDisabled } from './screenshot';
+import { showScreenshotOptions, type ScreenshotChoice } from './screenshot-options';
+
+export interface CaptureFlowConfig {
+ screenshotMode: 'optional' | 'auto' | 'required';
+ screenshotScale?: number;
+ accentColor?: string;
+ font?: string;
+ radius?: string;
+ borderWidth?: string;
+ bgColor?: string;
+ textColor?: string;
+ borderColor?: string;
+ theme: 'light' | 'dark' | 'auto';
+}
+
+export interface CaptureFlowResult {
+ screenshot: string | null;
+ elementSelector: string | null;
+ returnToForm: boolean;
+}
+
+export type EmptyCaptureReason =
+ | 'none'
+ | 'explicit-skip'
+ | 'capture-failure-skip'
+ | 'selection-cancelled';
+
+type ChosenCaptureResult =
+ | {
+ kind: 'captured';
+ screenshot: string;
+ elementSelector: string | null;
+ redactionCount: number;
+ redactionUnavailable: boolean;
+ }
+ | { kind: 'returnToForm' }
+ | { kind: 'empty'; reason: EmptyCaptureReason; elementSelector: string | null };
+
+export async function runScreenshotCaptureFlow(
+ root: HTMLElement,
+ config: CaptureFlowConfig,
+ includeScreenshot: boolean,
+ onComplexScreenshotSkipped: () => void
+): Promise {
+ if (config.screenshotMode === 'auto') {
+ return captureAutomaticScreenshot(root, config);
+ }
+
+ if (!includeScreenshot) {
+ return emptyCaptureResult();
+ }
+
+ const screenshotRequired = config.screenshotMode === 'required';
+ while (true) {
+ const result = await captureChosenScreenshot(root, config, screenshotRequired);
+ if (result.kind === 'returnToForm') {
+ return { ...emptyCaptureResult(), returnToForm: true };
+ }
+
+ if (result.kind === 'empty') {
+ if (!screenshotRequired && shouldRememberComplexScreenshotSkip(result.reason)) {
+ onComplexScreenshotSkipped();
+ }
+ if (screenshotRequired) continue;
+ return {
+ screenshot: null,
+ elementSelector: result.elementSelector,
+ returnToForm: false,
+ };
+ }
+
+ const annotatedScreenshot = await showAnnotationStep(
+ root,
+ result.screenshot,
+ result.redactionCount,
+ {
+ redactionUnavailable: result.redactionUnavailable,
+ }
+ );
+
+ if (annotatedScreenshot === 'retake') continue;
+ if (annotatedScreenshot === 'cancel') {
+ return { ...emptyCaptureResult(), returnToForm: true };
+ }
+
+ return {
+ screenshot: annotatedScreenshot,
+ elementSelector: result.elementSelector,
+ returnToForm: false,
+ };
+ }
+}
+
+async function captureAutomaticScreenshot(
+ root: HTMLElement,
+ config: CaptureFlowConfig
+): Promise {
+ if (isFullPageDisabled()) {
+ return emptyCaptureResult();
+ }
+
+ const result = await captureWithLoading(root, undefined, config.screenshotScale);
+ if (result.kind === 'cancelled') {
+ return { ...emptyCaptureResult(), returnToForm: true };
+ }
+
+ return {
+ screenshot: result.kind === 'ok' ? result.dataUrl : null,
+ elementSelector: null,
+ returnToForm: false,
+ };
+}
+
+export function shouldRememberComplexScreenshotSkip(reason: EmptyCaptureReason): boolean {
+ return reason === 'explicit-skip' || reason === 'capture-failure-skip';
+}
+
+async function captureChosenScreenshot(
+ root: HTMLElement,
+ config: CaptureFlowConfig,
+ screenshotRequired: boolean
+): Promise {
+ const screenshotChoice = await showScreenshotOptions(root, {
+ allowSkip: !screenshotRequired,
+ });
+
+ switch (screenshotChoice.kind) {
+ case 'cancel':
+ return { kind: 'returnToForm' };
+ case 'skip':
+ return { kind: 'empty', reason: 'explicit-skip', elementSelector: null };
+ case 'viewport':
+ return captureFromViewportChoice(root, screenshotChoice, screenshotRequired);
+ case 'capture':
+ return captureFromFullPageChoice(root, config, screenshotRequired);
+ case 'element':
+ return captureFromElementChoice(root, config, screenshotRequired);
+ case 'area':
+ return captureFromAreaChoice(root, config, screenshotRequired);
+ default:
+ return assertNever(screenshotChoice);
+ }
+}
+
+async function captureFromViewportChoice(
+ root: HTMLElement,
+ choice: Extract,
+ screenshotRequired: boolean
+): Promise {
+ const result = await capturePromiseWithLoading(
+ root,
+ choice.capture,
+ () => beginViewportCapture(),
+ {
+ allowSkip: !screenshotRequired,
+ showLoading: false,
+ }
+ );
+ if (result.kind === 'cancelled') return { kind: 'returnToForm' };
+ if (result.kind === 'skipped') {
+ return { kind: 'empty', reason: 'capture-failure-skip', elementSelector: null };
+ }
+ return {
+ kind: 'captured',
+ screenshot: result.dataUrl,
+ elementSelector: null,
+ redactionCount: 0,
+ redactionUnavailable: true,
+ };
+}
+
+async function captureFromFullPageChoice(
+ root: HTMLElement,
+ config: CaptureFlowConfig,
+ screenshotRequired: boolean
+): Promise {
+ const result = await captureWithLoading(root, undefined, config.screenshotScale, {
+ allowSkip: !screenshotRequired,
+ });
+ if (result.kind === 'cancelled') return { kind: 'returnToForm' };
+ if (result.kind === 'skipped') {
+ return { kind: 'empty', reason: 'capture-failure-skip', elementSelector: null };
+ }
+ return {
+ kind: 'captured',
+ screenshot: result.dataUrl,
+ elementSelector: null,
+ redactionCount: getRedactionCount(),
+ redactionUnavailable: false,
+ };
+}
+
+async function captureFromElementChoice(
+ root: HTMLElement,
+ config: CaptureFlowConfig,
+ screenshotRequired: boolean
+): Promise {
+ const element = await createElementPicker(getPickerStyle(config));
+ if (!element) {
+ return { kind: 'empty', reason: 'selection-cancelled', elementSelector: null };
+ }
+
+ const elementSelector = getElementSelector(element);
+ const result = await captureWithLoading(root, element, config.screenshotScale, {
+ allowSkip: !screenshotRequired,
+ });
+ if (result.kind === 'cancelled') return { kind: 'returnToForm' };
+ if (result.kind === 'skipped') {
+ return { kind: 'empty', reason: 'capture-failure-skip', elementSelector };
+ }
+ return {
+ kind: 'captured',
+ screenshot: result.dataUrl,
+ elementSelector,
+ redactionCount: getRedactionCount(element),
+ redactionUnavailable: false,
+ };
+}
+
+async function captureFromAreaChoice(
+ root: HTMLElement,
+ config: CaptureFlowConfig,
+ screenshotRequired: boolean
+): Promise {
+ const rect = await createAreaPicker(getPickerStyle(config), {
+ redactionsAvailable: getRedactionCount() > 0,
+ });
+ if (!rect) {
+ return { kind: 'empty', reason: 'selection-cancelled', elementSelector: null };
+ }
+
+ const result = await captureAreaWithLoading(root, rect, config.screenshotScale, {
+ allowSkip: !screenshotRequired,
+ });
+ if (result.kind === 'cancelled') return { kind: 'returnToForm' };
+ if (result.kind === 'skipped') {
+ return { kind: 'empty', reason: 'capture-failure-skip', elementSelector: null };
+ }
+ return {
+ kind: 'captured',
+ screenshot: result.dataUrl,
+ elementSelector: null,
+ redactionCount: getRedactionCount(undefined, rect),
+ redactionUnavailable: false,
+ };
+}
+
+function getPickerStyle(config: CaptureFlowConfig) {
+ return {
+ accentColor: config.accentColor,
+ font: config.font,
+ radius: config.radius,
+ borderWidth: config.borderWidth,
+ bgColor: config.bgColor,
+ textColor: config.textColor,
+ borderColor: config.borderColor,
+ theme: config.theme,
+ };
+}
+
+function emptyCaptureResult(): CaptureFlowResult {
+ return {
+ screenshot: null,
+ elementSelector: null,
+ returnToForm: false,
+ };
+}
+
+function assertNever(value: never): never {
+ throw new Error(`Unhandled screenshot choice: ${JSON.stringify(value)}`);
+}
+
+function getElementSelector(element: Element): string {
+ const path: string[] = [];
+ let current: Element | null = element;
+
+ while (current && current !== document.body) {
+ let selector = current.tagName.toLowerCase();
+
+ if (current.id) {
+ selector = `#${current.id}`;
+ path.unshift(selector);
+ break;
+ }
+
+ if (current.className) {
+ const classNameStr =
+ typeof current.className === 'string'
+ ? current.className
+ : (current.className as SVGAnimatedString).baseVal || '';
+ const classes = classNameStr
+ .split(' ')
+ .filter(c => c)
+ .slice(0, 2);
+ if (classes.length) {
+ selector += `.${classes.join('.')}`;
+ }
+ }
+
+ path.unshift(selector);
+ current = current.parentElement;
+ }
+
+ return path.join(' > ');
+}
diff --git a/src/widget/capture-loading.ts b/src/widget/capture-loading.ts
new file mode 100644
index 0000000..9b47403
--- /dev/null
+++ b/src/widget/capture-loading.ts
@@ -0,0 +1,145 @@
+import { MaskApplicationError } from './mask';
+import { captureAreaScreenshot, captureScreenshot } from './screenshot';
+import { createModal } from './ui';
+
+export type CaptureWithLoadingResult =
+ | { kind: 'ok'; dataUrl: string }
+ | { kind: 'skipped' }
+ | { kind: 'cancelled' };
+
+export async function captureWithLoading(
+ root: HTMLElement,
+ element?: Element,
+ screenshotScale?: number,
+ opts?: { allowSkip?: boolean }
+): Promise {
+ return capturePromiseWithLoading(
+ root,
+ captureScreenshot(element, screenshotScale),
+ () => captureScreenshot(element, screenshotScale),
+ opts
+ );
+}
+
+export async function captureAreaWithLoading(
+ root: HTMLElement,
+ rect: DOMRect,
+ screenshotScale?: number,
+ opts?: { allowSkip?: boolean }
+): Promise {
+ return capturePromiseWithLoading(
+ root,
+ captureAreaScreenshot(rect, screenshotScale),
+ () => captureAreaScreenshot(rect, screenshotScale),
+ opts
+ );
+}
+
+export async function capturePromiseWithLoading(
+ root: HTMLElement,
+ capturePromise: Promise,
+ retryCapture: () => Promise,
+ opts?: { allowSkip?: boolean; showLoading?: boolean }
+): Promise {
+ const loadingModal =
+ opts?.showLoading === false
+ ? null
+ : createModal(
+ root,
+ 'Capturing...',
+ `
+
+
+
Capturing screenshot...
+
+ `
+ );
+
+ try {
+ const screenshot = await capturePromise;
+ loadingModal?.remove();
+ return { kind: 'ok', dataUrl: screenshot };
+ } catch (error) {
+ console.warn('[BugDrop] Screenshot capture failed:', error);
+ loadingModal?.remove();
+ const allowSkip = opts?.allowSkip !== false;
+
+ if (error instanceof MaskApplicationError) {
+ return showMaskFailureModal(root);
+ }
+
+ return new Promise(resolve => {
+ const errorModal = createModal(
+ root,
+ 'Capture Failed',
+ `
+
+
+
+
+
Failed to capture screenshot. The page may be too complex or browser restrictions may apply.
+
+
+ ${allowSkip ? 'Skip Screenshot' : 'Choose Another Method'}
+ Try Again
+
+ `,
+ true
+ );
+
+ const closeBtn = errorModal.querySelector('.bd-close') as HTMLElement;
+ const skipBtn = errorModal.querySelector('[data-action="skip"]') as HTMLElement;
+ const retryBtn = errorModal.querySelector('[data-action="retry"]') as HTMLElement;
+
+ closeBtn?.addEventListener('click', () => {
+ errorModal.remove();
+ resolve({ kind: 'cancelled' });
+ });
+
+ skipBtn?.addEventListener('click', () => {
+ errorModal.remove();
+ resolve({ kind: 'skipped' });
+ });
+
+ retryBtn?.addEventListener('click', async () => {
+ errorModal.remove();
+ const result = await capturePromiseWithLoading(root, retryCapture(), retryCapture, opts);
+ resolve(result);
+ });
+ });
+ }
+}
+
+function showMaskFailureModal(root: HTMLElement): Promise {
+ return new Promise(resolve => {
+ const modal = createModal(
+ root,
+ 'Privacy masking failed',
+ `
+
+
+
+
+
Automatic redaction of private fields could not be applied. To protect your data, this screenshot was discarded. You can still submit feedback without one.
+
+
+ Continue without screenshot
+
+ `,
+ true
+ );
+
+ const closeBtn = modal.querySelector('.bd-close') as HTMLElement;
+ const skipBtn = modal.querySelector('[data-action="skip"]') as HTMLElement;
+
+ closeBtn?.addEventListener('click', () => {
+ modal.remove();
+ resolve({ kind: 'cancelled' });
+ });
+
+ skipBtn?.addEventListener('click', () => {
+ modal.remove();
+ resolve({ kind: 'skipped' });
+ });
+ });
+}
diff --git a/src/widget/index.ts b/src/widget/index.ts
index b8a336d..ded6327 100644
--- a/src/widget/index.ts
+++ b/src/widget/index.ts
@@ -1,16 +1,10 @@
import {
- beginViewportCapture,
- canCaptureViewportNatively,
- captureAreaScreenshot,
- captureScreenshot,
FULL_PAGE_DISABLE_THRESHOLD,
getDomNodeCount,
getRedactionCount,
isFullPageDisabled,
} from './screenshot';
-import { createElementPicker } from './picker';
-import { createAreaPicker } from './area-picker';
-import { createAnnotator, type Tool } from './annotator';
+import { runScreenshotCaptureFlow } from './capture-flow';
import { injectStyles, createModal, showSuccessModal } from './ui';
import {
resolveTheme,
@@ -92,14 +86,6 @@ interface FeedbackData {
email?: string;
}
-type ScreenshotChoice =
- | 'skip'
- | 'capture'
- | 'element'
- | 'area'
- | 'cancel'
- | { type: 'viewport'; capture: Promise };
-
// localStorage key for dismissed state
const BUGDROP_DISMISSED_KEY = 'bugdrop_dismissed';
const BUGDROP_WELCOMED_PREFIX = 'bugdrop_welcomed_';
@@ -760,134 +746,15 @@ async function openFeedbackFlow(
return;
}
- let screenshot: string | null = null;
- let elementSelector: string | null = null;
- let redactionCount = 0;
- let redactionUnavailable = false;
- let returnToForm = false;
-
- // Step 3: Screenshot flow (if configured/user opted in)
- if (config.screenshotMode === 'auto') {
- if (!isFullPageDisabled()) {
- const result = await captureWithLoading(root, undefined, config.screenshotScale);
- if (result === 'cancel') {
- returnToForm = true;
- } else {
- screenshot = result;
- redactionCount = screenshot ? getRedactionCount() : 0;
- }
- }
- } else if (formResult.includeScreenshot) {
- const screenshotRequired = config.screenshotMode === 'required';
- while (true) {
- screenshot = null;
- elementSelector = null;
- redactionCount = 0;
- redactionUnavailable = false;
-
- const screenshotChoice = await showScreenshotOptions(root, {
- allowSkip: !screenshotRequired,
- });
- if (screenshotChoice === 'cancel') {
- returnToForm = true;
- break;
- }
- if (screenshotChoice === 'skip') {
- rememberComplexScreenshotSkip(config, formResult);
- break;
- }
-
- const pickerStyle = {
- accentColor: config.accentColor,
- font: config.font,
- radius: config.radius,
- borderWidth: config.borderWidth,
- bgColor: config.bgColor,
- textColor: config.textColor,
- borderColor: config.borderColor,
- theme: config.theme,
- };
-
- if (typeof screenshotChoice === 'object' && screenshotChoice.type === 'viewport') {
- const result = await capturePromiseWithLoading(
- root,
- screenshotChoice.capture,
- () => beginViewportCapture(),
- {
- allowSkip: !screenshotRequired,
- showLoading: false,
- }
- );
- if (result === 'cancel') {
- returnToForm = true;
- break;
- }
- if (!result && !screenshotRequired) rememberComplexScreenshotSkip(config, formResult);
- screenshot = result;
- redactionUnavailable = Boolean(screenshot);
- redactionCount = 0;
- } else if (screenshotChoice === 'capture') {
- const result = await captureWithLoading(root, undefined, config.screenshotScale, {
- allowSkip: !screenshotRequired,
- });
- if (result === 'cancel') {
- returnToForm = true;
- break;
- }
- if (!result) rememberComplexScreenshotSkip(config, formResult);
- screenshot = result;
- redactionCount = screenshot ? getRedactionCount() : 0;
- } else if (screenshotChoice === 'element') {
- const element = await createElementPicker(pickerStyle);
- if (element) {
- const result = await captureWithLoading(root, element, config.screenshotScale, {
- allowSkip: !screenshotRequired,
- });
- if (result === 'cancel') {
- returnToForm = true;
- break;
- }
- if (!result) rememberComplexScreenshotSkip(config, formResult);
- screenshot = result;
- redactionCount = screenshot ? getRedactionCount(element) : 0;
- elementSelector = getElementSelector(element);
- }
- } else if (screenshotChoice === 'area') {
- const rect = await createAreaPicker(pickerStyle, {
- redactionsAvailable: getRedactionCount() > 0,
- });
- if (rect) {
- const result = await captureAreaWithLoading(root, rect, config.screenshotScale, {
- allowSkip: !screenshotRequired,
- });
- if (result === 'cancel') {
- returnToForm = true;
- break;
- }
- if (!result) rememberComplexScreenshotSkip(config, formResult);
- screenshot = result;
- redactionCount = screenshot ? getRedactionCount(undefined, rect) : 0;
- }
- }
-
- // Step 4: Annotate (if screenshot exists)
- if (screenshot) {
- const result = await showAnnotationStep(root, screenshot, redactionCount, {
- redactionUnavailable,
- });
- if (result === 'retake') continue;
- if (result === 'cancel') {
- returnToForm = true;
- break;
- }
- screenshot = result;
- }
- if (screenshotRequired && !screenshot) continue;
- break;
- }
- }
+ const submittedFormResult = formResult;
+ const screenshotResult = await runScreenshotCaptureFlow(
+ root,
+ config,
+ submittedFormResult.includeScreenshot,
+ () => rememberComplexScreenshotSkip(config, submittedFormResult)
+ );
- if (returnToForm) continue;
+ if (screenshotResult.returnToForm) continue;
// Submit
await submitFeedback(root, config, {
@@ -896,8 +763,8 @@ async function openFeedbackFlow(
category: formResult.category,
name: formResult.name,
email: formResult.email,
- screenshot,
- elementSelector,
+ screenshot: screenshotResult.screenshot,
+ elementSelector: screenshotResult.elementSelector,
});
break;
}
@@ -906,107 +773,6 @@ async function openFeedbackFlow(
_isModalOpen = false;
}
-async function captureWithLoading(
- root: HTMLElement,
- element?: Element,
- screenshotScale?: number,
- opts?: { allowSkip?: boolean }
-): Promise {
- return capturePromiseWithLoading(
- root,
- captureScreenshot(element, screenshotScale),
- () => captureScreenshot(element, screenshotScale),
- opts
- );
-}
-
-async function captureAreaWithLoading(
- root: HTMLElement,
- rect: DOMRect,
- screenshotScale?: number,
- opts?: { allowSkip?: boolean }
-): Promise {
- return capturePromiseWithLoading(
- root,
- captureAreaScreenshot(rect, screenshotScale),
- () => captureAreaScreenshot(rect, screenshotScale),
- opts
- );
-}
-
-async function capturePromiseWithLoading(
- root: HTMLElement,
- capturePromise: Promise,
- retryCapture: () => Promise,
- opts?: { allowSkip?: boolean; showLoading?: boolean }
-): Promise {
- // Show a temporary loading indicator
- const loadingModal =
- opts?.showLoading === false
- ? null
- : createModal(
- root,
- 'Capturing...',
- `
-
-
-
Capturing screenshot...
-
- `
- );
-
- try {
- const screenshot = await capturePromise;
- loadingModal?.remove();
- return screenshot;
- } catch (error) {
- console.warn('[BugDrop] Screenshot capture failed:', error);
- loadingModal?.remove();
- const allowSkip = opts?.allowSkip !== false;
-
- // Show error with retry option
- return new Promise(resolve => {
- const errorModal = createModal(
- root,
- 'Capture Failed',
- `
-
-
-
-
-
Failed to capture screenshot. The page may be too complex or browser restrictions may apply.
-
-
- ${allowSkip ? 'Skip Screenshot' : 'Choose Another Method'}
- Try Again
-
- `,
- true
- );
-
- const closeBtn = errorModal.querySelector('.bd-close') as HTMLElement;
- const skipBtn = errorModal.querySelector('[data-action="skip"]') as HTMLElement;
- const retryBtn = errorModal.querySelector('[data-action="retry"]') as HTMLElement;
-
- closeBtn?.addEventListener('click', () => {
- errorModal.remove();
- resolve('cancel');
- });
-
- skipBtn?.addEventListener('click', () => {
- errorModal.remove();
- resolve(null);
- });
-
- retryBtn?.addEventListener('click', async () => {
- errorModal.remove();
- const result = await capturePromiseWithLoading(root, retryCapture(), retryCapture, opts);
- resolve(result);
- });
- });
- }
-}
-
async function checkInstallation(
config: WidgetConfig
): Promise<{ status: 'installed' | 'not_installed' | 'unreachable'; appName?: string }> {
@@ -1303,170 +1069,6 @@ function escapeHtml(value: string): string {
.replace(/'/g, ''');
}
-function showScreenshotOptions(
- root: HTMLElement,
- opts?: { allowSkip?: boolean }
-): Promise {
- const fullPageDisabled = isFullPageDisabled();
- const nativeViewportAvailable = fullPageDisabled && canCaptureViewportNatively();
- const allowSkip = opts?.allowSkip !== false;
- const redactionNote =
- nativeViewportAvailable && fullPageDisabled
- ? 'Browser viewport capture cannot apply automatic private-field masks. Select Element to preserve automatic masking, or review and cover sensitive areas before sending.
'
- : getRedactionCount() > 0
- ? 'This site marked some fields for redaction. Review the screenshot before sending.
'
- : '';
-
- return new Promise(resolve => {
- const complexNote = fullPageDisabled
- ? `${nativeViewportAvailable ? 'This page is too complex for full-page or area capture. Capture the visible viewport or select a specific element instead.' : 'This page is too complex for full-page or area capture. Select a specific element instead.'}
`
- : '';
- const primaryCaptureButton = fullPageDisabled
- ? nativeViewportAvailable
- ? 'Capture Viewport '
- : ''
- : 'Full Page ';
-
- const modal = createModal(
- root,
- 'Capture Screenshot',
- `
- Choose what to capture:
- ${complexNote}
- ${redactionNote}
-
- ${primaryCaptureButton}
- ${fullPageDisabled ? '' : 'Select Area '}
- Select Element
- ${allowSkip ? 'Skip Screenshot ' : ''}
-
- `
- );
-
- const closeBtn = modal.querySelector('.bd-close') as HTMLElement;
- const skipBtn = modal.querySelector('[data-action="skip"]') as HTMLElement | null;
- const elementBtn = modal.querySelector('[data-action="element"]') as HTMLElement;
- const areaBtn = modal.querySelector('[data-action="area"]') as HTMLElement;
- const captureBtn = modal.querySelector('[data-action="capture"]') as HTMLElement;
- const viewportBtn = modal.querySelector('[data-action="viewport"]') as HTMLElement;
-
- closeBtn?.addEventListener('click', () => {
- modal.remove();
- resolve('cancel');
- });
-
- skipBtn?.addEventListener('click', () => {
- modal.remove();
- resolve('skip');
- });
-
- elementBtn?.addEventListener('click', () => {
- modal.remove();
- resolve('element');
- });
-
- areaBtn?.addEventListener('click', () => {
- modal.remove();
- resolve('area');
- });
-
- captureBtn?.addEventListener('click', () => {
- modal.remove();
- resolve('capture');
- });
-
- viewportBtn?.addEventListener('click', () => {
- modal.remove();
- const capture = beginViewportCapture();
- resolve({ type: 'viewport', capture });
- });
- });
-}
-
-function showAnnotationStep(
- root: HTMLElement,
- screenshot: string,
- redactionCount = 0,
- opts?: { redactionUnavailable?: boolean }
-): Promise {
- return new Promise(resolve => {
- const redactionNote = opts?.redactionUnavailable
- ? `This browser viewport capture could not apply automatic private-field masks. Review and cover any sensitive areas before sending.
`
- : redactionCount > 0
- ? `${redactionCount} private ${redactionCount === 1 ? 'item was' : 'items were'} marked for redaction in this screenshot. Review before sending.
`
- : '';
- const modal = createModal(
- root,
- 'Review Screenshot',
- `
- ${redactionNote}
-
- Check that no sensitive information is visible before sending. Cover sensitive areas before submitting. Redactions are baked into the uploaded image.
-
-
- ✏️ Draw
- ➡️ Arrow
- ▢ Rectangle
- Redact
- ↶ Undo
-
-
-
- Retake
- Submit Feedback
-
- `,
- false,
- 'bd-modal--annotator'
- );
-
- const canvasContainer = modal.querySelector('#annotation-canvas') as HTMLElement;
- const annotator = createAnnotator(canvasContainer, screenshot);
-
- // Tool buttons
- const toolButtons = modal.querySelectorAll('[data-tool]');
- toolButtons.forEach(btn => {
- btn.addEventListener('click', e => {
- const target = e.currentTarget as HTMLElement;
- const tool = target.dataset.tool;
-
- if (tool) {
- toolButtons.forEach(b => b.classList.remove('active'));
- target.classList.add('active');
- annotator.setTool(tool as Tool);
- }
- });
- });
-
- const undoBtn = modal.querySelector('[data-action="undo"]') as HTMLElement | null;
- undoBtn?.addEventListener('click', () => annotator.undo());
-
- // Action buttons
- const closeBtn = modal.querySelector('.bd-close') as HTMLElement;
- const retakeBtn = modal.querySelector('[data-action="retake"]') as HTMLElement;
- const doneBtn = modal.querySelector('[data-action="done"]') as HTMLElement;
-
- closeBtn?.addEventListener('click', () => {
- annotator.destroy();
- modal.remove();
- resolve('cancel');
- });
-
- retakeBtn?.addEventListener('click', () => {
- annotator.destroy();
- modal.remove();
- resolve('retake');
- });
-
- doneBtn?.addEventListener('click', () => {
- const annotated = annotator.getImageData();
- annotator.destroy();
- modal.remove();
- resolve(annotated);
- });
- });
-}
-
async function submitFeedback(root: HTMLElement, config: WidgetConfig, data: FeedbackData) {
// Show submitting modal with loading state
const modal = createModal(
@@ -1582,38 +1184,3 @@ function showSubmitError(
await submitFeedback(root, config, data);
});
}
-
-function getElementSelector(element: Element): string {
- const path: string[] = [];
- let current: Element | null = element;
-
- while (current && current !== document.body) {
- let selector = current.tagName.toLowerCase();
-
- if (current.id) {
- selector = `#${current.id}`;
- path.unshift(selector);
- break;
- }
-
- if (current.className) {
- // Handle SVG elements where className is SVGAnimatedString, not a string
- const classNameStr =
- typeof current.className === 'string'
- ? current.className
- : (current.className as SVGAnimatedString).baseVal || '';
- const classes = classNameStr
- .split(' ')
- .filter(c => c)
- .slice(0, 2);
- if (classes.length) {
- selector += `.${classes.join('.')}`;
- }
- }
-
- path.unshift(selector);
- current = current.parentElement;
- }
-
- return path.join(' > ');
-}
diff --git a/src/widget/mask.ts b/src/widget/mask.ts
index d74b4c6..66fe39c 100644
--- a/src/widget/mask.ts
+++ b/src/widget/mask.ts
@@ -5,40 +5,74 @@ interface MaskRect {
h: number;
}
+type RedactionReason = 'developer-marked' | 'sensitive-input';
+type RedactionStrategy = 'canvas-mask';
+
+interface RedactionTarget {
+ element: Element;
+ rect: MaskRect;
+ reason: RedactionReason;
+ strategy: RedactionStrategy;
+}
+
+interface RedactionPlan {
+ targets: RedactionTarget[];
+}
+
+export class MaskApplicationError extends Error {
+ constructor(message: string, options?: { cause?: unknown }) {
+ super(message, options);
+ this.name = 'MaskApplicationError';
+ }
+}
+
const EXPLICIT_SELECTOR =
'[data-bugdrop-mask], [data-bugdrop-redact], [data-bd-redact], [data-bugdrop-redacted]';
const DEFAULT_SELECTOR =
'input[type="password"], input[autocomplete*="cc-number"], input[autocomplete*="cc-csc"], input[autocomplete*="cc-exp"]';
-function shouldMask(el: Element): boolean {
- return el.matches(EXPLICIT_SELECTOR) || el.matches(DEFAULT_SELECTOR);
+function getRedactionReason(el: Element): RedactionReason | null {
+ if (el.matches(EXPLICIT_SELECTOR)) return 'developer-marked';
+ if (el.matches(DEFAULT_SELECTOR)) return 'sensitive-input';
+ return null;
}
-function pushRect(el: Element, rects: MaskRect[]): void {
+function createTarget(el: Element, reason: RedactionReason): RedactionTarget | null {
const rect = el.getBoundingClientRect();
- if (rect.width === 0 || rect.height === 0) return;
- rects.push({
- x: rect.left + window.scrollX,
- y: rect.top + window.scrollY,
- w: rect.width,
- h: rect.height,
- });
+ if (rect.width === 0 || rect.height === 0) return null;
+ return {
+ element: el,
+ rect: {
+ x: rect.left + window.scrollX,
+ y: rect.top + window.scrollY,
+ w: rect.width,
+ h: rect.height,
+ },
+ reason,
+ strategy: 'canvas-mask',
+ };
}
-export function collectMaskRects(root: Element): MaskRect[] {
- const rects: MaskRect[] = [];
+export function createRedactionPlan(root: Element): RedactionPlan {
+ const targets: RedactionTarget[] = [];
+ const rootReason = getRedactionReason(root);
- if (shouldMask(root)) {
- pushRect(root, rects);
- return rects;
+ if (rootReason) {
+ const rootTarget = createTarget(root, rootReason);
+ return { targets: rootTarget ? [rootTarget] : [] };
}
- walk(root, rects);
- return rects;
+ walk(root, targets);
+ walkOpenShadowRoot(root, targets);
+ return { targets };
+}
+
+export function collectMaskRects(root: Element): MaskRect[] {
+ return createRedactionPlan(root).targets.map(target => target.rect);
}
export function countMaskRects(root: Element = document.body, area?: DOMRect): number {
- const rects = collectMaskRects(root);
+ const rects = createRedactionPlan(root).targets.map(target => target.rect);
if (!area) return rects.length;
return rects.filter(rect => intersects(rect, area)).length;
}
@@ -52,14 +86,33 @@ function intersects(rect: MaskRect, area: DOMRect): boolean {
);
}
-function walk(node: Element, rects: MaskRect[]): void {
+function walk(node: Element, targets: RedactionTarget[]): void {
for (const child of Array.from(node.children)) {
- if (shouldMask(child)) {
- pushRect(child, rects);
+ const reason = getRedactionReason(child);
+ if (reason) {
+ const target = createTarget(child, reason);
+ if (target) targets.push(target);
// Top-most-ancestor rule: do not descend into masked subtrees.
continue;
}
- walk(child, rects);
+ walk(child, targets);
+ walkOpenShadowRoot(child, targets);
+ }
+}
+
+function walkOpenShadowRoot(node: Element, targets: RedactionTarget[]): void {
+ const shadowRoot = node.shadowRoot;
+ if (!shadowRoot) return;
+
+ for (const child of Array.from(shadowRoot.children)) {
+ const reason = getRedactionReason(child);
+ if (reason) {
+ const target = createTarget(child, reason);
+ if (target) targets.push(target);
+ continue;
+ }
+ walk(child, targets);
+ walkOpenShadowRoot(child, targets);
}
}
@@ -102,7 +155,7 @@ export async function applyMaskToImage(
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
- if (!ctx) throw new Error('Failed to get canvas context');
+ if (!ctx) throw new MaskApplicationError('Failed to get canvas context for privacy masking');
ctx.drawImage(img, 0, 0);
ctx.fillStyle = '#000';
@@ -119,7 +172,8 @@ function loadImage(dataUrl: string): Promise {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
- img.onerror = () => reject(new Error('Failed to apply privacy masks'));
+ img.onerror = () =>
+ reject(new MaskApplicationError('Failed to load image for privacy masking'));
img.src = dataUrl;
});
}
diff --git a/src/widget/screenshot-options.ts b/src/widget/screenshot-options.ts
new file mode 100644
index 0000000..928affb
--- /dev/null
+++ b/src/widget/screenshot-options.ts
@@ -0,0 +1,112 @@
+import {
+ beginViewportCapture,
+ canCaptureViewportNatively,
+ getRedactionCount,
+ isFullPageDisabled,
+} from './screenshot';
+import { createModal, redactionNoteHtml } from './ui';
+
+export type ScreenshotChoice =
+ | { kind: 'skip' }
+ | { kind: 'capture' }
+ | { kind: 'element' }
+ | { kind: 'area' }
+ | { kind: 'cancel' }
+ | { kind: 'viewport'; capture: Promise };
+
+export function showScreenshotOptions(
+ root: HTMLElement,
+ opts?: { allowSkip?: boolean }
+): Promise {
+ const fullPageDisabled = isFullPageDisabled();
+ const nativeViewportAvailable = fullPageDisabled && canCaptureViewportNatively();
+ const allowSkip = opts?.allowSkip !== false;
+
+ let redactionNote = '';
+ if (nativeViewportAvailable) {
+ redactionNote = redactionNoteHtml(
+ 'Browser viewport capture cannot apply automatic private-field masks. Select Element to preserve automatic masking, or review and cover sensitive areas before sending.'
+ );
+ } else if (getRedactionCount() > 0) {
+ redactionNote = redactionNoteHtml(
+ 'This site marked some fields for redaction. Review the screenshot before sending.'
+ );
+ }
+
+ return new Promise(resolve => {
+ const complexNote = fullPageDisabled
+ ? `${nativeViewportAvailable ? 'This page is too complex for full-page or area capture. Capture the visible viewport or select a specific element instead.' : 'This page is too complex for full-page or area capture. Select a specific element instead.'}
`
+ : '';
+
+ let primaryCaptureButton = '';
+ if (!fullPageDisabled) {
+ primaryCaptureButton =
+ 'Full Page ';
+ } else if (nativeViewportAvailable) {
+ primaryCaptureButton =
+ 'Capture Viewport ';
+ }
+
+ const modal = createModal(
+ root,
+ 'Capture Screenshot',
+ `
+ Choose what to capture:
+ ${complexNote}
+ ${redactionNote}
+
+ ${primaryCaptureButton}
+ ${fullPageDisabled ? '' : 'Select Area '}
+ Select Element
+ ${allowSkip ? 'Skip Screenshot ' : ''}
+
+ `
+ );
+
+ const closeBtn = modal.querySelector('.bd-close') as HTMLElement;
+ const skipBtn = modal.querySelector('[data-action="skip"]') as HTMLElement | null;
+ const elementBtn = modal.querySelector('[data-action="element"]') as HTMLElement;
+ const areaBtn = modal.querySelector('[data-action="area"]') as HTMLElement;
+ const captureBtn = modal.querySelector('[data-action="capture"]') as HTMLElement;
+ const viewportBtn = modal.querySelector('[data-action="viewport"]') as HTMLElement;
+
+ closeBtn?.addEventListener('click', () => {
+ modal.remove();
+ resolve({ kind: 'cancel' });
+ });
+
+ skipBtn?.addEventListener('click', () => {
+ modal.remove();
+ resolve({ kind: 'skip' });
+ });
+
+ elementBtn?.addEventListener('click', () => {
+ modal.remove();
+ resolve({ kind: 'element' });
+ });
+
+ areaBtn?.addEventListener('click', () => {
+ modal.remove();
+ resolve({ kind: 'area' });
+ });
+
+ captureBtn?.addEventListener('click', () => {
+ modal.remove();
+ resolve({ kind: 'capture' });
+ });
+
+ viewportBtn?.addEventListener('click', () => {
+ modal.remove();
+ // beginViewportCapture must run synchronously inside the click handler to
+ // preserve the user gesture required by getDisplayMedia. The in-flight
+ // promise is then handed to capture-flow for awaiting.
+ const capture = beginViewportCapture();
+ capture.catch(() => {
+ // Attach a handler immediately so an early rejection does not surface
+ // as 'Unhandled promise rejection' in the brief window before the
+ // caller awaits this promise. The real error is still raised on await.
+ });
+ resolve({ kind: 'viewport', capture });
+ });
+ });
+}
diff --git a/src/widget/screenshot.ts b/src/widget/screenshot.ts
index 83c67d2..ea9ff30 100644
--- a/src/widget/screenshot.ts
+++ b/src/widget/screenshot.ts
@@ -1,6 +1,6 @@
import * as htmlToImage from 'html-to-image';
import type { Options as HtmlToImageOptions } from 'html-to-image/lib/types';
-import { applyMaskToImage, collectMaskRects, countMaskRects } from './mask';
+import { applyMaskToImage, countMaskRects, createRedactionPlan } from './mask';
declare const __BUGDROP_ENABLE_TEST_HOOKS__: boolean;
@@ -202,7 +202,7 @@ export async function captureScreenshot(
filter: (node: HTMLElement) => node.id !== 'bugdrop-host',
};
- const rects = collectMaskRects(target);
+ const redactionPlan = createRedactionPlan(target);
let originOffset = { x: 0, y: 0 };
if (element) {
const r = element.getBoundingClientRect();
@@ -211,7 +211,12 @@ export async function captureScreenshot(
const capturePromise = toPng(target as HTMLElement, opts);
const dataUrl = await withCaptureTimeout(capturePromise);
- return applyMaskToImage(dataUrl, rects, pixelRatio, originOffset);
+ return applyMaskToImage(
+ dataUrl,
+ redactionPlan.targets.map(target => target.rect),
+ pixelRatio,
+ originOffset
+ );
}
export async function captureAreaScreenshot(
@@ -236,10 +241,16 @@ export async function captureAreaScreenshot(
};
const dataUrl = await withCaptureTimeout(toPng(document.body, opts));
- return applyMaskToImage(dataUrl, collectMaskRects(document.body), pixelRatio, {
- x: rect.x,
- y: rect.y,
- });
+ const redactionPlan = createRedactionPlan(document.body);
+ return applyMaskToImage(
+ dataUrl,
+ redactionPlan.targets.map(target => target.rect),
+ pixelRatio,
+ {
+ x: rect.x,
+ y: rect.y,
+ }
+ );
}
export function getRedactionCount(element?: Element, rect?: DOMRect): number {
diff --git a/src/widget/ui.ts b/src/widget/ui.ts
index 942700d..9aa4336 100644
--- a/src/widget/ui.ts
+++ b/src/widget/ui.ts
@@ -1063,6 +1063,10 @@ export function injectStyles(shadow: ShadowRoot, config: WidgetConfig) {
return root;
}
+export function redactionNoteHtml(message: string): string {
+ return `${message}
`;
+}
+
export function createModal(
container: HTMLElement,
title: string,
diff --git a/test/captureFlow.test.ts b/test/captureFlow.test.ts
new file mode 100644
index 0000000..38a2e31
--- /dev/null
+++ b/test/captureFlow.test.ts
@@ -0,0 +1,227 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { EmptyCaptureReason } from '../src/widget/capture-flow';
+import type { ScreenshotChoice } from '../src/widget/screenshot-options';
+import type { CaptureWithLoadingResult } from '../src/widget/capture-loading';
+
+const baseConfig = {
+ screenshotMode: 'optional' as const,
+ theme: 'light' as const,
+};
+
+async function loadCaptureFlowWithMocks(opts: {
+ screenshotChoice: ScreenshotChoice;
+ pickedElement?: Element | null;
+ captureResult?: CaptureWithLoadingResult;
+ annotationResult?: string | 'retake' | 'cancel';
+}) {
+ vi.resetModules();
+ vi.doMock('../src/widget/screenshot-options', () => ({
+ showScreenshotOptions: vi.fn().mockResolvedValue(opts.screenshotChoice),
+ }));
+ vi.doMock('../src/widget/picker', () => ({
+ createElementPicker: vi.fn().mockResolvedValue(opts.pickedElement ?? null),
+ }));
+ vi.doMock('../src/widget/area-picker', () => ({
+ createAreaPicker: vi.fn(),
+ }));
+ vi.doMock('../src/widget/capture-loading', () => ({
+ captureWithLoading: vi.fn().mockResolvedValue(opts.captureResult ?? { kind: 'skipped' }),
+ captureAreaWithLoading: vi.fn(),
+ capturePromiseWithLoading: vi.fn().mockResolvedValue(opts.captureResult ?? { kind: 'skipped' }),
+ }));
+ vi.doMock('../src/widget/annotation-flow', () => ({
+ showAnnotationStep: vi.fn().mockResolvedValue(opts.annotationResult ?? 'annotated-image'),
+ }));
+ vi.doMock('../src/widget/screenshot', () => ({
+ beginViewportCapture: vi.fn(),
+ getRedactionCount: vi.fn().mockReturnValue(0),
+ isFullPageDisabled: vi.fn().mockReturnValue(false),
+ }));
+
+ return import('../src/widget/capture-flow');
+}
+
+afterEach(() => {
+ vi.doUnmock('../src/widget/screenshot-options');
+ vi.doUnmock('../src/widget/picker');
+ vi.doUnmock('../src/widget/area-picker');
+ vi.doUnmock('../src/widget/capture-loading');
+ vi.doUnmock('../src/widget/annotation-flow');
+ vi.doUnmock('../src/widget/screenshot');
+ vi.resetModules();
+});
+
+describe('capture flow state decisions', () => {
+ it.each([
+ ['explicit-skip', true],
+ ['capture-failure-skip', true],
+ ['selection-cancelled', false],
+ ['none', false],
+ ] satisfies Array<[EmptyCaptureReason, boolean]>)(
+ 'complex screenshot skip persistence for %s is %s',
+ async (reason, expected) => {
+ const { shouldRememberComplexScreenshotSkip } = await import('../src/widget/capture-flow');
+ expect(shouldRememberComplexScreenshotSkip(reason)).toBe(expected);
+ }
+ );
+
+ it('remembers complex screenshot skip for an explicit optional skip', async () => {
+ const { runScreenshotCaptureFlow } = await loadCaptureFlowWithMocks({
+ screenshotChoice: { kind: 'skip' },
+ });
+ const onComplexScreenshotSkipped = vi.fn();
+
+ const result = await runScreenshotCaptureFlow(
+ document.createElement('div'),
+ baseConfig,
+ true,
+ onComplexScreenshotSkipped
+ );
+
+ expect(result).toEqual({ screenshot: null, elementSelector: null, returnToForm: false });
+ expect(onComplexScreenshotSkipped).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not remember complex screenshot skip when element selection is cancelled', async () => {
+ const { runScreenshotCaptureFlow } = await loadCaptureFlowWithMocks({
+ screenshotChoice: { kind: 'element' },
+ pickedElement: null,
+ });
+ const onComplexScreenshotSkipped = vi.fn();
+
+ const result = await runScreenshotCaptureFlow(
+ document.createElement('div'),
+ baseConfig,
+ true,
+ onComplexScreenshotSkipped
+ );
+
+ expect(result).toEqual({ screenshot: null, elementSelector: null, returnToForm: false });
+ expect(onComplexScreenshotSkipped).not.toHaveBeenCalled();
+ });
+
+ it('keeps selected element metadata when element capture is skipped after failure', async () => {
+ const element = document.createElement('button');
+ element.id = 'target-button';
+ document.body.appendChild(element);
+ const { runScreenshotCaptureFlow } = await loadCaptureFlowWithMocks({
+ screenshotChoice: { kind: 'element' },
+ pickedElement: element,
+ captureResult: { kind: 'skipped' },
+ });
+ const onComplexScreenshotSkipped = vi.fn();
+
+ const result = await runScreenshotCaptureFlow(
+ document.createElement('div'),
+ baseConfig,
+ true,
+ onComplexScreenshotSkipped
+ );
+
+ expect(result).toEqual({
+ screenshot: null,
+ elementSelector: '#target-button',
+ returnToForm: false,
+ });
+ expect(onComplexScreenshotSkipped).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns to form when the user dismisses the screenshot options modal', async () => {
+ const { runScreenshotCaptureFlow } = await loadCaptureFlowWithMocks({
+ screenshotChoice: { kind: 'cancel' },
+ });
+ const onComplexScreenshotSkipped = vi.fn();
+
+ const result = await runScreenshotCaptureFlow(
+ document.createElement('div'),
+ baseConfig,
+ true,
+ onComplexScreenshotSkipped
+ );
+
+ expect(result).toEqual({ screenshot: null, elementSelector: null, returnToForm: true });
+ expect(onComplexScreenshotSkipped).not.toHaveBeenCalled();
+ });
+
+ it('returns to form when the capture-failure modal is cancelled mid-capture', async () => {
+ const { runScreenshotCaptureFlow } = await loadCaptureFlowWithMocks({
+ screenshotChoice: { kind: 'capture' },
+ captureResult: { kind: 'cancelled' },
+ });
+ const onComplexScreenshotSkipped = vi.fn();
+
+ const result = await runScreenshotCaptureFlow(
+ document.createElement('div'),
+ baseConfig,
+ true,
+ onComplexScreenshotSkipped
+ );
+
+ expect(result).toEqual({ screenshot: null, elementSelector: null, returnToForm: true });
+ expect(onComplexScreenshotSkipped).not.toHaveBeenCalled();
+ });
+
+ it('returns to form when the annotation step is cancelled', async () => {
+ const { runScreenshotCaptureFlow } = await loadCaptureFlowWithMocks({
+ screenshotChoice: { kind: 'capture' },
+ captureResult: { kind: 'ok', dataUrl: 'data:image/png;base64,AAAA' },
+ annotationResult: 'cancel',
+ });
+ const onComplexScreenshotSkipped = vi.fn();
+
+ const result = await runScreenshotCaptureFlow(
+ document.createElement('div'),
+ baseConfig,
+ true,
+ onComplexScreenshotSkipped
+ );
+
+ expect(result).toEqual({ screenshot: null, elementSelector: null, returnToForm: true });
+ expect(onComplexScreenshotSkipped).not.toHaveBeenCalled();
+ });
+
+ it('flags redactionUnavailable on the annotation step for native viewport captures', async () => {
+ const annotationMock = vi.fn().mockResolvedValue('annotated-image');
+ vi.resetModules();
+ vi.doMock('../src/widget/screenshot-options', () => ({
+ showScreenshotOptions: vi.fn().mockResolvedValue({
+ kind: 'viewport',
+ capture: Promise.resolve('data:image/png;base64,VVVV'),
+ } satisfies ScreenshotChoice),
+ }));
+ vi.doMock('../src/widget/picker', () => ({ createElementPicker: vi.fn() }));
+ vi.doMock('../src/widget/area-picker', () => ({ createAreaPicker: vi.fn() }));
+ vi.doMock('../src/widget/capture-loading', () => ({
+ captureWithLoading: vi.fn(),
+ captureAreaWithLoading: vi.fn(),
+ capturePromiseWithLoading: vi
+ .fn()
+ .mockResolvedValue({ kind: 'ok', dataUrl: 'data:image/png;base64,VVVV' }),
+ }));
+ vi.doMock('../src/widget/annotation-flow', () => ({ showAnnotationStep: annotationMock }));
+ vi.doMock('../src/widget/screenshot', () => ({
+ beginViewportCapture: vi.fn(),
+ getRedactionCount: vi.fn().mockReturnValue(0),
+ isFullPageDisabled: vi.fn().mockReturnValue(true),
+ }));
+
+ const { runScreenshotCaptureFlow } = await import('../src/widget/capture-flow');
+ const onComplexScreenshotSkipped = vi.fn();
+
+ const result = await runScreenshotCaptureFlow(
+ document.createElement('div'),
+ baseConfig,
+ true,
+ onComplexScreenshotSkipped
+ );
+
+ expect(result.screenshot).toBe('annotated-image');
+ expect(annotationMock).toHaveBeenCalledWith(
+ expect.any(HTMLElement),
+ 'data:image/png;base64,VVVV',
+ 0,
+ { redactionUnavailable: true }
+ );
+ });
+});
diff --git a/test/mask.test.ts b/test/mask.test.ts
index 9803f5e..242926f 100644
--- a/test/mask.test.ts
+++ b/test/mask.test.ts
@@ -1,12 +1,22 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { collectMaskRects, applyMaskToImage, translateMaskRect } from '../src/widget/mask';
+import {
+ applyMaskToImage,
+ collectMaskRects,
+ createRedactionPlan,
+ MaskApplicationError,
+ translateMaskRect,
+} from '../src/widget/mask';
describe('mask module exports', () => {
it('exports collectMaskRects', () => {
expect(typeof collectMaskRects).toBe('function');
});
+ it('exports createRedactionPlan', () => {
+ expect(typeof createRedactionPlan).toBe('function');
+ });
+
it('exports applyMaskToImage', () => {
expect(typeof applyMaskToImage).toBe('function');
});
@@ -47,6 +57,14 @@ describe('collectMaskRects — explicit attribute', () => {
document.body.appendChild(div);
expect(collectMaskRects(document.body)).toEqual([{ x: 10, y: 20, w: 100, h: 50 }]);
+ expect(createRedactionPlan(document.body).targets).toMatchObject([
+ {
+ element: div,
+ rect: { x: 10, y: 20, w: 100, h: 50 },
+ reason: 'developer-marked',
+ strategy: 'canvas-mask',
+ },
+ ]);
});
it('returns rects for multiple sibling masked elements', () => {
@@ -74,6 +92,14 @@ describe('collectMaskRects — built-in defaults', () => {
document.body.appendChild(input);
expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 30 }]);
+ expect(createRedactionPlan(document.body).targets).toMatchObject([
+ {
+ element: input,
+ rect: { x: 0, y: 0, w: 200, h: 30 },
+ reason: 'sensitive-input',
+ strategy: 'canvas-mask',
+ },
+ ]);
});
it('masks credit-card autocomplete inputs', () => {
@@ -171,6 +197,78 @@ describe('collectMaskRects — nesting and scoping', () => {
expect(collectMaskRects(input)).toEqual([{ x: 0, y: 0, w: 200, h: 30 }]);
});
+
+ it('traverses open shadow DOM redaction targets from a host element', () => {
+ const host = withRect(document.createElement('div'), 0, 0, 200, 100);
+ const shadow = host.attachShadow({ mode: 'open' });
+ const privateField = withRect(document.createElement('div'), 10, 10, 50, 20);
+ privateField.setAttribute('data-bugdrop-mask', '');
+ shadow.appendChild(privateField);
+ document.body.appendChild(host);
+
+ expect(collectMaskRects(document.body)).toEqual([{ x: 10, y: 10, w: 50, h: 20 }]);
+ expect(createRedactionPlan(document.body).targets).toMatchObject([
+ {
+ element: privateField,
+ rect: { x: 10, y: 10, w: 50, h: 20 },
+ reason: 'developer-marked',
+ strategy: 'canvas-mask',
+ },
+ ]);
+ });
+
+ it('uses a marked shadow host as the top-most mask', () => {
+ const host = withRect(document.createElement('div'), 0, 0, 200, 100);
+ host.setAttribute('data-bugdrop-mask', '');
+ const shadow = host.attachShadow({ mode: 'open' });
+ const privateField = withRect(document.createElement('div'), 10, 10, 50, 20);
+ privateField.setAttribute('data-bugdrop-mask', '');
+ shadow.appendChild(privateField);
+ document.body.appendChild(host);
+
+ expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 100 }]);
+ });
+
+ it('does not traverse iframe document redaction targets', () => {
+ const iframe = withRect(document.createElement('iframe'), 0, 0, 200, 100);
+ const iframeBody = document.createElement('body');
+ const privateField = withRect(document.createElement('div'), 10, 10, 50, 20);
+ privateField.setAttribute('data-bugdrop-mask', '');
+ iframeBody.appendChild(privateField);
+ Object.defineProperty(iframe, 'contentDocument', {
+ value: { body: iframeBody },
+ configurable: true,
+ });
+ document.body.appendChild(iframe);
+
+ expect(createRedactionPlan(document.body).targets).toEqual([]);
+ });
+
+ it('masks a marked iframe container without inspecting iframe contents', () => {
+ const iframe = withRect(document.createElement('iframe'), 0, 0, 200, 100);
+ iframe.setAttribute('data-bugdrop-mask', '');
+ document.body.appendChild(iframe);
+
+ expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 100 }]);
+ });
+
+ it.each(['canvas', 'img', 'video'] as const)('masks a marked %s container', tagName => {
+ const media = withRect(document.createElement(tagName), 0, 0, 200, 100);
+ media.setAttribute('data-bugdrop-mask', '');
+ document.body.appendChild(media);
+
+ expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 100 }]);
+ });
+
+ it.each(['canvas', 'img', 'video'] as const)(
+ 'does not treat unmarked %s pixels as redaction targets',
+ tagName => {
+ const media = withRect(document.createElement(tagName), 0, 0, 200, 100);
+ document.body.appendChild(media);
+
+ expect(collectMaskRects(document.body)).toEqual([]);
+ }
+ );
});
describe('collectMaskRects — coordinates and visibility', () => {
@@ -357,6 +455,6 @@ describe('applyMaskToImage', () => {
};
await expect(
applyMaskToImage('data:image/png;base64,bad', [{ x: 0, y: 0, w: 10, h: 10 }], 1)
- ).rejects.toThrow('Failed to apply privacy masks');
+ ).rejects.toThrow(MaskApplicationError);
});
});