Exclusifbêta

Footprints <mv-footprints>

Présence des collaborateurs en direct pour les longs documents, specs, fichiers de code et tableurs : un rail fin à côté du conteneur défilant où chaque personne est un petit marqueur légendé (initiales ou avatar, dans sa propre couleur) à l’endroit exact où elle lit ou édite, suivi de quelques empreintes qui s’estompent et montrent dans quel sens elle se déplace : vers le bas, vers le haut, ou d’un bond vers une autre section, avec un arc pointillé. L’app transmet les positions, généralement depuis un websocket, avec move(id, { anchor | top | ratio, mode, note }) ; les ancres sont projetées sur le rail à partir d’offsets en cache, rafraîchis uniquement aux changements de mise en page : une rafale de déplacements distants ne force jamais de recalcul de mise en page du document. Votre propre viewport est une fenêtre translucide et déplaçable, les titres deviennent des graduations, si bien que le rail sert aussi de mini table des matières (section courante en surbrillance), et les marqueurs trop proches sont écartés, reliés par un fil à leur vraie position. Survoler ou focaliser un marqueur ouvre une carte (nom, « En train d’éditer · Actif maintenant », section et note, « 3 sections plus bas que vous ») ; un clic vous amène à cette personne, et un double-clic, F ou l’offre Suivre affichée après un saut vous fait défiler avec elle jusqu’à ce que vous fassiez défiler vous-même. L’édition et les commentaires reçoivent un badge, les personnes inactives s’estompent (idle-after) puis quittent le rail (gone-after), et les arrivées et départs sont annoncés poliment, avec limitation de fréquence.

CatégorieNavigation
TypeWeb Component (<mv-footprints>)
Statutbêta
Keywordsexclusive, culture, presence, collaboration, multiplayer, realtime, minimap, table-of-contents, scroll, follow-mode, avatars, cursors, document, code-review, websocket, navigation

When to use

  • Several people edit the same long spec, contract or wiki page and each needs to see where the others are working
  • A reviewer wants to jump to a colleague's spot in a long code file or sheet, or follow them while they walk through it
  • A long document needs a compact section overview and a live presence signal in the same narrow column
  • A pair-review or onboarding session needs a follow-the-presenter mode that ends as soon as the viewer scrolls

Avoid when

  • Only the reading progress matters, with nobody else in the document → use Scroll Progress instead
  • A solo reader needs the article's shape and what they have already read, not other people's positions → use Reading Map instead
  • A few flagged items (unread, errors, mentions) must be pointed at from the edges of a scroller or a 2-D canvas → use Edge Tracker instead

Installation

node scripts/add.mjs footprints --out ./src/marvelous

Agent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["footprints"], 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/focus.js, core/motion.js, core/pointer.js, core/position.js, components/footprints/footprints.js, components/footprints/footprints.css.

Utilisation

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :

<div id="fp-demo" style="width:min(100%,60rem);margin-inline:auto">
  <style>
    #fp-demo { display:grid; gap:1.25rem; align-content:start }
    #fp-demo .fp-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #fp-demo .fp-bar { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap; min-height:3.5rem; padding:.5rem 1rem .5rem 1.125rem; border-bottom:1px solid var(--mv-border) }
    #fp-demo .fp-title { display:grid; gap:.125rem; flex:1 1 16rem; min-width:0 }
    #fp-demo .fp-title strong { font-size:.9375rem; font-weight:650; letter-spacing:-.01em }
    #fp-demo .fp-title span { color:var(--mv-fg-muted); font-size:.75rem }
    #fp-demo .fp-stage { display:grid; grid-template-columns:minmax(0,1fr) 3.5rem; height:30rem }
    #fp-demo .fp-doc { position:relative; overflow:auto; overscroll-behavior:contain; padding:1.5rem 2.25rem 3rem; scrollbar-width:thin }
    #fp-demo .fp-doc h1 { margin:0 0 .375rem; font-size:1.5rem; letter-spacing:-.02em }
    #fp-demo .fp-doc .fp-meta { margin:0 0 1.5rem; color:var(--mv-fg-muted); font-size:.8125rem }
    #fp-demo .fp-doc h2 { margin:2rem 0 .5rem; font-size:1.125rem; letter-spacing:-.01em }
    #fp-demo .fp-doc h3 { margin:1.25rem 0 .375rem; font-size:.9375rem; color:var(--mv-fg) }
    #fp-demo .fp-doc p, #fp-demo .fp-doc li { color:var(--mv-fg-muted); font-size:.875rem; line-height:1.65; max-width:42rem }
    #fp-demo .fp-doc p { margin:0 0 .75rem }
    #fp-demo .fp-doc ul { margin:0 0 .75rem; padding-inline-start:1.25rem }
    #fp-demo .fp-doc table { width:100%; max-width:42rem; margin:.5rem 0 1rem; border-collapse:collapse; font-size:.8125rem }
    #fp-demo .fp-doc th, #fp-demo .fp-doc td { padding:.5rem .625rem; border-bottom:1px solid var(--mv-border); text-align:start }
    #fp-demo .fp-doc th { color:var(--mv-fg); font-weight:600; background:var(--mv-bg-subtle) }
    #fp-demo .fp-doc td { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
    #fp-demo .fp-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #fp-demo .fp-actions { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap }
    #fp-demo .fp-log { flex:1 1 100%; min-height:1rem; margin:0; color:var(--mv-fg-subtle); font:.6875rem/1.4 var(--mv-font-mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
    #fp-demo .mv-choice { font-size:.8125rem }

    /* Code review variant */
    #fp-demo .fp-code-stage { display:grid; grid-template-columns:3.25rem minmax(0,1fr); height:17rem }
    #fp-demo .fp-code { overflow:auto; overscroll-behavior:contain; padding:.75rem 0; background:var(--mv-bg-subtle); font:.75rem/1.6 var(--mv-font-mono); scrollbar-width:thin }
    #fp-demo .fp-line { display:grid; grid-template-columns:3rem 1fr; white-space:pre; color:var(--mv-fg-muted) }
    #fp-demo .fp-line span:first-child { padding-inline-end:1rem; color:var(--mv-fg-subtle); text-align:end; user-select:none }
    #fp-demo .fp-line.fp-fn { color:var(--mv-fg); font-weight:600 }
    #fp-demo .fp-line.fp-cm { color:var(--mv-fg-subtle); font-style:italic }
    @media (max-width:40rem) { #fp-demo .fp-doc { padding:1.25rem 1rem 2rem } }
  </style>

  <!-- Main: a product spec edited by four people -->
  <section class="fp-app" aria-label="Product spec editor">
    <header class="fp-bar">
      <span class="fp-title">
        <strong>Checkout v3 · Product spec</strong>
        <span>Edited by 4 people · Last saved Sep 23, 2026, 10:42 AM</span>
      </span>
      <span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot" data-pulse></span><span id="fp-count">4 here</span></span>
    </header>
    <div class="fp-stage">
      <article class="fp-doc" id="fp-doc" tabindex="0" aria-label="Checkout v3 product spec">
        <h1>Checkout v3</h1>
        <p class="fp-meta">Owner: Aiko Tanaka · Reviewers: Omar Haddad, Priya Raman, Lena Fischer · Status: In review</p>

        <h2 id="fp-summary">Summary</h2>
        <p id="fp-p-summary-1">Checkout v3 replaces the three-step checkout with a single adaptive page. Returning customers see a one-tap confirmation; new customers get inline account creation after payment instead of before it.</p>
        <p id="fp-p-summary-2">The redesign targets a 12% lift in completed orders on mobile and removes the separate shipping step for digital goods, which make up 38% of order volume.</p>

        <h2 id="fp-goals">Goals and non-goals</h2>
        <ul>
          <li>Reduce median time to purchase from 94 s to under 60 s on mobile.</li>
          <li>Support saved payment methods across web and the iOS and Android apps.</li>
          <li>Show taxes and fees before the final step in every supported market.</li>
        </ul>
        <p id="fp-p-goals-1">Out of scope for this release: gift cards, split payments between two cards, and the marketplace seller checkout, which keeps its current flow until Q2 2027.</p>

        <h2 id="fp-research">Customer research</h2>
        <p id="fp-p-research-1">We ran 18 moderated sessions in Tokyo, Berlin, São Paulo and Chicago. The top abandonment reason was surprise costs at the last step (11 of 18), followed by forced account creation (7 of 18).</p>
        <p id="fp-p-research-2">Analytics confirm the pattern: 41% of mobile sessions that reach the shipping step leave before payment, and the drop is twice as steep when the order total changes on that step.</p>
        <p id="fp-p-research-3">Participants who could pay with a saved wallet finished in 31 seconds on average, and none of them asked for an order summary before confirming.</p>

        <h2 id="fp-pricing">Pricing</h2>
        <p id="fp-p-pricing-1">Prices are shown tax-inclusive where required by law and tax-exclusive in the United States and Canada, with a live estimate as soon as a postal code is known.</p>
        <h3 id="fp-plans">Plans</h3>
        <table>
          <thead><tr><th>Plan</th><th>Monthly</th><th>Annual</th><th>Seats</th></tr></thead>
          <tbody>
            <tr><td>Starter</td><td>$19</td><td>$190</td><td>1</td></tr>
            <tr><td>Team</td><td>$49</td><td>$490</td><td>Up to 10</td></tr>
            <tr><td>Business</td><td>$129</td><td>$1,290</td><td>Up to 50</td></tr>
          </tbody>
        </table>
        <h3 id="fp-enterprise">Enterprise tier</h3>
        <p id="fp-p-enterprise-1">Enterprise customers keep invoiced billing with net-30 terms. The checkout page shows a “Request a quote” path instead of card entry once the seat count passes 50.</p>
        <p id="fp-p-enterprise-2">Volume pricing starts at $11 per seat per month and is negotiated per contract; the page never shows a price it cannot honor.</p>
        <h3 id="fp-discounts">Discounts</h3>
        <p id="fp-p-discounts-1">Promo codes move from a modal to an inline field under the order summary. Only one code applies per order; the best one is kept automatically when a customer tries a second.</p>

        <h2 id="fp-payments">Payment methods</h2>
        <p id="fp-p-payments-1">Cards, Apple Pay, Google Pay and PayPal at launch; iDEAL, Bancontact and Pix follow in phase 2. Wallet buttons appear first when the device supports them.</p>
        <p id="fp-p-payments-2">3-D Secure challenges open in place rather than in a redirect, and a failed challenge keeps the rest of the form intact.</p>

        <h2 id="fp-tax">Tax and invoicing</h2>
        <p id="fp-p-tax-1">Tax is computed by the existing tax service from the billing address. Business customers can add a VAT or GST number, validated on blur, which removes tax where reverse charge applies.</p>
        <p id="fp-p-tax-2">Invoices are generated as PDF within one minute of payment and emailed to the billing contact, with a copy in the account’s billing history.</p>

        <h2 id="fp-rollout">Rollout plan</h2>
        <h3 id="fp-phase-1">Phase 1 · Mobile web</h3>
        <p id="fp-p-phase1-1">Starts October 13, 2026 at 5% of mobile web traffic in the United States, Japan and Germany, ramping to 50% over two weeks if guardrail metrics hold.</p>
        <h3 id="fp-phase-2">Phase 2 · Apps and new markets</h3>
        <p id="fp-p-phase2-1">The iOS and Android apps adopt the new flow in November, together with local payment methods for the Netherlands, Belgium and Brazil.</p>

        <h2 id="fp-metrics">Success metrics</h2>
        <p id="fp-p-metrics-1">Primary: completed orders per checkout session. Guardrails: refund rate, payment failure rate, and support contacts tagged “checkout” per 1,000 orders.</p>
        <p id="fp-p-metrics-2">We will read results weekly and call the experiment after four weeks or 40,000 sessions per arm, whichever comes first.</p>

        <h2 id="fp-risks">Risks</h2>
        <p id="fp-p-risks-1">Showing tax earlier may lower the add-to-cart rate in tax-inclusive markets. A holdout group of 10% keeps the old checkout during the whole ramp.</p>

        <h2 id="fp-questions">Open questions</h2>
        <p id="fp-p-questions-1">Should guest checkout remember the email for a returning device? Legal review is scheduled for September 30, 2026.</p>
        <p id="fp-p-questions-2">Do we keep the order-notes field? Only 0.4% of orders use it, but most of those are high-value business orders.</p>
      </article>
      <mv-footprints id="fp-rail" scroller="#fp-doc" headings="h2, h3" idle-after="45s" label="Spec map and collaborators"></mv-footprints>
    </div>
    <div class="fp-controls">
      <span class="fp-actions">
        <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="fp-live" checked> Live updates</label>
      </span>
      <span class="fp-actions">
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="fp-follow">Follow Omar</button>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="fp-leap">Priya jumps</button>
        <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="fp-join">Mateo joins</button>
      </span>
      <p class="fp-log" id="fp-log" aria-live="off">Hover a marker for details, click to jump, double-click to follow.</p>
    </div>
  </section>

  <!-- Variant: a code review, rail on the start side, functions as ticks -->
  <section class="fp-app" aria-label="Code review">
    <header class="fp-bar">
      <span class="fp-title">
        <strong>payments/checkout.ts</strong>
        <span>Pull request #482 · Review in progress · rail on the start side, functions as sections</span>
      </span>
    </header>
    <div class="fp-code-stage">
      <mv-footprints id="fp-code-rail" scroller="#fp-code" headings=".fp-fn" side="start" label="File map and reviewers"></mv-footprints>
      <div class="fp-code" id="fp-code" tabindex="0" role="region" aria-label="checkout.ts source"></div>
    </div>
  </section>

  <script type="module">
    const rail = document.getElementById("fp-rail");
    const log = document.getElementById("fp-log");
    const count = document.getElementById("fp-count");
    const live = document.getElementById("fp-live");
    const joinBtn = document.getElementById("fp-join");
    const followBtn = document.getElementById("fp-follow");
    const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
    const say = (text) => { log.textContent = `${time()} · ${text}`; };

    // Simulated presence feed (what a websocket would deliver).
    const doc = document.getElementById("fp-doc");
    const paragraphs = [...doc.querySelectorAll("p[id]")].map((p) => p.id);
    const now = Date.now();
    rail.people = [
      { id: "aiko", name: "Aiko Tanaka", mode: "editing", anchor: "fp-p-enterprise-1", note: "Rewriting volume pricing", at: now },
      { id: "omar", name: "Omar Haddad", mode: "viewing", anchor: "fp-p-research-1", at: now },
      { id: "priya", name: "Priya Raman", mode: "commenting", anchor: "fp-p-phase1-1", note: "Comment on ramp dates", at: now },
      { id: "lena", name: "Lena Fischer", mode: "viewing", anchor: "fp-p-summary-2", at: now - 3 * 60_000 },
    ];

    let omar = paragraphs.indexOf("fp-p-research-1");
    let priyaAt = "fp-p-phase1-1";
    let beat = 0;
    const step = () => {
      if (!live.checked) return;
      beat++;
      // Omar reads steadily down the spec, then starts over from the top.
      omar = omar + 1 < paragraphs.length ? omar + 1 : 0;
      rail.move("omar", { anchor: paragraphs[omar], mode: "viewing" });
      // Aiko keeps editing the enterprise section.
      rail.move("aiko", { anchor: beat % 4 < 2 ? "fp-p-enterprise-1" : "fp-p-enterprise-2", mode: "editing", note: "Rewriting volume pricing" });
      if (beat % 6 === 0) leap();
      if (rail.people.some((p) => p.id === "mateo")) rail.move("mateo", { anchor: paragraphs[(beat + 3) % paragraphs.length] });
    };
    const leap = () => {
      const targets = ["fp-p-payments-1", "fp-p-phase1-1", "fp-p-goals-1", "fp-p-metrics-2", "fp-p-tax-1"];
      const others = targets.filter((t) => t !== priyaAt);
      priyaAt = others[Math.floor(Math.random() * others.length)];
      rail.move("priya", { anchor: priyaAt, mode: "commenting", note: "Reviewing comments" });
    };
    const timer = setInterval(step, 1600);

    rail.addEventListener("mv-join", (e) => say(`${e.detail.person.name} joined`));
    rail.addEventListener("mv-leave", (e) => say(`${e.detail.person.name} left (${e.detail.reason})`));
    rail.addEventListener("mv-status", (e) => say(`${e.detail.person.name} is ${e.detail.status}`));
    rail.addEventListener("mv-jump", (e) => say(e.detail.kind === "person" ? `Jumped to ${e.detail.person.name} in ${e.detail.person.section}` : `Jumped to ${e.detail.label}`));
    rail.addEventListener("mv-follow", (e) => { say(`Following ${e.detail.person.name}`); followBtn.textContent = "Stop following"; });
    rail.addEventListener("mv-unfollow", (e) => { say(`Stopped following (${e.detail.reason})`); followBtn.textContent = "Follow Omar"; });
    const recount = () => { count.textContent = `${rail.people.length} here`; };
    rail.addEventListener("mv-join", recount);
    rail.addEventListener("mv-leave", recount);

    followBtn.addEventListener("click", () => (rail.following ? rail.unfollow() : rail.follow("omar")));
    document.getElementById("fp-leap").addEventListener("click", leap);
    joinBtn.addEventListener("click", () => {
      if (rail.people.some((p) => p.id === "mateo")) { rail.leave("mateo"); joinBtn.textContent = "Mateo joins"; }
      else { rail.join("mateo", { name: "Mateo Rossi", mode: "viewing", anchor: "fp-p-tax-2" }); joinBtn.textContent = "Mateo leaves"; }
    });

    // Code review variant: build the file (text only), then two reviewers.
    const code = document.getElementById("fp-code");
    const src = [
      ["cm", "// Checkout orchestration: quote, pay, confirm."],
      ["", "import { taxFor } from \"./tax\";"],
      ["", "import { charge, confirm3ds } from \"./gateway\";"],
      ["", ""],
      ["fn", "export async function quote(cart, address) {"],
      ["", "  const subtotal = cart.items.reduce((s, i) => s + i.price * i.qty, 0);"],
      ["", "  const tax = await taxFor(address, subtotal);"],
      ["", "  const fees = cart.express ? 9.5 : 0;"],
      ["", "  return { subtotal, tax, fees, total: subtotal + tax + fees };"],
      ["", "}"],
      ["", ""],
      ["fn", "export function applyPromo(quote, codes) {"],
      ["cm", "  // Only one code per order: keep the best one."],
      ["", "  const best = codes.map((c) => c.value(quote)).sort((a, b) => b - a)[0] ?? 0;"],
      ["", "  return { ...quote, discount: best, total: quote.total - best };"],
      ["", "}"],
      ["", ""],
      ["fn", "export async function pay(order, method) {"],
      ["", "  const intent = await charge(order.total, method);"],
      ["", "  if (intent.status === \"requires_action\") {"],
      ["", "    const ok = await confirm3ds(intent);"],
      ["", "    if (!ok) return { status: \"failed\", keepForm: true };"],
      ["", "  }"],
      ["", "  return { status: \"paid\", id: intent.id };"],
      ["", "}"],
      ["", ""],
      ["fn", "export async function invoice(order, customer) {"],
      ["", "  const pdf = await render(\"invoice\", { order, customer });"],
      ["", "  await mail(customer.billingEmail, pdf);"],
      ["", "  return pdf.url;"],
      ["", "}"],
      ["", ""],
      ["fn", "export function receipt(order) {"],
      ["", "  return {"],
      ["", "    lines: order.items.map((i) => `${i.qty} × ${i.name}`),"],
      ["", "    total: formatUsd(order.total),"],
      ["", "  };"],
      ["", "}"],
    ];
    src.forEach(([kind, text], i) => {
      const line = document.createElement("div");
      line.className = `fp-line${kind ? ` fp-${kind}` : ""}`;
      line.id = `fp-l${i + 1}`;
      if (kind === "fn") line.dataset.footprintsLabel = text.replace(/^export (async )?function (\w+).*/, "$2()");
      const n = document.createElement("span");
      n.textContent = String(i + 1);
      const t = document.createElement("span");
      t.textContent = text || " ";
      line.append(n, t);
      code.append(line);
    });
    const codeRail = document.getElementById("fp-code-rail");
    codeRail.people = [
      { id: "kwame", name: "Kwame Boateng", mode: "commenting", anchor: "fp-l21", note: "Line 21: retry on 3-D Secure timeout?" },
      { id: "sofia", name: "Sofia Nguyen", mode: "viewing", anchor: "fp-l6" },
    ];
    let line = 6;
    const codeTimer = setInterval(() => {
      if (!live.checked) return;
      line = line >= src.length - 2 ? 2 : line + 2;
      codeRail.move("sofia", { anchor: `fp-l${line}` });
    }, 1400);

    // The demo can be replayed: stop the fake feeds when it leaves the page.
    const mo = new MutationObserver(() => { if (!rail.isConnected) { clearInterval(timer); clearInterval(codeTimer); mo.disconnect(); } });
    mo.observe(document.body, { childList: true, subtree: true });
  </script>
</div>

Référence culturelle

Harry Potter et le Prisonnier d’Azkaban, J. K. Rowling (1999, livre). Une carte enchantée d’un château montre chaque personne qui s’y trouve sous la forme d’une trace d’empreintes légendée qui se déplace en temps réel : son détenteur sait où est chacun et où il se dirige. Dans l’interface, un rail à côté d’un long document partagé montre chaque collaborateur comme un marqueur légendé à sa position en direct, avec des empreintes qui s’estompent et révèlent sa direction, et vous permet de le rejoindre ou de le suivre.

API

Attributes

NameTypeDefaultDescription
scrollerCSS selectorThe scrolling container the rail describes, looked up in the element's root (retried for a few seconds if rendered later). Omitted: the page itself; the rail then defaults to position: sticky; top: 0; height: 100dvh. Place the element next to the scroller (a grid column), not inside it.
headingsCSS selectorh2, h3Elements inside the scroller that become section ticks and name the sections in labels. Levels come from h1-h6, aria-level or data-footprints-level; the label from data-footprints-label or the text.
side"end" | "start"endWhich side of the content the rail sits on: ticks face the content, the card and the Follow pill open toward it. Mirrors automatically in right-to-left layouts.
idle-aftertime60sWithout a move or ping for this long, a person turns idle (faded, desaturated, “Idle for 3 min”). Accepts ms, s, m, h; "none" disables.
gone-aftertime5mWithout activity for this long, a person leaves the rail (mv-leave with reason "timeout"). "none" keeps everyone until leave().
trailnumber6Footprints drawn per move (0 disables the trail). Their fade time is --mv-footprints-trail-life.
offsetnumber (px)0Height of a sticky header inside the scroller: section jumps land below it and the current section is judged from there.
focus-linenumber (0..1)0.3Where in your viewport a person lands after a jump and while you follow them (0 = top).
focus-on-jump"keyboard" | "always" | "never"keyboardWhether a jump also moves focus to the target (the person's anchor or the heading), like a table-of-contents link. By default only keyboard activations do.
announce"polite" | "off"politeLive announcements of joins and leaves (coalesced, at most one every 4 s) and of Follow starting or stopping.
labelstringDocument mapAccessible name of the rail.

Properties

NameTypeDescription
peopleArray<{ id, name, color?, avatar?, initials?, mode?, note?, at?, anchor? | top? | ratio? }>Read: present people, top of the document first, as snapshots { id, name, color, mode, note, status, top, section, lastActive }. Write: replaces the roster silently (no announcement), handy for the initial state from the server.
followingstring | nullId of the person being followed (read-only; use follow() / unfollow()).
sectionsArray<{ label, level, top, element }>Sections read from the headings, with their cached offsets (read-only).
scrollerElementElement | nullSet the scroller as an element instead of a selector; reads the resolved scroller (null for the page).
stringsobjectEvery visible and announced text (sentence template, “3 sections below you”, hints, Follow labels…), merged over the en-US defaults.

Methods

NameDescription
move(id, { anchor | anchorId | top | ratio, offset?, mode?, note?, at?, name? })Someone moved. anchor: an element, an id or a selector inside the scroller (offset adds px within it); top: px from the top of the content; ratio: 0..1 of the content height. mode: "viewing" | "editing" | "commenting" or your own; note: a detail shown in the card (“Enterprise tier”); at: activity timestamp (default now). Unknown ids join automatically.
join(id, info)Adds or updates someone ({ name, color, avatar, initials } plus any move() field) and announces the arrival. Returns the snapshot.
leave(id)Removes someone (fades out, announced).
ping(id, at?)Activity without movement: keeps someone from turning idle.
jumpTo(target)Scrolls to a person (id) or a section (heading element or index), like a click. Returns false if canceled or unknown.
follow(id) / unfollow()Keeps the view on someone as they move; any scroll by the user (wheel, touch, keys, scrollbar, rail) stops it.
refresh()Re-reads headings and anchor offsets after a layout change the observers cannot see (e.g. content inside a canvas).

Events

NameDescription
mv-joinSomeone appeared. detail: { id, person }.
mv-leaveSomeone left. detail: { id, person, reason: "left" | "timeout" | "sync" }.
mv-statusSomeone turned idle or active again. detail: { id, status, person }.
mv-jumpBefore scrolling to a person or section. detail: { kind: "person" | "section", id?, person?, heading?, label?, top, source: "pointer" | "keyboard" | "api" }. Cancelable: preventDefault() and scroll yourself (virtualized lists, lazy sections).
mv-followBefore following someone. detail: { id, person }. Cancelable.
mv-unfollowFollow ended. detail: { id, person, reason: "user-scroll" | "api" | "left" | "replaced" | "disconnect" }.

CSS classes

NameDescription
mv-footprints-railThe rail (role=group): viewport window, section ticks, footprints layer and people list.
mv-footprints-windowYour viewport; draggable. data-following while you follow someone (tinted with their color).
mv-footprints-tick / -tick-lineSection buttons in an <ol>; data-depth 0-2, data-current and aria-current="location" on your section.
mv-footprints-person<li> per person, --_c = their color; data-mode, data-state="active | idle", data-following.
mv-footprints-marker / -avatar / -badgeThe jump button with initials or avatar, and the editing / commenting badge.
mv-footprints-pin / -tetherExact position in the lane, and the line to the marker when it had to be spread apart.
mv-footprints-step / -leapFootprints (data-foot, data-dir) and the dotted arc of a leap; removed when their fade ends.
mv-footprints-cardHover / focus card (popover, aria-hidden: the marker's accessible name carries the same text).
mv-footprints-followPill toward the content: data-state="offer" after a jump, "following" with a Stop button.

CSS variables

NameDefaultDescription
--mv-footprints-width3.5remRail width.
--mv-footprints-marker-size1.5remMarker diameter (spacing between spread markers follows).
--mv-footprints-color-1 … -8oklch(0.55 0.17 262) …Palette given to people without a color (each present person gets a distinct one).
--mv-footprints-inkvar(--mv-fg-on-accent)Initials and icons on a colored marker or avatar.
--mv-footprints-trail-life6sHow long a footprint takes to fade.
--mv-footprints-backgroundbg-subtle, 80%Rail background.

Accessibility

The rail is a labeled group holding two real lists, each a single Tab stop with roving focus (Up/Down, Home/End). The people list is in document order and every marker is a button whose accessible name is a full sentence kept up to date as people and you move: “Aiko Tanaka is editing Pricing (Enterprise tier), 3 sections below you”, “… on your screen”, “…, following”; its description says “Press Enter to jump there, F to follow” (aria-keyshortcuts="F"). The sections list is a table of contents: each tick is a button named after its heading, and your current section has aria-current="location". Keyboard activation moves focus to the target (the person's anchor or the heading, with a temporary tabindex=-1) like a TOC link; focus-on-jump changes that. Escape stops Follow or closes the card, and a focused marker that leaves hands focus to its neighbor. The hover/focus card duplicates the name visually, so it is aria-hidden and never takes focus; the Follow pill's button carries “Follow Aiko Tanaka” / “Stop following Aiko Tanaka”. A polite live region announces joins and leaves only (coalesced over 1.5 s, at most one message every 4 s, someone who joins and leaves inside that window is not mentioned) plus Follow starting and stopping, since those are the user's own actions; moves, idling and trails are never announced. Color is never the only cue: initials or avatars name each marker, editing and commenting have distinct icons, idle people are faded and hollow. Reduced motion (prefers-reduced-motion or data-motion="reduce"): no footprints or leap arcs are drawn, markers and the Follow scroll jump instead of gliding, and the editing ring stops breathing. Forced colors: system colors for the rail, window, ticks and markers, Highlight for your window and the followed marker. Performance: moves only store a spec; a single frame loop wakes on moves, scrolls and layout changes, reads offsets only after a resize or mutation, writes transforms, and stops when idle; everything is torn down when the element is removed.

Cette page a été traduite par IA. Signaler un problème de traduction