loupe-core
v0.1.1
Published
Durable DOM anchoring. Describe an element by its neighbourhood, then find it again after the page has changed.
Maintainers
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-coreUse
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 scoreAreas, 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 — BEMThe 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 insertedThree 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-testidis 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, soaltis 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 ChromiumWhy 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
