@digifinn/bug-report-widget
v0.3.7
Published
Framework-agnostic, zero-dependency bug report drawer (vanilla TS, single file).
Readme
bug-report-widget
Last updated: 2026-08-17
A framework-agnostic, zero-dependency bug report drawer. Vanilla TypeScript, one injected stylesheet, no runtime dependencies, 14.7 KB gzipped.
Docks to the edge of the viewport at full height, collects a structured report — severity, what happened (25+ characters unless a file is attached), optional email, optional evidence — and hands it to whatever transport you configure.
Sibling package to @digifinn/accessibility-widget;
the two share a design system and can sit on the same page without collision.
npm i @digifinn/bug-report-widgetQuickstart
No build (script tag)
<script
src="https://cdn.jsdelivr.net/npm/@digifinn/[email protected]/dist/bug-report-widget.iife.js"
integrity="sha384-…"
crossorigin="anonymous"
data-bug-report-auto
data-endpoint="/api/bug-reports"></script>That's the whole integration. The widget mounts itself, renders a floating launcher, and POSTs submitted reports to your endpoint.
Loading from a third-party CDN in production: pin an exact version (as above) and add a Subresource Integrity hash, so a compromised CDN can't swap the bundle for something that reads your users' pages. Serving the file from your own origin avoids the question entirely.
Bundler (ESM)
import { BugReportWidget } from "@digifinn/bug-report-widget";
const widget = new BugReportWidget({
appVersion: "1.4.0",
onSubmit: async (report) => {
const { id } = await api.fileBug(report);
return { ticketId: id };
},
}).mount();Submitting a report
Two transports. onSubmit wins when both are set.
onSubmit — you own the request
new BugReportWidget({
onSubmit: async (report) => {
// report.attachments[].blob are real Blobs — send them however you like.
const body = new FormData();
body.append("payload", JSON.stringify(report));
for (const a of report.attachments) body.append("files", a.blob, a.name);
const res = await fetch("/api/bugs", { method: "POST", body });
if (!res.ok) throw new Error("Could not file the report");
return { ticketId: (await res.json()).id };
},
});Resolve with { ticketId } to control the reference shown on the success
screen. Throw to drive the error state — the draft is preserved, so nothing
the user wrote is lost, and a Try again button re-runs the submit.
Digifinn public bugs (portal + endpoint)
When portal is set, endpoint POSTs the Digifinn public contract (flat
multipart, credentials: "include", no Bearer). Success must return
data.reference_code — the widget never invents a DF-BUG-… id on this path.
new BugReportWidget({
endpoint: "https://dev-api.digifinn.com/retail/presignup/api/v1/presignup/public/bugs",
portal: "RETAIL",
appVersion: "1.4.0",
});turnstileSiteKey is only for presignup hosts (action bug-report). Postsignup
portals omit it. An idempotency_key is minted on each open and reused on
retry; close or a host navigation discards it (and navigation also clears the
draft).
endpoint — generic transport (no portal)
new BugReportWidget({ endpoint: "/api/bug-reports" });POSTs multipart/form-data with:
| Part | Content |
|---|---|
| report | JSON: severity, description, email, diagnostics, submittedAt, and attachment metadata |
| attachments | One part per file (repeated) |
Respond with { "ticketId": "..." } or { "id": "..." }. Anything else and the
widget generates a client-side reference so the user always has something to
quote.
Configure one of the two. With neither, submitting surfaces the misconfiguration in the widget's error state rather than silently pretending the report was filed.
Evidence
Take screenshot uses the browser's native
getDisplayMedia
— no DOM-rasteriser dependency, and it captures canvas, iframes, cross-origin
images and native UI exactly as the user sees them. The user picks the surface
in a browser permission prompt; the widget removes its own drawer and launcher
from the render tree for the frame grab, so they never appear in their own
evidence. (display: none, not visibility: hidden — the latter leaves
composited descendants painted in the captured frame. Both elements are
position: fixed, so removing them reflows nothing.) Everything is restored
afterwards, including focus and scroll position. The capture preview is a 1×1
in-document <video> — Chrome does not decode Entire Screen / other-monitor
frames into a detached element, which left the share bar up and no PNG. If no
frame arrives, the share is stopped instead of hanging. Chrome tab or
Window in the picker is still the most reliable surface.
Where getDisplayMedia is unavailable (iOS Safari, some embedded webviews) the
button is omitted and Upload file spans the row. Dismissing the picker is a
no-op, not an error.
Upload file accepts png / jpeg / webp / pdf / txt / md / csv / json / mp4 / webm / mov by default, capped at 5 files / 5 MB each (25 MB for video) / 50 MB total. Each row shows a file-type tile (PNG, PDF, MP4, …) like the admin bug detail. Click the row to preview the file in a full-size window (about 92% of the viewport — the same scale as admin). With more than one file, a left list jumps to that file. Videos are last in the preview list only. Escape closes the preview first, then the drawer. While the report is sending, the drawer shows a percentage loader. All of these are configurable.
Diagnostics
Every report carries the environment facts triage needs:
{
url, referrer, userAgent, language, timezone,
viewport: { width, height },
screen: { width, height },
devicePixelRatio,
appVersion, // from config
capturedAt, // ISO
}Nothing else — no cookies, no storage contents, no identifiers beyond the
reporter email (typed in the field, or supplied by the host when hideEmail
is on).
This collection is silent. There is no in-widget notice and no user opt-out — the reporter is not told it is happening. That is a deliberate product decision, and it makes disclosure your responsibility: name these fields in your privacy policy before you ship. Set
collectDiagnostics: falseto turn collection off entirely, or add your own notice near whatever opens the drawer.
Config reference
| Option | Type | Default | Notes |
|---|---|---|---|
| onSubmit | (report) => Promise<{ticketId?} \| void> | — | Host transport. Wins over endpoint. |
| endpoint | string | — | Built-in POST target. With portal, uses the Digifinn contract. |
| portal | BugPortal | — | Digifinn fallback: PRESIGNUP / RETAIL / KYC / ADMIN / SELLER / BROKER / PARTNER. |
| turnstileSiteKey | string | — | Presignup only. Action bug-report. Dummy / placeholder keys are ignored. |
| email | string | — | Prefill the optional email field, or the submitted address when hideEmail is on. |
| hideEmail | boolean | false | Hide the email field. Submit uses host email / setEmail(). |
| userId | string | — | Host account id. Logged-in Digifinn portals set this from /me. |
| requestTimeoutMs | number | 120000 | Abort the built-in upload after this many ms. |
| headers | Record<string,string> | {} | Extra headers for the generic endpoint only. Never set Content-Type. |
| launcher | "icon" \| "pill" \| "tab" | "icon" | See below. |
| launcherPosition | "bottom-right" \| "bottom-left" \| "top-right" \| "top-left" | "bottom-right" | Ignored by tab, which docks to drawerSide. |
| showLauncher | boolean | true | Turn off to use only your own triggers. |
| drawerSide | "left" \| "right" | "right" | |
| zIndex | number | 2147483000 | |
| severities | Severity[] | Minor, Annoying, Blocking | Replaces the defaults wholesale. The last one renders in the amber warn tone. |
| minDescription | number | 25 | Visible characters required when no file is attached. A screenshot or upload waives this. Format / zero-width characters do not count. |
| maxDescription | number | 8000 | Trimmed characters above which submit stays locked. |
| collectDiagnostics | boolean | true | Host-level switch. There is no user-facing toggle — see Diagnostics. |
| appVersion | string \| null | null | Surfaced in diagnostics. |
| enableScreenshot | boolean | true | Also requires browser support. |
| maxFiles / maxFileSize / maxVideoFileSize / maxTotalSize | number | 5 / 5 MB / 25 MB / 50 MB | |
| acceptTypes | string | png/jpeg/webp/pdf/txt/md/csv/json/mp4/webm/mov | accept-style list. |
| storageKey | string | "digifinn-bug-report" | localStorage key for the draft. |
| labels | Partial<Record<StringKey,string>> | {} | Every visible string; see below. |
| icons | Partial<Record<"bug"\|"screenshot"\|"upload", string>> | {} | Raw SVG markup. |
| onOpen / onClose | () => void | — | |
| onSubmitSuccess / onSubmitError | (payload) => void | — | Same payloads as the events. |
Launcher variants
| Variant | Behaviour |
|---|---|
| icon | A 60px circle that grows into a labelled pill on hover/focus. |
| pill | Always labelled. |
| tab | Vertical, docked to the drawer's edge. |
Or bring your own trigger — showLauncher: false plus either
widget.attachLauncher(el) or a data-bug-report-launcher attribute on any
element. Every registered trigger keeps aria-expanded / aria-haspopup /
aria-controls in sync.
API & events
widget.mount() / unmount()
widget.open() / close() / toggle()
widget.submit() // programmatic; resolves when the transport settles
widget.getDraft() / getAttachments()
widget.attachLauncher(elOrSelector) // returns a detach function
widget.reset() // discard the draft and any evidence
widget.setEmail(email) // host reporter; no remount (use with hideEmail)
widget.setUserId(userId) // host account id from session /me; no remount
widget.updateConfig({ ... })
widget.on(event, handler) // returns an unsubscribe functionEvents: mount, unmount, open, close, draftChange, submitStart,
submitSuccess, submitError, reset, configChange.
Theming
Every colour, radius and duration is a CSS custom property on :root. Override
what you need:
:root {
--br-accent: #0e8a63;
--br-accent-strong: #0c7a57; /* accent where it meets white text */
--br-drawer-width: 420px;
--br-font: "Figtree", system-ui, sans-serif;
}--br-accent-strong exists because the brand green reaches only 4.34:1 against
white — just under WCAG AA for body-size text. Filled buttons with white labels
and accent-coloured text on white use the 2%-darker step (5.33:1); tints,
borders and icon fills keep the exact brand green. If you re-theme, keep that
distinction or re-check contrast.
Accessibility
role="dialog" + aria-modal, inert while closed, focus trapped on Tab,
Esc to dismiss (handled at the document, so it works even when focus has left
the drawer; an open attachment preview takes Escape first), focus returned to
the invoking trigger, and focus moved to Try again when a submit fails. Chips are real
radiogroups with roving tabindex and arrow-key navigation; attachment and
validation changes are announced through a polite live region. Motion respects
prefers-reduced-motion.
The Playwright suite runs @axe-core/playwright against the launcher, the open
drawer, a filled form with attachments, and the success screen, gating on
zero WCAG 2.1 AA violations.
Host isolation
The widget injects one stylesheet into document.head. It deliberately does
not import Tailwind: Preflight is emitted as bare element selectors (*,
button, h1, a, ul, img) which would silently restyle the host page the
moment the sheet lands. Instead the reset is hand-written and scoped to
.br-* classes only — nothing in the sheet can match host content, and :root
carries custom properties and nothing else. Both invariants are asserted in the
unit tests, and an e2e spec snapshots a host page's computed styles before and
after mount to prove nothing moved.
Browser support
Last 2 versions of Chrome, Edge, Firefox and Safari. Screenshot capture
additionally requires getDisplayMedia; everything else degrades cleanly
without it.
Examples
examples/plain-html/iife.html— script tag, with a live launcher-variant switcherexamples/plain-html/auto-init.html— zero host JavaScriptexamples/plain-html/esm.html— ES module import with event listenersexamples/react/App.tsx—useEffectmount/unmountexamples/nextjs/BugReportClient.tsx— App Router client component
Run pnpm build first — the examples load dist/ by relative path.
Development
pnpm install
pnpm build # inline CSS, then four bundles
pnpm typecheck # src + tests (tsup does not typecheck)
pnpm test # vitest, 237 unit tests
pnpm e2e # playwright, 45 specs incl. the axe AA gate
pnpm e2e:capture # screen-capture specs — needs a HEADED browser
pnpm size # gzip budget gate (30 KB)
pnpm typecheck:examplesThe design source of truth is docs/design-reference.html.
Publish (npm)
The package is public: @digifinn/bug-report-widget. Digifinn portals install it from the registry — not a vendor/*.tgz.
npm login
npm version patch
npm publish --access public
git push && git push --tagsprepublishOnly runs tests and the production build. After publish, portal lockfiles still pin the old version until someone runs npm update @digifinn/bug-report-widget (or npm install @digifinn/bug-report-widget@latest).
Digifinn portals
Product, QA, and backend contract (submit URL, Turnstile, admin inbox, error catalog): BUG-REPORTING.md.
Intended install after this version is on npm:
"@digifinn/bug-report-widget": "^0.3.0"Same pattern as @digifinn/accessibility-widget. After publish, run npm update @digifinn/bug-report-widget so the lockfile moves.
License
MIT
