@pixelmatters/markup 1.18.4 → 1.18.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixelmatters/markup",
3
- "version": "1.18.4",
3
+ "version": "1.18.6",
4
4
  "description": "Embeddable feedback widget for collecting visual bug reports, screenshots, and comments on live web apps.",
5
5
  "keywords": [
6
6
  "annotation",
@@ -1,18 +1,18 @@
1
1
  ---
2
2
  name: install-markup-widget
3
- description: Install, configure, and troubleshoot the `@pixelmatters/markup` feedback widget a drop-in script that lets end-users pin threaded comments on any web page. Use this skill whenever the host project depends on `@pixelmatters/markup`, imports `init` / `destroy` from it, references a `markup_…` API key, or the user asks about installing / debugging the Markup feedback widget.
3
+ description: Install, configure, and troubleshoot the `@pixelmatters/markup` feedback widget, a drop-in script that lets end-users pin threaded comments on any web page. Use this skill whenever the host project depends on `@pixelmatters/markup`, imports `init` / `destroy` from it, references a `markup_…` API key, or the user asks about installing / debugging the Markup feedback widget.
4
4
  sources:
5
5
  - 'Pixelmatters/markup:packages/widget/README.md'
6
6
  - 'Pixelmatters/markup:docs/widget-guide.md'
7
7
  - 'Pixelmatters/markup:packages/widget/src/widget.ts'
8
8
  metadata:
9
9
  library: pixelmatters-markup
10
- library_version: '1.18.4'
10
+ library_version: '1.18.6'
11
11
  ---
12
12
 
13
- `@pixelmatters/markup` is a Preact widget that runs inside a shadow DOM and talks to a hosted Convex backend at `https://<deployment>.convex.site`. The host page calls `init({ apiUrl, apiKey })` once at the app root and gets back a `destroy()` function. Everything else pin anchoring, threads, mentions, identity, screenshots, real-time updates — lives inside the widget bundle.
13
+ `@pixelmatters/markup` is a Preact widget that runs inside a shadow DOM and talks to a hosted Convex backend at `https://<deployment>.convex.site`. The host page calls `init({ apiUrl, apiKey })` once at the app root and gets back a `destroy()` function. Everything else lives inside the widget bundle: pin anchoring, threads, mentions, identity, screenshots, real-time updates.
14
14
 
15
- The widget renders a single compact **toolbar pill** in one bottom corner: comment, inbox (signed-in users only), pin-visibility toggle, identity menu, and an overflow menu holding Appearance, Position, an auto-capture toggle, and "Hide for this session". Installs predating 1.15.0 described a floating action button — that's gone, along with drag-to-reposition.
15
+ The widget renders a single compact **toolbar pill** in one bottom corner: comment, inbox (signed-in users only), pin-visibility toggle, identity menu, and an overflow menu holding Appearance, Position, an auto-capture toggle, and "Hide for this session". Installs predating 1.15.0 described a floating action button. That's gone, along with drag-to-reposition.
16
16
 
17
17
  ## Install
18
18
 
@@ -26,17 +26,17 @@ For a `<script>`-tag drop-in (no bundler / CMS / page-builder), use the inline E
26
26
 
27
27
  ```html
28
28
  <script type="module">
29
- import { init } from 'https://esm.sh/@pixelmatters/markup@1.18.4'
29
+ import { init } from 'https://esm.sh/@pixelmatters/markup@1.18.6'
30
30
  init({ apiUrl: '…', apiKey: '…' })
31
31
  </script>
32
32
  ```
33
33
 
34
- If the host disallows inline JS, use the auto-init form. `data-markup-widget="true"` is required it's how the bundle finds its own script element under `type="module"`:
34
+ If the host disallows inline JS, use the auto-init form. `data-markup-widget="true"` is required, since it's how the bundle finds its own script element under `type="module"`:
35
35
 
36
36
  ```html
37
37
  <script
38
38
  type="module"
39
- src="https://esm.sh/@pixelmatters/markup@1.18.4"
39
+ src="https://esm.sh/@pixelmatters/markup@1.18.6"
40
40
  data-markup-widget="true"
41
41
  data-api-url="…"
42
42
  data-api-key="…"
@@ -49,18 +49,18 @@ The recognised attributes are `data-api-url`, `data-api-key`, `data-position`, `
49
49
 
50
50
  ## Wire-up
51
51
 
52
- There is no framework-specific entrypoint no `@pixelmatters/markup/react`, no provider to wrap. Every framework calls `init()` from a mount hook (`useEffect`, `onMounted`, `onMount`, …) and the function it returns on cleanup. A module-level `destroy` is exported too, for call sites that can't hold onto the returned one; it tears down whichever instance is currently mounted.
52
+ There is no framework-specific entrypoint: no `@pixelmatters/markup/react`, no provider to wrap. Every framework calls `init()` from a mount hook (`useEffect`, `onMounted`, `onMount`, …) and the function it returns on cleanup. A module-level `destroy` is exported too, for call sites that can't hold onto the returned one; it tears down whichever instance is currently mounted.
53
53
 
54
54
  ```ts
55
55
  import { init, destroy } from '@pixelmatters/markup'
56
56
  ```
57
57
 
58
- `init` returns the matching `destroy`. Mount **once at the app root**, not per route the widget patches `history.pushState` / `replaceState` and listens for `popstate` itself, so SPAs work without remounting. Calling `init` repeatedly is safe (the history wrapper is module-scoped, so init/destroy cycles don't stack), but it tears down the previous mount each time so a render loop calling `init` will thrash. Always anchor it to a one-time mount lifecycle.
58
+ `init` returns the matching `destroy`. Mount **once at the app root**, not per route. The widget patches `history.pushState` / `replaceState` and listens for `popstate` itself, so SPAs work without remounting. Calling `init` repeatedly is safe (the history wrapper is module-scoped, so init/destroy cycles don't stack), but it tears down the previous mount each time, so a render loop calling `init` will thrash. Always anchor it to a one-time mount lifecycle.
59
59
 
60
- If `apiUrl` or `apiKey` is missing, `init` logs a `[markup]` console warning and returns a no-op `destroy` rather than mounting useful to know when debugging "nothing showed up."
60
+ If `apiUrl` or `apiKey` is missing, `init` logs a `[markup]` console warning and returns a no-op `destroy` rather than mounting, which is worth knowing when debugging "nothing showed up."
61
61
 
62
62
  ```ts
63
- // React App.tsx
63
+ // React, in App.tsx
64
64
  useEffect(() => {
65
65
  return init({
66
66
  apiUrl: import.meta.env.VITE_MARKUP_API_URL,
@@ -86,18 +86,18 @@ const stop = init({ apiUrl, apiKey })
86
86
  window.addEventListener('beforeunload', stop)
87
87
  ```
88
88
 
89
- Tear down on logout if you don't want the widget visible to signed-out users. `init` is safe to call repeatedly each call destroys the previous instance first.
89
+ Tear down on logout if you don't want the widget visible to signed-out users. `init` is safe to call repeatedly, and each call destroys the previous instance first.
90
90
 
91
91
  ## Credentials
92
92
 
93
- - `apiUrl`: the project's Convex deployment site URL copy from **Markup dashboard → Settings → Install**. Format: `https://<deployment>.convex.site`.
93
+ - `apiUrl`: the project's Convex deployment site URL, copied from **Markup dashboard → Settings → Install**. Format: `https://<deployment>.convex.site`.
94
94
  - `apiKey`: mint from **Settings → API Keys**. The raw key is shown **exactly once**; if it's lost, revoke and mint a new one.
95
95
 
96
96
  Conventions:
97
97
 
98
- - Store both in env vars (`VITE_MARKUP_API_URL`, `VITE_MARKUP_API_KEY`, or the framework equivalent `NEXT_PUBLIC_…`, `PUBLIC_…`).
98
+ - Store both in env vars (`VITE_MARKUP_API_URL`, `VITE_MARKUP_API_KEY`, or the framework equivalent, such as `NEXT_PUBLIC_…` or `PUBLIC_…`).
99
99
  - Add to `.env.example`. Ensure `.env*.local` is in `.gitignore` (most Vite / Next templates already do this).
100
- - The API key is public-by-design — it's gated server-side by the project's `allowedDomains`. Treating it like a secret anyway keeps it out of git history and forces rotation through the dashboard rather than ad-hoc.
100
+ - The API key is public by design, gated server-side by the project's `allowedDomains`. Treating it like a secret anyway keeps it out of git history and forces rotation through the dashboard rather than ad-hoc.
101
101
 
102
102
  ## Domain allowlist
103
103
 
@@ -107,19 +107,19 @@ Production deployments **do not** auto-allow `localhost`. Before the widget will
107
107
  2. Add the exact host (`app.example.com`) or a glob (`*.staging.example.com`).
108
108
  3. To test against the live deployment from local dev, add `localhost` explicitly.
109
109
 
110
- A request from an unlisted origin gets `403 Origin not allowed` with no CORS headers in browser devtools it shows as a CORS error, not a clean JSON error.
110
+ A request from an unlisted origin gets `403 Origin not allowed` with no CORS headers, so browser devtools shows it as a CORS error rather than a clean JSON error.
111
111
 
112
112
  ## Config options
113
113
 
114
- | Option | Type | Default | Notes |
115
- | -------------- | ---------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
116
- | `apiUrl` | `string` | required | `https://*.convex.site` |
117
- | `apiKey` | `string` | required | `markup_…` |
118
- | `position` | `'bottom-right' \| 'bottom-left' \| 'bottom-center'` | `'bottom-right'` | Initial toolbar corner users move it with the Position picker in the overflow menu; the choice is per-mount and resets on reload |
119
- | `theme` | `'light' \| 'dark' \| 'auto'` | `'auto'` | `'auto'` follows the host's `prefers-color-scheme`. A user's pick in the overflow menu persists and outranks this on later `init()` |
120
- | `screenshots` | `ScreenshotsConfig` (see below) | enabled, defaults | Capture toggle + PII-scrub knobs |
121
- | `dashboardUrl` | `string` | | Adds an **Account →** link to the signed-in identity menu and retargets the "Powered by Markup" line. For self-hosters |
122
- | `fab` | `'default' \| 'icon-only'` | | **Deprecated, ignored since 1.15.0.** Accepted so old configs still compile; `init()` drops it and warns once. Remove it |
114
+ | Option | Type | Default | Notes |
115
+ | -------------- | ---------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
116
+ | `apiUrl` | `string` | required | `https://*.convex.site` |
117
+ | `apiKey` | `string` | required | `markup_…` |
118
+ | `position` | `'bottom-right' \| 'bottom-left' \| 'bottom-center'` | `'bottom-right'` | Initial toolbar corner. Users move it with the Position picker in the overflow menu, and the choice is per-mount and resets on reload |
119
+ | `theme` | `'light' \| 'dark' \| 'auto'` | `'auto'` | `'auto'` follows the host's `prefers-color-scheme`. A user's pick in the overflow menu persists and outranks this on later `init()` |
120
+ | `screenshots` | `ScreenshotsConfig` (see below) | enabled, defaults | Capture toggle and PII-scrub options |
121
+ | `dashboardUrl` | `string` | none | Adds an **Account →** link to the signed-in identity menu and retargets the "Powered by Markup" line. For self-hosters |
122
+ | `fab` | `'default' \| 'icon-only'` | none | **Deprecated, ignored since 1.15.0.** Accepted so old configs still compile; `init()` drops it and warns once. Remove it |
123
123
 
124
124
  ### Screenshots & PII scrub
125
125
 
@@ -128,9 +128,9 @@ init({
128
128
  apiUrl,
129
129
  apiKey,
130
130
  screenshots: {
131
- enabled: true, // default true set false to disable screenshot capture
131
+ enabled: true, // default true; set false to disable screenshot capture
132
132
  strictScrub: false, // mask EVERY input/select/textarea (not just sensitive)
133
- redactSelector: '.private', // extra CSS selector matched elements always masked
133
+ redactSelector: '.private', // extra CSS selector; matched elements always masked
134
134
  },
135
135
  })
136
136
  ```
@@ -143,27 +143,27 @@ Auto-masked by default (regardless of `strictScrub`):
143
143
 
144
144
  HTML attributes the widget honours on the host page:
145
145
 
146
- - `data-markup-private` / `data-markup-redact` always masked (in addition to auto-detection).
147
- - `data-markup-safe` any element with this attribute (or an ancestor that has it) is **exempted** from auto-detection and from `redactSelector` / `strictScrub`. Use sparingly — putting it on a wrapper accidentally un-protects everything inside.
148
- - `data-markup-skip` removes the element from the screenshot entirely, rather than masking it.
146
+ - `data-markup-private` and `data-markup-redact` are always masked, on top of auto-detection.
147
+ - `data-markup-safe` **exempts** any element carrying it, or any descendant of one, from auto-detection and from `redactSelector` / `strictScrub`. Use it sparingly: on a wrapper it un-protects everything inside.
148
+ - `data-markup-skip` removes the element from the screenshot entirely, rather than masking it.
149
149
 
150
150
  Tell users with strict compliance needs to opt into `strictScrub: true` and pin sensitive areas with `data-markup-private` rather than relying on the default heuristics.
151
151
 
152
- End users can also switch capture off for themselves with **Auto-capture screenshots** in the overflow menu. `screenshots.enabled: false` from the host still wins that row renders disabled.
152
+ End users can also switch capture off for themselves with **Auto-capture screenshots** in the overflow menu. `screenshots.enabled: false` from the host still wins, and that row renders disabled.
153
153
 
154
- Capture degrades rather than failing silently: an image the browser won't hand over (a third-party avatar served without CORS headers is the usual culprit) comes through blank while the rest of the page captures; an oversized capture is re-encoded down a quality-then-scale ladder to fit the server's 2 MB cap; and if it still can't produce an image the composer says _Screenshot unavailable_ and the comment posts without one.
154
+ Capture degrades rather than failing outright: an image the browser won't hand over (a third-party avatar served without CORS headers is the usual culprit) comes through blank while the rest of the page captures; an oversized capture is re-encoded down a quality-then-scale ladder to fit the server's 2 MB cap; and if it still can't produce an image the composer says _Screenshot unavailable_ and the comment posts without one.
155
155
 
156
156
  ## Mentions & inbox
157
157
 
158
- Typing `@` in a composer opens a picker of project members and inserts a chip the stored `@[Display Name](userId)` form never reaches the screen. `↑`/`↓` move, `enter` or `tab` picks, `esc` dismisses. It works for anonymous visitors too; the member list is fetched once per mount with the API key, and a failed fetch degrades to "no picker, no chips" rather than blocking the widget.
158
+ Typing `@` in a composer opens a picker of project members and inserts a chip, and the stored `@[Display Name](userId)` form never reaches the screen. `↑`/`↓` move, `enter` or `tab` picks, `esc` dismisses. It works for anonymous visitors too; the member list is fetched once per mount with the API key, and a failed fetch degrades to "no picker, no chips" rather than blocking the widget.
159
159
 
160
- Mentioned teammates get a notification. **Signed-in** users read those from the inbox button on the toolbar, which carries an unread badge; anonymous visitors don't get the button. A mention that doesn't highlight means nobody was notified the target isn't a project member, has left, or is the author themselves.
160
+ Mentioned teammates get a notification. **Signed-in** users read those from the inbox button on the toolbar, which carries an unread badge; anonymous visitors don't get the button. A mention that doesn't highlight means nobody was notified: the target isn't a project member, has left, or is the author themselves.
161
161
 
162
162
  Comments written by an AI agent through Markup's MCP server carry a bot badge. They're posted under a team member's name, so the badge is the only signal that a machine wrote it.
163
163
 
164
164
  ## Verify the install
165
165
 
166
- Don't call it done on a clean build the widget can compile in and still never
166
+ Don't call it done on a clean build. The widget can compile in and still never
167
167
  mount. Run the dev server, then:
168
168
 
169
169
  1. Confirm the toolbar pill is in the configured bottom corner.
@@ -171,44 +171,44 @@ mount. Run the dev server, then:
171
171
  3. Confirm the thread appears on the project in the Markup dashboard. It arrives
172
172
  over WebSocket, so it should show without a refresh.
173
173
 
174
- A pin that posts but never lands in the dashboard is a project mismatch — the
174
+ A pin that posts but never lands in the dashboard is a project mismatch. The
175
175
  API key belongs to a different project than the one being watched.
176
176
 
177
177
  ## Diagnostics
178
178
 
179
179
  Walk these in order when the widget is misbehaving:
180
180
 
181
- | Symptom | Cause | Fix |
182
- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
183
- | Toolbar doesn't appear, console warns `[markup] init() requires both apiUrl and apiKey` | One of the env vars is unset / undefined at the call site | Confirm the framework's env-var prefix (`VITE_…`, `NEXT_PUBLIC_…`, etc.) and that `.env` is being loaded |
184
- | Toolbar doesn't appear, console shows `403 Origin not allowed` | Host is not in `allowedDomains` | Dashboard → Settings → Domains, add the exact host (`app.example.com`) or a wildcard (`*.example.com` matches any subdomain depth) |
185
- | Toolbar renders unstyled, or threads never load | CSP is missing `style-src 'unsafe-inline'` (the stylesheet is a `<style>` element in the shadow root) or the deployment origins under `connect-src` | See the CSP bullet under **Don'ts** |
186
- | Comments show initials where profile pictures are expected | The host's CSP `img-src` blocks the picture host, or the account has none set | Add the picture host to `img-src`. Initials are the intended fallback, not a bug |
187
- | `@` types a literal character, no picker | The member fetch failed (a `403`/`429` on `/widget/members` degrades silently by design) | Check the network tab for `/widget/members`; the usual cause is the domain allowlist |
188
- | `401 Invalid api key` | Key revoked, wrong project, or copy-paste truncation | Mint a fresh key and replace the env var |
189
- | `429` + `Retry-After` header | Rate-limited (per-key pre-auth or per-project bucket) | Stop polling; respect the `Retry-After` seconds. If hit during normal use, ask the user to contact support to raise the project limit |
190
- | `400 Invalid anchor` / `Invalid viewport` / `Field too long` | Widget POSTed a coord outside `[0..1]`, a non-finite viewport size, or an over-long string (route > 256, url > 2048, userAgent > 500, anchorSelector > 1024, authorEmail > 320) | The bundled widget always stays inside these limits; this only fires if something between the widget and the API rewrote the payload check for a misbehaving service worker or proxy |
191
- | Pins drift after host layout changes | Selector resolution failed; falling back to viewport fractions | Expected. Use stable IDs / data-attributes on anchor targets if precision matters |
192
- | Pins disappear after route change in an SPA | `init` was called per-route and remounted state | Move `init` to a single root-level mount; the widget handles history itself |
193
- | Widget styling looks broken inside an iframe | Shadow DOM doesn't pierce frame boundaries | Mount the widget **inside** the iframe document, not the parent |
194
- | Verified identity doesn't survive reload | Host code clears `localStorage['markup.identity']` on logout | Only clear it on Markup-specific sign-out, not on host logout |
195
- | Popup sign-in flashes and closes | Popup origin failed `allowedDomains` check, or the stored JWT was expired and dropped | Add the host to the project's allowlist; the popup re-validates. Expired JWTs are silently dropped on next load sign in again |
196
- | HMR leaves duplicate toolbars in dev | Hot reload re-runs `init` without cleanup | Return the `destroy` from `useEffect` / `onMounted` so HMR can call it. (The history wrapper itself is module-scoped, so repeated init/destroy cycles no longer leak listeners.) |
197
- | Screenshot is missing my custom field | Auto-mask matched the input (e.g. `autocomplete="cc-number"`) or your selector | Inspect with the widget devtools panel; add `data-markup-safe` on the wrapper to exempt it from auto-detection |
198
- | Widget invisible behind host UI | Z-index conflict on the shadow host element | The widget mounts `<div id="markup-widget">` at `document.body`; raise its `z-index` from host CSS if you must |
181
+ | Symptom | Cause | Fix |
182
+ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
183
+ | Toolbar doesn't appear, console warns `[markup] init() requires both apiUrl and apiKey` | One of the env vars is unset / undefined at the call site | Confirm the framework's env-var prefix (`VITE_…`, `NEXT_PUBLIC_…`, etc.) and that `.env` is being loaded |
184
+ | Toolbar doesn't appear, console shows `403 Origin not allowed` | Host is not in `allowedDomains` | Dashboard → Settings → Domains, add the exact host (`app.example.com`) or a wildcard (`*.example.com` matches any subdomain depth) |
185
+ | Toolbar renders unstyled, or threads never load | CSP is missing `style-src 'unsafe-inline'` (the stylesheet is a `<style>` element in the shadow root) or the deployment origins under `connect-src` | See the CSP bullet under **Don'ts** |
186
+ | Comments show initials where profile pictures are expected | The host's CSP `img-src` blocks the picture host, or the account has none set | Add the picture host to `img-src`. Initials are the intended fallback, not a bug |
187
+ | `@` types a literal character, no picker | The member fetch failed (a `403`/`429` on `/widget/members` degrades silently by design) | Check the network tab for `/widget/members`; the usual cause is the domain allowlist |
188
+ | `401 Invalid api key` | Key revoked, wrong project, or copy-paste truncation | Mint a fresh key and replace the env var |
189
+ | `429` + `Retry-After` header | Rate-limited (per-key pre-auth or per-project bucket) | Stop polling; respect the `Retry-After` seconds. If hit during normal use, ask the user to contact support to raise the project limit |
190
+ | `400 Invalid anchor` / `Invalid viewport` / `Field too long` | Widget POSTed a coord outside `[0..1]`, a non-finite viewport size, or an over-long string (route > 256, url > 2048, userAgent > 500, anchorSelector > 1024, authorEmail > 320) | The bundled widget always stays inside these limits, so this only fires if something between the widget and the API rewrote the payload. Check for a misbehaving service worker or proxy |
191
+ | Pins drift after host layout changes | Selector resolution failed; falling back to viewport fractions | Expected. Use stable IDs / data-attributes on anchor targets if precision matters |
192
+ | Pins disappear after route change in an SPA | `init` was called per-route and remounted state | Move `init` to a single root-level mount; the widget handles history itself |
193
+ | Widget styling looks broken inside an iframe | Shadow DOM doesn't pierce frame boundaries | Mount the widget **inside** the iframe document, not the parent |
194
+ | Verified identity doesn't survive reload | Host code clears `localStorage['markup.identity']` on logout | Only clear it on Markup-specific sign-out, not on host logout |
195
+ | Popup sign-in flashes and closes | Popup origin failed `allowedDomains` check, or the stored JWT was expired and dropped | Add the host to the project's allowlist; the popup re-validates. Expired JWTs are dropped on the next load, so sign in again |
196
+ | HMR leaves duplicate toolbars in dev | Hot reload re-runs `init` without cleanup | Return the `destroy` from `useEffect` / `onMounted` so HMR can call it. (The history wrapper itself is module-scoped, so repeated init/destroy cycles no longer leak listeners.) |
197
+ | Screenshot is missing my custom field | Auto-mask matched the input (e.g. `autocomplete="cc-number"`) or your selector | Inspect with the widget devtools panel; add `data-markup-safe` on the wrapper to exempt it from auto-detection |
198
+ | Widget invisible behind host UI | Z-index conflict on the shadow host element | The widget mounts `<div id="markup-widget">` at `document.body`; raise its `z-index` from host CSS if you must |
199
199
 
200
200
  ## Don'ts
201
201
 
202
- - **Don't import CSS** there is none to import. Styles live inside the shadow root.
203
- - **Don't wrap in a provider component.** `init` is the entire public surface.
202
+ - **Don't import CSS.** There is none to import. Styles live inside the shadow root.
203
+ - **Don't wrap in a provider component.** `init` is the whole public API.
204
204
  - **Don't call `init` inside a route component.** Mount at the app root once.
205
205
  - **Don't hardcode the API key.** Use env vars so rotations don't require a code change.
206
- - **Don't ship CDN URLs without a version pin.** `@pixelmatters/markup@1.18.4`, not `@pixelmatters/markup`.
207
- - **Don't add `https://*.convex.site` to `connect-src` and assume that's the whole CSP story.** The deployment domain (`<your-deployment>.convex.site`) is what the widget hits over HTTP, and it must be listed explicitly. Live thread updates go to `wss://<your-deployment>.convex.cloud` the widget derives that origin from `apiUrl` so `connect-src` needs both. `img-src` needs `blob:`, `data:`, and the host serving profile pictures; `style-src` needs `'unsafe-inline'` because the widget appends its stylesheet as a `<style>` element inside its shadow root. Add `https://esm.sh` to `script-src` only if you took the CDN path.
206
+ - **Don't ship CDN URLs without a version pin.** `@pixelmatters/markup@1.18.6`, not `@pixelmatters/markup`.
207
+ - **Don't add `https://*.convex.site` to `connect-src` and assume that's the whole CSP story.** The deployment domain (`<your-deployment>.convex.site`) is what the widget hits over HTTP, and it must be listed explicitly. Live thread updates go to `wss://<your-deployment>.convex.cloud`, an origin the widget derives from `apiUrl`, so `connect-src` needs both. `img-src` needs `blob:`, `data:`, and the host serving profile pictures; `style-src` needs `'unsafe-inline'` because the widget appends its stylesheet as a `<style>` element inside its shadow root. Add `https://esm.sh` to `script-src` only if you took the CDN path.
208
208
 
209
209
  ## Versioning & releases
210
210
 
211
- The widget follows semver. Pin to a major in `package.json` (`^1.5.0`) so patch + minor updates flow through `pnpm update`, but a future `2.0` cannot break the host silently. For CDN consumers, pin the exact version there's no lockfile to catch a surprise major.
211
+ The widget follows semver. Pin to a major in `package.json` (`^1.5.0`) so patch + minor updates flow through `pnpm update`, but a future `2.0` cannot break the host silently. For CDN consumers, pin the exact version, because there's no lockfile to catch a surprise major.
212
212
 
213
213
  ## Reference
214
214
 
@@ -218,4 +218,4 @@ The widget follows semver. Pin to a major in `package.json` (`^1.5.0`) so patch
218
218
 
219
219
  ## When in doubt
220
220
 
221
- The widget's `init({ apiUrl, apiKey })` is intentionally the entire API. If a question seems to need a richer surface (custom triggers, programmatic pin creation, headless mode), it's almost certainly something the widget doesn't do say so plainly rather than inventing options.
221
+ The widget's `init({ apiUrl, apiKey })` is intentionally the entire API. If a question seems to need a richer API (custom triggers, programmatic pin creation, headless mode), it's almost certainly something the widget doesn't do. Say so plainly rather than inventing options.