@getpinax/widget 1.0.0-beta.1 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,11 +13,14 @@ npm install @getpinax/widget
13
13
  ```
14
14
 
15
15
  ```ts
16
- import { init, open, identify, destroy } from '@getpinax/widget';
16
+ import { init, open, identify, consent, destroy } from '@getpinax/widget';
17
17
 
18
18
  // Once, as early as you like (e.g. in your app's entry file). Browser only.
19
19
  init({ key: 'pk_...' });
20
20
 
21
+ // From your cookie banner, on sites that wait for consent (see Visitor consent).
22
+ consent({ diagnostics: true, replay: true });
23
+
21
24
  // When someone signs in, so their reports carry who they are (C-09).
22
25
  identify({ id: user.id, name: user.name, email: user.email });
23
26
  identify(null); // on sign-out
@@ -49,36 +52,66 @@ when needed. On server-rendered frameworks, call `init()` in the browser only
49
52
  (`useEffect`, `onMount`, a client component).
50
53
 
51
54
  Options: `key` (the website key from Install), `apiUrl` (default
52
- `https://api.getpinax.com`) and `cdnUrl` (default `https://cdn.getpinax.com`,
53
- where the site's settings are read from, P-15). With only `apiUrl` set,
54
- settings come from that API, which is what you want for a local backend.
55
+ `https://api.getpinax.com`), `cdnUrl` (default `https://cdn.getpinax.com`,
56
+ where the site's settings are read from, P-15) and `consent` (`'wait'` or
57
+ `'none'`, overriding the site's "Wait for visitor consent" setting; see
58
+ Visitor consent). With only `apiUrl` set, settings come from that API, which
59
+ is what you want for a local backend.
55
60
 
56
61
  ### Script tag (CDN)
57
62
 
58
63
  ```html
59
64
  <script src="https://cdn.getpinax.com/pinax.js" data-key="pk_..." async></script>
60
- <!-- data-api="https://api.example" overrides the API origin, data-cdn="https://cdn.example" the settings origin -->
65
+ <!-- data-api="https://api.example" overrides the API origin, data-cdn="https://cdn.example" the settings origin,
66
+ data-consent="wait" or "none" the site's "Wait for visitor consent" setting -->
61
67
  ```
62
68
 
63
69
  ### Publishing
64
70
 
65
- `npm publish` runs `prepublishOnly`: the production build (with
66
- `VITE_PINAX_API=https://api.getpinax.com` and
67
- `VITE_PINAX_CDN=https://cdn.getpinax.com` baked in), the tests and the size
68
- check. The package ships `dist/` only. Bump `version` first; it's also the
69
- `?v=` the script tag uses to load matching editor and recorder files from the CDN.
71
+ Releases go to npm and cdn.getpinax.com together, from a clean `main`:
72
+
73
+ ```bash
74
+ yarn release # next beta: 1.0.0-beta.1 → 1.0.0-beta.2 (npm tag "beta")
75
+ yarn release 1.0.0 # a stable version (npm tag "latest"); also patch|minor|major
76
+ yarn release --dry-run # check, test and pack; changes nothing
77
+ yarn release --cdn-only # npm worked but the CDN upload failed: upload again
78
+ ```
79
+
80
+ `scripts/release.sh` checks you're on `main`, clean and in step with
81
+ `origin`, runs lint and tests, bumps `version`, commits and tags `v<version>`,
82
+ then publishes to npm. `prepublishOnly` builds with
83
+ `VITE_PINAX_API=https://api.getpinax.com` and `VITE_PINAX_CDN=https://cdn.getpinax.com`
84
+ and tests again. Next it uploads the build with `cdn/deploy.sh` (the Worker is
85
+ redeployed only when `cdn/` changed, or with `--worker`), pushes the commit and
86
+ tag, and checks npm and the CDN serve the new version. If npm refuses (no 2FA
87
+ code, version taken), the local commit and tag are undone.
88
+
89
+ It needs `npm login` (with 2FA; pass `--otp <code>` or confirm in the browser)
90
+ and the Cloudflare deploy credentials `CLOUDFLARE_API_TOKEN`,
91
+ `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_ZONE_ID`, exported or in
92
+ `~/.config/pinax/cdn.env` (outside the repo). `version` is also the `?v=` the
93
+ script tag uses to load matching editor and recorder files, and
94
+ `/v/<version>/` files are cached for a year, so a version is never reused.
70
95
 
71
96
  ## API
72
97
 
73
98
  | Call | What it does |
74
99
  |---|---|
75
- | `init({ key, apiUrl?, cdnUrl?, segment? })` | Starts capture and fetches settings (from the CDN, P-15). Later calls are ignored. The script tag calls it after the page's `load` event. |
100
+ | `init({ key, apiUrl?, cdnUrl?, segment?, consent? })` | Starts capture and fetches settings (from the CDN, P-15). Later calls are ignored. The script tag calls it after the page's `load` event. |
76
101
  | `open()` | Opens the panel from the site's own link (S-05). Before settings arrive, it opens once they do. Works with `hideButton` and on hidden paths. Closing returns focus to whatever had it before. |
77
- | `identify({ id?, name?, email? } \| null)` | Attaches the signed-in visitor to reports (C-09). Works before `init`. The email also pre-fills the form. |
102
+ | `identify({ id?, name?, email? } \| null)` | Attaches the signed-in visitor's id and name to reports (C-09). Works before `init`. The email only fills in the form's email box (unless the site's `identifyPrefill` is off) and is sent only if the visitor leaves it there; it's never sent on its own. |
103
+ | `setBasket({ value, currency } \| null)` | What's in the shopper's basket now, in major units (`42.5`, not `4250`) and an ISO code (`'GBP'`); `null` once it's empty. Works before `init`. Sent only with a sampled sighting of a known error (AI-21), never on its own. |
104
+ | `consent(true \| false \| { diagnostics?, replay? })` | The visitor's answer to the site's cookie banner (see Visitor consent). `true`/`false` set both parts; the object form changes only the parts given. Works before `init`, and as often as the banner reports a change; withdrawing stops what it covers at once. Only matters where the page waits for consent. |
78
105
  | `destroy()` | Removes the widget and restores `console`, `fetch` and `XMLHttpRequest`. |
79
106
 
80
107
  With the script tag these are on `window.Pinax`.
81
108
 
109
+ ```ts
110
+ // A shop: keep the basket up to date.
111
+ setBasket({ value: cart.total, currency: 'GBP' });
112
+ setBasket(null); // emptied
113
+ ```
114
+
82
115
  ## Settings
83
116
 
84
117
  The widget follows the settings from `GET /v1/widget/{key}/config`
@@ -93,6 +126,10 @@ malformed falls back to the defaults (`src/settings.ts`).
93
126
  | `smallOnMobile` | A smaller button on screens up to 520 px wide (S-04). |
94
127
  | `segment` | Off by default. When on and the page runs Segment, the widget sends its events to `window.analytics` (see Segment below). |
95
128
  | `hideButton` | No floating button at all; the site calls `Pinax.open()` (S-05). The panel then sits 24 px from the bottom instead of above the button. |
129
+ | `knownIssues` | `{ enabled, notify }`, both on by default. `enabled` shows the "We know about this" card (see Known issues below); `notify` offers "Email me when it's fixed" after Me too. |
130
+ | `consent` | `{ required }`, off by default. On: the site shows a cookie banner, so replay, technical logs, error counts and the page-view count wait for `Pinax.consent()` (see Visitor consent). `data-consent` / the `consent` option overrides it. |
131
+ | `identifyPrefill` | On by default. The email given to `identify()` fills in the form's email box; the visitor can change or clear it, and a box they cleared isn't filled in again on this page. Off: the box starts empty. Either way only what's in the box is sent (`email`); `user` carries the id and name only. |
132
+ | `privacyUrl` | The site's privacy policy (an `https:` address on one of its domains; the dashboard checks that). When set, the form's notice links it ("Privacy policy", new tab). Anything that isn't `https:` is ignored. |
96
133
  | `wording` | S-06. An empty string keeps the default. `chooserTitle` is the "What would you like to do?" heading. `problemTitle`, `ideaTitle` and `generalTitle` rename the type in the chooser and head its form (the General default heading is "How was your visit?" with stars, "General feedback" without). `messagePlaceholder` replaces the message placeholder for all three types. `confirmation` replaces "Thanks! We've got it." (not the offline "Saved for later"). |
97
134
 
98
135
  History wrapping follows the same rules as the `fetch` wrapping: the original
@@ -101,6 +138,171 @@ our callback can't throw into the page, it's only installed when `hidePaths`
101
138
  is non-empty and the button isn't hidden anyway, and `destroy()` restores the
102
139
  originals unless something else wrapped them on top of us.
103
140
 
141
+ ## Known issues
142
+
143
+ A team can mark an open report "Show to visitors" with a public title they
144
+ write. The config then lists it in `known` (`{ id, title, paths, signature }`,
145
+ up to 20, newest first; missing on older APIs, which means none). A known
146
+ issue matches this page view when one of its `paths` is the current path
147
+ (`/book` and `/book/` are the same page, as in fix checks; `*` is not
148
+ special) or when its error `signature` showed up in this page view's
149
+ console errors or failed requests (the fix-check signatures, every page view,
150
+ no sampling; `src/known.ts`). Nothing is sent while watching.
151
+
152
+ When the panel opens and something matches, it starts on a card above the
153
+ type chooser: "We know about this", up to 3 public titles, and a **Me too**
154
+ button for each (`POST /v1/widget/:key/known/:id/me-too {}`). After it:
155
+ "Thanks, we've added you." (announced through the panel's live region) and,
156
+ with `knownIssues.notify`, an "Email me when it's fixed" field and **Send**
157
+ (the same request with `{ email }`). "Report something else" leaves the card
158
+ for the normal flow (straight to the form when the site has one type). It
159
+ works with `hideButton` through `open()`. A report already under way (a note
160
+ typed) isn't interrupted by the card.
161
+
162
+ ## Visitor consent
163
+
164
+ On sites that ask visitors for cookie consent (EU and UK cookie rules, and
165
+ CNIL's guidance on session replay), the widget waits for the visitor to
166
+ agree before anything beyond the button and its settings leaves the
167
+ browser, or before replay starts at all. A page waits when the site's
168
+ `settings.consent.required` is on ("Wait for visitor consent" in the
169
+ dashboard), or when the script tag says `data-consent="wait"` (`init({
170
+ consent: 'wait' })`). `data-consent="none"` makes a page not wait whatever
171
+ the setting. Sites that don't wait behave exactly as before.
172
+
173
+ Two parts, given with `Pinax.consent()`:
174
+
175
+ - `diagnostics`: technical logs (console, failed requests and breadcrumbs)
176
+ attached to reports, fix checks (AI-17) and error counts (AI-20, with the
177
+ basket, AI-21), the Segment user id read when a report is sent, and the
178
+ page-view count (B-16).
179
+ - `replay`: the session recorder (AI-15).
180
+
181
+ | While the page waits | Without consent | With consent |
182
+ |---|---|---|
183
+ | Console, network and breadcrumb capture | Keeps running, in memory only: nothing leaves the browser | The same |
184
+ | Logs on a report | Only if the visitor ticks "Include technical details" in the form | Attached (as far as the site's capture settings allow, C-10) |
185
+ | Fix checks, error counts | Not started | Started once; `POST /fix-check` and `/sighting` carry `consent: true` (see The flags) |
186
+ | Segment user id on a report | Not read | Read, as before |
187
+ | Page-view count | Not sent | One `POST {cdn}/count/{key}` per page load (`POST {api}/v1/widget/{key}/count` without a CDN): no body, no cookies, `keepalive` |
188
+ | Session replay | Not started; stopped, and its 30 seconds dropped, when consent is withdrawn | Started if this page view was in the sample |
189
+
190
+ - **Withdrawing** (`consent(false)`, or `{ replay: false }`) stops the fix
191
+ checks and error counts and stops the recorder, dropping what it held.
192
+ Given again, they start again; errors from before are never sent twice.
193
+ - **The replay sample** is drawn once per page view, when the settings
194
+ arrive, so consent given later still follows `replaySampleRate`.
195
+ - **Reports** still work without consent: the note, the screenshot, the page
196
+ address and the browser details. The body carries `consent: { diagnostics,
197
+ replay }` and `includeLogs: true` when the box was ticked; the API drops
198
+ logs and the replay that weren't agreed to, as a second line of defence.
199
+ - **The flags** (`consent` on reports, `consent: true` on fix checks and
200
+ error counts) go wherever the page waits *or* the site's setting does,
201
+ with what is actually allowed. The API goes by the site's setting alone,
202
+ so on a site that waits, `data-consent="none"` sends everything as agreed
203
+ (both `true`) rather than having it dropped. Sites that don't wait, with
204
+ no override, send no flags.
205
+ - **The count**: on these sites the CDN doesn't count the settings request
206
+ (`cdn/README.md`); the widget counts the page load once the visitor
207
+ agrees. It follows the site's setting, like the CDN, so with a
208
+ `data-consent` override a page load is never counted twice or not at all.
209
+ - **Before `pinax.js` loads**, calls wait in the stub queue (below) and are
210
+ replayed in order. `consent()` before `init()` is kept, like `identify()`;
211
+ `destroy()` forgets it.
212
+
213
+ ### The form's notice
214
+
215
+ Above Send, every form says what goes with the note, listing only what will
216
+ actually be attached:
217
+
218
+ > Sent to harbourcafe.co.uk with your note: the page address, your browser
219
+ > details, the screenshot, technical logs, a recording of the last 30
220
+ > seconds. [Privacy policy]
221
+
222
+ The site is this page's host name (without `www.`); the screenshot,
223
+ technical logs and recording appear only when they'll go. With `privacyUrl`
224
+ set, "Privacy policy" links it (new tab, `rel="noopener noreferrer"`). Send
225
+ is described by the notice (`aria-describedby`), so screen readers read it
226
+ with the button.
227
+
228
+ The screenshot "Report a problem" takes on its own is only in the visitor's
229
+ browser until Send: **Remove** under it (named "Remove screenshot" for screen
230
+ readers) drops it, and the notice stops listing it. The optional email field
231
+ says what the address is for and nothing more: "Only used to tell you about
232
+ this report." on problems, "Only used to reply to you about this." on the
233
+ other types, and "Only used to tell you when it's fixed." under a known
234
+ issue's "Email me when it's fixed".
235
+
236
+ While the page waits and the visitor hasn't given `diagnostics`, and console
237
+ or network capture is on, an **unticked** checkbox sits above the notice:
238
+ "Include technical details (console messages and failed requests) to help
239
+ fix this". Ticking it adds "technical logs" to the notice and sends the logs
240
+ with this report (`includeLogs: true`). It's a native, labelled checkbox
241
+ (Space toggles it). If consent changes while the form is open, the notice
242
+ and the box update in place without disturbing what the visitor is typing.
243
+ Nothing is attached that the notice didn't list.
244
+
245
+ ### Banner recipes
246
+
247
+ Put the stub first, so `Pinax.consent()` calls from a banner that answers
248
+ before `pinax.js` has loaded are queued:
249
+
250
+ ```html
251
+ <script>
252
+ window.Pinax = window.Pinax || { q: [] }; ['init','open','identify','consent'].forEach(function (m) { window.Pinax[m] = window.Pinax[m] || function () { window.Pinax.q.push([m, [].slice.call(arguments)]); }; });
253
+ </script>
254
+ <script src="https://cdn.getpinax.com/pinax.js" data-key="pk_..." async></script>
255
+ ```
256
+
257
+ Then one of these, after the stub:
258
+
259
+ **Cookiebot**
260
+
261
+ ```js
262
+ window.addEventListener('CookiebotOnConsentReady', () => Pinax.consent({ diagnostics: Cookiebot.consent.statistics, replay: Cookiebot.consent.statistics }));
263
+ ```
264
+
265
+ **OneTrust** (`C0002` is OneTrust's default Performance group; the id can
266
+ differ in your OneTrust account)
267
+
268
+ ```js
269
+ window.addEventListener('OneTrustGroupsUpdated', (e) => { const ok = e.detail.includes('C0002'); Pinax.consent({ diagnostics: ok, replay: ok }); });
270
+ ```
271
+
272
+ **Usercentrics** (add "Pinax" as a service in Usercentrics first)
273
+
274
+ ```js
275
+ window.addEventListener('UC_CONSENT', (e) => { const ok = !!e.detail.services['Pinax']?.consent?.given; Pinax.consent({ diagnostics: ok, replay: ok }); });
276
+ ```
277
+
278
+ **Google Tag Manager**: a Custom HTML tag
279
+
280
+ ```html
281
+ <script>Pinax.consent(true);</script>
282
+ ```
283
+
284
+ with Consent Settings → "Require additional consent for tag to fire:
285
+ `analytics_storage`", fired on the consent update event. A tag can't take
286
+ consent back: withdrawal needs the banner's own event (`Pinax.consent(false)`)
287
+ or a page reload.
288
+
289
+ **Shopify**
290
+
291
+ ```js
292
+ window.Shopify.loadFeatures([{ name: 'consent-tracking-api', version: '0.2' }], (err) => { if (err) return; const apply = () => { const ok = window.Shopify.customerPrivacy.analyticsProcessingAllowed(); Pinax.consent({ diagnostics: ok, replay: ok }); }; apply(); document.addEventListener('visitorConsentCollected', apply); });
293
+ ```
294
+
295
+ **Anything else**
296
+
297
+ ```js
298
+ Pinax.consent({ diagnostics: true, replay: true }); // the visitor agreed
299
+ Pinax.consent(false); // refused or withdrew
300
+ ```
301
+
302
+ With replay on, CNIL recommends naming it on the banner's first layer, for
303
+ example: "We record your actions on this page to fix technical problems
304
+ (session replay)."
305
+
104
306
  ## Segment
105
307
 
106
308
  Sites that use Segment (analytics.js) can install Pinax alongside it and get
@@ -127,7 +329,8 @@ or after `analytics.ready`) works the same way: the widget only looks for
127
329
  Never sent: the message, the email, screenshots or anything else the visitor
128
330
  typed. If the site hasn't called `Pinax.identify()` with an `id`, reports use
129
331
  Segment's signed-in user id (`analytics.user().id()`) as the visitor's user
130
- ID; anonymous Segment visitors stay anonymous. Errors from Segment never reach
332
+ ID (on pages that wait for consent, only once `diagnostics` is given);
333
+ anonymous Segment visitors stay anonymous. Errors from Segment never reach
131
334
  the page.
132
335
 
133
336
  ## Live preview (dashboard, S-03)
@@ -152,7 +355,12 @@ p.destroy();
152
355
  ```
153
356
 
154
357
  `settings` is the same shape as the backend's widget settings; any field may
155
- be left out. `phone: true` shows the phone layout (bottom sheet,
358
+ be left out. `known: [{ id, title, paths, signature }]` in the options shows
359
+ the known issues card on the chooser as if they matched (the first 3); without
360
+ it the card never shows in the preview. `site: 'harbourcafe.co.uk'` names the
361
+ site in the form's notice ("Sent to … with your note", default "this
362
+ website"); with `consent: { required: true }` in the settings the forms show
363
+ the "Include technical details" box. `phone: true` shows the phone layout (bottom sheet,
156
364
  `smallOnMobile`) whatever the page width. `hidePaths` has no effect in the
157
365
  preview. The preview never moves focus, so it can update while the owner
158
366
  types. `playground/preview.html` is a working example.
@@ -166,10 +374,10 @@ turns everything off when it's missing, and caps both sample rates at
166
374
 
167
375
  | Feature | What the widget does |
168
376
  |---|---|
169
- | AI-11 breadcrumbs | A ring buffer of the last 20 clicks, page views (load and SPA navigation) and "typed in field X" events, sent with every report (`src/capture/breadcrumbs.ts`). A target is its role plus its accessible name or text, cut to 60 characters: `button "Book a table"`, `link "Menu"`, `textbox "Email"`. Typed values are never read. Nothing is read from inside inputs, `contenteditable`, `[data-pinax-mask]` or `.pinax-mask`; password and card fields are just `password field` / `card field`. Paths keep the query (secrets masked) but not the fragment. |
377
+ | AI-11 breadcrumbs | A ring buffer of the last 20 clicks, page views (load and SPA navigation) and "typed in field X" events, sent with every report (`src/capture/breadcrumbs.ts`). A target is its role plus its accessible name or text, cut to 60 characters: `button "Book a table"`, `link "Menu"`, `textbox "Email"`. Typed values are never read. Nothing is read from inside inputs, `contenteditable`, `[data-pinax-mask]` or `.pinax-mask`; password and card fields are just `password field` / `card field`. Paths keep the query's keys but not its values (`/search?q=…`) or the fragment; emails and phone numbers in a target are starred out when the report is sent. |
170
378
  | AI-10 follow-up | With `ai.followUp`, a problem or idea with a note of 140 characters or fewer calls `POST /follow-up` first (4 s timeout). A question shows as one more panel step ("One quick question", the note, the question as the answer field's label, **Send** and **Skip and send**). The answer goes in the report as `followUp`. On no question, an error or the timeout, the report is sent straight away. Asked once per note. |
171
379
  | AI-12 auto-blur | With `ai.autoBlur`, the editor chunk finds visible emails, phone numbers (UK and international) and likely names (after labels such as "Name", "Customer", "Guest name", "Booked by", "Signed in as", in a "Name" table column or `<dt>`, or in `autocomplete=name` / schema.org Person elements) and paints the same striped mask as form fields over them (`src/editor/pii.ts`, `src/privacy.ts`). The live page is never changed. The panel says "We blurred N private details for you." and the editor outlines them with an "Auto-blurred" tag; they're part of the image, so only the visitor's own Blur marks can be undone. |
172
- | AI-15 replay (Pro, Agency) | With `ai.replay` and a passing draw of `replaySampleRate`, `pinax-recorder.js` (rrweb's `record`, MIT) is loaded after the page's `load` event. It keeps a rolling 30 s in memory, in segments that each start with a full snapshot (checkout every 10 s), so at most 40 s is held. Inputs are masked (`maskAllInputs`), `.pinax-mask` / `[data-pinax-mask]` and the widget are blocked, editable text is starred out and emails and phone numbers in page text are starred out. **Nothing is sent by the recorder.** When a report is sent, the buffer is gzipped (`CompressionStream`; no replay where it's missing), base64-encoded and added as `replay` if it fits 4.5 M characters and the whole request stays under 11 MB (fewer seconds are tried before giving up). |
380
+ | AI-15 replay (Pro, Agency) | With `ai.replay` and a passing draw of `replaySampleRate` (once per page view), `pinax-recorder.js` (rrweb's `record`, MIT) is loaded after the page's `load` event (and, where the page waits for consent, once `replay` is given; see Visitor consent). It keeps a rolling 30 s in memory, in segments that each start with a full snapshot (checkout every 10 s), so at most 40 s is held. Always: inputs are masked (`maskAllInputs`), hidden inputs, `.pinax-mask` / `[data-pinax-mask]` and the widget are blocked, and attributes are cleaned on the way out (URL secrets, emails, phone numbers). `ai.replayMask` decides the rest: **`'all'`** (the default, also when the config doesn't say; CNIL's "mask everything") stars out every text and text attributes (`alt`, `title`, `aria-label`, `placeholder`, `value`…) and leaves out `img`, `picture`, `video`, `canvas` and `iframe` (the replay shows an empty box of the same size); **`'inputs'`** keeps the page's text, starring out editable text and emails and phone numbers in it. **Nothing is sent by the recorder.** When a report is sent, the buffer is gzipped (`CompressionStream`; no replay where it's missing), base64-encoded and added as `replay` if it fits 4.5 M characters and the whole request stays under 11 MB (fewer seconds are tried before giving up). |
173
381
  | AI-17 fix check (P-14) | For each `ai.fixChecks` entry whose `path` equals `location.pathname` (re-checked on SPA navigation), one draw of `fixCheckSampleRate` per page view. If it passes, this page view's console errors and failed requests (from the existing capture) are signed with the backend's own `errorSignature` (`src/capture/signature.ts`, ported exactly), and a match sends `POST /fix-check {checkId, signature}` once per check per page view. Nothing else is sent, and nothing on success. |
174
382
 
175
383
  ## Privacy
@@ -180,18 +388,40 @@ turns everything off when it's missing, and caps both sample rates at
180
388
  private copy, and striped blocks are painted over the field positions.
181
389
  - The network log keeps only failed (status 0, 4xx, 5xx) and slow (over 2 s)
182
390
  requests: method, URL, status, duration and time. Headers, cookies and
183
- bodies are never read. Secret-looking query values are replaced with
184
- `[hidden]` and URL fragments are dropped.
391
+ bodies are never read. URL fragments and credentials are dropped, and
392
+ every query value is replaced with `…` (`/api/orders?email=…&page=…`):
393
+ the keys stay, so the team can still see what was asked.
394
+ - The page address on a report keeps only the values of `utm_*`, `page`,
395
+ `lang`, `ref`, `variant`, `tab` and `sort`; every other value is `…`
396
+ (`/search?q=…&page=2`), and there's no fragment. The `?pinax_verify` ping
397
+ sends the address the same way.
398
+ - Console messages and stacks have emails and phone numbers starred out
399
+ (`redactPersonal`, `src/privacy.ts`) when a report is built. The error
400
+ signature that fix checks and error counts compare is made from the
401
+ starred-out text too, so it never carries them (it's published in the
402
+ site's settings).
403
+ - The API applies all of these again (`widget.sanitise.ts`), for older
404
+ cached widgets.
185
405
  - No keystrokes are recorded. Pinax's own requests never appear in the log.
406
+ - A report that can't be sent (offline, or a server error; W-16) waits in
407
+ `localStorage` (`pinax:queue:<key>`) for an hour at most, without the
408
+ email address, `identify()` data, console log, screenshot or replay. The
409
+ email and `identify()` data stay in the page's memory and go with a retry
410
+ from the same page only; after a reload the report goes without them, and
411
+ the "Saved for later" screen says so when an email was given. A report is
412
+ removed once it's sent or refused for good, and anything older than an
413
+ hour is deleted unsent the next time the widget loads (`src/queue.ts`).
186
414
  - Breadcrumbs and the replay follow the same masking rules; see the table above.
415
+ - On sites that wait for visitor consent, logs, error counts, the page-view
416
+ count and the replay wait for it (see Visitor consent).
187
417
 
188
418
  ## How it loads
189
419
 
190
420
  | File | Gzipped | When |
191
421
  |---|---|---|
192
- | `dist/pinax.js` | ~17 KB | The script tag. Button, panel, capture. Limit 50 KB (W-13). |
422
+ | `dist/pinax.js` | ~23 KB | The script tag. Button, panel, capture. Limit 50 KB (W-13). |
193
423
  | `dist/pinax-editor.js` | ~17 KB | ES module loaded with `import()` from the same folder as `pinax.js`, only when a visitor takes a screenshot. Screenshot capture (`modern-screenshot`, MIT), auto-blur and the annotation editor. |
194
- | `dist/pinax-recorder.js` | ~26 KB | ES module loaded the same way, after page load, only on sampled Pro/Agency visits with replay on. Session recorder (`@rrweb/record`, MIT). |
424
+ | `dist/pinax-recorder.js` | ~27 KB | ES module loaded the same way, after page load, only on sampled Pro/Agency visits with replay on (after consent where the page waits for it). Session recorder (`@rrweb/record`, MIT). |
195
425
  | `dist/pinax.mjs` + `dist/chunks/` | | npm build. The editor is a normal dynamic-import chunk. |
196
426
  | `dist/preview.mjs` + `dist/chunks/` | ~10 KB | `@getpinax/widget/preview`, the dashboard's live preview. Shares the UI chunk with `pinax.mjs`. |
197
427
 
@@ -199,8 +429,9 @@ turns everything off when it's missing, and caps both sample rates at
199
429
  with CORS allowed (they are ES module imports). On page load the widget makes
200
430
  one request, for the site's settings from the CDN
201
431
  (`https://cdn.getpinax.com/config/<key>.json`, P-15), plus `POST /ping` only
202
- when the URL has `?pinax_verify`, and a retry only when this visitor has a
203
- report waiting from an earlier offline send (W-16). It asks the API
432
+ when the URL has `?pinax_verify`, a retry only when this visitor has a
433
+ report waiting from an earlier offline send (W-16), and, on sites that wait
434
+ for visitor consent, one `POST /count/<key>` once the visitor agrees. It asks the API
204
435
  (`GET /v1/widget/<key>/config`) only when the CDN can't answer, or when there's
205
436
  no CDN (local builds, or `apiUrl`/`data-api` set without `cdnUrl`/`data-cdn`).
206
437
 
@@ -211,7 +442,8 @@ no CDN (local builds, or `apiUrl`/`data-api` set without `cdnUrl`/`data-cdn`).
211
442
  (the URL customers paste never changes), `?v=<version>` files for a year as
212
443
  immutable, and each site's settings are static JSON the API republishes on
213
444
  every change, revalidated by browsers on each page load so the edge can count
214
- it as a page view (B-16) without the API ever being called.
445
+ it as a page view (B-16) without the API ever being called. Sites that wait
446
+ for visitor consent are counted by `POST /count/<key>` instead, after consent.
215
447
 
216
448
  ## Accessibility and touch (W-12, W-15)
217
449
 
@@ -252,10 +484,24 @@ yarn test # vitest (jsdom)
252
484
  yarn run check # biome. Plain `yarn check` is Yarn's own command.
253
485
  ```
254
486
 
487
+ **Accessibility checks (dev).** `yarn dev` adds an "Accessibility (dev)" panel
488
+ (`playground/a11y-check.js`, never shipped). "Check the widget" steps through
489
+ every screen (button, chooser, each form, thanks) and runs axe-core inside the
490
+ shadow root in a real browser, so colour contrast and target size are measured
491
+ rather than read from the CSS as in `src/__tests__/a11y.test.ts` (W-15). It
492
+ uses `globalThis.__pinaxDevUI`, set in `src/index.ts` only when
493
+ `import.meta.env.DEV` (Vite drops it from builds). "Scan this page" runs the
494
+ scan an accessibility report attaches (W-20, `src/a11y/scan.ts`). Both
495
+ playground pages have a "Broken on purpose" card with six failing controls
496
+ (label, select name, image alt, link and button names, contrast) for trying
497
+ accessibility reports; the site needs `types.accessibility` on.
498
+
255
499
  Both playground pages take `?key=pk_...` (and `?api=...`). They also have
256
500
  "Pinax AI switches" (`playground/fake-ai.js`): because a local backend has no
257
501
  model key, they patch the config in the page to turn on the follow-up
258
- question (with a canned question), force auto-blur, or turn replay off. The
502
+ question (with a canned question), force auto-blur, turn replay off, or add
503
+ a known issue on the current page (tied to the "console.error" button's
504
+ error, its Me too answered in the page). The
259
505
  widget code is unchanged; the switches wrap the page's `fetch` before the
260
506
  widget loads. `index.html` also
261
507
  reads `VITE_PINAX_KEY` from `.env.local`. To get a key, sign up through the
@@ -0,0 +1,25 @@
1
+ import axe from 'axe-core';
2
+ import type { A11yScan } from '../types';
3
+ export declare const SCAN_LIMITS: {
4
+ readonly violations: 50;
5
+ readonly nodes: 5;
6
+ readonly target: 300;
7
+ readonly html: 300;
8
+ readonly summary: 500;
9
+ readonly help: 300;
10
+ };
11
+ /** "wcag143" → "1.4.3", "wcag2aa" → null. */
12
+ export declare function wcagCriterion(tag: string): string | null;
13
+ /**
14
+ * The element's opening tag only: no text inside it, no typed values and
15
+ * no data attributes, with emails and phone numbers starred out.
16
+ */
17
+ export declare function describeElement(el: Element | undefined | null): string;
18
+ /**
19
+ * Runs axe-core on the page, leaving out the widget itself (`exclude`, the
20
+ * shadow host's selector). Resolves to null if axe fails.
21
+ */
22
+ export declare function scanPage(options?: {
23
+ exclude?: string;
24
+ }): Promise<A11yScan | null>;
25
+ export declare function summarise(result: axe.AxeResults, durationMs: number): A11yScan;
package/dist/api.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { ReportBody } from './report';
2
- import type { WidgetConfig } from './types';
1
+ import { type ReportBody } from './report';
2
+ import type { PinaxBasket, WidgetConfig } from './types';
3
3
  /**
4
4
  * The API origin. Production builds set VITE_PINAX_API (e.g. the real API
5
5
  * domain); without it the widget talks to a local backend. `data-api` on the
@@ -55,8 +55,29 @@ export declare function fetchFollowUp(doFetch: Fetch, api: string, key: string,
55
55
  message: string;
56
56
  pagePath: string;
57
57
  }, timeoutMs?: number): Promise<string | null>;
58
- /** AI-17: the watched error came back on a page marked Fixed. Fire and forget. */
58
+ /**
59
+ * AI-17: the watched error came back on a page marked Fixed; AI-20
60
+ * (`'sighting'`): an open report's error showed up. Fire and forget; only
61
+ * the report id and the signature are sent (P-14, P-17). `consent: true`
62
+ * only on sites that wait for consent, once the visitor agreed.
63
+ */
59
64
  export declare function sendFixCheck(doFetch: Fetch, api: string, key: string, hit: {
60
65
  checkId: string;
61
66
  signature: string;
62
- }): void;
67
+ basket?: PinaxBasket;
68
+ consent?: boolean;
69
+ }, kind?: 'fix-check' | 'sighting'): void;
70
+ /** Where the page-view count goes: the CDN, or the API when there's none. */
71
+ export declare function countUrl(api: string, key: string, cdn: string | null): string;
72
+ /**
73
+ * B-16 on sites that wait for consent: the settings request isn't counted
74
+ * there, so once the visitor agrees, this page load is counted with one
75
+ * empty POST (no body, no cookies). Fire and forget.
76
+ */
77
+ export declare function sendCount(doFetch: Fetch, api: string, key: string, cdn: string | null): void;
78
+ /**
79
+ * Known issues: "Me too" on a public report, with an email to hear when
80
+ * it's fixed. Sending the email after a Me too repeats the call; the API
81
+ * counts each visitor once and keeps the email.
82
+ */
83
+ export declare function sendMeToo(doFetch: Fetch, api: string, key: string, id: string, email?: string): Promise<boolean>;
@@ -30,7 +30,7 @@ export declare function nameOf(el: Element): string;
30
30
  * only its role or tag: its text may be the visitor's own details.
31
31
  */
32
32
  export declare function describeTarget(start: Element): string;
33
- /** The page as breadcrumbs record it: path and query, secrets masked, no fragment. */
33
+ /** The page as breadcrumbs record it: path and query keys (values hidden), no fragment. */
34
34
  export declare function currentPath(href?: string): string;
35
35
  type Options = {
36
36
  /** Events from inside the widget itself are left out. */
@@ -16,7 +16,7 @@ type Options = {
16
16
  };
17
17
  /**
18
18
  * C-05/C-06: wraps fetch and XMLHttpRequest and records only failed or slow
19
- * requests: method, URL (secrets masked), status (0 = network error),
19
+ * requests: method, URL (query values hidden), status (0 = network error),
20
20
  * duration and time. Headers and bodies are never read.
21
21
  */
22
22
  export declare function captureNetwork(buffer: RingBuffer<NetworkEntry>, target?: Window, options?: Options): () => void;
@@ -1,2 +1,7 @@
1
+ /**
2
+ * Emails and phone numbers are starred out first, as in the report's console
3
+ * text, so a signature never carries them (it's published in the site's
4
+ * config) and matches whether the text was starred out already or not.
5
+ */
1
6
  export declare function normalise(text: string): string;
2
7
  export declare function errorSignature(consoleLogs: unknown, networkLogs: unknown): string | null;
@@ -2,6 +2,22 @@
2
2
  export declare function clip(value: string, max: number): string;
3
3
  /**
4
4
  * C-06: drops the fragment, credentials and secret-looking query values from a
5
- * URL before it is kept. The backend masks again (defence in depth).
5
+ * URL before it is kept. The backend masks again (defence in depth). For
6
+ * what is sent, `logUrl` and `pageUrl` (report.ts) also hide query values.
6
7
  */
7
8
  export declare function safeUrl(raw: string, base?: string): string;
9
+ /**
10
+ * Query parameters whose values a report's page address keeps: campaign and
11
+ * view state, never anything a visitor typed. Everything else becomes `…`.
12
+ * Mirrors `PAGE_PARAM` in `backend/src/modules/widget/widget.sanitise.ts`.
13
+ */
14
+ export declare const PAGE_PARAM: RegExp;
15
+ /**
16
+ * Personal data hides in query strings (`?email=`, `?name=`, search terms),
17
+ * so every value becomes `…` and only the keys stay, except those `keep`
18
+ * names. Works on the string, so `…` isn't percent-encoded, and keys,
19
+ * order and key-only parameters (`?debug`) are kept as they were.
20
+ */
21
+ export declare function maskQuery(url: string, keep?: RegExp): string;
22
+ /** Network log and breadcrumb URLs: `safeUrl`, then every query value hidden. */
23
+ export declare function logUrl(raw: string, base?: string): string;