Evidence

Screenshots

captureScreenshot takes a renderer rather than importing one. A bundler resolves every import it can see, optional or not, so a built-in import of html-to-image would make it a hard dependency for every application — including the ones that never take a picture.

bugbottle/html-to-image is the ready-made renderer. Any function with the same shape works in its place:

ts
type ScreenshotRenderer = (
  root: HTMLElement,
  options: { filter: (node: Node) => boolean; pixelRatio: number },
) => Promise<string>; // PNG data URL

The capture renders from the DOM, not from the screen, so it can only ever show the page the reporter is on — never another tab, another window, or the desktop behind it. That is a deliberate limit rather than a missing feature.

What the reporter typed is hidden before the picture is taken; see Masking.

Choosing what the picture shows #

"The total is wrong" is quicker to triage with a picture of the total than with a picture of the whole checkout — and a picture of the total is also a picture of less of everything else on the screen. bugbottle/region lets the reporter choose:

ts
import { htmlToImage } from "bugbottle/html-to-image";
import { captureArea, captureElement } from "bugbottle/region";
import { buildReport, sendReport } from "bugbottle";

const shot = await captureArea(htmlToImage); // null when the reporter pressed Escape
if (shot) {
  await sendReport(endpoint, buildReport({
    type: "bug",
    message,
    screenshotDataUrl: shot.dataUrl,
    screenshotRegion: shot.region,
  }));
}

The page is rendered the usual way — from the DOM, masked — and the rectangle is cut out of it on a canvas. The render is at the device's pixel ratio, so a crop on a 2x screen is as sharp as what the reporter saw, lowered when the whole page at that ratio would pass the 16 megapixels Safari on iOS allows a canvas. The scale is read back off the rendered picture rather than assumed, so a renderer that had to settle for less still crops the right pixels. A crop that is still over the size limit is halved, then quartered, before it gives up with ScreenshotTooLargeError, exactly as a whole page does.

annotate: true draws the rectangle on a picture of the whole page instead of cutting it out — the reader sees where on the page it was, at the cost of the page being in the picture:

ts
const shot = await captureElement(htmlToImage, { annotate: true });

Every function takes a signal, and resolves with null when it is aborted, as it does on Escape. The overlay carries data-bugbottle, so it is never in a picture; it is a dialog named by its instruction, it takes focus while it is up and gives it back afterwards, its outline is a white line on a dark one so it shows on any page, forced colours redraw it with the system palette, and nothing on it moves, so there is nothing for prefers-reduced-motion to turn off. instructions replaces the English line on it — the panel passes the reporter's language.

What arrives on the report is a screenshotRegion beside the picture:

jsonc
"screenshotRegion": {
  "mode": "element",                                      // or "area"
  "rect": { "x": 912, "y": 1640, "width": 134, "height": 52 },  // page CSS pixels
  "viewport": { "x": 912, "y": 240, "width": 134, "height": 52 },
  "selector": "form#checkout > button:nth-of-type(2)",   // element mode only
  "annotated": true                                       // only with annotate
}

It is only ever sent beside a picture, and the server drops it whenever the picture is dropped (screenshot: "drop", or a picture that fails its PNG check). A whole-page picture has none, so a report from before 1.1 looks exactly as it did.

The panel takes the function as region, the same seam as annotate: three buttons — Whole page, Select area, Pick element — appear under the screenshot box, the panel steps aside while the reporter works on the page, and the pressed button says which one the attached picture came from.

ts
import { mountBugbottle } from "bugbottle/ui";
import { htmlToImage } from "bugbottle/html-to-image";
import { captureRegion } from "bugbottle/region";

mountBugbottle({ endpoint, screenshot: htmlToImage, region: captureRegion });
// or outline the choice on the whole page:
mountBugbottle({ endpoint, screenshot: htmlToImage, region: { on: captureRegion, annotate: true } });

The form adapters stay headless: call captureArea or captureElement from your own button, hide your form while it runs, and hand the result to attachScreenshot, which puts the picture, the region and a picked element on the form state (screenshotRegion is on it beside screenshot). A cancelled selection is null and changes nothing.

tsx
const form = useBugReport({ endpoint, screenshot: htmlToImage });
<button onClick={async () => form.attachScreenshot(await captureArea(htmlToImage))}>
  Select area
</button>

The script-tag builds do not carry bugbottle/region: they ship no renderer, so a page using them is already passing one to mount from its own code, and can import the module from jsDelivr the same way (https://cdn.jsdelivr.net/npm/bugbottle@1/dist/region.js is plain ESM).

Two browser limits are worth knowing. Safari renders html-to-image's SVG pictures less faithfully than Chromium and Firefox — web fonts and images can be missing from the first render — and a crop inherits whatever the render got wrong. And position: fixed content is drawn where it sits relative to the top of the page rather than where it was on screen after a scroll — that is how html-to-image lays it out — so an area selected over a sticky header further down the page may not show the header, and a fixed or sticky element picked after a scroll may be cut from the wrong place. annotate: true has the same limit; on such pages, the whole page is the safer mode.

Marking the picture #

A screenshot of the whole page rarely says which part of it is wrong. The reporter can mark it before sending: a rectangle to point at something, an arrow to point from somewhere, and a blur.

The blur is the privacy tool as much as the marking one. It does not put a frosted rectangle over the region — it reads those pixels back out of the canvas, averages them in 12-pixel blocks and paints the averages on top, so the original pixels are gone from the exported PNG. Whoever receives the report cannot recover what was under it. That is the tool to reach for when a picture caught a customer name the masking rules did not know about. The region is rounded outwards to whole pixels before it is averaged, so a drag covers a fraction of a pixel too much rather than a fraction too little.

In the ready-made panel it is a button, "Edit picture", that appears once a picture is attached; it opens a toolbar and the canvas in place of the preview. The panel does not import the annotator — you hand it in, the way you hand in a screenshot renderer, a scrubber or a signer, so that a panel nobody marks a picture in does not ship a canvas editor:

ts
import { mountBugbottle } from "bugbottle/ui";
import { createAnnotator } from "bugbottle/annotate";

mountBugbottle({ endpoint: "/api/feedback", screenshot: htmlToImage, annotate: createAnnotator });

Leave annotate out (or pass false) and nothing on screen leads to an editor. The script tag is the build that carries everything, so it wires the annotator up for you and data-annotate="off" is how you switch it off there. Sending with the editor still open keeps the marks: the picture is folded back into the report either way, so a blur cannot be lost by skipping "Done". Escape in the editor leaves the editor rather than the panel — it is the way out that drops the marks nobody confirmed, puts the preview back and returns focus to "Edit picture"; while a mark is being drawn it still abandons that mark, and with the editor closed it still closes the panel.

With your own form, use the annotator directly. It is its own entry point, about 1.4 kB gzipped, and it draws on a canvas you supply:

ts
import { createAnnotator } from "bugbottle/annotate";
import { buildReport, sendReport, captureScreenshot } from "bugbottle";
import { htmlToImage } from "bugbottle/html-to-image";

const picture = await captureScreenshot(htmlToImage);
const annotator = createAnnotator(canvas, picture);
await annotator.ready; // the canvas is now the size of the picture

toolbar.onclick = (e) => annotator.setTool(e.target.value); // "rect" | "arrow" | "blur"
undoButton.onclick = () => annotator.undo();

// When the reporter is finished, the marked picture is the attachment.
const report = buildReport({
  type: "bug",
  message: message.value,
  screenshotDataUrl: annotator.toDataUrl(),
});
await sendReport("/api/feedback", report);

createAnnotator(canvas, dataUrl, options) takes tool, colour (defaults to the computed --bb-primary), lineWidth, blockSize and an onChange called with the number of marks. It returns ready, setTool, getTool, undo, clear, count, toDataUrl and destroy. Drawing is by pointer, so a mouse, a pen and a finger all work; Backspace or Delete undoes and Escape abandons the mark being drawn — with nothing being drawn the key is left to travel, so the form around the canvas can decide what it means. Nothing in it is a string the reporter reads, so it needs no locale of its own — the panel supplies the labels around it.

Edit this page on GitHub