npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pixelmatters/markup

v1.27.0

Published

Embeddable feedback widget for collecting visual bug reports, screenshots, and comments on live web apps.

Downloads

1,956

Readme

@pixelmatters/markup

npm license bundle

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 / replaceState and follows popstate and hashchange to refresh threads on route changes. Hash routers are supported, and a query string is left out of the route unless you opt it in with routeParams. See Routing.
  • Respects the platform. Honours prefers-reduced-motion and prefers-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/markup

CDN 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's latest on 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 apiUrl on convex.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> whose autocomplete attribute contains cc-number, cc-csc, cc-exp, cc-name, cc-type, current-password, new-password, or one-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 your connect-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.

routeParams reads the real query string only. On a hash router the query lives inside the fragment (#/orders?tab=2), where location.search is empty — so listing tab there 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 routeParams re-keys new threads. Pins already stored under the old route stop appearing until an operator runs threads/migration:rekeySearchRoutes with 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"> on document.body and 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 stable id, 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.cloud origin, which the widget derives from apiUrl. Everything else, meaning comments, identity, screenshots and error reports, goes to *.convex.site over HTTP.
  • Identity lives in host-page localStorage under markup.identity, keyed to the top-level site. On first load the widget mints a server-signed anonymous JWT via POST /widget/anon-identity so the backend can verify the authorClientId on every anon write. Tampering with the cached clientId invalidates the signature. Verified identities upgrade to a Bearer JWT via the popup flow described below.

Identity

Anonymous by default, with two opt-in upgrade paths:

  • Sign in with Markup. The widget POSTs to /widget/popup-exchange with 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 popup postMessages a verified identity plus a short-lived server-signed Bearer JWT back to the host page, which attaches it as Authorization: Bearer on subsequent writes. Where partitioned (CHIPS) cookies are available the tokens are held in HttpOnly cookies and the session refreshes for up to 7 days; where they aren't, the widget falls back to markup.identity in the host page's localStorage and 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 clientId and 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.com to convex.site cannot see the dashboard session. The popup is the only reliable way to bridge identity across sites without per-host configuration.

Getting an API key

  1. Sign in to the Markup dashboard.
  2. Open your project → Settings → API Keys.
  3. Click New key, label it, and copy the raw key. It is shown once.
  4. Open Settings → Domains and add the host domain (app.example.com, *.staging.example.com). Production deployments do not auto-allow localhost; add it to the allowlist explicitly if you test against the deployment from http://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 install

Re-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-widget

Pass 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.