@getpinax/widget 0.0.0-stage → 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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +513 -2
  3. package/dist/a11y/scan.d.ts +25 -0
  4. package/dist/api.d.ts +83 -0
  5. package/dist/capture/breadcrumbs.d.ts +49 -0
  6. package/dist/capture/console.d.ts +17 -0
  7. package/dist/capture/env.d.ts +25 -0
  8. package/dist/capture/network.d.ts +23 -0
  9. package/dist/capture/ring.d.ts +14 -0
  10. package/dist/capture/signature.d.ts +7 -0
  11. package/dist/capture/text.d.ts +23 -0
  12. package/dist/capture/ua.d.ts +13 -0
  13. package/dist/chunks/editor-0Kr31Uly.mjs +1368 -0
  14. package/dist/chunks/privacy-B_b1_Y1n.mjs +39 -0
  15. package/dist/chunks/recorder-tu4yugLw.mjs +2874 -0
  16. package/dist/chunks/scan-CFQwrtAQ.mjs +46137 -0
  17. package/dist/chunks/text-D9SaDBT0.mjs +36 -0
  18. package/dist/chunks/widget-BpTBdX2R.mjs +1272 -0
  19. package/dist/editor/annotator.d.ts +20 -0
  20. package/dist/editor/capture.d.ts +28 -0
  21. package/dist/editor/encode.d.ts +7 -0
  22. package/dist/editor/index.d.ts +5 -0
  23. package/dist/editor/mask.d.ts +29 -0
  24. package/dist/editor/pii.d.ts +22 -0
  25. package/dist/editor/shapes.d.ts +31 -0
  26. package/dist/editor-loader.d.ts +9 -0
  27. package/dist/editor-loader.embed.d.ts +6 -0
  28. package/dist/fixcheck.d.ts +42 -0
  29. package/dist/index.d.ts +85 -0
  30. package/dist/known.d.ts +27 -0
  31. package/dist/mount.d.ts +8 -0
  32. package/dist/navigation.d.ts +1 -0
  33. package/dist/paths.d.ts +4 -0
  34. package/dist/pinax-axe.js +46167 -0
  35. package/dist/pinax-editor.js +1417 -0
  36. package/dist/pinax-recorder.js +2915 -0
  37. package/dist/pinax.js +135 -0
  38. package/dist/pinax.mjs +1270 -0
  39. package/dist/preview.d.ts +48 -0
  40. package/dist/preview.mjs +67 -0
  41. package/dist/privacy.d.ts +24 -0
  42. package/dist/queue.d.ts +10 -0
  43. package/dist/replay/buffer.d.ts +40 -0
  44. package/dist/replay/recorder.d.ts +25 -0
  45. package/dist/replay/sanitize.d.ts +19 -0
  46. package/dist/report.d.ts +128 -0
  47. package/dist/segment.d.ts +31 -0
  48. package/dist/settings.d.ts +40 -0
  49. package/dist/types.d.ts +247 -0
  50. package/dist/ui/color.d.ts +7 -0
  51. package/dist/ui/dom.d.ts +14 -0
  52. package/dist/ui/styles.d.ts +3 -0
  53. package/dist/ui/widget.d.ts +241 -0
  54. package/package.json +74 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pinax
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,514 @@
1
- # Temporary Holding Version
1
+ # Pinax widget
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The script customers embed (requirements sections 1 and 2): a feedback button,
4
+ a panel with three kinds of feedback, screenshots with markup, and automatic
5
+ page context.
6
+
7
+ ## Install
8
+
9
+ ### npm (S-10)
10
+
11
+ ```bash
12
+ npm install @getpinax/widget
13
+ ```
14
+
15
+ ```ts
16
+ import { init, open, identify, consent, destroy } from '@getpinax/widget';
17
+
18
+ // Once, as early as you like (e.g. in your app's entry file). Browser only.
19
+ init({ key: 'pk_...' });
20
+
21
+ // From your cookie banner, on sites that wait for consent (see Visitor consent).
22
+ consent({ diagnostics: true, replay: true });
23
+
24
+ // When someone signs in, so their reports carry who they are (C-09).
25
+ identify({ id: user.id, name: user.name, email: user.email });
26
+ identify(null); // on sign-out
27
+
28
+ // From your own "Give feedback" link or button (S-05).
29
+ document.querySelector('#feedback')?.addEventListener('click', () => open());
30
+
31
+ // On teardown (tests, micro-frontends). Restores console, fetch and XHR.
32
+ destroy();
33
+ ```
34
+
35
+ React example:
36
+
37
+ ```tsx
38
+ import { useEffect } from 'react';
39
+ import { init, identify } from '@getpinax/widget';
40
+
41
+ export function Feedback({ user }: { user?: { id: string; email: string } }) {
42
+ useEffect(() => init({ key: 'pk_...' }), []);
43
+ useEffect(() => identify(user ?? null), [user]);
44
+ return null;
45
+ }
46
+ ```
47
+
48
+ The package is an ES module with TypeScript types. It does nothing on import;
49
+ `init()` starts it. The screenshot editor and the session recorder are
50
+ dynamic `import()` chunks, so your bundler splits them out and they load only
51
+ when needed. On server-rendered frameworks, call `init()` in the browser only
52
+ (`useEffect`, `onMount`, a client component).
53
+
54
+ Options: `key` (the website key from Install), `apiUrl` (default
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.
60
+
61
+ ### Script tag (CDN)
62
+
63
+ ```html
64
+ <script src="https://cdn.getpinax.com/pinax.js" data-key="pk_..." async></script>
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 -->
67
+ ```
68
+
69
+ ### Publishing
70
+
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.
95
+
96
+ ## API
97
+
98
+ | Call | What it does |
99
+ |---|---|
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. |
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. |
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. |
105
+ | `destroy()` | Removes the widget and restores `console`, `fetch` and `XMLHttpRequest`. |
106
+
107
+ With the script tag these are on `window.Pinax`.
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
+
115
+ ## Settings
116
+
117
+ The widget follows the settings from `GET /v1/widget/{key}/config`
118
+ (`backend/src/modules/widget/widget-settings.ts`). Anything missing or
119
+ malformed falls back to the defaults (`src/settings.ts`).
120
+
121
+ | Setting | Effect |
122
+ |---|---|
123
+ | `color`, `text`, `position` | Button colour, label and placement (S-01). |
124
+ | `types`, `rating` | Which feedback types are offered; stars on General (S-02). |
125
+ | `hidePaths` | No button on matching paths (S-04). Matched against `location.pathname`; `*` matches anything, so `/checkout/*` covers `/checkout`, `/checkout/` and `/checkout/pay/2`. `/pricing` covers `/pricing` and `/pricing/` only. Case-sensitive. Re-checked on `popstate`, `history.pushState` and `history.replaceState`, so single-page sites work. An open panel stays open when the visitor navigates to a hidden path. |
126
+ | `smallOnMobile` | A smaller button on screens up to 520 px wide (S-04). |
127
+ | `segment` | Off by default. When on and the page runs Segment, the widget sends its events to `window.analytics` (see Segment below). |
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. |
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"). |
134
+
135
+ History wrapping follows the same rules as the `fetch` wrapping: the original
136
+ always runs with the caller's arguments and its return value is passed back,
137
+ our callback can't throw into the page, it's only installed when `hidePaths`
138
+ is non-empty and the button isn't hidden anyway, and `destroy()` restores the
139
+ originals unless something else wrapped them on top of us.
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
+
306
+ ## Segment
307
+
308
+ Sites that use Segment (analytics.js) can install Pinax alongside it and get
309
+ the widget's events in Segment. Turn on "Send events to Segment" on the
310
+ dashboard's Widget screen (setting `segment`), or force it per page with
311
+ `data-segment="true"` on the script tag or `init({ key, segment: true })`.
312
+
313
+ ```html
314
+ <!-- 1. Segment's own snippet, as Segment gives it to you -->
315
+ <script>!function(){var analytics=window.analytics=window.analytics||[]; /* … */ analytics.load("YOUR_WRITE_KEY");analytics.page();}();</script>
316
+ <!-- 2. Pinax, after it -->
317
+ <script src="https://cdn.getpinax.com/pinax.js" data-key="pk_..." data-segment="true" async></script>
318
+ ```
319
+
320
+ Loading Pinax through Segment instead (for example from a Segment-managed tag
321
+ or after `analytics.ready`) works the same way: the widget only looks for
322
+ `window.analytics` when something happens, so the order doesn't matter.
323
+
324
+ | Event | Properties |
325
+ |---|---|
326
+ | `Pinax Widget Opened` | `source: "pinax"` |
327
+ | `Feedback Submitted` | `source`, `type` (`problem`, `idea`, `general`), `rating` (when given), `reportId` (when sent), `queued: true` (saved offline) |
328
+
329
+ Never sent: the message, the email, screenshots or anything else the visitor
330
+ typed. If the site hasn't called `Pinax.identify()` with an `id`, reports use
331
+ Segment's signed-in user id (`analytics.user().id()`) as the visitor's user
332
+ ID (on pages that wait for consent, only once `diagnostics` is given);
333
+ anonymous Segment visitors stay anonymous. Errors from Segment never reach
334
+ the page.
335
+
336
+ ## Live preview (dashboard, S-03)
337
+
338
+ `@getpinax/widget/preview` (`dist/preview.mjs`, ~10 KB gzipped with its shared
339
+ chunk) renders the real button and panel into a container's shadow root from
340
+ a settings object. It makes no API calls and installs nothing: no console,
341
+ network or history wrapping, no screenshot library, no reports. "Send" just
342
+ shows the confirmation; the problem form shows a placeholder screenshot.
343
+
344
+ ```ts
345
+ import { preview } from '@getpinax/widget/preview';
346
+
347
+ // The container needs a size; the widget is positioned inside it as it
348
+ // would be in the visitor's window. Call only in the browser (useEffect).
349
+ const p = preview(element, settings, { view: 'choose', phone: false, poweredBy: true });
350
+ p.update(nextSettings); // on every form change; keeps the screen on show
351
+ p.update(nextSettings, { phone: true });
352
+ p.show('idea'); // 'button' | 'choose' | 'problem' | 'idea' | 'general' | 'done'
353
+ p.view(); // the screen on show (visitors can click around)
354
+ p.destroy();
355
+ ```
356
+
357
+ `settings` is the same shape as the backend's widget settings; any field may
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,
364
+ `smallOnMobile`) whatever the page width. `hidePaths` has no effect in the
365
+ preview. The preview never moves focus, so it can update while the owner
366
+ types. `playground/preview.html` is a working example.
367
+
368
+ ## Pinax AI in the browser (Phase 4)
369
+
370
+ `GET /config` also returns `ai` (`widgetAiConfig` in
371
+ `backend/src/modules/widget/widget.service.ts`); `resolveAi` (`src/settings.ts`)
372
+ turns everything off when it's missing, and caps both sample rates at
373
+ `samplingRate` (B-17).
374
+
375
+ | Feature | What the widget does |
376
+ |---|---|
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. |
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. |
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. |
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). |
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. |
382
+
383
+ ## Privacy
384
+
385
+ - Every form field (inputs, textareas, selects, contenteditable) is hidden in
386
+ screenshots, as is anything marked `data-pinax-mask` or `class="pinax-mask"`.
387
+ The live page is never changed: masking happens on the screenshot library's
388
+ private copy, and striped blocks are painted over the field positions.
389
+ - The network log keeps only failed (status 0, 4xx, 5xx) and slow (over 2 s)
390
+ requests: method, URL, status, duration and time. Headers, cookies and
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.
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`).
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).
417
+
418
+ ## How it loads
419
+
420
+ | File | Gzipped | When |
421
+ |---|---|---|
422
+ | `dist/pinax.js` | ~23 KB | The script tag. Button, panel, capture. Limit 50 KB (W-13). |
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. |
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). |
425
+ | `dist/pinax.mjs` + `dist/chunks/` | | npm build. The editor is a normal dynamic-import chunk. |
426
+ | `dist/preview.mjs` + `dist/chunks/` | ~10 KB | `@getpinax/widget/preview`, the dashboard's live preview. Shares the UI chunk with `pinax.mjs`. |
427
+
428
+ `pinax-editor.js` and `pinax-recorder.js` must be served next to `pinax.js`
429
+ with CORS allowed (they are ES module imports). On page load the widget makes
430
+ one request, for the site's settings from the CDN
431
+ (`https://cdn.getpinax.com/config/<key>.json`, P-15), plus `POST /ping` only
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
435
+ (`GET /v1/widget/<key>/config`) only when the CDN can't answer, or when there's
436
+ no CDN (local builds, or `apiUrl`/`data-api` set without `cdnUrl`/`data-cdn`).
437
+
438
+ ### The CDN (P-15, B-16)
439
+
440
+ `cdn/` is the Cloudflare Worker behind `cdn.getpinax.com`; see
441
+ [`cdn/README.md`](cdn/README.md). In short: `pinax.js` is cached for an hour
442
+ (the URL customers paste never changes), `?v=<version>` files for a year as
443
+ immutable, and each site's settings are static JSON the API republishes on
444
+ every change, revalidated by browsers on each page load so the edge can count
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.
447
+
448
+ ## Accessibility and touch (W-12, W-15)
449
+
450
+ `src/__tests__/a11y.test.ts` runs axe-core (WCAG 2.0 to 2.2 A and AA, plus
451
+ best practice) on every panel screen, the follow-up question and the
452
+ screenshot editor, and checks text contrast (4.5:1), non-text contrast (3:1)
453
+ and target sizes (24 px, 2.5.8) from the CSS. `annotator.test.ts` drives the
454
+ editor with touch, pen and mouse pointer events, the keyboard fallbacks
455
+ (Enter on the image places a mark or selects the middle area; Ctrl/Cmd+Z;
456
+ Escape; focus stays inside) and area selection (W-04).
457
+
458
+ jsdom has no layout or real touch, so these still need a person with a device
459
+ before each release:
460
+
461
+ - iPhone (Safari) and Android (Chrome): open the panel, take a screenshot,
462
+ draw a box, an arrow and a blur with a finger, select an area, send. The
463
+ page mustn't scroll or zoom while drawing, and focusing a field mustn't
464
+ zoom the page.
465
+ - iPad (Safari) in both orientations, plus Apple Pencil.
466
+ - iOS screenshots: pages with cross-origin images or fonts without CORS
467
+ headers show those images as grey blocks and fall back to system fonts (a
468
+ browser rule; nothing the widget can fix). Captures are capped at 2560 px
469
+ on the long side and 5 MP in total (`captureScale`), well under iOS
470
+ Safari's 16.7 MP canvas limit, and the editor frees its scratch canvas.
471
+ - VoiceOver (iOS and macOS) and NVDA (Windows): open with the keyboard, hear
472
+ the dialog name, choose a type, hear errors and the confirmation, use the
473
+ editor without a pointer.
474
+ - 200% and 400% zoom, and Windows high contrast (forced colours).
475
+
476
+ ## Development
477
+
478
+ ```bash
479
+ yarn dev # http://localhost:5175 (index.html, ES module from src/)
480
+ yarn playground # build, then http://localhost:5177/playground/embed.html (the built script tag)
481
+ # and /playground/preview.html (the dashboard preview). PORT=5176 to change the port.
482
+ yarn build # types, four bundles, declarations, size check
483
+ yarn test # vitest (jsdom)
484
+ yarn run check # biome. Plain `yarn check` is Yarn's own command.
485
+ ```
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
+
499
+ Both playground pages take `?key=pk_...` (and `?api=...`). They also have
500
+ "Pinax AI switches" (`playground/fake-ai.js`): because a local backend has no
501
+ model key, they patch the config in the page to turn on the follow-up
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
505
+ widget code is unchanged; the switches wrap the page's `fetch` before the
506
+ widget loads. `index.html` also
507
+ reads `VITE_PINAX_KEY` from `.env.local`. To get a key, sign up through the
508
+ API and create a website for `http://localhost:5175`; its allowed domain is
509
+ `localhost`, which covers every port.
510
+
511
+ The report body is built in `src/report.ts` to the limits in
512
+ `backend/src/modules/widget/widget.validator.ts`. When `backend/` is checked
513
+ out next to `widget/`, `report.test.ts` also checks the body against that
514
+ real schema.
@@ -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 ADDED
@@ -0,0 +1,83 @@
1
+ import { type ReportBody } from './report';
2
+ import type { PinaxBasket, WidgetConfig } from './types';
3
+ /**
4
+ * The API origin. Production builds set VITE_PINAX_API (e.g. the real API
5
+ * domain); without it the widget talks to a local backend. `data-api` on the
6
+ * script tag, or `apiUrl` in init(), still overrides this per site.
7
+ */
8
+ export declare const DEFAULT_API: string;
9
+ export declare function apiBase(apiUrl?: string): string;
10
+ /**
11
+ * P-15: the CDN origin that serves each site's settings as static JSON
12
+ * (`/config/<key>.json`). Production builds set VITE_PINAX_CDN; without it
13
+ * (local builds) settings come straight from the API.
14
+ */
15
+ export declare const DEFAULT_CDN: string;
16
+ /**
17
+ * Where settings come from: `cdnUrl` / `data-cdn` if given; otherwise the
18
+ * build's CDN, unless the API was overridden (a local or self-hosted API has
19
+ * no CDN in front of it). Null means "ask the API".
20
+ */
21
+ export declare function cdnBase(cdnUrl?: string, apiUrl?: string): string | null;
22
+ export declare function cdnConfigUrl(cdn: string, key: string): string;
23
+ /**
24
+ * The fetch the widget itself uses: the page's fetch as it was before Pinax
25
+ * wrapped it, so Pinax's own calls never show up in the network log.
26
+ */
27
+ export type Fetch = typeof fetch;
28
+ /**
29
+ * P-15: settings from the CDN, so a page load never reaches our servers.
30
+ * The API is asked only when there's no CDN, or the CDN can't answer
31
+ * (down, blocked, or not set up yet).
32
+ */
33
+ export declare function fetchConfig(doFetch: Fetch, api: string, key: string, cdn?: string | null): Promise<WidgetConfig | null>;
34
+ /** S-12: only when the owner opens their site with ?pinax_verify. */
35
+ export declare function pingIfVerifying(doFetch: Fetch, api: string, key: string, href: string): void;
36
+ export type SendResult = {
37
+ ok: true;
38
+ id: string;
39
+ } | {
40
+ ok: false;
41
+ retry: boolean;
42
+ status: number;
43
+ message: string;
44
+ };
45
+ export declare function sendReport(doFetch: Fetch, api: string, key: string, body: ReportBody): Promise<SendResult>;
46
+ /** AI-10: the follow-up question gets this long before the report goes without it. */
47
+ export declare const FOLLOW_UP_TIMEOUT_MS = 4000;
48
+ /**
49
+ * AI-10: asks the backend whether a short note needs one follow-up question.
50
+ * Resolves to null on any error or after the timeout, so sending never
51
+ * waits longer than that.
52
+ */
53
+ export declare function fetchFollowUp(doFetch: Fetch, api: string, key: string, input: {
54
+ type: string;
55
+ message: string;
56
+ pagePath: string;
57
+ }, timeoutMs?: number): Promise<string | null>;
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
+ */
64
+ export declare function sendFixCheck(doFetch: Fetch, api: string, key: string, hit: {
65
+ checkId: string;
66
+ signature: string;
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>;