Exclusivobeta
Cry Wolf <mv-cry-wolf>
Proteção contra a fadiga de alertas para centrais de notificação e consoles de monitoramento: ele não se limita a exibir alertas, decide quais merecem interromper. Cada fonte ganha uma pontuação de credibilidade conforme o que o usuário realmente faz com os alertas dela (agir a aumenta; dispensar sem agir, “Not useful” e repetições idênticas a reduzem; as evidências antigas perdem peso com uma meia-vida, para que uma fonte corrigida recupere a confiança). As fontes abaixo do limite ficam retidas em um resumo discreto (“4 low-signal alerts from Disk monitor · Review”), as rajadas são limitadas por fonte e os alertas idênticos se agrupam em um contador (“×12 in 1 h”), mas um alerta marcado como crítico sempre passa, inline e anunciado de forma assertiva, com o histórico da sua fonte para ajudar na triagem. Barras de sinal mostram a credibilidade de cada fonte, um painel Sources mostra um medidor de confiança por fonte com os ajustes Sempre mostrar / Auto / Só no resumo, a propriedade sources expõe as estatísticas para que as equipes corrijam os alertas ruidosos, mv-demote permite que o app vete qualquer rebaixamento, e o aprendizado pode persistir por usuário.
| Categoria | Feedback |
|---|---|
| Tipo | Web Component (<mv-cry-wolf>) |
| Status | beta |
| Kit | Monitoramento sem fadiga de alertas |
| Também instala | 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 d system alerts compete for attention and eac h user should tune what interrupts the min
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
Instalação
node scripts/add.mjs cry-wolf --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["cry-wolf"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): 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.
Uso
Início rápido, a menor marcação que funciona:
<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>Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis 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] · 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>Referência cultural
The Boy Who Cried Wolf, Esopo (c. 600 a.C., fábula). Um jovem pastor dá tantos alarmes falsos que, quando o lobo aparece de verdade, os aldeões já não o escutam. Na interface, cada fonte de alertas ganha ou perde credibilidade conforme a reação das pessoas a ela, as fontes ruidosas ficam retidas discretamente em um resumo, e um alarme marcado como crítico é sempre ouvido, com o histórico da sua fonte.
API
Attributes
| Name | Tipo | 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 | Tipo | 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.