@pixelmatters/markup 1.18.4 → 1.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,22 +4,22 @@
4
4
  [![license](https://img.shields.io/npm/l/@pixelmatters/markup.svg)](./LICENSE)
5
5
  [![bundle](https://img.shields.io/bundlephobia/minzip/@pixelmatters/markup)](https://bundlephobia.com/package/@pixelmatters/markup)
6
6
 
7
- Pin-anchored feedback for live web apps. Drop in a script tag and your stakeholders can leave threaded comments, reactions, and annotated screenshots on top of any page without touching the host's CSS, build, or routing.
7
+ Pin-anchored feedback for live web apps. Drop in a script tag and your stakeholders can leave threaded comments, reactions, and annotated screenshots on top of any page, without touching the host's CSS, build, or routing.
8
8
 
9
9
  ## Highlights
10
10
 
11
- - **Pin anywhere** click anywhere on the page to attach a comment to that exact element. Pins re-anchor across reflow using a CSS selector + viewport-fraction fallback.
12
- - **Threads, in real time** replies stream in via WebSocket. Per-comment edit, delete (with tombstones), and emoji reactions.
13
- - **@-mentions** typing `@` in the composer opens a project-member picker and inserts a chip; the `@[Name](userId)` wire format never reaches the screen. Mentioned teammates are notified, and signed-in users read those notifications from the toolbar's inbox.
14
- - **Annotated screenshots** — opt-in capture with the pin marker drawn on the image and embedded fonts so the snapshot matches what the user saw.
15
- - **Drop-in identity** anonymous by default, with a popup-based sign-in that survives Safari ITP / Chrome storage partitioning. Signed-in authors show their profile picture; everyone else gets initials.
16
- - **Agent replies are labelled** a comment written by an AI agent through Markup's MCP server carries a bot badge. It's posted under a team member's name, so the badge is the only way a visitor can tell a machine answered.
17
- - **Style-isolated** runs inside an open shadow root with `:host { all: initial }`, so host CSS can't bleed in and widget CSS can't bleed out.
18
- - **SPA-aware** patches `history.pushState` / `replaceState` and follows `popstate` to refresh threads on route changes.
19
- - **Respects the platform** honours `prefers-reduced-motion` and `prefers-color-scheme`; full keyboard navigation with focus traps in popovers.
20
- - **Tiny surface, tiny config** `init({ apiUrl, apiKey })` is enough to start. No global CSS to import, no provider to wrap.
11
+ - **Pin anywhere.** Click anywhere on the page to attach a comment to that exact element. Pins re-anchor across reflow using a CSS selector plus a viewport-fraction fallback.
12
+ - **Threads, in real time.** Replies stream in via WebSocket. Per-comment edit, delete (with tombstones), and emoji reactions.
13
+ - **@-mentions.** Typing `@` in the composer opens a project-member picker and inserts a chip; the `@[Name](userId)` wire format never reaches the screen. Mentioned teammates are notified, and signed-in users read those notifications from the toolbar's inbox.
14
+ - **Annotated screenshots.** Opt-in capture with the pin marker drawn on the image and embedded fonts, so the snapshot matches what the user saw.
15
+ - **Drop-in identity.** Anonymous by default, with a popup-based sign-in that survives Safari ITP and Chrome storage partitioning. Signed-in authors show their profile picture; everyone else gets initials.
16
+ - **Agent replies are labelled.** A comment written by an AI agent through Markup's MCP server carries a bot badge. It's posted under a team member's name, so the badge is the only way a visitor can tell a machine answered.
17
+ - **Style-isolated.** Runs inside an open shadow root with `:host { all: initial }`, so host CSS can't bleed in and widget CSS can't bleed out.
18
+ - **SPA-aware.** Patches `history.pushState` / `replaceState` and follows `popstate` to refresh threads on route changes.
19
+ - **Respects the platform.** Honours `prefers-reduced-motion` and `prefers-color-scheme`, with full keyboard navigation and focus traps in popovers.
20
+ - **Tiny API, tiny config.** `init({ apiUrl, apiKey })` is enough to start. No global CSS to import, no provider to wrap.
21
21
 
22
- Built with [Preact](https://preactjs.com). Ships as a single ESM bundle wire it into any framework with a one-liner in your root component (snippets below).
22
+ Built with [Preact](https://preactjs.com). It ships as a single ESM bundle, so wiring it into any framework takes a one-liner in your root component. Snippets below.
23
23
 
24
24
  ## Install
25
25
 
@@ -32,14 +32,14 @@ yarn add @pixelmatters/markup
32
32
  npm install @pixelmatters/markup
33
33
  ```
34
34
 
35
- CDN drop-in (no build step) paste this just before `</body>`:
35
+ CDN drop-in, no build step. Paste this just before `</body>`:
36
36
 
37
37
  ```html
38
38
  <script type="module">
39
- // Pin the exact version esm.sh resolves it from npm
40
- import { init } from 'https://esm.sh/@pixelmatters/markup@1.18.4'
39
+ // Pin the exact version; esm.sh resolves it from npm
40
+ import { init } from 'https://esm.sh/@pixelmatters/markup@1.18.5'
41
41
  // or
42
- // import { init } from 'https://esm.run/@pixelmatters/markup@1.18.4'
42
+ // import { init } from 'https://esm.run/@pixelmatters/markup@1.18.5'
43
43
 
44
44
  init({
45
45
  apiUrl: 'https://your-deployment.convex.site',
@@ -50,14 +50,14 @@ CDN drop-in (no build step) — paste this just before `</body>`:
50
50
  </script>
51
51
  ```
52
52
 
53
- > **Why pin the version?** CDN URLs without a version (`@pixelmatters/markup`) resolve to whatever's `latest` on npm a future major release will break your page silently. Always pin (`@pixelmatters/markup@1.18.4`).
53
+ > **Why pin the version?** CDN URLs without a version (`@pixelmatters/markup`) resolve to whatever's `latest` on npm, so a future major release will break your page with no warning. Always pin (`@pixelmatters/markup@1.18.5`).
54
54
 
55
- If your platform doesn't allow inline JS (some CMS / page-builder editors), use the auto-init form instead point a `<script src=…>` at the bundle and pass config via `data-*` attributes:
55
+ If your platform doesn't allow inline JS (some CMS / page-builder editors), use the auto-init form instead. Point a `<script src=…>` at the bundle and pass config via `data-*` attributes:
56
56
 
57
57
  ```html
58
58
  <script
59
59
  type="module"
60
- src="https://esm.sh/@pixelmatters/markup@1.18.4"
60
+ src="https://esm.sh/@pixelmatters/markup@1.18.5"
61
61
  data-markup-widget="true"
62
62
  data-api-url="https://your-deployment.convex.site"
63
63
  data-api-key="markup_..."
@@ -66,7 +66,7 @@ If your platform doesn't allow inline JS (some CMS / page-builder editors), use
66
66
  ></script>
67
67
  ```
68
68
 
69
- `data-markup-widget="true"` is required it's how the bootstrap finds its own `<script>` tag (since `document.currentScript` is `null` for `type="module"`). The recognised attributes are `data-api-url`, `data-api-key`, `data-position`, `data-theme`, and `data-dashboard-url`; the `screenshots` options are only available through `init()`.
69
+ `data-markup-widget="true"` is required, since it's how the bootstrap finds its own `<script>` tag (`document.currentScript` is `null` for `type="module"`). The recognised attributes are `data-api-url`, `data-api-key`, `data-position`, `data-theme`, and `data-dashboard-url`; the `screenshots` options are only available through `init()`.
70
70
 
71
71
  ## Quickstart
72
72
 
@@ -153,20 +153,20 @@ export default function App() {
153
153
 
154
154
  Mounts the widget. Always tears down any existing instance before mounting, so calling `init` again (with the same or a different config) is safe. Returns the `destroy` function.
155
155
 
156
- | Option | Type | Default | Description |
157
- | -------------- | ---------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158
- | `apiUrl` | `string` | required | Convex deployment site URL (`https://*.convex.site`) |
159
- | `apiKey` | `string` | required | Project API key mint one in the dashboard |
160
- | `position` | `'bottom-right' \| 'bottom-left' \| 'bottom-center'` | `'bottom-right'` | Initial placement for the toolbar users can move it with the Position picker in the toolbar's overflow menu |
161
- | `theme` | `'light' \| 'dark' \| 'auto'` | `'auto'` | Initial theme `'light'` or `'dark'`, or `'auto'` (default) to follow the host's `prefers-color-scheme`. Users can change this from the overflow menu; their choice persists and wins over this option from then on |
162
- | `analytics` | `boolean` | `true` | Product telemetry counts of widget interactions, sent to Markup. Adds no third-party script, sets no cookie, writes nothing to storage, and carries no identifier for your users. See [Product telemetry](#product-telemetry) |
163
- | `screenshots` | `ScreenshotsConfig` | capture enabled | Capture and PII-scrub options see [Screenshots & privacy](#screenshots--privacy) |
164
- | `dashboardUrl` | `string` | | Dashboard URL the identity menu links to as **Account →** for signed-in users; it also retargets the overflow menu's "Powered by Markup" line. Omit it and the Account entry is hidden mostly useful for self-hosters, whose dashboard origin the widget can't know statically |
156
+ | Option | Type | Default | Description |
157
+ | -------------- | ---------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158
+ | `apiUrl` | `string` | required | Convex deployment site URL (`https://*.convex.site`) |
159
+ | `apiKey` | `string` | required | Project API key. Mint one in the dashboard |
160
+ | `position` | `'bottom-right' \| 'bottom-left' \| 'bottom-center'` | `'bottom-right'` | Initial placement for the toolbar. Users can move it with the Position picker in the toolbar's overflow menu |
161
+ | `theme` | `'light' \| 'dark' \| 'auto'` | `'auto'` | Initial theme. `'light'` or `'dark'`, or `'auto'` (default) to follow the host's `prefers-color-scheme`. Users can change this from the overflow menu; their choice persists and wins over this option from then on |
162
+ | `analytics` | `boolean` | `true` | Product telemetry: counts of widget interactions, sent to Markup. Adds no third-party script, sets no cookie, writes nothing to storage, and carries no identifier for your users. See [Product telemetry](#product-telemetry) |
163
+ | `screenshots` | `ScreenshotsConfig` | capture enabled | Capture and PII-scrub options. See [Screenshots & privacy](#screenshots--privacy) |
164
+ | `dashboardUrl` | `string` | none | Dashboard URL the identity menu links to as **Account →** for signed-in users; it also retargets the overflow menu's "Powered by Markup" line. Omit it and the Account entry is hidden. Mostly useful for self-hosters, whose dashboard origin the widget can't know statically |
165
165
 
166
166
  <details>
167
167
  <summary><code>fab</code> (deprecated, ignored since 1.15.0)</summary>
168
168
 
169
- The floating action button was replaced by the toolbar pill in 1.15.0, and a pill has no variants neither `'default'` nor `'icon-only'` describes anything the widget can render. The option and the `WidgetFab` type are still exported so upgrading needs no code change; `init()` drops the value and logs a one-time console warning. Both go away in the next major:
169
+ The floating action button was replaced by the toolbar pill in 1.15.0, and a pill has no variants: neither `'default'` nor `'icon-only'` describes anything the widget can render. The option and the `WidgetFab` type are still exported so upgrading needs no code change; `init()` drops the value and logs a one-time console warning. Both go away in the next major:
170
170
 
171
171
  ```diff
172
172
  init({
@@ -180,9 +180,9 @@ The floating action button was replaced by the toolbar pill in 1.15.0, and a pil
180
180
 
181
181
  ### Product telemetry
182
182
 
183
- The widget reports counts of its own interactions a comment started, a
184
- comment submitted, a screenshot captured so we can tell which parts of
185
- it earn their place. Turn it off with `analytics: false`, or
183
+ The widget reports counts of its own interactions, such as a comment
184
+ started, a comment submitted, or a screenshot captured, so we can tell
185
+ which parts of it earn their place. Turn it off with `analytics: false`, or
186
186
  `data-analytics="false"` on the script tag.
187
187
 
188
188
  What it is not, concretely:
@@ -209,45 +209,45 @@ The widget mounts a single compact pill in the corner set by `position`:
209
209
 
210
210
  | Control | What it does |
211
211
  | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
212
- | **Comment** | Arms placement the next click on the page drops a pin. Flips to a cancel icon while armed. |
212
+ | **Comment** | Arms placement. The next click on the page drops a pin, and it flips to a cancel icon while armed. |
213
213
  | **Inbox** | Mention notifications for this project, with an unread badge. Signed-in users only; anonymous visitors don't get the button. |
214
214
  | **Pins** (eye) | Hides or shows every pin without hiding the toolbar. |
215
215
  | **Identity** | Avatar button. Anonymous: a sign-in prompt plus "Forget me on this site". Signed in: name, email, an **Account →** link when `dashboardUrl` is set, and **Sign out**. |
216
216
  | **Overflow** (`☰`) | **Appearance** (Light / Dark / Auto), **Position** (left / center / right), an **Auto-capture screenshots** toggle, **Privacy & data**, **Keyboard shortcuts**, **Hide for this session**, and the widget version. |
217
217
 
218
- Appearance persists to the host page's `localStorage` (`markup:widget:theme`) and, once a user has set it, takes precedence over the `theme` option on every later `init()`. Position, pin visibility, and the auto-capture toggle are per-mount they reset on reload, and `position` seeds the toolbar again.
218
+ Appearance persists to the host page's `localStorage` (`markup:widget:theme`) and, once a user has set it, takes precedence over the `theme` option on every later `init()`. Position, pin visibility, and the auto-capture toggle are per-mount. They reset on reload, and `position` seeds the toolbar again.
219
219
 
220
220
  The last two menu rows open panels anchored to the same corner as the toolbar:
221
221
 
222
- - **Privacy & data** a plain-language account of what the widget holds, generated from the live session rather than from a policy document: who you're posting as, what a comment sends, what sits in this site's storage, whether captures are on, and the single host the widget talks to. Anonymous visitors also get **Forget me on this site** here, with the same arm-then-confirm as the identity menu.
223
- - **Keyboard shortcuts** the table below, in the modifier vocabulary of the reader's platform. The menu is the only way in on purpose: a key binding for it (`?` being the conventional one) would compete with whatever your app already binds, and the widget can't see your handlers to know.
222
+ - **Privacy & data.** A plain-language account of what the widget holds, generated from the live session rather than from a policy document: who you're posting as, what a comment sends, what sits in this site's storage, whether captures are on, and the single host the widget talks to. Anonymous visitors also get **Forget me on this site** here, with the same arm-then-confirm as the identity menu.
223
+ - **Keyboard shortcuts.** The table below, in the modifier vocabulary of the reader's platform. The menu is the only way in on purpose: a key binding for it (`?` being the conventional one) would compete with whatever your app already binds, and the widget can't see your handlers to know.
224
224
 
225
225
  ### Keyboard & mouse
226
226
 
227
- | Shortcut | Action |
228
- | --------------------- | --------------------------------------------------------------------------------- |
229
- | `c` | Start placing a markup (ignored while typing) |
230
- | `@` | In a composer, open the member picker `↑`/`↓` to move, `enter` or `tab` to pick |
231
- | `cmd/ctrl + enter` | Post the comment being written |
232
- | `esc` | Cancel placement, dismiss the mention picker, or close the open popover / menu |
233
- | `cmd/ctrl + .` | Toggle HUD visibility |
234
- | `cmd/ctrl + click` | Click the toolbar's comment button to hide the HUD with a hint toast |
235
- | Drag a popover header | Move the open thread / new-thread popover; resets to the pin on reopen |
227
+ | Shortcut | Action |
228
+ | --------------------- | -------------------------------------------------------------------------------- |
229
+ | `c` | Start placing a markup (ignored while typing) |
230
+ | `@` | In a composer, open the member picker. `↑`/`↓` to move, `enter` or `tab` to pick |
231
+ | `cmd/ctrl + enter` | Post the comment being written |
232
+ | `esc` | Cancel placement, dismiss the mention picker, or close the open popover / menu |
233
+ | `cmd/ctrl + .` | Toggle HUD visibility |
234
+ | `cmd/ctrl + click` | Click the toolbar's comment button to hide the HUD with a hint toast |
235
+ | Drag a popover header | Move the open thread / new-thread popover; resets to the pin on reopen |
236
236
 
237
237
  ## Screenshots & privacy
238
238
 
239
- By default, the widget captures the visible viewport as a JPEG before you submit a thread. Sensitive fields are blacked out **before** the image is produced the live DOM is mutated only for the duration of the capture and immediately restored. No image content leaves the browser until the user explicitly attaches the screenshot and posts.
239
+ By default, the widget captures the visible viewport as a JPEG before you submit a thread. Sensitive fields are blacked out **before** the image is produced. The live DOM is mutated only for the duration of the capture, then restored. No image content leaves the browser until the user explicitly attaches the screenshot and posts.
240
240
 
241
241
  **Auto-scrubbed (zero config):**
242
242
 
243
243
  - `input[type="password"]`
244
244
  - Any `<input>` whose `autocomplete` attribute contains `cc-number`, `cc-csc`, `cc-exp`, `cc-name`, `cc-type`, `current-password`, `new-password`, or `one-time-code`
245
245
 
246
- **Attribute API** add to any element to control capture:
246
+ **Attribute API.** Add any of these to an element to control capture:
247
247
 
248
248
  | Attribute | Behaviour |
249
249
  | --------------------- | ---------------------------------------------------------------------- |
250
- | `data-markup-private` | Always mask contents replaced with a solid block. |
250
+ | `data-markup-private` | Always mask; contents are replaced with a solid block. |
251
251
  | `data-markup-redact` | Alias for `data-markup-private`. |
252
252
  | `data-markup-safe` | Exempts descendants from the default auto-detect rules (escape hatch). |
253
253
  | `data-markup-skip` | Removes the element from the screenshot entirely. |
@@ -258,19 +258,19 @@ By default, the widget captures the visible viewport as a JPEG before you submit
258
258
  | ---------------------------- | --------- | ------- | ---------------------------------------------------------- |
259
259
  | `screenshots.enabled` | `boolean` | `true` | Set to `false` to disable capture entirely. |
260
260
  | `screenshots.strictScrub` | `boolean` | `false` | Also masks all `input`, `select`, and `textarea` elements. |
261
- | `screenshots.redactSelector` | `string` | | Custom CSS selector matched elements are always masked. |
261
+ | `screenshots.redactSelector` | `string` | none | Custom CSS selector; matched elements are always masked. |
262
262
 
263
263
  When a screenshot is attached in the composer, a chip shows how many fields were redacted. Clicking it expands the list of CSS selectors that were masked.
264
264
 
265
- Users can also switch capture off for themselves with **Auto-capture screenshots** in the toolbar's overflow menu. A host that set `screenshots.enabled: false` still wins the row renders disabled and says so, rather than offering a control that does nothing.
265
+ Users can also switch capture off for themselves with **Auto-capture screenshots** in the toolbar's overflow menu. A host that set `screenshots.enabled: false` still wins. The row renders disabled and says so, rather than offering a control that does nothing.
266
266
 
267
267
  The overflow menu's **Privacy & data** panel restates all of this for the person using the widget, and reflects whichever of the three capture states is live: captures on, switched off by the user, or disabled by the host.
268
268
 
269
269
  ### When a capture doesn't work out
270
270
 
271
- Capture degrades instead of failing silently:
271
+ Capture degrades instead of failing outright:
272
272
 
273
- - **An image the browser won't hand over** a third-party avatar served without CORS headers is the usual culprit — comes through blank, and the rest of the page still captures. It used to abort the whole screenshot.
273
+ - **An image the browser won't hand over** comes through blank, and the rest of the page still captures. A third-party avatar served without CORS headers is the usual culprit. It used to abort the whole screenshot.
274
274
  - **An oversized capture** is re-encoded until it fits the server's 2 MB cap: quality drops first (0.85 → 0.6), then the raster shrinks (full → ¾ → ½), because a smaller sharp screenshot beats a full-size illegible one.
275
275
  - **If nothing works**, the composer reads _Screenshot unavailable_ and the comment posts without one. Previously the row just disappeared, which looked identical to screenshots being switched off for the project.
276
276
 
@@ -280,19 +280,19 @@ Capture degrades instead of failing silently:
280
280
  - All UI lives in that shadow root, with `:host { all: initial }` blocking style inheritance.
281
281
  - The host element is `position: fixed; inset: 0; pointer-events: none`, so the widget paints over the entire viewport without blocking the host's clicks; only the toolbar and active popovers opt back in to pointer events.
282
282
  - Pins are anchored as `(x, y)` fractions of the document plus a best-effort CSS selector (via [`@medv/finder`](https://github.com/antonmedv/finder)). The selector wins when it still resolves; the fraction is the fallback so pins survive layout changes.
283
- - Live thread updates come over a WebSocket to the deployment's `*.convex.cloud` origin, which the widget derives from `apiUrl` everything else (comments, identity, screenshots, error reports) goes to `*.convex.site` over HTTP.
284
- - Identity lives in **host-page** `localStorage` under `markup.identity`, keyed to the top-level site. On first load the widget mints a server-signed anonymous JWT via `POST /widget/anon-identity` so the backend can verify the `authorClientId` on every anon write tampering with the cached `clientId` invalidates the signature. Verified identities upgrade to a `Bearer` JWT via the popup flow described below.
283
+ - Live thread updates come over a WebSocket to the deployment's `*.convex.cloud` origin, which the widget derives from `apiUrl`. Everything else, meaning comments, identity, screenshots and error reports, goes to `*.convex.site` over HTTP.
284
+ - Identity lives in **host-page** `localStorage` under `markup.identity`, keyed to the top-level site. On first load the widget mints a server-signed anonymous JWT via `POST /widget/anon-identity` so the backend can verify the `authorClientId` on every anon write. Tampering with the cached `clientId` invalidates the signature. Verified identities upgrade to a `Bearer` JWT via the popup flow described below.
285
285
 
286
286
  ## Identity
287
287
 
288
288
  Anonymous by default, with two opt-in upgrade paths:
289
289
 
290
- - **Sign in with Markup** the widget POSTs to `/widget/popup-exchange` with the apiKey + Origin gate, gets back a single-use 60-second code, then opens `${apiUrl}/widget/auth?code=…` in a popup. Because the popup is first-party to the deployment origin, the better-auth session cookie is sent normally (sidestepping third-party cookie blocks). The popup `postMessage`s a verified identity plus a short-lived server-signed `Bearer` JWT back to the host page, which attaches it as `Authorization: Bearer` on subsequent writes. Where partitioned (CHIPS) cookies are available the tokens are held in `HttpOnly` cookies and the session refreshes for up to 7 days; where they aren't, the widget falls back to `markup.identity` in the host page's `localStorage` and the session is capped at 24 hours, since a token stored there is readable by any script on the page. The apiKey never appears in the popup URL.
291
- - **Continue as a guest** name (and optional email), stored alongside the server-issued anonymous `clientId` + JWT minted on first load.
290
+ - **Sign in with Markup.** The widget POSTs to `/widget/popup-exchange` with the apiKey + Origin gate, gets back a single-use 60-second code, then opens `${apiUrl}/widget/auth?code=…` in a popup. Because the popup is first-party to the deployment origin, the better-auth session cookie is sent normally (sidestepping third-party cookie blocks). The popup `postMessage`s a verified identity plus a short-lived server-signed `Bearer` JWT back to the host page, which attaches it as `Authorization: Bearer` on subsequent writes. Where partitioned (CHIPS) cookies are available the tokens are held in `HttpOnly` cookies and the session refreshes for up to 7 days; where they aren't, the widget falls back to `markup.identity` in the host page's `localStorage` and the session is capped at 24 hours, since a token stored there is readable by any script on the page. The apiKey never appears in the popup URL.
291
+ - **Continue as a guest.** Name and optional email, stored alongside the server-issued anonymous `clientId` and JWT minted on first load.
292
292
 
293
293
  The popup origin is validated against the project's `allowedDomains` before any identity is returned, so only embeds on approved domains can resolve dashboard sessions. Verified JWTs can be invalidated before their TTL via the dashboard's "Sign out everywhere" action.
294
294
 
295
- Signed-in authors render their profile picture on their comments and on the toolbar's identity button. The URL is validated as `http(s)` both when it arrives and when it's read back out of `localStorage`, because that record lives on the customer's origin where any script could rewrite a value the widget hands to an `<img src>`. Anonymous visitors, accounts with no picture, and images the host's CSP blocks all fall back to initials.
295
+ Signed-in authors render their profile picture on their comments and on the toolbar's identity button. The URL is validated as `http(s)` both when it arrives and when it's read back out of `localStorage`, because that record lives on the customer's origin where any script could rewrite a value the widget hands to an `<img src>`. Anonymous visitors, accounts with no picture, and images the host's CSP blocks all fall back to initials.
296
296
 
297
297
  **Forget me on this site** (in the anonymous identity menu) clears the cached name, email, and client id from the host page's `localStorage`. Comments already posted stay where they are.
298
298
 
@@ -302,12 +302,12 @@ Signed-in authors render their profile picture — on their comments and on the
302
302
 
303
303
  1. Sign in to the Markup dashboard.
304
304
  2. Open your project → **Settings → API Keys**.
305
- 3. Click **New key**, label it, and copy the raw key it's shown once.
305
+ 3. Click **New key**, label it, and copy the raw key. It is shown once.
306
306
  4. Open **Settings → Domains** and add the host domain (`app.example.com`, `*.staging.example.com`). Production deployments do not auto-allow `localhost`; add it to the allowlist explicitly if you test against the deployment from `http://localhost`.
307
307
 
308
308
  ## AI prompt
309
309
 
310
- This package ships its own Agent Skill persistent install + troubleshooting guidance for your agent, instead of a prompt you paste once and lose. Install it rather than the prompt below where you can.
310
+ This package ships its own Agent Skill: persistent install and troubleshooting guidance for your agent, instead of a prompt you paste once and lose. Install it rather than the prompt below where you can.
311
311
 
312
312
  If `@pixelmatters/markup` is already a dependency, the skill is already on disk under `node_modules`, versioned with the release you installed. [TanStack Intent](https://tanstack.com/intent/latest) wires it into whatever agent config you use (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, …):
313
313
 
@@ -315,7 +315,7 @@ If `@pixelmatters/markup` is already a dependency, the skill is already on disk
315
315
  npx @tanstack/intent@latest install
316
316
  ```
317
317
 
318
- Re-run it when you add another Intent-enabled package. A `pnpm update` of this one is enough on its own the skill travels with the tarball, so it refreshes in place.
318
+ Re-run it when you add another Intent-enabled package. A `pnpm update` of this one is enough on its own, because the skill travels with the tarball and refreshes in place.
319
319
 
320
320
  If you'd rather pull it straight from the repo, or you're deciding whether to adopt the widget at all and haven't installed it yet, the [`skills`](https://www.npmjs.com/package/skills) CLI takes it by path:
321
321
 
@@ -323,7 +323,7 @@ If you'd rather pull it straight from the repo, or you're deciding whether to ad
323
323
  npx skills add Pixelmatters/markup/packages/widget/skills/install-markup-widget
324
324
  ```
325
325
 
326
- Pass the full path, not just the repository it's where this skill lives, and a bare repository reference resolves to a different set of skills.
326
+ Pass the full path, not just the repository. That is where this skill lives, and a bare repository reference resolves to a different set of skills.
327
327
 
328
328
  Otherwise, paste the block below into Claude, ChatGPT, Cursor, or any other LLM and it'll wire the widget into your codebase end-to-end.
329
329
 
@@ -353,24 +353,24 @@ init({
353
353
  dashboardUrl?: string, // optional: adds an "Account →" link to the identity menu
354
354
  screenshots?: {
355
355
  enabled?: boolean, // default true
356
- strictScrub?: boolean, // default false also masks every input/select/textarea
356
+ strictScrub?: boolean, // default false; also masks every input/select/textarea
357
357
  redactSelector?: string, // extra CSS selector to mask
358
358
  },
359
- }) // returns a destroy() function call it on unmount / logout / route teardown
359
+ }) // returns a destroy() function; call it on unmount / logout / route teardown
360
360
  ```
361
361
 
362
- There is no `fab` option any more — it's accepted, ignored, and warns once. Drop it if you find one in my config.
362
+ There is no `fab` option any more. It's accepted, ignored, and warns once. Drop it if you find one in my config.
363
363
 
364
364
  For React/Vue/Solid hosts, call `init()` from a mount lifecycle hook
365
365
  (`useEffect`, `onMounted`, `onMount`) and call the returned `destroy` on
366
- cleanup. There's no framework-specific entrypoint `init` is the entire
367
- public surface.
366
+ cleanup. There's no framework-specific entrypoint; `init` is the whole
367
+ public API.
368
368
 
369
369
  For a `<script>` tag drop-in (no bundler), use the inline ESM form and **pin the version**:
370
370
 
371
371
  ```html
372
372
  <script type="module">
373
- import { init } from 'https://esm.sh/@pixelmatters/markup@1.18.4'
373
+ import { init } from 'https://esm.sh/@pixelmatters/markup@1.18.5'
374
374
 
375
375
  init({
376
376
  apiUrl: '...',
@@ -386,7 +386,7 @@ If inline JS is disallowed (some CMS / page-builder editors), use the auto-init
386
386
  ```html
387
387
  <script
388
388
  type="module"
389
- src="https://esm.sh/@pixelmatters/markup@1.18.4"
389
+ src="https://esm.sh/@pixelmatters/markup@1.18.5"
390
390
  data-markup-widget="true"
391
391
  data-api-url="..."
392
392
  data-api-key="..."
@@ -405,7 +405,7 @@ If inline JS is disallowed (some CMS / page-builder editors), use the auto-init
405
405
 
406
406
  Constraints:
407
407
 
408
- - Do **not** add CSS imports or provider components the widget needs neither.
408
+ - Do **not** add CSS imports or provider components. The widget needs neither.
409
409
  - Do **not** hardcode the key.
410
410
  - If the project has a CSP: add `https://<MY_DEPLOYMENT>.convex.site` and `wss://<MY_DEPLOYMENT>.convex.cloud` to `connect-src`, `blob:` and `data:` (plus the host of our profile pictures) to `img-src`, and `'unsafe-inline'` to `style-src`. Add `https://esm.sh` to `script-src` only if I'm using the `<script>` tag path.
411
411
  ````
@@ -419,14 +419,14 @@ If the host page ships a CSP, the widget needs:
419
419
  | `connect-src` | `https://<deployment>.convex.site` | threads, comments, identity, screenshot upload, error reports |
420
420
  | `connect-src` | `wss://<deployment>.convex.cloud` | live thread updates |
421
421
  | `img-src` | wherever your team's profile pictures are hosted, plus `blob:` and `data:` | avatars, the screenshot preview thumbnail, and the capture pipeline |
422
- | `script-src` | `https://esm.sh` | CDN path only a bundled install needs nothing here |
422
+ | `script-src` | `https://esm.sh` | CDN path only; a bundled install needs nothing here |
423
423
  | `style-src` | `'unsafe-inline'` | the widget appends its stylesheet as a `<style>` element inside its own shadow root |
424
424
 
425
425
  Only `img-src` degrades gracefully: a blocked avatar falls back to initials, and a blocked image in a capture comes through blank. A missing `connect-src` entry stops the widget working at all, and a missing `style-src` one leaves it unstyled.
426
426
 
427
427
  ## Browser support
428
428
 
429
- Modern evergreen browsers (Chrome, Edge, Firefox, Safari) and their mobile equivalents. The widget uses native ESM, shadow DOM, and `IntersectionObserver` no IE11 / legacy bundle.
429
+ Modern evergreen browsers (Chrome, Edge, Firefox, Safari) and their mobile equivalents. The widget uses native ESM, shadow DOM, and `IntersectionObserver`, with no IE11 or legacy bundle.
430
430
 
431
431
  ## Self-hosting Markup
432
432
 
package/dist/widget.js CHANGED
@@ -3036,7 +3036,7 @@ var Si = class extends Error {
3036
3036
  }, Ci = class extends Si {
3037
3037
  category;
3038
3038
  constructor(e, t) {
3039
- super(`${e} took too long check your connection`, 0), this.name = "WidgetTimeoutError", this.category = t;
3039
+ super(`${e} took too long, so check your connection`, 0), this.name = "WidgetTimeoutError", this.category = t;
3040
3040
  }
3041
3041
  }, wi = 15e3, J = 3e4;
3042
3042
  function Ti(e, t) {
@@ -3486,7 +3486,7 @@ async function Qi(e) {
3486
3486
  return e?.name !== "TypeError" && I(e, { context: "popup-exchange" }), Promise.reject(e instanceof Error ? e : /* @__PURE__ */ Error("Failed to start popup exchange"));
3487
3487
  }
3488
3488
  let c = Math.max(0, window.screenX + (window.outerWidth - 420) / 2), l = Math.max(0, window.screenY + (window.outerHeight - 560) / 2), u = window.open(s, "markup-auth", `width=420,height=560,left=${c},top=${l}`);
3489
- if (!u) return Promise.reject(/* @__PURE__ */ Error("Popup blocked allow popups for this site to sign in"));
3489
+ if (!u) return Promise.reject(/* @__PURE__ */ Error("Popup blocked. Allow popups for this site to sign in"));
3490
3490
  let d = await new Promise((e, t) => {
3491
3491
  let n = 0, r = !1;
3492
3492
  function a() {
@@ -3496,7 +3496,7 @@ async function Qi(e) {
3496
3496
  if (!r) {
3497
3497
  if (e) {
3498
3498
  if (n >= Zi) {
3499
- a(), t(/* @__PURE__ */ Error("Sign-in could not be restarted close the window and try again"));
3499
+ a(), t(/* @__PURE__ */ Error("Sign-in could not be restarted. Close the window and try again"));
3500
3500
  return;
3501
3501
  }
3502
3502
  n += 1;
@@ -4644,7 +4644,7 @@ function fs({ identity: e, position: t, isSigningIn: n, isSigningOut: r, dashboa
4644
4644
  children: /* @__PURE__ */ Q(cs, { size: 16 })
4645
4645
  }), /* @__PURE__ */ Q("span", {
4646
4646
  class: "lbl",
4647
- children: r ? "Signing out…" : f ? "Confirm sign out of this site" : "Sign out"
4647
+ children: r ? "Signing out…" : f ? "Confirm: sign out of this site" : "Sign out"
4648
4648
  })]
4649
4649
  })
4650
4650
  ] }) : /* @__PURE__ */ Q("div", {
@@ -4675,7 +4675,7 @@ function fs({ identity: e, position: t, isSigningIn: n, isSigningOut: r, dashboa
4675
4675
  style: { marginTop: "2px" },
4676
4676
  children: /* @__PURE__ */ Q("span", {
4677
4677
  class: "lbl",
4678
- children: u ? "Confirm comments stay, name/email cleared" : "Forget me on this site…"
4678
+ children: u ? "Confirm: comments stay, name and email cleared" : "Forget me on this site…"
4679
4679
  })
4680
4680
  })
4681
4681
  ]
@@ -5363,7 +5363,7 @@ function Ks({ pageX: e, pageY: t, authorName: n, isVerified: r, isIdentityResolv
5363
5363
  ]
5364
5364
  }) : u ? /* @__PURE__ */ Q("span", {
5365
5365
  class: "markup-screenshot-status",
5366
- title: "This page has something the capture can't read often a third-party image. Your comment will post without a screenshot.",
5366
+ title: "This page has something the capture can't read, often a third-party image. Your comment will post without a screenshot.",
5367
5367
  children: [/* @__PURE__ */ Q(Jo, { size: 12 }), /* @__PURE__ */ Q("span", { children: "Screenshot unavailable" })]
5368
5368
  }) : /* @__PURE__ */ Q("label", {
5369
5369
  class: "markup-screenshot-toggle",
@@ -5633,7 +5633,7 @@ function Zs({ position: e, onSetPosition: t, autoCapture: n, autoCaptureLocked:
5633
5633
  role: "radio",
5634
5634
  "aria-checked": p.mode === "auto",
5635
5635
  "aria-label": `Auto (currently ${p.resolved})`,
5636
- title: `Auto currently ${p.resolved}`,
5636
+ title: `Auto, currently ${p.resolved}`,
5637
5637
  class: Z(p.mode === "auto" && "is-on"),
5638
5638
  onClick: () => p.set("auto"),
5639
5639
  children: /* @__PURE__ */ Q(is, { size: 14 })
@@ -6016,7 +6016,7 @@ function ac({ identity: e, apiUrl: t, autoCapture: n, autoCaptureLocked: r, isFo
6016
6016
  }),
6017
6017
  /* @__PURE__ */ Q("ul", {
6018
6018
  class: "markup-panel-list",
6019
- children: [/* @__PURE__ */ Q("li", { children: d ? "Your sign-in token in this site's storage, or a cookie only this site can send" : "Your commenter id and the token that proves it, in this site's storage" }), /* @__PURE__ */ Q("li", { children: "Your appearance and toolbar-position picks" })]
6019
+ children: [/* @__PURE__ */ Q("li", { children: d ? "Your sign-in token, held in this site's storage or in a cookie only this site can send" : "Your commenter id and the token that proves it, in this site's storage" }), /* @__PURE__ */ Q("li", { children: "Your appearance and toolbar-position picks" })]
6020
6020
  }),
6021
6021
  /* @__PURE__ */ Q("p", {
6022
6022
  class: "markup-panel-note",
@@ -6047,7 +6047,7 @@ function ac({ identity: e, apiUrl: t, autoCapture: n, autoCaptureLocked: r, isFo
6047
6047
  class: "markup-panel-mono",
6048
6048
  children: oc(t)
6049
6049
  }),
6050
- ". No analytics, no ad tech, no third-party requests from the widget. If the widget itself crashes it posts the error, this page's URL and your user-agent to that same host never your identity or what you were writing."
6050
+ ". No analytics, no ad tech, no third-party requests from the widget. If the widget itself crashes it posts the error, this page's URL and your user-agent to that same host, never your identity or what you were writing."
6051
6051
  ]
6052
6052
  })]
6053
6053
  }),
@@ -6069,7 +6069,7 @@ function ac({ identity: e, apiUrl: t, autoCapture: n, autoCaptureLocked: r, isFo
6069
6069
  onClick: () => {
6070
6070
  c ? (o(), l(!1)) : l(!0);
6071
6071
  },
6072
- children: i ? "Erasing…" : c ? "Confirm erase and forget me" : "Forget me on this site…"
6072
+ children: i ? "Erasing…" : c ? "Confirm: erase and forget me" : "Forget me on this site…"
6073
6073
  }),
6074
6074
  a ? /* @__PURE__ */ Q("p", {
6075
6075
  class: "markup-panel-error",
@@ -7183,7 +7183,7 @@ function Dc({ apiUrl: e, apiKey: t, position: n = "bottom-right", screenshots: r
7183
7183
  signal: AbortSignal.timeout(6e4)
7184
7184
  });
7185
7185
  } catch (e) {
7186
- throw e instanceof DOMException && e.name === "TimeoutError" ? Error("Screenshot upload took too long check your connection") : e;
7186
+ throw e instanceof DOMException && e.name === "TimeoutError" ? Error("Screenshot upload took too long, so check your connection") : e;
7187
7187
  }
7188
7188
  if (!t.ok) throw Error(`Screenshot upload failed (${t.status})`);
7189
7189
  let { storageId: n } = await t.json();
@@ -7499,7 +7499,7 @@ function Dc({ apiUrl: e, apiKey: t, position: n = "bottom-right", screenshots: r
7499
7499
  onOpenPrivacy: () => C("privacy"),
7500
7500
  onOpenShortcuts: () => C("shortcuts"),
7501
7501
  onHideForSession: () => Ce({ type: "hide-with-toast" }),
7502
- version: "1.18.4",
7502
+ version: "1.18.5",
7503
7503
  dashboardUrl: i
7504
7504
  })
7505
7505
  }) : null,
@@ -7596,11 +7596,11 @@ function jc(e, t) {
7596
7596
  a.className = "markup-shadow-root", r.appendChild(a), je({
7597
7597
  apiUrl: t.apiUrl,
7598
7598
  apiKey: t.apiKey,
7599
- widgetVersion: "1.18.4"
7599
+ widgetVersion: "1.18.5"
7600
7600
  }), be(t.analytics === !1 ? null : {
7601
7601
  apiUrl: t.apiUrl,
7602
7602
  apiKey: t.apiKey,
7603
- widgetVersion: "1.18.4"
7603
+ widgetVersion: "1.18.5"
7604
7604
  }), F("widget_loaded");
7605
7605
  let { theme: o, analytics: s, ...c } = t;
7606
7606
  return de(/* @__PURE__ */ Q(Ce, {
@@ -7614,8 +7614,8 @@ function jc(e, t) {
7614
7614
  //#region src/widget.ts
7615
7615
  var Mc = null, Nc = null, Pc = null, Fc = !1;
7616
7616
  function Ic(e) {
7617
- if (!e.apiUrl || !e.apiKey) return console.warn("[markup] init() requires both apiUrl and apiKey widget not mounted"), () => {};
7618
- e.fab != null && !Fc && (Fc = !0, console.warn("[markup] the `fab` option is ignored and will be removed in the next major the toolbar replaced the floating action button. Remove it from your config.")), Lc();
7617
+ if (!e.apiUrl || !e.apiKey) return console.warn("[markup] init() requires both apiUrl and apiKey, so the widget was not mounted"), () => {};
7618
+ e.fab != null && !Fc && (Fc = !0, console.warn("[markup] the `fab` option is ignored and will be removed in the next major. The toolbar replaced the floating action button. Remove it from your config.")), Lc();
7619
7619
  let t = () => {
7620
7620
  if (Pc = null, !document.body) return;
7621
7621
  let t = document.createElement("div");