Exclusivobeta

Deja Vu <mv-deja-vu>

Sinaliza, na hora e no lugar, as mudanças que o sistema fez pelas costas do usuário, nunca as que o próprio usuário causou. Envolve uma região ao vivo (feed, dashboard, tabela, formulário sincronizado com o servidor); quando uma unidade (linha, card, fieldset… ou qualquer elemento marcado com data-deja-vu-unit) muda e o usuário não apontou, digitou nem pressionou uma tecla dentro dela nos últimos grace ms, ela faz uma dupla tomada: um fantasma do valor anterior cruza o novo duas vezes enquanto o novo valor repete sua entrada, dois pulsos idênticos em ~600 ms. Depois, uma pequena aba “Changed · 12s ago” (“New” para linhas inseridas) e um contorno discreto ficam na unidade, com um tempo relativo que se atualiza a baixo custo, até que a unidade receba o mouse, o foco ou fique linger ms na tela. As mudanças são agrupadas por quadro, uma unidade nunca repete sua dupla tomada mais de uma vez por cooldown, e a lista de mudanças não reconhecidas é exposta como changes (acknowledge() a esvazia); um anúncio polite opcional as resume (“3 values changed: Revenue today, Status of Order #4821, Conversion.”).

CategoriaFeedback
TipoWeb Component (<mv-deja-vu>)
Statusbeta
KitEstados do 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

Instalação

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

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

Arquivos copiados (dependências incluídas): 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

Início rápido, a menor marcação que funciona:

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

Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis 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>

Referência cultural

Matrix, Lana e Lilly Wachowski (1999, filme). Um déjà vu (o mesmo gato preto passando duas vezes por uma porta) é o sinal de que o sistema acabou de mudar algo pelas suas costas. Na interface, um valor que o sistema mudou sem você repete sua transição duas vezes em uma rápida gagueira, para que as mudanças silenciosas sejam notadas, enquanto as suas próprias edições nunca a disparam.

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 foi traduzida com IA. Informar um problema de tradução