feat: screenshot privacy masking via data-bugdrop-mask#148
Merged
Conversation
neonwatty
added a commit
that referenced
this pull request
May 10, 2026
Knip flagged MaskRect as exported but unused outside mask.ts. The interface is referenced only by the module's own internal helpers and function signatures; consumers infer the type via TypeScript. Fixes the failing 'Lint, Typecheck, Knip, Audit' CI check on PR #148.
Design spec for a developer-configured masking primitive: site owners tag sensitive elements with `data-bugdrop-mask` (plus built-in defaults for password/credit-card inputs), and the widget paints opaque rectangles over those regions on the captured PNG before the annotator opens. Coordinates are document-space to align with the existing area picker and survive scroll. No DOM mutation, no annotator changes, no new API surface.
13 TDD-style tasks covering: mask.ts module (collectMaskRects, applyMaskToImage, translateMaskRect), captureScreenshot wiring, E2E fixtures, 11 Playwright tests across full-page/element/area-crop captures + scrolled coordinates, and the security/install/README docs.
Replace flat querySelectorAll traversal with a hand-written tree walk that respects the top-most-ancestor rule (masked subtrees are not descended into), scoped collection (only descendants of root), and root inclusion (root itself is checked before walking).
Wire collectMaskRects and applyMaskToImage into captureScreenshot so privacy masks are baked into every captured image. Rects and origin offset are computed before awaiting toPng to snapshot DOM state at the same instant capture begins.
Adds 3 Screenshot Masking tests that run against real html-to-image output (no mock) and verify pixel values: password fields are masked by default, data-bugdrop-mask elements are masked, and unrelated elements are not. Infers the actual capture pixelRatio from the image dimensions because the widget enforces a minimum scale of 2.
- Fix NaN-tolerant skip guard in applyMaskToImage: change `t.w <= 0 || t.h <= 0` to `!(t.w > 0 && t.h > 0)` so NaN coordinates are caught (canvas silently ignores fillRect(NaN,...) — a silent privacy failure) - Widen pushRect zero-size guard from AND to OR so zero-width or zero-height rects are also skipped (both conditions indicate no visible area) - Replace 14-line inline Promise.race/setTimeout/clearTimeout block in captureScreenshot with existing withCaptureTimeout helper (eliminates duplication) - Replace IIFE-in-ternary for originOffset with plain let + if/else for clarity
Previously the catch block in capturePromiseWithLoading discarded the error silently (_error). Now it logs the error with the [BugDrop] prefix so developers can identify why screenshot capture failed in production.
…crolled area E2E
- Tighten integration test assertion to .toBe('data:image/png;base64,masked')
so it proves applyMaskToImage actually ran (not just that captureScreenshot
returned something truthy)
- Add applyMaskToImage describe block in mask.test.ts covering: coord translation
with pixelRatio scaling, empty-rects short-circuit, originOffset subtraction,
off-canvas skip (non-positive dimensions), null context rejection, and image
load error rejection (6 new unit tests; 147 total)
- Add E2E test: scrolled area-cropped capture preserves masks at translated
crop-local coordinates — proves the document-coordinate masking math is correct
when the page is scrolled at area-selection time
Knip flagged MaskRect as exported but unused outside mask.ts. The interface is referenced only by the module's own internal helpers and function signatures; consumers infer the type via TypeScript. Fixes the failing 'Lint, Typecheck, Knip, Audit' CI check on PR #148.
Two negative-pixel-sample assertions failed on CI but passed locally because headless Chromium produces fully-black anti-aliased text pixels that exactly match [0, 0, 0, 255]. With GPU acceleration locally, the glyphs render slightly off-black so the assertions held by accident. - 'does not mask unrelated elements': sample inside the panel padding (4px from top-left) where the yellow background is guaranteed without text overlap, instead of the geometric center which could land on glyph anti-aliasing. - 'masked child of unmasked parent is masked; siblings are not': sample four corners of the <p> rect and assert AT LEAST ONE is non-black. A real mask would make every pixel inside the rect solid black, so even one non-black sample is sufficient to disprove masking. This is robust to font rendering differences across environments.
b393788 to
a035663
Compare
|
🎉 This PR is included in version 1.31.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a developer-configured privacy primitive that paints opaque rectangles over
data-bugdrop-maskelements (and password / credit-card autocomplete inputs by default) on the captured screenshot before the annotator opens. Masks are baked into the PNG client-side, work with all three capture modes (full page / element-scoped / area-cropped), and survive page scroll because rectangles are collected in document coordinates.Approach
src/widget/mask.tsexportscollectMaskRects(root)(DOM walk → document-coordinate rects) andapplyMaskToImage(dataUrl, rects, pixelRatio, originOffset)(canvas blit). Pure helpertranslateMaskRectextracted for unit-testable math.captureScreenshotinvokes them betweenhtml-to-imageand the existing return path. No DOM mutation, no annotator changes, no new public API.gap/ margin gaps._errornow logged viaconsole.warn('[BugDrop] ...').Spec & plan
docs/superpowers/specs/2026-05-10-screenshot-privacy-masking-design.mddocs/superpowers/plans/2026-05-10-screenshot-privacy-masking.mdTest plan
npm run build:widget— bundlesmask.tsintowidget.jscleanly.Known limitations (documented in
security.mdx)getDisplayMediawith no DOM access.