익스클루시브
Foresee <mv-foresee>
포인터 궤적으로 사용자가 클릭하려는 컨트롤을 클릭 전에 예견하고, 자신의 예견 적중률을 기록합니다.
문화적 레퍼런스
마이너리티 리포트, 스티븐 스필버그(필립 K. 딕의 1956년 중편 소설 원작)(2002년, 영화). 예지자들은 행위가 일어나기 전에 그것을 예견하고, 이견을 보인 환영은 소수 의견 보고서(minority report)로 기록됩니다. UI에서는 포인터 궤적이 클릭이 일어나기 전에 그것을 예고하고, 앱은 미리 대응하며, 빗나간 예측은 모두 컴포넌트 자체의 점수에 이견으로 기록됩니다.
작동 방식
포인터 궤적으로 사용자가 클릭하려는 컨트롤을 클릭 전에 예견하고, 자신의 예견 적중률을 기록합니다. 링크와 버튼이 있는 어떤 영역이든 감싸고, 영역 바깥 최대 240px부터 접근을 추적합니다. 후보마다 방향(움직임의 연장선이 대상을 지나는가?), 거리, 감속(사람은 대상에 다가가며 속도를 줄입니다: 정지 지점은 속도와 감속도로 추정)을 점수화하고, 가장 높은 후보가 임곗값 위에 머무르면 data-foresee="foreseen", --mv-foresee-confidence 변수, 그리고 확신이 커질수록 또렷해지는 옅은 링이 부여됩니다. mv-foresee(취소 가능)를 사용하면 앱이 무엇이든 미리 준비할 수 있고(데이터 가져오기, 대화상자 코드 로드), prefetch 속성은 동일 출처 링크마다 <link rel="prefetch">를 하나씩 삽입합니다. 이후 모든 클릭은 현재 예측과 비교해 판정되며(mv-verdict: hit 또는 dissent, ms 단위의 선행 시간), stats는 정확도와 평균 선행 시간을 집계합니다. 키보드 포커스와 터치 누름은 확실한 예견으로 간주되므로 모든 입력 방식이 선행 시간을 얻습니다. show-indicator="false"로 설정하면 순수한 프리페치 인텔리전스로서 보이지 않게 동작합니다.
| 카테고리 | 커서 |
|---|---|
| 유형 | Web Component (<mv-foresee>) |
| 상태 | 안정 |
| Keywords | exclusive, culture, cursor, prediction, prefetch, preload, intent, trajectory, pointer, performance, navigation, speculation, analytics, anticipation |
When to use
- A navigation grid or dashboard should start loading the next page or its data before the user actually clicks
- A heavy dialog, editor or route chunk should be warmed up only when the user is clearly heading for its trigger
- A team wants to measure how predictable its navigation is (accuracy and head start per click) before investing in prefetching
- Hover-based prefetching fires too late or too often and a trajectory-based signal is needed
Avoid when
- The targets are few, far apart and cheap to load: plain links already feel instant
- The goal is a decorative effect that follows the cursor → use Cursor Follower instead
- Prefetching has side effects (GET links that mutate state, metered data): only use the event, never the prefetch attribute
설치
node scripts/add.mjs foresee --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["foresee"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/foresee/foresee.js, components/foresee/foresee.css.
사용법
기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="pc-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#pc-demo { --pc-gap: .875rem }
#pc-demo .pc-layout { display:grid; grid-template-columns:minmax(0,1fr) 17rem; gap:1.25rem; align-items:start }
#pc-demo .pc-top { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; margin:0 0 1rem }
#pc-demo .pc-top h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
#pc-demo .pc-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#pc-demo .pc-actions { display:flex; gap:.5rem; flex-wrap:wrap }
#pc-demo .pc-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:var(--pc-gap) }
#pc-demo .pc-card { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.25rem .75rem; align-items:start; padding:1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); color:inherit; text-decoration:none; transition:border-color var(--mv-duration-fast), background-color var(--mv-duration-fast) }
#pc-demo .pc-card:hover { border-color:var(--mv-border-strong) }
#pc-demo .pc-card:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
#pc-demo .pc-card[data-foresee="foreseen"] { background:color-mix(in oklab, var(--mv-accent) calc(3% * var(--mv-foresee-confidence, 0) + 1%), var(--mv-surface)) }
#pc-demo .pc-ico { grid-row:span 2; display:grid; place-items:center; width:2.25rem; height:2.25rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-muted); color:var(--mv-fg-muted) }
#pc-demo .pc-ico svg { width:1.125rem; height:1.125rem }
#pc-demo .pc-card b { font-size:.875rem; font-weight:600 }
#pc-demo .pc-card span.pc-d { color:var(--mv-fg-muted); font-size:.75rem; line-height:1.4 }
#pc-demo .pc-hud { position:sticky; top:1rem; display:grid; gap:1rem; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-sm); font-size:.8125rem }
#pc-demo .pc-hud h4 { display:flex; align-items:center; justify-content:space-between; margin:0; font-size:.8125rem; font-weight:600 }
#pc-demo .pc-k { color:var(--mv-fg-muted); font-size:.6875rem; letter-spacing:.04em; text-transform:uppercase; font-weight:600 }
#pc-demo .pc-now { display:grid; gap:.375rem }
#pc-demo .pc-target { font-size:1rem; font-weight:600; letter-spacing:-.01em; min-height:1.5rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#pc-demo .pc-target[data-empty] { color:var(--mv-fg-subtle); font-weight:500; font-size:.875rem }
#pc-demo .pc-bar { position:relative; height:6px; border-radius:3px; background:var(--mv-bg-muted); overflow:hidden }
#pc-demo .pc-bar i { position:absolute; inset:0; border-radius:inherit; background:var(--mv-accent); transform-origin:left; scale:var(--v,0) 1; transition:scale 120ms linear }
#pc-demo .pc-bar-row { display:flex; justify-content:space-between; color:var(--mv-fg-muted); font-size:.75rem; font-variant-numeric:tabular-nums }
#pc-demo .pc-stats { display:grid; grid-template-columns:1fr 1fr; gap:.75rem; margin:0 }
#pc-demo .pc-stats div { display:grid; gap:.125rem; padding:.625rem .75rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#pc-demo .pc-stats dt { color:var(--mv-fg-muted); font-size:.6875rem }
#pc-demo .pc-stats dd { margin:0; font-size:1.25rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
#pc-demo .pc-stats dd small { font-size:.6875rem; font-weight:500; color:var(--mv-fg-muted); letter-spacing:0 }
#pc-demo .pc-log { list-style:none; margin:0; padding:0; display:grid; gap:.375rem }
#pc-demo .pc-log li { display:flex; justify-content:space-between; gap:.5rem; font-size:.75rem; color:var(--mv-fg-muted) }
#pc-demo .pc-log li b { color:var(--mv-fg); font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#pc-demo .pc-log li[data-hit="false"] b::after { content:" · dissent"; color:var(--mv-fg-subtle); font-weight:400 }
#pc-demo .pc-log li span { font-variant-numeric:tabular-nums; white-space:nowrap }
#pc-demo .pc-log .pc-empty { color:var(--mv-fg-subtle) }
#pc-demo .pc-sep { height:1px; background:var(--mv-border) }
#pc-demo .pc-hint { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
@media (max-width:52rem) {
#pc-demo .pc-layout { grid-template-columns:minmax(0,1fr) }
#pc-demo .pc-hud { position:static }
}
@media (max-width:36rem) { #pc-demo .pc-grid { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="pc-layout">
<mv-foresee id="pc-region" prefetch>
<div class="pc-top">
<div>
<h3>Workspace</h3>
<p>Acme Labs · 14 members</p>
</div>
<div class="pc-actions">
<button class="mv-button" data-variant="outline" data-size="sm" type="button">Invite</button>
<button class="mv-button" data-variant="outline" data-size="sm" type="button">Export</button>
<button class="mv-button" data-size="sm" type="button">New project</button>
</div>
</div>
<nav class="pc-grid" aria-label="Workspace sections">
<a class="pc-card" href="#overview"><span class="pc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg></span><b>Overview</b><span class="pc-d">Key metrics and recent activity</span></a>
<a class="pc-card" href="#projects"><span class="pc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></span><b>Projects</b><span class="pc-d">12 active · 3 due this week</span></a>
<a class="pc-card" href="#analytics"><span class="pc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20V10"/><path d="M10 20V4"/><path d="M16 20v-7"/><path d="M22 20H2"/></svg></span><b>Analytics</b><span class="pc-d">Traffic, funnels and retention</span></a>
<a class="pc-card" href="#docs"><span class="pc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h4"/></svg></span><b>Documentation</b><span class="pc-d">Guides, API reference, changelog</span></a>
<a class="pc-card" href="#billing"><span class="pc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="5" width="19" height="14" rx="2"/><path d="M2.5 10h19"/><path d="M6.5 15h3"/></svg></span><b>Billing</b><span class="pc-d">Pro plan · $240 / month</span></a>
<a class="pc-card" href="#team"><span class="pc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0"/><path d="M16 4.5a3.5 3.5 0 0 1 0 7"/><path d="M18.5 14.5a6.5 6.5 0 0 1 3 5.5"/></svg></span><b>Team</b><span class="pc-d">Roles, invitations and access</span></a>
<a class="pc-card" href="#integrations"><span class="pc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/></svg></span><b>Integrations</b><span class="pc-d">Chat, code hosting and 20 more</span></a>
<a class="pc-card" href="#security"><span class="pc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2.5 4 5.5v6c0 4.8 3.4 8.7 8 10 4.6-1.3 8-5.2 8-10v-6z"/><path d="m8.8 12 2.2 2.2 4.2-4.4"/></svg></span><b>Security</b><span class="pc-d">SSO, audit log, API keys</span></a>
<a class="pc-card" href="#settings"><span class="pc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></span><b>Settings</b><span class="pc-d">Workspace name, region, defaults</span></a>
</nav>
</mv-foresee>
<aside class="pc-hud" aria-label="Foresight monitor">
<h4>Foresight <span class="mv-badge" data-variant="secondary" data-shape="pill" id="pc-source">idle</span></h4>
<div class="pc-now">
<span class="pc-k">Foreseen</span>
<span class="pc-target" id="pc-target" data-empty>Move toward a card…</span>
<div class="pc-bar" aria-hidden="true"><i id="pc-bar"></i></div>
<div class="pc-bar-row"><span>Confidence</span><span id="pc-conf">-</span></div>
</div>
<dl class="pc-stats">
<div><dt>Accuracy</dt><dd id="pc-acc">-</dd></div>
<div><dt>Last head start</dt><dd id="pc-lead">-</dd></div>
<div><dt>Hits</dt><dd id="pc-hits">0 <small>of 0</small></dd></div>
<div><dt>Avg head start</dt><dd id="pc-avg">-</dd></div>
</dl>
<div class="pc-sep"></div>
<div class="pc-now">
<span class="pc-k">Warmed up before the click</span>
<ol class="pc-log" id="pc-log"><li class="pc-empty">Nothing yet: click any card</li></ol>
</div>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="pc-ring" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Show foresight ring</span></span>
</label>
<span class="mv-sr-only" role="status" id="pc-status"></span>
</aside>
</div>
<p class="pc-hint">Head for a card and slow down as you approach: it is foreseen before you click · Tab through the cards for keyboard foresight</p>
<script type="module">
const pc = document.getElementById("pc-region");
await customElements.whenDefined("mv-foresee");
const $ = (id) => document.getElementById(id);
const target = $("pc-target"), bar = $("pc-bar"), conf = $("pc-conf"), source = $("pc-source");
const log = $("pc-log");
let raf = 0;
const labels = { pointer: "trajectory", keyboard: "keyboard", press: "press", touch: "touch", api: "app" };
const paint = () => {
raf = 0;
if (!pc.isConnected) return;
const f = pc.foreseen;
if (!f) return;
bar.style.setProperty("--v", f.confidence.toFixed(2));
conf.textContent = `${Math.round(f.confidence * 100)}%`;
raf = requestAnimationFrame(paint);
};
pc.addEventListener("mv-foresee", (e) => {
const { label, source: src } = e.detail;
target.textContent = e.detail.target.querySelector("b")?.textContent ?? label;
target.removeAttribute("data-empty");
source.textContent = labels[src] ?? src;
source.dataset.variant = src === "pointer" ? "info" : "secondary";
// Here the app would warm up whatever the section needs (data, code, a dialog…).
if (!raf) raf = requestAnimationFrame(paint);
});
pc.addEventListener("mv-foresee-end", () => {
cancelAnimationFrame(raf); raf = 0;
target.textContent = "Move toward a card…";
target.setAttribute("data-empty", "");
source.textContent = "idle"; source.dataset.variant = "secondary";
bar.style.setProperty("--v", 0);
conf.textContent = "-";
});
pc.addEventListener("click", (e) => { if (e.target.closest("a[href^='#']")) e.preventDefault(); });
const ms = (v) => (v == null ? "-" : `${v} ms`);
pc.addEventListener("mv-verdict", (e) => {
const { hit, actual, predicted, leadTime, source: src } = e.detail;
const s = pc.stats;
$("pc-acc").textContent = s.accuracy == null ? "-" : `${Math.round(s.accuracy * 100)}%`;
$("pc-hits").replaceChildren(`${s.hits} `, Object.assign(document.createElement("small"), { textContent: `of ${s.predictions}` }));
$("pc-avg").textContent = ms(s.avgLeadTime);
if (hit) $("pc-lead").textContent = ms(leadTime);
const name = (el) => el?.querySelector("b")?.textContent ?? el?.textContent.trim() ?? "";
const li = document.createElement("li");
li.dataset.hit = String(hit);
const b = document.createElement("b");
b.textContent = name(actual);
const t = document.createElement("span");
t.textContent = hit ? `${leadTime} ms early` : predicted ? `foresaw ${name(predicted)}` : "not foreseen";
li.append(b, t);
log.querySelector(".pc-empty")?.remove();
log.prepend(li);
while (log.children.length > 4) log.lastElementChild.remove();
$("pc-status").textContent = hit ? `${name(actual)} was foreseen ${leadTime} milliseconds early (${src}).` : `${name(actual)} was not foreseen.`;
});
$("pc-ring").addEventListener("change", (e) => { pc.showIndicator = e.target.checked; });
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
threshold | number (0..1) | 0.55 | Confidence above which the best candidate is foreseen (it must also stay there for dwell ms). Lower = earlier but more dissents; the foresight is dropped when its confidence falls below 60% of the threshold for 200 ms or another target wins. |
targets | CSS selector | a[href], button, [role='button'], [data-foresee] | Candidates inside the region. Disabled ones (:disabled, aria-disabled="true", inside [inert]) and invisible ones are skipped. Rects are cached and refreshed only on scroll, resize or DOM changes. |
prefetch | boolean | false | When a same-origin http(s) link is foreseen and mv-foresee is not canceled, inserts <link rel="prefetch"> once per URL (deduplicated across the document). Skipped for in-page anchors, download links, data-foresee-prefetch="false" and when the browser asks to save data. |
show-indicator | "true" | "false" | true | "false" hides the ring: the component still foresees, emits events and prefetches, invisibly. |
dwell | number (ms) | 40 | How long the best candidate must stay above the threshold before it is foreseen (filters out fly-overs). |
data-foresee | "foreseen" | any | On a candidate: set to "foreseen" by the component while it is foreseen (its previous value is restored afterwards). Any element carrying the attribute is also a candidate by default. | |
data-foresee-label | string | On a candidate: name reported in events and the foreseen property (default: aria-label, aria-labelledby, then text). | |
data-foresee-prefetch | "false" | On a link: never prefetched, even when foreseen. |
Properties
| Name | 유형 | Description |
|---|---|---|
foreseen | { target, confidence, source, since, label } | null | Current foresight (read-only). source: "pointer" (trajectory), "keyboard" (focus), "press" (mouse/pen down), "touch" or "api". confidence is updated every frame while the pointer moves. |
stats | { predictions, hits, misses, accuracy, avgLeadTime } | Score of the pointer foresight (read-only). predictions = pointer clicks judged, hits = clicks on the foreseen target, accuracy = hits / predictions (null before the first click), avgLeadTime = mean head start of hits in ms. Keyboard and touch foresights are certain by construction and are not counted. |
threshold / targets / prefetch / showIndicator / dwell | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
foresee(target, { confidence? }) | Forces a foresight on a candidate (element or selector) from app logic, e.g. the next step of a wizard. Emits mv-foresee like any other foresight. Returns false if the element is not a candidate. |
resetStats() | Clears the score. |
Events
| Name | Description |
|---|---|
mv-foresee | A target is foreseen. detail: { target, confidence, leadTime (estimated ms until the click, null when unknown), distance (px left to travel, 0 once on the target), source, label }. Cancelable: preventDefault() skips the built-in prefetch; warm up anything else here. |
mv-foresee-end | The foresight ended. detail: { target, reason: "lost" | "replaced" | "leave" | "click" | "blur" | "disconnect" }. |
mv-verdict | A candidate was clicked (or activated with the keyboard). detail: { hit, predicted (element or null), actual, leadTime (ms between foresight and click, hits only), confidence, source, stats }. A miss is a dissent: the prediction standing at pointerdown is the one judged. |
Content structure
| Name | Description |
|---|---|
(content) | Any region containing links and buttons. The ring is drawn in an overlay layer appended to the element; the app's DOM is only touched through data-foresee and --mv-foresee-confidence on the foreseen target. |
CSS classes
| Name | Description |
|---|---|
mv-foresee-layer | Overlay (aria-hidden, pointer-events: none) holding the indicator; hidden when show-indicator="false". |
mv-foresee-indicator / -ring | The foresight ring. data-active while visible, data-source, data-verdict="hit | miss" for half a second after a click, --_c = confidence. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-foresee-confidence | 0..1 | Set on the foreseen target: style it by confidence (subtle tint, raised shadow…). |
--mv-foresee-color | var(--mv-accent) | Ring tint. |
--mv-foresee-offset | 3px | Gap between the target and the ring (the ring's corner radius follows the target's plus this gap). |
Accessibility
Purely additive: nothing is announced and focus is never moved, the ring is aria-hidden with pointer-events: none, and the foreseen target keeps its role, name and behavior (only data-foresee and a CSS variable are added, then restored). Keyboard users get the same benefit as pointer users: moving focus to a candidate is a foresight with confidence 1 (mv-foresee fires, links are prefetched), while the visible focus ring stays the only indicator so the two never compete. Touch has no hover, so a press foresees immediately and gives ~100 ms of head start before the click. The verdict is observed in the capture phase without calling preventDefault, so links and buttons work exactly as before. Reduced motion (prefers-reduced-motion or data-motion="reduce"): the ring jumps between targets with no glide and no pulse; confidence is still shown as a static change of intensity. Forced colors: the ring uses the Highlight system color. Performance: pointer events only store coordinates; scoring runs in a frameLoop that sleeps when the pointer is still, candidate rects are read once per invalidation (scroll, resize, DOM changes) and never inside the loop's write phase; everything stops when the element is removed.