@pixelmatters/markup
v1.42.0
Published
Embeddable feedback widget for collecting visual bug reports, screenshots, and comments on live web apps.
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. A thread can be deleted by its author, or by any project member, and only project members can resolve one. Deleting a thread deletes its screenshot for good; no backup can restore it.
- @-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. Screenshots are shown only to signed-in project members, whichever way the project is set.
- Agent replies are labeled. 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 !important }, so host CSS can't bleed in or restyle the widget, 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. Honors
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 recognized attributes are data-api-url, data-api-key, data-position, data-theme, data-dashboard-url, data-analytics, data-shortcuts and data-route-params (comma-separated); the screenshots options are only available through init().
Quickstart
Vanilla JS / TypeScript
import { init } from '@pixelmatters/markup'
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'
})
// init() returns a function that removes this instance again, e.g. on logout.
// The exported destroy() removes whichever instance is mounted.React
React 18 or later. Render the component once near your app root:
'use client' // Next.js App Router only
import { useMarkup } from '@pixelmatters/markup/react'
export function Markup() {
useMarkup({
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'
enabled: true, // optional: false unmounts it, e.g. from your build environment
})
return null
}The hook takes the same options as init(), plus enabled. An inline object
is fine: the widget remounts only when a value changes. StrictMode's double
mount leaves one widget, two components with the same options share it until
both unmount, and server rendering mounts nothing. Don't also call init() on
the same page.
On the Next.js App Router, keep 'use client' and read
process.env.NEXT_PUBLIC_MARKUP_API_URL and
process.env.NEXT_PUBLIC_MARKUP_API_KEY instead: Next doesn't define
import.meta.env.
Vue 3
Vue 3.3 or later. Call it once from your root component:
<script setup lang="ts">
import { useMarkup } from '@pixelmatters/markup/vue'
useMarkup({
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'
})
</script>The composable takes the same options as init(), plus enabled, as a plain
object, a ref or a getter (() => ({ …, theme: theme.value })), and remounts
the widget only when a value changes. Two components with the same options
share one widget until both unmount, and nothing mounts during server
rendering.
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 and returns a function that tears down that instance. Once a later init has replaced it, the returned function does nothing, so a stale cleanup can't remove its successor. Calling init again with an equal config is a no-op, so the live instance keeps its connection and any unsent draft. A different config tears the existing instance down before mounting. If your page removes the widget's host element, by re-rendering a <body> it owns or swapping <body> on navigation, the widget puts the same element back, with any open thread and draft intact. It stops after destroy(), and gives up on a page that removes it again every time it returns; in that case the next init mounts it again.
| 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 |
| shortcuts | boolean | true | false turns off the bare-key shortcuts (c), for a page that binds those keys itself. See Keyboard & mouse |
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 pin placed,
a comment submitted or abandoned, or a toolbar setting changed, 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. They say whether the visitor was anonymous or signed in, never who. 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.
Hiding the widget or stacking it lower
Ordinary CSS can't reach the widget's host element (see How it works), so #markup-widget { display: none } or a z-index of yours has no effect. Set one of these custom properties instead, on #markup-widget or on any ancestor such as :root. Both inherit, so they work inside a media query or under a class you toggle:
/* Hide the widget on small screens. */
@media (max-width: 640px) {
:root {
--markup-display: none;
}
}
/* Keep the widget beneath your cookie banner. */
#markup-widget {
--markup-z-index: 999;
}--markup-display takes none to hide the widget, and defaults to block. --markup-z-index takes an integer, and defaults to 2147483647. The hidden attribute on #markup-widget also hides it. The widget never prints.
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. Grayed out with a red dot while the widget can't reach Markup or the browser is offline; the console says why. A gray dot means live updates are paused, and commenting still works. Screen readers hear both, and hear when the connection is back. |
| 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.
A thread's own ⋯ menu has Copy link, and for signed-in users Copy for agent: the thread as a Markdown prompt for a coding agent, with the page, element, declared source path and every comment. Comment text is quoted line by line and every other value is a quoted string, so what a visitor typed can't pass itself off as part of the instructions.
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, or while the widget can't reach Markup) |
| enter / space | While placing, drop the pin on the focused element (tab to it first) |
| @ | In a composer, open the member picker. ↑/↓ to move, enter or tab to pick |
| cmd/ctrl + enter | Post the comment being written |
| enter | Send a reply. shift + enter adds a new line, as enter does on touch keyboards |
| 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 |
The widget can't see your page's own key handlers, so a bare c reaches both: your handler runs, and the widget starts a markup. If your app binds c (or any single letter the widget may claim later), pass shortcuts: false (data-shortcuts="false" on the script tag) to turn the bare-key shortcuts off. The toolbar button, the chords and the keys that act only inside an open popover or menu keep working.
Inside the Pixelmatters Toolbar
If the page already runs @pixelmatters/toolbar, register Markup as one of its plugins instead of calling init(). The toolbar then draws the bar, and Markup keeps its pins and comment popovers on the page:
import { markupPlugin } from '@pixelmatters/markup/toolbar'
import { createToolbar } from '@pixelmatters/toolbar'
const toolbar = createToolbar({ theme: 'auto' })
toolbar.register(markupPlugin({ apiUrl: 'https://…convex.site', apiKey: 'markup_…' }))markupPlugin() takes the init() options except position, theme and shortcuts, because the toolbar owns its corner, its theme and its keys. It gives the toolbar two actions, each a button on the bar once it opens and an entry under its Search (⌥K):
| Action | What it does |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Leave a comment | A toolbar mode on c. The next click on the page drops a pin, or tab to an element and press enter; esc, the button again or another tool's mode cancels it. It doesn't start over a comment being written. While Markup can't be reached it's grayed, its name says why, and c is left to the page. |
| Markup | Markup's panel, with the count of unread mentions on its button (a dot on the folded bar). It opens on the Inbox, under a Show pins switch that hides every pin while it's off (also Hide pins or Show pins under Search): mention notifications for this project, with Mark all read. A row marks itself read and opens its thread, or goes to the page it's on, and signed-out visitors get a Sign in button there instead. Settings, from the header, holds who you're commenting as, Sign in, or, signed in, your name and email with Account and Sign out, then the overflow menu's Auto-capture screenshots, Only this URL's pins and Show resolved threads toggles, per mount as there. Privacy & data is one view further, the same account of the session as the overflow menu's, with Forget me on this site for anonymous visitors. What signing out or forgetting did shows as the toolbar's toast. |
The toolbar's own Hide for this session (cmd/ctrl + .) hides the pins too, and Markup binds neither c nor cmd/ctrl + . itself. A comment being written stays open when the toolbar hides, as it does when the widget's own pill hides. The toolbar keeps the theme (Appearance, under its Search), and Markup's popovers follow it. Use the plugin or init(), not both: whichever starts second refuses, with a console warning from init() and a rejected register() from the plugin. @pixelmatters/toolbar 2 is an optional peer dependency, needed only for this entry.
On @pixelmatters/toolbar 2.3 or later, a comment's screenshot goes through the toolbar's capture hooks, so another plugin can mask or leave out what it mustn't show (a privacy guard's fields, say) before anything is drawn, and other plugins can take screenshots with Markup's engine through ctx.capture. Outlines other toolbar plugins draw on the page (data-pxt-overlay) are left out of screenshots either way.
The toolbar can't turn its single-letter keys off either, so on a page that binds c itself, use init() with shortcuts: false instead.
The toolbar injects a <style> element, so a page whose CSP has no 'unsafe-inline' in style-src shows the toolbar unstyled. The widget is styled as it is without the toolbar (see Content Security Policy).
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 mask is drawn into the snapshot, never onto your page, so nothing on the page changes, and masking holds even under a style-src without 'unsafe-inline'. Each masked element comes out as one solid black block covering its box, whatever your stylesheets say about it: its text, placeholder, images, canvases, SVG, icons, backgrounds and child elements are all left out, and its images and backgrounds aren't fetched or read. Sprite files and webfonts still are, since the rest of the page shares them and they hold nothing of the field. No image content leaves the browser until the user explicitly attaches the screenshot and posts.
Who sees a screenshot. Project members only. A visitor who isn't signed in as a member of the project, the person who attached it included, sees the thread and its comments without the screenshot, on a members-only project and an open one alike. A member who signs in sees screenshots straight away. A screenshot link handed out before this rule took effect keeps working until the screenshot is deleted, since a link carries no expiry of its own.
The capture works in slices of a few milliseconds and leaves out what is outside the viewport. Your page's own timers, messages and frames run between slices, so a large page stays responsive while it runs. One step can't be split: the browser laying out the finished snapshot, which takes longer the more is on screen. Because the page keeps running, each part of it is captured as it is when the capture reaches it, so content your page moves or changes mid-capture appears where your page put it. A list or table your page renders again while the capture is partway through it is read again, so it comes out whole rather than half old and half new.
Two things don't follow your scroll position: an element that scrolls on its own is captured from its top (this includes an app shell that scrolls a root <div> rather than the window), and a position: sticky element is captured where it sits before it sticks.
Auto-scrubbed (zero config):
- Every
<textarea>, and every editable region (contenteditableother than"false") - Inputs of type
password,emailandtel, plus a password field that a show-password toggle flipped totextafter someone typed into it - Any field whose
autocompletecontainscc-,password,one-time-code,emailortel - Inputs whose
nameoridreads like a card, CVC, IBAN, SSN, PIN, one-time code, password, secret or token field, and any input holding a Luhn-valid card number
A plain text input, such as a search box, stays visible. Exempt a field the heuristics catch by wrapping it in data-markup-safe.
Attribute API. Add any of these to an element to control capture:
| Attribute | Behavior |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| data-markup-private | Always mask: the element and everything inside it become a solid block. |
| data-markup-redact | Alias for data-markup-private. |
| data-markup-safe | Exempts descendants (not the element itself) from auto-detection, redactSelector and strictScrub (escape hatch). |
| data-markup-skip | Removes the element from the screenshot entirely. |
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.
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 masked unless inside a data-markup-safe element. |
The rules apply inside open shadow roots and same-origin iframes too. When a screenshot is attached in the composer, a chip shows how many masked fields the screenshot shows, those included; a matching field that is hidden, skipped or off screen isn't in it and isn't counted. 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.
Page URLs. Each thread stores the URL it was left on, so reopening it lands on that exact view. Params whose name says they carry a credential (token, access_token, code, session, auth, key, secret, password, sig, jwt, otp and names built from them) are removed first, in the query string and in the fragment, unless they are in routeParams. Whoever opens the thread, from the dashboard, a mention or the widget, lands on that URL. When an AI agent reads feedback over Markup's MCP server, every query value is shown as [redacted] except those of routeParams and of the params a project admin allows in the dashboard. See Page URLs in the guide.
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. One that hasn't answered within 3 seconds leaves them blank in that screenshot only; the next one asks again. - 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.
- 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….
- 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.
Anchors and source paths
Two optional attributes make pins sturdier and give agents a place to start. Neither is required; without them the widget anchors by structure and text.
<section data-markup-anchor="pricing" data-markup-source="src/components/Pricing.tsx">…</section>| Attribute | What it does |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data-markup-anchor | A stable name for the element, used ahead of ids and test attributes when a pin's selector is built. Treat it like an id: a name unique on the page addresses the element on its own, and a pin placed on it stays attached when its text changes. A name several elements share (edit on every row's button) is kept only as a hint alongside the element's position. In a list that can be reordered, name each item (data-markup-anchor="todo-42") so a pin follows its item rather than its position. |
| data-markup-source | A repository-relative path. The nearest one above the pinned element is stored with the thread, shown in the dashboard, and returned to agents by the MCP server's get_thread as anchorSource. Values over 512 characters are ignored. |
On a React app built with Vite, webpack, Rspack or Next.js, the
@pixelmatters/markup-plugin
build plugin writes data-markup-source for you, as
<path>:<line>:<column>, on every element your JSX renders. It tags the dev
server by default and a build only when you ask, because a tagged build ships
your file paths to everyone who loads it; turn it on for preview and staging
builds.
Without a name, a pin in a list that was reordered or filtered still finds its item when the pinned text is unique in the list, such as a title. It can't tell identical controls apart: a pin on one of several Edit buttons stays with whichever row now holds its old position. Name the items to cover that case.
Both are read from your page as it is, so keep them accurate: an agent is told
to start from anchorSource and confirm it, not to trust it blindly.
How it works
- The widget mounts
<div id="markup-widget">ondocument.bodyand attaches an open shadow root. It does so once the page is idle, or 2 s afterinit()on a page that never is, soinit()returns before the element exists. The requests it needs before it can show pins, for the live-update credential and, on a first visit, an anonymous identity, go out frominit()itself, so they overlap that wait. - All UI lives in that shadow root, with
:host { all: initial !important }blocking style inheritance. The!importantalso means no rule on your page can restyle the host element, whether it matches by accident (div,body > div,[data-theme]) or aims at#markup-widget: it can't paint or move the widget. To hide it or stack it lower, use the custom properties in Hiding the widget or stacking it lower; to take it off the page, calldestroy(). It never prints. - 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. - A modal on your page, such as a Radix or Base UI dialog, doesn't lock the widget out. Focus, wheel and touch events inside the widget are not passed on to your page, so a focus trap or scroll lock never sees them. A field of yours still gets its
focusoutwhen focus moves into the widget, without arelatedTarget. The widget also removes thearia-hiddenorinerta modal puts on it, so screen readers can reach it. A Base UI popup that allows presses outside it still closes on a click in the widget, and a native<dialog>opened withshowModal()makes the widget inert. - 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, which the browser keeps per origin. 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 click opens a blank popup straight away, so the browser doesn't block it. The widget then POSTs to
/widget/popup-exchangewith the apiKey + Origin gate, gets back a single-use code that lapses after five minutes, and sends the popup to${apiUrl}/widget/auth?code=…. 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. Whether they are is checked once per tab, when a sign-in looks likely (the pointer on the avatar, its menu open, or a sign-in prompt shown), not on every page load. The apiKey never appears in the popup URL.The cookies belong to the whole site, so the same project's other origins there, such as
pr-12.app.pages.devbesideapp.pages.dev, share them. A widget on one of those origins with no identity stored there yet asksPOST /widget/auth/resumewho the cookies sign in, and signs that person in without the popup; the request reads the session and never changes it. A visitor who already has an anonymous identity on that origin keeps it, since it is what edits and erases their anonymous comments there, and signs in through the popup as before. If one of those origins signs someone else in, a widget signed in on another origin drops to anonymous at its next refresh, within about 15 minutes, rather than keep showing one name while its requests go out as the other. ThelocalStoragefallback has no shared state to resume, so there each origin signs in through the popup.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) erases the comments, screenshots and reactions that identity posted on this site, and clears the cached name, email, and client id from the host page's localStorage. The threads it started stay, under "Deleted user", so other people's replies still read.
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. Within one site, the partitioned cookies the first sign-in left are what the widget resumes from, so only the first origin needs the popup.
Getting an API key
- Sign in to the Markup dashboard.
- Open your project's API keys page.
- Click New key, label it, and copy the raw key. It is shown once.
- Open the project's Domains page 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 haven't installed the widget yet, or you're still deciding whether to adopt it, the skills CLI fetches the same skill from the Markup site:
npx skills add https://markup.pixelmatters.dev --skill install-markup-widgetKeep --skill. The site also publishes a skill for reading feedback over MCP, and without the flag the CLI asks which to install.
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 the project's Install page in the Markup dashboard
- `apiKey`: `markup_...` ← replace with a key from the project's API keys page in the Markup dashboard
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 function that removes this instance; call it on unmount or logout
```
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 or Vue, use `useMarkup` from `@pixelmatters/markup/react` or
`@pixelmatters/markup/vue` once near the app root. For Solid and others, call
`init()` from a mount lifecycle hook (`onMount`) and call the returned
`destroy` on cleanup.
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. If it's a React app built with Vite, webpack, Rspack or Next.js 16, offer to add `@pixelmatters/markup-plugin` as a dev dependency so each pinned element records its file, line and column. Vite: `import markup from '@pixelmatters/markup-plugin/vite'` and add `markup()` before the React plugin. webpack or Rspack: add `markup()` from `@pixelmatters/markup-plugin/webpack` or `@pixelmatters/markup-plugin/rspack` to `plugins` (a CommonJS config takes `require(...).default`). Next.js: wrap the exported config with `withMarkup` from `@pixelmatters/markup-plugin/next`. It tags the dev server by default; tag preview builds with `MARKUP_SOURCE=1`, never production, since a tagged build ships file paths to every visitor.
7. Show me a diff of the changes and a one-line note on how to verify (e.g. "run dev server, click the comment button on the toolbar 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` only if we support Safari before 16.4 or Firefox before 101. 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, and any CDN serving your webfonts, CSS backgrounds, cross-origin images or SVG sprite | fetched so screenshots include them, such as <use href="/sprite.svg#icon"> icons |
| 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', for Safari before 16.4 and Firefox before 101 only | those browsers have no constructed stylesheets, so the widget falls back to a <style> element inside its own shadow root |
Only img-src and the asset entry degrade gracefully: a blocked avatar falls back to initials, and a blocked image or sprite in a capture comes through blank. Without data: in img-src the capture itself can't load, and the composer reads Screenshot unavailable. A missing https://…convex.site entry stops the widget working at all. A missing wss://…convex.cloud entry leaves the toolbar up and posting working, but threads never load; the widget reports that once rather than retrying. A missing 'unsafe-inline' in style-src leaves it unstyled only on Safari before 16.4 and Firefox before 101; everywhere else the widget adopts a constructed stylesheet, which style-src does not block.
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/devtools.
