@pixelmatters/markup 1.31.6 → 1.32.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
@@ -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.31.6'
40
+ import { init } from 'https://esm.sh/@pixelmatters/markup@1.32.0'
41
41
  // or
42
- // import { init } from 'https://esm.run/@pixelmatters/markup@1.31.6'
42
+ // import { init } from 'https://esm.run/@pixelmatters/markup@1.32.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.31.6`).
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.32.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.31.6"
60
+ src="https://esm.sh/@pixelmatters/markup@1.32.0"
61
61
  data-markup-widget="true"
62
62
  data-api-url="https://your-deployment.convex.site"
63
63
  data-api-key="markup_..."
@@ -190,9 +190,9 @@ The floating action button was replaced by the toolbar pill in 1.15.0, and a pil
190
190
 
191
191
  ### Product telemetry
192
192
 
193
- The widget reports counts of its own interactions, such as a comment
194
- started, a comment submitted, or a screenshot captured, so we can tell
195
- which parts of it earn their place. Turn it off with `analytics: false`, or
193
+ The widget reports counts of its own interactions, such as a pin placed,
194
+ a comment submitted or abandoned, or a toolbar setting changed, so we can
195
+ tell which parts of it earn their place. Turn it off with `analytics: false`, or
196
196
  `data-analytics="false"` on the script tag.
197
197
 
198
198
  What it is not, concretely:
@@ -201,7 +201,8 @@ What it is not, concretely:
201
201
  `convex.site`, the same origin the widget already talks to. Nothing new
202
202
  for your CSP, and no vendor SDK on your page.
203
203
  - **No identifier for your users.** Events are keyed on the Markup
204
- project, not the person. The session id is generated per page load, held
204
+ project, not the person. They say whether the visitor was anonymous or
205
+ signed in, never who. The session id is generated per page load, held
205
206
  in memory, and gone when the tab closes.
206
207
  - **No cookie, no `localStorage`.** Telemetry adds nothing to your page's
207
208
  storage.
@@ -268,7 +269,7 @@ close.
268
269
 
269
270
  ## Screenshots & privacy
270
271
 
271
- 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.
272
+ 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. Each masked element becomes one solid black block covering its box, with nothing inside it showing: no text, images, icons or child elements, even where they overflow the box. Masking a `<canvas>`, `<video>` or `<iframe>` also blacks out any image beside it in the same parent. The mask is applied to the copy of the page the capture renders, so the page itself never shows it. No image content leaves the browser until the user explicitly attaches the screenshot and posts.
272
273
 
273
274
  **Auto-scrubbed (zero config):**
274
275
 
@@ -277,20 +278,22 @@ By default, the widget captures the visible viewport as a WebP image before you
277
278
 
278
279
  **Attribute API.** Add any of these to an element to control capture:
279
280
 
280
- | Attribute | Behaviour |
281
- | --------------------- | ---------------------------------------------------------------------- |
282
- | `data-markup-private` | Always mask; contents are replaced with a solid block. |
283
- | `data-markup-redact` | Alias for `data-markup-private`. |
284
- | `data-markup-safe` | Exempts descendants from the default auto-detect rules (escape hatch). |
285
- | `data-markup-skip` | Removes the element from the screenshot entirely. |
281
+ | Attribute | Behaviour |
282
+ | --------------------- | -------------------------------------------------------------------------------------------------------------------- |
283
+ | `data-markup-private` | Always mask: the element and everything inside it become a solid block. |
284
+ | `data-markup-redact` | Alias for `data-markup-private`. |
285
+ | `data-markup-safe` | Exempts descendants (not the element itself) from auto-detection, `redactSelector` and `strictScrub` (escape hatch). |
286
+ | `data-markup-skip` | Removes the element from the screenshot entirely. |
287
+
288
+ **Web components.** Masking reaches into open shadow roots, nested ones included, because the capture shows their content. A `data-markup-safe` on a component's host, or on any element around it, exempts the fields in its shadow root, as it does any other descendant. `redactSelector` is matched inside each shadow root on its own, so a selector can't span a shadow boundary: `.card-field` reaches into a component, `checkout-form .card-field` doesn't. A closed shadow root's own content never appears in the screenshot; light-DOM children slotted into it still do, and are masked like any other element.
286
289
 
287
290
  **`init()` `screenshots` config:**
288
291
 
289
- | Option | Type | Default | Description |
290
- | ---------------------------- | --------- | ------- | ---------------------------------------------------------- |
291
- | `screenshots.enabled` | `boolean` | `true` | Set to `false` to disable capture entirely. |
292
- | `screenshots.strictScrub` | `boolean` | `false` | Also masks all `input`, `select`, and `textarea` elements. |
293
- | `screenshots.redactSelector` | `string` | none | Custom CSS selector; matched elements are always masked. |
292
+ | Option | Type | Default | Description |
293
+ | ---------------------------- | --------- | ------- | -------------------------------------------------------------------------------------------- |
294
+ | `screenshots.enabled` | `boolean` | `true` | Set to `false` to disable capture entirely. |
295
+ | `screenshots.strictScrub` | `boolean` | `false` | Also masks all `input`, `select`, and `textarea` elements. |
296
+ | `screenshots.redactSelector` | `string` | none | Custom CSS selector; matched elements are masked unless inside a `data-markup-safe` element. |
294
297
 
295
298
  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.
296
299
 
@@ -303,8 +306,9 @@ The overflow menu's **Privacy & data** panel restates all of this for the person
303
306
  Capture degrades instead of failing outright:
304
307
 
305
308
  - **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.
306
- - **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.
309
+ - **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. One that hasn't answered within 3 seconds leaves them blank in that screenshot only; the next one asks again.
307
310
  - **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. A 640px-wide thumbnail of the same redacted image is uploaded alongside it for the inline preview in a thread, so the full screenshot downloads only when someone opens it.
311
+ - **A font or image that never answers**, behind a blackholing proxy or a stalled CDN, is given up on after 3 seconds and treated like one the browser refuses: an image comes through blank, a font falls back to the next one in its stack. The next screenshot asks for it again, and waits for it again. An image the browser refuses outright is not asked for again until the page reloads. A capture still unfinished after 30 seconds is abandoned as unavailable, rather than leaving the composer on _Capturing…_.
308
312
  - **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.
309
313
 
310
314
  ## Routing
@@ -497,7 +501,7 @@ For a `<script>` tag drop-in (no bundler), use the inline ESM form and **pin the
497
501
 
498
502
  ```html
499
503
  <script type="module">
500
- import { init } from 'https://esm.sh/@pixelmatters/markup@1.31.6'
504
+ import { init } from 'https://esm.sh/@pixelmatters/markup@1.32.0'
501
505
 
502
506
  init({
503
507
  apiUrl: '...',
@@ -513,7 +517,7 @@ If inline JS is disallowed (some CMS / page-builder editors), use the auto-init
513
517
  ```html
514
518
  <script
515
519
  type="module"
516
- src="https://esm.sh/@pixelmatters/markup@1.31.6"
520
+ src="https://esm.sh/@pixelmatters/markup@1.32.0"
517
521
  data-markup-widget="true"
518
522
  data-api-url="..."
519
523
  data-api-key="..."
package/dist/react.js CHANGED
@@ -1,2 +1,2 @@
1
- "use client";import{init as e,t}from"./widget.js";import{t as n}from"./shared-mount-B9ij4vuL.js";import{useEffect as r}from"react";function i(i){let{enabled:a=!0,...o}=i,s=a?t(o):null;r(()=>{if(s!==null)return n(s,e)},[s])}export{i as useMarkup};
1
+ "use client";import{init as e,n as t,t as n}from"./widget.js";import{t as r}from"./shared-mount-B9ij4vuL.js";import{useEffect as i}from"react";function a(a){let{enabled:o=!0,...s}=a,c=o?n(s):null;i(()=>{if(c!==null)return t(`react`),r(c,e)},[c])}export{a as useMarkup};
2
2
  //# sourceMappingURL=react.js.map
package/dist/react.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["'use client'\n\nimport { useEffect } from 'react'\n\nimport { configKey } from './runtime/config-key'\nimport { acquireMount } from './runtime/shared-mount'\nimport { init, type WidgetConfig } from './widget'\n\nexport type { WidgetConfig } from './widget'\n\nexport type UseMarkupOptions = WidgetConfig & {\n /**\n * `false` unmounts the widget, or never mounts it. Drive it from your build\n * environment to keep the widget off production.\n *\n * @default true\n */\n enabled?: boolean\n}\n\n/**\n * Mounts the widget for as long as the calling component is mounted. Call it\n * once near your app root.\n *\n * An inline options object is fine: the widget remounts only when a value\n * changes, not when the object is recreated. Two components using the same\n * options share one widget, and it stays until both unmount. Server rendering\n * mounts nothing.\n */\nexport function useMarkup(options: UseMarkupOptions): void {\n const { enabled = true, ...config } = options\n const key = enabled ? configKey(config) : null\n\n useEffect(() => {\n if (key === null) return\n return acquireMount(key, init)\n }, [key])\n}\n"],"mappings":"mIA6BA,SAAgB,EAAU,EAAiC,CACzD,GAAM,CAAE,UAAU,GAAM,GAAG,GAAW,EAChC,EAAM,EAAU,EAAU,CAAM,EAAI,KAE1C,MAAgB,CACV,OAAQ,KACZ,OAAO,EAAa,EAAK,CAAI,CAC/B,EAAG,CAAC,CAAG,CAAC,CACV"}
1
+ {"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["'use client'\n\nimport { useEffect } from 'react'\n\nimport { setInstallEntry } from './runtime/analytics'\nimport { configKey } from './runtime/config-key'\nimport { acquireMount } from './runtime/shared-mount'\nimport { init, type WidgetConfig } from './widget'\n\nexport type { WidgetConfig } from './widget'\n\nexport type UseMarkupOptions = WidgetConfig & {\n /**\n * `false` unmounts the widget, or never mounts it. Drive it from your build\n * environment to keep the widget off production.\n *\n * @default true\n */\n enabled?: boolean\n}\n\n/**\n * Mounts the widget for as long as the calling component is mounted. Call it\n * once near your app root.\n *\n * An inline options object is fine: the widget remounts only when a value\n * changes, not when the object is recreated. Two components using the same\n * options share one widget, and it stays until both unmount. Server rendering\n * mounts nothing.\n */\nexport function useMarkup(options: UseMarkupOptions): void {\n const { enabled = true, ...config } = options\n const key = enabled ? configKey(config) : null\n\n useEffect(() => {\n if (key === null) return\n setInstallEntry('react')\n return acquireMount(key, init)\n }, [key])\n}\n"],"mappings":"+IA8BA,SAAgB,EAAU,EAAiC,CACzD,GAAM,CAAE,UAAU,GAAM,GAAG,GAAW,EAChC,EAAM,EAAU,EAAU,CAAM,EAAI,KAE1C,MAAgB,CACV,OAAQ,KAEZ,OADA,EAAgB,OAAO,EAChB,EAAa,EAAK,CAAI,CAC/B,EAAG,CAAC,CAAG,CAAC,CACV"}
package/dist/vue.js CHANGED
@@ -1,2 +1,2 @@
1
- import{init as e,t}from"./widget.js";import{t as n}from"./shared-mount-B9ij4vuL.js";import{computed as r,onBeforeUnmount as i,onMounted as a,toValue as o,watch as s}from"vue";function c(c){let l=r(()=>{let{enabled:e=!0,...n}=o(c);return e?t(n):null}),u=null,d=t=>{u?.(),u=t===null?null:n(t,e)},f=null;a(()=>{d(l.value),f=s(l,d)}),i(()=>{f?.(),d(null)})}export{c as useMarkup};
1
+ import{init as e,n as t,t as n}from"./widget.js";import{t as r}from"./shared-mount-B9ij4vuL.js";import{computed as i,onBeforeUnmount as a,onMounted as o,toValue as s,watch as c}from"vue";function l(l){let u=i(()=>{let{enabled:e=!0,...t}=s(l);return e?n(t):null}),d=null,f=n=>{d?.(),n!==null&&t(`vue`),d=n===null?null:r(n,e)},p=null;o(()=>{f(u.value),p=c(u,f)}),a(()=>{p?.(),f(null)})}export{l as useMarkup};
2
2
  //# sourceMappingURL=vue.js.map
package/dist/vue.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"vue.js","names":[],"sources":["../src/vue.ts"],"sourcesContent":["import { computed, onBeforeUnmount, onMounted, toValue, watch, type MaybeRefOrGetter } from 'vue'\n\nimport { configKey } from './runtime/config-key'\nimport { acquireMount } from './runtime/shared-mount'\nimport { init, type WidgetConfig } from './widget'\n\nexport type { WidgetConfig } from './widget'\n\nexport type UseMarkupOptions = WidgetConfig & {\n /**\n * `false` unmounts the widget, or never mounts it. Drive it from your build\n * environment to keep the widget off production.\n *\n * @default true\n */\n enabled?: boolean\n}\n\n/**\n * Mounts the widget for as long as the calling component is mounted. Call it\n * once from your root component's `setup`.\n *\n * Pass a plain object, a ref, or a getter. The widget remounts only when a\n * value changes, not when the object is replaced by an equal one. Two\n * components using the same options share one widget, and it stays until\n * both unmount. Nothing mounts during server rendering, since mounting waits\n * for `onMounted`.\n */\nexport function useMarkup(options: MaybeRefOrGetter<UseMarkupOptions>): void {\n const key = computed(() => {\n const { enabled = true, ...config } = toValue(options)\n return enabled ? configKey(config) : null\n })\n\n let release: (() => void) | null = null\n const apply = (next: string | null) => {\n release?.()\n release = next === null ? null : acquireMount(next, init)\n }\n\n let stopWatching: (() => void) | null = null\n onMounted(() => {\n apply(key.value)\n stopWatching = watch(key, apply)\n })\n onBeforeUnmount(() => {\n stopWatching?.()\n apply(null)\n })\n}\n"],"mappings":"+KA4BA,SAAgB,EAAU,EAAmD,CAC3E,IAAM,EAAM,MAAe,CACzB,GAAM,CAAE,UAAU,GAAM,GAAG,GAAW,EAAQ,CAAO,EACrD,OAAO,EAAU,EAAU,CAAM,EAAI,IACvC,CAAC,EAEG,EAA+B,KAC7B,EAAS,GAAwB,CACrC,IAAU,EACV,EAAU,IAAS,KAAO,KAAO,EAAa,EAAM,CAAI,CAC1D,EAEI,EAAoC,KACxC,MAAgB,CACd,EAAM,EAAI,KAAK,EACf,EAAe,EAAM,EAAK,CAAK,CACjC,CAAC,EACD,MAAsB,CACpB,IAAe,EACf,EAAM,IAAI,CACZ,CAAC,CACH"}
1
+ {"version":3,"file":"vue.js","names":[],"sources":["../src/vue.ts"],"sourcesContent":["import { computed, onBeforeUnmount, onMounted, toValue, watch, type MaybeRefOrGetter } from 'vue'\n\nimport { setInstallEntry } from './runtime/analytics'\nimport { configKey } from './runtime/config-key'\nimport { acquireMount } from './runtime/shared-mount'\nimport { init, type WidgetConfig } from './widget'\n\nexport type { WidgetConfig } from './widget'\n\nexport type UseMarkupOptions = WidgetConfig & {\n /**\n * `false` unmounts the widget, or never mounts it. Drive it from your build\n * environment to keep the widget off production.\n *\n * @default true\n */\n enabled?: boolean\n}\n\n/**\n * Mounts the widget for as long as the calling component is mounted. Call it\n * once from your root component's `setup`.\n *\n * Pass a plain object, a ref, or a getter. The widget remounts only when a\n * value changes, not when the object is replaced by an equal one. Two\n * components using the same options share one widget, and it stays until\n * both unmount. Nothing mounts during server rendering, since mounting waits\n * for `onMounted`.\n */\nexport function useMarkup(options: MaybeRefOrGetter<UseMarkupOptions>): void {\n const key = computed(() => {\n const { enabled = true, ...config } = toValue(options)\n return enabled ? configKey(config) : null\n })\n\n let release: (() => void) | null = null\n const apply = (next: string | null) => {\n release?.()\n if (next !== null) setInstallEntry('vue')\n release = next === null ? null : acquireMount(next, init)\n }\n\n let stopWatching: (() => void) | null = null\n onMounted(() => {\n apply(key.value)\n stopWatching = watch(key, apply)\n })\n onBeforeUnmount(() => {\n stopWatching?.()\n apply(null)\n })\n}\n"],"mappings":"2LA6BA,SAAgB,EAAU,EAAmD,CAC3E,IAAM,EAAM,MAAe,CACzB,GAAM,CAAE,UAAU,GAAM,GAAG,GAAW,EAAQ,CAAO,EACrD,OAAO,EAAU,EAAU,CAAM,EAAI,IACvC,CAAC,EAEG,EAA+B,KAC7B,EAAS,GAAwB,CACrC,IAAU,EACN,IAAS,MAAM,EAAgB,KAAK,EACxC,EAAU,IAAS,KAAO,KAAO,EAAa,EAAM,CAAI,CAC1D,EAEI,EAAoC,KACxC,MAAgB,CACd,EAAM,EAAI,KAAK,EACf,EAAe,EAAM,EAAK,CAAK,CACjC,CAAC,EACD,MAAsB,CACpB,IAAe,EACf,EAAM,IAAI,CACZ,CAAC,CACH"}
package/dist/widget.d.ts CHANGED
@@ -20,7 +20,8 @@ export interface ScreenshotsConfig {
20
20
  */
21
21
  strictScrub?: boolean;
22
22
  /**
23
- * Custom CSS selector — matched elements are always masked.
23
+ * Custom CSS selector. Matched elements are masked unless a `data-markup-safe` element contains them.
24
+ * It is matched inside each open shadow root on its own, so it can't span a shadow boundary.
24
25
  *
25
26
  * @default undefined
26
27
  */