Exclusifbêta
While Away <mv-while-away>
« While you were away… » : enveloppe n’importe quelle région (tableau de bord, fil d’actualité, tableau kanban), repère ce qui a changé pendant que vous étiez ailleurs (onglet masqué ou inactivité) et vous remet dans le contexte en 3 secondes. À votre retour, une carte résume l’absence par niveau (blocages, décisions, mises à jour, chacun avec une icône et un libellé), et chaque entrée mène à l’élément concerné ; « Replay (3s) » rejoue les changements dans l’ordre : l’ancien contenu réapparaît en place, puis se fond dans le contenu actuel avec un léger balayage et une étiquette « 42 minutes ago → now », tandis que les éléments hors écran reçoivent des flèches en bordure. Des marqueurs « Unseen » restent sur chaque élément jusqu’à ce qu’il ait réellement été regardé.
| Catégorie | Feedback |
|---|---|
| Type | Web Component (<mv-while-away>) |
| Statut | bêta |
| Kit | Des états système honnêtes |
| Keywords | exclusive, 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
Installation
node scripts/add.mjs while-away --out ./src/marvelousAgent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["while-away"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : 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.
Utilisation
Démarrage rapide, le balisage minimal qui fonctionne :
<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>Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables 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 · Nomad 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">Figma · 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 | Type | Default | Description |
|---|---|---|---|
idle | number (ms) | 60000 | Inactivity (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. |
threshold | number (ms) | 20000 | Minimum length of an absence for it to count: below it, changes are ignored (notes are kept). |
follow | boolean | false | During the replay, scrolls to each off-screen element instead of showing an edge arrow. |
observe-attributes | string | data-state, data-status, aria-*, value, hidden, src, datetime | Watched attributes (comma- or space-separated). “aria-*” covers the common ARIA states; add “class” explicitly. Text and added/removed nodes are always watched. |
label | string | While you were away | Title of the return card. |
dwell | number (ms) | 1000 | Time 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. |
data-away-label | string | On 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-tier | blocker | decision | done | info | Tier of the changes detected in this element, read at return time (the app can set it along with the change). Default: info. | |
data-away-key | boolean | Marks the part(s) whose text sums up the unit (a status, a value): the “before → after” uses only them. | |
data-away-ignore | boolean | Subtree that is never observed (clocks, decorative counters…). |
Properties
| Name | Type | 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. |
isAway | boolean | True during a confirmed absence. |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-away | Confirmed absence. detail: { reason: "hidden" | "idle" | "manual", since }. |
mv-return | Return. detail: { duration, reason, changes }. |
mv-seen | Changes were seen (on screen, by click, by the replay or markSeen). detail: { changes, remaining }. |
Content structure
| Name | Description |
|---|---|
(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
| Name | Description |
|---|---|
mv-while-away-card | Return card (role=region): header, per-tier counts, entries, actions. |
mv-while-away-entry / -tier | Entry (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 / -ghost | Replay: halo and sweep, time tag, edge arrow, ghost of the old content. |
mv-while-away-diffs | Before / after list (reduced motion). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-while-away-offset | var(--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-bg | var(--mv-surface-raised) | Card background. |
--mv-while-away-blocker | var(--mv-danger) | Blocker tier color. |
--mv-while-away-decision | var(--mv-warning) | Decision tier color. |
--mv-while-away-done | var(--mv-success) | Done tier color. |
--mv-while-away-info | var(--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.