エクスクルーシブ
Courtyard <mv-courtyard>
すべてのフィードを同時に監視し、どこを見るべきかを教えてくれるモニタリングウォールです。任意の子要素(ライブ統計、動画、カメラの静止画、CI ジョブ、生徒の画面)が自動グリッド内の「ウィンドウ」になり、それぞれが継続的な data-activity レベル、単発の activity(window, level, note) 呼び出し、または静かな期間の後に起きた自身の DOM の変化(MutationObserver、バッチ処理)によって活動を報告します。そのため、規則的に更新されるタイルは落ち着いたままで、新しい動きだけが目立ちます。
文化的リファレンス
裏窓、アルフレッド・ヒッチコック(1954年、映画)。自宅のアパートで療養中の男は、中庭の向こうのすべての窓を同時に眺め、そのうちの 1 つで何かが変わったことに気づくと、レンズを向けてその窓だけをじっくり観察します。UI では、ライブフィードのウォールがすべてを視界に収め、何かが変わったばかりの窓を光らせ、落ち着いた窓はそのままにします。任意の窓をフォーカスビューに引き上げることができ、その間もほかの窓はフィルムストリップからシグナルを送り続けます。
仕組み
すべてのフィードを同時に見守り、どこを見るべきかを教えてくれる監視ウォール:任意の子要素(ライブ統計、動画、カメラの静止画、CI ジョブ、生徒の画面)が自動グリッド内の「ウィンドウ」になり、それぞれが継続的な data-activity レベル、単発の activity(window, level, note) 呼び出し、または静かな時間のあとの自身の DOM の変化(MutationObserver。バッチ処理されるため、一定間隔で更新されるタイルは落ち着いたままで、新しい出来事だけが目立ちます)によってアクティビティを報告します。アクティブなウィンドウは、時間とともに薄れていく言葉とアイコンによるステータス(Changed、Active、Alert)とともにやわらかく光り、落ち着いたウィンドウはそのまま、更新が止まったフィードには「Stalled」のフラグが付きます。すべてのウィンドウは 30 本のバーによるアクティビティのタイムラインを保持します。ウィンドウをクリックするか、そのフォーカスボタンで Enter を押すと、元の位置から FLIP でなめらかに移動してフォーカスビューに表示され(均等な拡大縮小とトリミングで引き伸ばしはなく、モーション軽減時は即時)、最近のアクティビティのログが表示されます。ほかのウィンドウは光り続けるフィルムストリップに縮小されます。←/→ で切り替え、Escape で戻ります。ウォッチピンを付けたウィンドウは、変化したときにスクリーンリーダーに通知し、フォーカスビューにジャンプボタンを表示します。mv-activity と mv-focus-window はキャンセル可能です。
| カテゴリー | レイアウト |
|---|---|
| タイプ | Web Component(<mv-courtyard>) |
| ステータス | 安定版 |
| キット | アラート疲れのないモニタリング |
| 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
インストール
node scripts/add.mjs courtyard --out ./src/marvelousMarvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["courtyard"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
コピーされるファイル(依存関係を含む):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。
使い方
クイックスタート、動作する最小限のマークアップ:
<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>基本となるマークアップです。ここから属性、data-*、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>API
Attributes
| Name | タイプ | 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 | タイプ | 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 | タイプ | 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. Durations (“53s ago”, “No updates for 2 minutes”) and log times follow the nearest lang attribute (English by default); the surrounding words come from strings.