Exclusifbêta
Courtyard <mv-courtyard>
Mur de supervision qui surveille tous les flux à la fois et vous dit où regarder : n’importe quels enfants (statistiques en direct, vidéo, images de caméra, jobs de CI, écrans d’élèves) deviennent des « fenêtres » dans une grille automatique, et chacune signale son activité par un niveau data-activity soutenu, par des appels ponctuels activity(window, level, note), ou par les changements de son propre DOM après une période calme (MutationObserver, par lots, si bien qu’une tuile qui s’actualise régulièrement reste calme et que seule la nouveauté ressort). Les fenêtres actives brillent doucement avec un statut en toutes lettres et une icône (Modifié, Actif, Alerte) qui décroît avec le temps, les fenêtres calmes restent calmes, les flux qui ne se mettent plus à jour sont signalés « Figé », et chaque fenêtre conserve une chronologie d’activité de 30 barres. Cliquer sur une fenêtre, ou appuyer sur Entrée sur son bouton de focus, l’amène dans une vue focus avec un glissement FLIP depuis son emplacement (mise à l’échelle uniforme et recadrage, sans étirement ; instantané en mouvement réduit) et révèle son journal d’activité récente, tandis que les autres se réduisent en une bande de vignettes qui continue de briller ; ←/→ pour passer de l’une à l’autre, Échap pour revenir. Une épingle de surveillance fait qu’une fenêtre s’annonce aux lecteurs d’écran et fait apparaître un bouton d’accès direct dans la vue focus quand elle change ; mv-activity et mv-focus-window sont annulables.
| Catégorie | Mise en page |
|---|---|
| Type | Web Component (<mv-courtyard>) |
| Statut | bêta |
| Kit | Une supervision sans fatigue d’alerte |
| Keywords | exclusive, culture, monitoring, dashboard, wall, noc, operations, cctv, multi-view, live, activity, change-detection, mutation-observer, stale-detection, sparkline, focus-view, filmstrip, flip, grid, watch, alerts, keyboard |
When to use
- An operations or NOC screen shows many regions, services or pipelines and must point at the one that just started failing
- A camera, stream or live-event wall needs one feed enlarged on demand while the others stay visible and keep signaling
- A kitchen display, trading desk or scoreboard updates constantly and only unusual changes should draw the eye
- A teacher or moderator watches many live screens and wants to pin a few and be told when they change
Avoid when
- The grid is static marketing content with no live activity to watch → use Bento Grid instead
- The goal is to decide which alerts deserve to interrupt, with snoozes and credibility per source → use Cry Wolf instead
- Items are photos to browse and enlarge, not feeds to monitor → use Mosaic Gallery instead
Installation
node scripts/add.mjs courtyard --out ./src/marvelousAgent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["courtyard"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/courtyard/courtyard.js, components/courtyard/courtyard.css.
Utilisation
Démarrage rapide, le balisage minimal qui fonctionne :
<mv-courtyard label="Regional operations" observe>
<article><h4>US East</h4>118 ms p95</article>
<article><h4>EU West</h4>112 ms p95</article>
<article data-activity="medium" data-activity-note="p95 above 400 ms"><h4>EU Central</h4>436 ms p95</article>
</mv-courtyard>Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :
<div id="cy-demo" style="width:min(100%,62rem);margin-inline:auto">
<style>
#cy-demo .cy-top { display:flex; align-items:flex-end; justify-content:space-between; gap:.75rem 1.25rem; flex-wrap:wrap; margin:0 0 1rem }
#cy-demo .cy-top h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
#cy-demo .cy-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#cy-demo .cy-controls { display:flex; align-items:center; gap:.5rem .75rem; flex-wrap:wrap }
#cy-demo .cy-controls .mv-choice { font-size:.8125rem }
#cy-demo mv-courtyard { --mv-courtyard-min:17rem }
/* One region tile (the app's own markup). */
#cy-demo .ops-head { display:flex; align-items:center; gap:.625rem; padding:.75rem .875rem 0 }
#cy-demo .ops-code { display:grid; place-items:center; width:2.25rem; height:1.5rem; border-radius:var(--mv-radius-sm); background:var(--mv-bg-muted); color:var(--mv-fg-muted); font:600 .6875rem/1 var(--mv-font-mono); letter-spacing:.04em }
#cy-demo .ops-name { display:grid; min-width:0; line-height:1.2 }
#cy-demo .ops-name b { font-size:.875rem; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
#cy-demo .ops-name small { color:var(--mv-fg-subtle); font-size:.6875rem }
#cy-demo .ops-state { margin-inline-start:auto; display:inline-flex; align-items:center; gap:.3125rem; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:500; white-space:nowrap }
#cy-demo .ops-state::before { content:""; width:.4375rem; height:.4375rem; border-radius:50%; background:var(--mv-success) }
#cy-demo .ops-state[data-state="degraded"]::before { background:var(--mv-warning); border-radius:1px; rotate:45deg; width:.375rem; height:.375rem }
#cy-demo .ops-state[data-state="outage"]::before { background:var(--mv-danger); border-radius:1px; width:.4375rem; height:.4375rem }
#cy-demo .ops-state[data-state="outage"] { color:color-mix(in oklab, var(--mv-danger), var(--mv-fg) 25%); font-weight:600 }
#cy-demo .ops-body { display:grid; gap:.5rem; padding:.5rem .875rem .75rem }
#cy-demo .ops-main { display:flex; align-items:baseline; gap:.375rem }
#cy-demo .ops-big { font-size:clamp(1.625rem, 8cqi, 3.5rem); font-weight:650; letter-spacing:-.03em; font-variant-numeric:tabular-nums; line-height:1 }
#cy-demo .ops-unit { color:var(--mv-fg-muted); font-size:.75rem }
#cy-demo .ops-chart { display:block; width:100%; height:2.25rem; overflow:visible }
#cy-demo .ops-line { fill:none; stroke:var(--mv-fg-muted); stroke-width:1.5; vector-effect:non-scaling-stroke; stroke-linejoin:round }
#cy-demo .ops-area { fill:color-mix(in oklab, var(--mv-fg) 6%, transparent) }
#cy-demo .ops[data-activity="high"] .ops-line { stroke:var(--mv-danger) }
#cy-demo .ops[data-activity="high"] .ops-area { fill:color-mix(in oklab, var(--mv-danger) 12%, transparent) }
#cy-demo .ops[data-activity="medium"] .ops-line { stroke:var(--mv-warning) }
#cy-demo .ops-kv { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.5rem; margin:0 }
#cy-demo .ops-kv div { display:grid; gap:.0625rem; min-width:0 }
#cy-demo .ops-kv dt { color:var(--mv-fg-subtle); font-size:.6875rem }
#cy-demo .ops-kv dd { margin:0; font-size:.8125rem; font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap }
#cy-demo .ops-more { display:none }
/* Filmstrip preview: just the name, the state shape and the headline number. */
@container mv-courtyard-window (max-width: 12rem) {
#cy-demo .ops-head { gap:.375rem; padding:.5rem .625rem 0 }
#cy-demo .ops-code, #cy-demo .ops-name small { display:none }
#cy-demo .ops-name b { font-size:.75rem }
#cy-demo .ops-state { font-size:0; gap:0 }
#cy-demo .ops-body { padding:.25rem .625rem }
#cy-demo .ops-big { font-size:1.375rem }
#cy-demo .ops-unit { font-size:.625rem }
}
/* Raised: the same tile, looked at closely. */
@container mv-courtyard-window (min-width: 36rem) {
#cy-demo .ops-head { padding:1.125rem 1.25rem 0 }
#cy-demo .ops-body { grid-template-columns:minmax(0,1.6fr) minmax(0,1fr); gap:1rem 2rem; padding:.75rem 1.25rem 1.25rem }
#cy-demo .ops-main, #cy-demo .ops-chart { grid-column:1 }
#cy-demo .ops-chart { height:9rem }
#cy-demo .ops-kv { grid-column:1; font-size:1rem }
#cy-demo .ops-kv dd { font-size:1rem }
#cy-demo .ops-more { display:grid; grid-column:2; grid-row:1 / span 3; align-content:start; gap:.25rem; margin:0; padding:0; list-style:none; font-size:.8125rem }
#cy-demo .ops-more li { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:.75rem; padding:.4375rem 0; border-bottom:1px solid var(--mv-border); font-variant-numeric:tabular-nums }
#cy-demo .ops-more li:first-child { color:var(--mv-fg-subtle); font-size:.6875rem; padding-top:0 }
#cy-demo .ops-more code { font:500 .75rem var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
}
#cy-demo .cy-log { margin:1rem 0 0; padding:.75rem .875rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle); font-size:.75rem }
#cy-demo .cy-log h4 { display:flex; justify-content:space-between; margin:0 0 .375rem; font-size:.6875rem; letter-spacing:.04em; text-transform:uppercase; color:var(--mv-fg-muted); font-weight:600 }
#cy-demo .cy-log h4 span { text-transform:none; letter-spacing:0; font-weight:500; color:var(--mv-fg-subtle) }
#cy-demo .cy-log ol { list-style:none; margin:0; padding:0; display:grid; gap:.25rem }
#cy-demo .cy-log li { display:grid; grid-template-columns:5.5rem 7rem 4rem minmax(0,1fr); gap:.5rem; color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
#cy-demo .cy-log li b { color:var(--mv-fg); font-weight:500 }
#cy-demo .cy-log li span:last-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--mv-fg) }
@media (max-width:40rem) { #cy-demo .cy-log li { grid-template-columns:4.5rem minmax(0,1fr); } #cy-demo .cy-log li span:nth-child(n+3) { grid-column:2 } }
</style>
<div class="cy-top">
<div>
<h3>Global API · Operations wall</h3>
<p>9 regions · live metrics · click a region, or Tab to its focus button and press Enter</p>
</div>
<div class="cy-controls">
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="cy-incident">Resolve incident</button>
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="cy-freeze">Freeze Sydney feed</button>
<label class="mv-choice">
<input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="cy-observe" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Detect changes</span></span>
</label>
</div>
</div>
<mv-courtyard id="cy-wall" label="Regional operations" observe quiet="5s" decay="25s" stale="8s">
<article class="ops" data-window data-region="iad"><header class="ops-head"><span class="ops-code">IAD</span><span class="ops-name"><b data-window-title>US East</b><small>Virginia</small></span><span class="ops-state" data-state="ok">Operational</span></header><div class="ops-body"><div class="ops-main"><span class="ops-big" data-k="p95">118</span><span class="ops-unit">ms p95</span></div><svg class="ops-chart" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><path class="ops-area"/><path class="ops-line"/></svg><dl class="ops-kv"><div><dt>Requests</dt><dd data-k="rps">18.2k/s</dd></div><div><dt>Errors</dt><dd data-k="err">0.03%</dd></div><div><dt>Nodes</dt><dd data-k="nodes">48</dd></div></dl></div></article>
<article class="ops" data-window data-region="pdx"><header class="ops-head"><span class="ops-code">PDX</span><span class="ops-name"><b data-window-title>US West</b><small>Oregon</small></span><span class="ops-state" data-state="ok">Operational</span></header><div class="ops-body"><div class="ops-main"><span class="ops-big" data-k="p95">104</span><span class="ops-unit">ms p95</span></div><svg class="ops-chart" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><path class="ops-area"/><path class="ops-line"/></svg><dl class="ops-kv"><div><dt>Requests</dt><dd data-k="rps">11.7k/s</dd></div><div><dt>Errors</dt><dd data-k="err">0.02%</dd></div><div><dt>Nodes</dt><dd data-k="nodes">32</dd></div></dl></div></article>
<article class="ops" data-window data-region="yul"><header class="ops-head"><span class="ops-code">YUL</span><span class="ops-name"><b data-window-title>Canada</b><small>Montreal</small></span><span class="ops-state" data-state="ok">Operational</span></header><div class="ops-body"><div class="ops-main"><span class="ops-big" data-k="p95">96</span><span class="ops-unit">ms p95</span></div><svg class="ops-chart" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><path class="ops-area"/><path class="ops-line"/></svg><dl class="ops-kv"><div><dt>Requests</dt><dd data-k="rps">3.9k/s</dd></div><div><dt>Errors</dt><dd data-k="err">0.01%</dd></div><div><dt>Nodes</dt><dd data-k="nodes">12</dd></div></dl></div></article>
<article class="ops" data-window data-region="gru"><header class="ops-head"><span class="ops-code">GRU</span><span class="ops-name"><b data-window-title>South America</b><small>São Paulo</small></span><span class="ops-state" data-state="ok">Operational</span></header><div class="ops-body"><div class="ops-main"><span class="ops-big" data-k="p95">142</span><span class="ops-unit">ms p95</span></div><svg class="ops-chart" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><path class="ops-area"/><path class="ops-line"/></svg><dl class="ops-kv"><div><dt>Requests</dt><dd data-k="rps">5.6k/s</dd></div><div><dt>Errors</dt><dd data-k="err">0.05%</dd></div><div><dt>Nodes</dt><dd data-k="nodes">16</dd></div></dl></div></article>
<article class="ops" data-window data-region="dub"><header class="ops-head"><span class="ops-code">DUB</span><span class="ops-name"><b data-window-title>EU West</b><small>Dublin</small></span><span class="ops-state" data-state="ok">Operational</span></header><div class="ops-body"><div class="ops-main"><span class="ops-big" data-k="p95">112</span><span class="ops-unit">ms p95</span></div><svg class="ops-chart" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><path class="ops-area"/><path class="ops-line"/></svg><dl class="ops-kv"><div><dt>Requests</dt><dd data-k="rps">9.8k/s</dd></div><div><dt>Errors</dt><dd data-k="err">0.03%</dd></div><div><dt>Nodes</dt><dd data-k="nodes">28</dd></div></dl></div></article>
<article class="ops" data-window data-region="fra" data-watch data-activity="medium" data-activity-note="p95 latency above 400 ms"><header class="ops-head"><span class="ops-code">FRA</span><span class="ops-name"><b data-window-title>EU Central</b><small>Frankfurt</small></span><span class="ops-state" data-state="degraded">Degraded</span></header><div class="ops-body"><div class="ops-main"><span class="ops-big" data-k="p95">436</span><span class="ops-unit">ms p95</span></div><svg class="ops-chart" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><path class="ops-area"/><path class="ops-line"/></svg><dl class="ops-kv"><div><dt>Requests</dt><dd data-k="rps">10.4k/s</dd></div><div><dt>Errors</dt><dd data-k="err">1.84%</dd></div><div><dt>Nodes</dt><dd data-k="nodes">36</dd></div></dl></div></article>
<article class="ops" data-window data-region="bom"><header class="ops-head"><span class="ops-code">BOM</span><span class="ops-name"><b data-window-title>India</b><small>Mumbai</small></span><span class="ops-state" data-state="ok">Operational</span></header><div class="ops-body"><div class="ops-main"><span class="ops-big" data-k="p95">151</span><span class="ops-unit">ms p95</span></div><svg class="ops-chart" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><path class="ops-area"/><path class="ops-line"/></svg><dl class="ops-kv"><div><dt>Requests</dt><dd data-k="rps">7.1k/s</dd></div><div><dt>Errors</dt><dd data-k="err">0.04%</dd></div><div><dt>Nodes</dt><dd data-k="nodes">20</dd></div></dl></div></article>
<article class="ops" data-window data-region="nrt"><header class="ops-head"><span class="ops-code">NRT</span><span class="ops-name"><b data-window-title>Japan</b><small>Tokyo</small></span><span class="ops-state" data-state="ok">Operational</span></header><div class="ops-body"><div class="ops-main"><span class="ops-big" data-k="p95">88</span><span class="ops-unit">ms p95</span></div><svg class="ops-chart" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><path class="ops-area"/><path class="ops-line"/></svg><dl class="ops-kv"><div><dt>Requests</dt><dd data-k="rps">13.3k/s</dd></div><div><dt>Errors</dt><dd data-k="err">0.02%</dd></div><div><dt>Nodes</dt><dd data-k="nodes">44</dd></div></dl></div></article>
<article class="ops" data-window data-region="syd"><header class="ops-head"><span class="ops-code">SYD</span><span class="ops-name"><b data-window-title>Australia</b><small>Sydney</small></span><span class="ops-state" data-state="ok">Operational</span></header><div class="ops-body"><div class="ops-main"><span class="ops-big" data-k="p95">124</span><span class="ops-unit">ms p95</span></div><svg class="ops-chart" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><path class="ops-area"/><path class="ops-line"/></svg><dl class="ops-kv"><div><dt>Requests</dt><dd data-k="rps">4.4k/s</dd></div><div><dt>Errors</dt><dd data-k="err">0.03%</dd></div><div><dt>Nodes</dt><dd data-k="nodes">14</dd></div></dl></div></article>
</mv-courtyard>
<section class="cy-log" aria-label="Activity events">
<h4>mv-activity <span>latest events</span></h4>
<ol id="cy-events"></ol>
</section>
<script type="module">
const wall = document.getElementById("cy-wall");
await customElements.whenDefined("mv-courtyard");
const root = document.getElementById("cy-demo");
const tiles = [...wall.querySelectorAll(".ops")];
const byId = (id) => wall.querySelector(`[data-region="${id}"]`);
const rand = (a, b) => a + Math.random() * (b - a);
const timers = new Set();
const later = (fn, ms) => { const t = setTimeout(() => { timers.delete(t); if (wall.isConnected) fn(); }, ms); timers.add(t); return t; };
// Detail only visible once a region is raised: slowest endpoints.
const endpoints = ["/v1/orders", "/v1/checkout", "/v1/search", "/v1/auth/token"];
const model = new Map();
for (const el of tiles) {
const p95 = Number(el.querySelector("[data-k=p95]").textContent);
const rps = parseFloat(el.querySelector("[data-k=rps]").textContent);
const err = parseFloat(el.querySelector("[data-k=err]").textContent);
const base = el.dataset.region === "fra" ? 120 : p95;
model.set(el, { base, p95, rps, err, series: Array.from({ length: 40 }, () => p95 * rand(0.9, 1.1)), frozen: false });
const more = document.createElement("ol");
more.className = "ops-more";
more.setAttribute("aria-label", "Slowest endpoints");
const head = document.createElement("li");
head.append(Object.assign(document.createElement("span"), { textContent: "Endpoint" }), Object.assign(document.createElement("span"), { textContent: "p95" }), Object.assign(document.createElement("span"), { textContent: "Errors" }));
more.append(head);
for (const ep of endpoints) {
const li = document.createElement("li");
li.append(Object.assign(document.createElement("code"), { textContent: ep }), Object.assign(document.createElement("span"), { className: "ep-p95" }), Object.assign(document.createElement("span"), { className: "ep-err" }));
more.append(li);
}
el.querySelector(".ops-body").append(more);
}
const incident = { on: true, level: 2 };
const draw = (el) => {
const m = model.get(el);
const max = Math.max(...m.series) * 1.15;
const pts = m.series.map((v, i) => `${(i / (m.series.length - 1) * 100).toFixed(1)},${(30 - (v / max) * 28).toFixed(1)}`);
el.querySelector(".ops-line").setAttribute("d", `M${pts.join("L")}`);
el.querySelector(".ops-area").setAttribute("d", `M0,30L${pts.join("L")}L100,30Z`);
};
const paint = (el) => {
const m = model.get(el);
el.querySelector("[data-k=p95]").textContent = Math.round(m.p95);
el.querySelector("[data-k=rps]").textContent = `${m.rps.toFixed(1)}k/s`;
el.querySelector("[data-k=err]").textContent = `${m.err.toFixed(2)}%`;
el.querySelectorAll(".ops-more li:not(:first-child)").forEach((li, i) => {
li.querySelector(".ep-p95").textContent = `${Math.round(m.p95 * [1.4, 1.2, 0.9, 0.6][i])} ms`;
li.querySelector(".ep-err").textContent = `${(m.err * [1.8, 1.3, 0.6, 0.3][i]).toFixed(2)}%`;
});
draw(el);
};
const step = (el) => {
const m = model.get(el);
if (!m.frozen) {
const fra = el.dataset.region === "fra";
const target = fra && incident.on ? (incident.level === 3 ? 880 : 440) : m.base;
m.p95 += (target - m.p95) * 0.35 + rand(-0.06, 0.06) * m.base;
m.rps *= rand(0.97, 1.03);
const errTarget = fra && incident.on ? (incident.level === 3 ? 6.4 : 1.8) : 0.03;
m.err = Math.max(0.01, m.err + (errTarget - m.err) * 0.4 + rand(-0.01, 0.01));
m.series.push(m.p95); m.series.shift();
paint(el);
}
later(() => step(el), rand(1400, 2300));
};
const setState = (el, state, text) => { const s = el.querySelector(".ops-state"); s.dataset.state = state; s.textContent = text; };
const escalate = () => {
const fra = byId("fra");
incident.level = 3;
setState(fra, "outage", "Outage");
fra.setAttribute("data-activity-note", "Error rate 6.4% · upstream 502s");
fra.dataset.activity = "high";
};
const startIncident = () => {
const fra = byId("fra");
incident.on = true; incident.level = 2;
setState(fra, "degraded", "Degraded");
fra.setAttribute("data-activity-note", "p95 latency above 400 ms");
fra.dataset.activity = "medium";
later(escalate, 2600);
};
const resolve = () => {
const fra = byId("fra");
incident.on = false;
setState(fra, "ok", "Operational");
fra.removeAttribute("data-activity-note");
fra.removeAttribute("data-activity");
};
// Event log.
const list = document.getElementById("cy-events");
const fmt = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const words = { calm: "Calm", low: "Changed", medium: "Active", high: "Alert" };
wall.addEventListener("mv-activity", (e) => {
const { label, level, note, source } = e.detail;
const li = document.createElement("li");
const cells = [fmt.format(Date.now()), label, source === "stale" ? "Stalled" : words[level], note];
cells.forEach((text, i) => li.append(Object.assign(document.createElement(i === 1 ? "b" : "span"), { textContent: text })));
list.prepend(li);
while (list.children.length > 4) list.lastElementChild.remove();
});
// A little history so every timeline starts alive.
const now = Date.now();
for (const el of tiles) for (let t = 58000; t > 0; t -= rand(1600, 2400)) wall.activity(el, "calm", "", { at: now - t, silent: true });
wall.activity(byId("gru"), "medium", "Queue depth above 5,000", { at: now - 52000, silent: true });
wall.activity(byId("nrt"), "low", "Autoscaled to 44 nodes", { at: now - 34000, silent: true });
wall.activity(byId("fra"), "low", "p95 latency creeping up", { at: now - 21000, silent: true });
wall.activity(byId("pdx"), "low", "Deploy v2.14.1 rolled out", { at: now - 2500 });
for (const el of tiles) { paint(el); later(() => step(el), rand(900, 2200)); }
later(escalate, 900);
// Now and then, something small happens somewhere.
const news = [["iad", "Traffic shifted +8% from EU Central"], ["dub", "Cache warmed after deploy"], ["yul", "TLS certificate renewed"], ["bom", "Autoscaled to 22 nodes"], ["nrt", "Deploy v2.14.1 rolled out"], ["gru", "Replica lag back under 1 s"]];
const chatter = () => { const [id, note] = news[Math.floor(Math.random() * news.length)]; wall.activity(byId(id), "low", note); later(chatter, rand(7000, 12000)); };
later(chatter, 6000);
// Controls.
const incidentBtn = document.getElementById("cy-incident");
incidentBtn.addEventListener("click", () => {
if (incident.on) { resolve(); incidentBtn.textContent = "Start incident"; }
else { startIncident(); incidentBtn.textContent = "Resolve incident"; }
});
const freezeBtn = document.getElementById("cy-freeze");
freezeBtn.addEventListener("click", () => {
const m = model.get(byId("syd"));
m.frozen = !m.frozen;
freezeBtn.textContent = m.frozen ? "Resume Sydney feed" : "Freeze Sydney feed";
});
document.getElementById("cy-observe").addEventListener("change", (e) => { wall.observe = e.target.checked; });
// Replays and teardown: stop every timer when the demo leaves the page.
new MutationObserver((_, mo) => { if (!root.isConnected) { timers.forEach(clearTimeout); mo.disconnect(); } }).observe(document.body, { childList: true, subtree: true });
</script>
</div>Référence culturelle
Fenêtre sur cour, Alfred Hitchcock (1954, film). Cloué dans son appartement, un homme observe toutes les fenêtres de la cour à la fois, remarque quand quelque chose change dans l’une d’elles, puis lève son téléobjectif pour regarder de près cette seule fenêtre. Dans l’interface, un mur de flux en direct garde tout en vue, fait briller la fenêtre où quelque chose vient de changer pendant que les autres restent calmes, et amène n’importe quelle fenêtre dans une vue focus tandis que les autres continuent de se signaler depuis une bande de vignettes.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
label | string | Monitoring wall | Accessible name of the wall (role="group"), unless you set aria-label or aria-labelledby yourself. |
observe | boolean | false | Watches each window's DOM (text and nodes, batched per microtask, the component's own chrome ignored). A change after at least quiet ms of stillness counts as activity (level low, “Content changed”); changes at a window's usual rhythm only feed its timeline as ambient ticks. Opt a window out with data-courtyard-observe="false". |
quiet | time ("4s", "800ms", ms number) | 4s | Stillness a window must keep before an observed change counts as news. Raise it for feeds that tick often. |
decay | time | 20s | How long a one-off activity (activity() call or observed change) keeps its status and glow; the glow fades linearly over this time. Sustained data-activity levels do not decay. |
stale | time | With observe: a window that has updated at least twice and then stays silent this long is flagged “Stalled” (level medium, pause icon, data-courtyard-stale) until it updates again (“Updates resumed”). Off when omitted. | |
history | time | 1m | Time span of the activity timeline (30 bars). |
announce | auto | watched | off | auto | Screen reader announcements: auto = watched windows at any level plus any window reaching Alert; watched = watched windows only; off = none. At most one announcement every 4 s, merging the others (“Also changed: …”). |
data-window | marker attribute | Marks the windows among the children. Without any, every direct element child is a window. | |
data-label | string | On a window: its name (otherwise aria-label, aria-labelledby, the first heading or [data-window-title], else “Window N”). | |
data-activity | calm | low | medium | high (or 0-3; aliases info, warning, critical…) | On a window: a sustained level, e.g. a failing region, kept until you change or remove it. Raising it flashes and emits mv-activity; lowering it to calm logs “Back to calm”. | |
data-activity-note | string | On a window: the reason shown next to its sustained level (“Error rate 6.4%”). | |
data-watch | boolean | On a window: the watch pin (also toggled by its eye button or watch()). Watched windows are announced when they change and raise a jump button in the focus view. | |
data-courtyard-observe | "false" | On a window: never treat its DOM changes as activity (nor as stalls). | |
data-mode / data-courtyard-* | set by the component | Host: data-mode="wall | focus". Windows: data-courtyard-level (calm | low | medium | high), data-courtyard-stale, data-courtyard-sustained, data-courtyard-focused, data-courtyard-thumb, and --mv-courtyard-heat (0-1). |
Properties
| Name | Type | Description |
|---|---|---|
windows | Element[] | The windows currently managed, in order (read-only). |
focused | Element | null | The raised window, or null on the wall (read-only). |
mode | "wall" | "focus" | Current view (read-only). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (level words calm, low, medium, high, stalled; changed, stalledNote, resumed, recovered, ago, justNow, focus, show, watch, back, previous, next, position, announce, announceMore, announceFocus, focusHint, alertPill, log, logEmpty…). English defaults. |
observe / quiet / decay / stale / history / announce / label | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
activity(window, level?, note?, { at?, silent? }) | Reports a one-off activity. window: element (or any descendant), index, selector or label; level defaults to low (calm records an ambient tick in the timeline). at backdates it (epoch ms) to seed history; silent skips the event, flash and announcement. Returns false if unknown or vetoed. |
focusWindow(window) / showWall() | Raises a window into the focus view / returns to the wall (FLIP transition). Both emit mv-focus-window and return false if cancelled. |
next() / previous() | Cycles the focus view to the next or previous window (wraps). |
watch(window, force?) | Toggles or sets the watch pin; emits mv-watch. Returns the new state. |
acknowledge(window?) | Clears the decaying status of one window, or of all (sustained levels and stalls stay). Returns how many. |
stateOf(window) | Snapshot: { level, heat, stalled, sustained, watched, lastActivity, log } with log entries { level, note, at, source }. |
Events
| Name | Description |
|---|---|
mv-activity | A window reported activity. detail: { window, level (calm | low | medium | high), value (0-3), note, source ("api" | "mutation" | "stale" | "attribute"), label, at }. Cancelable except for source "attribute": preventDefault() ignores it (no glow, no log, no announcement), e.g. to filter noise. |
mv-focus-window | Cancelable, before the view changes. detail: { window (null when returning to the wall), previous, index, label, reason ("click" | "keyboard" | "cycle" | "alert" | "api") }. |
mv-watch | A watch pin changed from the eye button or watch(). detail: { window, watched, label }. |
Content structure
| Name | Description |
|---|---|
(content) | The windows: any elements (cards, video, images, iframes, live stats). They are never moved or re-created; each gets a status strip (.mv-courtyard-strip) and a flash ring appended, plus role="region" and a name when it has none. Windows are size containers named mv-courtyard-window, so their content can grow with @container when raised. |
CSS classes
| Name | Description |
|---|---|
mv-courtyard-window | Added to every window (frame, glow from --mv-courtyard-heat, tone from data-courtyard-level). |
mv-courtyard-strip | Status strip inside a window: .mv-courtyard-status (-icon, -word, -meta), .mv-courtyard-spark (timeline bars with data-l), .mv-courtyard-watch, .mv-courtyard-raise, and .mv-courtyard-log (recent activity, shown when raised). |
mv-courtyard-ring | Flash ring of a window: data-flash toggles a one-shot pulse; breathes while a sustained Alert lasts. |
mv-courtyard-bar | Focus-view bar: .mv-courtyard-back (with Esc hint), .mv-courtyard-name, .mv-courtyard-pos, .mv-courtyard-alert (jump button, data-level), .mv-courtyard-prev / -next. |
CSS variables
| Name | Type | Default | Description |
|---|---|---|---|
--mv-courtyard-min | 15rem | Minimum window width in the wall grid. | |
--mv-courtyard-gap | var(--mv-space-3) | Gap between windows. | |
--mv-courtyard-low / -medium / -high | var(--mv-accent) / var(--mv-warning) / var(--mv-danger) | Tones of the three activity levels (glow, status icon, timeline bars). | |
--mv-courtyard-stage-min | 20rem | Minimum height of the raised window. | |
--mv-courtyard-thumb-min / -thumb-height | 7rem / 6.5rem | Minimum width and fixed height of filmstrip previews (they crop the window's top). | |
--mv-courtyard-heat | output | Set on each window, 0-1: current attention level, usable in your own styles. |
Accessibility
The wall is a labelled group and every window a labelled region (its heading, data-label or aria-label), so screen reader users can jump between windows by landmark; the component never moves or re-creates the app's elements. Each window's status is text, not color: an icon, a word (Calm, Changed, Active, Alert, Stalled), the reason and how long ago, plus a visually hidden sentence (“Alert. Error rate 6.4%, 12 seconds ago.”) that describes the window's focus button (a real <button> named “Focus EU Central”), so its last activity is read on demand without the page chattering; the timeline is aria-hidden. A window's own links and buttons keep working: clicking elsewhere in the window raises it, never a click on its controls or while selecting text. In the focus view the raised window receives focus (tabindex=-1) and its name, position and, the first time, the keyboard hint are announced politely; ←/→ cycle windows (ignored inside fields, sliders, tabs, menus and other widgets that own the arrows; mirrored in RTL), Escape returns to the wall and puts focus back on that window's focus button, and the bar offers the same actions as buttons (Back to wall, Previous, Next). Filmstrip previews become single buttons (“Show US East”, described by their status) and their inner content is made inert so Tab does not wander through cropped controls; inert is restored afterwards. Announcements are throttled to one every 4 seconds, merged (“Also changed: …”), and limited to watched windows plus any window reaching Alert (assertive only for Alert); observed DOM changes are never announced for unwatched windows, and announce="off" silences everything. The watch pin is a toggle button with aria-pressed. Reduced motion (OS setting or data-motion="reduce"): no FLIP glide, flash or breathing, the view switches instantly and the glow steps without transitions. Forced colors: levels become outlines of increasing weight and style (thin, dashed, thick) in Highlight, next to the words.