独家

While Away <mv-while-away>

“While you were away…”:包裹任意区域(仪表盘、信息流、看板),察觉你离开期间(标签页隐藏或闲置)发生的变化,并在 3 秒内帮你重新进入状态。

工作原理

“While you were away…”:包裹任何区域(仪表盘、信息流、看板),察觉你在别处时(标签页隐藏或闲置)发生了哪些变化,并在 3 秒内帮你重新进入状态。回来时,一张卡片按层级汇总离开期间的变化(阻塞项、决策、更新,各带图标和标签),每一条都会引导到它所涉及的元素;“Replay (3s)” 会按顺序回放这些变化:旧内容在原位重新出现,然后伴随轻柔的扫光和 “42 minutes ago → now” 标签融入当前内容,屏幕外的元素则会显示边缘箭头。“Unseen” 标记会一直留在每个元素上,直到它真正被看过为止。

分类反馈
类型Web Component(<mv-while-away>)
状态稳定版
套件诚实的系统状态
Keywordsexclusive, feedback, resumption, catch-up, absence, idle, visibility, notification, tiered-notifications, unseen, changes, diff, replay, activity, dashboard, mutation-observer

When to use

  • A dashboard, feed or board changes in real time and returning users need a summary of what changed
  • Changes must be ranked as blockers, decisions or updates, each linking to the element it concerns
  • Changed elements should keep an unseen marker until the user has actually looked at them

Avoid when

  • Each change should be reported immediately as it happens → use Toast instead
  • The region is static content that rarely changes; there is nothing to catch up on
  • The region updates many times per second, like tickers; exclude those parts or the summary gets noisy

安装

node scripts/add.mjs while-away --out ./src/marvelous

使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["while-away"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

复制的文件(含依赖):tokens/tokens.css、core/base.css、core/dom.js、core/element.js、core/motion.js、core/observe.js、components/while-away/while-away.js、components/while-away/while-away.css。

用法

快速开始,最小可运行标记:

<mv-while-away>
  <ul><li data-away-label="Deploy #214" data-away-tier="blocker">Status: <b data-away-key>Running</b></li></ul>
</mv-while-away>

标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:

<div id="wa-demo" style="width:min(100%,58rem);margin-inline:auto">
  <style>
    #wa-demo .wa-top { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; margin:0 0 1rem }
    #wa-demo .wa-top h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
    #wa-demo .wa-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #wa-demo .wa-sim { display:flex; align-items:center; gap:.625rem; flex-wrap:wrap }
    #wa-demo .wa-status { color:var(--mv-fg-muted); font-size:.75rem }
    #wa-demo mv-while-away[data-away] .wa-grid { filter:saturate(.4) opacity(.72); transition:filter .3s }
    #wa-demo .wa-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.875rem; transition:filter .3s }
    #wa-demo .wa-card { min-width:0; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #wa-demo .wa-card h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0 0 .75rem; font-size:.8125rem; font-weight:600 }
    #wa-demo .wa-card h4 small { color:var(--mv-fg-subtle); font-weight:500; font-size:.75rem }
    #wa-demo .wa-kpi .wa-k { color:var(--mv-fg-muted); font-size:.75rem }
    #wa-demo .wa-kpi strong { display:block; margin:.25rem 0 .125rem; font-size:1.625rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
    #wa-demo .wa-trend { font-size:.75rem; color:var(--mv-fg-muted) }
    #wa-demo .wa-trend[data-dir="up"] { color:color-mix(in oklab, var(--mv-success), var(--mv-fg) 30%) }
    #wa-demo .wa-tasks { grid-column:span 2 }
    #wa-demo .wa-side { display:grid; gap:.875rem; align-content:start }
    #wa-demo ul { list-style:none; margin:0; padding:0 }
    #wa-demo .wa-task { display:grid; grid-template-columns:minmax(0,1fr) auto auto; align-items:center; gap:.75rem; padding:.625rem .5rem; border-radius:var(--mv-radius-md) }
    #wa-demo .wa-task + .wa-task { border-top:1px solid var(--mv-border) }
    #wa-demo .wa-task b { display:block; font-weight:500; font-size:.875rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #wa-demo .wa-task span.wa-meta { color:var(--mv-fg-subtle); font-size:.75rem }
    #wa-demo .wa-build .wa-row { display:flex; align-items:center; justify-content:space-between; gap:.5rem; font-size:.8125rem }
    #wa-demo .wa-bar { height:6px; margin:.625rem 0 .5rem; border-radius:3px; background:var(--mv-bg-muted); overflow:hidden }
    #wa-demo .wa-bar i { display:block; height:100%; width:var(--p); border-radius:inherit; background:var(--mv-accent); transition:width .4s }
    #wa-demo .wa-build[data-state="success"] .wa-bar i { background:var(--mv-success) }
    #wa-demo .wa-build .wa-sub { color:var(--mv-fg-muted); font-size:.75rem }
    #wa-demo .wa-build .mv-button { margin-top:.75rem; width:100% }
    #wa-demo .wa-comment { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.625rem; padding:.5rem 0 }
    #wa-demo .wa-comment + .wa-comment { border-top:1px solid var(--mv-border) }
    #wa-demo .wa-comment p { margin:.125rem 0 0; font-size:.8125rem; line-height:1.45 }
    #wa-demo .wa-comment .wa-who { font-size:.75rem; font-weight:600 }
    #wa-demo .wa-comment .wa-who span { color:var(--mv-fg-subtle); font-weight:400 }
    #wa-demo .wa-hint { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
    @media (max-width:46rem) {
      #wa-demo .wa-grid { grid-template-columns:minmax(0,1fr) }
      #wa-demo .wa-tasks { grid-column:auto }
    }
  </style>

  <div class="wa-top">
    <div>
      <h3>Website redesign · Sprint 14</h3>
      <p>Team dashboard · Brightfold Studio</p>
    </div>
    <div class="wa-sim">
      <span class="wa-status" id="wa-status" aria-live="polite"></span>
      <button class="mv-button" data-variant="outline" data-size="sm" type="button" id="wa-simulate">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
        Simulate a 45-min absence
      </button>
    </div>
  </div>

  <mv-while-away id="wa-dash" threshold="5000">
    <div class="wa-grid">
      <article class="wa-card wa-kpi" data-away-label="Conversion rate">
        <span class="wa-k">Conversion rate</span>
        <strong data-f="conv" data-away-key>3.2%</strong>
        <span class="wa-trend" data-f="conv-trend">flat over 7 days</span>
      </article>
      <article class="wa-card wa-kpi" data-away-label="Live visitors">
        <span class="wa-k">Live visitors</span>
        <strong data-f="live" data-away-key>128</strong>
        <span class="wa-trend" data-f="live-trend">peaked at 212 yesterday</span>
      </article>
      <article class="wa-card wa-kpi" data-away-label="Sprint budget">
        <span class="wa-k">Sprint budget</span>
        <strong>64%</strong>
        <span class="wa-trend">$18,400 of $28,750</span>
      </article>

      <section class="wa-card wa-tasks" data-away-label="Team tasks" aria-labelledby="wa-tasks-h">
        <h4 id="wa-tasks-h">Team tasks <small>Sprint 14 · 5 tasks</small></h4>
        <ul>
          <li class="wa-task" data-away-label="Homepage mockups" data-t="home">
            <div><b>Homepage mockups</b><span class="wa-meta">Design file · 12 screens</span></div>
            <span class="mv-avatar" data-size="xs" data-variant="tinted" role="img" aria-label="Lena Moore"><span class="mv-avatar-fallback" aria-hidden="true">LM</span></span>
            <span class="mv-badge" data-variant="warning" data-shape="pill"><span class="mv-badge-dot"></span><span data-f="status" data-away-key>In review</span></span>
          </li>
          <li class="wa-task" data-away-label="Heading typography" data-t="type">
            <div><b>Heading typography</b><span class="wa-meta">2 typefaces in testing</span></div>
            <span class="mv-avatar" data-size="xs" data-variant="tinted" role="img" aria-label="Kwame Boateng"><span class="mv-avatar-fallback" aria-hidden="true">KB</span></span>
            <span class="mv-badge" data-variant="info" data-shape="pill"><span class="mv-badge-dot"></span><span data-f="status" data-away-key>In progress</span></span>
          </li>
          <li class="wa-task" data-away-label="End-to-end tests" data-t="e2e">
            <div><b>End-to-end tests</b><span class="wa-meta">Checkout flow</span></div>
            <span class="mv-avatar" data-size="xs" data-variant="tinted" role="img" aria-label="Sofia Nguyen"><span class="mv-avatar-fallback" aria-hidden="true">SN</span></span>
            <span class="mv-badge" data-variant="secondary" data-shape="pill"><span class="mv-badge-dot"></span><span data-f="status" data-away-key>To do</span></span>
          </li>
          <li class="wa-task" data-away-label="CMS migration">
            <div><b>CMS migration</b><span class="wa-meta">148 pages imported</span></div>
            <span class="mv-avatar" data-size="xs" data-variant="tinted" role="img" aria-label="Isabel Moreno"><span class="mv-avatar-fallback" aria-hidden="true">IM</span></span>
            <span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot"></span>Done</span>
          </li>
          <li class="wa-task" data-away-label="Accessibility audit">
            <div><b>Accessibility audit</b><span class="wa-meta">WCAG 2.2 · 86 criteria</span></div>
            <span class="mv-avatar" data-size="xs" data-variant="tinted" role="img" aria-label="Tom Grant"><span class="mv-avatar-fallback" aria-hidden="true">TG</span></span>
            <span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot"></span>Done</span>
          </li>
        </ul>
      </section>

      <div class="wa-side">
        <section class="wa-card wa-build" data-away-label="Production build" data-state="running" aria-labelledby="wa-build-h">
          <h4 id="wa-build-h">Production build <small data-f="build-id" data-away-key>#482</small></h4>
          <div class="wa-row"><span data-f="build-state" data-away-key>Running · step 3 of 5</span><span data-f="build-pct">68%</span></div>
          <div class="wa-bar"><i data-f="build-bar" style="--p:68%"></i></div>
          <span class="wa-sub" data-f="build-sub">Started by Sofia · main branch</span>
          <button class="mv-button" data-size="sm" type="button" id="wa-deploy" hidden>Approve deployment</button>
        </section>

        <section class="wa-card" data-away-label="Discussion" aria-labelledby="wa-talk-h">
          <h4 id="wa-talk-h">Discussion <small data-f="talk-count">2 messages</small></h4>
          <ul data-f="thread" data-away-key>
            <li class="wa-comment">
              <span class="mv-avatar" data-size="xs" data-variant="tinted" aria-hidden="true"><span class="mv-avatar-fallback">LM</span></span>
              <div><span class="wa-who">Lena Moore <span>· 9:12 AM</span></span><p>The mockups are ready for review.</p></div>
            </li>
            <li class="wa-comment">
              <span class="mv-avatar" data-size="xs" data-variant="tinted" aria-hidden="true"><span class="mv-avatar-fallback">KB</span></span>
              <div><span class="wa-who">Kwame Boateng <span>· 9:40 AM</span></span><p>Still comparing two typefaces for the headings.</p></div>
            </li>
          </ul>
        </section>
      </div>
    </div>
  </mv-while-away>
  <p class="wa-hint">Leave this tab for more than 5 seconds: the team keeps working while you're gone · markers fade once each item has been seen</p>

  <script type="module">
    const wa = document.getElementById("wa-dash");
    await customElements.whenDefined("mv-while-away");
    const btn = document.getElementById("wa-simulate");
    const status = document.getElementById("wa-status");
    const $ = (sel, root = wa) => root.querySelector(sel);
    const f = (name, root = wa) => $(`[data-f="${name}"]`, root);
    const task = (t) => $(`[data-t="${t}"]`);
    const setTask = (t, text, variant, tier) => {
      const li = task(t);
      f("status", li).textContent = text;
      li.querySelector(".mv-badge").dataset.variant = variant;
      if (tier) li.dataset.awayTier = tier; else delete li.dataset.awayTier;
    };
    const build = $(".wa-build");
    const talk = $("[data-away-label='Discussion']");
    const setBuild = ({ id, state, label, pct, sub, deploy }) => {
      build.dataset.state = state;
      if (state === "success") build.dataset.awayTier = "done"; else delete build.dataset.awayTier;
      f("build-id").textContent = id;
      f("build-state").textContent = label;
      f("build-pct").textContent = `${pct}%`;
      f("build-bar").style.setProperty("--p", `${pct}%`);
      f("build-sub").textContent = sub;
      document.getElementById("wa-deploy").hidden = !deploy;
    };
    const comment = (initials, who, time, text) => {
      const li = document.createElement("li");
      li.className = "wa-comment";
      const av = document.createElement("span");
      av.className = "mv-avatar"; av.dataset.size = "xs"; av.dataset.variant = "tinted"; av.setAttribute("aria-hidden", "true");
      const fb = document.createElement("span"); fb.className = "mv-avatar-fallback"; fb.textContent = initials; av.append(fb);
      const body = document.createElement("div");
      const w = document.createElement("span"); w.className = "wa-who"; w.append(who, Object.assign(document.createElement("span"), { textContent: ` · ${time}` }));
      const p = document.createElement("p"); p.textContent = text;
      body.append(w, p); li.append(av, body);
      return li;
    };
    const thread = f("thread");
    const initialComments = thread.children.length;

    // Back to the "before" dashboard (not observed: nobody is away).
    const reset = () => {
      f("conv").textContent = "3.2%"; f("conv-trend").textContent = "flat over 7 days"; delete f("conv-trend").dataset.dir;
      f("live").textContent = "128"; f("live-trend").textContent = "peaked at 212 yesterday";
      setTask("home", "In review", "warning"); setTask("type", "In progress", "info"); setTask("e2e", "To do", "secondary");
      setBuild({ id: "#482", state: "running", label: "Running · step 3 of 5", pct: 68, sub: "Started by Sofia · main branch", deploy: false });
      while (thread.children.length > initialComments) thread.lastElementChild.remove();
      f("talk-count").textContent = "2 messages";
      delete talk.dataset.awayTier;
    };

    // What happens on screen while you are gone.
    const script = [
      () => setBuild({ id: "#483", state: "success", label: "Passed · 5 steps", pct: 100, sub: "4 min 12 s · 312 tests passed", deploy: true }),
      () => setTask("home", "Done", "success", "done"),
      () => { f("conv").textContent = "3.6%"; const t = f("conv-trend"); t.textContent = "+0.4 pt since this morning"; t.dataset.dir = "up"; },
      () => { thread.append(comment("IM", "Isabel Moreno", "10:26 AM", "We need to pick one of the two logos before Thursday. Do you prefer option A?")); f("talk-count").textContent = "3 messages"; talk.dataset.awayTier = "decision"; },
      () => setTask("type", "Needs approval", "warning", "decision"),
      () => wa.note({ label: "Deployment is waiting for your approval", detail: "Build #483 is ready for production", tier: "blocker", target: build }),
      () => setTask("e2e", "In progress", "info"),
      () => { f("live").textContent = "342"; f("live-trend").textContent = "new daily peak"; },
    ];

    let running = false;
    const simulate = async (gap) => {
      if (running) return;
      running = true;
      btn.disabled = true;
      wa.markSeen();
      reset();
      wa.away({ since: Date.now() - 45 * 60000 });
      status.textContent = gap ? "You're away… the team keeps going without you" : "";
      for (const step of script) { step(); await new Promise((r) => setTimeout(r, gap)); }
      wa.back();
      status.textContent = "";
      btn.disabled = false;
      running = false;
    };
    btn.addEventListener("click", () => simulate(320));

    // Real tab switch: the team keeps going while the page is hidden.
    wa.addEventListener("mv-away", (e) => {
      if (e.detail.reason !== "hidden") return;
      setTimeout(() => {
        if (!wa.isAway) return;
        f("live").textContent = String(300 + Math.round(Math.random() * 90));
        f("live-trend").textContent = "updated just now";
      }, 1200);
      setTimeout(() => {
        if (!wa.isAway) return;
        const now = new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
        thread.append(comment("SN", "Sofia Nguyen", now, "End-to-end tests are passing on staging."));
        f("talk-count").textContent = `${thread.children.length} messages`;
        wa.note({ label: "Sofia needs your go-ahead to ship to staging", tier: "decision", target: talk });
      }, 2400);
    });
    wa.addEventListener("mv-return", (e) => console.info("mv-return", `${Math.round(e.detail.duration / 1000)}s`, e.detail.changes.map((c) => `${c.tier}: ${c.label}`)));
    document.getElementById("wa-deploy").addEventListener("click", () => {
      setBuild({ id: "#483", state: "success", label: "Deployment started", pct: 100, sub: "Rolling out to production", deploy: false });
      const blocker = wa.changes.find((c) => c.tier === "blocker");
      if (blocker) wa.markSeen(blocker);
    });

    // First sight: you just got back from a 45-minute meeting.
    simulate(40);
  </script>
</div>

API

Attributes

Name类型DefaultDescription
idlenumber (ms)60000Inactivity (no pointer, keyboard, scroll or focus) after which the user counts as away. 0 turns idle detection off (a hidden tab is still detected). Watching starts quietly after 10s of calm so nothing is missed, and is dropped at the first gesture.
thresholdnumber (ms)20000Minimum length of an absence for it to count: below it, changes are ignored (notes are kept).
followbooleanfalseDuring the replay, scrolls to each off-screen element instead of showing an edge arrow.
observe-attributesstringdata-state, data-status, aria-*, value, hidden, src, datetimeWatched attributes (comma- or space-separated). “aria-*” covers the common ARIA states; add “class” explicitly. Text and added/removed nodes are always watched.
labelstringWhile you were awayTitle of the return card.
dwellnumber (ms)1000Time on screen (at least 60% of the element, or half a screen of it) before an element counts as seen. Counting starts 1.5s after the return.
localeBCP 47 tagen-USLocale of the relative times (“17 minutes ago”, “now”) and of the absence length (Intl). The other texts stay English; label renames the card.
data-away-labelstringOn an element inside the region: makes it a “change unit” with this label. Otherwise: the nearest ancestor among li, tr, article, figure, fieldset or a titled section (label: its heading, aria-label or the start of its text).
data-away-tierblocker | decision | done | infoTier of the changes detected in this element, read at return time (the app can set it along with the change). Default: info.
data-away-keybooleanMarks the part(s) whose text sums up the unit (a status, a value): the “before → after” uses only them.
data-away-ignorebooleanSubtree that is never observed (clocks, decorative counters…).

Properties

Name类型Description
changes{ id, kind, label, tier, time, seen, element, before, after }[]Pending changes, sorted by importance then chronologically (read-only). before / after: summarized text before and after.
isAwaybooleanTrue during a confirmed absence.

Methods

NameDescription
away({ since? })Starts an absence now (snapshot of the region, observation). since backdates the start: change times are then spread over the simulated span (demos, tests).
back()Ends the absence and shows the card if anything changed. Returns the new changes.
note({ label, tier?, target?, detail?, time? })Semantic event pushed by the app (“Deployment is waiting for your approval”). tier: blocker | decision | done | info; target: element or selector the entry leads to. During an absence the note waits for the return; otherwise a blocker opens the card right away, other tiers join the open card or the next return.
replay()Replays the changes in ~3s (calling again stops it). Reduced motion: shows the before / after list.
markSeen(change?)Marks one change (object or id) as seen; with no argument, everything, and closes the card.

Events

NameDescription
mv-awayConfirmed absence. detail: { reason: "hidden" | "idle" | "manual", since }.
mv-returnReturn. detail: { duration, reason, changes }.
mv-seenChanges were seen (on screen, by click, by the replay or markSeen). detail: { changes, remaining }.

Content structure

NameDescription
(content)Any region. The card is inserted at the top (sticky: it stays visible in a tall region and folds to its header once stuck); markers and the replay are drawn in an overlay layer, without touching the app's DOM.

CSS classes

NameDescription
mv-while-away-cardReturn card (role=region): header, per-tier counts, entries, actions.
mv-while-away-entry / -tierEntry (button) and its tier chip (icon + label). data-tier, data-seen.
mv-while-away-mark / -dot“Unseen” halo and badge set on a changed element.
mv-while-away-flash / -stamp / -edge / -ghostReplay: halo and sweep, time tag, edge arrow, ghost of the old content.
mv-while-away-diffsBefore / after list (reduced motion).

CSS variables

NameDefaultDescription
--mv-while-away-offsetvar(--mv-space-3)Distance from the top of the screen when the card is stuck (height of a fixed header, for example).
--mv-while-away-bgvar(--mv-surface-raised)Card background.
--mv-while-away-blockervar(--mv-danger)Blocker tier color.
--mv-while-away-decisionvar(--mv-warning)Decision tier color.
--mv-while-away-donevar(--mv-success)Done tier color.
--mv-while-away-infovar(--mv-info)Update tier color.

Accessibility

The card is a named region (“While you were away”), announced through a polite live region (“While you were away (47 min): 1 blocker, 2 decisions, 5 updates.”); focus moves to it only after a tab switch, never after mere idling nor while you are typing. Entries are real buttons (role=list), sorted by importance, navigable with the arrows, Home and End; each one spells out the tier, the label, “Before: … Now: …”, its age and a “Seen / Unseen” state; activating it scrolls to the element, focuses it and marks it as seen. The replay is purely visual (aria-hidden) and announced when it starts and ends; Escape stops it. Markers, halos and arrows are aria-hidden, and a tier color is always paired with an icon and a label. Reduced motion (system or data-motion="reduce"): no animation, “Replay” becomes “Before / after” (aria-expanded) and opens the comparison list, markers are static. Performance: a single MutationObserver, active only during an absence, batched with no layout reads; the snapshot is cloned once at the start of the absence; one IntersectionObserver for “seen”; overlays recomputed at most once per frame (read then write); everything is released when the element is removed. Password fields are never copied. Forced colors: unseen halos, badges, replay flashes, edge arrows and the replay progress switch to outlines, borders and system colors. Edge arrows are placed against the viewport even inside a transformed ancestor (they then scroll with it during their 1.5 s). Known limits: the region is cloned once per absence, so its cost grows with the region (exclude busy parts with data-away-ignore); a field whose .value is changed by script without an input or change event is not detected; beyond label and locale, the card texts and announcements are English.

本页面由 AI 翻译。报告翻译问题