익스클루시브

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로 번역되었습니다. 번역 문제 신고