@weiping/mbridge-page 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weiping/mbridge-page",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "mbridge's usage at a glance inside magpie: what magpie's ledger recorded, and where mbridge's own status page is.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -23,12 +23,12 @@
23
23
  ],
24
24
  "magpie": {
25
25
  "page": {
26
- "title": "mbridge",
26
+ "title": "鹊桥",
27
27
  "titles": {
28
- "zh": "mbridge",
29
- "zh-TW": "mbridge",
30
- "ja": "mbridge",
31
- "de": "mbridge"
28
+ "zh": "鹊桥",
29
+ "zh-TW": "鵲橋",
30
+ "ja": "鵲橋",
31
+ "de": "Magpie Bridge"
32
32
  },
33
33
  "file": "page/index.html",
34
34
  "icon": "page/icon.svg"
package/page/app.js CHANGED
@@ -1,191 +1,111 @@
1
- // app.js reads what mbridge published into the plugin's options, and
2
- // draws it. It never reaches mbridge: the sandbox a magpie page runs in
3
- // has no network at all (connect-src 'none'), so mbridge pushes its
4
- // state into the options and the page reads them in-process (SP14 §7).
5
- //
6
- // A classic script, like the community's usage page: it reads the view
7
- // from globalThis.
1
+ // app.js powers the 鹊桥 plugin page inside magpie's window.
2
+ // It reads the snapshot mbridge published into magpie.options,
3
+ // renders the full dashboard, and dispatches user actions (window tabs,
4
+ // restart, and refresh).
5
+
8
6
  (async () => {
9
7
  const V = globalThis.MbridgeView;
10
- const $ = (id) => document.getElementById(id);
11
- // el makes an element with text and class
12
- function el(tag, attrs, kids) {
13
- const n = document.createElement(tag);
14
- for (const [k, v] of Object.entries(attrs || {})) {
15
- if (v === undefined || v === null) continue;
16
- if (k === "text") n.textContent = v;
17
- else if (k === "class") n.className = v;
18
- else n.setAttribute(k, v);
19
- }
20
- for (const kid of [].concat(kids || [])) {
21
- if (kid === undefined || kid === null) continue;
22
- n.appendChild(typeof kid === "object" ? kid : document.createTextNode(String(kid)));
8
+ const theme = await magpie.ready;
9
+ if (theme && theme.lang) {
10
+ if (theme.lang.toLowerCase().startsWith("zh")) {
11
+ // view.js defines lang globally inside its IIFE, but we can also set navigator/locale
23
12
  }
24
- return n;
25
13
  }
26
- const theme = await magpie.ready;
27
- let lang = theme.lang;
28
14
 
29
- // the page's own keys in the options (kept so mbridge's key survives
30
- // a write)
31
15
  let opts = {};
32
- try {
33
- opts = (await magpie.options.get()) || {};
34
- } catch {
35
- // no options yet: mbridge publishes them
36
- }
37
- const state = {
38
- mbridge: null,
39
- cmdSeq: 0,
40
- };
16
+ const current = { window: "7d" };
41
17
 
42
- // the address of mbridge's own page: selectable text, not a link (the
43
- // sandbox may navigate itself, but that is a one-way ticket, SP14 §2)
44
- function words() {
45
- const t = V.text(lang);
46
- document.querySelectorAll("[data-t]").forEach((e) => {
47
- e.textContent = t[e.dataset.t];
48
- });
49
- $("status").textContent = t.status;
50
- $("restart").textContent = t.restart;
51
- $("cmd").textContent = "mbridge service restart";
52
- $("addr").textContent = V.STATUS_URL;
53
- $("mbridge-ask").textContent = t.mbridgeAsk;
54
- $("mbridge-restart").textContent = t.mbridgeRestart;
18
+ function fail(message) {
19
+ const root = document.getElementById("root");
20
+ root.textContent = "";
21
+ const h1 = document.createElement("h1");
22
+ h1.textContent = "鹊桥";
23
+ const banner = document.createElement("div");
24
+ banner.className = "banner bad err";
25
+ banner.textContent = message;
26
+ root.append(h1, banner);
55
27
  }
56
28
 
57
- // readOptions reads the options: what mbridge published. What we write
58
- // back must be what we just read, so mbridge's snapshot is not put back
59
- // stale.
60
- async function readOptions() {
29
+ async function command(id) {
61
30
  try {
62
- opts = (await magpie.options.get()) || {};
63
- state.mbridge = opts.mbridge || null;
31
+ const o = (await magpie.options.get()) || {};
32
+ o.cmd = { id: id, seq: Date.now() };
33
+ await magpie.options.set(o);
34
+ return true;
64
35
  } catch {
65
- // the options are not readable: keep what we have
36
+ return false;
66
37
  }
67
38
  }
68
39
 
69
- function paintMbridge() {
70
- const t = V.text(lang);
71
- const v = V.mbridgeView(state.mbridge, lang);
72
- $("mbridge-age").textContent = v.none ? "" : (t.mbridgeAge + " " + v.ageText);
73
- $("mbridge-empty").textContent = v.none ? v.text : (v.noDecisions ? t.noDecisions : "");
74
- $("mbridge-empty").hidden = !(v.none || v.noDecisions);
75
- const stats = $("mbridge-stats");
76
- stats.textContent = "";
77
- if (!v.none) {
78
- const items = [
79
- [String(v.turnCount), t.mbridgeTurns],
80
- [v.coverage, t.mbridgeCoverage],
81
- [String(v.failures), t.mbridgeFailures],
82
- [v.topReason || "—", t.reason],
83
- ];
84
- for (const [value, label] of items) {
85
- const cell = document.createElement("div");
86
- cell.className = "stat";
87
- const b = document.createElement("b");
88
- b.textContent = value;
89
- const s2 = document.createElement("span");
90
- s2.textContent = label;
91
- cell.append(b, s2);
92
- stats.append(cell);
40
+ async function setWindow(kind) {
41
+ current.window = kind;
42
+ try {
43
+ const o = (await magpie.options.get()) || {};
44
+ o.period = kind;
45
+ await magpie.options.set(o);
46
+ // Ask mbridge to immediately publish the updated window
47
+ await command("refresh");
48
+ } catch {}
49
+ }
50
+
51
+ async function readOptions() {
52
+ try {
53
+ opts = (await magpie.options.get()) || {};
54
+ if (opts.period && (opts.period === "1d" || opts.period === "7d" || opts.period === "30d")) {
55
+ current.window = opts.period;
93
56
  }
57
+ return opts.mbridge || null;
58
+ } catch (e) {
59
+ return null;
94
60
  }
95
- const live = $("mbridge-live");
96
- live.textContent = "";
97
- if (v.none) {
98
- live.append(el("div", { class: "muted", text: t.mbridgeNone }));
99
- } else if (v.emptyLive) {
100
- live.append(el("div", { class: "muted", text: t.mbridgeNoLive }));
101
- } else {
102
- for (const c of v.live) {
103
- const bits = [];
104
- if (c.reason) bits.push(c.reason);
105
- if (c.conf !== undefined && c.conf !== null) bits.push("conf " + c.conf.toFixed(2));
106
- if (c.tools !== null && c.tools !== undefined) bits.push(t.tools + " " + c.tools);
107
- live.append(el("div", { class: "card" }, [
108
- el("div", { class: "row1" }, [
109
- el("span", { text: c.harness + " · " + c.agent }),
110
- el("span", { text: c.tier + (c.group ? " → " + c.group : "") }),
111
- ]),
112
- el("div", { class: "row2", text: c.session + " · " + bits.join(" · ") }),
113
- ]));
114
- }
61
+ }
62
+
63
+ function renderView(data) {
64
+ if (!data) {
65
+ fail("鹊桥 正在等待 mbridge 发布快照数据…");
66
+ return;
115
67
  }
116
- const body = $("mbridge-decisions");
117
- body.textContent = "";
118
- for (const d of v.decisions || []) {
119
- const tr = document.createElement("tr");
120
- const cells = [
121
- [d.clock, "muted"],
122
- [d.harness, ""],
123
- [d.agent, ""],
124
- [d.tier, ""],
125
- [d.advised, d.differs ? "differs" : "muted"],
126
- [d.reason, "muted"],
127
- [d.conf === undefined || d.conf === null ? "—" : d.conf.toFixed(2), "num"],
128
- ];
129
- for (const [text, cls] of cells) {
130
- const td = document.createElement("td");
131
- if (cls) td.className = cls;
132
- td.textContent = text;
133
- tr.append(td);
68
+ V.render(data);
69
+
70
+ // Wire up tab switching in the rendered view
71
+ const tabButtons = document.querySelectorAll(".tabs button");
72
+ tabButtons.forEach((btn) => {
73
+ const kind = btn.textContent.trim();
74
+ if (kind === "1d" || kind === "7d" || kind === "30d") {
75
+ btn.onclick = async () => {
76
+ await setWindow(kind);
77
+ // optimistically update tab highlight
78
+ tabButtons.forEach((b) => b.setAttribute("aria-pressed", String(b.textContent.trim() === kind)));
79
+ };
134
80
  }
135
- body.append(tr);
136
- }
137
- }
81
+ });
138
82
 
139
- function paint() {
140
- paintMbridge();
83
+ // Wire up the restart button in the rendered view
84
+ const restartBtn = document.querySelector(".restart");
85
+ if (restartBtn) {
86
+ restartBtn.onclick = async () => {
87
+ restartBtn.disabled = true;
88
+ restartBtn.textContent = "正在重启…";
89
+ const ok = await command("restart");
90
+ if (ok) {
91
+ restartBtn.textContent = "已发出重启指令";
92
+ } else {
93
+ restartBtn.textContent = "指令发送失败";
94
+ restartBtn.disabled = false;
95
+ }
96
+ };
97
+ }
141
98
  }
142
99
 
143
- // command asks mbridge to do something. The sequence number makes it
144
- // one-shot: the page repeats it until mbridge answers, and mbridge
145
- // runs it once.
146
- async function command(id) {
147
- state.cmdSeq += 1;
148
- const cmd = { id: id, seq: state.cmdSeq };
149
- try {
150
- const o = (await magpie.options.get()) || {};
151
- o.cmd = cmd;
152
- await magpie.options.set(o);
153
- } catch {
154
- // mbridge will not hear it; the page says so
155
- return false;
100
+ async function poll() {
101
+ const snap = await readOptions();
102
+ if (snap) {
103
+ renderView(snap);
104
+ } else {
105
+ fail("鹊桥 正在等待 mbridge 发布快照数据…");
156
106
  }
157
- return true;
158
107
  }
159
108
 
160
- $("mbridge-ask").textContent = V.text(lang).mbridgeAsk;
161
- $("mbridge-restart").textContent = V.text(lang).mbridgeRestart;
162
- $("mbridge-ask").addEventListener("click", async () => {
163
- const b = $("mbridge-ask");
164
- const t = V.text(lang);
165
- b.disabled = true;
166
- b.textContent = t.mbridgeAsking;
167
- const ok = await command("refresh");
168
- if (!ok) { b.disabled = false; b.textContent = t.mbridgeAsk; }
169
- // mbridge publishes and the next poll picks it up
170
- setTimeout(() => { b.disabled = false; b.textContent = t.mbridgeAsk; load(); }, 3000);
171
- });
172
- $("mbridge-restart").addEventListener("click", async () => {
173
- const b = $("mbridge-restart");
174
- const t = V.text(lang);
175
- b.disabled = true;
176
- b.textContent = t.mbridgeRestarting;
177
- const ok = await command("restart");
178
- if (!ok) { b.disabled = false; b.textContent = t.mbridgeRestart; return; }
179
- b.textContent = t.mbridgeRestartAsked;
180
- });
181
-
182
- words();
183
- await readOptions();
184
- paint();
185
- // mbridge's snapshot moves every fifteen seconds, and reading the
186
- // options is free
187
- setInterval(async () => {
188
- await readOptions();
189
- paint();
190
- }, 5000);
109
+ await poll();
110
+ setInterval(poll, 5000);
191
111
  })();
package/page/index.html CHANGED
@@ -3,55 +3,13 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>mbridge</title>
6
+ <title>鹊桥</title>
7
7
  <link rel="stylesheet" href="style.css">
8
8
  <script src="_magpie/bridge.js"></script>
9
9
  <script src="view.js"></script>
10
10
  </head>
11
11
  <body>
12
- <main class="root">
13
- <header class="head">
14
- <h1 data-t="title">mbridge</h1>
15
- <p class="subtitle"></p>
16
- </header>
17
-
18
- <section class="mbridge">
19
- <div class="mbridge-head">
20
- <h2 data-t="mbridge">mbridge</h2>
21
- <span id="mbridge-age" class="muted"></span>
22
- </div>
23
- <div id="mbridge-stats" class="stats"></div>
24
- <h3 data-t="mbridgeLive">sessions running now</h3>
25
- <div id="mbridge-live" class="cards"></div>
26
- <h3 data-t="mbridgeDecisions">recent decisions</h3>
27
- <table class="table mbridge-table">
28
- <thead>
29
- <tr>
30
- <th></th><th data-t="harness">harness</th><th data-t="agent">agent</th>
31
- <th data-t="tier">tier</th><th data-t="advised">advised</th>
32
- <th data-t="reason">reason</th><th class="num" data-t="conf">confidence</th>
33
- </tr>
34
- </thead>
35
- <tbody id="mbridge-decisions"></tbody>
36
- </table>
37
- <p id="mbridge-empty" class="muted"></p>
38
- <div class="mbridge-actions">
39
- <button type="button" id="mbridge-ask" class="btn"></button>
40
- <button type="button" id="mbridge-restart" class="btn"></button>
41
- </div>
42
- </section>
43
-
44
- <section class="notes">
45
- <p class="note">
46
- <span id="status"></span>
47
- <span class="addr" id="addr" title="select and copy it"></span>
48
- <span id="restart"></span>
49
- <code class="cmd" id="cmd"></code>
50
- </p>
51
- </section>
52
-
53
- <p class="error" id="error" hidden></p>
54
- </main>
12
+ <main id="root"></main>
55
13
  <script src="app.js" defer></script>
56
14
  </body>
57
15
  </html>
package/page/style.css CHANGED
@@ -1,67 +1,87 @@
1
- /* Only magpie's own variables, so the page follows the window's theme in
2
- both light and dark without knowing either. */
3
- .root {
4
- --pad: 12px;
5
- padding: var(--pad);
6
- font: 12px/1.5 var(--magpie-font, system-ui, -apple-system, "Segoe UI", sans-serif);
7
- color: var(--magpie-fg, #1a1a1a);
8
- background: var(--magpie-bg, transparent);
9
- }
10
- h1 { margin: 0; font-size: 1.05em; font-weight: 600; }
11
- .subtitle { margin: 2px 0 0; color: var(--magpie-fg-muted, #6b6b6b); }
12
- .since { margin: 0 0 8px; color: var(--magpie-fg-muted, #6b6b6b); font-size: .9em; }
13
- .bar { display: flex; gap: 8px; justify-content: space-between; flex-wrap: wrap; margin-bottom: 10px; }
14
- .segmented { display: flex; gap: 2px; }
15
- .segmented button {
16
- font: inherit; color: inherit; cursor: pointer; padding: 2px 8px; border-radius: 5px;
17
- border: 1px solid var(--magpie-border, #d8d8d8);
18
- background: var(--magpie-bg-subtle, transparent);
19
- }
20
- .segmented button[aria-selected="true"] {
21
- background: var(--magpie-accent, #2f6feb); border-color: transparent; color: #fff;
22
- }
23
- .tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 14px; }
24
- .tile {
25
- padding: 6px 8px; border-radius: 6px;
26
- border: 1px solid var(--magpie-border, #e3e3e3);
27
- background: var(--magpie-bg-subtle, transparent);
28
- }
29
- .tile .k { color: var(--magpie-fg-muted, #6b6b6b); }
30
- .tile .v { font-size: 1.15em; font-weight: 600; font-variant-numeric: tabular-nums; }
31
- .tile .s { color: var(--magpie-fg-muted, #6b6b6b); }
32
- .models h2 { margin: 0 0 4px; font-size: 1em; font-weight: 600; }
33
- .muted { color: var(--magpie-fg-muted, #6b6b6b); }
34
- .table { width: 100%; border-collapse: collapse; }
35
- .table th, .table td { text-align: left; padding: 3px 4px; border-bottom: 1px solid var(--magpie-border, #e3e3e3); }
36
- .table .num { text-align: right; font-variant-numeric: tabular-nums; }
37
- .table th.num { text-align: right; color: var(--magpie-fg-muted, #6b6b6b); font-weight: 500; }
38
- .model { width: 55%; overflow-wrap: anywhere; }
39
- .bar { display: block; height: 2px; margin-top: 2px; background: var(--magpie-border, #e3e3e3); border-radius: 1px; overflow: hidden; }
40
- .bar > i { display: block; height: 100%; background: var(--magpie-accent, #2f6feb); }
41
- .empty { margin: 6px 0; }
42
- .notes { margin-top: 14px; }
43
- .note { margin: 4px 0; color: var(--magpie-fg-muted, #6b6b6b); }
44
- /* the address is selectable text, not a link: the sandbox may navigate
45
- itself, but that is a one-way ticket (SP14 §2) */
46
- .addr { user-select: all; color: var(--magpie-fg, inherit); }
47
- .cmd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--magpie-fg, inherit); }
48
- .error { color: var(--magpie-danger, #b42318); margin: 8px 0 0; }
1
+ :root {
2
+ color-scheme: light dark;
3
+ --bg: var(--magpie-bg, #ffffff); --fg: var(--magpie-fg, #1a1a1a); --dim: var(--magpie-fg-muted, #6b6b6b); --line: var(--magpie-border, #e3e3e3);
4
+ --ok: #1a7f37; --warn: #9a6700; --bad: #b42318; --chip: var(--magpie-bg-subtle, #f2f2f2);
5
+ }
6
+ @media (prefers-color-scheme: dark) {
7
+ :root { --bg: #17181a; --fg: #e8e8e8; --dim: #9a9a9a; --line: #2e2f33;
8
+ --ok: #57ab5a; --warn: #d0a44a; --bad: #e5534b; --chip: #232427; }
9
+ }
10
+ * { box-sizing: border-box; }
11
+ body { margin: 0; padding: 1.25rem; background: var(--bg); color: var(--fg);
12
+ font: 13px/1.5 var(--magpie-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif); }
13
+ h1 { font-size: 1.1rem; margin: 0 0 .2rem; }
14
+ h2 { font-size: .85rem; margin: 1.4rem 0 .5rem; text-transform: uppercase;
15
+ letter-spacing: .06em; color: var(--dim); }
16
+ .sub { color: var(--dim); margin: 0 0 1rem; }
17
+ .banner { padding: .5rem .7rem; border-radius: 6px; margin: .6rem 0;
18
+ background: color-mix(in srgb, var(--warn) 14%, transparent);
19
+ border: 1px solid color-mix(in srgb, var(--warn) 45%, transparent); }
20
+ .banner.bad { background: color-mix(in srgb, var(--bad) 12%, transparent);
21
+ border-color: color-mix(in srgb, var(--bad) 45%, transparent); }
22
+ table { border-collapse: collapse; width: 100%; }
23
+ th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid var(--line);
24
+ vertical-align: top; }
25
+ th { color: var(--dim); font-weight: 500; white-space: nowrap; }
26
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
27
+ .cards { display: grid; gap: .5rem; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
28
+ .card { border: 1px solid var(--line); border-radius: 8px; padding: .6rem .7rem; background: var(--chip); }
29
+ .card .row1 { display: flex; justify-content: space-between; gap: .5rem; font-weight: 600; }
30
+ .card .row2 { color: var(--dim); font-size: .85em; margin-top: .15rem;
31
+ overflow-wrap: anywhere; }
32
+ .card .row3 { margin-top: .3rem; }
33
+ .card.off { opacity: .5; }
34
+ .chip { display: inline-block; padding: .05rem .4rem; border-radius: 4px;
35
+ background: var(--chip); border: 1px solid var(--line); font-size: .85em; }
36
+ .chip.tier-fast { border-color: var(--ok); }
37
+ .chip.tier-balanced { border-color: var(--warn); }
38
+ .chip.tier-performance { border-color: var(--bad); }
39
+ .muted { color: var(--dim); }
40
+ .off { color: var(--dim); }
41
+ .bar { height: 4px; background: var(--line); border-radius: 2px; overflow: hidden; }
42
+ .bar > i { display: block; height: 100%; background: var(--fg); }
43
+ .err { color: var(--bad); }
44
+ .tabs { display: flex; gap: .3rem; margin: 0 0 .8rem; }
45
+ .tabs button { font: inherit; padding: .2rem .55rem; border-radius: 5px; cursor: pointer;
46
+ border: 1px solid var(--line); background: var(--bg); color: var(--fg); }
47
+ .tabs button[aria-pressed="true"] { background: var(--fg); color: var(--bg); }
48
+ .kv { display: grid; grid-template-columns: max-content 1fr; gap: .15rem .8rem; }
49
+ .kv > :nth-child(odd) { color: var(--dim); }
50
+ .foot { margin-top: 2rem; color: var(--dim); font-size: .85em; }
51
+ .foot ul { margin: .3rem 0; padding-left: 1.1rem; }
49
52
 
50
- /* the mbridge section: what mbridge published into the options */
51
- .mbridge { margin-top: 16px; padding-top: 10px; border-top: 1px solid var(--magpie-border, #e3e3e3); }
52
- .mbridge-head { display: flex; justify-content: space-between; align-items: baseline; }
53
- .mbridge-head h2 { margin: 0 0 4px; font-size: 1em; font-weight: 600; }
54
- .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 6px 0 10px; }
55
- .stat { padding: 5px 7px; border: 1px solid var(--magpie-border, #e3e3e3); border-radius: 6px; }
56
- .stat b { display: block; font-size: 1.1em; font-variant-numeric: tabular-nums; }
57
- .stat span { color: var(--magpie-fg-muted, #6b6b6b); font-size: .85em; }
58
- .cards { display: grid; gap: 5px; margin-bottom: 10px; }
59
- .card { padding: 5px 7px; border: 1px solid var(--magpie-border, #e3e3e3); border-radius: 6px; }
60
- .card .row1 { display: flex; justify-content: space-between; gap: .5rem; }
61
- .card .row2 { color: var(--magpie-fg-muted, #6b6b6b); font-size: .85em; }
62
- .mbridge-table td { padding: 2px 4px; }
63
- .mbridge-table td.differs { font-weight: 600; }
64
- .mbridge-actions { display: flex; gap: 6px; margin-top: 8px; }
65
- .btn { font: inherit; padding: 2px 9px; border-radius: 5px; cursor: pointer;
66
- border: 1px solid var(--magpie-border, #d8d8d8); background: var(--magpie-bg-subtle, transparent); color: inherit; }
67
- .btn[disabled] { opacity: .6; cursor: default; }
53
+ .head { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
54
+ h1 { font-size: 1.15rem; margin: 0; }
55
+ h1 .version { color: var(--dim); font-weight: 400; font-size: .8em; }
56
+ h3 { font-size: .8rem; margin: .8rem 0 .3rem; color: var(--dim); font-weight: 500; }
57
+ .pulse { display: flex; gap: 1.4rem; align-items: baseline; flex-wrap: wrap;
58
+ margin: .2rem 0 .9rem; padding: .55rem .7rem; border-radius: 8px;
59
+ border: 1px solid var(--line); background: var(--chip); }
60
+ .pulse .stat { display: flex; flex-direction: column; }
61
+ .pulse .stat b { font-size: 1.25em; font-weight: 600; font-variant-numeric: tabular-nums; }
62
+ .pulse .stat span { color: var(--dim); font-size: .8em; }
63
+ .pulse .tabs { margin-left: auto; }
64
+ .procline { display: flex; gap: 1rem; flex-wrap: wrap; color: var(--dim);
65
+ font-size: .85em; margin-bottom: .4rem; }
66
+ .procline span { white-space: nowrap; }
67
+ .dists { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
68
+ .distrow { display: grid; grid-template-columns: 8rem 1fr auto; gap: .5rem;
69
+ align-items: center; padding: 1px 0; }
70
+ .distname { overflow-wrap: anywhere; font-size: .9em; }
71
+ .distnum { font-variant-numeric: tabular-nums; font-size: .85em; color: var(--dim); }
72
+ .fixes { margin: .3rem 0 .6rem; padding-left: 1.2rem; color: var(--dim); }
73
+ .cal { margin: .5rem 0; }
74
+ .cfg { margin-top: 1.2rem; border-top: 1px solid var(--line); padding-top: .6rem; }
75
+ .cfg summary { cursor: pointer; color: var(--dim); }
76
+ .restartrow { margin-top: 1rem; }
77
+ .restart { font: inherit; padding: .3rem .8rem; border-radius: 6px; cursor: pointer;
78
+ border: 1px solid var(--line); background: var(--bg); color: var(--fg); }
79
+ .restart[disabled] { opacity: .6; cursor: default; }
80
+ table.wide { font-size: .92em; }
81
+ table.wide td { padding: .2rem .45rem; }
82
+ td.reason { color: var(--dim); }
83
+ td.differs { color: var(--fg); font-weight: 600; }
84
+ .chip.tier-fast { border-color: var(--ok); }
85
+ .chip.tier-balanced { border-color: var(--warn); }
86
+ .chip.tier-performance { border-color: var(--bad); }
87
+ .chip.differs { border-color: var(--fg); }