独家

Courtyard <mv-courtyard>

同时监视所有信息源并告诉你该看哪里的监控墙:任意子元素(实时统计、视频、摄像头截图、CI 任务、学生屏幕)在自动网格中变成“窗口”,每个窗口通过持续的 data-activity 级别、一次性的 activity(window, level, note) 调用,或在一段平静后自身 DOM 的变化来报告活动(MutationObserver,批量处理,因此稳定跳动的磁贴保持平静,只有新消息才会凸显)。

文化出处

后窗,阿尔弗雷德·希区柯克(1954 年,电影)。一名男子困在公寓里养伤,同时观察庭院对面的每一扇窗户,一旦其中某扇窗有了变化,他就举起长焦镜头,只仔细看那一扇。在 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>)
状态稳定版
套件没有告警疲劳的监控
Keywordsexclusive, 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/marvelous

使用 Marvelous 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类型DefaultDescription
labelstringMonitoring wallAccessible name of the wall (role="group"), unless you set aria-label or aria-labelledby yourself.
observebooleanfalseWatches 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".
quiettime ("4s", "800ms", ms number)4sStillness a window must keep before an observed change counts as news. Raise it for feeds that tick often.
decaytime20sHow 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.
staletimeWith 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.
historytime1mTime span of the activity timeline (30 bars).
announceauto | watched | offautoScreen 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-windowmarker attributeMarks the windows among the children. Without any, every direct element child is a window.
data-labelstringOn a window: its name (otherwise aria-label, aria-labelledby, the first heading or [data-window-title], else “Window N”).
data-activitycalm | 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-notestringOn a window: the reason shown next to its sustained level (“Error rate 6.4%”).
data-watchbooleanOn 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 componentHost: 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
windowsElement[]The windows currently managed, in order (read-only).
focusedElement | nullThe raised window, or null on the wall (read-only).
mode"wall" | "focus"Current view (read-only).
stringsPartial<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 / labelreflectedMirror the attributes.

Methods

NameDescription
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

NameDescription
mv-activityA 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-windowCancelable, before the view changes. detail: { window (null when returning to the wall), previous, index, label, reason ("click" | "keyboard" | "cycle" | "alert" | "api") }.
mv-watchA watch pin changed from the eye button or watch(). detail: { window, watched, label }.

Content structure

NameDescription
(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

NameDescription
mv-courtyard-windowAdded to every window (frame, glow from --mv-courtyard-heat, tone from data-courtyard-level).
mv-courtyard-stripStatus 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-ringFlash ring of a window: data-flash toggles a one-shot pulse; breathes while a sustained Alert lasts.
mv-courtyard-barFocus-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类型DefaultDescription
--mv-courtyard-min15remMinimum window width in the wall grid.
--mv-courtyard-gapvar(--mv-space-3)Gap between windows.
--mv-courtyard-low / -medium / -highvar(--mv-accent) / var(--mv-warning) / var(--mv-danger)Tones of the three activity levels (glow, status icon, timeline bars).
--mv-courtyard-stage-min20remMinimum height of the raised window.
--mv-courtyard-thumb-min / -thumb-height7rem / 6.5remMinimum width and fixed height of filmstrip previews (they crop the window's top).
--mv-courtyard-heatoutputSet 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.

本页面由 AI 翻译。报告翻译问题