@pixelmatters/markup
v1.27.0
Published
Embeddable feedback widget for collecting visual bug reports, screenshots, and comments on live web apps.
Downloads
1,956
Maintainers
Readme
@pixelmatters/markup
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.
Highlights
- 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.
- Threads, in real time. Replies stream in via WebSocket. Per-comment edit, delete (with tombstones), and emoji reactions.
- @-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. - Annotated screenshots. Opt-in capture with the pin marker drawn on the image and embedded fonts, so the snapshot matches what the user saw.
- 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.
- 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.
- 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. - SPA-aware. Patches
history.pushState/replaceStateand followspopstateandhashchangeto refresh threads on route changes. Hash routers are supported, and a query string is left out of the route unless you opt it in withrouteParams. See Routing. - Respects the platform. Honours
prefers-reduced-motionandprefers-color-scheme, with full keyboard navigation and focus traps in popovers. - Tiny API, tiny config.
init({ apiUrl, apiKey })is enough to start. No global CSS to import, no provider to wrap.
Built with Preact. It ships as a single ESM bundle, so wiring it into any framework takes a one-liner in your root component. Snippets below.
Install
# pnpm
pnpm add @pixelmatters/markup
# or yarn
yarn add @pixelmatters/markup
# or npm
npm install @pixelmatters/markupCDN drop-in, no build step. Paste this just before </body>:
<script type="module">
// Pin the exact version; esm.sh resolves it from npm
import { init } from 'https://esm.sh/@pixelmatters/[email protected]'
// or
// import { init } from 'https://esm.run/@pixelmatters/[email protected]'
init({
apiUrl: 'https://your-deployment.convex.site',
apiKey: 'markup_...',
position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
theme: 'auto', // optional: 'auto' | 'light' | 'dark'
})
</script>Why pin the version? CDN URLs without a version (
@pixelmatters/markup) resolve to whatever'slateston npm, so a future major release will break your page with no warning. Always pin (@pixelmatters/[email protected]).
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:
<script
type="module"
src="https://esm.sh/@pixelmatters/[email protected]"
data-markup-widget="true"
data-api-url="https://your-deployment.convex.site"
data-api-key="markup_..."
data-position="bottom-right"
data-theme="auto"
></script>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().
Quickstart
Vanilla JS / TypeScript
import { init, destroy } from '@pixelmatters/markup'
const stop = init({
apiUrl: 'https://your-deployment.convex.site',
apiKey: 'markup_...',
position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
theme: 'light', // optional: 'auto' | 'light' | 'dark'
})
// Tear down on logout / route change / page unload:
stop() // equivalent to destroy()React
import { useEffect } from 'react'
import { init } from '@pixelmatters/markup'
export default function App() {
useEffect(() => {
return init({
apiUrl: import.meta.env.VITE_MARKUP_API_URL,
apiKey: import.meta.env.VITE_MARKUP_API_KEY,
position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
theme: 'auto', // optional: 'auto' | 'light' | 'dark'
})
}, [])
return <>{/* your app */}</>
}Vue 3
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'
import { init } from '@pixelmatters/markup'
let stop: (() => void) | undefined
onMounted(() => {
stop = init({
apiUrl: import.meta.env.VITE_MARKUP_API_URL,
apiKey: import.meta.env.VITE_MARKUP_API_KEY,
position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
theme: 'auto', // optional: 'auto' | 'light' | 'dark'
})
})
onBeforeUnmount(() => stop?.())
</script>SolidJS
import { onMount, onCleanup } from 'solid-js'
import { init } from '@pixelmatters/markup'
export default function App() {
onMount(() => {
const stop = init({
apiUrl: import.meta.env.VITE_MARKUP_API_URL,
apiKey: import.meta.env.VITE_MARKUP_API_KEY,
position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
theme: 'auto', // optional: 'auto' | 'light' | 'dark'
})
onCleanup(stop)
})
return <>{/* your app */}</>
}API
init(config) → destroy
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.
| Option | Type | Default | Description |
| -------------- | ---------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| apiUrl | string | required | Convex deployment site URL (https://*.convex.site) |
| apiKey | string | required | Project API key. Mint one in the dashboard |
| 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 |
| 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 |
| 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 |
| screenshots | ScreenshotsConfig | capture enabled | Capture and PII-scrub options. See Screenshots & privacy |
| 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 |
| 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 |
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:
init({
apiUrl: 'https://your-deployment.convex.site',
apiKey: 'markup_...',
- fab: 'icon-only',
})Product telemetry
The widget reports counts of its own interactions, such as a comment
started, a comment submitted, or a screenshot captured, so we can tell
which parts of it earn their place. Turn it off with analytics: false, or
data-analytics="false" on the script tag.
What it is not, concretely:
- No third-party script. Events post to your
apiUrlonconvex.site, the same origin the widget already talks to. Nothing new for your CSP, and no vendor SDK on your page. - No identifier for your users. Events are keyed on the Markup project, not the person. The session id is generated per page load, held in memory, and gone when the tab closes.
- No cookie, no
localStorage. Telemetry adds nothing to your page's storage. - No content and no IP. Comment text, page URLs, names, and email are never sent. Events reach our analytics provider from our backend, so your visitors' IP addresses never leave your site.
destroy()
Unmounts the widget and removes the host element. Safe to call when nothing is mounted.
The toolbar
The widget mounts a single compact pill in the corner set by position:
| Control | What it does |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Comment | Arms placement. The next click on the page drops a pin, and it flips to a cancel icon while armed. |
| Inbox | Mention notifications for this project, with an unread badge. Signed-in users only; anonymous visitors don't get the button. |
| Pins (eye) | Hides or shows every pin without hiding the toolbar. |
| 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. |
| 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. |
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.
Only this URL's pins is off by default. A route is host + pathname, so
views your app distinguishes only by a query param share one set of pins and
draw each other's. Turned on, the widget renders only the pins left on the URL
you are on, by query string, and the overflow button carries
a badge counting what is being held back — the pins are hidden from the page,
never dropped, and an open thread stays drawn whichever URL it belongs to.
Leave it off if your params are filters rather than views (?page=2&sort=name):
it would split one page's feedback across every combination a reader happens to
have on. A pin's tooltip names the URL it was left on either way.
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.
The last two menu rows open inside the menu itself, replacing the rows with a
reading panel and a Back button. Esc steps back to the rows, and again to
close.
- 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.
- 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.
Keyboard & mouse
| Shortcut | Action |
| --------------------- | -------------------------------------------------------------------------------- |
| c | Start placing a markup (ignored while typing) |
| @ | In a composer, open the member picker. ↑/↓ to move, enter or tab to pick |
| cmd/ctrl + enter | Post the comment being written |
| esc | Cancel placement, dismiss the mention picker, or close the open popover / menu |
| cmd/ctrl + . | Toggle HUD visibility |
| cmd/ctrl + click | Click the toolbar's comment button to hide the HUD with a hint toast |
| Drag a popover header | Move the open thread / new-thread popover; resets to the pin on reopen |
Screenshots & privacy
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.
Auto-scrubbed (zero config):
input[type="password"]- Any
<input>whoseautocompleteattribute containscc-number,cc-csc,cc-exp,cc-name,cc-type,current-password,new-password, orone-time-code
Attribute API. Add any of these to an element to control capture:
| Attribute | Behaviour |
| --------------------- | ---------------------------------------------------------------------- |
| data-markup-private | Always mask; contents are replaced with a solid block. |
| data-markup-redact | Alias for data-markup-private. |
| data-markup-safe | Exempts descendants from the default auto-detect rules (escape hatch). |
| data-markup-skip | Removes the element from the screenshot entirely. |
init() screenshots config:
| Option | Type | Default | Description |
| ---------------------------- | --------- | ------- | ---------------------------------------------------------- |
| screenshots.enabled | boolean | true | Set to false to disable capture entirely. |
| screenshots.strictScrub | boolean | false | Also masks all input, select, and textarea elements. |
| screenshots.redactSelector | string | none | Custom CSS selector; matched elements are always masked. |
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.
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.
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.
When a capture doesn't work out
Capture degrades instead of failing outright:
- 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.
- 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 yourconnect-src) leaves those icons blank and the rest captures as before. - 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.
- 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.
Routing
A thread is filed under a route, and pins are drawn for the route you are
standing on. A route is host + pathname, plus a hash-router path when the
fragment is one:
| URL | Route |
| ------------------------------------- | ------------------------- |
| https://app.acme.com/orders | app.acme.com/orders |
| https://app.acme.com/orders?page=2 | app.acme.com/orders |
| https://app.acme.com/#/orders | app.acme.com/#/orders |
| https://app.acme.com/#/orders?tab=2 | app.acme.com/#/orders |
| https://staging.acme.com/orders | staging.acme.com/orders |
The host is part of it, so staging and production never share pins. The query
string is not, in either position — ?page=2 is a filter over a page, not a
different page, and keying on it would split one conversation across every
combination a reader happens to have on.
When a query param is the page
Some apps route a view by param: a multi-step form at one path with
?step=shipping, ?step=payment and so on. Those are different pages that
happen to share a URL path, and by default they share one set of pins — every
step's feedback is drawn on whichever step you are reading, at positions that
mean nothing there.
List the params that name a view:
init({
apiUrl: '…',
apiKey: '…',
routeParams: ['step'],
})/checkout?step=shipping and /checkout?step=payment now have their own
routes and their own pins. Params you don't list are still ignored, so
?step=payment&page=2 and ?step=payment&page=3 remain one page.
Order doesn't matter — the key sorts them, so a router free to reorder its query can't split a view in two. A listed param the URL omits contributes nothing, so if a view is reachable both bare and with the param, those are two routes; make your router always write it.
routeParamsreads the real query string only. On a hash router the query lives inside the fragment (#/orders?tab=2), wherelocation.searchis empty — so listingtabthere does nothing. A hash-routed app that needs per-param views has to put the distinguishing part in the router path (#/orders/tab/2), which the key already carries. Tell us if that's you.
Changing
routeParamsre-keys new threads. Pins already stored under the old route stop appearing until an operator runsthreads/migration:rekeySearchRouteswith the same list. Threads store their full URL, so nothing is lost and the backfill can be re-run whenever the list changes — widening, narrowing and dropping the option are all recoverable — but there is a window each time. Coordinate the config change with the backfill.
Keep the listed params short. The route key is capped at 256 characters at the API edge, so a param carrying a serialized filter or a token can push a long path over it — and the failure is one-sided: pins still render, but posting a comment on those views is rejected.
Leaving routeParams empty is the right answer for most apps. Where views
share a route, each pin's tooltip names the URL it was left on, and the
overflow menu's Only this URL's pins narrows the page to one view without
changing how anything is stored.
How it works
- The widget mounts
<div id="markup-widget">ondocument.bodyand attaches an open shadow root. - All UI lives in that shadow root, with
:host { all: initial }blocking style inheritance. - 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. - Pins are anchored as
(x, y)fractions of the document plus a CSS path from the nearest landmark (a stableid,data-testid,main,form,table, …) and the element's text. The path wins when it still resolves, relaxing from the top if a wrapper changed, and a match whose text differs is rejected; the fraction is the fallback so pins survive layout changes. - Live thread updates come over a WebSocket to the deployment's
*.convex.cloudorigin, which the widget derives fromapiUrl. Everything else, meaning comments, identity, screenshots and error reports, goes to*.convex.siteover HTTP. - Identity lives in host-page
localStorageundermarkup.identity, keyed to the top-level site. On first load the widget mints a server-signed anonymous JWT viaPOST /widget/anon-identityso the backend can verify theauthorClientIdon every anon write. Tampering with the cachedclientIdinvalidates the signature. Verified identities upgrade to aBearerJWT via the popup flow described below.
Identity
Anonymous by default, with two opt-in upgrade paths:
- Sign in with Markup. The widget POSTs to
/widget/popup-exchangewith 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 popuppostMessages a verified identity plus a short-lived server-signedBearerJWT back to the host page, which attaches it asAuthorization: Beareron subsequent writes. Where partitioned (CHIPS) cookies are available the tokens are held inHttpOnlycookies and the session refreshes for up to 7 days; where they aren't, the widget falls back tomarkup.identityin the host page'slocalStorageand 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. - Continue as a guest. Name and optional email, stored alongside the server-issued anonymous
clientIdand JWT minted on first load.
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.
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.
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.
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.comtoconvex.sitecannot see the dashboard session. The popup is the only reliable way to bridge identity across sites without per-host configuration.
Getting an API key
- Sign in to the Markup dashboard.
- Open your project → Settings → API Keys.
- Click New key, label it, and copy the raw key. It is shown once.
- Open Settings → Domains and add the host domain (
app.example.com,*.staging.example.com). Production deployments do not auto-allowlocalhost; add it to the allowlist explicitly if you test against the deployment fromhttp://localhost.
AI prompt
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.
If @pixelmatters/markup is already a dependency, the skill is already on disk under node_modules, versioned with the release you installed. TanStack Intent wires it into whatever agent config you use (AGENTS.md, CLAUDE.md, .cursorrules, …):
npx @tanstack/intent@latest installRe-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.
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 CLI takes it by path:
npx skills add Pixelmatters/markup/packages/widget/skills/install-markup-widgetPass 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.
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.
You are helping me install the **`@pixelmatters/markup`** feedback widget into my web app.
## What it is
A drop-in feedback widget published on npm as `@pixelmatters/markup`. It mounts a compact toolbar that lets users pin threaded comments (and optional annotated screenshots) anywhere on the page. It runs inside a shadow DOM so it doesn't affect host CSS.
## My credentials
- `apiUrl`: `https://<MY_DEPLOYMENT>.convex.site` ← replace with the value from Markup dashboard → Settings → Install
- `apiKey`: `markup_...` ← replace with a key from Markup dashboard → Settings → API Keys
Store these in environment variables (e.g. `VITE_MARKUP_API_URL`, `VITE_MARKUP_API_KEY`, or the equivalent for my framework). Do not hardcode them.
## API
```ts
import { init, destroy } from '@pixelmatters/markup'
init({
apiUrl: string, // required
apiKey: string, // required
position?: 'bottom-right' | 'bottom-left' | 'bottom-center', // default 'bottom-right'
theme?: 'light' | 'dark' | 'auto', // default 'auto'
dashboardUrl?: string, // optional: adds an "Account →" link to the identity menu
screenshots?: {
enabled?: boolean, // default true
strictScrub?: boolean, // default false; also masks every input/select/textarea
redactSelector?: string, // extra CSS selector to mask
},
}) // returns a destroy() function; call it on unmount / logout / route teardown
```
There is no `fab` option any more. It's accepted, ignored, and warns once. Drop it if you find one in my config.
For React/Vue/Solid hosts, call `init()` from a mount lifecycle hook
(`useEffect`, `onMounted`, `onMount`) and call the returned `destroy` on
cleanup. There's no framework-specific entrypoint; `init` is the whole
public API.
For a `<script>` tag drop-in (no bundler), use the inline ESM form and **pin the version**:
```html
<script type="module">
import { init } from 'https://esm.sh/@pixelmatters/[email protected]'
init({
apiUrl: '...',
apiKey: '...',
position: 'bottom-right',
theme: 'auto',
})
</script>
```
If inline JS is disallowed (some CMS / page-builder editors), use the auto-init `<script src=…>` form with `data-*` attributes (`data-markup-widget="true"` is required):
```html
<script
type="module"
src="https://esm.sh/@pixelmatters/[email protected]"
data-markup-widget="true"
data-api-url="..."
data-api-key="..."
data-position="bottom-right"
></script>
```
## Your task
1. Detect my framework (React, Vue, Svelte, Next.js, plain HTML, etc.) by inspecting the project.
2. Install `@pixelmatters/markup` with the package manager already in use (pnpm/npm/yarn).
3. Wire the widget into the **root layout / app shell** so it shows on every page.
4. Read `apiUrl` and `apiKey` from environment variables; create `.env.example` entries and update `.gitignore` if needed.
5. For SPAs, ensure the widget is mounted once at the root (not per route) and unmounted via `destroy()` on logout.
6. Show me a diff of the changes and a one-line note on how to verify (e.g. "run dev server, click the button in the bottom-right").
Constraints:
- Do **not** add CSS imports or provider components. The widget needs neither.
- Do **not** hardcode the key.
- 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.Content Security Policy
If the host page ships a CSP, the widget needs:
| Directive | Value | Why |
| ------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| connect-src | https://<deployment>.convex.site | threads, comments, identity, screenshot upload, error reports |
| connect-src | wss://<deployment>.convex.cloud | live thread updates |
| connect-src | your own origin, if you serve icons from an SVG sprite | fetched so <use href="/sprite.svg#icon"> icons aren't blank in screenshots |
| img-src | wherever your team's profile pictures are hosted, plus blob: and data: | avatars, the screenshot preview thumbnail, and the capture pipeline |
| script-src | https://esm.sh | CDN path only; a bundled install needs nothing here |
| style-src | 'unsafe-inline' | the widget appends its stylesheet as a <style> element inside its own shadow root |
Only img-src and the sprite entry degrade gracefully: a blocked avatar falls back to initials, and a blocked image or sprite 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.
Browser support
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.
Self-hosting Markup
The widget talks to a Markup deployment; apiUrl is where yours lives. If you're running your own rather than using a hosted project, the backend, the dashboard and the deployment steps are in the project repository.
License
MIT © Pixelmatters
Issues and PRs welcome at Pixelmatters/markup.
