loupe-widget
The drop-in. Renders pins, the composer and the feedback panel, and bundles the anchoring engine so one script tag is the whole install.
Installing
npm
npm install loupe-widget
Next.js (App Router)
Client components are still prerendered in Node during SSR and SSG, where location and document don't exist. Call createFeedback inside a useEffect, which runs only in the browser.
'use client'
import { useEffect } from 'react'
import { createFeedback } from 'loupe-widget'
export default function Layout({ children }: { children: React.ReactNode }) {
useEffect(() => {
void createFeedback({ publishableKey: 'pk_live_…' })
}, [])
return <>{children}</>
}Next.js (Pages Router)
// pages/_app.tsx
import { useEffect } from 'react'
import { createFeedback } from 'loupe-widget'
export default function App({ Component, pageProps }) {
useEffect(() => {
void createFeedback({ publishableKey: 'pk_live_…' })
}, [])
return <Component {...pageProps} />
}Vite / other client-only bundlers
No server-side step, so module scope is safe.
import { createFeedback } from 'loupe-widget'
createFeedback({ publishableKey: 'pk_live_…' })Getting a key
Sign in at loupe.worksbybrad.xyz and mint a key from the project page, to control personal mode and the rest. Or no account at all: npx loupe-widget prints a pk_live_… and the backend provisions a personal-mode project the first time it sees it — no signup, no dashboard visit. See Personal mode below for what that means. Point the widget at your own backend with apiUrl and it works the same way.
Script tag
No build step. Works with WordPress, Rails, Django, Laravel or plain HTML. Load the file first, then call createFeedback once it is in — a deferrred script runs after an inline init on the following line, so the loader below is the only order that works:
<script>
(function () {
if (window.Loupe) return; // already loaded — never start twice
var s = document.createElement('script');
s.src = 'https://loupe.worksbybrad.xyz/loupe.js';
s.async = true;
s.onload = function () {
Loupe.createFeedback({ publishableKey: 'pk_live_…' });
};
document.head.appendChild(s);
})();
</script>Tag manager / site builders
The route worth knowing about for a client site you don't deploy. Most such sites already run Google Tag Manager and someone on the client side already has access — a Custom HTML tag firing on all pages installs the widget with no repo access and no deploy. Paste the same load-then-init snippet above into the custom-code field of GTM, Webflow, Squarespace, Framer or Shopify; the window.Loupe guard stops it starting twice on single-page-app navigation.
Options
| Option | What it does |
|---|---|
| publishableKey | Required. Safe in your bundle — it identifies the project and reads nothing. Generate your own with npx loupe-widget; the backend provisions a personal-mode project on first use (see Personal mode). Writes made with it are signed by a device registered on the page, so a lifted key alone can't fake feedback. |
| viewerToken | A string, or a function returning one. Your server mints it from the secret key. Without it, nobody sees the team's comments — an anonymous visitor may still see their own, if the project has personal mode on. |
| release | Commit SHA or tag. Ties each anchor to a deploy, which is what makes drift measurable. |
| label | Launcher text. Defaults to Feedback. |
| authorName | Attached to comments this browser files, when your app knows who the person is. |
| authorEmail | Attached to comments this browser files. Unlike authorName this reaches project members through the dashboard, so only set it when your app is sure the site's visitor agreed to be contacted on it. Anyone leaving feedback can also volunteer a name and email in the composer; those win over these. |
| apiUrl | Point at a self-hosted API. Defaults to https://loupe.worksbybrad.xyz/api. |
| mask | Selectors whose content is redacted before anything leaves the browser. Nothing is masked unless you name it — see below. |
| theme | Colours, radius and type. See below. |
| capture | What to gather with a comment. environment (viewport, screen, device class, platform) is on. console is off and must be turned on deliberately. |
| activation | See below. |
Theming
The widget runs on your site, in front of your users, so it should look like it belongs there. Twelve tokens, applied as custom properties inside the shadow root — nothing leaks out, and you never have to fight it with !important.
createFeedback({
publishableKey: 'pk_live_…',
theme: {
accent: '#7c3aed', // pins, the active tool, primary buttons
surface: '#ffffff', // panels and dialogs
ink: '#14181a', // body text
muted: '#5c6a69', // secondary text
line: '#e6e6ef', // borders and dividers
bar: '#14181a', // the launcher and the toolbar
barInk: '#edf1f0',
radius: '10px',
font: 'Inter, system-ui, sans-serif',
colorScheme: 'auto', // 'auto' | 'light' | 'dark'
dark: { surface: '#16161f', line: '#2a2a3a' },
},
})You do not set the text colour on your accent. It is derived from the accent's luminance, because white on a light brand colour is the mistake this option would otherwise invite — set accentInk if your brand insists on a specific pairing.
Setting accent alone applies it in both schemes. Setting a surface colour applies it only to light, since a white panel chosen for daylight should not still be white at midnight; put dark surfaces in dark.
Flagged and orphaned pins keep their own colours whatever you choose. They report the state of our resolver rather than anything about your product, and they need to stay readable and distinguishable from a confident pin.
Minting a viewer token
Your own auth decides who qualifies, so we never hold your user table. Fifteen minutes, refreshed silently.
// your server — the only place sk_ ever appears
const res = await fetch('https://loupe.worksbybrad.xyz/api/v1/viewer-tokens', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.LOUPE_SECRET_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ subject: user.id, name: user.name, role: 'member' }),
})
const { token } = await res.json()Activation
The failure that gets a widget deleted from a live site is an end user discovering the panel — or reading the team's internal notes about the page they are standing on. So nothing renders at all before a credential resolves: a viewer token you minted, or their own pins in a personal-mode project.
| Entry point | Default |
|---|---|
| Cmd/Ctrl+Shift+K | On everywhere. The only entry point with no visual footprint. |
| Launcher | On when a viewer token resolves or the visitor has their own pins; dormant otherwise. |
| ?feedback=on | On. Persists for the tab, so a review link survives navigation. |
| loupe.open() | Always. Put it in your own admin nav, behind your own role check. |
createFeedback({
publishableKey: 'pk_live_…',
activation: {
chord: 'mod+shift+k', // or false
bubble: true, // 'auto' | true | false
bubblePosition: 'bottom-right',
urlParam: 'feedback', // or false
},
})Personal mode
Personal mode is a project setting, not a widget option. It is served in the config and toggled on the project page; nothing in the widget's script can turn it on or off, which is the point of it living server-side. When it is on, a visitor with no credential at all — no viewer token minted by your server — can still use the widget: they leave comments and see back only the ones they left themselves. Everything is scoped to their own device, so they never see anyone else's feedback.
It is what makes the widget usable before there is a team: one person, their own project, no account. That is exactly what npx loupe-widget provisions — a personal-mode project — the first time the new key talks to the backend.
A visitor with a viewer token sees the team's feedback normally whatever the setting, because the viewer token is the whole way a real project's members read comments. Switching personal mode off only takes it away from anonymous visitors.
Leaving feedback
Pressing Feedback opens a toolbar and arms the comment tool. Leaving feedback is a mode, not a single action: the tool stays armed across picks, and sending or cancelling the dialog returns to it rather than switching it off. Escape unwinds one layer at a time — the dialog, then the tool, then the toolbar.
Click an element to pin a point. Drag across an area and the anchor lands on the deepest element that fully contains it — the image for part of a hero, the figure for an image plus its caption, the grid for a span of cards. The selection is stored as fractions of that element, so it survives the same reflow the element does.
A click with nothing under it but <html> or <body> — most of a centred layout — files a page-level comment: a thread with no anchor, which lists everywhere and draws no pin. An anchor on the root element is a fraction of the whole document; it resolves perfectly, scores as confident, and puts a pin in the gutter. Screenshot does the same for the whole page, captured with getDisplayMedia — one permission prompt, and what the person actually saw.
The dialog offers a type and a priority, both optional and blank by default. A required picker is a tax on the person doing you the favour of reporting something, and produces a backlog marked with whatever was easiest to click.
What is gathered with a comment
capture.environment is on: viewport, screen, device class, pixel ratio, platform, language and user agent. Device class comes from touch and width rather than the user-agent string — iPadOS has reported itself as a Mac for years, so a UA answer is wrong for the device people most often report from.
capture.console is off and has to be turned on per install. It keeps the last 50 lines. It is the one thing here that can carry your data — apps log tokens, addresses and whole API responses — and this runs on your site, so it is not something to switch on by accident.
It captures from the moment the widget finishes booting, not from the moment the page started — boot waits on the config request and a device registration first. If your interesting errors happen during load, this will not have seen them.
Reading feedback
The launcher shows how many open threads are on this page. Opening it arms the comment tool; the list is behind the toolbar's Feedback button, and clicking a pin opens that thread directly. Either way the sheet opens beside the pin it belongs to — right, left, below or above, whichever side fits whole.
You can edit and delete your own comments. Actions end where their intent points: resolving is finishing, so the sheet closes, and deleting your last comment removes the thread and closes it too. Reopening stays on the thread, because you have just said this is not done and the next thing you will do is say why. Resolved threads sink under a heading rather than vanishing, and their pins mute rather than disappearing — a thread that disappears on resolve cannot be reopened by whoever needs it back. Hovering a pin shows the author and the comment without opening anything.
A link of the form ?lp_thread=<id> opens that thread on load and scrolls to its pin — which is what the Open on site link in your inbox uses.
Masking
On a logged-in page, the two things that carry content upward are the anchor and the screenshot. Anchoring stores up to 120 characters of an element's own text, its aria-label and its alt — that is how a pin survives a redeploy, and on an account page it is somebody's name. mask takes selectors, and their content is removed before either leaves the browser.
createFeedback({
publishableKey: 'pk_live_…',
mask: ['.customer-name', '[data-private]', '#invoice-total'],
})A child of a masked element is masked. A neighbour is not: siblings are captured separately, so a masked element does not take the innocent element beside it with it. Dragging a region that touches anything masked blanks all of the text, because a region anchors to whichever element contains it rather than the one under the pointer.
Structure is kept on purpose. The selector, the xpath, the tag path, the id and the stable classes all survive. Blanking them would hide nobody — the element stays just as visible on the page — and would only stop the pin from ever being found again. A masked element anchors on structure alone, so expect it to drift sooner than an unmasked one.
An invalid selector throws and the widget does not start. A mask that quietly matches nothing behaves exactly like a correct one until someone reads the stored data, which is the worst place for a mistake to surface. It is checked at boot, before any network call, so the failure lands in development where the typo is.
Screenshots are refused rather than mis-redacted. The browser's share picker does not have to give us this tab — the person can hand over another window or a whole monitor — and the rectangles being painted are in this page's coordinates. Rather than produce a picture with black boxes in the wrong places, which looks redacted and isn't, the capture is abandoned and the toolbar asks for this tab instead. Nothing is masked by default, inputs included: a screenshot shows whatever was on screen.
Sharing a recording
The Record button in the toolbar records a video of the page — the masked canvas, not the raw pixels — and uploads it when the person stops sharing. It is the samegetDisplayMedia prompt as Screenshot, recorded at 15 fps into a webm with the masked areas painted out frame by frame. The dashboard's Recordings pages list every capture: a row exists from the moment they say Share, so a recording abandoned mid-capture shows up rather than vanishing, and the playback appears the moment the upload lands.
It refuses to lie about privacy exactly like the screenshot does. The masking rectangles are painted in this page's coordinates, so if the share picker hands over another window or a whole monitor, the capture is abandoned and the toolbar asks for this tab instead. And the browser has to produce a webm — the storage only accepts that container — so on Safari, which records mp4, the widget says recording is unsupported in this browser rather than happily storing nothing.
Recordings are capped at 60 MB on upload. Nothing is recorded by default: the button is a person pressing a button, not a passive capture.
Getting other people in
A viewer token your server mints from the secret key is the only route. Your own auth decides who qualifies, so it is the one the team's members and any client use to see the project's feedback. Mint one per request from the secret key — see above — and hand it to the widget as viewerToken: a string, or a function that fetches a fresh one each time.
Nothing renders anywhere without a credential — that is the rule that keeps your real users from finding the panel. A visitor who has no viewer token sees the widget only if the project has personal mode on, and then sees back just the comments they left themselves.
When a pin loses its element
Anchoring is best-effort by construction. An element can be deleted outright, and no amount of scoring finds something that is not there — so a thread whose element has gone draws no pin, because a pin in the gutter would be a claim nobody made.
It is not lost, though. It appears in the feedback list under Lost their place, with its pin number replaced by a dash and a button that arms a picker: click the element it means now and the pin moves there. Only the person who opened the thread can do that, plus your server and your dashboard — a reply is a comment on somebody else's report, and moving what that report points at rewrites their meaning rather than adding to it.
Single-page apps
Route changes are detected by patching history.pushState and replaceState and listening for popstate; the widget re-fetches when the path actually changes. Pins are matched by route pattern, so /blog/[slug] shows the same template feedback on every post. DOM changes reposition pins on a debounce; they do not trigger a fetch.
destroy() restores the history methods it patched, so mounting and unmounting repeatedly leaves nothing behind.
One catch: if the page unmounts while createFeedback is still resolving, the widget keeps booting anyway — call destroy() on the handle once it resolves, or it stays mounted and follows the visitor to the pages they reach next. React StrictMode double-mounts effects in development and is exactly how to hit this.
API
| Member | Purpose |
|---|---|
| createFeedback(opts) | Boots the widget. Resolves to the handle below. |
| loupe.open() | Opens the toolbar and arms the comment tool. |
| loupe.close() | Closes the toolbar, panel, composer and any selection in progress. |
| loupe.refresh() | Re-fetches this route's threads and repaints. |
| loupe.state() | { credential, config, deviceId, personalEngaged }. Useful when it renders nothing and you want to know why. |
| loupe.destroy() | Removes everything and unpatches history. |
Size and CSP
The eager path is 15805 bytes gzipped across five files, against a 16000-byte budget — resolver, pin layer, token handshake, theme tokens, device and personal-mode records. The composer, panel, toolbar, selection gesture, screenshot capture, session recording, environment snapshot, console tap, masking and drift reporting are all dynamic imports, so they cost nothing until someone acts. Five files rather than one because bundlers emit shared chunks that the entry imports statically; counting only the entry is how this number was under-reported for a while. npm run size fails the build if the eager path goes over, and the budget does not move to accommodate a feature.
Under a strict script-src policy, allow the bundle's origin or self-host it; SRI hashes are published with each release. This is the quiet reason widget pilots stall, so it is worth checking before you roll out.