Loupe.DocsSign in

loupe-core

The anchoring engine on its own: capture a description of an element, find it again after the page has changed. No network, no storage, no UI, no dependencies. MIT.

npm install loupe-core
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, about 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
A CSS selector is an address — it says where an element lives, and breaks when anything upstream is renovated. This stores a description: the button between the price line and the terms link, inside the checkout region, that says “Buy now”. Descriptions survive renovations, because markup churns constantly and copy rarely.

Signals and weights

SignalWeightSurvives
text0.28class refactors, restyles, framework migrations
stableIds0.22everything, until someone deletes them
siblings0.16the element itself being rewritten
tagRole0.10class churn
parent0.10siblings moving nearby
breadcrumb0.08one or two new wrapper levels
media0.08media only — the file an img or link points at
classes0.04nothing much, deliberately
geometry0.02tiebreaker only

Weights are relative and renormalize over the signals that were actually available, so an element with no test id is not punished for lacking one, and an image with no text leans on its alt and its filename instead. A designer renaming every class in the codebase moves the score by 0.04.

Give your elements test ids

data-testid is worth 0.22 on its own and makes the scope check cheap. The engine works without them; it works considerably better with them.

Areas, not just points

import { captureRegion, regionRect } from 'loupe-core'

const anchor = captureRegion({ left, top, right, bottom }, document.body)
// part of a hero image  → the <img>
// image plus caption    → the <figure> holding both
// a span of cards       → the grid container

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

The rectangle is stored as fractions of the anchor element's box, so it survives the same reflow the element does. The walk descends through elements with no box of their own — display: contents is the common case — but never selects one.

Reference

ExportPurpose
captureAnchor(el, opts?)Snapshot an element and its neighbourhood → Anchor
captureRegion(rect, root, opts?)Anchor a dragged area
regionRect(anchor, el)Turn a stored region back into page coordinates
resolveAnchor(anchor, root?)Find it again → band, score, margin, breakdown
findElement(anchor, root?)Just the element, or null
scoreCandidate(el, anchor)Score one element, with a per-signal breakdown
partitionClasses(el)Split classes into stable tokens and a discard count
THRESHOLDS / WEIGHTSThe constants above, if you want to tune them

Thresholds

anchored: 0.75    // and a clear margin over the runner-up
flagged:  0.45
minMargin: 0.08   // two candidates closer than this are ambiguous,
                  // whatever they scored

// when the surrounding region is gone the search widened to the whole
// document, so anchoring needs a clearly better result
scopeLostAnchored: 0.85
scopeLostMargin:   0.15

A duplicated component scores a perfect 1.00 and is still flagged, because two candidates tied. A high score on an ambiguous match is exactly when a naive resolver pins the wrong one.

Limits

  • Cross-origin iframes and canvas content have no DOM to anchor to. Out of scope by construction.
  • Geometry is bucketed and weighted at 0.02, so results are stable in jsdom and across device pixel ratios.
  • The published benchmark is synthetic — eleven scenarios on small fixtures, plus a real-browser suite for the region maths. Real proof means live sites across two of their own deploys, and that is not done yet.

← The widget