This document is the authoritative contract for the @dignetwork/components package. An
independent reimplementation of any exported component MUST interoperate with the deployed
bugreport.dig.net service (or a compatible apiBase) exactly as described here.
- Name:
@dignetwork/components. Distribution: ESM (dist/index.js) + CJS (dist/index.cjs) + TypeScript declarations (dist/index.d.ts), built withtsup. - Peer dependencies:
reactandreact-dom, both>=18.0.0. Neither is bundled — a bundled/duplicated React instance is a bug (breaks hooks via the "invalid hook call" class of failure when two React copies exist in one page). - CSP: the bundle contains no
eval, nonew Function(...), and no other dynamic code-generation. It runs under a strictscript-src 'self'CSP with nounsafe-eval. No external resource (font, image, stylesheet, CDN script) is ever referenced. - Side effects:
sideEffects: false. Importing any export must not mutate global state by itself; global state (console/network wrapping, event listeners, the scoped stylesheet) is only installed while a component that needs it is mounted, and torn down on unmount. - Structure: each component lives in its own
src/<ComponentName>/directory (component + types + supporting modules + tests, co-located) and is re-exported from the package rootsrc/index.ts. New components are added the same way; this file gains a new numbered section per component. - Exports:
BugReportButton(component),resolveAppVersion(helper, §2.8), and the public types (BugReportButtonProps,BugReportButtonTheme,ConsoleLogEntry,ConsoleLogLevel,NetworkLogEntry,ChallengeResponse,ReportPayload,SubmitReportResult,IssueRef).
| Prop | Type | Required | Default |
|---|---|---|---|
repo |
string |
yes | — |
apiBase |
string |
no | "https://api.bugreport.dig.net" |
position |
"bottom-right" | "bottom-left" |
no | "bottom-right" |
appVersion |
string |
no | auto-detected (§2.8) |
theme |
{ accentColor?: string; accentColorSecondary?: string } |
no | { accentColor: "#7a3dff", accentColorSecondary: "#c13de0" } |
messages |
Partial<BugReportMessages> |
no | English defaults (DEFAULT_MESSAGES, §2.10) |
repo is passed through verbatim to the server, which validates it against its own allowlist
(§3.4 of bugreport.dig.net's SPEC.md). This package does not itself validate repo — the server
is the single source of truth for the allowlist so it can change without a client release.
Theming: the accent pair drives a purple→magenta gradient (launcher, header icon, primary button,
success badge). When only accentColor is provided, accentColorSecondary defaults to the same
value (a solid accent, never a mismatched gradient).
The component has one boolean, panelOpen, and one status enum driving the panel body:
idle → sending → success
↑ ↓
└──── error
- idle — the form is editable.
Send reportis disabled whiledescriptionis empty or whitespace-only. - sending — set synchronously on submit; the form is disabled (all inputs + the submit
button); the
aria-live="polite"status region announces "Sending report…". - success — set when the server accepts the report (
200/202). Shows a confirmation and the opaque reportidas a reference the user can quote. The GitHub issue reference the API may return is NOT surfaced — issue URLs are maintainer-internal navigation, not end-user UI; a client MUST NOT render a link to it. The form is unmounted in favor of the success view; closing the panel (Escape, the × button, or "Done") is the only action available. - error — set on any non-accepted outcome. The form REMAINS mounted with the user's draft intact (title/description/contact/screenshot/diagnostics selection are never cleared on error) so a retry costs nothing. The submit button doubles as the retry action; its label changes to "Retry".
Opening the panel (the launcher click while closed) performs, IN THIS ORDER:
- Synchronously snapshots the page DOM for the automatic screenshot (§2.3) BEFORE the panel mounts — the ordering is a contract: the shot must show the page exactly as the user saw it, with no report UI in it.
- Resets the draft to a blank state (title/description/contact/screenshot cleared, disclosures collapsed).
- Records
openedAt = Date.now()(sent asopened_at_ms). - Calls
GET {apiBase}/v1/challenge(§2.4) and stores the resulting token. - Mounts the panel; when the (asynchronous) rasterization completes, the screenshot preview is filled in — unless the user already attached an image, or the panel session that started the capture has been closed (a stale capture is discarded).
Closing the panel does not send anything and does not need network access.
Three tiers, strictly ordered. Every tier is best-effort (failure falls through, never throws) and produces a PNG/JPEG data URL that is previewed in the panel with a Remove affordance.
- Automatic (default): in-page DOM rasterization (
captureViewportScreenshot). No permission prompt, no picker, andgetDisplayMediais NEVER invoked automatically — a client that auto-triggers the screen-share picker is non-conforming. Mechanics (self-contained, no external library or network dependency):- The page DOM is deep-cloned synchronously at open time, before the panel mounts. Every
element carrying the
data-dig-bugreportmarker attribute (the widget stamps it on its launcher and overlay roots) is excluded, subtree and all.script/style/link/iframe/ media elements are dropped; each remaining element's computed style is inlined; live form state is reflected (never password or file input values);<canvas>content is snapshotted where readable. - Asynchronously (on the detached clone only):
<img>sources are inlined as data URLs (same-origin/CORS fetch with a short timeout; unfetchable images are stripped),srcsetand externalurl(...)style references are removed (an SVG rendered as an image may not load any external resource). - The clone is serialized into
<svg><foreignObject>markup, loaded as a data-URL image, drawn to a canvas at CSS-pixel scale (1×, keeping the payload well under the server's decoded-size cap) over a backfill of the page's background color, and exported viacanvas.toDataURL("image/png").
- The page DOM is deep-cloned synchronously at open time, before the panel mounts. Every
element carrying the
- Explicit opt-in: screen capture (
captureScreenViaDisplayMedia), bound to the panel's "Capture screen" button ONLY — for content DOM rasterization can't render (cross-origin iframes, WebGL). Before invokingnavigator.mediaDevices.getDisplayMedia, the component hides its own chrome (launcher + overlay getvisibility: hidden) for the duration, so the captured frame cannot include the report panel covering the page. One frame is drawn to a canvas and exported as PNG; all media tracks are stopped immediately after. - Manual: file attach —
<input type="file" accept="image/png,image/jpeg">rendered as a styled control (§2.7); the file is read viaFileReader.readAsDataURLand replaces any current screenshot.
Nothing captured by this section is ever sent automatically — it is included in the next
POST /v1/reports call ONLY if still present in state when the user presses Send report. There is
no client-side size-capping or re-encoding beyond the 1× rasterization scale; the server enforces
the size cap (2 MiB decoded) and rejects oversized/invalid images.
This is the exact wire contract; the bugreport.dig.net service implements the server side to match. A client MUST NOT deviate from field names, types, or the request sequence below.
On panel open — GET {apiBase}/v1/challenge:
- Response
200:{ "token": "<opaque string>", "exp": <number, epoch ms> }. - The
tokenis held in memory for the lifetime of the open panel; it is not persisted (not localStorage, not cookies). - On any failure (network error or non-2xx), the token is left unset; the component will retry the fetch transparently the next time the user presses Send report (see below) before giving up with an honest error.
On submit — POST {apiBase}/v1/reports, JSON body:
{
repo: string,
title?: string,
description: string, // required, non-empty after trim
reporter_contact?: string,
url?: string, // window.location.href at submit time
user_agent?: string, // navigator.userAgent
app_version?: string, // resolveAppVersion(appVersion) — §2.8
console_logs?: ConsoleLogEntry[], // console entries + network entries (§2.5/§2.6), merged
// chronologically by ts_ms; present only when non-empty
screenshot?: string, // data URL, present only when one is attached
challenge_token: string, // from the most recent /v1/challenge call
hp: string, // honeypot — ALWAYS "" for a human; see below
opened_at_ms: number, // Date.now() when the panel opened
}challenge_token— if no token is held (initial fetch failed), the component fetches one fresh, synchronously blocking the submit, before sending. If that also fails, the submission is aborted with an honest error and NO request is sent to/v1/reports.hp— bound to a real<input name="email_confirm">rendered off-screen (position: absolute; left: -9999px),tabIndex={-1}, andaria-hidden="true"— invisible and unreachable for a sighted, keyboard, or assistive-technology user, but present in the DOM for a bot that blindly fills every input on the page. The client NEVER inspects or blocks on this field's value — it is forwarded verbatim; the server is the sole enforcement point (a non-emptyhpmarks the submission as a bot server-side).opened_at_ms— the server uses this to reject implausibly-fast submissions (a real human cannot fill and submit the form in a few hundred milliseconds). The client does not enforce a minimum delay itself; it simply reports the true value.
Response handling (discriminated purely on HTTP status + body, never inferred from timing):
| Status | Client outcome |
|---|---|
200 or 202 |
accepted — { id, issue } from the body drive the success view. issue is null when no GitHub issue was created (still a successful report). The UI shows id only; issue is never surfaced (§2.2). |
403 |
challenge_expired — the component transparently calls GET /v1/challenge again to obtain a fresh token, then enters the error state with a message telling the user to press Send report again. The retry reuses the freshly fetched token. |
429 |
rate_limited — error state with the server's message (or a generic "sending too quickly" message if none was provided). |
| any other non-2xx, or a network failure | error — error state with the server's message (or a generic failure message). |
No outcome is ever silently dropped; every branch above ends in a state the user can see.
- Installed from the component's mount effect (not panel-open) via
installConsoleCapture(300)(src/BugReportButton/consoleCapture.ts) — a ring buffer capped at 300 entries capturingconsole.debug/log/info/warn/error(always calling through to the original method), pluswindowerrorandunhandledrejectionevents. - Entries are
{ level, ts_ms, text }.level∈debug | log | info | warn | error | network(networkis reserved for merged network entries, §2.6; console capture itself never emits it). Buffer eviction is FIFO (oldest dropped first) once the cap is reached. - Capture is memory-only: nothing is persisted (no storage) and nothing is transmitted by capture itself — only a report the user explicitly sends includes the buffer's current contents.
- The panel exposes the buffer via the Console errors disclosure (§2.7), default collapsed, showing the entry count in a badge. A Remove from report action clears the buffer immediately, which also drops it from the next submission.
- Uninstalled (original console methods restored, listeners removed) on the component's unmount.
- Installed from the same mount effect via
installNetworkCapture(100)(src/BugReportButton/networkCapture.ts): wrapswindow.fetchandXMLHttpRequest.prototype.open/sendinto a ring buffer capped at 100 entries. - What is recorded: a request that THROWS (network failure / CORS / abort — for XHR, status 0
at
loadend), or resolves with HTTP status ≥ 400. Successful requests are never recorded. - Entry shape:
{ method, url, status, duration_ms, ts_ms }wherestatusis the HTTP status number or the literal"failed", andts_msis when the request started. - Privacy: the recorded
urlis sanitized — query string and hash are ALWAYS stripped (they routinely carry tokens/PII) and the result is truncated to 200 chars. Request/response BODIES and HEADERS are never captured. - Pure passive observation (hard rule): the wrap forwards every argument untouched, returns
the exact same
Response/result object, and rethrows the exact same rejection reason. All recording is fail-silent — a capture bug must never break or alter a host-app request. The originals are restored on unmount. - Payload merging: at submit, network entries are formatted as
ConsoleLogEntrys withlevel: "network"andtext: "<METHOD> <url> → <status> (<duration>ms)"(e.g.GET https://api.example/data → 502 (1240ms)), merged with the console buffer, sorted byts_ms, and sent in the SAMEconsole_logsfield — no dedicated payload field, so the server and GitHub-issue rendering need no change. - The panel exposes the buffer via the Network errors disclosure (§2.7), default collapsed, count-badged, with its own Remove from report action (clears the buffer + excludes it from the next submission).
- The Diagnostics area always shows an instruction line: when either buffer is non-empty, it says errors are captured automatically and asks the user to describe anything not listed; when both are empty, it asks the user to include any error they saw in the description.
- Scoped stylesheet. All styling lives in one stylesheet string (
buildStylesheet()insrc/BugReportButton/styles.ts), injected as a<style id="digbr-styles">element while at least one instance is mounted (ref-counted; removed after the last unmount). Every rule is scoped under adigbr-class prefix — no global selectors, no element selectors, nothing that can leak into or depend on host CSS. No external resource is referenced (CSP-safe); icons are inline SVG. - Theming is per-instance via CSS custom properties (
--digbr-accent,--digbr-accent-2) set inline on the widget roots from thethemeprop. - Dark mode via
@media (prefers-color-scheme: dark)(full token swap — surfaces, inks, borders); motion (panel entrance, hover lifts, success pop) is disabled under@media (prefers-reduced-motion: reduce); small screens (≤520px) render the panel as a full-width bottom sheet. - Structure: launcher = a 56px circular gradient FAB with an inline-SVG bug glyph; panel = an elevated card (gradient hairline, header with icon chip + title + subtitle + close, scrollable body) containing the form, the screenshot section (framed preview thumbnail + overlay Remove pill + "Attach image"/"Replace image" file control + "Capture screen" opt-in), the Diagnostics disclosures, the error banner, the primary Send button, and a privacy footnote.
- The console/network sections use the ARIA disclosure pattern: a real
<button>witharia-expanded+aria-controlsreferencing an always-present region (hiddenwhen collapsed) — NOT a native<details>/<summary>. - The file-attach control is a styled twin covered by the real (opacity-0, full-size) file input, so the visible button IS the input's native hit target.
resolveAppVersion(explicit?: string): string | undefined (exported) resolves the value sent as
app_version, first non-empty match wins:
- the
appVersionprop, <meta name="app-version" content="…">in the document,window.__APP_VERSION__when it is a string,undefined(the field is omitted from the payload).
Values are trimmed; empty/whitespace-only and non-string values are skipped. This lets a host app expose its version once (meta tag or global) instead of plumbing a prop to every render site.
Conformance target: WCAG 2.2 AA, machine-verified with @axe-core/playwright on the OPEN
panel (tags wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa) with no rule suppressions —
including target-size.
- Launcher: a real
<button>,aria-label="Report a bug",aria-haspopup="dialog",aria-expandedreflecting panel state. - Panel:
role="dialog",aria-modal="true",aria-labelledbypointing at the panel heading. The panel's title bar is a plain container (NOT a<header>element): arole="dialog"does not scope a descendant<header>out of its implicitbannerrole, and the host app shell already owns the document's single banner landmark. Introducing a second banner fails WCAG 2.2 (axelandmark-unique), so the widget MUST NOT emit abanner(or any other page landmark). - Target size (WCAG 2.5.8): every interactive target is at least 24×24 CSS px; the launcher (56px) and the primary Send/Done action (44px min-height) exceed the comfort size. The two controls that historically failed (the file-input control and the console toggle) are now a full-size covered file input (§2.7) and a ≥40px disclosure button.
- Focus trap (
src/BugReportButton/useFocusTrap.ts): on open, focus moves to the first focusable element in the panel; Tab/Shift+Tab cycle within the panel's focusable elements only (never escaping to the page behind it);Escapecloses the panel; on close, focus returns to whatever had it before the panel opened (in practice, the launcher button). - The honeypot input is excluded from the tab order (
tabIndex={-1}) and from the accessibility tree (aria-hidden="true"), on top of being visually off-screen — three independent hides so no real usage path (visual, keyboard, or screen-reader) can reach it. - A
role="status"aria-live="polite"region announces state transitions (sending / success / error text) to assistive technology; it is visually hidden (clip-based, notdisplay: none, so it stays in the accessibility tree) since the same information is also shown visually. - Motion respects
prefers-reduced-motion(§2.7). - Every form control has an associated
<label htmlFor>oraria-label; the scrollable log lists are keyboard-focusable (tabIndex={0}) witharia-labels. - Stable
data-testids are attached to every interactive element and state container (see the component source for the full list) so scripted/agent clients can drive the panel deterministically. The 0.1.0 test ids are preserved; 0.1.1 addsbugreport-network-details,bugreport-network-toggle,bugreport-network-list,bugreport-network-remove,bugreport-screen-capture, andbugreport-diagnostics-hint.
Every user-visible string the widget renders is sourced from a typed dictionary,
BugReportMessages (exported), so a host app can present the widget in any of the ecosystem's
locales instead of the untranslated English literals. The mechanism is a single optional prop:
messages?: Partial<BugReportMessages>— a partial override the host supplies from its own i18n catalog. The widget merges it overDEFAULT_MESSAGES(the exported canonical English copy, the single source of defaults):{ ...DEFAULT_MESSAGES, ...messages }. Any omitted key falls back to its English default, so a host may translate any subset. Omittingmessagesentirely renders the exact English defaults — the prop is purely additive and backwards-compatible; an existing consumer that passes nomessagesbehaves identically to before it existed.- Coverage.
BugReportMessageshas one field per user-visible string: the launcher/closearia-labels, the panel heading + subtitle, every field label + placeholder (title, description, contact) + the "(optional)" suffix, the screenshot section (label, imagealt, captions, attach/replace button +aria-label, capture-screen button + caption, remove button), the diagnostics section (label, both hint variants, the console/network disclosure labels + listaria-labels, the empty-log placeholder, the remove-from-report button), the submit button (its default/sending/retry labels) + footnote, the threearia-livestatus texts, the success view (heading, note, done button), and the four fallback error strings (session-start, session-expired, rate-limited, network-error, request-failed). - Brand/scheme tokens are NON-translatable.
$DIG,XCH,DIGHub,chia://, anddig://MUST be preserved verbatim inside any translated string. The current copy embeds none; a future string needing one keeps the token as a literal constant so translation can never mangle it. - The
api.tsmodule stays UI-agnostic. The two fallback error strings it must synthesize (transport failure, non-2xx with no error envelope) are passed IN by the component (submitReport(apiBase, payload, fallbacks)) rather than hardcoded there — the component owns the copy, the api layer owns the wire contract. For the request-failed fallback the failing HTTP status is appended as" (<status>).".
- The wire contract in §2.4 MUST match bugreport.dig.net's own
SPEC.md(the server side of the same contract) byte-for-byte in field names and semantics. A change to either side is made in the same unit of work as the other, per the ecosystem'sSYSTEM.mdcross-repo interaction rule. - Field names are wire contracts and are never renamed; new optional fields may be added
additively. The
"network"console-log level (§2.6) is an additive value within the existingconsole_logsfield.