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:
type ScreenshotRenderer = (
root: HTMLElement,
options: { filter: (node: Node) => boolean; pixelRatio: number },
) => Promise<string>; // PNG data URLThe 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:
- Whole page —
captureScreenshot, as always. - Select area — a dimmed overlay covers the page and the reporter drags a rectangle with a mouse, a pen or a finger. Enter takes the visible part of the page, which is the keyboard's way through; Escape cancels. A drag smaller than 8 CSS pixels a side is a stray click and is ignored.
- Pick element — the element under the pointer is outlined, and a click (or
Enter on a focused control) takes a picture of its box with 8 pixels of the
page around it. The element's description — selector, tag, text, rectangle —
comes with it, the same
ElementRefpickElementresolves with.
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:
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:
"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.
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.
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:
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:
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.