@pixelmatters/markup 1.16.0 → 1.17.0

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
@@ -10,8 +10,10 @@ Pin-anchored feedback for live web apps. Drop in a script tag and your stakehold
10
10
 
11
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
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.
13
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.
14
- - **Drop-in identity** — anonymous by default, with a popup-based sign-in that survives Safari ITP / Chrome storage partitioning. Verified users get a team badge.
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.
15
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.
16
18
  - **SPA-aware** — patches `history.pushState` / `replaceState` and follows `popstate` to refresh threads on route changes.
17
19
  - **Respects the platform** — honours `prefers-reduced-motion` and `prefers-color-scheme`; full keyboard navigation with focus traps in popovers.
@@ -35,9 +37,9 @@ CDN drop-in (no build step) — paste this just before `</body>`:
35
37
  ```html
36
38
  <script type="module">
37
39
  // Pin the exact version — esm.sh resolves it from npm
38
- import { init } from 'https://esm.sh/@pixelmatters/markup@1.16.0'
40
+ import { init } from 'https://esm.sh/@pixelmatters/markup@1.17.0'
39
41
  // or
40
- // import { init } from 'https://esm.run/@pixelmatters/markup@1.16.0'
42
+ // import { init } from 'https://esm.run/@pixelmatters/markup@1.17.0'
41
43
 
42
44
  init({
43
45
  apiUrl: 'https://your-deployment.convex.site',
@@ -48,14 +50,14 @@ CDN drop-in (no build step) — paste this just before `</body>`:
48
50
  </script>
49
51
  ```
50
52
 
51
- > **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.16.0`).
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.17.0`).
52
54
 
53
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:
54
56
 
55
57
  ```html
56
58
  <script
57
59
  type="module"
58
- src="https://esm.sh/@pixelmatters/markup@1.16.0"
60
+ src="https://esm.sh/@pixelmatters/markup@1.17.0"
59
61
  data-markup-widget="true"
60
62
  data-api-url="https://your-deployment.convex.site"
61
63
  data-api-key="markup_..."
@@ -64,7 +66,7 @@ If your platform doesn't allow inline JS (some CMS / page-builder editors), use
64
66
  ></script>
65
67
  ```
66
68
 
67
- `data-markup-widget="true"` is required — it's how the bootstrap finds its own `<script>` tag (since `document.currentScript` is `null` for `type="module"`).
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()`.
68
70
 
69
71
  ## Quickstart
70
72
 
@@ -151,26 +153,64 @@ export default function App() {
151
153
 
152
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.
153
155
 
154
- | Option | Type | Default | Description |
155
- | ---------- | ---------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
156
- | `apiUrl` | `string` | required | Convex deployment site URL (`https://*.convex.site`) |
157
- | `apiKey` | `string` | required | Project API key — mint one in the dashboard |
158
- | `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 |
159
- | `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 |
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
+ | `screenshots` | `ScreenshotsConfig` | capture enabled | Capture and PII-scrub options — see [Screenshots & privacy](#screenshots--privacy) |
163
+ | `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 |
164
+
165
+ <details>
166
+ <summary><code>fab</code> (deprecated, ignored since 1.15.0)</summary>
167
+
168
+ 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
+
170
+ ```diff
171
+ init({
172
+ apiUrl: 'https://your-deployment.convex.site',
173
+ apiKey: 'markup_...',
174
+ - fab: 'icon-only',
175
+ })
176
+ ```
177
+
178
+ </details>
160
179
 
161
180
  ### `destroy()`
162
181
 
163
182
  Unmounts the widget and removes the host element. Safe to call when nothing is mounted.
164
183
 
184
+ ## The toolbar
185
+
186
+ The widget mounts a single compact pill in the corner set by `position`:
187
+
188
+ | Control | What it does |
189
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
190
+ | **Comment** | Arms placement — the next click on the page drops a pin. Flips to a cancel icon while armed. |
191
+ | **Inbox** | Mention notifications for this project, with an unread badge. Signed-in users only; anonymous visitors don't get the button. |
192
+ | **Pins** (eye) | Hides or shows every pin without hiding the toolbar. |
193
+ | **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**. |
194
+ | **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. |
195
+
196
+ 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.
197
+
198
+ The last two menu rows open panels anchored to the same corner as the toolbar:
199
+
200
+ - **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.
201
+ - **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.
202
+
165
203
  ### Keyboard & mouse
166
204
 
167
- | Shortcut | Action |
168
- | --------------------- | ---------------------------------------------------------------------- |
169
- | `c` | Start placing a markup (ignored while typing) |
170
- | `esc` | Cancel placement, or close the open popover |
171
- | `cmd/ctrl + .` | Toggle HUD visibility |
172
- | `cmd/ctrl + click` | Click the toolbar's comment button to hide the HUD with a hint toast |
173
- | Drag a popover header | Move the open thread / new-thread popover; resets to the pin on reopen |
205
+ | Shortcut | Action |
206
+ | --------------------- | --------------------------------------------------------------------------------- |
207
+ | `c` | Start placing a markup (ignored while typing) |
208
+ | `@` | In a composer, open the member picker — `↑`/`↓` to move, `enter` or `tab` to pick |
209
+ | `cmd/ctrl + enter` | Post the comment being written |
210
+ | `esc` | Cancel placement, dismiss the mention picker, or close the open popover / menu |
211
+ | `cmd/ctrl + .` | Toggle HUD visibility |
212
+ | `cmd/ctrl + click` | Click the toolbar's comment button to hide the HUD with a hint toast |
213
+ | Drag a popover header | Move the open thread / new-thread popover; resets to the pin on reopen |
174
214
 
175
215
  ## Screenshots & privacy
176
216
 
@@ -200,12 +240,25 @@ By default, the widget captures the visible viewport as a JPEG before you submit
200
240
 
201
241
  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.
202
242
 
243
+ 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.
244
+
245
+ 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.
246
+
247
+ ### When a capture doesn't work out
248
+
249
+ Capture degrades instead of failing silently:
250
+
251
+ - **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.
252
+ - **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.
253
+ - **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.
254
+
203
255
  ## How it works
204
256
 
205
257
  - The widget mounts `<div id="markup-widget">` on `document.body` and attaches an open shadow root.
206
258
  - All UI lives in that shadow root, with `:host { all: initial }` blocking style inheritance.
207
259
  - 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.
208
260
  - 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.
261
+ - 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.
209
262
  - 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.
210
263
 
211
264
  ## Identity
@@ -217,6 +270,10 @@ Anonymous by default, with two opt-in upgrade paths:
217
270
 
218
271
  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.
219
272
 
273
+ 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.
274
+
275
+ **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.
276
+
220
277
  > **Why a popup, not auto-detect?** Safari ITP, Chrome Storage Partitioning, and Firefox TCP all partition third-party storage and cookies by top-level site. A cross-origin fetch from `customer.com` to `convex.site` cannot see the dashboard session. The popup is the only reliable way to bridge identity across sites without per-host configuration.
221
278
 
222
279
  ## Getting an API key
@@ -259,9 +316,17 @@ init({
259
316
  apiKey: string, // required
260
317
  position?: 'bottom-right' | 'bottom-left' | 'bottom-center', // default 'bottom-right'
261
318
  theme?: 'light' | 'dark' | 'auto', // default 'auto'
319
+ dashboardUrl?: string, // optional: adds an "Account →" link to the identity menu
320
+ screenshots?: {
321
+ enabled?: boolean, // default true
322
+ strictScrub?: boolean, // default false — also masks every input/select/textarea
323
+ redactSelector?: string, // extra CSS selector to mask
324
+ },
262
325
  }) // returns a destroy() function — call it on unmount / logout / route teardown
263
326
  ```
264
327
 
328
+ There is no `fab` option any more — it's accepted, ignored, and warns once. Drop it if you find one in my config.
329
+
265
330
  For React/Vue/Solid hosts, call `init()` from a mount lifecycle hook
266
331
  (`useEffect`, `onMounted`, `onMount`) and call the returned `destroy` on
267
332
  cleanup. There's no framework-specific entrypoint — `init` is the entire
@@ -271,7 +336,7 @@ For a `<script>` tag drop-in (no bundler), use the inline ESM form and **pin the
271
336
 
272
337
  ```html
273
338
  <script type="module">
274
- import { init } from 'https://esm.sh/@pixelmatters/markup@1.16.0'
339
+ import { init } from 'https://esm.sh/@pixelmatters/markup@1.17.0'
275
340
 
276
341
  init({
277
342
  apiUrl: '...',
@@ -287,7 +352,7 @@ If inline JS is disallowed (some CMS / page-builder editors), use the auto-init
287
352
  ```html
288
353
  <script
289
354
  type="module"
290
- src="https://esm.sh/@pixelmatters/markup@1.16.0"
355
+ src="https://esm.sh/@pixelmatters/markup@1.17.0"
291
356
  data-markup-widget="true"
292
357
  data-api-url="..."
293
358
  data-api-key="..."
@@ -308,9 +373,23 @@ Constraints:
308
373
 
309
374
  - Do **not** add CSS imports or provider components — the widget needs neither.
310
375
  - Do **not** hardcode the key.
311
- - If the project has a CSP, add `https://esm.sh` to `script-src` only if I'm using the `<script>` tag path.
376
+ - 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.
312
377
  ````
313
378
 
379
+ ## Content Security Policy
380
+
381
+ If the host page ships a CSP, the widget needs:
382
+
383
+ | Directive | Value | Why |
384
+ | ------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
385
+ | `connect-src` | `https://<deployment>.convex.site` | threads, comments, identity, screenshot upload, error reports |
386
+ | `connect-src` | `wss://<deployment>.convex.cloud` | live thread updates |
387
+ | `img-src` | wherever your team's profile pictures are hosted, plus `blob:` and `data:` | avatars, the screenshot preview thumbnail, and the capture pipeline |
388
+ | `script-src` | `https://esm.sh` | CDN path only — a bundled install needs nothing here |
389
+ | `style-src` | `'unsafe-inline'` | the widget appends its stylesheet as a `<style>` element inside its own shadow root |
390
+
391
+ 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.
392
+
314
393
  ## Browser support
315
394
 
316
395
  Modern evergreen browsers (Chrome, Edge, Firefox, Safari) and their mobile equivalents. The widget uses native ESM, shadow DOM, and `IntersectionObserver` — no IE11 / legacy bundle.