익스클루시브
Cry Wolf <mv-cry-wolf>
알림 센터와 모니터링 콘솔을 위한 알림 피로 방지 장치: 단순히 알림을 표시하는 것이 아니라 어떤 알림이 사용자를 방해할 만한지 판단합니다.
문화적 레퍼런스
양치기 소년, 이솝(기원전 600년경, 우화). 양치기 소년이 거짓 경보를 너무 많이 울린 탓에, 정말로 늑대가 나타났을 때 마을 사람들은 더 이상 귀 기울이지 않습니다. UI에서는 각 알림 출처가 사람들의 반응에 따라 신뢰도를 얻거나 잃고, 시끄러운 출처는 조용히 요약에 보류되며, 긴급으로 표시된 알림은 출처의 이력과 함께 항상 전달됩니다.
작동 방식
알림 센터와 모니터링 콘솔을 위한 알림 피로 방지 장치: 단순히 알림을 표시하는 것이 아니라 어떤 알림이 사용자를 방해할 만한지 판단합니다. 모든 출처는 사용자가 그 알림으로 실제로 무엇을 하는지에 따라 신뢰도 점수를 얻습니다(조치를 취하면 올라가고, 조치 없이 닫거나 “Not useful”을 누르거나 똑같은 알림이 반복되면 내려가며, 오래된 근거는 반감기에 따라 희미해지므로 문제가 해결된 출처는 신뢰를 되찾습니다). 기준치 미만의 출처는 조용한 요약에 보류되고(“4 low-signal alerts from Disk monitor · Review”), 폭주하는 알림은 출처별로 속도가 제한되며 똑같은 알림은 카운터로 합쳐집니다(“×12 in 1 h”). 그래도 긴급으로 표시된 알림은 항상 전달되어 인라인으로 표시되고 assertive 방식으로 안내되며, 분류에 도움이 되도록 출처의 이력이 함께 표시됩니다. 신호 막대는 각 출처의 신뢰도를 보여 주고, Sources 패널은 출처별 신뢰도 미터와 Always show / Auto / Digest only 재정의 옵션을 제공하며, sources 속성은 팀이 시끄러운 알림을 고칠 수 있도록 통계를 노출하고, mv-demote로 앱이 모든 강등을 거부할 수 있으며, 학습 결과는 사용자별로 저장할 수 있습니다.
| 카테고리 | 피드백 |
|---|---|
| 유형 | Web Component (<mv-cry-wolf>) |
| 상태 | 안정 |
| 키트 | 알림 피로 없는 모니터링 |
| 함께 설치되는 항목 | button |
| Keywords | exclusive, culture, notifications, alerts, alert-fatigue, notification-center, digest, deduplication, rate-limit, monitoring, ops, incident, credibility, trust, signal-to-noise, triage, feedback, preferences |
When to use
- A notification center or ops console receives more alerts than people read, and they start ignoring all of them
- Some alert sources are known to be noisy but must stay connected, and the real emergencies from them must still get through
- A team wants numbers on which alert sources nobody acts on, to fix or retire them
- In-app announcements an일 system alerts compete for attention and eac시간 user should tune what interrupts the분
Avoid when
- A single transient confirmation or error must simply be displayed → use Toast instead
- A static callout inside the page (warning, maintenance notice) that is not a stream of alerts → use Alert instead
- Every alert is legally or operationally mandatory to show (safety, compliance): no learning should ever hold one back
설치
node scripts/add.mjs cry-wolf --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["cry-wolf"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/cry-wolf/cry-wolf.js, components/cry-wolf/cry-wolf.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-cry-wolf label="Operations alerts"></mv-cry-wolf>
<script type="module">
await customElements.whenDefined("mv-cry-wolf");
document.querySelector("mv-cry-wolf").push({ source: "disk", title: "Disk usage 91% on db-02", severity: "warning" });
</script>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="cw-demo" style="width:min(100%,66rem);margin-inline:auto">
<style>
#cw-demo .cw-layout { display:grid; grid-template-columns:minmax(0,1fr) 18.5rem; gap:1.25rem; align-items:start }
#cw-demo .cw-console { padding:1.125rem 1.125rem 1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg); box-shadow:var(--mv-shadow-sm) }
#cw-demo .cw-side { display:grid; gap:1rem }
#cw-demo .cw-card { display:grid; gap:.75rem; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#cw-demo .cw-card h4 { margin:0; font-size:.8125rem; font-weight:600 }
#cw-demo .cw-card p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.5 }
#cw-demo .cw-sim { display:grid; gap:.375rem }
#cw-demo .cw-sim .mv-button { justify-content:flex-start; width:100% }
#cw-demo .cw-sim .mv-button small { margin-inline-start:auto; color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:500 }
#cw-demo .cw-report { display:grid; gap:.5rem; margin:0; padding:0; list-style:none }
#cw-demo .cw-report li { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.125rem .5rem; align-items:center; font-size:.75rem }
#cw-demo .cw-report b { font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#cw-demo .cw-report .cw-bar { grid-column:1 / -1; height:4px; border-radius:2px; background:var(--mv-bg-muted); overflow:hidden }
#cw-demo .cw-report .cw-bar i { display:block; height:100%; width:calc(var(--v) * 100%); border-radius:inherit; background:var(--mv-fg-muted) }
#cw-demo .cw-report .cw-num { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
#cw-demo .cw-log { display:grid; gap:.25rem; margin:0; padding:0; list-style:none; font:.6875rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
#cw-demo .cw-log li { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#cw-demo .cw-log li:first-child { color:var(--mv-fg) }
#cw-demo .mv-choice { font-size:.8125rem }
@media (max-width:56rem) { #cw-demo .cw-layout { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="cw-layout">
<div class="cw-console">
<!-- Operations console: sources learn their credibility from what the on-call engineer does -->
<mv-cry-wolf id="cw-main" label="Operations alerts" rate-limit="2/30m" dedupe="1h"></mv-cry-wolf>
</div>
<aside class="cw-side" aria-label="Demo controls">
<div class="cw-card">
<h4>Simulate incoming alerts</h4>
<div class="cw-sim">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cw-noise">Disk warning <small>noisy source</small></button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cw-pay">Payments incident <small>trusted</small></button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cw-wolf">Critical disk alert <small>noisy + critical</small></button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cw-burst">Same build failure ×6 <small>folded</small></button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cw-sec">Admin sign-in <small>Always show</small></button>
</div>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="cw-persist"> Remember per user (this browser)</label>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="cw-reset">Reset demo</button>
</div>
<div class="cw-card">
<h4>Noise report <span class="mv-badge" data-variant="secondary" data-size="sm">sources</span></h4>
<p>From the <code>sources</code> property: fix the alerts nobody acts on.</p>
<ul class="cw-report" id="cw-report"></ul>
</div>
<div class="cw-card">
<h4>Events</h4>
<ul class="cw-log" id="cw-log" aria-live="off"><li>Waiting for alerts…</li></ul>
</div>
</aside>
</div>
<script type="module">
await customElements.whenDefined("mv-cry-wolf");
const cw = document.getElementById("cw-main");
const $ = (id) => document.getElementById(id);
const min = (n) => Date.now() - n * 60_000;
const SOURCES = [
{ id: "payments", label: "Payments API" },
{ id: "disk", label: "Disk monitor" },
{ id: "ci", label: "Build pipeline" },
{ id: "security", label: "Security scanner", trust: "always" },
{ id: "campaigns", label: "Campaign bot", trust: "digest" },
];
// Last week's reactions, as a server report would give them.
const HISTORY = {
version: 1,
sources: {
payments: { received: 16, acted: 11, dismissed: 3, notUseful: 0, record: "aadaaadaaada" },
disk: { received: 41, acted: 2, dismissed: 15, notUseful: 6, merged: 14, record: "ddndddnddadn" },
ci: { received: 22, acted: 6, dismissed: 5, notUseful: 1, record: "adaddaanddaa" },
security: { received: 8, acted: 3, dismissed: 4, notUseful: 1, record: "dadndad" },
campaigns: { received: 12, acted: 0, dismissed: 7, notUseful: 2, record: "ddnddddnd" },
},
};
const seed = () => {
cw.clear();
cw.sources = SOURCES;
if (!$("cw-persist").checked) cw.snapshot = HISTORY;
const backlog = [
{ source: "disk", title: "Disk usage 81% on web-03", severity: "warning", time: min(52) },
...[40, 31, 22, 12, 6].map((m) => ({ source: "disk", title: "Disk usage 81% on web-03", severity: "warning", time: min(m) })),
{ source: "ci", title: "Build #1287 failed on main", text: "3 tests failed in checkout-service after commit a41f9c2 by Priya Raman.", severity: "danger", time: min(28), actions: [{ label: "View logs", value: "logs" }] },
{ source: "campaigns", title: "Newsletter “Fall launch” reached a 38% open rate", severity: "success", time: min(35) },
{ source: "disk", title: "Inode usage 74% on cache-01", severity: "warning", time: min(30) },
{ source: "ci", title: "Build #1287 failed on main", severity: "danger", time: min(20) },
{ source: "campaigns", title: "A/B test “Hero copy” reached significance", severity: "info", time: min(20) },
{ source: "disk", title: "Swap above 60% on worker-07", severity: "warning", time: min(18) },
{ source: "ci", title: "Build #1287 failed on main", severity: "danger", time: min(11) },
{ source: "ci", title: "Flaky test quarantined: checkout.spec.ts", text: "Failed 4 of the last 20 runs. Owners: Payments team.", severity: "warning", time: min(12), actions: [{ label: "Open test", value: "test" }] },
{ source: "disk", title: "Log partition growing 4%/h on api-02", severity: "info", time: min(9) },
{ source: "ci", title: "Coverage dropped below 82% on main", severity: "info", time: min(8) },
{ source: "campaigns", title: "Unsubscribe rate +0.4% this week (within normal range)", severity: "info", time: min(6) },
{ source: "ci", title: "Dependency audit: 3 moderate advisories", severity: "warning", time: min(5) },
{ source: "payments", title: "Payout batch #4812 failed for 3 merchants", text: "The bank rejected the transfer file (code R03). $18,240.00 is on hold until the batch is retried.", severity: "warning", time: min(4), actions: [{ label: "Retry batch", value: "retry" }, { label: "View batch", value: "view" }] },
{ source: "ci", title: "Build #1287 failed on main", severity: "danger", time: min(3) },
{ source: "disk", title: "Database volume 97% full on db-primary-02", text: "Writes will start failing in about 20 minutes at the current rate.", severity: "danger", critical: true, time: min(1), actions: [{ label: "Expand volume", value: "expand" }, { label: "Open runbook", value: "runbook" }] },
];
cw.push(backlog, { silent: true });
report();
};
// Noise report built from the public stats.
const report = () => {
const list = $("cw-report");
list.replaceChildren(...cw.sources.map((s) => {
const li = document.createElement("li");
const name = document.createElement("b");
name.textContent = s.label;
const badge = document.createElement("span");
badge.className = "mv-badge";
badge.dataset.size = "sm";
const routed = s.trust === "always" ? ["Always", "info"] : s.trust === "digest" ? ["Digest only", "secondary"] : s.routing === "digest" ? ["Held", "warning"] : ["Shown", "success"];
badge.textContent = routed[0];
badge.dataset.variant = routed[1];
const bar = document.createElement("span");
bar.className = "cw-bar";
const fill = document.createElement("i");
fill.style.setProperty("--v", s.credibility);
bar.append(fill);
const num = document.createElement("span");
num.className = "cw-num";
num.textContent = `${Math.round(s.credibility * 100)}% · acted on ${s.actionRate == null ? "-" : `${Math.round(s.actionRate * 100)}%`}`;
li.append(name, badge, bar, num);
return li;
}));
};
const log = (text, key) => {
const li = document.createElement("li");
if (key) li.dataset.key = key;
li.textContent = `${new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" })} ${text}`;
const el = $("cw-log");
const first = el.firstElementChild;
if (first?.textContent === "Waiting for alerts…") el.replaceChildren();
// Repeats of the same alert update one line instead of flooding the log.
if (first && li.dataset.key && first.dataset.key === li.dataset.key) first.replaceWith(li);
else el.prepend(li);
while (el.children.length > 5) el.lastElementChild.remove();
};
const reasons = { credibility: "low credibility", rate: "rate limited", trust: "digest only" };
cw.addEventListener("mv-alert", (e) => {
const { alert, verdict, reason } = e.detail;
if (alert.last < Date.now() - 50_000) return; // backlog
log(verdict === "merged" ? `folded ×${alert.count} · ${alert.title}` : verdict === "digest" ? `held (${reasons[reason]}) · ${e.detail.source.label}` : `shown${alert.critical ? " (critical)" : ""} · ${alert.title}`, verdict === "merged" ? alert.id : null);
report();
});
cw.addEventListener("mv-feedback", (e) => {
const { kind, source, credibility, previous, value } = e.detail;
const delta = typeof previous === "number" ? ` ${Math.round(previous * 100)}% → ${Math.round(credibility * 100)}%` : "";
log(`${kind}${kind === "trust" ? ` = ${value}` : ""} · ${source.label}${delta}`);
report();
});
let n = 0;
$("cw-noise").addEventListener("click", () => {
const hosts = ["web-05", "web-07", "cache-02", "worker-03"];
cw.push({ source: "disk", title: `Disk usage ${82 + (n % 6)}% on ${hosts[n++ % hosts.length]}`, severity: "warning" });
});
$("cw-pay").addEventListener("click", () => {
cw.push({ source: "payments", title: "Card authorizations failing in EU-West", text: "Decline rate 14% over the last 5 minutes (usual: 2%). Acquirer: Adyen EU.", severity: "danger", actions: [{ label: "Open incident", value: "incident" }] });
});
$("cw-wolf").addEventListener("click", () => {
cw.push({ source: "disk", title: "Disk full on queue-01", text: "The message broker stopped accepting new jobs at 100% usage.", critical: true, actions: [{ label: "Free space", value: "free" }] });
});
$("cw-burst").addEventListener("click", () => {
let i = 0;
const t = setInterval(() => {
cw.push({ source: "ci", title: "Build #1287 failed on main", text: "Retry failed: the same 3 tests in checkout-service.", severity: "danger" });
if (++i >= 6) clearInterval(t);
}, 220);
});
$("cw-sec").addEventListener("click", () => {
cw.push({ source: "security", title: "New admin sign-in from São Paulo, Brazil", text: "Account: [email protected] · Desktop browser · MFA passed.", severity: "info", actions: [{ label: "Review session", value: "review" }] });
});
$("cw-persist").addEventListener("change", (e) => {
if (e.target.checked) cw.storageKey = "mv-demo-cry-wolf";
else { try { localStorage.removeItem("mv-demo-cry-wolf"); } catch {} cw.storageKey = null; }
});
$("cw-reset").addEventListener("click", () => { cw.resetLearning(); seed(); log("demo reset"); });
seed();
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
threshold | number (0..1) | 0.4 | Credibility under which an Auto source is held in the digest. It comes back once it climbs 0.1 above (hysteresis, no flapping). New sources start at 0.67 (benefit of the doubt): about three dismissals in a row demote a new source. |
rate-limit | "<count>/<time>" | "off" | 5/10m | Per source: at most count alerts shown inline within the sliding window; the next ones go to the digest with the reason “Rate limited”. Critical alerts and Always show sources are never limited. |
dedupe | time | "off" | 1h | Alerts with the same key (default: source + title) or the same id within this window fold into the existing one (inline or held): counter “×12 in 1 h”, latest time and text. Each repeat lowers the source’s credibility a little (0.2 of a dismissal). A critical repeat of a held alert escalates it inline. |
memory | time | "off" | 7d | Half-life of the learned evidence: reactions older than this weigh half as much, so a source that was fixed recovers on its own. off keeps everything forever. |
storage-key | string | Opt-in persistence: the learned state (credibility evidence, counters, record, trust overrides, never the alerts themselves) is saved to localStorage under this key, restored on connection and synced across tabs. Use one key per user. | |
label | string | Alerts | Heading shown in the toolbar; also the accessible name of the region. |
max | number | 50 | Maximum alerts kept. Beyond it, the oldest held alert is dropped first, then the oldest non-critical one. |
placement | bottom-end | bottom-start | bottom | top-end | top-start | top | bottom-end | Preferred side of the Sources panel relative to its button (flips and shifts to stay in the viewport). |
Properties
| Name | 유형 | Description |
|---|---|---|
sources | SourceStats[] (get) · { id, label?, trust? }[] (set) | Read: per-source stats, noisiest first: { id, label, trust, credibility (0..1), demoted, routing ("inline" | "digest"), received, shown, digested, merged, acted, dismissed, notUseful, actionRate (acted / reactions, null before any), record (last 12 reactions), pending (current alerts), lastAt }. Write: declare labels and default trust ("always" | "auto" | "digest") without touching the learning. |
snapshot | { version, savedAt, sources } | Serializable learned state for per-user persistence on your server. Set it back to restore; entries with counts only (acted, dismissed, notUseful, merged) are accepted and turned into evidence, handy to seed from a report. |
alerts | Alert[] | Current alerts (read-only copies) with placement ("inline" | "digest"), reason ("credibility" | "rate" | "trust" | null), count, first and last. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement ({placeholders} kept). English defaults. |
Methods
| Name | Description |
|---|---|
push(alert | alert[], { silent? }) | Submits alerts: { source, title, text?, severity? ("info" | "success" | "warning" | "danger"), critical?, id?, key?, sourceLabel?, actions? ([{ label, value, keep? }], up to 3), href? (http, https, mailto only), time? (Date | ms), data? }. Returns { id, verdict: "inline" | "digest" | "merged" | "pending", reason }. silent skips the live announcement (hydrating a backlog). Calls made before the element is connected are queued. |
acted(id, value?) / dismiss(id) / notUseful(id) | Teach from outside the component (the user fixed it elsewhere, a keyboard shortcut…). Each removes the alert and emits mv-feedback. |
resolve(id) / clear() | The condition cleared by itself: removes one alert (or all) without teaching anything. |
setTrust(sourceId, "always" | "auto" | "digest") | Same as the Sources panel: digest moves the source’s non-critical alerts into the digest, always moves its held alerts back inline. Emits mv-feedback with kind "trust". |
credibility(sourceId) | Current credibility (0..1), decayed to now. |
resetLearning(sourceId?) | Forgets the learned evidence, counters and record of one source or all; trust overrides are kept. |
openSources() / closeSources() | Opens or closes the Sources panel. |
Events
| Name | Description |
|---|---|
mv-alert | After every push. detail: { alert, verdict: "inline" | "digest" | "merged", reason, source (stats) }. Route inline verdicts to a toast or a push notification if you like: only what passed the guard. |
mv-demote | Cancelable, before an alert is held back (and before setTrust moves one). detail: { alert, reason: "credibility" | "rate" | "trust", source, credibility }. preventDefault() keeps it inline. |
mv-feedback | The user (or your code) taught something. detail: { kind: "acted" | "dismissed" | "not-useful" | "trust" | "reset", alert (null for trust and reset), alerts and bulk: true for Dismiss all, value (action value, "link", or the new trust), previous (credibility before, or previous trust), source (stats), credibility }. Send it to your analytics to track alert quality. |
Content structure
| Name | Description |
|---|---|
empty | Children with slot="empty" replace the default “All clear” text shown when nothing is inline. |
CSS classes
| Name | Description |
|---|---|
mv-cry-wolf-bar | Toolbar: -heading (-title, -summary “3 shown · 16 held back”) and .mv-cry-wolf-toggle (Sources button, with -pill counting held-back sources). |
mv-cry-wolf-list / mv-cry-wolf-item | Inline alerts (<ul> of <li>, data-severity, data-critical, data-tone, data-acted). Inside: .mv-cry-wolf-alert (<article>), -icon, -body, -alert-title, -flag (Critical), -text, -history (track record of a critical alert), -actions, -not-useful, -meta (-source with signal bars, <time>, -repeat), -dismiss. |
mv-cry-wolf-digest | Held-back section: -digest-head, -groups (one .mv-cry-wolf-group per source, data-open, data-flash when an alert is absorbed), -group-toggle (disclosure: -group-label with -count, -group-reason, -group-cue), -group-body, -rows (.mv-cry-wolf-row compact alerts), -group-actions. |
mv-cry-wolf-panel | Sources panel (Popover API, role dialog): -panel-head, -sources (.mv-cry-wolf-source-row, data-tone, data-routing), -meter (role meter) with -meter-fill and -meter-mark (the threshold), -trust (fieldset of three radios), -source-foot (status, stats, record), -panel-foot (Reset learning). |
mv-cry-wolf-signal / mv-cry-wolf-record | Four signal bars (data-level 1..4, colored by data-tone high | mid | low) and the reaction record: filled dot = acted on, ring = dismissed, diamond = not useful. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-cry-wolf-high | var(--mv-success) | Tone of credible sources (signal bars, meter, acted dots). |
--mv-cry-wolf-mid | var(--mv-warning) | Tone of sources between the threshold and 60%. |
--mv-cry-wolf-low | var(--mv-fg-subtle) | Tone of held-back sources: noise is muted, not alarming. |
--mv-cry-wolf-critical | var(--mv-danger) | Critical alerts: side bar, ring, Critical flag. |
--mv-cry-wolf-gap | var(--mv-space-2) | Space between inline alerts. |
Accessibility
The component is a labelled region (role="region" named by its heading). Inline alerts are a real list of <article> elements labelled by their title and described by their text and meta line, which includes the source’s credibility and the repeat count in words (“repeated 12 times in 1 h”) while the visual “×12” chip is aria-hidden. Only alerts that pass the guard are announced: a polite live region for normal ones (a burst is announced once, “3 new alerts. Latest: …”, never as a stream) and an assertive one for critical alerts; held-back alerts, merged repeats and hydrated backlogs (silent) are never announced, yet stay fully reachable: each digest group is a disclosure <button> (aria-expanded, aria-controls) whose name says how many alerts, from which source and why (“4 low-signal alerts from Disk monitor, Low credibility, latest 3 min ago”), and its rows keep their actions and a Dismiss button. Every button has an explicit name (“Dismiss: <title>”, “Not useful: <title>”); after a dismissal, focus moves to the next alert (or the previous one, then the digest, then the Sources button), never to the top of the page. When a user’s feedback flips a source in or out of the digest, it is announced politely. The Sources button has aria-haspopup="dialog" and aria-expanded; the panel is a labelled non-modal dialog placed right after the toolbar in the DOM, closed by Escape (focus returns to the button), outside click or its Close button. Each source’s credibility is a role="meter" with a value text (“18%, held in the digest”), each override is a native radio group in a fieldset with a legend (“Delivery for Disk monitor”), operable with arrow keys, and the reaction record has a text equivalent. Colors never carry meaning alone: signal bars also change length, record marks differ by shape, critical alerts carry a “Critical” flag and a side bar. Forced colors: bars, meter and marks use system colors. Reduced motion (OS or data-motion="reduce"): alerts appear and leave without height or slide animations, counters do not pop, the meter jumps.