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

v0.1.1

Published

Durable DOM anchoring. Describe an element by its neighbourhood, then find it again after the page has changed.

Readme

loupe-core

Durable DOM anchoring. Describe an element by its neighbourhood, then find it again after the page has changed.

A CSS selector is an address: it tells you where an element lives, and it breaks the moment anything upstream is renovated. This library stores a description instead — the button between the price line and the terms link, inside the checkout region, that says "Buy now". Descriptions survive renovations, because the things they describe change at different rates. Markup churns constantly. Copy rarely. Structure somewhere in between.

Built for pinning comments to live websites, but it has no opinion about what you anchor. No dependencies, no network, no DOM mutation, no UI.

npm install loupe-core

Use

import { captureAnchor, resolveAnchor } from 'loupe-core'

// When the user points at something
const anchor = captureAnchor(element, { point: { x: e.clientX, y: e.clientY } })
await save(anchor) // plain JSON, ~1KB

// Later, on a page that has since been redeployed
const result = resolveAnchor(anchor)

result.band     // 'anchored' | 'flagged' | 'orphaned'
result.element  // the element, or null when orphaned
result.score    // 0..1
result.margin   // gap to the runner-up; a small gap means ambiguous
result.healed   // a refreshed anchor, when we trust the match enough to rewrite it
result.scopeLost // the region it lived in is gone; the bar was raised
result.truncated // more candidates existed than we were willing to score

Areas, not just points

A reviewer pointing at a hero image or circling a whole section is not clicking one element. captureRegion takes the dragged rectangle, anchors it to the deepest element that fully contains it, and stores the selection as fractions of that element's box — so it survives the same reflow the element survives.

const anchor = captureRegion({ left, top, right, bottom }, document.body)
// part of a hero image        → anchors to the <img>
// across image + caption      → anchors to the <figure> that holds both
// across a whole card grid    → anchors to the grid container
// a whole section             → anchors to the <section>

const box = regionRect(anchor, resolveAnchor(anchor).element!)

The walk descends through elements that have no box of their own — display: contents is the common case, and Chromium really does report a zero rect for it — but never selects one. A drag over a single card inside a display: contents wrapper anchors to that card, not to the grid two levels up.

The three bands

Most anchoring libraries return an element or nothing. That is the wrong shape, because it forces a library that is 60% sure to either lie or give up. This one always tells you which it is.

| Band | When | What to do | |---|---|---| | anchored | score ≥ 0.75 and a clear margin | Place the pin. Store result.healed — the anchor gets more accurate over time instead of decaying. | | flagged | score ≥ 0.45, or two candidates within 0.08 of each other | Place the pin, and say it may have moved. Show the original capture next to it. | | orphaned | nothing cleared the floor | Do not place the pin at stored coordinates. Move it to a tray and let a human re-pin or close it. |

That middle band is the whole point. A pin that is confidently in the wrong place teaches reviewers to distrust every pin on the page. A pin that admits "I'm not sure this is still the same button" keeps their trust and costs you one line of UI.

How scoring works

Every stored signal is compared, weighted, and normalized against the signals that were available — an element with no test id is not punished for lacking one.

| Signal | Weight | Survives | |---|---|---| | text — the element's own text | 0.28 | class refactors, restyles, framework migrations | | stableIds — id, data-testid, aria-label, alt, name | 0.22 | everything, until someone deletes them | | siblings — what it sits between | 0.16 | the element itself being rewritten | | tagRole — tag and ARIA role | 0.10 | class churn | | parent — parent tag, child count, ordinal | 0.10 | siblings moving nearby | | breadcrumb — up to 6 ancestor tags, no classes | 0.08 | one or two new wrapper levels | | media — the file an img/video/link points at | 0.08 | media only — a redesign that changes everything else | | stableClasses — class tokens that survive the hash filter | 0.04 | nothing much, deliberately | | geometry — bucketed size and font | 0.02 | used only as a tiebreaker |

Text and deliberate names are half the score, and they are the two things a CSS refactor never touches. A designer renaming every class in the codebase moves the score by 0.04. That is the design, not an accident.

Weights are relative, not a budget: each one counts only when the stored anchor actually had that signal, and the total renormalizes over what was comparable. An <img> has no text, so text drops out and media — the filename — carries it instead.

The class filter

Class attributes are mostly machine output now, so they are filtered before they are stored:

isGeneratedClass('css-1x2y3z')          // true  — emotion
isGeneratedClass('Button_root__x7Fq2')  // true  — CSS modules
isGeneratedClass('sc-bdVaJa')           // true  — styled-components
isGeneratedClass('bg-blue-500')         // false — Tailwind, a person typed this
isGeneratedClass('card__title')         // false — BEM

The count of discarded tokens is kept too. An element whose classes were 100% generated tells the resolver that this codebase's classes are worthless as signal here.

Benchmarks

Run npm run report. Every row is a real thing that happens to a codebase between someone filing a comment and someone else reading it.

  band      score margin  cand layout  scenario
  ANCHORED  1.00  1.00     1     5   unchanged page
  ANCHORED  0.96  1.00     1     5   class refactor (Tailwind to CSS modules)
  ANCHORED  0.97  1.00     1     5   sibling inserted above
  ANCHORED  0.93  1.00     1     5   layout wrapper added
  ANCHORED  0.86  1.00     1     5   tag changed to div[role=button]
  FLAGGED   0.56  1.00     1     2   copy edited + test id removed
  FLAGGED   1.00  0.00     2     4   component duplicated
  ORPHANED  0.00  0.00     0     0   region deleted
  FLAGGED   0.78  1.00     1     2   scope gone, lookalike elsewhere
  ANCHORED  0.87  1.00     1     5   image: class refactor + new wrapper
  ANCHORED  1.00  0.32   200    15   800-row list, target #700, row inserted

Three rows carry most of the design:

  • Component duplicated scores a perfect 1.00 and is still flagged, because two candidates tied. A high score on an ambiguous match is exactly when naive resolvers pin the wrong one.
  • Scope gone scores 0.78 — above the normal 0.75 bar — and is still flagged. The scope is the gate that guarantees nothing else could compete; when it disappears, the search widened to the whole document, so anchoring needs 0.85 and a 0.15 margin instead.
  • 800-row list is the regression that named this release. Candidates are gathered in tiers — identity, then text, then the stored selector, then a cheap same-tag sweep — so the budget is never spent on the first 200 elements in document order while the highest-weighted signal goes unused.

The layout column counts getComputedStyle + getBoundingClientRect calls per resolve. Scoring runs without geometry and only the top six candidates are re-scored with it, so the 200-candidate case costs 15 layout reads rather than 400. Geometry is worth 0.02; it can break a tie and nothing more.

API

| Export | Purpose | |---|---| | captureAnchor(el, opts?) | Snapshot an element and its neighbourhood → Anchor (plain JSON) | | captureRegion(rect, root, opts?) | Anchor a dragged area — an image, a section, a span of cards | | regionRect(anchor, el) | Turn a stored region back into page coordinates | | deepestContaining(rect, root) | The deepest element that fully contains a rectangle | | resolveAnchor(anchor, root?) | Find it again → ResolveResult with band, score, margin, breakdown | | findElement(anchor, root?) | Convenience — just the element, or null | | scoreCandidate(el, anchor) | Score one specific element, with a per-signal breakdown | | partitionClasses(el) | Split classes into stable tokens and a discard count | | findScope(el) / identityOf(el) | Nearest stable ancestor, and whether an element has a durable identity | | THRESHOLDS / WEIGHTS | The constants above, if you want to tune them |

Notes and limits

  • Scope gating. Resolution searches inside the nearest stable ancestor only — an element with an id, a test id, or a landmark role. A redesign elsewhere on the page cannot produce a competing match.
  • Give your elements test ids. data-testid is worth 0.22 on its own and makes the scope gate cheap. This library works without them; it works much better with them.
  • No layout, no problem. Geometry is bucketed and weighted at 0.02, so results are stable in jsdom and across device pixel ratios.
  • Media is a first-class case. An <img> has no text, so alt is scored alongside the other deliberate names and the file basename gets its own signal. An image keeps its identity through a redesign that changes everything else about it.
  • Cross-origin iframes and canvas content have no DOM to anchor to. Out of scope by construction.
  • The benchmark is synthetic. Eleven scenarios on small fixtures. Real proof means snapshotting live sites across two of their own deploys, and that is not done yet.
  • This is the engine, not the product. No network calls, no storage, no rendering.

Development

npm install
npm test           # 30 unit tests (jsdom)
npm run report     # the scenario table above
npm run typecheck
npm run build

npx playwright install chromium
npm run test:browser   # 10 tests in real Chromium

Why there are two suites. jsdom has no layout engine, so every getBoundingClientRect is zero and none of the region maths can be exercised there — the geometry in the unit suite runs against stubbed rectangles. Anything that depends on real layout lives in test/browser, against real Chromium at two viewport widths: dragged areas over images, figures, grids and sections; a selection surviving a responsive reflow from three columns to one; a selection surviving a class refactor, a new wrapper and a reflow at once; and the display: contents descent, which is verified to fail if the descent is removed.

Licence

MIT