> ## Documentation Index
> Fetch the complete documentation index at: https://docs.burnsidesteps.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Widget reference

> Every attribute the embed script reads, what the widget captures, and the JavaScript API.

The widget is one script tag. It reads its configuration from that tag's own attributes, so the
same built file behaves differently per site with no build step and no bundler.

```html theme={null}
<script src="https://<relay-host>/widget.js" type="module" data-project="<project-id>"></script>

`type="module"` is required, not optional. The widget loads its screenshot library only when
somebody presses the camera button, which needs a module. Without it the browser parses the file as
a classic script, hits an `import`, and the widget does not run at all.

It also means `defer` is unnecessary: a module is deferred by default.

Every browser that supports the widget supports modules. If you need to support one that does not,
the widget will not run there either way.
```

## Attributes

| Attribute      | Required | Default                 | Notes                                                                                                                        |
| -------------- | -------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `data-project` | Yes      | none                    | The project id. Without it the widget logs an error and does not start.                                                      |
| `data-relay`   | No       | the script's own origin | Where submissions go. Derived from `src`, so script host and API host are the same by default. A trailing slash is stripped. |
| `data-mode`    | No       | `on`                    | Visibility. One of `on`, `query`, `manual`, `key`.                                                                           |
| `data-dry-run` | No       | off                     | Capture and annotate as normal, send nothing.                                                                                |
| `data-console` | No       | on                      | Set to `off` to capture no console output at all.                                                                            |
| `data-help`    | No       | none                    | A URL. Adds a "Need help?" control that opens it in a new tab.                                                               |
| `data-mask`    | No       | none                    | A CSS selector list. Matching elements are left out of the screenshot entirely. See below.                                   |
| `data-consent` | No       | none                    | Set to `deferred` and nothing runs until you call `start()`. See below.                                                      |

Three of these behave in ways worth knowing before you debug them:

* **An unrecognised `data-mode` silently becomes `on`.** The widget does not warn. A typo in this
  attribute leaves the button visible to everyone, which is the opposite of what a typo in
  `data-mode="manual"` was meant to achieve. The admin panel is stricter and rejects an invalid
  mode outright, so this only bites in the embed.
* **`data-console` responds to the exact string `off` and nothing else.** `data-console="false"`
  and `data-console="0"` both leave capture switched on.
* **`data-dry-run` is enabled by presence.** `<script ... data-dry-run>` is enough. The one value
  that turns it off again is `data-dry-run="false"`, which exists so a template can emit the
  attribute unconditionally and decide with a variable.
* **`data-consent` defers on any value it does not recognise**, and logs an error saying so. This
  is the one attribute that fails toward doing less. A typo in `data-mode` leaves a button visible;
  a typo here would otherwise leave collection running on every visitor, so it does the opposite.
  An empty value is the exception and means "not set", so a template can emit the attribute with
  nothing in it.

## Visibility modes

`data-mode` decides when the launcher appears. It is a separate axis from `data-dry-run`, which
decides whether anything is sent, and every combination of the two is meaningful.

**`on`** shows the launcher to everyone. This is the default.

**`manual`** never shows it on its own. Call `window.BurnsideSteps.show()` when you want it, which
is the mode to use if you are putting feedback behind your own button or your own permission check.

**`query`** shows it once the page is loaded with `?feedback=on`. The grant is then remembered in
`sessionStorage`, so the visitor keeps the widget as they navigate the rest of the site without
carrying the query string around. It lasts for the browser session, not forever: a new tab starts
clean.

**`key`** shows it only to visitors holding a valid access key. The widget posts the page's whole
query string to the relay's `/api/gate`, and the relay decides, so the key never has to be readable
in your page source. A successful check is remembered per project in `sessionStorage`. Which query
parameter carries the key, and what its value must be, are set per project on the relay: see
[configuration](/configuration).

## JavaScript API

Once mounted, the widget exposes these methods on `window.BurnsideSteps`:

```js theme={null}
window.BurnsideSteps.start(); // begin, when the embed is deferred. See below
window.BurnsideSteps.open();  // open the feedback panel
window.BurnsideSteps.show();  // show the launcher
window.BurnsideSteps.hide();  // hide the launcher
```

These are available regardless of mode, and they are how `manual` becomes useful.

## Waiting for consent

If you run a consent banner, add `data-consent="deferred"`:

```html theme={null}
<script src="https://relay.example.com/widget.js"
        data-project="acme"
        data-consent="deferred" defer></script>
```

The script loads and then does nothing at all. It makes no request to us, inserts no element,
reads no storage, and does not touch the page's console. Call `start()` from your banner's accept
handler:

```js theme={null}
onConsentGranted(() => window.BurnsideSteps.start());
```

`start()` is safe to call more than once, so you do not need to track whether you already have.

If you run the admin panel, tick **"This site uses a consent banner"** on the project instead of
editing the snippet by hand. The panel then emits the attribute in every copy of the snippet it
gives out, including the one a colleague copies next month.

That setting changes the snippet rather than being something the widget looks up, and it has to
work that way: consent is decided before the first request, so a setting the widget had to fetch
could not gate the fetch.

`open()`, `show()` and `hide()` do nothing before `start()` and log an error telling you so. That
is deliberate: your page might call `open()` from a route change or a keyboard shortcut rather
than because someone clicked, and treating any of those as consent would defeat the point of
asking.

### What deferring actually stops

Worth being specific, because "the widget is hidden" and "the widget has not started" are different
things and only one of them is a consent gate:

| Without `data-consent`                                          | With it, before `start()` |
| --------------------------------------------------------------- | ------------------------- |
| A request to your relay for the project's tags, theme and badge | None                      |
| The last 50 console entries retained on the visitor's page      | Nothing captured          |
| One `localStorage` read for the launcher's remembered position  | None                      |
| In `key` mode, a request carrying the key from the page URL     | None                      |

`data-mode="manual"` is not a substitute. A manual widget is mounted and invisible: it has already
patched the console and read the visitor's storage, and it is waiting for a call. Deferred means
not begun.

## What a submission contains

The issue that arrives in your tracker carries:

| Field      | From                                               |
| ---------- | -------------------------------------------------- |
| Reporter   | What the reporter typed, or "anonymous"            |
| Type       | The tags they picked, when the project defines any |
| Page       | The page URL                                       |
| Device     | Operating system and browser                       |
| Screen     | Viewport width, height, and device pixel ratio     |
| Screenshot | The annotated capture, with the pin they placed    |
| Console    | The buffer below, unless you turned it off         |

The widget also sends its own version with every report, so an embed left on an old build is
identifiable from the issue rather than by guesswork. It looks like `2.0.0+904c2659`: the package
version, then an id derived from the built file itself. Compare it with `reported` in
`/widget-manifest.json` on your relay. If they differ, that page is running an older build than the
one you are serving, and a reload picks up the current one.

Device is worth one note: the widget does not send it. The relay reads the `User-Agent` header off
the submission and reduces it to an operating system and a browser name, so the issue says
`macOS · Safari` rather than carrying a full user agent string into your tracker.

## Keeping things out of the screenshot

The widget captures the visible page. That is the point of it, and it is also the reason you need a
way to say "not that part". There are two, and they work the same way underneath.

### data-mask, on the embed

A CSS selector list. Anything matching it is omitted from the capture:

```html theme={null}
<script src="https://relay.example.com/widget.js" type="module"
        data-project="acme"
        data-mask="#card-number, .customer-pii, [data-private]"></script>
```

Matching elements are **omitted, not blurred**. The screenshot has a blank space where they were,
which is unambiguous in a way a blur is not: nobody has to decide whether the smudge was legible.

Set it once on the embed, and it applies to every capture on every page that embed is on. The page
is only modified for the duration of the capture, and it is put back afterwards even if the capture
fails or times out.

An invalid selector masks **nothing** and logs an error to the console. It does not stop the
capture, so a typo here is loud rather than silently protective.

### data-capture, on the element

If it is easier to mark the element than to name it in a selector, put the attribute on the element
itself:

```html theme={null}
<td data-capture="exclude">4242 4242 4242 4242</td>
```

This is the same mechanism `data-mask` uses, and the widget uses it on its own UI so the widget
never appears in its own screenshots. An element marked this way stays excluded permanently, and
`data-mask` will not disturb it.

### What to mask

Worth being deliberate about, because a screenshot goes into your issue tracker and inherits
whatever access that tracker has:

* Anything showing another person's data. Customer records, order details, email addresses in a
  list, a support inbox.
* Card numbers, bank details, and anything else a screenshot of a checkout page might contain.
* API keys and tokens shown in a settings page, which are easy to forget because they look like
  interface rather than like secrets.

Password fields are worth a specific note. A browser renders a password input as dots rather than
characters, and the capture inherits that, so a password field is not expected to reveal its
contents. We have not verified that behaviour across every browser, though, and the cost of being
wrong is high, so if a page has a filled-in password field on it, mask it and do not rely on the
rendering.

## What the capture does on the network

Nothing third-party, ever. A capture may request your own font files, from your own origin, and
nothing else leaves the page except the report itself when the visitor presses Send.

That needs stating because the library the capture is built on would go further by default. Left
alone it resolves fonts through a built-in list of font hosts: `fonts.googleapis.com`,
`fonts.gstatic.com`, `use.typekit.net`, `p.typekit.net`, `kit.fontawesome.com`,
`use.fontawesome.com` and `cdn.jsdelivr.net`. On a site using webfonts that would mean pressing the
feedback button caused the visitor's browser to fetch from a third party. Every one of those hosts
is excluded by name, so none of them is ever contacted.

**Why fonts are embedded at all.** The capture is not a raster of what the browser drew. The library
serialises your page into an SVG and has the browser lay it out a second time, in a context with no
access to your font faces. Without the face embedded, that second layout falls back to different
metrics and the text re-wraps: a live three-line paragraph was measured rendering as four. On a tool
whose whole job is a faithful record, that made every report about spacing, wrapping or typography
quietly misleading.

So faces we can reach are embedded, and the third-party hosts above are refused. If your fonts are
self-hosted, your captures are faithful and your visitors still contact nobody.

**If your fonts come from one of those hosts**, the text in the capture will re-wrap, exactly as it
did before. Rather than let that be silent, the report says so underneath the screenshot:

> *Text may wrap differently from the live page: fonts from fonts.googleapis.com could not be
> embedded.*

Self-hosting your webfonts removes the caveat and makes the captures exact.

**One exception, which we cannot switch off.** The library always embeds icon fonts, and offers no
option to stop it. If your page uses an icon font served from a CDN, a capture may fetch that one
file. It is the visitor's browser making a request to a host your page already uses, and it carries
nothing about the report, but it is a request your page would not otherwise have made at that
moment. If that matters for your site, serve your icon font from your own origin, which is a good
idea for other reasons anyway.

## The console buffer

Capture starts when the widget loads and runs until the page is unloaded, so a report includes what
happened before the reporter decided to file it.

It records **`log`, `info`, `warn` and `error`**, plus two things that never reach `console` at all:
uncaught `window` errors, and unhandled promise rejections. Both arrive tagged `error`. A widget
that only forwarded `console.error` would miss the two failures most worth seeing.

Three limits keep it bounded, and the oldest entries are dropped first:

| Limit                 | Value |
| --------------------- | ----- |
| Entries               | 50    |
| Characters per entry  | 500   |
| Total serialized size | 20 KB |

The buffer wraps the page's own `console`, so anything your application or a third-party script
logs can end up attached to an issue. If your pages log anything you would not want in your
tracker, set `data-console="off"`, which skips installing the wrapper entirely rather than
capturing and discarding.

## Versions and pinning

Every relay serves the widget at two URLs, and they make deliberately different promises.

`/widget.js` is the **rolling** URL. It is what the install snippet gives you and what almost
everyone should use. Its contents change when the relay is upgraded, and it is served with a short
cache and revalidation, so a fix reaches browsers that already have the old copy within about a
minute.

`/widget-<version>.<hash>.js` is the **pinned** URL. The hash is derived from the file's own bytes,
so the name changes whenever the contents do, and the file is served `immutable` for a year. A
browser that has it never asks for it again.

The current build is described at `/widget-manifest.json`:

```json theme={null}
{
  "version": "2.0.0",
  "build": "904c2659",
  "reported": "2.0.0+904c2659",
  "rolling": "widget.js",
  "pinned": "widget-2.0.0.69b0ffe0.js",
  "integrity": "sha384-..."
}
```

`reported` is the value the widget puts in the issues it files. `build` is the same id on its own.
Both change whenever the widget's code or its bundled dependencies change, and only then, so no
release step has to remember to bump anything.

The id in `pinned` is a different number: it is a digest of the finished file, taken after the
build id was stamped into it, which is what lets the pinned URL promise that the name is the
content. The two always move together. Read the pairing off this manifest rather than assuming it.

### What pinning costs

A relay serves exactly one pinned build: the one it was deployed with. Upgrading the relay retires
the previous pinned URL, and a page still pointing at it will get a 404 and load no widget at all.

So pinning does not mean "this URL works forever". It means "these exact bytes, until you choose to
move", and moving is a step you have to take yourself when the relay is upgraded. If nobody is
watching for that, the rolling URL is the safer choice, which is why it is the default.

### Subresource Integrity

The `integrity` value in the manifest is the SRI hash of the pinned build. Copy it from
`/widget-manifest.json` on your own relay rather than from here: it changes with every build, so any
value written into documentation is wrong by the time you read it. Using it requires
`crossorigin="anonymous"`, because the widget is loaded from a different origin than your page:

```html theme={null}
<script src="https://relay.example.com/widget-2.0.0.69b0ffe0.js"
        integrity="sha384-..."
        crossorigin="anonymous"
        data-project="acme" defer></script>
```

Both attributes are required together. An `integrity` attribute without `crossorigin` fails the
integrity check and the script does not run, which looks exactly like the widget being broken.

The same caveat as above applies, and harder: an SRI hash pins the bytes, so it stops matching the
moment the relay serves a different build. Use it on the pinned URL, never on the rolling one.
