Exclusivobeta

Deja Vu <mv-deja-vu>

Señala, al instante y en su sitio, los cambios que el sistema hizo a espaldas del usuario, nunca los que provocó el propio usuario. Envuelve una región en vivo (feed, dashboard, tabla, formulario sincronizado con el servidor); cuando una unidad (fila, tarjeta, fieldset… o cualquier elemento marcado con data-deja-vu-unit) cambia y el usuario no ha apuntado, escrito ni pulsado una tecla dentro de ella durante los últimos grace ms, hace una doble toma: un fantasma del valor anterior cruza dos veces el nuevo mientras el nuevo valor repite su entrada, dos pulsos idénticos en ~600 ms. Después, una pequeña pestaña “Cambiado · hace 12 s” (“Nuevo” para las filas insertadas) y un contorno tenue permanecen en la unidad, con un tiempo relativo que se actualiza con bajo coste, hasta que la unidad recibe el puntero, el foco o lleva linger ms en pantalla. Los cambios se agrupan por fotograma, una unidad nunca repite su doble toma más de una vez por cooldown y la lista de cambios no confirmados se expone como changes (acknowledge() la vacía); un anuncio cortés opcional los resume (“3 valores cambiados: Ingresos de hoy, Estado del pedido n.º 4821, Conversión.”).

CategoríaFeedback
TipoWeb Component (<mv-deja-vu>)
Estadobeta
KitEstados del sistema honestos
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

Instalación

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

Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["deja-vu"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Archivos copiados (dependencias incluidas): 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.

Uso

Inicio rápido, el marcado mínimo que funciona:

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

Marcado de referencia: parte de él y personalízalo con atributos, data-* y 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>

Referencia cultural

Matrix, Lana y Lilly Wachowski (1999, película). Un déjà vu, el mismo gato negro que cruza dos veces una puerta, es la señal de que el sistema acaba de cambiar algo a tus espaldas. En la interfaz, un valor que el sistema cambió sin ti reproduce su transición dos veces en un rápido tartamudeo, para que los cambios silenciosos se noten, mientras que tus propias ediciones nunca lo activan.

API

Attributes

NameTipoDefaultDescription
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

NameTipoDescription
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.

Esta página se tradujo con IA. Informar de un problema de traducción