# @sphoro/bugcatch-web-sdk

A browser SDK that lets your customers report a problem from inside your app, and
attaches what you'd otherwise have to ask them for: console output, network
activity, unhandled errors, the environment, and — when you tell it — who the
customer is and which loan, application or account they were looking at.

No extension, no dependencies. The widget renders in a shadow root, so its CSS and
yours never collide.

- [Quick start](#quick-start)
- [Tell BugCatch who the customer is](#tell-bugcatch-who-the-customer-is) — with loan / customer IDs
- [Context for the current screen](#context-for-the-current-screen)
- [A lending app, end to end](#a-lending-app-end-to-end)
- [Events](#events)
- [Reporting and opening from your own UI](#reporting-and-opening-from-your-own-ui)
- [The AI assistant](#the-ai-assistant)
- [Privacy: what is captured and what is not](#privacy-what-is-captured-and-what-is-not)
- [Options](#options)
- [API at a glance](#api-at-a-glance)
- [Troubleshooting](#troubleshooting)
- [Reference](#reference) — attachments, sign-in, live updates, replies, known issues, calls

## Quick start

You need two values from BugCatch:

| Value | Where to find it |
|---|---|
| `apiKey` | Workspace Settings → API Keys (`bc_live_…`; shown once when created) |
| `projectId` | Project Settings → Web SDK (the snippet there is pre-filled) |

and the URL of your BugCatch API (`apiBase`).

**npm / bundler**

```bash
npm install @sphoro/bugcatch-web-sdk
```

```js
import BugCatch from '@sphoro/bugcatch-web-sdk';

BugCatch.init({
  apiBase: 'https://api.bugcatch.yourdomain.com',
  apiKey: 'bc_live_…',
  projectId: '…',
  release: '4.12.0',           // optional, shown on every report
  environment: 'production',   // optional
});
```

**Script tag**

```html
<!-- Pin the version. Without one the CDN serves whatever is latest, so a
     future release changes behaviour on a site nobody redeployed. -->
<script src="https://unpkg.com/@sphoro/bugcatch-web-sdk@0.19.0/dist/bugcatch.umd.js"></script>
<script>
  BugCatch.init({ apiBase: '…', apiKey: 'bc_live_…', projectId: '…' });
</script>
```

A floating **Report a bug** button appears, and whatever the page was doing is
attached to each report. Types ship with the package (`src/index.d.ts`), so
editors autocomplete every option and method below.

The script is the launcher and the capture, and nothing more. The report panel
and the call code are separate files in `dist/` — `bugcatch-panel.umd.js` and
`bugcatch-call.umd.js` — fetched from the folder the script was loaded from the
first time they are needed: the panel once the page has settled (or when the
button is pressed, if that comes first), the call code when a call is offered.
From a bundler, the same two parts are `import()`ed and your bundler splits them.

- **Content-Security-Policy:** allow the host in `script-src`, not the one file.
  A `nonce` on the script tag is carried over to the parts it loads.
- **Hosting it yourself:** copy every `dist/bugcatch*.umd.js` into one folder
  and load `bugcatch.umd.js` from it. If the script cannot know its own address
  — pasted inline, or concatenated into one of your files — point it at the
  folder with `init({ chunkBase: '/vendor/bugcatch/' })`, or serve
  `dist/bugcatch.standalone.umd.js`, which is all of it in one file.

Call `init()` once, as early as you can — console and network capture start at
`init()`, so anything before it is not recorded. `identify()` and `setContext()`
may be called before or after it.

## Tell BugCatch who the customer is

```js
BugCatch.identify({
  id: customer.id,                  // your customer's own account id
  email: customer.email,
  name: customer.fullName,
  phone: customer.mobile,           // include the country code: '+919876543210'
  customerNumber: customer.number,  // your customer / client number
  loanId: customer.activeLoanId,    // a loan they hold
  applicationId: customer.appId,    // a loan application
});

// On sign-out
BugCatch.identify(null);
BugCatch.setContext(null);
BugCatch.forgetReports();   // so the next person on this device doesn't see their updates
```

Every field is optional; send what you have. What each one does:

| Field | Type | What BugCatch does with it |
|---|---|---|
| `id` | string \| number | The customer's account id in **your** system. Used to look them up in your connected data source (as `userId`). Never pass a staff or agent id here. |
| `email` | string | Threads their reports together in the dashboard, and is where the "your issue is resolved" email goes. Treated as reachable because your app authenticated them. Needed for live updates and calls. |
| `name` | string | Shown instead of a bare address. |
| `phone` | string \| number | With `email`, compared against the record BugCatch looked up, to confirm the reporter really is that customer before anything is done on their behalf. |
| `loanId` | string \| number | Looked up as a **loan** (`loanId`). Alias: `loanNumber`. |
| `applicationId` | string \| number | Looked up as a **loan application** (`loanId`). |
| `customerNumber` | string \| number | Looked up as a **customer** (`customerNumber`). Alias: `customerId`. |
| anything else | primitive or small object | Kept as readable metadata on the report. Not used for lookups. |

**Why the IDs matter.** When your project has a data source connected (MCP), the
AI assistant looks the customer up *before* it reasons about the report. IDs your
app passes are **trusted identifiers**: your application asserted them, so the
assistant uses them directly instead of asking the customer "what is your loan
number?", and a record found by one of them counts as belonging to the reporter.
IDs a customer types into the report are still read — but as hints, not facts.

Rules the SDK enforces (with a console warning, never an exception):

- Identifiers must be strings or numbers, at most 120 characters, with no control
  characters. Anything else is dropped — a truncated loan number is a different loan.
- An object, array or function passed as an identifier is dropped.
- `identify('u_42')` (not an object) is ignored and keeps the previous user.
  Only `identify(null)` signs out.
- Extra fields: up to 30, strings capped at 500 characters, nested objects at 1 kB of JSON.

## Context for the current screen

`identify()` says who the customer is. `setContext()` says what they are looking
at — so a report filed from the repayment screen of one loan carries that loan,
even when the customer holds three.

```js
// Entering a loan's screen
BugCatch.setContext({ loanId: loan.id, screen: 'repayment' });

// Leaving it — null removes one key
BugCatch.setContext({ loanId: null });

// Clear everything
BugCatch.setContext(null);

BugCatch.getContext();   // { screen: 'repayment' }
```

- Merged over what was set before, and kept until changed, for the life of the
  page (not across reloads — call it again when the screen mounts).
- `loanId`, `applicationId` and `customerNumber` are validated and trusted exactly
  as on `identify()`, and **outrank** the same field there: if the customer holds
  loan A but files from loan B's screen, B is what gets looked up.
- Anything else (`screen`, `step`, `route`, a feature flag) is readable metadata.
- For a single report, pass `context` to `report()` instead — it is merged for
  that report only and does not change what `setContext()` holds.

In a single-page app, the natural place is your router:

```js
router.afterEach((to) => {
  BugCatch.setContext({
    screen: to.name,
    loanId: to.params.loanId ?? null,
    applicationId: to.params.applicationId ?? null,
  });
});
```

## A lending app, end to end

```js
import BugCatch from '@sphoro/bugcatch-web-sdk';

BugCatch.init({
  apiBase: 'https://bugcatch-api.example.com',
  apiKey: 'bc_live_…',
  projectId: '…',
  release: APP_VERSION,
  environment: 'production',
  onReportSubmitted: ({ bugId }) => analytics.track('bug_reported', { bugId }),
  onAssistantPhase: ({ phase }) => {
    if (phase === 'needs_info') showBadge('We need one more detail from you');
  },
});

auth.onSignIn((customer) => {
  BugCatch.identify({
    id: customer.userId,               // e.g. 'dc046b84-3314-46ae-9b8f-526d3e50bc50'
    email: customer.email,
    name: customer.name,
    phone: customer.mobile,            // '+919876543210'
    customerNumber: customer.customerNumber,   // e.g. 'C1234567'
  });
});

auth.onSignOut(() => {
  BugCatch.identify(null);
  BugCatch.setContext(null);
  BugCatch.forgetReports();
});

// Loan details / repayment / EMI screens
function onLoanScreen(loan) {
  BugCatch.setContext({ loanId: loan.formattedLoanId, screen: 'loan-details' });   // 'L26090028459'
}
```

**How the IDs travel.**

1. The SDK sends `identify()` fields as `metadata.user` and `setContext()` as
   `metadata.context` on every report (and on "me too" joins).
2. The API reads them — only on the SDK's authenticated route, never from a public
   recording link — and stores them as references with source `identify`:
   `id` → `identify_user_id`, `loanId` → `auto_loan`, `applicationId` →
   `auto_application`, `customerNumber` → `auto_customer`.
3. The analysis looks each one up as what it is (`loanId`, `customerNumber`,
   `userId`, loans first) through your data source's tools, and marks the join
   trusted only when the record it found came from one of these IDs.
4. `phone` and `email` from `identify()` are what the ownership check compares
   with the record, before the assistant acts for the customer.

## Events

Every event is available two ways: as a single callback in `init()`, or through
`on()`, which several parts of your app can use independently. `on()` returns an
unsubscribe function. A handler that throws is logged and never breaks the SDK.

| `on(…)` event | `init` option | Payload | When |
|---|---|---|---|
| `'report'` | `onReportSubmitted` | `{ bugId, title, via: 'form' \| 'call', result }` | A report was accepted — from the widget, `report()`, or pressing Call. |
| `'assistant'` | `onAssistantPhase` | `{ bugId, phase, title, detail }` | The AI assistant working a report changed phase — see [The AI assistant](#the-ai-assistant). |
| `'error'` | `onError` | `{ action: 'report' \| 'call', error }` | A report or call failed. `error.status` is set for an HTTP refusal. |
| `'update'` | `onUpdate` | `ReportUpdate` | A report filed from this browser changed (status, reply, …). |
| `'unread'` | — | `{ count, updates }` | The unread count changed — for your own bell. |
| `'call'` | `onCallState` | `{ bugId, state, … }` | A call moved: `ringing`, `connecting`, `connected`, `missed`, `ended`, `failed`. |
| `'incoming-call'` | `onIncomingCall` | `{ bugId, callId, state }` | The team is ringing this page. |
| `'signin'` / `'signout'` | — | `{ email, name }` | The reporter signed in or out through the widget. |

```js
const off = BugCatch.on('report', ({ bugId, via }) => {
  console.log(`filed ${bugId} via ${via}`);
});
off();   // stop listening
```

## Reporting and opening from your own UI

Open the built-in panel from your own menu item:

```js
helpMenu.onReportProblem = () => BugCatch.open();
BugCatch.close();
BugCatch.isOpen();
```

Or turn the widget off and file reports yourself — everything captured is still
attached:

```js
BugCatch.init({ apiKey, projectId, apiBase, widget: false });

const result = await BugCatch.report({
  title: 'EMI paid but still showing due',   // required
  description: 'Paid ₹4,210 by UPI this morning',
  priority: 'HIGH',                          // LOW | MEDIUM | HIGH | CRITICAL
  context: { loanId: 'L26090028459' },       // this report only
  references: [{ key: 'utr', value: '4321…' }],   // what the customer typed, if you asked
  metadata: { paymentMethod: 'upi' },
});
// → { id, redactionsApplied, watchToken?, estimate? }  or { cancelled: true } from beforeSend
```

`report()` rejects with an `Error` whose message says what went wrong
(`[BugCatch] report failed (HTTP 401): the apiKey was not accepted`), and emits
`'error'`. Malformed `references` entries are dropped rather than failing the report.

## The AI assistant

When your workspace has AI analysis on, every report is investigated as soon as
it arrives: the assistant reads the report, the captured logs and — with a
connected data source — the customer's own records, looked up by the IDs above.
After a report is filed, the widget opens its conversation, where the reporter
watches progress and answers questions.

| Phase | What the reporter sees |
|---|---|
| `analyzing` | "Analyzing the issue…" — reading the report, the account and the logs. |
| `needs_info` | It asked a question (often: which loan?) and is waiting for the reporter's reply. |
| `confirming` | It found the likely cause and asks the reporter to confirm. |
| `resolving` | The cause is confirmed, or it is being handled. |
| `reviewing` | Handed to your team, who will reply in the thread. |
| `resolved` | Settled. |

The phase is known when a report is fetched: the widget does this every few
seconds while its thread is open, and `BugCatch.myReport(bugId)` does it for your
own view (it also returns `report.assistant`). Each change fires
`onAssistantPhase` / `'assistant'` once.

### Questions it answers and closes by itself

When the project has the Sphoro Agent answering questions, the widget opens
with one-tap questions: *Where is my application?*, *When is my next EMI due?*,
*Is my KYC complete?*, *My application was rejected — please review it again*,
and others. It only shows the ones your connected data source can answer. The
assistant looks the customer up, answers from their own record, and asks
*"Did this answer your question?"*. **Yes** resolves the report, **No** hands it
to your team, and no reply in two days closes it (any later reply reopens it).
A rejected-application request is explained and passed to your team, who move
the customer in your system and mark it done.

Building your own form (`widget: false`)? Offer the same list:

```js
const issues = await BugCatch.agentIssues();
// [{ key: 'emi', title: 'When is my next EMI due?', hint: 'The date and the amount' }, …]
await BugCatch.report({ title: issues[0].title, issue: issues[0].key });
```

**Passing IDs is the biggest thing you can do for it.** Without them, the first
thing the assistant usually has to do is ask the customer which account or loan
they mean — the `needs_info` step — and wait for an answer.

## Privacy: what is captured and what is not

Captured, and only after `init()`:

- **Console** — the last 100 entries (level, message, time), plus uncaught errors
  and unhandled promise rejections. Whatever your app logs is captured, so do not
  log tokens or personal data — or drop those entries in `beforeSend`.
- **Network** — the last 50 `fetch`/XHR calls: URL, method, status, duration.
  **Request and response bodies are never read.** Credentials in URLs (API keys,
  tokens, signatures, JWTs, `user:password@`) are replaced with `[REDACTED]` before
  the entry is even buffered.
- **Environment** — browser, OS, language, viewport and screen, timezone,
  connection type, page title and referrer.
- **The page URL** when the report is filed (credential-scrubbed the same way).
- **Only what you pass** to `identify()`, `setContext()` and `report({ metadata })`.
  The SDK never reads your cookies, your app's storage or what is typed into your forms.
- **Screenshots and recordings** only when the reporter chooses to take one; the
  browser always shows its own permission prompt.

On top of that:

- `beforeSend(payload)` runs in the browser before anything leaves it — mutate the
  payload or return `false` to cancel.
- Your workspace's redaction rules (Workspace Settings → Security) run again on the
  server before anything is stored; encrypted workspaces seal it at rest.
- `captureConsole`, `captureNetwork` and `captureErrors` can each be switched off.
- The SDK stores in the reporter's own browser only: a watch token for their reports,
  unread update notices, unfinished drafts (7 days), and a widget sign-in if used.
  `forgetReports()` clears the first two.

## Options

| Option | Default | What it does |
|---|---|---|
| `apiKey` | *required* | Workspace API key (`bc_live_…`). |
| `projectId` | *required* | Project the reports land in. |
| `apiBase` | `http://localhost:3000` | Your BugCatch API URL. **Set it everywhere except local development.** A trailing slash is removed. |
| `user` | `null` | Same as calling `identify()` right after `init()`. |
| `context` | `null` | Same as calling `setContext()` right after `init()`. |
| `release` / `environment` / `buildId` | — | Attached to every report. |
| `debug` | `false` | Log identify, context and each report's shape to the console. |
| `onReportSubmitted` | — | See [Events](#events). |
| `onAssistantPhase` | — | See [Events](#events). |
| `onError` | — | See [Events](#events). |
| `widget` | `true` | Floating launcher and panel. `false` to drive everything from your own UI. |
| `widgetPosition` | `'bottom-right'` | `bottom-left`, `top-right`, `top-left`. |
| `theme` | `'auto'` | `auto` follows the system; `light` / `dark` pin it. Visitors can change it from the panel's header, and their choice wins on later visits. |
| `accent` | `null` | Any CSS colour, for the launcher, buttons and the assistant's orb. Text on it is white or near-black, whichever reads. `null` is ink: black on light, white on dark. |
| `captureConsole` / `captureNetwork` / `captureErrors` | `true` | Each capture can be switched off. |
| `consoleLimit` / `networkLimit` | `100` / `50` | Entries retained. |
| `attachments` | `true` | Offer screenshot, recording and file attach. |
| `maxAttachments` / `maxAttachmentBytes` / `maxRecordingMs` | `5` / `25 MB` / `120000` | Attachment limits. |
| `drafts` | `true` | Recover an unfinished report on this device for seven days. |
| `agentIssues` | `true` | Show the one-tap questions the assistant answers by itself. Draws nothing until the project turns the agent on. |
| `knownIssues` | `true` | Show what this project already knows is broken, with "me too". Draws nothing until the project turns it on. |
| `announcements` | `true` | Show what your team publishes under Settings → Project → Widget announcements, in the panel and beside the button. Draws nothing until one is published. `false` to show them your own way with `announcements()`. |
| `beforeSend` | — | Mutate the payload, or return `false` to cancel. |
| `updates` | `true` | Tell the reporter live when a report they filed moves. |
| `updateToast` | `true` | Show the built-in notification; `false` to render your own from `'update'` / `'unread'`. |
| `onUpdate` | — | Called on every update. |
| `voice` | `true` | Offer calls where the workspace has calling configured. `false` switches calls off in both directions. |
| `onCallState` / `onIncomingCall` | — | See [Talking a report through](#talking-a-report-through). |
| `chunkBase` | the script's own folder | Script tag only: where the panel and call code are fetched from. Set it when the bundle is inlined or its `dist/` files are served elsewhere. |

An option the SDK does not know is reported in the console with a suggestion
(`unknown option "projectID" — did you mean "projectId"?`).

## API at a glance

```js
BugCatch.init(options);                   // once
BugCatch.identify({ id, email, name, phone, loanId, applicationId, customerNumber });
BugCatch.identify(null);                  // sign-out
BugCatch.setContext({ loanId, screen });  // merge; null value removes a key
BugCatch.setContext(null);                // clear
BugCatch.getContext();

BugCatch.open(); BugCatch.close(); BugCatch.isOpen();
await BugCatch.report({ title, description, priority, context, references, metadata, attachments, issue });
await BugCatch.uploadAttachment(blob, mime, onProgress);

const off = BugCatch.on('report' | 'assistant' | 'error' | 'update' | 'unread' | 'call'
  | 'incoming-call' | 'signin' | 'signout', handler);
BugCatch.off(event, handler);

await BugCatch.myReports();               // everything filed from this browser
await BugCatch.myReport(bugId);           // one report, with timeline and assistant phase
await BugCatch.replyToReport(bugId, { body, attachments });
await BugCatch.verifyFix(bugId, { result, resolvedAt, note }); // 'CONFIRMED' | 'STILL_HAPPENING'
await BugCatch.rate(bugId, { score, comment });                 // 1–5, once it is settled
BugCatch.unreadUpdates(); BugCatch.markUpdateRead(id); BugCatch.markReportRead(bugId); BugCatch.markUpdatesRead();
BugCatch.hasReports(); BugCatch.forgetReports();

await BugCatch.loginState(); BugCatch.signedInAs();
await BugCatch.signIn(email); await BugCatch.confirmSignIn(email, code); await BugCatch.signOut();
await BugCatch.referenceFields();         // the identifier question this project asks
await BugCatch.agentIssues();             // one-tap questions the assistant answers and closes

await BugCatch.knownIssues(); await BugCatch.matchKnownIssues(q, { signal }); await BugCatch.meToo(bugId);
await BugCatch.announcements(); BugCatch.dismissAnnouncement(a);  // what your team is telling visitors

await BugCatch.canCallNow(); await BugCatch.startCall(bugId?); BugCatch.endCall();

BugCatch.snapshot();                      // what would be sent right now (user, context, logs)
BugCatch.clear();                         // empty the capture buffers
BugCatch.destroy();                       // restore patched globals, remove the widget
BugCatch.version;                         // the SDK version, for support tickets
```

## Troubleshooting

Turn on `debug: true` first: the console then shows what `init()` received, every
`identify()` / `setContext()` and the shape of each report as it is sent.

| Symptom | Likely cause |
|---|---|
| `[BugCatch] apiKey is required` / `projectId is required` thrown | The option is missing or still the snippet placeholder. |
| Warning: *apiBase is not set, so reports go to http://localhost:3000* | Set `apiBase` to your BugCatch API URL. |
| Warning: *apiKey looks like a project id* | `apiKey` and `projectId` are swapped. |
| `report failed (HTTP 401): the apiKey was not accepted` | Revoked or mistyped key — keys are shown once; create a new one. |
| `report failed (HTTP 403)` | The key belongs to a different workspace than `projectId`. |
| `could not reach https://…` | Wrong `apiBase`, the API is down, or the API's CORS does not allow your site's origin. |
| `This project needs something to look the report up by — …` | The project requires an identifier. Pass `loanId` / `customerNumber` via `identify()` or `setContext()`, or let the widget ask. |
| The assistant keeps asking customers for their loan number | Pass the IDs through `identify()` / `setContext()`; check `BugCatch.snapshot().user` and `.context` show them. |
| `identify()` "did nothing" | It was called with a non-object (ignored with a warning), or an identifier was an object/array/too long. See the console. |
| Reports are not attributed / no resolution email / no live updates / no Call button | `identify()` has no `email`. |
| The panel will not open with `open()` | `widget: false` — there is no built-in panel; use `report()`. |
| *The report form didn't load* above the launcher | `bugcatch-panel.umd.js` could not be fetched from beside `bugcatch.umd.js` — the console names the address. Serve `dist/` together, allow it in `script-src`, or set `chunkBase`. |
| Nothing is captured from before a certain point | Capture starts at `init()`. Call it as early as possible. |

## Reference

### Screenshots, recordings and files

The widget can attach a screenshot, a screen recording, or a file the reporter
picks. Capture goes through `getDisplayMedia`, so **the browser always shows its
own picker and permission prompt** — nothing is captured without the reporter
choosing what to share. The widget hides itself first, so the capture shows the
page rather than this dialog, and a recording collapses the panel to a small
timer bar with **Stop** and **Discard**.

Each attachment uploads as soon as it is added, straight to storage on a
presigned URL — the bytes never pass through the API. That means **Send** is
instant and a failed upload is visible next to the thing that failed rather than
at the end.

Accepted types are `image/png`, `image/jpeg`, `image/webp`, `video/webm` and
`video/mp4`. Anything else is refused in the widget, and again by the API.

Capture needs a secure context (HTTPS or `localhost`) and a browser with
`getDisplayMedia`. Where it is missing the two capture buttons are simply not
rendered — file attach still works. Set `attachments: false` to turn all of it
off.

> **Deploying with `STORAGE_DRIVER=s3`?** The bucket needs its own CORS policy,
> because the upload goes browser → bucket and never touches the API — so the
> API's CORS config never applies to it. Without one the `PUT` is blocked with
> no status at all, which surfaces as a bare network error while the API logs
> look perfectly healthy.
>
> ```bash
> aws s3api put-bucket-cors --bucket <bucket> --region <region> \
>   --cors-configuration '{"CORSRules":[{"AllowedOrigins":["*"],
>     "AllowedMethods":["PUT","GET"],"AllowedHeaders":["Content-Type"],
>     "MaxAgeSeconds":86400}]}'
> ```

### Letting them sign in themselves

`identify()` is your application vouching for its own user. Some projects want
the address proved instead — the reporter types one, we email a code, and they
type it back. That is a **project setting**, not an option here: open **Project
Settings → General → Reporter sign-in** in BugCatch and pick one of

| Setting | What the widget does |
|---|---|
| **Off** *(default)* | No sign-in. Nothing changes from the behaviour above. |
| **Optional** | Offers a sign-in above the form. Anyone who would rather not can still report anonymously. |
| **Required** | No form until they have signed in. Every report then carries an address its owner proved they can read. |

It is a project's own setting because the answer differs per product: a widget
on a public marketing page would lose most of its reports behind a mandatory
sign-in, and one behind a paywall gains little from an anonymous one.

**Anyone can sign in with any address.** There is no list of people allowed to
report a bug in your product — the code proves the address, and that is all a
reply-to has ever needed to be worth. Signing in also gives them their own
report history and live updates without your app doing anything.

**One live session per address per project.** Signing in on a phone signs the
same address out of that project on the laptop, immediately. The widget notices
on its next open and says so rather than failing at the moment they press Send.
Sessions are held per project, so a page mounting two projects' widgets has two
independent sign-ins, and each project's is separate from the browser extension
and from recording links.

The built-in widget draws all of this itself. With `widget: false` you drive it:

```js
const { mode, signedIn } = await BugCatch.loginState();
if (mode !== 'off' && !signedIn) {
  await BugCatch.signIn('alex@example.com');            // a code goes out
  await BugCatch.confirmSignIn('alex@example.com', code); // whatever they typed back
}
```

Where both are present the session wins: on a project that requires sign-in the
address on the report is the one that answered the code, never the one the page
passed to `identify()`. `identify()` still supplies the name and pre-fills the
address, so signing in is one field and one code rather than two.

### Telling the reporter what is happening

The person who found the bug is the one a tracker usually forgets. They are
already in your app, so that is where they hear about it — no email to open, no
page to remember.

It is on by default and needs no setup beyond `identify()`. Once someone has
filed a report, the SDK watches it and shows a small card when something
happens:

```js
BugCatch.init({ apiKey, projectId });
BugCatch.identify({ email: 'alex@example.com', name: 'Alex' });
// Alex reports a bug. Later, someone marks it resolved —
// Alex sees "Your report has been fixed" without leaving your app.
```

Four kinds of update arrive, each with a `summary` written for the reporter:

| `kind` | When | Also carries |
|---|---|---|
| `status` | open → in progress → fixed → closed | `status` |
| `severity` | the team re-prioritised it | `priority` |
| `assignment` | someone picked it up, or put it back | `assigned` |
| `reply` | the team sent them a message | `reply.body`, `reply.author` |

Severity and assignment matter more than they look: someone who reported a
crash and hears nothing cannot tell *triaged and queued* from *ignored*.

#### It stays until it is read

Nothing here is on a timer. A notification used to be shown for eight seconds
and then dropped, which meant the reporter found out only if they happened to be
looking at that tab at that moment — and the update most worth hearing, *your
bug is fixed*, is the one most likely to arrive while somebody is elsewhere.

So each update is held until it is marked read, and read is something the
reporter does rather than something a clock decides:

- **The card carries a Mark as read button**, and nothing else dismisses it — no
  timeout, and no stray click on the page.
- **Unread cards come back after a reload.** The stream replays nothing when it
  reconnects, so they are kept in `localStorage` until they are read.
- **Opening the report reads its updates.** The widget marks a report's cards
  read when its thread opens: a card saying a bug was fixed, over the screen
  saying the same thing, is the notification arguing with itself.
- **Three at a time**, newest nearest the launcher, with anything older counted
  on a line above them and a **Mark all as read** beside it.

Driving your own list is four calls:

```js
BugCatch.unreadUpdates();          // [{ id, bugId, summary, kind, at }, …] oldest first
BugCatch.markUpdateRead(id);       // one — what your own "dismiss" should call
BugCatch.markReportRead(bugId);    // everything about one report, when they open it
BugCatch.markUpdatesRead();        // clear all

// The number for your own bell: fires on every arrival and every mark-read.
BugCatch.on('unread', ({ count }) => setBadge(count));
```

Read state is this browser's, kept beside the watch token and dropped with it —
there is no "read on my laptop, read on my phone", because the SDK deliberately
knows nothing about the reporter beyond having filed from here.

Render them yourself instead:

```js
BugCatch.init({
  apiKey,
  projectId,
  updateToast: false,
  onUpdate: (u) => myToast(u.summary, () => BugCatch.markUpdateRead(u.id)),
});                                      // summary is already phrased for the reporter

// Or subscribe from anywhere — several listeners can coexist.
const off = BugCatch.on('update', (u) => {
  if (u.kind === 'reply') openInbox(u.bugId, u.reply.body);
});
```

With `updateToast: false` the SDK still holds what is unread — your UI is the
only thing that knows when its reader has seen one, so it is the one that has to
say.

Prefer `summary` over building the sentence yourself: the API writes it, so the
card, the widget timeline and the reporter portal all say the same thing.

Turn it off entirely with `updates: false` — no connection is opened at all.

Two things worth knowing:

- **Nothing connects until this browser has filed something.** A visitor who has
  never reported has nothing to be told about, so there is no request on load.
- **Call `BugCatch.forgetReports()` on sign-out.** The subscription lives in
  `localStorage`; without this, the next person at a shared machine inherits the
  previous one's notifications. It takes the unread ones with it.

- **Internal comments never arrive.** Only a comment a team member explicitly
  marked as visible to the reporter becomes a `reply`. The rest are the team
  talking to each other and are not sent, summarised or redacted — they are
  simply not the reporter's.
- **The assignee is never named.** `assigned: true` says the report has an
  owner; who that is stays inside the workspace.

Authentication is a token returned when the report was filed, not your API key —
the key is publishable and sits in your page source, so a key-authenticated feed
keyed on an email address would let anyone read anyone's reports.

### Showing someone their own reports

Once this browser has filed something, the widget grows a **Your reports**
button listing everything they've reported and where each one got to — and
tapping one opens its timeline. No code, no sign-in.

To build your own, the data is two calls:

```js
const { reports } = await BugCatch.myReports();
// [{ id, reference: '3F2A9B01', title: 'Checkout does nothing',
//    status: 'RESOLVED', priority: 'HIGH', createdAt, project, attachments: 1 }]

if (BugCatch.hasReports()) showMyReportsLink();   // hide it until it has content
```

```js
const report = await BugCatch.myReport(id);
report.timeline
// [{ kind: 'filed',      summary: 'You reported this.',                       at },
//  { kind: 'severity',   summary: 'Severity raised to high.',                 at },
//  { kind: 'assignment', summary: 'Someone on the team has picked up your report.', at },
//  { kind: 'reply',      summary: 'The team replied to your report.',
//    reply: { body: 'Confirmed — a fix ships this afternoon.', author: 'Priya' }, at },
//  { kind: 'status',     summary: 'Your report has been fixed.',              at }]
```

The timeline is stored server-side, not accumulated in the browser, so it
survives a reload and a new device — someone who reports a bug on Monday and
checks back on Friday sees the triage and the reply, not just "fixed".

`myReports()` resolves empty when nothing has been filed from this browser, or
when `identify()` supplied no email. `myReport()` resolves `null` when this
browser cannot prove it filed that particular report — a token opens only its
own reports, never another reporter's.

### Letting them answer

The timeline above used to be the end of it: the team could say a fix had
shipped, and the reporter had nowhere to say "still broken on Safari". So
"which browser were you on?" meant leaving your app for email, and the answer
came back attached to nothing.

Opening a report in the widget now opens a **conversation**. Status changes are
the quiet lines between messages; the team's replies and the reporter's sit
either side of them; and there is a box at the bottom that takes text, files,
images and a voice note from the microphone. A reply from the team lands in the
open thread live, without a reload.

Their message reaches the dashboard attributed to them, so somebody sees it.

To drive it from your own UI:

```js
const file = picker.files[0];
const uploaded = await BugCatch.uploadAttachment(file, file.type);

await BugCatch.replyToReport(id, {
  body: 'Still empty on page 3',
  attachments: [{ ...uploaded, name: file.name, size: file.size }],
});
```

Body or attachments — at least one. Messages take more types than a report
does: alongside images and video, a message carries `audio/*` voice notes and
documents (PDF, txt, csv, json, zip, doc(x), xls(x)). A report's own
attachments stay images and video, because they are typed `SCREENSHOT` or
`VIDEO` and a document filed as a screenshot renders as a broken image.

`report.canReply` says whether this reader can write back; the widget hides its
composer when it is false rather than offering a box that would fail on send.

### Asking how it went

Once a report is resolved or closed, the widget asks the reporter **How did we
do?** under it: five stars, one tap to send, and a box for a note after the
tap rather than before — a form that asks for a sentence first is a form people
close. Their list of reports says **Rate it** against the settled ones still
waiting for a rating, and shows the score on the ones that have one.

The same rating is asked for on the reporter's own page and in the email that
tells them it was fixed, so whichever they answer is the one that counts.
Rating again replaces the earlier score. Whether a project asks at all is
**Project Settings → Resolutions → Ask the reporter to rate it**, on by default.

To drive it from your own UI:

```js
const report = await BugCatch.myReport(id);

if (report.ratingAsked) {
  // report.rating is null until they rate, then
  // { score: 4, comment: 'Quick, thanks', ratedAt, via: 'widget', current: true }
  await BugCatch.rate(id, { score: 4, comment: 'Quick, thanks' });
}
```

`ratingAsked` is true only for a settled report on a project that asks; `rate()`
rejects otherwise. `rating.current` is false when the report was reopened and
fixed again after the rating was given — the rating stands, but it was about an
earlier fix. In `myReports()`, each row carries `rating` (the score, or null)
and `ratingAsked`.

### Reporting from your own UI

```js
BugCatch.init({ apiKey, projectId, widget: false });

document.querySelector('#help').onclick = () =>
  BugCatch.report({ title: 'Something broke', priority: 'HIGH' });
```

### Attaching from your own UI

`uploadAttachment()` resolves to the storage key a report references, so you can
build your own capture flow and still get media onto the bug:

```js
const { storageKey, mime } = await BugCatch.uploadAttachment(
  file, file.type, (fraction) => showProgress(fraction),
);

await BugCatch.report({
  title: 'Layout breaks on the pricing page',
  attachments: [{ type: 'SCREENSHOT', storageKey, mime }],   // or 'VIDEO'
});
```

Entries without a usable key or an accepted mime type are dropped rather than
failing the whole report.

### Scrubbing before send

`beforeSend` runs in the browser, so you can drop things that should never leave it:

```js
BugCatch.init({
  apiKey, projectId,
  beforeSend: (payload) => {
    payload.logs.network.entries = payload.logs.network.entries
      .filter((e) => !e.url.includes('/internal/'));
    return payload;
  },
});
```

Server-side redaction rules (Workspace Settings → Security) run *as well*, so secrets
are caught even if a client forgets.

### Credential scrubbing (always on)

Bodies are never read, which leaves the URL as the place credentials actually turn up:
`?api_key=`, an S3 presigned `X-Amz-Signature`, an OAuth `#access_token=` fragment, a JWT
in a path segment, or `https://user:password@host`. All of those are replaced with
`[REDACTED]` **before the entry enters the ring buffer** — nothing you have to enable, and
the secret never leaves the page.

```
https://api.example.com/v1/me?api_key=abcd1234efgh5678&page=2
                            ↓
https://api.example.com/v1/me?api_key=[REDACTED]&page=2
```

Structure survives on purpose. Scheme, host, path and every parameter *name* stay
readable, because a Network tab that blanks whole URLs is one nobody can triage with —
and the usual response to that is to switch capture off, which helps no one.

Recognised without configuration: parameters named for a credential (including
app-specific ones like `X-Custom-Auth-Token`), plus JWTs, AWS/Google/GitHub/GitLab/
Stripe/Slack/Shopify/OpenAI/Anthropic key formats wherever they appear. Ordinary
parameters — `?page=2`, `?sort=created_at`, `?code=US` — are left alone.

The API applies the same scrubbing on intake and again when a report is read back, so
reports captured by older SDK versions are covered too.

### What is already known

The widget opens with the reports this project has chosen to say are already
broken, above the form rather than after it, and offers **Me too** beside each
one. Somebody who came to report a thing that is already known can say so in one
tap and leave, and the team gets one report carrying everybody's environment
instead of fourteen reports of the same thing.

Nothing here is in anybody's way. The list is dismissed by ignoring it, and the
form underneath is the form that was always there.

Two switches, both off until somebody sets them, and both required:

- **Project Settings → General → "Show what is already known before the report
  form"** decides whether this project has a list at all.
- **A report's own "Known issue" control**, on its page in the dashboard, decides
  whether *that* report is on it.

Two rather than one because they are two decisions, made by different people at
different times: a team deciding it is willing to publish anything, and a member
deciding this particular title is fit to be read by strangers. A report title is
written for a queue, not for publication, so nothing is ever listed by default.

This is deliberately **not** the same control as session visibility. A public
session is readable by whoever holds its link, which somebody hands to one
person on purpose; listing puts the title in front of every visitor of your site.

Joining is a promise, and the SDK keeps it: everyone who pressed **Me too** is
told when the report is resolved — over the same live stream and the same email
as the person who filed it. That is why it asks for an address when neither a
sign-in nor `identify()` has supplied one, rather than recording somebody nobody
can ever write to.

```js
const issues = await BugCatch.knownIssues();
// [{ id, title, status, affectedCount, since, joined }]

await BugCatch.meToo(issues[0].id, { email: 'them@example.com' });
// { joined: true, affectedCount: 4 }
```

`knownIssues()` resolves to an empty list on every project that has not turned
this on, which is the default — draw nothing rather than an empty heading. What
comes back is a title, a state and a count, and never a severity, an assignee or
a description: the question it answers is "is this the thing I came here about".

### Telling visitors something

Your team can put a notice in the widget from the dashboard, and change it or
take it down there, without redeploying your site: **Settings → Project → Widget
announcements**. "UPI payments are delayed today — we're on it, no need to
report it" is the reason it exists: said where people come to report, before
they type it out.

Each one picks where it appears:

- **In the widget**: at the top of the report panel, when somebody opens it.
- **Next to the button, too**: also as a small card beside the launcher on
  every page, for people who came to do something else. Always closable.

A tone (info, success, warning, critical) picks the colour and the icon; info
wears your `accent`. A link is optional — a path like `/status` is a page on
your site, a full `https://` address opens a tab. Each runs between two days,
inclusive, on the visitor's own calendar, and the last day is required.
Closing one is remembered in that browser until somebody presses **Show it
again**; editing the wording does not reopen it.

A browser asks at most every five minutes, so a change reaches visitors within
that, and a project with nothing published costs one small request per browser
per five minutes.

```js
// widget: false, or announcements: false — draw them your own way
const notices = await BugCatch.announcements();
// [{ id, title, body, tone, ctaLabel, ctaHref, placement, dismissible, ... }]
BugCatch.dismissAnnouncement(notices[0]);
```

`announcements()` is already narrowed to the visitor's day and to what this
browser has not closed, and resolves to an empty list on every failure.

### Talking a report through

Where the workspace has sphoro.voice configured, a reporter can talk to the team
instead of typing. There are two ways in.

**Instead of the form.** A **Talk to the team** button appears next to *Send
report*. Pressing it asks for the microphone, files the report — carrying the
console, network and environment the SDK is already holding — and rings the team
about it. The report is written before the phones ring, so a call nobody answers
still leaves the context behind, and it opens unassigned: whoever takes it in
the dashboard owns it.

**After somebody picks it up.** When a report is assigned, its reporter is
offered a call about it, and the open report's composer grows a phone button.

The other end is a colleague, not software. The audio travels directly between
the two browsers over WebRTC and never reaches BugCatch or sphoro.voice, so
there is no transcript — the report's thread gets a note saying the call
happened, who took it and how long it lasted. It goes both ways: the team can
ring back, which is the section after this one.

```js
BugCatch.init({
  apiBase: '…', apiKey: '…', projectId: '…',
  onCallState: (state) => setStatus(state),   // 'ringing' → 'connected' → 'ended'
});
```

Both affordances are built in: the **Talk to the team** button on the composer,
and — with `updateToast` on, the default — a **Talk it through** button on the
update card when a report is picked up (pressing it marks that update read:
acting on a notification is the clearest way there is of having seen it). To
render your own instead:

```js
// Instead of the form: files a report and rings the team about it.
if (await BugCatch.canCallNow()) {
  const call = await BugCatch.startCall();   // resolves once it is *ringing*
  call.bugId;   // what it was filed as
  call.setMuted(true);
  call.hangUp();
}

// About a report already filed.
if (await BugCatch.canCall(bugId)) {
  await BugCatch.startCall(bugId);
}

BugCatch.on('call', ({ state }) => {
  // 'ringing' → 'connecting' → 'connected' → 'ended'
  // or 'ringing' → 'missed', when nobody picked up in about twenty seconds.
});
```

`startCall()` with no id rejects with `.filed` set when the report was written
but nobody could be rung — the report still exists, and telling the reporter
otherwise would have them file it twice. `startLiveCall()` is the same call
under its older name, from when this was one of two kinds.

Four things are worth being explicit about, because they are what people ask:

- **It is a real person, and it is not recorded.** The audio goes directly
  between the two browsers, so there is no transcript for the team to read
  instead of answering. The thread gets a note saying the call happened.
- **It can go unanswered.** It rings everybody who can see the report for about
  twenty seconds; if nobody is free, the state is `missed` and the reporter is
  pointed back at the chat.
- **The microphone is asked for on the press, never before.** An offer that
  raised a permission prompt on arrival would be a page listening to somebody
  who walked away from it.
- **One call at a time**, and `destroy()` ends it. Two microphones on one page
  talk over each other.

#### Being called

The same live call, placed from the other end. Somebody on the team reads a
report an hour after it was filed, has one question whose answer is thirty
seconds of talking and four days of email, and rings the person who filed it.

Nothing is needed to switch it on beyond what live calling already needs — but
it does need the update stream, because that is what a call arrives on:

```js
BugCatch.init({
  apiBase: '…', apiKey: '…', projectId: '…',
  updates: true,                    // the default; the call arrives on this
  onIncomingCall: ({ from, reference, recorded }) => pauseTheVideo(),
});

BugCatch.on('incoming-call', ({ state }) => {
  // 'ringing' → 'answered' → (then the ordinary 'call' events)
  // or 'ringing' → 'declined' | 'missed' | 'failed'
});
```

The SDK draws the card itself — who is calling, which report, whether the call
would be recorded, and Answer / Not now — and rings softly while it is up.
`onIncomingCall` fires *after* that, for a host application that wants to duck
its own audio or pause a video; it is not a hook that can suppress a call the
person can already see ringing.

What is worth telling a customer about this one:

- **It is an offer, never a connection.** No microphone is opened and no audio
  moves until Answer is pressed. A page somebody walked away from ten minutes
  ago does not start listening to the room because the team rang it.
- **It says who is calling.** An unnamed voice asking a customer about their
  account is indistinguishable from the fraud their bank warns them about, so
  the card carries the caller's name and the report before anything else.
- **It says whether it is recorded, before Answer.** On a peer-to-peer call the
  copy that gets kept is uplinked by the customer's own browser, which makes
  the card the only honest moment to mention it.
- **Declining is recorded as declining.** Not the same as not being there: the
  report's thread says which, because one invites ringing again in ten minutes
  and the other does not.
- **`updates: false` switches it off**, as does `voice: false`. With no stream
  there is nothing for a call to arrive on, and no card is ever drawn.
- **It only reaches a signed-in reporter, and that is enforced.** The team's
  button is refused unless the person who filed the report has *proved their
  address* — a widget sign-in (`sdkLogin` on the project, giving a live
  session) or a sign-in on their own report page. An address that only arrived
  through `identify()` is the host application vouching for its own user, which
  is not a claim anybody outside that application can rely on, and calls are
  not placed on it.

  A project with `sdkLogin: OFF` therefore has no callable reports through the
  widget, because it never asks anyone who they are. Turn sign-in on to make
  calling available.

- **And they have to be at the page.** Being signed in is about *who*; a call
  still joins two browsers, so theirs has to be open. The two refusals read
  differently on the bug page because only one of them clears by itself.

- **The seat is theirs alone.** The chair kept open on an outbound call can
  only be claimed with one of the reporter's own credentials, so a call placed
  to a customer cannot be picked up by anybody else — including a member of the
  team looking at the same report.

It also rings in the reporter portal — the page where somebody signs in by email
to check back on what they reported. Whichever tab is in front of them gets the
call, and if both are open, the first to answer gets it.

`canCall` and `canCallNow` answer `false` — and no button appears — on any
deployment without sphoro.voice and any workspace that has not switched calling
on, which is most of them. `canCallNow` additionally needs `identify()` to have
supplied an email: a call with nobody to attribute has no way to tell the
reporter what happened next. Set `voice: false` to opt out entirely; the call
code is then never fetched, and nothing runs.

### Notes

- The widget renders in a shadow root, so your CSS and its CSS can't collide.
- Capture never throws into your page: every hook is wrapped, and originals are always
  called.
- Attachment uploads use pristine `fetch`/`XHR` references taken before the network hooks
  are installed, so a report never ends up describing its own upload.
- The panel traps focus, closes on `Escape`, restores focus to the launcher, and honours
  `prefers-reduced-motion` — every animation is an entrance or a state hint, so switching
  them off costs nothing.
- `destroy()` restores `console.*`, `fetch` and `XMLHttpRequest` to their originals.

## Build

```bash
npm install
npm run build     # every file below, into dist/ (scripts/build.mjs; the builds are in vite.config.js)
npm run dev       # the same, rebuilt on change
```

| File | For |
|---|---|
| `bugcatch.es.js` (+ `bugcatch-core.es.js`, `bugcatch-panel.es.js`, `bugcatch-call.es.js`) | Bundlers. The panel and call code are `import()`ed, so they split off. |
| `bugcatch.umd.js` | A `<script>` tag; `window.BugCatch`. Fetches the two below from beside itself. |
| `bugcatch-panel.umd.js`, `bugcatch-call.umd.js` | Those parts, as scripts that register themselves with it. |
| `bugcatch.standalone.umd.js` | All of it in one file, for a page that cannot load a second one. |

The build fails if the main script ends up containing either part, or if a part
carries its own copy of something whose state belongs to the main script.
