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

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.

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

The 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 (accentInk overrides if your brand insists).
  • Brand tokens (accent, radius, font) apply in both schemes; surface colours are light-only — dark surfaces go in dark. 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.