@getpinax/widget 0.0.0-stage → 1.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +267 -2
- package/dist/api.d.ts +62 -0
- package/dist/capture/breadcrumbs.d.ts +49 -0
- package/dist/capture/console.d.ts +17 -0
- package/dist/capture/env.d.ts +25 -0
- package/dist/capture/network.d.ts +23 -0
- package/dist/capture/ring.d.ts +14 -0
- package/dist/capture/signature.d.ts +2 -0
- package/dist/capture/text.d.ts +7 -0
- package/dist/capture/ua.d.ts +13 -0
- package/dist/chunks/editor-DbHLGwOA.mjs +1368 -0
- package/dist/chunks/privacy-DmU6v5Fr.mjs +39 -0
- package/dist/chunks/recorder-BUtEB_em.mjs +2859 -0
- package/dist/chunks/text-8QYEyRYs.mjs +18 -0
- package/dist/chunks/widget-CkR1Ptzb.mjs +961 -0
- package/dist/editor/annotator.d.ts +20 -0
- package/dist/editor/capture.d.ts +28 -0
- package/dist/editor/encode.d.ts +7 -0
- package/dist/editor/index.d.ts +5 -0
- package/dist/editor/mask.d.ts +29 -0
- package/dist/editor/pii.d.ts +22 -0
- package/dist/editor/shapes.d.ts +31 -0
- package/dist/editor-loader.d.ts +6 -0
- package/dist/editor-loader.embed.d.ts +5 -0
- package/dist/fixcheck.d.ts +31 -0
- package/dist/index.d.ts +46 -0
- package/dist/mount.d.ts +8 -0
- package/dist/navigation.d.ts +1 -0
- package/dist/paths.d.ts +4 -0
- package/dist/pinax-editor.js +1417 -0
- package/dist/pinax-recorder.js +2902 -0
- package/dist/pinax.js +113 -0
- package/dist/pinax.mjs +1041 -0
- package/dist/preview.d.ts +35 -0
- package/dist/preview.mjs +54 -0
- package/dist/privacy.d.ts +22 -0
- package/dist/queue.d.ts +9 -0
- package/dist/replay/buffer.d.ts +40 -0
- package/dist/replay/recorder.d.ts +15 -0
- package/dist/replay/sanitize.d.ts +13 -0
- package/dist/report.d.ts +109 -0
- package/dist/segment.d.ts +31 -0
- package/dist/settings.d.ts +31 -0
- package/dist/types.d.ts +137 -0
- package/dist/ui/color.d.ts +7 -0
- package/dist/ui/dom.d.ts +14 -0
- package/dist/ui/styles.d.ts +3 -0
- package/dist/ui/widget.d.ts +161 -0
- package/package.json +73 -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,268 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pinax widget
|
|
2
2
|
|
|
3
|
-
|
|
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, 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
|
+
// When someone signs in, so their reports carry who they are (C-09).
|
|
22
|
+
identify({ id: user.id, name: user.name, email: user.email });
|
|
23
|
+
identify(null); // on sign-out
|
|
24
|
+
|
|
25
|
+
// From your own "Give feedback" link or button (S-05).
|
|
26
|
+
document.querySelector('#feedback')?.addEventListener('click', () => open());
|
|
27
|
+
|
|
28
|
+
// On teardown (tests, micro-frontends). Restores console, fetch and XHR.
|
|
29
|
+
destroy();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
React example:
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { useEffect } from 'react';
|
|
36
|
+
import { init, identify } from '@getpinax/widget';
|
|
37
|
+
|
|
38
|
+
export function Feedback({ user }: { user?: { id: string; email: string } }) {
|
|
39
|
+
useEffect(() => init({ key: 'pk_...' }), []);
|
|
40
|
+
useEffect(() => identify(user ?? null), [user]);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The package is an ES module with TypeScript types. It does nothing on import;
|
|
46
|
+
`init()` starts it. The screenshot editor and the session recorder are
|
|
47
|
+
dynamic `import()` chunks, so your bundler splits them out and they load only
|
|
48
|
+
when needed. On server-rendered frameworks, call `init()` in the browser only
|
|
49
|
+
(`useEffect`, `onMount`, a client component).
|
|
50
|
+
|
|
51
|
+
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
|
+
|
|
56
|
+
### Script tag (CDN)
|
|
57
|
+
|
|
58
|
+
```html
|
|
59
|
+
<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 -->
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Publishing
|
|
64
|
+
|
|
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.
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
| Call | What it does |
|
|
74
|
+
|---|---|
|
|
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. |
|
|
76
|
+
| `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. |
|
|
78
|
+
| `destroy()` | Removes the widget and restores `console`, `fetch` and `XMLHttpRequest`. |
|
|
79
|
+
|
|
80
|
+
With the script tag these are on `window.Pinax`.
|
|
81
|
+
|
|
82
|
+
## Settings
|
|
83
|
+
|
|
84
|
+
The widget follows the settings from `GET /v1/widget/{key}/config`
|
|
85
|
+
(`backend/src/modules/widget/widget-settings.ts`). Anything missing or
|
|
86
|
+
malformed falls back to the defaults (`src/settings.ts`).
|
|
87
|
+
|
|
88
|
+
| Setting | Effect |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `color`, `text`, `position` | Button colour, label and placement (S-01). |
|
|
91
|
+
| `types`, `rating` | Which feedback types are offered; stars on General (S-02). |
|
|
92
|
+
| `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. |
|
|
93
|
+
| `smallOnMobile` | A smaller button on screens up to 520 px wide (S-04). |
|
|
94
|
+
| `segment` | Off by default. When on and the page runs Segment, the widget sends its events to `window.analytics` (see Segment below). |
|
|
95
|
+
| `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. |
|
|
96
|
+
| `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
|
+
|
|
98
|
+
History wrapping follows the same rules as the `fetch` wrapping: the original
|
|
99
|
+
always runs with the caller's arguments and its return value is passed back,
|
|
100
|
+
our callback can't throw into the page, it's only installed when `hidePaths`
|
|
101
|
+
is non-empty and the button isn't hidden anyway, and `destroy()` restores the
|
|
102
|
+
originals unless something else wrapped them on top of us.
|
|
103
|
+
|
|
104
|
+
## Segment
|
|
105
|
+
|
|
106
|
+
Sites that use Segment (analytics.js) can install Pinax alongside it and get
|
|
107
|
+
the widget's events in Segment. Turn on "Send events to Segment" on the
|
|
108
|
+
dashboard's Widget screen (setting `segment`), or force it per page with
|
|
109
|
+
`data-segment="true"` on the script tag or `init({ key, segment: true })`.
|
|
110
|
+
|
|
111
|
+
```html
|
|
112
|
+
<!-- 1. Segment's own snippet, as Segment gives it to you -->
|
|
113
|
+
<script>!function(){var analytics=window.analytics=window.analytics||[]; /* … */ analytics.load("YOUR_WRITE_KEY");analytics.page();}();</script>
|
|
114
|
+
<!-- 2. Pinax, after it -->
|
|
115
|
+
<script src="https://cdn.getpinax.com/pinax.js" data-key="pk_..." data-segment="true" async></script>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Loading Pinax through Segment instead (for example from a Segment-managed tag
|
|
119
|
+
or after `analytics.ready`) works the same way: the widget only looks for
|
|
120
|
+
`window.analytics` when something happens, so the order doesn't matter.
|
|
121
|
+
|
|
122
|
+
| Event | Properties |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `Pinax Widget Opened` | `source: "pinax"` |
|
|
125
|
+
| `Feedback Submitted` | `source`, `type` (`problem`, `idea`, `general`), `rating` (when given), `reportId` (when sent), `queued: true` (saved offline) |
|
|
126
|
+
|
|
127
|
+
Never sent: the message, the email, screenshots or anything else the visitor
|
|
128
|
+
typed. If the site hasn't called `Pinax.identify()` with an `id`, reports use
|
|
129
|
+
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
|
|
131
|
+
the page.
|
|
132
|
+
|
|
133
|
+
## Live preview (dashboard, S-03)
|
|
134
|
+
|
|
135
|
+
`@getpinax/widget/preview` (`dist/preview.mjs`, ~10 KB gzipped with its shared
|
|
136
|
+
chunk) renders the real button and panel into a container's shadow root from
|
|
137
|
+
a settings object. It makes no API calls and installs nothing: no console,
|
|
138
|
+
network or history wrapping, no screenshot library, no reports. "Send" just
|
|
139
|
+
shows the confirmation; the problem form shows a placeholder screenshot.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { preview } from '@getpinax/widget/preview';
|
|
143
|
+
|
|
144
|
+
// The container needs a size; the widget is positioned inside it as it
|
|
145
|
+
// would be in the visitor's window. Call only in the browser (useEffect).
|
|
146
|
+
const p = preview(element, settings, { view: 'choose', phone: false, poweredBy: true });
|
|
147
|
+
p.update(nextSettings); // on every form change; keeps the screen on show
|
|
148
|
+
p.update(nextSettings, { phone: true });
|
|
149
|
+
p.show('idea'); // 'button' | 'choose' | 'problem' | 'idea' | 'general' | 'done'
|
|
150
|
+
p.view(); // the screen on show (visitors can click around)
|
|
151
|
+
p.destroy();
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`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,
|
|
156
|
+
`smallOnMobile`) whatever the page width. `hidePaths` has no effect in the
|
|
157
|
+
preview. The preview never moves focus, so it can update while the owner
|
|
158
|
+
types. `playground/preview.html` is a working example.
|
|
159
|
+
|
|
160
|
+
## Pinax AI in the browser (Phase 4)
|
|
161
|
+
|
|
162
|
+
`GET /config` also returns `ai` (`widgetAiConfig` in
|
|
163
|
+
`backend/src/modules/widget/widget.service.ts`); `resolveAi` (`src/settings.ts`)
|
|
164
|
+
turns everything off when it's missing, and caps both sample rates at
|
|
165
|
+
`samplingRate` (B-17).
|
|
166
|
+
|
|
167
|
+
| Feature | What the widget does |
|
|
168
|
+
|---|---|
|
|
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. |
|
|
170
|
+
| 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
|
+
| 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). |
|
|
173
|
+
| 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
|
+
|
|
175
|
+
## Privacy
|
|
176
|
+
|
|
177
|
+
- Every form field (inputs, textareas, selects, contenteditable) is hidden in
|
|
178
|
+
screenshots, as is anything marked `data-pinax-mask` or `class="pinax-mask"`.
|
|
179
|
+
The live page is never changed: masking happens on the screenshot library's
|
|
180
|
+
private copy, and striped blocks are painted over the field positions.
|
|
181
|
+
- The network log keeps only failed (status 0, 4xx, 5xx) and slow (over 2 s)
|
|
182
|
+
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.
|
|
185
|
+
- No keystrokes are recorded. Pinax's own requests never appear in the log.
|
|
186
|
+
- Breadcrumbs and the replay follow the same masking rules; see the table above.
|
|
187
|
+
|
|
188
|
+
## How it loads
|
|
189
|
+
|
|
190
|
+
| File | Gzipped | When |
|
|
191
|
+
|---|---|---|
|
|
192
|
+
| `dist/pinax.js` | ~17 KB | The script tag. Button, panel, capture. Limit 50 KB (W-13). |
|
|
193
|
+
| `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). |
|
|
195
|
+
| `dist/pinax.mjs` + `dist/chunks/` | | npm build. The editor is a normal dynamic-import chunk. |
|
|
196
|
+
| `dist/preview.mjs` + `dist/chunks/` | ~10 KB | `@getpinax/widget/preview`, the dashboard's live preview. Shares the UI chunk with `pinax.mjs`. |
|
|
197
|
+
|
|
198
|
+
`pinax-editor.js` and `pinax-recorder.js` must be served next to `pinax.js`
|
|
199
|
+
with CORS allowed (they are ES module imports). On page load the widget makes
|
|
200
|
+
one request, for the site's settings from the CDN
|
|
201
|
+
(`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
|
|
204
|
+
(`GET /v1/widget/<key>/config`) only when the CDN can't answer, or when there's
|
|
205
|
+
no CDN (local builds, or `apiUrl`/`data-api` set without `cdnUrl`/`data-cdn`).
|
|
206
|
+
|
|
207
|
+
### The CDN (P-15, B-16)
|
|
208
|
+
|
|
209
|
+
`cdn/` is the Cloudflare Worker behind `cdn.getpinax.com`; see
|
|
210
|
+
[`cdn/README.md`](cdn/README.md). In short: `pinax.js` is cached for an hour
|
|
211
|
+
(the URL customers paste never changes), `?v=<version>` files for a year as
|
|
212
|
+
immutable, and each site's settings are static JSON the API republishes on
|
|
213
|
+
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.
|
|
215
|
+
|
|
216
|
+
## Accessibility and touch (W-12, W-15)
|
|
217
|
+
|
|
218
|
+
`src/__tests__/a11y.test.ts` runs axe-core (WCAG 2.0 to 2.2 A and AA, plus
|
|
219
|
+
best practice) on every panel screen, the follow-up question and the
|
|
220
|
+
screenshot editor, and checks text contrast (4.5:1), non-text contrast (3:1)
|
|
221
|
+
and target sizes (24 px, 2.5.8) from the CSS. `annotator.test.ts` drives the
|
|
222
|
+
editor with touch, pen and mouse pointer events, the keyboard fallbacks
|
|
223
|
+
(Enter on the image places a mark or selects the middle area; Ctrl/Cmd+Z;
|
|
224
|
+
Escape; focus stays inside) and area selection (W-04).
|
|
225
|
+
|
|
226
|
+
jsdom has no layout or real touch, so these still need a person with a device
|
|
227
|
+
before each release:
|
|
228
|
+
|
|
229
|
+
- iPhone (Safari) and Android (Chrome): open the panel, take a screenshot,
|
|
230
|
+
draw a box, an arrow and a blur with a finger, select an area, send. The
|
|
231
|
+
page mustn't scroll or zoom while drawing, and focusing a field mustn't
|
|
232
|
+
zoom the page.
|
|
233
|
+
- iPad (Safari) in both orientations, plus Apple Pencil.
|
|
234
|
+
- iOS screenshots: pages with cross-origin images or fonts without CORS
|
|
235
|
+
headers show those images as grey blocks and fall back to system fonts (a
|
|
236
|
+
browser rule; nothing the widget can fix). Captures are capped at 2560 px
|
|
237
|
+
on the long side and 5 MP in total (`captureScale`), well under iOS
|
|
238
|
+
Safari's 16.7 MP canvas limit, and the editor frees its scratch canvas.
|
|
239
|
+
- VoiceOver (iOS and macOS) and NVDA (Windows): open with the keyboard, hear
|
|
240
|
+
the dialog name, choose a type, hear errors and the confirmation, use the
|
|
241
|
+
editor without a pointer.
|
|
242
|
+
- 200% and 400% zoom, and Windows high contrast (forced colours).
|
|
243
|
+
|
|
244
|
+
## Development
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
yarn dev # http://localhost:5175 (index.html, ES module from src/)
|
|
248
|
+
yarn playground # build, then http://localhost:5177/playground/embed.html (the built script tag)
|
|
249
|
+
# and /playground/preview.html (the dashboard preview). PORT=5176 to change the port.
|
|
250
|
+
yarn build # types, four bundles, declarations, size check
|
|
251
|
+
yarn test # vitest (jsdom)
|
|
252
|
+
yarn run check # biome. Plain `yarn check` is Yarn's own command.
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Both playground pages take `?key=pk_...` (and `?api=...`). They also have
|
|
256
|
+
"Pinax AI switches" (`playground/fake-ai.js`): because a local backend has no
|
|
257
|
+
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
|
|
259
|
+
widget code is unchanged; the switches wrap the page's `fetch` before the
|
|
260
|
+
widget loads. `index.html` also
|
|
261
|
+
reads `VITE_PINAX_KEY` from `.env.local`. To get a key, sign up through the
|
|
262
|
+
API and create a website for `http://localhost:5175`; its allowed domain is
|
|
263
|
+
`localhost`, which covers every port.
|
|
264
|
+
|
|
265
|
+
The report body is built in `src/report.ts` to the limits in
|
|
266
|
+
`backend/src/modules/widget/widget.validator.ts`. When `backend/` is checked
|
|
267
|
+
out next to `widget/`, `report.test.ts` also checks the body against that
|
|
268
|
+
real schema.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ReportBody } from './report';
|
|
2
|
+
import type { 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
|
+
/** AI-17: the watched error came back on a page marked Fixed. Fire and forget. */
|
|
59
|
+
export declare function sendFixCheck(doFetch: Fetch, api: string, key: string, hit: {
|
|
60
|
+
checkId: string;
|
|
61
|
+
signature: string;
|
|
62
|
+
}): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { RingBuffer } from './ring';
|
|
2
|
+
/**
|
|
3
|
+
* AI-11: what the visitor did just before sending. The backend's schema is
|
|
4
|
+
* `breadcrumbs` in `backend/src/modules/widget/widget.validator.ts`.
|
|
5
|
+
*/
|
|
6
|
+
export type Breadcrumb = {
|
|
7
|
+
kind: 'click' | 'navigation' | 'input';
|
|
8
|
+
at: string;
|
|
9
|
+
path: string;
|
|
10
|
+
/** Short, privacy-safe description of the target, e.g. button "Book a table". */
|
|
11
|
+
target?: string;
|
|
12
|
+
};
|
|
13
|
+
export declare const MAX_BREADCRUMBS = 20;
|
|
14
|
+
export declare const MAX_NAME = 60;
|
|
15
|
+
/** A password or payment card field: not even its label is kept. */
|
|
16
|
+
export declare function isSecretField(el: Element): boolean;
|
|
17
|
+
export declare function roleOf(el: Element): string;
|
|
18
|
+
/**
|
|
19
|
+
* Visible text of `el`, skipping form fields, editable areas and anything
|
|
20
|
+
* the owner masked. Images contribute their alt text.
|
|
21
|
+
*/
|
|
22
|
+
export declare function safeText(el: Element, max?: number): string;
|
|
23
|
+
/** The accessible name, as far as it can be read without visitor data. */
|
|
24
|
+
export declare function nameOf(el: Element): string;
|
|
25
|
+
/**
|
|
26
|
+
* A short, privacy-safe description of what was clicked or typed in:
|
|
27
|
+
* `button "Book a table"`, `link "Menu"`, `textbox "Email"`,
|
|
28
|
+
* `password field`. Names are cut to 60 characters. A click on anything
|
|
29
|
+
* that isn't a control (a paragraph, a table cell, an order summary) keeps
|
|
30
|
+
* only its role or tag: its text may be the visitor's own details.
|
|
31
|
+
*/
|
|
32
|
+
export declare function describeTarget(start: Element): string;
|
|
33
|
+
/** The page as breadcrumbs record it: path and query, secrets masked, no fragment. */
|
|
34
|
+
export declare function currentPath(href?: string): string;
|
|
35
|
+
type Options = {
|
|
36
|
+
/** Events from inside the widget itself are left out. */
|
|
37
|
+
ignore?: (target: EventTarget | null) => boolean;
|
|
38
|
+
doc?: Document;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Records clicks and typing (never what was typed) into `buffer`. Page views
|
|
42
|
+
* are added by the caller through `navigated()`, so they share the one
|
|
43
|
+
* history watcher. Never throws into the page.
|
|
44
|
+
*/
|
|
45
|
+
export declare function captureBreadcrumbs(buffer: RingBuffer<Breadcrumb>, options?: Options): {
|
|
46
|
+
navigated: () => void;
|
|
47
|
+
stop: () => void;
|
|
48
|
+
};
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RingBuffer } from './ring';
|
|
2
|
+
export type ConsoleLevel = 'error' | 'warn' | 'log' | 'info' | 'debug';
|
|
3
|
+
export type ConsoleEntry = {
|
|
4
|
+
level: ConsoleLevel;
|
|
5
|
+
message: string;
|
|
6
|
+
stack?: string;
|
|
7
|
+
at: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const MAX_MESSAGE = 2000;
|
|
10
|
+
export declare const MAX_STACK = 4000;
|
|
11
|
+
export declare function formatArgs(args: unknown[]): string;
|
|
12
|
+
/**
|
|
13
|
+
* C-04: wraps console methods and listens for uncaught errors and rejected
|
|
14
|
+
* promises. Never throws into the page; if the host replaces `console` later,
|
|
15
|
+
* capture just stops for the replaced methods.
|
|
16
|
+
*/
|
|
17
|
+
export declare function captureConsole(buffer: RingBuffer<ConsoleEntry>, target?: Window & typeof globalThis): () => void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type DeviceType } from './ua';
|
|
2
|
+
export type Environment = {
|
|
3
|
+
userAgent: string;
|
|
4
|
+
browser?: string;
|
|
5
|
+
browserVersion?: string;
|
|
6
|
+
os?: string;
|
|
7
|
+
deviceType: DeviceType;
|
|
8
|
+
deviceModel?: string;
|
|
9
|
+
screen: {
|
|
10
|
+
w: number;
|
|
11
|
+
h: number;
|
|
12
|
+
};
|
|
13
|
+
viewport: {
|
|
14
|
+
w: number;
|
|
15
|
+
h: number;
|
|
16
|
+
};
|
|
17
|
+
pixelRatio: number;
|
|
18
|
+
timeZone: string;
|
|
19
|
+
language?: string;
|
|
20
|
+
occurredAt: string;
|
|
21
|
+
};
|
|
22
|
+
/** Local time with its UTC offset, e.g. 2026-11-04T15:20:01.123+01:00 (C-01). */
|
|
23
|
+
export declare function localIsoTime(date?: Date): string;
|
|
24
|
+
/** C-01 to C-03, read at the moment the visitor sends. */
|
|
25
|
+
export declare function readEnvironment(win?: Window): Environment;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RingBuffer } from './ring';
|
|
2
|
+
export type NetworkEntry = {
|
|
3
|
+
method: string;
|
|
4
|
+
url: string;
|
|
5
|
+
status: number;
|
|
6
|
+
durationMs: number;
|
|
7
|
+
at: string;
|
|
8
|
+
};
|
|
9
|
+
/** C-05: a request is kept when it fails or takes longer than this. */
|
|
10
|
+
export declare const SLOW_MS = 2000;
|
|
11
|
+
export declare function shouldRecord(status: number, durationMs: number): boolean;
|
|
12
|
+
type Options = {
|
|
13
|
+
/** Requests to these URL prefixes (Pinax's own API) are never recorded. */
|
|
14
|
+
ignore?: (url: string) => boolean;
|
|
15
|
+
now?: () => number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
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),
|
|
20
|
+
* duration and time. Headers and bodies are never read.
|
|
21
|
+
*/
|
|
22
|
+
export declare function captureNetwork(buffer: RingBuffer<NetworkEntry>, target?: Window, options?: Options): () => void;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** A fixed-size buffer that keeps only the newest `capacity` items (C-04, C-05). */
|
|
2
|
+
export declare class RingBuffer<T> {
|
|
3
|
+
readonly capacity: number;
|
|
4
|
+
private items;
|
|
5
|
+
private listeners;
|
|
6
|
+
constructor(capacity: number);
|
|
7
|
+
push(item: T): void;
|
|
8
|
+
/** Called with every new item (AI-17 fix check). Returns an unsubscribe. */
|
|
9
|
+
subscribe(listener: (item: T) => void): () => void;
|
|
10
|
+
/** Oldest first. Returns a copy. */
|
|
11
|
+
toArray(): T[];
|
|
12
|
+
clear(): void;
|
|
13
|
+
get size(): number;
|
|
14
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Cuts a string to `max` characters, marking the cut with an ellipsis. */
|
|
2
|
+
export declare function clip(value: string, max: number): string;
|
|
3
|
+
/**
|
|
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).
|
|
6
|
+
*/
|
|
7
|
+
export declare function safeUrl(raw: string, base?: string): string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type DeviceType = 'desktop' | 'mobile' | 'tablet';
|
|
2
|
+
export type ParsedUA = {
|
|
3
|
+
browser?: string;
|
|
4
|
+
browserVersion?: string;
|
|
5
|
+
os?: string;
|
|
6
|
+
deviceType: DeviceType;
|
|
7
|
+
deviceModel?: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* C-02: a small hand-written user-agent parser. `maxTouchPoints` tells iPads
|
|
11
|
+
* in desktop mode (which claim to be a Mac) apart from real Macs.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseUserAgent(ua: string, maxTouchPoints?: number): ParsedUA;
|