익스클루시브
Delivery Trail <mv-delivery-trail>
중요한 메시지의 전달 과정을 단계별로 관찰할 수 있게 하고, 조용한 실패를 눈에 띄고 조치 가능한 정체 상태로 바꿉니다. 비밀번호 재설정 및 인증 이메일, SMS 코드, 청구서, 푸시 알림, 웹훅, 전자 서명 요청에 적합합니다.
문화적 레퍼런스
로미오와 줄리엣, 윌리엄 셰익스피어(1597년, 희곡). 계획을 설명하는 로렌스 수사의 편지는 전령이 격리되는 바람에 로미오에게 끝내 전달되지 않고, 전달이 실패했다는 사실을 아무도 모르기에 비극은 소리 없는 미도착에서 비롯됩니다. UI에서는 중요한 메시지의 모든 홉이 관찰되고 시간이 측정되며, 예산 시간을 넘긴 홉은 아무도 알아차리지 못하는 침묵 대신 대체 수단과 함께 원인이 설명된, 눈에 띄는 지연이 됩니다.
작동 방식
중요한 메시지의 전달 과정을 홉 단위로 관찰할 수 있게 하고, 조용한 실패를 눈에 띄고 조치 가능한 지연으로 바꿉니다: 비밀번호 재설정 및 인증 이메일, SMS 코드, 청구서, 푸시 알림, 웹훅, 전자 서명 요청. 각 메시지는 순서가 있는 경로(created → queued → sent → delivered → opened, 또는 직접 선언한 홉)이며, 타임스탬프, 각 홉에 걸린 시간, 제공업체 자체의 상태 텍스트를 함께 보여 줍니다. 모든 홉에는 시간 예산이 있습니다. 다음 홉을 기다리는 동안 그쪽으로 향하는 선이 예산 시간에 걸쳐 채워지고, 진행 없이 예산이 소진되면 경로는 바로 그 홉에 조용한 지연을 표시하고, 무슨 일이 일어났을 가능성이 높은지 쉬운 말로 설명하며(“Delivered to the mail server 12 min ago, not opened. It may be in spam or a promotions folder.”), 앱이 선언한 대체 수단(Resend, Send via SMS, Copy link, Call)을 waitUntil()을 지원하는 취소 가능한 mv-fallback 이벤트로 제공합니다. 반송은 사유를 원문 그대로 보여 주고, 제공업체의 재시도는 표시되며, 순서가 뒤바뀐 웹훅 이벤트는 조정되고, 늦게 도착한 항목에는 “over budget” 표시가 붙습니다. update(hop, status, { at, detail }) 또는 events 배열로 데이터를 공급합니다. 여러 메시지는 가장 나쁜 상태가 먼저 오는 간결한 목록이 되며(행이 미끄러지듯 제자리를 찾음), 행마다 미니 트랙과 합계가 표시됩니다. 타이밍은 절대 타임스탬프와 마감 타이머에서 계산되므로 백그라운드 탭에서도 어긋나거나 지연을 놓치는 일이 없습니다.
| 카테고리 | 데이터 표시 |
|---|---|
| 유형 | Web Component (<mv-delivery-trail>) |
| 상태 | 안정 |
| 키트 | 정직한 시스템 상태 |
| 함께 설치되는 항목 | button |
| Keywords | exclusive, culture, delivery, message-status, email, sms, webhook, push-notification, transactional-email, otp, verification, bounce, sla, timeout, stall, fallback, support-tool, observability, tracking, status |
When to use
- A support agent must see why a customer never got a reset email, invoice or code, and act on it without opening the provider's logs
- A sign-in or checkout flow sends a verification code and should offer SMS or a call when the email silently stalls
- An admin console lists outbound webhooks, SMS and emails and must surface failed or overdue deliveries first
- An e-signature or invoicing tool tracks sent, viewed and signed steps and must prompt a reminder when one takes too long
Avoid when
- The user moves through the steps themselves, such as a checkout or onboarding wizard → use Stepper instead
- The concern is a stream of events that stopped arriving altogether, not one message's hops → use Quiet Alarm instead
- The events are a finished history with no expected timing or pending step, where a plain list reads better
설치
node scripts/add.mjs delivery-trail --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["delivery-trail"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/delivery-trail/delivery-trail.js, components/delivery-trail/delivery-trail.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-delivery-trail label="Password reset email" recipient="[email protected]" channel="email"
fallbacks="resend: Resend email, sms: Send code via SMS"></mv-delivery-trail>
<!-- from your provider webhooks: trail.update("delivered") -->기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="dt-demo" style="width:min(100%,62rem);margin-inline:auto">
<style>
#dt-demo { display:grid; gap:1.5rem; align-content:start }
#dt-demo .dt-grid { display:grid; grid-template-columns:17rem minmax(0,1fr); gap:1rem; align-items:start }
#dt-demo .dt-card { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#dt-demo .dt-ticket { display:grid; gap:.875rem; padding:1rem 1.125rem }
#dt-demo .dt-k { margin:0; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.05em; text-transform:uppercase }
#dt-demo .dt-person { display:flex; align-items:center; gap:.75rem }
#dt-demo .dt-avatar { display:grid; place-items:center; width:2.5rem; height:2.5rem; flex:none; border-radius:50%; background:var(--mv-bg-emphasis); font-size:.8125rem; font-weight:600 }
#dt-demo .dt-person b { display:block; font-size:.9375rem; letter-spacing:-.01em }
#dt-demo .dt-person span { color:var(--mv-fg-muted); font-size:.8125rem }
#dt-demo .dt-quote { margin:0; padding:.625rem .75rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle); color:var(--mv-fg); font-size:.8125rem; line-height:1.5 }
#dt-demo .dt-facts { display:grid; grid-template-columns:auto 1fr; gap:.375rem .75rem; margin:0; font-size:.8125rem }
#dt-demo .dt-facts dt { color:var(--mv-fg-muted) }
#dt-demo .dt-facts dd { margin:0; text-align:end; font-variant-numeric:tabular-nums }
#dt-demo .dt-badges { display:flex; gap:.375rem; flex-wrap:wrap }
#dt-demo .dt-side { display:grid; gap:.75rem; min-width:0 }
#dt-demo .dt-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.75rem 1rem; border:1px dashed var(--mv-border-strong); border-radius:var(--mv-radius-lg) }
#dt-demo .dt-controls p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; flex:1 1 14rem }
#dt-demo .dt-actions { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap }
#dt-demo .mv-choice { font-size:.8125rem }
#dt-demo .dt-log { min-height:1.1rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); overflow-wrap:anywhere }
#dt-demo .dt-head { display:flex; align-items:end; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; margin:0 0 .75rem }
#dt-demo .dt-head h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#dt-demo .dt-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
@media (max-width:46rem) { #dt-demo .dt-grid { grid-template-columns:minmax(0,1fr) } }
</style>
<!-- 1. Support console: a password reset email that silently never got opened -->
<div class="dt-grid">
<aside class="dt-card dt-ticket" aria-label="Support ticket">
<p class="dt-k">Ticket #48213 · Sign-in</p>
<div class="dt-person">
<span class="dt-avatar" aria-hidden="true">AO</span>
<span><b>Amara Okafor</b><span>Halcyra Health · Admin</span></span>
</div>
<p class="dt-quote">“I asked for a password reset twice this morning and nothing ever came. I have a board meeting at noon.”</p>
<dl class="dt-facts">
<dt>Plan</dt><dd>Business</dd>
<dt>Last sign-in</dt><dd>Sep 20, 2026</dd>
<dt>Phone</dt><dd>+1 (415) 555-0192</dd>
</dl>
<div class="dt-badges">
<span class="mv-badge" data-variant="warning">High priority</span>
<span class="mv-badge" data-variant="secondary">Email</span>
</div>
</aside>
<div class="dt-side">
<mv-delivery-trail id="dt-main"
label="Password reset email"
recipient="[email protected]"
channel="email"
budgets="queued: 10s, sent: 30s, delivered: 2m, opened: 10m"
fallbacks="resend: Resend email, sms: Send code via SMS, copy: Copy reset link"
announce="all"></mv-delivery-trail>
<div class="dt-controls">
<p>Resend runs a live, accelerated delivery (budgets of a few seconds). Keep “Lands in spam” on to watch it stall again.</p>
<span class="dt-actions">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="dt-spam" checked> Lands in spam</label>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="dt-send">Send a new link</button>
</span>
</div>
<p class="dt-log" id="dt-log" aria-live="off">mv-stall · opened</p>
</div>
</div>
<!-- 2. Outbound messages: several deliveries, worst state first -->
<section aria-labelledby="dt-out-title">
<div class="dt-head">
<div>
<h3 id="dt-out-title">Outbound messages · last hour</h3>
<p>The push notification is on a 10-second budget: watch it stall and move up.</p>
</div>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="dt-replay">Replay</button>
</div>
<mv-delivery-trail id="dt-list"></mv-delivery-trail>
</section>
<script type="module">
const main = document.getElementById("dt-main");
const list = document.getElementById("dt-list");
const log = document.getElementById("dt-log");
const spam = document.getElementById("dt-spam");
const s = 1000, min = 60 * s;
const ago = (ms) => Date.now() - ms;
const say = (text) => { log.textContent = text; };
// ── 1. Password reset email: delivered 12 minutes ago, never opened (10 min budget) ──
main.events = [
{ hop: "created", at: ago(12 * min + 5.4 * s), detail: "Requested from the sign-in page" },
{ hop: "queued", at: ago(12 * min + 5.1 * s), detail: "job_7Hq2 · transactional" },
{ hop: "sent", at: ago(12 * min + 3.8 * s), detail: "Accepted by provider · msg_01J8ZK4F" },
{ hop: "delivered", at: ago(12 * min), detail: "250 2.0.0 OK · mx1.example.com" },
];
let run = [];
const clearRun = () => { run.forEach(clearTimeout); run = []; };
const later = (ms, fn) => run.push(setTimeout(fn, ms));
// A live, accelerated send: each hop lands after a short delay; spam = never opened.
function liveSend() {
clearRun();
main.budgets = "queued: 2s, sent: 3s, delivered: 4s, opened: 6s";
main.reset(undefined, { start: true, detail: "Resent by support · Daniel Park" });
later(350, () => main.update("queued", "done", { detail: "job_9Kc4 · transactional" }));
later(1100, () => main.update("sent", "done", { detail: "Accepted by provider · msg_01J8ZQ2M" }));
later(2600, () => main.update("delivered", "done", { detail: "250 2.0.0 OK · mx1.example.com" }));
if (!spam.checked) later(4800, () => main.update("opened", "done", { detail: "Opened on mobile · Mail app" }));
}
main.addEventListener("mv-fallback", (e) => {
const { action } = e.detail;
say(`mv-fallback · ${action}`);
if (action === "resend") e.detail.waitUntil(new Promise((r) => setTimeout(r, 500)).then(liveSend));
if (action === "sms") e.detail.waitUntil(new Promise((r) => setTimeout(r, 700)).then(() => say("mv-fallback · sms → code sent to +1 (415) •••-0192")));
});
main.addEventListener("mv-stall", (e) => say(`mv-stall · ${e.detail.hop}`));
main.addEventListener("mv-recover", (e) => say(`mv-recover · ${e.detail.hop}`));
main.addEventListener("mv-complete", () => say("mv-complete"));
document.getElementById("dt-send").addEventListener("click", liveSend);
// ── 2. Outbound messages ──
const at = (list) => list.map(([hop, t, detail, status]) => ({ hop, at: ago(t), detail, status }));
const messages = () => [
{
id: "push-rider", label: "“Your driver is 2 minutes away”", recipient: "Push · Rider app · Sofia Reyes", channel: "push",
hops: ["created", "sent", "delivered", "opened"], budgets: { sent: "5s", delivered: "10s", opened: "5m" },
fallbacks: [{ action: "sms", label: "Send as SMS" }],
events: at([["created", 2.4 * s, "trip_5521 · driver_eta"], ["sent", 2 * s, "Push gateway · msg_0:1727"]]),
},
{
id: "invoice", label: "Invoice INV-2048 · $17,400.00", recipient: "[email protected]", channel: "invoice",
hops: ["created", "queued", "sent", "delivered", "opened"],
fallbacks: [{ action: "edit", label: "Fix the address" }, { action: "copy", label: "Copy payment link", copy: "https://pay.example.com/i/INV-2048" }],
events: at([
["created", 22 * min, "Issued by Priya Nair"], ["queued", 22 * min - 0.4 * s], ["sent", 22 * min - 1.6 * s, "msg_01J8XR3T"],
["delivered", 21 * min, "550 5.1.1 <[email protected]>: Recipient address rejected: User unknown", "failed"],
]),
},
{
id: "otp", label: "Sign-in code", recipient: "+44 7700 900123 · Oliver Hughes", channel: "sms",
hops: ["created", "sent", "delivered", "confirmed"], budgets: { delivered: "1m", confirmed: "5m" },
fallbacks: [{ action: "resend", label: "Resend code" }, { action: "call", label: "Call with code", href: "tel:+447700900123" }],
events: at([["created", 3 * min + 2 * s], ["sent", 3 * min, "Carrier: Northline · SM1f3a9c"]]),
},
{
id: "hook", label: "Webhook · order.paid", recipient: "https://api.example.com/hooks/orders", channel: "webhook",
hops: ["created", "sent", "delivered"], budgets: { delivered: "5m" },
fallbacks: [{ action: "retry", label: "Retry now" }],
events: at([["created", 48 * s, "evt_3PqK81"], ["sent", 47 * s], ["delivered", 17 * s, "HTTP 503 Service Unavailable · retry 2 of 5 in 30 s", "retrying"]]),
},
{
id: "esign", label: "Master services agreement", recipient: "Li Wei · Kestrel Logistics", channel: "esign",
hops: ["created", "sent", "delivered", "viewed", "signed"],
events: at([["created", 41 * min], ["sent", 41 * min - 1 * s], ["delivered", 41 * min - 4 * s], ["viewed", 18 * min, "Web browser · Singapore"], ["signed", 6 * min, "Signature ID sig_92KD"]]),
},
{
id: "magic", label: "Magic sign-in link", recipient: "[email protected]", channel: "email",
events: at([["created", 95 * s], ["queued", 94.8 * s], ["sent", 93 * s], ["delivered", 90 * s, "250 OK · mx1.example.com"], ["opened", 60 * s, "Webmail · desktop"]]),
},
];
list.messages = messages();
document.getElementById("dt-replay").addEventListener("click", () => { list.messages = messages(); });
list.addEventListener("mv-fallback", (e) => {
if (e.detail.action === "retry") {
e.detail.waitUntil(new Promise((r) => setTimeout(r, 600)).then(() => list.update("delivered", "done", { message: "hook", detail: "HTTP 200 OK · 184 ms" })));
}
if (e.detail.action === "resend") {
e.detail.waitUntil(new Promise((r) => setTimeout(r, 600)).then(() => {
list.reset("otp", { start: true });
setTimeout(() => list.update("sent", "done", { message: "otp", detail: "Carrier: Northline · SM1f40d2" }), 500);
setTimeout(() => list.update("delivered", "done", { message: "otp", detail: "DELIVRD · handset" }), 1600);
}));
}
if (e.detail.action === "sms") {
e.detail.waitUntil(new Promise((r) => setTimeout(r, 600)));
}
});
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
hops | comma list | created, queued, sent, delivered, opened | Hops of every message that doesn't declare its own (message.hops). Known ids get English labels and stall wording (created, queued, accepted, sent, delivered, opened, clicked, read, viewed, confirmed, signed, paid, processed); any other id is shown capitalized. |
budgets | "hop: time, …" ("queued: 10s, delivered: 2m, opened: 10m") | queued 30s · accepted 1m · sent 2m · delivered 5m · opened 30m | Time allowed between the previous hop and this one before it is a silent stall. Times accept ms, s, m/min, h, d or a bare number of ms. Per-message budgets and per-hop budget override it; hops without any budget never stall. |
fallbacks | "action: Label, …" | Fallback buttons offered during a stall or failure for messages that don't declare their own (message.fallbacks). Known actions get an icon: resend/retry, sms/text, copy/link, call/phone, email, push. | |
label / recipient / channel | string | Shorthand for a single message (id "default") when you don't set messages. channel (email, sms, push, webhook, esign, invoice, call or any string) picks the icon and channel-specific wording ("Delivered to the mail server", "The phone may be off"). | |
layout | auto | trail | list | auto | trail: every message fully expanded; list: compact rows (disclosure buttons) with mini tracks and totals; auto: trail for one message, list for several. |
announce | problems | all | none | problems | What the polite live region says: stalls, failures and late recoveries (problems), plus every hop reached and completion (all), or nothing. |
locale | BCP 47 | en-US | Locale of absolute timestamps (10:42:07 AM; month and day are added when not today). |
data-state / data-layout | failed | stalled | waiting | idle | complete | Set by the component on the host (worst state) and on each .mv-delivery-trail-item (that message's state). |
Properties
| Name | 유형 | Description |
|---|---|---|
messages | Array<{ id, label, recipient, channel, hops?: (string | { id, label?, budget? })[], budgets?: Record<hop, time> | string, fallbacks?: Fallback[] | string, hints?: Record<hop, string>, events?: Event[] }> | Replace every message (read: snapshots). Messages with the same id keep their open state and don't re-emit stalls already reported. A Fallback is { action, label, when?: "problem" | "stall" | "failed", href? (rendered as a link: tel:, mailto:, https:), copy? (text copied to the clipboard), icon?, variant?, done? (label shown after success) }. |
events | Array<{ message?, hop, status?, at?, detail? }> | Apply a batch of events in order (same rules as update()). Replaying events already applied is harmless. Read: the last 500 events applied. |
state | "failed" | "stalled" | "waiting" | "idle" | "complete" | Worst state across messages (read-only). |
strings | Partial<Record<string, string>> & { hops?, past?, missing?, hints? } | Overrides for every visible text and announcement. hops = hop labels, past = how a reached hop is phrased in a stall ("email:delivered": "Delivered to the mail server"), missing = how the awaited hop is phrased ("not opened"), hints = the probable cause ("email:opened": "It may be in spam…"); keys can be "hop" or "channel:hop". Other keys: stallText ({past} {ago}, {missing}. {hint}), stallTitle, failTitle, failText, state*, step*, note*, waiting, expected, late, totals*, fallback*, announce*. English defaults. |
hops / budgets / fallbacks / label / recipient / channel / layout / announce / locale | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
update(hop, status = "done", { at?, detail?, message? }) | Record provider progress. status: done | failed (bounce, error: detail is shown as the reason) | retrying (deferred, soft bounce: marked on the awaited hop, the budget keeps running) | pending (undo). at: Date, epoch ms or ISO string (default now). A later hop marks earlier ones as reached (out-of-order webhooks). message defaults to the only message. Also accepts one object { message, hop, status, at, detail }. Returns true if applied. |
addMessage(message) / removeMessage(id) | Add (or replace by id) one message; remove one. addMessage returns the id. |
reset(id?, { start?, at?, detail? }) | Clear a message's progress (before a resend); start: true marks its first hop done now (or at). |
get(id?) | Snapshot { id, label, recipient, channel, state, hop, elapsed, budget, overdue, hops[] } of one message. |
Events
| Name | Description |
|---|---|
mv-stall | A hop exceeded its budget without progress (once per hop and attempt). detail: { message, hop, elapsed, budget, since, initial } (initial: already stalled when the data was loaded, not announced). |
mv-fallback | Cancelable. A fallback button was pressed. detail: { message, action, hop, state, fallback, waitUntil(promise) }. waitUntil shows a busy button, then a brief check (or an error, announced). preventDefault() cancels the default behavior (link navigation, clipboard copy). |
mv-fail | A hop failed. detail: { message, hop, detail, at, initial }. |
mv-recover | A stalled hop finally arrived. detail: { message, hop, late (ms past the budget) }. |
mv-progress | A hop changed status through update() or events. detail: { message, hop, status, at, detail }. |
mv-complete | Every hop of a message is reached. detail: { message }. |
Content structure
| Name | Description |
|---|---|
(JSON) | An optional child <script type="application/json"> holding an array of messages or { messages, events } is read on first connection (server-rendered pages). |
CSS classes
| Name | Description |
|---|---|
mv-delivery-trail-item | One message (<li>, data-state). Contains -head (a disclosure <button class="mv-delivery-trail-summary"> in list layout): -icon, -label, -recipient, -track/-tick (mini track, aria-hidden), -pill, -note, -chevron; then -body. |
mv-delivery-trail-steps / -step | The ordered list of hops; each step has data-status (done | waiting | stalled | failed | pending | skipped), data-rail (line to the next hop: done | flow | stall | fail | idle | end), data-late, aria-current on the awaited hop. Parts: -marker, -rail/-rail-fill, -name, -word, -time, -delta, -meta, -detail. |
mv-delivery-trail-callout | Stall or failure box inside the problem hop (data-tone): -callout-title, -callout-text, -callout-reason (bounce text), -actions with .mv-delivery-trail-fallback buttons (data-action, data-state pending | done | error). |
mv-delivery-trail-totals | Counts line above the list layout (-total, -count[data-tone]). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-delivery-trail-ok | var(--mv-success) | Reached hops and complete messages. |
--mv-delivery-trail-active | var(--mv-accent) | The awaited hop and the line filling toward it. |
--mv-delivery-trail-stall | var(--mv-warning) | Silent stalls, retries and late arrivals. |
--mv-delivery-trail-fail | var(--mv-danger) | Failures and bounces. |
--mv-delivery-trail-stall-fg | the stall color mixed 78% toward near-black | Text and icons on the solid stall fill (Stalled word, stalled marker). |
--mv-delivery-trail-fail-fg | white in light theme, a near-black red in dark theme | Text and icons on the solid fail fill (Failed word, failed marker). Set it when you change --mv-delivery-trail-fail, to keep 4.5:1. |
Accessibility
Each message's hops are a real ordered list (<ol>) and every step carries its status in words, not only color: a visually hidden “Step 3 of 5”, the hop name, a status word (visible for Waiting, Retrying, Stalled, Failed, Not reached; spoken only for Done and Pending, whose check or empty ring shows it), the timestamp in a <time datetime> element and the provider detail. The awaited, stalled or failed hop has aria-current="step". Stalls, failures and late recoveries are announced once in a polite live region (“Password reset email: stalled at Opened. Delivered to the mail server 12 min ago, not opened. It may be in spam or a promotions folder.”); announce="all" adds every hop reached, announce="none" silences it; data loaded already stalled is shown but not announced. The stall box is a labelled group; fallbacks are real <button>s (or <a> for tel:/mailto: links) whose visible label says the action and whose aria-describedby points at the stall explanation; while waitUntil() runs they are aria-busy and aria-disabled, and success or failure is announced. In the list layout each row is a disclosure <button> (aria-expanded, aria-controls) named from the label, recipient, state and note; the list keeps worst-first order, and focus is restored when rows move or a stall box disappears (to the row, or to the trail). Icons are decorative (aria-hidden); every tone comes with an icon and a word, and forced-colors mode draws markers, rails and callouts with system colors. Reduced motion (OS or data-motion="reduce"): no breathing dots, beacon or row sliding; the line toward the awaited hop steps once per second instead of flowing.