Exclusivebeta
Cry Wolf — <mv-cry-wolf>
Alert-fatigue guard for notification centers and monitoring consoles: it does not just display alerts, it decides which ones deserve to interrupt. Every source earns a credibility score from what the user really does with its alerts (acted on raises it; dismissed without action, “Not useful” and identical repeats lower it; old evidence fades with a half-life so a fixed source earns its trust back). Sources below the threshold are held back in a quiet digest (“4 low-signal alerts from Disk monitor · Review”), bursts are rate-limited per source and identical alerts fold into a counter (“×12 in 1 h”), yet an alert marked critical always gets through, inline and announced assertively, with its source’s track record to help triage. Signal bars show each source’s credibility, a Sources panel shows a trust meter per source with Always show / Auto / Digest only overrides, the sources property exposes the stats so teams can fix noisy alerts, mv-demote lets the app veto any demotion, and learning can be persisted per user.
| Category | Feedback |
|---|---|
| Type | Web Component (<mv-cry-wolf>) |
| Status | beta |
| Also installs | 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 and system alerts compete for attention and each user should tune what interrupts them
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
Install
node scripts/add.mjs cry-wolf --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["cry-wolf"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.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, components/button/button.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<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] · Chrome on macOS · 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>Cultural reference
The Boy Who Cried Wolf — Aesop (c. 600 BC, fable). A shepherd boy raises so many false alarms that when the wolf really comes, the villagers no longer listen. In the UI, each alert source earns or loses credibility from how people react to it, noisy sources are quietly held in a digest, and an alarm marked critical is always heard, with its source’s track record.
API
Attributes
| Name | Type | 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 | Type | 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.