loupe-widget
v1.1.0
Published
Loupe is feedback your team and clients leave directly on your live website. Notes stay pinned to the thing they were left on, even after a redesign.
Maintainers
Readme
loupe-widget
Loupe is feedback your team and clients leave directly on your live site. Anyone you invite can click anything on a page — a button, an image, a whole section — and note what's broken, what's missing, or what should change. Once left, the note stays pinned to that exact thing, even after you redesign the layout or ship again.
This package is the drop-in UI: call createFeedback() once and you're done.
It also exports the anchoring engine (captureAnchor, resolveAnchor) for
teams building feedback into their own interface.
Install
npm i loupe-widgetThe widget boots against the page, so it only works in a browser. In a
framework that prerenders on a server — Next.js prerenders "use client"
components in Node during SSR and SSG — call createFeedback from a
useEffect, which runs only in the browser. In a client-only build (Vite,
plain bundlers) module scope is fine:
Next.js — App Router (app/layout.tsx):
"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 / client-only:
import { createFeedback } from "loupe-widget";
createFeedback({ publishableKey: "pk_live_…" });The full options
const loupe = await createFeedback({
publishableKey: "pk_live_…", // required; identifies the project, reads nothing
viewerToken: () => fetch("/api/loupe-token").then((r) => r.text()),
release: process.env.COMMIT_SHA,
theme: { accent: "#7c3aed" },
});Mount it once, in your root layout — no framework wrapper.
Getting a key
Sign in at https://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 # a ready pk_live_…
npx loupe-widget --test # …or a pk_test_…No account, no dashboard visit, no DNS: the backend provisions a personal-mode
project the first time it sees a new key. Run it once, paste the key into
createFeedback(). Keep it out of source control.
The same key works against any loupe-compatible backend — point apiUrl at the
one you run and the widget is independent of us entirely.
Script tag — the single-file build (dist/index.global.js, no build step,
exposed as Loupe). Load the file, then call createFeedback once it's in —
that order matters, which is why the snippet loads loupe.js with a loader
rather than defer (a deferrred file runs after the next inline script,
which would throw Loupe is not defined):
<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 — a Custom HTML tag in Google Tag Manager, or the custom-code field in Webflow / Squarespace / Shopify / Framer. No repo access, no deploy: the load-then-init snippet above pastes straight in. Step-by-step GTM and site-builder walks live in docs/install.md.
Options
| Option | Default | What it does |
| ---------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| publishableKey | — | Required. Identifies the project; safe in a browser bundle. Generate your own with npx loupe-widget — no account needed. |
| viewerToken | — | string \| () => string \| null, minted by your server from the secret key. Without one: personal mode only. |
| release | — | Commit SHA / tag, ties anchors to a deploy. |
| mask | — | Selectors redacted before anything leaves the browser. |
| label | "Feedback" | Launcher text. |
| authorName | — | Name attached to comments this browser files. |
| theme | — | Colours, radius, font — see Theming. |
| activation | see Activation | Entry points. |
| capture | { environment: true, console: false } | What's gathered with a comment. |
| apiUrl | https://loupe.worksbybrad.xyz/api | Which backend to talk to. Point it at your own and the widget needs nothing from us. |
| authorEmail | — | Email attached to comments this browser files. Reaches project members through the dashboard, so only set it when the visitor agreed to be contacted on it. |
| fetch | global fetch | Test seam. |
The handle
| Method | Does |
| -------------------- | --------------------------------------------------------------------------------------- |
| open() / close() | Raise / put down the toolbar |
| refresh() | Re-fetch threads, re-resolve pins |
| state() | { credential, config, deviceId, personalEngaged, pins } — why it renders what it does |
| destroy() | Remove everything, restore patched history |
If your page unmounts while createFeedback is still resolving — a quick route
change, or a framework double-mounting an effect — call destroy() on the handle
when it does resolve, or that widget stays mounted and follows the visitor to
every page they reach afterwards.
Showing the all comments
On its own the widget shows a reviewer only their own comments. For all comments, mint a short-lived viewer token from the secret key and hand it to the widget:
POST /v1/viewer-tokens # your server, the only place sk_ appears
Authorization: Bearer sk_live_…
{ "subject": "user_8812", "role": "member", "ttl": 900 }
→ { "token": "vt_…", "expiresAt": "…" }Refreshed silently before it expires. Your auth decides who qualifies, so the widget never sees your user table.
It renders nothing until it's allowed to
| Viewer token | Personal mode | Result | | ------------ | ------------- | --------------------------------- | | yes | — | full widget | | no | yes | personal mode — own comments only | | no | no | nothing |
Nothing means nothing: no bubble, no overlay, no listeners, no device
registered; open() is a no-op.
A key you generate yourself is row 2 by construction: personal mode, your own comments only — until you hand the widget a viewer token.
Activation
| Entry point | Default |
| ------------------------------------- | --------------- |
| Chord mod+shift+k | on |
| Corner bubble | on when allowed |
| ?feedback=on (persists for the tab) | on |
| loupe.open() from your own UI | always |
Leaving feedback
- A mode, not an action. The comment tool stays armed across picks — five notes take one trip to the toolbar. Escape unwinds dialog → tool → toolbar.
- Click pins the element under the cursor.
- Drag past 6px anchors to the deepest element that fully contains the
rectangle, and draws a dashed outline that stays on it — for part of a hero,
the
<img>; for image plus caption, the<figure>. - Click
<html>/<body>— most of a centred layout — files a page-level comment: no anchor, no pin, listed everywhere. - Screenshot captures via
getDisplayMedia. Crop on the live page or send whole; cropping is only offered when the capture provably is this viewport. - Record shares a masked video of the page, uploaded when they stop sharing and listed in the dashboard under Recordings. See Sharing a recording.
- Triage: optional type and priority, both blank by default. Unset is a truthful state.
Sharing a recording
- The Record button records the masked canvas at 15 fps into a webm and uploads it on stop. The dashboard's Recordings pages list it from the moment the row is created, so an abandoned recording shows up rather than vanishes.
- Same honesty rules as Screenshot: if the share picker hands over another window, the capture is abandoned and the toolbar asks for this tab. And the storage accepts webm only, so Safari — which records mp4 — is told recording is unsupported in this browser instead of quietly storing nothing.
- Recordings are capped at 60 MB on upload. The button is a person pressing a button, not a passive capture.
Reading feedback
- Clicking a pin or list entry opens the sheet beside it — the side that fits whole. List entries scroll the element into view.
- Edit and delete only your own. Resolving closes the sheet; reopening stays on the thread with the reply box ready. Resolved threads sink under a heading, their pins mute. Hover a pin for author + comment.
- Pin states — the pins and the list agree: confident = filled numbered badge; unsure-of-place = amber dashed badge (a weak area anchor draws its dot at the middle); element gone = Lost their place with a dash and a re-pin button.
?lp_thread=<id>opens a thread on load and scrolls to its pin (the dashboard's Open on site link).
When an anchor cannot be found
A deleted element draws no pin — a pin in the gutter would be a claim nobody made — and the thread lists under Lost their place. Re-pin is a point, never a drag, and only the thread's author (or your server/dashboard) can do it: moving someone else's report rewrites their meaning. Moving discards the drift readings behind it.
Theming
Twelve tokens applied as custom properties — nothing leaks, nothing to fight
with !important:
accent, accentInk, surface, surfaceAlt, ink, muted, line, bar,
barInk, radius, font, shadow, plus colorScheme (auto | light |
dark) and a dark block.
theme: {
accent: '#7c3aed',
radius: '10px',
font: 'Inter, system-ui, sans-serif',
dark: { surface: '#171b1c' },
}- Text on the accent derives from its luminance for contrast (
accentInkoverrides if your brand insists). - Brand tokens (
accent,radius,font) apply in both schemes; surface colours are light-only — dark surfaces go indark. A white panel set for daylight should not stay white at midnight. - Flagged and orphaned pins keep their own colours whatever you choose.
What's gathered with a comment
environment(on): viewport, screen, device class (from touch + width, not the UA string), platform, language, UA. Captured from boot, not page load.console(off): last 50 console lines, opt-in per install — app logs routinely carry tokens and whole API responses.
Masking
mask: [".customer-name", "[data-private]"];- Children of a masked element are masked (
closest()); siblings are captured separately and stay. A dragged region that touches anything masked blanks all text. - Structure (selector, xpath, tag path, id, stable classes) is kept so pins still resolve — masked elements anchor on structure alone and drift sooner.
- An invalid selector throws at boot, not at first use.
- A screenshot is abandoned rather than mis-redacted: if the share picker hands over another window, the capture is refused. Nothing is masked by default, inputs included.
Getting other people in
One way in: a viewer token your server mints from the secret key. Your own auth decides who qualifies, so it outranks anything the browser can grant itself. Nothing renders anywhere without a credential — that is what keeps your real users from finding the panel — so a visitor with no viewer token sees the widget only if the project has personal mode on, and then sees back just the comments they left themselves.
Single-page apps
Patches history.pushState / replaceState + popstate and re-fetches only
when the path actually changes. Pins match by route pattern, so /blog/[slug]
shows the same template feedback on every post. DOM churn repositions pins on a
150ms debounce and never fetches. destroy() restores the patched methods and
does not stack wrappers.
Content Security Policy
No inline scripts, so no unsafe-inline needed. Self-host the single-file
build and load it with a nonce or an integrity= hash, or allow its origin.
The npm build's lazy chunks load via import(), so script-src must allow
the origin they're served from.
Performance
Pins far outside the viewport cost nothing until the viewer scrolls them
within about a viewport of the fold — the observer defers the button and its
rect read, not the resolution. A page that scales to hundreds of comments
stays cheap at either end of a long scroll, and the orphan tray still knows
every thread exists. Browsers without IntersectionObserver (and jsdom)
draw everything, as always. The observer lives in its own lazy chunk, so a
page with no threads never constructs one.
Not done yet
- A self-served project cannot be claimed into an account yet — a key you generated stays personal until an adoption path ships.
