Exclusivebeta
Courtyard — <mv-courtyard>
Monitoring wall that watches every feed at once and tells you where to look: any children (live stats, video, camera stills, CI jobs, student screens) become “windows” in an auto grid, and each one reports activity through a sustained data-activity level, one-off activity(window, level, note) calls, or its own DOM changing after a quiet spell (MutationObserver, batched, so a steadily ticking tile stays calm and only news stands out). Active windows glow softly with a status in words and an icon (Changed, Active, Alert) that decays over time, calm ones stay calm, feeds that stop updating are flagged “Stalled”, and every window keeps a 30-bar activity timeline. Clicking a window, or Enter on its focus button, raises it into a focus view with a FLIP glide from its slot (uniform scale and crop, no stretching; instant under reduced motion) and reveals its recent-activity log, while the others shrink into a filmstrip that keeps glowing; ←/→ cycle, Escape returns. A watch pin makes a window announce itself to screen readers and pop a jump button in the focus view when it changes; mv-activity and mv-focus-window are cancelable.
| Category | Layout |
|---|---|
| Type | Web Component (<mv-courtyard>) |
| Status | beta |
| 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
Install
node scripts/add.mjs courtyard --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["courtyard"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): 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.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<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>Cultural reference
Rear Window — Alfred Hitchcock (1954, film). Laid up in his apartment, a man watches every window across the courtyard at once, notices when something changes in one of them, then raises his lens to look closely at just that window. In the UI, a wall of live feeds keeps everything in view, makes the window where something just changed glow while calm ones stay calm, and raises any window into a focus view while the others keep signaling from a filmstrip.
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.