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 ambiguousSignals and weights
| Signal | Weight | Survives |
|---|---|---|
| text | 0.28 | class refactors, restyles, framework migrations |
| stableIds | 0.22 | everything, until someone deletes them |
| siblings | 0.16 | the element itself being rewritten |
| tagRole | 0.10 | class churn |
| parent | 0.10 | siblings moving nearby |
| breadcrumb | 0.08 | one or two new wrapper levels |
| media | 0.08 | media only — the file an img or link points at |
| classes | 0.04 | nothing much, deliberately |
| geometry | 0.02 | tiebreaker 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
| Export | Purpose |
|---|---|
| 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 / WEIGHTS | The 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.15A 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.