Exclusifbêta
Foresee <mv-foresee>
Anticipe le contrôle sur lequel l’utilisateur va cliquer d’après la trajectoire du pointeur, avant le clic, et tient le score de ses propres prédictions. Enveloppe n’importe quelle zone de liens et de boutons et suit l’approche jusqu’à 240px à l’extérieur ; le cap (le rayon du mouvement croise-t-il la cible ?), la distance et le freinage (on ralentit en arrivant sur sa cible : le point d’arrêt est estimé à partir de la vitesse et de la décélération) sont notés pour chaque candidat et, une fois que le meilleur reste au-dessus du seuil, il reçoit data-foresee="foreseen", une variable --mv-foresee-confidence et un anneau discret qui se renforce avec la confiance. mv-foresee (annulable) permet à l’app de préparer n’importe quoi à l’avance (récupérer des données, charger le code d’une boîte de dialogue), et l’attribut prefetch insère un <link rel="prefetch"> par lien de même origine. Chaque clic est ensuite confronté à la prédiction en cours (mv-verdict : hit ou dissent, avance en ms) et stats fait la synthèse de la précision et de l’avance moyenne. Le focus clavier et les appuis tactiles comptent comme des prédictions certaines, pour que chaque mode de saisie profite de l’avance ; show-indicator="false" le fait tourner de façon invisible, comme pur moteur de prefetch.
| Catégorie | Curseurs |
|---|---|
| Type | Web Component (<mv-foresee>) |
| Statut | bêta |
| 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
Installation
node scripts/add.mjs foresee --out ./src/marvelousAgent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["foresee"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : 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.
Utilisation
Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables 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>Northwind 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">Slack, GitHub 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>Référence culturelle
Minority Report, Steven Spielberg (d’après la nouvelle de Philip K. Dick de 1956) (2002, film). Des voyants précognitifs prévoient un acte avant qu’il ne se produise, et une vision divergente est consignée sous forme de rapport minoritaire. Dans l’interface, la trajectoire du pointeur prédit le clic avant qu’il n’ait lieu, l’app agit en avance, et chaque mauvaise prédiction est consignée comme une divergence dans le score propre au composant.
API
Attributes
| Name | Type | 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 | Type | 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.