Get started

Opening it without a button

A form nobody can find is a form nobody uses, and a floating button is not always wanted. bugbottle/triggers is two listeners, under 1.3 kB gzipped together and importing nothing but the fingerprint hash:

ts
import { onShortcut, onUncaughtError } from "bugbottle/triggers";

const offKeys = onShortcut("mod+shift+b", () => widget.open());
const offErrors = onUncaughtError((error) => {
  console.warn("uncaught", error.fingerprint);
  widget.open();
});

mod is Command on a Mac and Control everywhere else, so one string covers both. The shortcut never fires while the reporter is typing in a field or a contenteditable region, and a match is preventDefaulted so the browser does not also act on it. "Typing" includes typing inside a shadow root: a keystroke that crosses a shadow boundary is retargeted to the host on the way out, so the check reads event.composedPath()[0] as well as target, and follows document.activeElement down through every shadowRoot.activeElement. That covers the panel this package ships, which lives in one. onUncaughtError listens for error and unhandledrejection, describes each one the same way, and calls you at most once per fingerprint (message plus the first stack frame) per dedupeMs — 60 000 by default — which is what makes it safe to open a panel from. Pass ignore to drop the ones you already know about. Both return the unsubscribe.

The ready-made panel wires both for you. The shortcut is on by default:

ts
const widget = mountBugbottle({
  endpoint: "/api/feedback",
  shortcut: "mod+shift+b", // the default; `false` installs no listener
  openOnError: { prefill: true },
});

The shortcut opens the panel and closes it again — except while the caret is in the panel's own box, where the keystroke belongs to the reporter and Escape or the close button is the way out.

openOnError is off unless you ask for it: a panel that appears uninvited is a decision about your product, not a default. Switched on, an uncaught error opens the panel with the type set to bug and the locale's openedByError line where the intro usually is — "Something went wrong on this page. Want to tell us what you were doing?" — and { prefill: true } also puts the error message in the box, without overwriting anything the reporter has already written. They still have to press send. Closing the panel puts the ordinary intro back.

From the script tag it is data-shortcut (data-shortcut="off" for none) and data-open-on-error (any value, or "prefill").

Shake to report #

On a phone there is no keyboard, and shaking the device is what people already expect from a bug reporter. bugbottle/shake is that gesture in 685 bytes gzipped, importing nothing:

ts
import { onShake, requestShakePermission } from "bugbottle/shake";

const off = onShake(() => widget.open());

A shake is three crossings of 15 m/s² with alternating direction inside one second, measured on whichever axis moves most once gravity has been filtered out. Alternation is what separates a shake from a drop — falling onto a desk is one large reading in one direction — and after a shake the detector is quiet for three seconds, so one gesture opens one panel however long the reporter keeps shaking. threshold and cooldownMs change both; windowMs changes the second. Nothing is measured while the page is hidden: the listener comes off on visibilitychange and goes back on when the page returns. On a laptop it simply never fires, which is why no media query switches it off.

iOS needs a gesture, and only Safari has the gate. Since iOS 13, Safari delivers no motion events at all until DeviceMotionEvent.requestPermission() has been called from inside a user gesture — a real click or tap — and granted. onShake never calls it: a permission prompt nobody asked for is worse than a feature nobody found, and the browser would refuse it outside a gesture anyway. Put it on a button of your own:

ts
button.addEventListener("click", async () => {
  const state = await requestShakePermission();
  // "unsupported" is every browser but Safari, where motion simply arrives.
  if (state === "denied") showTheButtonInstead();
});

requestShakePermission() resolves to "granted", "denied" or "unsupported", and a call Safari rejects because it did not come from a gesture is reported as "denied" rather than thrown. Two more facts worth knowing: motion is a secure-context feature, so a page served over plain HTTP gets no events whatever the permission says; and until permission is granted onShake is installed and silent, which is exactly what it looks like on a desktop.

The panel takes the detector the way it takes the annotator — a function you hand in, so nobody pays for a gesture they never use:

ts
import { onShake } from "bugbottle/shake";

mountBugbottle({
  endpoint: "/api/feedback",
  shake: onShake, // or { on: onShake, threshold: 12, cooldownMs: 5000 }
});

It is off by default, because of the permission dance above. A shake opens the panel; it never closes it, since the gesture that would close it is the one that shook it open. From the script tag it is data-shake — presence enables it, and a number tunes the threshold (data-shake="12" is a lighter flick). That build also exposes window.bugbottle.requestShakePermission(), which is the only way a page with no bundler can ask iOS.

Edit this page on GitHub