@pixelmatters/markup 1.25.2 → 1.27.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
@@ -15,7 +15,7 @@ Pin-anchored feedback for live web apps. Drop in a script tag and your stakehold
15
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. A project can be set to **members only** in the dashboard, in which case visitors are asked to sign in before commenting; everyone still sees the pins.
16
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
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` and `hashchange` to refresh threads on route changes. Hash routers are supported: a `#/orders` path is part of the route a thread is filed under, while a plain `#section` anchor is not, and neither is a query string in either position — `#/orders?tab=2` and `#/orders` are one page, as `/orders?tab=2` and `/orders` already were.
18
+ - **SPA-aware.** Patches `history.pushState` / `replaceState` and follows `popstate` and `hashchange` to refresh threads on route changes. Hash routers are supported, and a query string is left out of the route unless you opt it in with `routeParams`. See [Routing](#routing).
19
19
  - **Respects the platform.** Honours `prefers-reduced-motion` and `prefers-color-scheme`, with full keyboard navigation and focus traps in popovers.
20
20
  - **Tiny API, tiny config.** `init({ apiUrl, apiKey })` is enough to start. No global CSS to import, no provider to wrap.
21
21
 
@@ -37,9 +37,9 @@ CDN drop-in, no build step. Paste this just before `</body>`:
37
37
  ```html
38
38
  <script type="module">
39
39
  // Pin the exact version; esm.sh resolves it from npm
40
- import { init } from 'https://esm.sh/@pixelmatters/markup@1.25.2'
40
+ import { init } from 'https://esm.sh/@pixelmatters/markup@1.27.0'
41
41
  // or
42
- // import { init } from 'https://esm.run/@pixelmatters/markup@1.25.2'
42
+ // import { init } from 'https://esm.run/@pixelmatters/markup@1.27.0'
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, so a future major release will break your page with no warning. Always pin (`@pixelmatters/markup@1.25.2`).
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.27.0`).
54
54
 
55
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.25.2"
60
+ src="https://esm.sh/@pixelmatters/markup@1.27.0"
61
61
  data-markup-widget="true"
62
62
  data-api-url="https://your-deployment.convex.site"
63
63
  data-api-key="markup_..."
@@ -162,6 +162,7 @@ Mounts the widget. Always tears down any existing instance before mounting, so c
162
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
163
  | `screenshots` | `ScreenshotsConfig` | capture enabled | Capture and PII-scrub options. See [Screenshots & privacy](#screenshots--privacy) |
164
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
+ | `routeParams` | `string[]` | `[]` | Query params that name a _view_ rather than filter one, and so belong in the route a thread is filed under. See [Routing](#routing) |
165
166
 
166
167
  <details>
167
168
  <summary><code>fab</code> (deprecated, ignored since 1.15.0)</summary>
@@ -207,15 +208,26 @@ Unmounts the widget and removes the host element. Safe to call when nothing is m
207
208
 
208
209
  The widget mounts a single compact pill in the corner set by `position`:
209
210
 
210
- | Control | What it does |
211
- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
212
- | **Comment** | Arms placement. The next click on the page drops a pin, and it flips to a cancel icon while armed. |
213
- | **Inbox** | Mention notifications for this project, with an unread badge. Signed-in users only; anonymous visitors don't get the button. |
214
- | **Pins** (eye) | Hides or shows every pin without hiding the toolbar. |
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
- | **Overflow** (`☰`) | **Appearance** (Light / Dark / Auto), **Position** (left / center / right), an **Auto-capture screenshots** toggle, a **Show resolved threads** toggle, **Privacy & data**, **Keyboard shortcuts**, **Hide for this session**, and the widget version. |
211
+ | Control | What it does |
212
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
213
+ | **Comment** | Arms placement. The next click on the page drops a pin, and it flips to a cancel icon while armed. |
214
+ | **Inbox** | Mention notifications for this project, with an unread badge. Signed-in users only; anonymous visitors don't get the button. |
215
+ | **Pins** (eye) | Hides or shows every pin without hiding the toolbar. |
216
+ | **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**. |
217
+ | **Overflow** (`☰`) | **Appearance** (Light / Dark / Auto), **Position** (left / center / right), an **Auto-capture screenshots** toggle, an **Only this URL's pins** toggle, a **Show resolved threads** toggle, **Privacy & data**, **Keyboard shortcuts**, **Hide for this session**, and the widget version. |
217
218
 
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, the auto-capture toggle and the resolved-threads toggle are per-mount. They reset on reload, and `position` seeds the toolbar again.
219
+ 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, the auto-capture toggle, the URL-scope toggle and the resolved-threads toggle are per-mount. They reset on reload, and `position` seeds the toolbar again.
220
+
221
+ **Only this URL's pins** is off by default. A route is `host + pathname`, so
222
+ views your app distinguishes only by a query param share one set of pins and
223
+ draw each other's. Turned on, the widget renders only the pins left on the URL
224
+ you are on, by query string, and the overflow button carries
225
+ a badge counting what is being held back — the pins are hidden from the page,
226
+ never dropped, and an open thread stays drawn whichever URL it belongs to.
227
+
228
+ Leave it off if your params are filters rather than views (`?page=2&sort=name`):
229
+ it would split one page's feedback across every combination a reader happens to
230
+ have on. A pin's tooltip names the URL it was left on either way.
219
231
 
220
232
  **Show resolved threads** is off by default. Turned on, the widget also renders the route's most recently resolved threads (up to 100) as muted check-mark pins. Opening one shows the thread read-only: no replies, edits, reactions or resolve button. Reopening still happens from the dashboard.
221
233
 
@@ -240,7 +252,7 @@ close.
240
252
 
241
253
  ## Screenshots & privacy
242
254
 
243
- 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.
255
+ By default, the widget captures the visible viewport as a WebP image before you submit a thread (JPEG on browsers that can't encode WebP). 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.
244
256
 
245
257
  **Auto-scrubbed (zero config):**
246
258
 
@@ -276,9 +288,79 @@ Capture degrades instead of failing outright:
276
288
 
277
289
  - **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.
278
290
  - **Icons from an SVG sprite** are fetched and inlined before the capture. The capture renders your page as an SVG document, which is not allowed to load anything external, so a `<use href="/sprite.svg#icon">` would otherwise draw nothing — on a design system that ships its icons that way, every icon in the screenshot went missing. A sprite the widget can't read (cross-origin without CORS headers, or outside your `connect-src`) leaves those icons blank and the rest captures as before.
279
- - **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.
291
+ - **Captures are sized for storage, not for zooming.** The raster is capped at 1.5x device pixel ratio, so a 2x or 3x display doesn't bank detail nobody looks at in a lightbox. The image is then encoded down a ladder — quality drops first (0.85 → 0.6), then the raster shrinks (full → ¾ → ½) — until it lands under roughly 400 KB. A page that can't get there at any rung keeps the sharpest version that still fits the server's 2 MB hard cap, because a smaller sharp screenshot beats a full-size illegible one but not by any margin.
280
292
  - **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.
281
293
 
294
+ ## Routing
295
+
296
+ A thread is filed under a **route**, and pins are drawn for the route you are
297
+ standing on. A route is `host + pathname`, plus a hash-router path when the
298
+ fragment is one:
299
+
300
+ | URL | Route |
301
+ | ------------------------------------- | ------------------------- |
302
+ | `https://app.acme.com/orders` | `app.acme.com/orders` |
303
+ | `https://app.acme.com/orders?page=2` | `app.acme.com/orders` |
304
+ | `https://app.acme.com/#/orders` | `app.acme.com/#/orders` |
305
+ | `https://app.acme.com/#/orders?tab=2` | `app.acme.com/#/orders` |
306
+ | `https://staging.acme.com/orders` | `staging.acme.com/orders` |
307
+
308
+ The host is part of it, so staging and production never share pins. The query
309
+ string is not, in either position — `?page=2` is a filter over a page, not a
310
+ different page, and keying on it would split one conversation across every
311
+ combination a reader happens to have on.
312
+
313
+ ### When a query param _is_ the page
314
+
315
+ Some apps route a view by param: a multi-step form at one path with
316
+ `?step=shipping`, `?step=payment` and so on. Those are different pages that
317
+ happen to share a URL path, and by default they share one set of pins — every
318
+ step's feedback is drawn on whichever step you are reading, at positions that
319
+ mean nothing there.
320
+
321
+ List the params that name a view:
322
+
323
+ ```js
324
+ init({
325
+ apiUrl: '…',
326
+ apiKey: '…',
327
+ routeParams: ['step'],
328
+ })
329
+ ```
330
+
331
+ `/checkout?step=shipping` and `/checkout?step=payment` now have their own
332
+ routes and their own pins. Params you don't list are still ignored, so
333
+ `?step=payment&page=2` and `?step=payment&page=3` remain one page.
334
+
335
+ Order doesn't matter — the key sorts them, so a router free to reorder its
336
+ query can't split a view in two. A listed param the URL omits contributes
337
+ nothing, so if a view is reachable both bare and with the param, those are two
338
+ routes; make your router always write it.
339
+
340
+ > **`routeParams` reads the real query string only.** On a hash router the
341
+ > query lives inside the fragment (`#/orders?tab=2`), where `location.search`
342
+ > is empty — so listing `tab` there does nothing. A hash-routed app that needs
343
+ > per-param views has to put the distinguishing part in the router _path_
344
+ > (`#/orders/tab/2`), which the key already carries. Tell us if that's you.
345
+
346
+ > **Changing `routeParams` re-keys new threads.** Pins already stored under the
347
+ > old route stop appearing until an operator runs
348
+ > `threads/migration:rekeySearchRoutes` with the same list. Threads store their
349
+ > full URL, so nothing is lost and the backfill can be re-run whenever the list
350
+ > changes — widening, narrowing and dropping the option are all recoverable —
351
+ > but there is a window each time. Coordinate the config change with the
352
+ > backfill.
353
+
354
+ Keep the listed params short. The route key is capped at 256 characters at the
355
+ API edge, so a param carrying a serialized filter or a token can push a long
356
+ path over it — and the failure is one-sided: pins still render, but posting a
357
+ comment on those views is rejected.
358
+
359
+ Leaving `routeParams` empty is the right answer for most apps. Where views
360
+ share a route, each pin's tooltip names the URL it was left on, and the
361
+ overflow menu's **Only this URL's pins** narrows the page to one view without
362
+ changing how anything is stored.
363
+
282
364
  ## How it works
283
365
 
284
366
  - The widget mounts `<div id="markup-widget">` on `document.body` and attaches an open shadow root.
@@ -375,7 +457,7 @@ For a `<script>` tag drop-in (no bundler), use the inline ESM form and **pin the
375
457
 
376
458
  ```html
377
459
  <script type="module">
378
- import { init } from 'https://esm.sh/@pixelmatters/markup@1.25.2'
460
+ import { init } from 'https://esm.sh/@pixelmatters/markup@1.27.0'
379
461
 
380
462
  init({
381
463
  apiUrl: '...',
@@ -391,7 +473,7 @@ If inline JS is disallowed (some CMS / page-builder editors), use the auto-init
391
473
  ```html
392
474
  <script
393
475
  type="module"
394
- src="https://esm.sh/@pixelmatters/markup@1.25.2"
476
+ src="https://esm.sh/@pixelmatters/markup@1.27.0"
395
477
  data-markup-widget="true"
396
478
  data-api-url="..."
397
479
  data-api-key="..."
package/dist/widget.d.ts CHANGED
@@ -76,6 +76,45 @@ export interface WidgetConfig {
76
76
  * @default 'https://markup.pixelmatters.dev'
77
77
  */
78
78
  dashboardUrl?: string;
79
+ /**
80
+ * Query params that name a *view* rather than filter one, and so belong in
81
+ * the route a thread is filed under.
82
+ *
83
+ * A route is `host + pathname` (plus a hash-router path). Views your app
84
+ * distinguishes only by a param — `/checkout?step=shipping` and
85
+ * `/checkout?step=payment` — therefore share one route and draw each
86
+ * other's pins. Listing `'step'` gives each its own.
87
+ *
88
+ * Empty by default, and left that way for most apps: a param that filters
89
+ * a list (`?page=2&sort=name`) is not a different page, and keying on it
90
+ * splits one conversation across every combination a reader happens to
91
+ * have on. List only the params that change *what the page is*.
92
+ *
93
+ * Order doesn't matter — the key sorts them, so a router free to reorder
94
+ * its query can't split a view in two. A listed param the URL omits
95
+ * contributes nothing, so if a view is reachable both bare and with the
96
+ * param, those are two routes.
97
+ *
98
+ * Reads `location.search` only. A hash router's query lives inside the
99
+ * fragment (`#/orders?tab=2`), where there is no query string, so this
100
+ * option cannot reach it — such an app needs the distinguishing part in
101
+ * the router path, which the route key already carries.
102
+ *
103
+ * **Changing this re-keys new threads.** Pins already stored under the old
104
+ * route stop appearing until an operator runs the matching backfill
105
+ * (`threads/migration:rekeySearchRoutes`) with the same list. The backfill
106
+ * can be re-run whenever the list changes, so widening, narrowing and
107
+ * dropping the option are all recoverable — but there is a window each
108
+ * time, so coordinate the two.
109
+ *
110
+ * Keep the listed params short. The route key is capped at 256 characters
111
+ * at the API edge, and a param carrying a serialized filter or a token can
112
+ * push a long path past it; comments on those views are then rejected
113
+ * while the pins still render.
114
+ *
115
+ * @default []
116
+ */
117
+ routeParams?: string[];
79
118
  /**
80
119
  * Product telemetry — counts of widget interactions, sent to Markup.
81
120
  *