エクスクルーシブ

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 で翻訳されています。翻訳の問題を報告