Exclusifbêta

Deja Vu <mv-deja-vu>

Signale, instantanément et sur place, les changements que le système a faits dans le dos de l’utilisateur, jamais ceux que l’utilisateur a provoqués. Enveloppe une zone en direct (flux, tableau de bord, tableau, formulaire synchronisé avec le serveur) ; quand une unité (ligne, carte, fieldset… ou tout ce qui porte data-deja-vu-unit) change et que l’utilisateur n’y a ni pointé, ni tapé, ni appuyé sur une touche pendant les grace dernières ms, elle bégaie : un fantôme de la valeur précédente traverse deux fois la nouvelle pendant que la nouvelle valeur rejoue son entrée, deux impulsions identiques en ~600 ms. Un petit onglet « Modifié · il y a 12 s » (« Nouveau » pour les lignes insérées) et un léger contour restent ensuite sur l’unité, avec un temps relatif mis à jour à peu de frais, jusqu’à ce que l’unité soit survolée, reçoive le focus ou soit restée à l’écran pendant linger ms. Les changements sont regroupés par image, une unité ne rejoue jamais son bégaiement plus d’une fois par cooldown, et la liste des changements non acquittés est exposée via changes (acknowledge() la vide) ; une annonce polie facultative les résume (« 3 valeurs modifiées : Chiffre d’affaires du jour, Statut de la commande n° 4821, Conversion. »).

CatégorieFeedback
TypeWeb Component (<mv-deja-vu>)
Statutbêta
KitDes états système honnêtes
Keywordsexclusive, culture, feedback, change-detection, live-data, realtime, sync, dashboard, table, mutation-observer, highlight, diff, notification, awareness, collaboration

When to use

  • A dashboard or table is updated by the server while people read it, and silent value changes must not go unnoticed
  • A form is synced from another device or collaborator and fields may change under the user's eyes
  • A live feed or queue updates items in place and users need to see which ones changed without being interrupted
  • Operators must trust that a number they just looked at is still the one on screen

Avoid when

  • The user needs a summary of everything that changed while they were away or the tab was hidden → use While Away instead
  • The change is an event that deserves its own message (error, completed job, new message) → use Toast instead
  • The region re-renders wholesale several times per second (tickers, live charts): mark that part data-deja-vu-ignore

Installation

node scripts/add.mjs deja-vu --out ./src/marvelous

Agent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["deja-vu"], 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/deja-vu/deja-vu.js, components/deja-vu/deja-vu.css.

Utilisation

Démarrage rapide, le balisage minimal qui fonctionne :

<mv-deja-vu>
  <ul><li>Order #4821 · <span>Packed</span></li><li>Order #4820 · <span>Processing</span></li></ul>
</mv-deja-vu>

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

<div id="dv-demo" style="width:min(100%,60rem);margin-inline:auto">
  <style>
    #dv-demo .dv-top { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; margin:0 0 1.25rem }
    #dv-demo .dv-top h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
    #dv-demo .dv-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #dv-demo .dv-bar { display:flex; align-items:center; gap:.625rem; flex-wrap:wrap }
    #dv-demo .dv-count { display:inline-flex; align-items:center; gap:.375rem; color:var(--mv-fg-muted); font-size:.8125rem; font-variant-numeric:tabular-nums }
    #dv-demo .dv-count b { color:var(--mv-fg); font-weight:600 }
    #dv-demo .dv-live { display:inline-flex; align-items:center; gap:.375rem; color:var(--mv-fg-muted); font-size:.75rem }
    #dv-demo .dv-live i { width:.4375rem; height:.4375rem; border-radius:50%; background:var(--mv-success); box-shadow:0 0 0 3px color-mix(in oklab, var(--mv-success) 20%, transparent) }
    #dv-demo .dv-kpis { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:.875rem; margin:0 0 .875rem }
    #dv-demo .dv-kpi { min-width:0; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #dv-demo .dv-kpi h4 { margin:0; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
    #dv-demo .dv-kpi strong { display:block; margin:.375rem 0 .125rem; font-size:1.5rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums; line-height:1.2 }
    #dv-demo .dv-kpi small { color:var(--mv-fg-subtle); font-size:.75rem; font-variant-numeric:tabular-nums }
    #dv-demo .dv-main { display:grid; grid-template-columns:minmax(0,1fr) 17rem; gap:.875rem; align-items:start }
    #dv-demo .dv-panel { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); overflow:hidden }
    #dv-demo .dv-panel > header { display:flex; align-items:center; justify-content:space-between; padding:.875rem 1.125rem; border-bottom:1px solid var(--mv-border) }
    #dv-demo .dv-panel h4 { margin:0; font-size:.8125rem; font-weight:600 }
    #dv-demo .dv-panel header small { color:var(--mv-fg-subtle); font-size:.75rem }
    #dv-demo table { width:100%; border-collapse:collapse; font-size:.8125rem }
    #dv-demo th { padding:.5rem 1.125rem; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase; text-align:left; background:var(--mv-bg-subtle) }
    #dv-demo td { padding:.625rem 1.125rem; border-top:1px solid var(--mv-border); white-space:nowrap }
    #dv-demo td:first-child { font-weight:600; font-variant-numeric:tabular-nums }
    #dv-demo td.dv-num, #dv-demo th.dv-num { text-align:right; font-variant-numeric:tabular-nums }
    #dv-demo td.dv-who { color:var(--mv-fg-muted) }
    #dv-demo fieldset { margin:0; padding:1rem 1.125rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); display:grid; gap:.625rem }
    #dv-demo legend { float:left; width:100%; padding:0; margin:0 0 .25rem; font-size:.8125rem; font-weight:600 }
    #dv-demo label.dv-l { display:grid; gap:.375rem; font-size:.75rem; color:var(--mv-fg-muted) }
    #dv-demo .dv-note { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.45 }
    #dv-demo .dv-note b { color:var(--mv-fg); font-weight:600; font-variant-numeric:tabular-nums }
    #dv-demo .dv-sync { display:flex; align-items:center; gap:.375rem; color:var(--mv-fg-subtle); font-size:.6875rem }
    #dv-demo .dv-hint { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
    @media (max-width:52rem) {
      #dv-demo .dv-kpis { grid-template-columns:repeat(2,minmax(0,1fr)) }
      #dv-demo .dv-main { grid-template-columns:minmax(0,1fr) }
      #dv-demo .dv-panel { overflow-x:auto }
    }
  </style>

  <div class="dv-top">
    <div>
      <h3>Store operations</h3>
      <p>Today · synced with the order service</p>
    </div>
    <div class="dv-bar">
      <span class="dv-live"><i aria-hidden="true"></i>Live</span>
      <span class="dv-count" aria-live="polite"><b id="dv-count">0</b> changes flagged</span>
      <button class="mv-button" data-variant="ghost" data-size="sm" type="button" id="dv-ack">Acknowledge all</button>
      <button class="mv-button" data-variant="outline" data-size="sm" type="button" id="dv-now">Change something now</button>
    </div>
  </div>

  <mv-deja-vu id="dv-dash" announce linger="12000">
    <div class="dv-kpis">
      <article class="dv-kpi"><h4>Revenue today</h4><strong data-k="revenue">$48,210</strong><small data-k="revenue-note">+8.2% vs. last Tuesday</small></article>
      <article class="dv-kpi"><h4>Orders</h4><strong data-k="orders">312</strong><small>avg. basket $154.52</small></article>
      <article class="dv-kpi"><h4>Conversion</h4><strong data-k="conversion">3.4%</strong><small>2,140 sessions</small></article>
      <article class="dv-kpi"><h4>Refund requests</h4><strong data-k="refunds">4</strong><small>2 awaiting review</small></article>
    </div>

    <div class="dv-main">
      <section class="dv-panel" aria-labelledby="dv-orders-h">
        <header><h4 id="dv-orders-h">Recent orders</h4><small>Updated by the order service</small></header>
        <table>
          <thead><tr><th>Order</th><th>Customer</th><th class="dv-num">Total</th><th>Status</th></tr></thead>
          <tbody id="dv-rows">
            <tr data-deja-vu-unit="Order #4821"><td>#4821</td><td class="dv-who">Amara Okafor</td><td class="dv-num">$212.40</td><td><span class="mv-badge" data-variant="info" data-shape="pill"><span class="mv-badge-dot"></span><span data-s>Packed</span></span></td></tr>
            <tr data-deja-vu-unit="Order #4820"><td>#4820</td><td class="dv-who">Lucas Moreau</td><td class="dv-num">$89.00</td><td><span class="mv-badge" data-variant="warning" data-shape="pill"><span class="mv-badge-dot"></span><span data-s>Processing</span></span></td></tr>
            <tr data-deja-vu-unit="Order #4819"><td>#4819</td><td class="dv-who">Mei Tanaka</td><td class="dv-num">$145.90</td><td><span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot"></span><span data-s>Shipped</span></span></td></tr>
            <tr data-deja-vu-unit="Order #4818"><td>#4818</td><td class="dv-who">Diego Alvarez</td><td class="dv-num">$64.25</td><td><span class="mv-badge" data-variant="warning" data-shape="pill"><span class="mv-badge-dot"></span><span data-s>Processing</span></span></td></tr>
            <tr data-deja-vu-unit="Order #4817"><td>#4817</td><td class="dv-who">Hannah Becker</td><td class="dv-num">$320.00</td><td><span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot"></span><span data-s>Delivered</span></span></td></tr>
          </tbody>
        </table>
      </section>

      <fieldset>
        <legend>Free-shipping threshold</legend>
        <label class="dv-l">Orders above
          <input class="mv-input" id="dv-threshold" type="number" inputmode="decimal" min="0" step="5" value="75">
        </label>
        <p class="dv-note">Applies to <b id="dv-share">64%</b> of today's orders.</p>
        <span class="dv-sync">Type here: your own edits are never flagged</span>
      </fieldset>
    </div>
  </mv-deja-vu>
  <p class="dv-hint">The order service updates this dashboard every few seconds · each change it makes does a quick double take, then keeps a marker until you hover, focus or look at it for a while</p>

  <script type="module">
    const dv = document.getElementById("dv-dash");
    await customElements.whenDefined("mv-deja-vu");
    const $ = (sel) => dv.querySelector(sel);
    const k = (name) => $(`[data-k="${name}"]`);
    const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
    const cents = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
    const count = document.getElementById("dv-count");
    const threshold = document.getElementById("dv-threshold");
    const share = document.getElementById("dv-share");
    const pick = (list) => list[Math.floor(Math.random() * list.length)];
    const num = (el) => Number(el.textContent.replace(/[^0-9.]/g, ""));

    // The user's own edit: the derived text updates, and nothing is flagged.
    const recompute = () => {
      const v = Number(threshold.value) || 0;
      share.textContent = `${Math.max(4, Math.min(98, Math.round(100 - v * 0.48)))}%`;
    };
    threshold.addEventListener("input", recompute);

    const FLOW = ["Processing", "Packed", "Shipped", "Delivered"];
    const VARIANT = { Processing: "warning", Packed: "info", Shipped: "success", Delivered: "success" };
    const CUSTOMERS = ["Priya Raman", "Tomás Silva", "Grace Kim", "Noah Fischer", "Leila Haddad", "Oliver Brown"];
    let nextId = 4822;
    const rows = document.getElementById("dv-rows");

    const updates = [
      () => { const el = k("revenue"); el.textContent = usd.format(num(el) + 80 + Math.round(Math.random() * 640)); },
      () => { const el = k("orders"); el.textContent = String(num(el) + 1 + Math.floor(Math.random() * 3)); },
      () => { const el = k("conversion"); el.textContent = `${(num(el) + (Math.random() < 0.6 ? 0.1 : -0.1)).toFixed(1)}%`; },
      () => {
        const live = [...rows.querySelectorAll("[data-s]")].filter((s) => s.textContent !== "Delivered");
        if (!live.length) return false;
        const s = pick(live);
        const next = FLOW[FLOW.indexOf(s.textContent) + 1];
        s.textContent = next;
        s.closest(".mv-badge").dataset.variant = VARIANT[next];
      },
      () => {
        const id = nextId++;
        const tr = document.createElement("tr");
        tr.dataset.dejaVuUnit = `Order #${id}`;
        const cells = [`#${id}`, pick(CUSTOMERS), cents.format(40 + Math.round(Math.random() * 260))];
        cells.forEach((text, i) => { const td = document.createElement("td"); td.textContent = text; if (i === 1) td.className = "dv-who"; if (i === 2) td.className = "dv-num"; tr.append(td); });
        const td = document.createElement("td");
        const badge = document.createElement("span");
        badge.className = "mv-badge"; badge.dataset.variant = "warning"; badge.dataset.shape = "pill";
        const dot = document.createElement("span"); dot.className = "mv-badge-dot";
        const label = document.createElement("span"); label.dataset.s = ""; label.textContent = "Processing";
        badge.append(dot, label); td.append(badge); tr.append(td);
        rows.prepend(tr);
        while (rows.children.length > 5) rows.lastElementChild.remove();
      },
      () => {
        // A server-synced value: only when you are not editing it.
        if (document.activeElement === threshold) return false;
        const v = String(Number(threshold.value) === 75 ? 80 : 75);
        threshold.value = v;
        threshold.setAttribute("value", v);
        recompute();
      },
      () => { const el = k("refunds"); const n = num(el); el.textContent = String(n > 5 ? n - 1 : n + 1); },
    ];
    const change = () => { for (let i = 0; i < 4 && pick(updates)() === false; i++); };

    let flagged = 0;
    dv.addEventListener("mv-deja-vu", () => { count.textContent = String(++flagged); });

    // Random system changes every 2-4 s; stops by itself once the dashboard is removed.
    let timer = 0;
    const loop = () => {
      if (!dv.isConnected) return clearTimeout(timer);
      change();
      timer = setTimeout(loop, 2000 + Math.random() * 2000);
    };
    timer = setTimeout(() => { if (!dv.isConnected) return; updates[0](); updates[3](); timer = setTimeout(loop, 2600); }, 350);

    document.getElementById("dv-now").addEventListener("click", change);
    document.getElementById("dv-ack").addEventListener("click", () => dv.acknowledge());
  </script>
</div>

Référence culturelle

Matrix, Lana et Lilly Wachowski (1999, film). Un déjà-vu (le même chat noir qui passe deux fois devant une porte) trahit que le système vient de modifier quelque chose dans votre dos. Dans l’interface, une valeur que le système a changée sans vous rejoue sa transition deux fois en un bref bégaiement : les changements silencieux deviennent visibles, tandis que vos propres modifications ne le déclenchent jamais.

API

Attributes

NameTypeDefaultDescription
gracenumber (ms)1500A change counts as the user's own (never flagged) when they pressed, typed, pasted or changed a field inside that unit within this window (e.g. a total recalculated from what they are typing).
echonumber (ms)300Region-wide window after any interaction inside the region during which every change is considered a reaction to it (a click that re-renders another card). 0 disables it.
cooldownnumber (ms)4000Minimum time between two double takes of the same unit. Changes in between still refresh its marker and are still reported.
lingernumber (ms)8000Time a flagged unit must spend on screen (at least half of it, tab visible) before its marker fades on its own. 0 keeps markers until hover, focus or acknowledge().
observe-attributesstringdata-state, data-status, aria-*, value, src, datetimeWatched attributes (comma- or space-separated), on top of text and inserted nodes which are always watched. “aria-*” covers the common ARIA states; add “class” explicitly. A field's value attribute is treated like text (ghost of the old value).
announcebooleanfalsePolite live-region summary of system changes, throttled to one message every 4 s at most: “Revenue today changed to $48,930.” or “3 values changed: Revenue today, Status of Order #4821, Orders.” Never triggered by the user's own edits.
data-deja-vu-unitstringOn an element inside the region: makes it a unit, with this label. Otherwise the nearest li, tr, [role=listitem], [role=row], article, figure, fieldset or section (label: aria-label, heading, legend or first cell), else the region's direct child.
data-deja-vu-labelstringOn a part of a unit: name of that value in announcements (“Status of Order #4821”). Table cells use their column header by default.
data-deja-vu-ignorebooleanSubtree never observed (clocks, sparklines, counters that tick on purpose).
data-deja-vu"changed"Set by the component on a flagged unit until it is acknowledged (style hook).

Properties

NameTypeDescription
changes{ unit, kind, label, field, before, after, time, count }[]Unacknowledged system changes, newest first (read-only copies). kind: "changed" or "added"; before is the value before the first unacknowledged change, after the current one; count: how many times the unit changed since.
grace / echo / cooldown / linger / observeAttributes / announcereflectedMirror the attributes.

Methods

NameDescription
acknowledge(unit?)Acknowledges one unit (element inside it, or selector) or, with no argument, every change: markers fade out and mv-acknowledge fires. Returns the number of changes acknowledged.
flag(target, { before?, after? })Reports a change the observer cannot see (a value set through a property only, a canvas redraw). Same rules as observed changes: grace, cooldown, batching.

Events

NameDescription
mv-deja-vuA system change was flagged (once per unit per frame). detail: { unit, kind, label, field, before, after, time, count }.
mv-acknowledgeChanges were acknowledged (hover, focus, time on screen or acknowledge()). detail: { changes, remaining }.

Content structure

NameDescription
(content)Any live region. Ghosts, pulses and markers are drawn in an aria-hidden overlay layer appended to the element; the app's DOM only receives data-deja-vu on flagged units and short opacity animations on changed values.

CSS classes

NameDescription
mv-deja-vu-flashThe unit's double pulse (transient).
mv-deja-vu-ghostThe previous value crossing the new one twice (transient; copies the value's font, color, padding and alignment).
mv-deja-vu-mark / -markerPersistent outline of a flagged unit and its “Changed · 12s ago” tab. data-leaving while fading out.

CSS variables

NameDefaultDescription
--mv-deja-vu-colorvar(--mv-accent)Tint of pulses, outline and marker.
--mv-deja-vu-outline38%Strength of the persistent outline (color-mix percentage).

Accessibility

Visual cues are aria-hidden with pointer-events: none and never move focus or alter the flagged unit's semantics (only a data-deja-vu attribute and brief opacity animations on the changed value). With announce, screen-reader users get one polite, throttled summary per burst of system changes (at most one message every 4 s, names first, “and 2 more” beyond three), never for their own typing, since changes inside a unit they touched within grace ms are ignored by design. Acknowledgement works for every input: keyboard focus inside a unit acknowledges it immediately, as does hovering it for 350 ms or keeping it on screen for linger ms; acknowledge() lets the app offer an explicit “Mark all as seen”. Markers pair color with a text label and an icon, and use Highlight in forced colors. Reduced motion (prefers-reduced-motion or data-motion="reduce"): no ghost and no stutter, the unit simply gets its static outline and marker. Performance: one MutationObserver whose callback does no layout reads (text and cached lookups only); records are batched per animation frame, where every rect is read before any overlay is written; the relative-time clock ticks once per second only while markers exist and the tab is visible; everything is released when the element is removed. Password fields are never read.

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