sloppatrol 0.1.0 → 0.1.2

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.
Files changed (73) hide show
  1. package/README.md +111 -160
  2. package/dist/census/index.js +4 -2
  3. package/dist/census/index.js.map +1 -1
  4. package/dist/census/lib/guardrails.js +38 -9
  5. package/dist/census/lib/guardrails.js.map +1 -1
  6. package/dist/census/lib/stack.d.ts +2 -1
  7. package/dist/census/lib/stack.js +8 -4
  8. package/dist/census/lib/stack.js.map +1 -1
  9. package/dist/cli/index.js +20 -4
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/cli/lib/browser.d.ts +2 -0
  12. package/dist/cli/lib/browser.js +13 -0
  13. package/dist/cli/lib/browser.js.map +1 -0
  14. package/dist/cli/lib/usage.d.ts +1 -1
  15. package/dist/cli/lib/usage.js +4 -3
  16. package/dist/cli/lib/usage.js.map +1 -1
  17. package/dist/config/lib/schema.d.ts +30 -0
  18. package/dist/config/lib/schema.js +16 -0
  19. package/dist/config/lib/schema.js.map +1 -1
  20. package/dist/dashboard/index.d.ts +4 -0
  21. package/dist/dashboard/index.js +6 -2
  22. package/dist/dashboard/index.js.map +1 -1
  23. package/dist/dashboard/lib/snapshot.d.ts +168 -30
  24. package/dist/dashboard/lib/snapshot.js +104 -37
  25. package/dist/dashboard/lib/snapshot.js.map +1 -1
  26. package/dist/dashboard/lib/summary.d.ts +230 -0
  27. package/dist/dashboard/lib/summary.js +398 -0
  28. package/dist/dashboard/lib/summary.js.map +1 -0
  29. package/dist/domain/index.d.ts +2 -0
  30. package/dist/domain/index.js +1 -0
  31. package/dist/domain/index.js.map +1 -1
  32. package/dist/domain/lib/cycle.d.ts +17 -0
  33. package/dist/domain/lib/cycle.js +2 -0
  34. package/dist/domain/lib/cycle.js.map +1 -0
  35. package/dist/{patrol → domain}/lib/pick.d.ts +2 -1
  36. package/dist/{patrol → domain}/lib/pick.js.map +1 -1
  37. package/dist/domain/lib/refactor.d.ts +2 -0
  38. package/dist/gate/index.d.ts +7 -0
  39. package/dist/gate/index.js +15 -0
  40. package/dist/gate/index.js.map +1 -1
  41. package/dist/patrol/lib/attempt.js +10 -2
  42. package/dist/patrol/lib/attempt.js.map +1 -1
  43. package/dist/patrol/lib/base.d.ts +10 -3
  44. package/dist/patrol/lib/base.js +14 -8
  45. package/dist/patrol/lib/base.js.map +1 -1
  46. package/dist/patrol/lib/cycle.d.ts +1 -1
  47. package/dist/patrol/lib/cycle.js +32 -1
  48. package/dist/patrol/lib/cycle.js.map +1 -1
  49. package/dist/patrol/lib/recover.d.ts +2 -1
  50. package/dist/patrol/lib/recover.js +16 -1
  51. package/dist/patrol/lib/recover.js.map +1 -1
  52. package/dist/patrol/lib/scorecard.js +12 -4
  53. package/dist/patrol/lib/scorecard.js.map +1 -1
  54. package/dist/patrol/lib/steps.d.ts +5 -2
  55. package/dist/patrol/lib/steps.js +9 -6
  56. package/dist/patrol/lib/steps.js.map +1 -1
  57. package/dist/playbook/index.js +3 -3
  58. package/dist/roles/lib/prompts.d.ts +1 -0
  59. package/dist/roles/lib/prompts.js +6 -0
  60. package/dist/roles/lib/prompts.js.map +1 -1
  61. package/dist/roles/lib/refactorer.d.ts +1 -0
  62. package/dist/roles/lib/refactorer.js +24 -6
  63. package/dist/roles/lib/refactorer.js.map +1 -1
  64. package/dist/roles/lib/scorecard-text.js +1 -1
  65. package/dist/roles/lib/scorecard-text.js.map +1 -1
  66. package/dist/store/index.d.ts +4 -1
  67. package/dist/store/index.js +7 -0
  68. package/dist/store/index.js.map +1 -1
  69. package/package.json +1 -1
  70. package/ui/app.js +1236 -324
  71. package/ui/index.html +35 -13
  72. package/ui/style.css +1063 -340
  73. /package/dist/{patrol → domain}/lib/pick.js +0 -0
package/ui/app.js CHANGED
@@ -1,8 +1,11 @@
1
1
  // The Sloppatrol dashboard: reads /api/state every two seconds and renders
2
- // the agents, the refactors with their gate, the check runs, the backlog and
3
- // the agent sessions. Read-only.
2
+ // the overview, the refactors with their gate, the scorecard, the check
3
+ // runs, the backlog and the agent sessions. Read-only. The server computes
4
+ // every number once (`state.summary`); the pages only show them.
4
5
 
5
- const LANES = [
6
+ /** The pages. `share` has no tab: it is the scorecard as a card to post. */
7
+ const TABS = ['overview', 'refactors', 'scorecard', 'checks', 'backlog', 'sessions', 'share'];
8
+ const ROLES = [
6
9
  ['patrol', 'Patrol'],
7
10
  ['surveyor', 'Surveyor'],
8
11
  ['refactorer', 'Refactorer'],
@@ -10,212 +13,559 @@ const LANES = [
10
13
  ['gate', 'Gate'],
11
14
  ['reviewer', 'Reviewer'],
12
15
  ];
13
- const OPPORTUNITY_FILTERS = ['open', 'verified', 'proposed', 'done', 'exhausted', 'dismissed', 'all'];
14
16
 
15
17
  let state;
16
- let selection; // { type: 'refactor' | 'check' | 'opportunity' | 'session', id }
17
- let backlogFilter = 'open';
18
+ let selection; // { type: 'refactor' | 'check' | 'opportunity' | 'session' | 'unit', id }
19
+ const filters = { refactors: 'all', scorecard: 'all', checks: 'all', backlog: 'open', sessions: 'all' };
20
+ /** The groups a person closed, by id, so a refresh keeps them closed. */
21
+ const closed = new Set(['refactors:merged', 'refactors:closed']);
18
22
  const fetched = new Map(); // full check runs and session events, by key
19
23
 
20
24
  const $ = (id) => document.getElementById(id);
21
25
 
26
+ // ---------- theme ----------
27
+ // The choice cycles system → light → dark. "System" follows the OS setting, also when it changes.
28
+
29
+ const THEMES = ['system', 'light', 'dark'];
30
+ const THEME_WORDS = { system: 'System', light: 'Light', dark: 'Dark' };
31
+ const darkQuery = matchMedia('(prefers-color-scheme: dark)');
32
+ const ICONS = {
33
+ system: '<rect x="2" y="3" width="12" height="8" rx="1"/><path d="M6 14h4M8 11v3"/>',
34
+ light:
35
+ '<circle cx="8" cy="8" r="3"/><path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1"/>',
36
+ dark: '<path d="M13 9.5A5.5 5.5 0 0 1 6.5 3a5.5 5.5 0 1 0 6.5 6.5Z"/>',
37
+ external: '<path d="M9 3h4v4M13 3 7.5 8.5M11 9.5V13H3V5h3.5"/>',
38
+ };
39
+ let themeChoice = THEMES.includes(localStorage.getItem('sloppatrol-theme'))
40
+ ? localStorage.getItem('sloppatrol-theme')
41
+ : 'system';
42
+
43
+ function icon(name) {
44
+ return `<svg viewBox="0 0 16 16" aria-hidden="true">${ICONS[name]}</svg>`;
45
+ }
46
+
47
+ function applyTheme() {
48
+ const theme = themeChoice === 'system' ? (darkQuery.matches ? 'dark' : 'light') : themeChoice;
49
+ document.documentElement.dataset.theme = theme;
50
+ const next = THEMES[(THEMES.indexOf(themeChoice) + 1) % THEMES.length];
51
+ const button = $('theme');
52
+ button.innerHTML = icon(themeChoice);
53
+ button.setAttribute('aria-label', `Theme: ${THEME_WORDS[themeChoice]}. Change the theme to ${THEME_WORDS[next]}.`);
54
+ button.title = `Theme: ${THEME_WORDS[themeChoice]}. Click for ${THEME_WORDS[next]}.`;
55
+ }
56
+
57
+ $('theme').addEventListener('click', () => {
58
+ themeChoice = THEMES[(THEMES.indexOf(themeChoice) + 1) % THEMES.length];
59
+ if (themeChoice === 'system') localStorage.removeItem('sloppatrol-theme');
60
+ else localStorage.setItem('sloppatrol-theme', themeChoice);
61
+ applyTheme();
62
+ });
63
+ darkQuery.addEventListener('change', applyTheme);
64
+ applyTheme();
65
+
66
+ // ---------- words and numbers ----------
67
+
22
68
  function esc(value) {
23
69
  return String(value ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
24
70
  }
25
71
 
72
+ const number = (n) => Number(n ?? 0).toLocaleString('en-US');
73
+
74
+ function plural(n, word, many = `${word}s`) {
75
+ return `${number(n)} ${n === 1 ? word : many}`;
76
+ }
77
+
78
+ function capital(text) {
79
+ return text ? text.charAt(0).toUpperCase() + text.slice(1) : '';
80
+ }
81
+
82
+ /** A coarse age, so the page does not change each second. */
26
83
  function ago(iso) {
27
84
  if (!iso) return '';
28
85
  const s = Math.max(0, (Date.parse(state?.now ?? Date.now()) - Date.parse(iso)) / 1000);
29
- if (s < 60) return `${Math.round(s)}s ago`;
86
+ if (s < 60) return 'now';
30
87
  if (s < 3600) return `${Math.round(s / 60)} min ago`;
31
88
  if (s < 86400) return `${Math.round(s / 3600)} h ago`;
32
89
  return `${Math.round(s / 86400)} d ago`;
33
90
  }
34
91
 
35
- function seconds(ms) {
36
- return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
92
+ function duration(ms) {
93
+ if (ms === undefined || Number.isNaN(ms)) return '';
94
+ if (ms < 1000) return `${ms} ms`;
95
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
96
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} min ${Math.round((ms % 60_000) / 1000)} s`;
97
+ return `${Math.floor(ms / 3_600_000)} h ${Math.round((ms % 3_600_000) / 60_000)} min`;
37
98
  }
38
99
 
39
100
  function clock(iso) {
40
101
  return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
41
102
  }
42
103
 
43
- /** A status word to a chip tone. */
44
- function tone(status) {
45
- if (['passed', 'verified', 'published', 'merged', 'done', 'proposed', 'approved'].includes(status)) return 'ok';
46
- if (['failed', 'rejected', 'exhausted', 'blocked'].includes(status)) return 'bad';
47
- if (['running', 'working', 'open'].includes(status)) return status === 'open' ? '' : 'run';
48
- return '';
104
+ const money = (usd) => `$${Number(usd ?? 0).toFixed(2)}`;
105
+ const short = (sha) => (sha ? sha.slice(0, 10) : '');
106
+
107
+ // ---------- shared blocks ----------
108
+
109
+ /** A status chip. The word carries the meaning; the color repeats it. */
110
+ function chip(text, tone = '') {
111
+ return `<span class="chip ${tone}"><span class="dot" aria-hidden="true"></span>${esc(text)}</span>`;
112
+ }
113
+
114
+ function tag(text) {
115
+ return `<span class="chip kind">${esc(text)}</span>`;
49
116
  }
50
117
 
51
- function chip(text, cls = tone(text)) {
52
- return `<span class="chip ${cls}"><span class="dot"></span>${esc(text)}</span>`;
118
+ /** A row of numbers. A stat with a target is a button that opens its page. */
119
+ function statBand(stats) {
120
+ return `<div class="band">${stats
121
+ .filter(Boolean)
122
+ .map(({ label, value, note, tone, go }) => {
123
+ const body = `<span class="stat-label">${esc(label)}</span>
124
+ <strong class="stat-value">${value}</strong>
125
+ ${note ? `<span class="stat-note" title="${esc(note)}">${esc(note)}</span>` : ''}`;
126
+ const cls = `stat${tone ? ` tone-${tone}` : ''}`;
127
+ return go
128
+ ? `<button type="button" class="${cls}" data-go="${esc(go)}">${body}</button>`
129
+ : `<div class="${cls}">${body}</div>`;
130
+ })
131
+ .join('')}</div>`;
53
132
  }
54
133
 
55
- function setHtml(element, html) {
56
- if (element.dataset.html !== html) {
57
- element.innerHTML = html;
58
- element.dataset.html = html;
59
- }
134
+ function commandBox(command) {
135
+ return `<div class="command"><code>${esc(command)}</code><button type="button" class="copy" data-copy="${esc(command)}" aria-label="Copy the command ${esc(command)}">Copy</button></div>`;
136
+ }
137
+
138
+ /**
139
+ * The one empty state of the dashboard: what the block shows, why it is
140
+ * empty now, and the one next step as a command.
141
+ */
142
+ function emptyState({ title, why, command, compact = false }) {
143
+ return `<div class="empty-state${compact ? ' compact' : ''}">
144
+ <h3>${esc(title)}</h3>
145
+ <p>${esc(why)}</p>
146
+ ${command ? commandBox(command) : ''}
147
+ </div>`;
148
+ }
149
+
150
+ function card(title, aside, body, cls = '') {
151
+ return `<section class="card ${cls}">
152
+ <div class="card-head"><h2>${esc(title)}</h2>${aside ? `<span class="muted">${aside}</span>` : ''}</div>
153
+ ${body}
154
+ </section>`;
155
+ }
156
+
157
+ /** A group of rows that a person can close. */
158
+ function group(id, title, count, body, tone = '') {
159
+ const open = !closed.has(id);
160
+ return `<details class="group" data-group="${esc(id)}" ${open ? 'open' : ''}>
161
+ <summary><span class="group-title ${tone}">${esc(title)}</span><span class="count">${number(count)}</span></summary>
162
+ <div class="list">${body}</div>
163
+ </details>`;
164
+ }
165
+
166
+ /** Filter buttons with counts. A button with no item is still there, so the set stays the same. */
167
+ function filterBar(page, options) {
168
+ return `<div class="filters" role="group" aria-label="Filter">${options
169
+ .map(
170
+ ({ id, label, count }) =>
171
+ `<button type="button" data-filter="${page}:${id}" aria-pressed="${filters[page] === id}" class="${filters[page] === id ? 'active' : ''}">${esc(label)}${count === undefined ? '' : ` <span class="count">${number(count)}</span>`}</button>`,
172
+ )
173
+ .join('')}</div>`;
174
+ }
175
+
176
+ function sparkline(values, max = 100) {
177
+ if (values.length < 2) return '';
178
+ const w = 96;
179
+ const h = 24;
180
+ const points = values
181
+ .map((v, i) => `${((i / (values.length - 1)) * w).toFixed(1)},${(h - 2 - (v / max) * (h - 4)).toFixed(1)}`)
182
+ .join(' ');
183
+ return `<svg class="spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${points}" /></svg>`;
184
+ }
185
+
186
+ function delta(n, unit = '') {
187
+ if (n === undefined) return '';
188
+ const cls = n > 0 ? 'up' : n < 0 ? 'down' : '';
189
+ const word = n > 0 ? 'up' : n < 0 ? 'down' : 'no change';
190
+ return `<span class="delta ${cls}" title="${word} since the scorecard before">${n > 0 ? '▲' : n < 0 ? '▼' : '±'}${Math.abs(n)}${unit}</span>`;
191
+ }
192
+
193
+ /** The colour band of a 0-100 score. */
194
+ function band(pct) {
195
+ return pct < 40 ? 'low' : pct < 70 ? 'mid' : 'high';
196
+ }
197
+
198
+ const pct = (score) => Math.round((score / 4) * 100);
199
+
200
+ function bar(parts) {
201
+ const total = parts.reduce((sum, p) => sum + p.value, 0);
202
+ if (!total) return '';
203
+ return `<div class="bar" role="img" aria-label="${esc(parts.map((p) => `${p.value} ${p.label}`).join(', '))}">${parts
204
+ .filter((p) => p.value)
205
+ .map(
206
+ (p) => `<span class="bar-${p.tone}" style="flex-grow:${p.value}" title="${esc(`${p.value} ${p.label}`)}"></span>`,
207
+ )
208
+ .join('')}</div>`;
209
+ }
210
+
211
+ // ---------- lookups ----------
212
+
213
+ const opportunityById = (id) => state.opportunities.find((o) => o.id === id);
214
+ const refactorById = (id) => state.refactors.find((r) => r.id === id);
215
+
216
+ /** What a session works on, in words. */
217
+ function subjectWords(subject) {
218
+ if (!subject) return '';
219
+ const [kind, id] = subject.split(':');
220
+ if (kind === 'refactor') return refactorById(id)?.title ?? `Refactor ${id}`;
221
+ if (kind === 'survey') return `Survey of ${short(id)}`;
222
+ if (kind === 'score') return `Scores of ${short(id)}`;
223
+ return subject;
224
+ }
225
+
226
+ const OUTCOME = {
227
+ working: ['in progress', 'run'],
228
+ waits: ['waits for you', 'warn'],
229
+ rejected: ['rejected', 'bad'],
230
+ error: ['error', 'bad'],
231
+ interrupted: ['interrupted', 'stop'],
232
+ merged: ['merged', 'ok'],
233
+ closed: ['closed', ''],
234
+ };
235
+
236
+ function outcomeChip(r) {
237
+ if (r.status === 'verified') return chip('to publish', 'warn');
238
+ if (r.status === 'published') return chip('pull request open', 'warn');
239
+ const [text, tone] = OUTCOME[r.outcome];
240
+ return chip(text, tone);
241
+ }
242
+
243
+ function stepper(r) {
244
+ const marks = { ok: '✓', bad: '✗', run: '•', wait: '…', '': '' };
245
+ const words = { ok: 'passed', bad: 'failed', run: 'running', wait: 'waits', '': 'not reached' };
246
+ return `<ol class="steps" aria-label="Pipeline">${r.steps
247
+ .map(
248
+ (s) =>
249
+ `<li class="step ${s.state}"><span class="mark" aria-hidden="true">${marks[s.state]}</span>${esc(s.label)}<span class="sr-only">: ${words[s.state]}</span></li>`,
250
+ )
251
+ .join('')}</ol>`;
60
252
  }
61
253
 
62
- // ---------- header, lanes, totals ----------
254
+ // ---------- top bar ----------
63
255
 
64
256
  function renderTop() {
65
257
  $('project').textContent = state.project;
66
- const p = state.patrol;
258
+ const a = state.summary.activity;
259
+ const tone = { running: 'run', waiting: 'ok', stopped: '' }[a.state];
260
+ const title = a.state === 'stopped' ? 'No Sloppatrol process runs now. Start a patrol with: sloppatrol' : a.detail;
67
261
  setHtml(
68
- $('meta'),
69
- [
70
- p.lastSha ? `<span>main <code>${esc(p.lastSha.slice(0, 10))}</code></span>` : '',
71
- `<span>${p.cycles} cycle${p.cycles === 1 ? '' : 's'}</span>`,
72
- p.lastCycleAt ? `<span>last cycle ${ago(p.lastCycleAt)}</span>` : '<span>no cycle yet</span>',
73
- ].join(''),
262
+ $('activity'),
263
+ `<span class="chip ${tone}" title="${esc(title)}"><span class="dot" aria-hidden="true"></span>${esc(a.text)}</span>`,
74
264
  );
75
- const statuses = Object.fromEntries(state.statuses.map((s) => [s.role, s]));
76
- setHtml(
77
- $('lanes'),
78
- LANES.map(([role, label]) => {
79
- const s = statuses[role];
80
- const status = s ? s.state : 'idle';
81
- return `<div class="lane" ${s?.sessionId ? `data-session="${esc(s.sessionId)}"` : ''}>
82
- <div class="lane-head">${esc(label)} ${chip(status, status === 'idle' ? '' : tone(status === 'done' ? 'passed' : status))}</div>
83
- <div class="lane-detail">${esc(s?.detail ?? 'Not started.')}</div>
84
- <div class="lane-time">${s ? ago(s.updatedAt) : ''}</div>
85
- </div>`;
86
- }).join(''),
265
+ }
266
+
267
+ // ---------- overview ----------
268
+
269
+ function confidenceBand() {
270
+ const s = state.summary;
271
+ const main = s.main;
272
+ const waits = s.decisions.toPublish + s.decisions.toReview;
273
+ const q = s.quality;
274
+ const last = s.cycles.last;
275
+ return statBand([
276
+ { label: 'Status', value: esc(s.verdict.text), note: s.verdict.detail, tone: s.verdict.tone },
277
+ {
278
+ label: 'Checks on main',
279
+ value: main ? (main.state === 'passed' ? 'Pass' : main.state === 'failed' ? 'Fail' : 'Running') : '–',
280
+ note: main
281
+ ? main.failed
282
+ ? `${main.failed} fails on ${short(main.sha)}`
283
+ : `On ${short(main.sha)}, ${ago(main.at)}`
284
+ : 'No run yet',
285
+ tone: main?.state === 'failed' ? 'fail' : main?.state === 'passed' ? 'pass' : undefined,
286
+ go: 'checks:main',
287
+ },
288
+ {
289
+ label: 'Waits for you',
290
+ value: number(waits),
291
+ note: waits
292
+ ? [
293
+ s.decisions.toPublish && `${s.decisions.toPublish} to publish`,
294
+ s.decisions.toReview && `${plural(s.decisions.toReview, 'pull request')} to review`,
295
+ ]
296
+ .filter(Boolean)
297
+ .join(' · ')
298
+ : 'No decision waits',
299
+ tone: waits ? 'warn' : undefined,
300
+ go: 'refactors:waits',
301
+ },
302
+ {
303
+ label: 'Backlog',
304
+ value: number(s.backlog.open),
305
+ note: s.backlog.open
306
+ ? main?.state === 'failed'
307
+ ? 'None can start while main fails'
308
+ : `${s.backlog.ready.length} ready · ${s.backlog.byStrength.strong} strong`
309
+ : 'No open opportunity',
310
+ go: 'backlog:open',
311
+ },
312
+ {
313
+ label: 'Code quality',
314
+ value: q?.overall !== undefined ? `${q.overall}% ${delta(q.delta)}` : '–',
315
+ note: q ? `${number(q.coverage.files)} of ${plural(q.coverage.totalFiles, 'file')} scored` : 'No scorecard yet',
316
+ go: 'scorecard',
317
+ },
318
+ {
319
+ label: 'Last cycle',
320
+ value: last ? esc(ago(last.at)) : '–',
321
+ note: last ? last.text : `${plural(s.cycles.count, 'cycle')}`,
322
+ tone: last?.tone === 'fail' ? 'fail' : undefined,
323
+ },
324
+ ]);
325
+ }
326
+
327
+ function attentionItem(item) {
328
+ const links = (item.links ?? [])
329
+ .map(
330
+ (l) =>
331
+ `<li><button type="button" class="link" data-select="${esc(l.select)}">${esc(l.label)}</button>${l.href ? ` <a class="external" href="${esc(l.href)}" target="_blank" rel="noreferrer" aria-label="Open the pull request on GitHub" title="Open the pull request on GitHub">${icon('external')}</a>` : ''}</li>`,
332
+ )
333
+ .join('');
334
+ const target = item.target ? `${item.target.tab}${item.target.filter ? `:${item.target.filter}` : ''}` : '';
335
+ return `<li class="attention ${item.tone}">
336
+ <div class="attention-head">
337
+ <strong>${esc(item.title)}</strong>
338
+ ${target && !item.target.select ? `<button type="button" class="link" data-go="${esc(target)}">Show</button>` : ''}
339
+ ${item.target?.select ? `<button type="button" class="link" data-select="${esc(item.target.select)}">Show</button>` : ''}
340
+ </div>
341
+ <p>${esc(item.detail)}</p>
342
+ ${links ? `<ul class="links">${links}</ul>` : ''}
343
+ ${item.command ? commandBox(item.command) : ''}
344
+ </li>`;
345
+ }
346
+
347
+ function agentsCard() {
348
+ const byRole = Object.fromEntries(state.statuses.map((s) => [s.role, s]));
349
+ const rows = ROLES.map(([role, label]) => {
350
+ const s = byRole[role];
351
+ const word = !s ? 'not started' : s.stale ? 'stopped' : s.state;
352
+ const tone = { running: 'run', done: 'ok', failed: 'bad', stopped: 'stop' }[word] ?? '';
353
+ const body = `<span class="agent-name">${esc(label)}</span>${chip(word, tone)}
354
+ <span class="agent-detail">${esc(s?.stale ? 'Stopped without an end. The next patrol cleans up.' : (s?.detail ?? 'No work yet.'))}</span>
355
+ <span class="agent-time">${s ? ago(s.updatedAt) : ''}</span>`;
356
+ return s?.sessionId
357
+ ? `<button type="button" class="agent" data-select="session:${esc(s.sessionId)}" aria-label="${esc(`${label}: ${word}. Open its session.`)}">${body}</button>`
358
+ : `<div class="agent">${body}</div>`;
359
+ }).join('');
360
+ return card('Agents', '', `<div class="agents">${rows}</div>`);
361
+ }
362
+
363
+ function qualityCard() {
364
+ const q = state.summary.quality;
365
+ if (!q)
366
+ return card(
367
+ 'Code quality',
368
+ '',
369
+ emptyState({
370
+ title: 'No scorecard yet',
371
+ why: 'The assessor scores the files of main in each cycle.',
372
+ command: 'sloppatrol score',
373
+ compact: true,
374
+ }),
375
+ );
376
+ const rows = q.metrics
377
+ .map(
378
+ (m) => `<li><span>${esc(m.name)}</span>
379
+ <span class="meter" aria-hidden="true"><span class="${band(m.value)}" style="width:${m.value}%"></span></span>
380
+ <span class="num">${m.value}%</span>${delta(m.delta) || '<span></span>'}</li>`,
381
+ )
382
+ .join('');
383
+ return card(
384
+ 'Code quality',
385
+ `<button type="button" class="link" data-go="scorecard">Scorecard</button>`,
386
+ `<ul class="meters">${rows}</ul>
387
+ <p class="muted small">${number(q.coverage.files)} of ${plural(q.coverage.totalFiles, 'file')} scored (${q.coverage.share}% of the lines) on ${short(q.sha)}.</p>`,
87
388
  );
88
- const count = (list, ...statuses) => list.filter((x) => statuses.includes(x.status)).length;
89
- // Only refactors the gate judged: an attempt that failed or stopped was never judged.
90
- const gate = state.refactors.filter((r) => !['working', 'failed'].includes(r.status));
91
- const kept = count(gate, 'verified', 'published', 'merged', 'closed');
92
- setHtml(
93
- $('stats'),
94
- [
95
- [state.opportunities.filter((o) => backlogStatus(o) === 'open').length, 'open opportunities'],
96
- [count(state.refactors, 'verified'), 'verified, to publish'],
97
- [count(state.refactors, 'published'), 'pull requests open'],
98
- [count(state.refactors, 'merged'), 'merged'],
99
- [count(state.refactors, 'rejected'), 'rejected by the gate'],
100
- [gate.length ? `${Math.round((kept / gate.length) * 100)}%` : '–', 'of refactors kept'],
101
- ]
102
- .map(([n, label]) => `<span><b>${n}</b>${label}</span>`)
103
- .join(''),
389
+ }
390
+
391
+ function outcomesCard() {
392
+ const r = state.summary.refactors;
393
+ if (!r.total) return card('Refactor results', '', emptyState({ ...refactorsEmpty(), compact: true }));
394
+ const parts = [
395
+ { label: 'merged', value: r.merged, tone: 'ok' },
396
+ { label: 'wait for you', value: r.waits, tone: 'warn' },
397
+ { label: 'closed', value: r.closed, tone: 'idle' },
398
+ { label: 'rejected by the gate', value: r.rejected, tone: 'bad' },
399
+ { label: 'stopped with an error', value: r.error, tone: 'stop' },
400
+ ];
401
+ const steps = r.rejectedBy
402
+ .map((s) => `<li><span>Rejected at ${esc(s.label)}</span><span class="num">${number(s.count)}</span></li>`)
403
+ .join('');
404
+ return card(
405
+ 'Refactor results',
406
+ `<button type="button" class="link" data-go="refactors">Refactors</button>`,
407
+ `<p class="big">${r.keptRate === undefined ? '–' : `${r.keptRate}%`} <span class="muted">of judged refactors kept (${number(r.kept)} of ${number(r.judged)})</span></p>
408
+ ${bar(parts)}
409
+ <div class="legend">${parts.map((p) => `<span><i class="bar-${p.tone}" aria-hidden="true"></i>${number(p.value)} ${esc(p.label)}</span>`).join('')}</div>
410
+ ${steps ? `<ul class="tally">${steps}</ul>` : ''}`,
104
411
  );
105
412
  }
106
413
 
414
+ function renderOverview() {
415
+ const s = state.summary;
416
+ if (s.verdict.text === 'Not started')
417
+ return `${confidenceBand()}<div class="card">${emptyState({
418
+ title: 'Sloppatrol has not run on this repo yet',
419
+ why: 'Run one cycle. It runs your checks on main, scores the code, records the first opportunities, and tries the best one. If you are not sure that the setup is complete, run sloppatrol doctor first.',
420
+ command: 'sloppatrol --once',
421
+ })}</div>`;
422
+ const attention = s.attention.length
423
+ ? `<ul class="attention-list">${s.attention.map(attentionItem).join('')}</ul>`
424
+ : emptyState({
425
+ title: 'Nothing needs attention',
426
+ why: 'The checks pass on main, no refactor waits for you, and no agent failed in the last 7 days.',
427
+ command: 'sloppatrol --once',
428
+ compact: true,
429
+ });
430
+ const recent = state.refactors.slice(0, 5);
431
+ return `${confidenceBand()}
432
+ <div class="grid two">
433
+ ${card('Needs attention', s.attention.length ? plural(s.attention.length, 'item') : '', attention, 'attention-card')}
434
+ ${agentsCard()}
435
+ </div>
436
+ <div class="grid two">
437
+ ${qualityCard()}
438
+ ${outcomesCard()}
439
+ </div>
440
+ ${
441
+ recent.length
442
+ ? card(
443
+ 'Recent refactors',
444
+ `<button type="button" class="link" data-go="refactors">All ${number(s.refactors.total)}</button>`,
445
+ `<div class="list">${recent.map(refactorRowHtml).join('')}</div>`,
446
+ )
447
+ : ''
448
+ }`;
449
+ }
450
+
107
451
  // ---------- refactors ----------
108
452
 
109
- function opportunityOf(refactor) {
110
- return state.opportunities.find((o) => o.id === refactor.opportunityId);
111
- }
112
-
113
- function gateRun(refactor) {
114
- return state.checkRuns.find((run) => run.refactorId === refactor.id && run.purpose === 'gate');
115
- }
116
-
117
- /** The five steps of a refactor, each `ok`, `bad`, `run`, or pending. */
118
- function steps(refactor) {
119
- const v = refactor.verdict;
120
- const run = gateRun(refactor);
121
- const working = refactor.status === 'working';
122
- const agent = refactor.headSha ? 'ok' : working ? 'run' : 'bad';
123
- const guards = v ? (v.guards.length ? 'bad' : 'ok') : '';
124
- const checks = run ? { running: 'run', passed: 'ok', failed: 'bad' }[run.status] : '';
125
- const proof = refactor.proof;
126
- const tests = opportunityOf(refactor)?.side === 'tests';
127
- const mutationRun = state.checkRuns.find((r) => r.refactorId === refactor.id && r.purpose === 'mutation');
128
- const mutation = proof?.behaviour?.mutation
129
- ? proof.behaviour.mutation.caught
130
- ? 'ok'
131
- : 'bad'
132
- : mutationRun?.status === 'running'
133
- ? 'run'
134
- : '';
135
- const metricsDone = proof?.improvement ? (proof.improvement.improved ? 'ok' : 'bad') : '';
136
- const ready = checks === 'ok' && (!tests || mutation === 'ok');
137
- const metrics = metricsDone || (working && ready ? 'run' : '');
138
- const review = v?.review ? (v.review.approve ? 'ok' : 'bad') : working && metrics === 'ok' ? 'run' : '';
139
- const pr = refactor.prUrl ? (refactor.status === 'closed' ? 'bad' : 'ok') : '';
140
- const checkLabel = run?.status === 'running' && run.current ? `Checks: ${run.current}` : 'Checks';
141
- return [
142
- ['Refactorer', agent],
143
- ['Guards', guards],
144
- [checkLabel, checks],
145
- ...(tests ? [['Mutation', mutation]] : []),
146
- ['Metrics', metrics],
147
- ['Review', review],
148
- [refactor.status === 'merged' ? 'Merged' : 'Pull request', pr],
149
- ];
453
+ function refactorsEmpty() {
454
+ const s = state.summary;
455
+ if (s.main?.state === 'failed')
456
+ return {
457
+ title: 'No refactor can start',
458
+ why: `The check ${s.main.failed} fails on main. The gate needs green checks on main to verify a refactor. Fix main, then run the checks again.`,
459
+ command: 'sloppatrol check',
460
+ };
461
+ if (!s.backlog.total)
462
+ return {
463
+ title: 'No refactor yet',
464
+ why: 'The refactorer works on the opportunities that the surveyor records. The backlog is empty.',
465
+ command: 'sloppatrol survey',
466
+ };
467
+ return {
468
+ title: 'No refactor yet',
469
+ why: `${plural(s.backlog.open, 'opportunity', 'opportunities')} ${s.backlog.open === 1 ? 'waits' : 'wait'} for the refactorer. Try the best one now.`,
470
+ command: 'sloppatrol refactor',
471
+ };
150
472
  }
151
473
 
152
- function stepper(refactor) {
153
- const marks = { ok: '✓', bad: '✗', run: '•', '': '' };
154
- return `<div class="steps">${steps(refactor)
155
- .map(([label, s]) => `<span class="step ${s}"><span class="mark">${marks[s]}</span>${esc(label)}</span>`)
156
- .join('<span class="arrow">→</span>')}</div>`;
474
+ function refactorRowHtml(r) {
475
+ const stat = r.diffStat ? `+${r.diffStat.added} −${r.diffStat.removed} in ${plural(r.diffStat.files, 'file')}` : '';
476
+ const why =
477
+ r.rejectedAt && r.reason
478
+ ? `Rejected at ${r.steps.find((s) => s.id === r.rejectedAt)?.label ?? r.rejectedAt}: ${r.reason.split('\n')[0]}`
479
+ : ['error', 'interrupted'].includes(r.outcome) && r.reason
480
+ ? r.reason.split('\n')[0]
481
+ : '';
482
+ return `<button type="button" class="row ${selected('refactor', r.id)}" data-select="refactor:${esc(r.id)}">
483
+ <span class="row-head">${outcomeChip(r)}<span class="row-title">${esc(r.title)}</span>${r.kind ? tag(r.kind) : ''}</span>
484
+ ${stepper(r)}
485
+ <span class="row-sub"><span>${ago(r.updatedAt)}</span>${stat ? `<span>${stat}</span>` : ''}${why ? `<span class="why">${esc(why.slice(0, 160))}</span>` : ''}</span>
486
+ </button>`;
157
487
  }
158
488
 
489
+ const REFACTOR_GROUPS = [
490
+ ['working', 'In progress', (r) => r.outcome === 'working', 'run'],
491
+ ['waits', 'Waits for you', (r) => r.outcome === 'waits', 'warn'],
492
+ ['error', 'Stopped with an error', (r) => r.outcome === 'error' || r.outcome === 'interrupted', 'bad'],
493
+ ['rejected', 'Rejected by the gate', (r) => r.outcome === 'rejected', 'bad'],
494
+ ['merged', 'Merged', (r) => r.outcome === 'merged', 'ok'],
495
+ ['closed', 'Closed without a merge', (r) => r.outcome === 'closed', ''],
496
+ ];
497
+
159
498
  function renderRefactors() {
160
- if (!state.refactors.length)
161
- return empty('No refactor yet. The refactorer starts once the surveyor has recorded opportunities.');
162
- return `<div class="list">${state.refactors
163
- .map((r) => {
164
- const o = opportunityOf(r);
165
- const stat = r.diffStat
166
- ? `+${r.diffStat.added} −${r.diffStat.removed} in ${r.diffStat.files} file${r.diffStat.files === 1 ? '' : 's'}`
167
- : '';
168
- return `<div class="row ${selected('refactor', r.id)}" data-select="refactor:${esc(r.id)}">
169
- <div class="row-head">${chip(r.status)}<span class="row-title">${esc(o?.title ?? r.branch)}</span>${o ? `<span class="chip kind">${esc(o.kind)}</span>` : ''}</div>
170
- ${stepper(r)}
171
- <div class="row-sub"><span>${ago(r.updatedAt)}</span>${stat ? `<span>${stat}</span>` : ''}${r.reason && r.status !== 'verified' ? `<span>${esc(r.reason.split('\n')[0].slice(0, 140))}</span>` : ''}</div>
172
- </div>`;
499
+ const r = state.summary.refactors;
500
+ const numbers = statBand([
501
+ { label: 'Attempts', value: number(r.total), note: `${plural(r.judged, 'refactor')} judged by the gate` },
502
+ {
503
+ label: 'Kept',
504
+ value: r.keptRate === undefined ? '–' : `${r.keptRate}%`,
505
+ note: `${number(r.kept)} of ${number(r.judged)} passed the gate`,
506
+ tone: r.keptRate === undefined ? undefined : r.keptRate >= 50 ? 'pass' : 'warn',
507
+ },
508
+ {
509
+ label: 'Waits for you',
510
+ value: number(r.waits),
511
+ note: `${state.summary.decisions.toPublish} to publish · ${state.summary.decisions.toReview} to review`,
512
+ tone: r.waits ? 'warn' : undefined,
513
+ go: 'refactors:waits',
514
+ },
515
+ { label: 'Merged', value: number(r.merged), go: 'refactors:merged' },
516
+ {
517
+ label: 'Rejected',
518
+ value: number(r.rejected),
519
+ note: r.rejectedBy[0] ? `Most at ${r.rejectedBy[0].label} (${r.rejectedBy[0].count})` : 'None',
520
+ go: 'refactors:rejected',
521
+ },
522
+ { label: 'Errors', value: number(r.error), tone: r.error ? 'fail' : undefined, go: 'refactors:error' },
523
+ ]);
524
+ if (!state.refactors.length) return `${numbers}<div class="card">${emptyState(refactorsEmpty())}</div>`;
525
+ const counts = Object.fromEntries(REFACTOR_GROUPS.map(([id, , test]) => [id, state.refactors.filter(test).length]));
526
+ const bar = filterBar('refactors', [
527
+ { id: 'all', label: 'All', count: state.refactors.length },
528
+ ...REFACTOR_GROUPS.map(([id, label]) => ({ id, label, count: counts[id] })),
529
+ ]);
530
+ const shown = REFACTOR_GROUPS.filter(([id]) => filters.refactors === 'all' || filters.refactors === id);
531
+ const groups = shown
532
+ .map(([id, label, test, tone]) => {
533
+ const list = state.refactors.filter(test);
534
+ if (!list.length) return '';
535
+ return group(`refactors:${id}`, label, list.length, list.map(refactorRowHtml).join(''), tone);
173
536
  })
174
- .join('')}</div>`;
537
+ .join('');
538
+ return `${numbers}${bar}${
539
+ groups ||
540
+ emptyState({
541
+ title: 'No refactor in this group',
542
+ why: 'No refactor has this result now. Select All to see each refactor.',
543
+ compact: true,
544
+ })
545
+ }`;
175
546
  }
176
547
 
177
548
  function refactorDetail(r) {
178
- const o = opportunityOf(r);
549
+ if (!r) return '<p>This refactor is not in the data.</p>';
550
+ const o = opportunityById(r.opportunityId);
179
551
  const v = r.verdict;
180
- const run = gateRun(r);
552
+ const gate = v?.checkRunId ?? state.checkRuns.find((c) => c.refactorId === r.id && c.purpose === 'gate')?.id;
181
553
  const sessions = state.sessions.filter((s) => s.subject === `refactor:${r.id}`);
182
554
  return `
183
- <h2>${esc(o?.title ?? r.branch)}</h2>
184
- <div class="pills">${chip(r.status)}${o ? `<span class="chip kind">${esc(o.kind)}</span><span class="chip kind">${esc(o.side)} only</span>` : ''}</div>
555
+ <h2>${esc(r.title)}</h2>
556
+ <div class="pills">${outcomeChip(r)}${o ? `${tag(o.kind)}${tag(`${o.side} only`)}` : ''}</div>
185
557
  ${stepper(r)}
558
+ ${r.status === 'verified' ? `<h3>Next step</h3><p>The gate kept this refactor. Publish it to open its pull request.</p>${commandBox('sloppatrol publish')}` : ''}
186
559
  ${r.prUrl ? `<h3>Pull request</h3><p><a href="${esc(r.prUrl)}" target="_blank" rel="noreferrer">${esc(r.prUrl)}</a></p>` : ''}
187
560
  ${r.reason ? `<h3>Reason</h3><p>${esc(r.reason)}</p>` : ''}
188
561
  ${r.summary ? `<h3>What changed</h3><p>${esc(r.summary)}</p>` : ''}
189
562
  ${o ? `<h3>Files</h3><div class="files">${o.files.map((f) => `<code>${esc(f)}</code>`).join('')}</div>` : ''}
190
563
  ${v?.guards?.length ? `<h3>Guard findings</h3>${v.guards.map((g) => `<p>✗ <b>${esc(g.guard)}</b> ${g.file ? `<code>${esc(g.file)}</code> ` : ''}${esc(g.detail)}</p>`).join('')}` : ''}
191
- ${run ? `<h3>Gate checks</h3>${checkPills(run)}<p><a href="#checks" data-select="check:${esc(run.id)}">Open the check run</a></p>` : ''}
564
+ ${gate ? `<h3>Gate checks</h3><p><button type="button" class="link" data-select="check:${esc(gate)}">Open the check run</button></p>` : ''}
192
565
  ${r.proof ? proofHtml(r.proof) : ''}
193
566
  ${v?.review ? `<h3>Review: ${v.review.approve ? 'approved' : 'rejected'}</h3><p>${esc(v.review.reasons)}</p>` : ''}
194
- <h3>Branch</h3><p><code>${esc(r.branch)}</code> from <code>${esc(r.baseSha.slice(0, 10))}</code></p>
195
- ${sessions.length ? `<h3>Agent sessions</h3>${sessionRows(sessions)}` : ''}`;
196
- }
197
-
198
- // ---------- proof ----------
199
-
200
- /**
201
- * A score for people, 0-100. The assessor picks an anchored level 0-4 (which
202
- * keeps judges consistent); a level of 3 shows as 75.
203
- */
204
- function pct(score) {
205
- return Math.round((score / 4) * 100);
206
- }
207
-
208
- /** The colour band of a 0-100 score. */
209
- function band(score) {
210
- const p = pct(score);
211
- return p < 40 ? 'low' : p < 70 ? 'mid' : 'high';
212
- }
213
-
214
- function delta(before, after) {
215
- if (before === undefined || after === undefined) return '';
216
- const d = pct(after) - pct(before);
217
- const cls = d > 0 ? 'up' : d < 0 ? 'down' : '';
218
- return `<span class="delta ${cls}">${d > 0 ? '+' : ''}${d}</span>`;
567
+ <h3>Branch</h3><p><code>${esc(r.branch)}</code> from <code>${esc(short(r.baseSha))}</code></p>
568
+ ${sessions.length ? `<h3>Agent sessions</h3><div class="list">${sessions.map(sessionRowHtml).join('')}</div>` : ''}`;
219
569
  }
220
570
 
221
571
  function fmt(n) {
@@ -243,15 +593,19 @@ function proofHtml(proof) {
243
593
  if (imp) {
244
594
  const rows = state.metrics
245
595
  .filter((m) => imp.before[m.id] !== undefined || imp.after[m.id] !== undefined)
246
- .map(
247
- (m) =>
248
- `<tr class="${imp.targets.includes(m.id) ? 'target' : ''}"><td>${esc(m.name)}${imp.targets.includes(m.id) ? ' <span class="chip kind">target</span>' : ''}</td><td>${fmt(imp.before[m.id])}</td><td>${fmt(imp.after[m.id])}</td><td>${delta(imp.before[m.id], imp.after[m.id])}</td></tr>`,
249
- )
596
+ .map((m) => {
597
+ const target = imp.targets.includes(m.id);
598
+ const change =
599
+ imp.before[m.id] !== undefined && imp.after[m.id] !== undefined
600
+ ? delta(pct(imp.after[m.id]) - pct(imp.before[m.id]))
601
+ : '';
602
+ return `<tr class="${target ? 'target' : ''}"><td>${esc(m.name)}${target ? ` ${tag('target')}` : ''}</td><td>${fmt(imp.before[m.id])}</td><td>${fmt(imp.after[m.id])}</td><td>${change}</td></tr>`;
603
+ })
250
604
  .join('');
251
605
  const s = imp.signals;
252
606
  html += `<h3>Proof: it makes the code better</h3>
253
- <p class="muted">Scored blind by ${imp.judges ?? 1} independent assessor${(imp.judges ?? 1) === 1 ? '' : 's'} (rubric <code>${esc(imp.rubric)}</code>), 0 worst to 100 best.</p>
254
- <table class="scores"><thead><tr><th>Metric</th><th>Before</th><th>After</th><th></th></tr></thead><tbody>${rows}</tbody></table>
607
+ <p class="muted">${plural(imp.judges ?? 1, 'independent assessor')} scored it blind (rubric <code>${esc(imp.rubric)}</code>), 0 worst to 100 best.</p>
608
+ <table class="scores"><thead><tr><th>Metric</th><th>Before</th><th>After</th><th>Change</th></tr></thead><tbody>${rows}</tbody></table>
255
609
  <p class="muted">Lines ${s.before.lines} → ${s.after.lines} · nesting ${s.before.maxNesting} → ${s.after.maxNesting} · comments ${s.before.commentLines} → ${s.after.commentLines} · mocks ${s.before.mocks} → ${s.after.mocks} · suppressions ${s.before.suppressions} → ${s.after.suppressions}</p>
256
610
  <p>${chip(imp.improved ? 'improved' : 'not improved', imp.improved ? 'ok' : 'bad')} ${esc(imp.reason)}</p>`;
257
611
  }
@@ -262,107 +616,136 @@ function proofHtml(proof) {
262
616
 
263
617
  // ---------- scorecard ----------
264
618
 
265
- function sparkline(values) {
266
- if (values.length < 2) return '';
267
- const w = 120;
268
- const h = 28;
269
- const points = values
270
- .map((v, i) => `${((i / (values.length - 1)) * w).toFixed(1)},${(h - (v / 4) * h).toFixed(1)}`)
271
- .join(' ');
272
- return `<svg class="spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${points}" /></svg>`;
273
- }
274
-
275
- function scoreCell(score) {
276
- if (score === undefined) return '<td class="na">·</td>';
277
- return `<td><span class="score ${band(score.score)}">${pct(score.score)}%</span></td>`;
278
- }
279
-
280
- const TABLE_ROWS = 60;
619
+ const TABLE_ROWS = 80;
281
620
 
282
- function coverageHtml(c) {
283
- if (!c) return '';
284
- const share = c.totalLines ? Math.round((c.lines / c.totalLines) * 100) : 0;
285
- return `<div class="coverage"><div class="coverage-bar"><span style="width:${share}%"></span></div>
286
- <span>Scored ${c.files.toLocaleString()} of ${c.totalFiles.toLocaleString()} files · ${share}% of ${c.totalLines.toLocaleString()} lines. Each cycle scores more; <code>sloppatrol score --files N</code> scores more now.</span></div>`;
621
+ function avg(unit) {
622
+ const scores = Object.values(unit.scores).map((s) => s.score);
623
+ return scores.reduce((a, b) => a + b, 0) / (scores.length || 1);
287
624
  }
288
625
 
289
626
  function renderScorecard() {
290
- const cards = state.scorecards;
291
- const current = cards.at(-1);
292
- if (!current) return empty('No scorecard yet. The first cycle scores the most changed and the largest files.');
293
- const previous = cards.at(-2);
294
- const means = Object.values(current.means);
295
- const mean = (values) => (values.length ? values.reduce((a, b) => a + b, 0) / values.length : undefined);
296
- const overall = (c) => mean(Object.values(c.means));
297
- // Deltas compare like with like: only the files in both scorecards, so a
298
- // file that joined or left the panel does not read as a change in quality.
299
- // An older scorecard keeps its means only, so the like-for-like change compares
300
- // with the previous scorecard only when it still holds its files.
301
- const common = previous?.units.length
302
- ? current.units.filter((u) => previous.units.some((p) => p.path === u.path))
303
- : [];
304
- const pair = (metric) => {
305
- const units = common.filter(
306
- (u) => u.scores[metric] && previous.units.find((p) => p.path === u.path).scores[metric],
307
- );
308
- return [
309
- mean(units.map((u) => previous.units.find((p) => p.path === u.path).scores[metric].score)),
310
- mean(units.map((u) => u.scores[metric].score)),
311
- ];
312
- };
313
- const pairAll = () => {
314
- const pairs = state.metrics.map((m) => pair(m.id)).filter(([b, a]) => b !== undefined && a !== undefined);
315
- return [mean(pairs.map(([b]) => b)), mean(pairs.map(([, a]) => a))];
316
- };
317
- const overallTile = means.length
318
- ? `<div class="metric overall">
319
- <div class="metric-name">Overall</div>
320
- <div class="metric-value ${band(overall(current))}">${pct(overall(current))}<span class="of">%</span> ${delta(...pairAll())}</div>
321
- ${sparkline(cards.map(overall).filter((v) => v !== undefined))}
322
- <div class="metric-q">The mean of every metric below.</div>
323
- </div>`
324
- : '';
325
- const tiles = state.metrics
326
- .filter((m) => current.means[m.id] !== undefined)
627
+ const q = state.summary.quality;
628
+ const current = state.scorecards.at(-1);
629
+ if (!q || !current)
630
+ return `<div class="card">${emptyState({
631
+ title: 'No scorecard yet',
632
+ why:
633
+ state.summary.main?.state === 'failed'
634
+ ? 'Each cycle scores the files of main, but the checks fail on main now. Score the code now, also while main is red.'
635
+ : 'Each cycle scores the most changed and the largest files of main first. Score the code now to start.',
636
+ command: 'sloppatrol score',
637
+ })}</div>`;
638
+ const lowest = [...q.metrics].sort((a, b) => a.value - b.value)[0];
639
+ const numbers = statBand([
640
+ {
641
+ label: 'Overall',
642
+ value: `${q.overall ?? '–'}% ${delta(q.delta)}`,
643
+ note: q.compared
644
+ ? `Change over the ${plural(q.compared, 'file')} in both of the last two scorecards`
645
+ : 'The mean of each metric',
646
+ tone: q.overall === undefined ? undefined : q.overall >= 70 ? 'pass' : q.overall >= 40 ? 'warn' : 'fail',
647
+ },
648
+ {
649
+ label: 'Files scored',
650
+ value: `${number(q.coverage.files)} <span class="of">of ${number(q.coverage.totalFiles)}</span>`,
651
+ note: `${q.coverage.share}% of the lines`,
652
+ },
653
+ lowest
654
+ ? {
655
+ label: 'Lowest metric',
656
+ value: esc(lowest.name),
657
+ note: `${lowest.value}%`,
658
+ tone: lowest.value < 40 ? 'fail' : 'warn',
659
+ }
660
+ : null,
661
+ {
662
+ label: 'Guardrails',
663
+ value: `${q.guardrails.present} <span class="of">of ${q.guardrails.total}</span>`,
664
+ note:
665
+ q.guardrails.present < q.guardrails.total
666
+ ? `${q.guardrails.total - q.guardrails.present} missing`
667
+ : 'All present',
668
+ },
669
+ { label: 'Scored', value: esc(ago(q.at)), note: `Commit ${short(q.sha)} · rubric ${q.rubric}` },
670
+ ]);
671
+ const tiles = q.metrics
327
672
  .map((m) => {
328
- const trend = cards.map((c) => c.means[m.id]).filter((v) => v !== undefined);
329
- return `<div class="metric" title="${esc(m.question)}">
673
+ const meta = state.metrics.find((x) => x.id === m.id);
674
+ return `<div class="metric" title="${esc(meta?.question)}">
330
675
  <div class="metric-name">${esc(m.name)}</div>
331
- <div class="metric-value ${band(current.means[m.id])}">${pct(current.means[m.id])}<span class="of">%</span> ${delta(...pair(m.id))}</div>
332
- ${sparkline(trend)}
333
- <div class="metric-q">${esc(m.question)}</div>
676
+ <div class="metric-value"><span class="score-text ${band(m.value)}">${m.value}%</span> ${delta(m.delta)}</div>
677
+ ${sparkline(m.trend)}
334
678
  </div>`;
335
679
  })
336
680
  .join('');
337
- const columns = state.metrics.map((m) => `<th title="${esc(m.question)}">${esc(m.name)}</th>`).join('');
338
- const shown = [...current.units].sort((a, b) => avg(a) - avg(b)).slice(0, TABLE_ROWS);
339
- const rows = shown
681
+ const coverage =
682
+ q.coverage.share < 100
683
+ ? `<div class="callout"><p>The scores cover ${q.coverage.share}% of the lines. Each cycle scores more files. Score each file now:</p>${commandBox('sloppatrol score')}</div>`
684
+ : '';
685
+ const guardrails = [...(current.guardrails ?? [])]
686
+ .sort((a, b) => Number(a.present) - Number(b.present))
340
687
  .map(
341
- (u) =>
342
- `<tr class="${selected('unit', u.contentHash)}" data-select="unit:${esc(u.contentHash)}"><td class="path"><code>${esc(u.path)}</code></td>${state.metrics.map((m) => scoreCell(u.scores[m.id])).join('')}<td>${u.signals.lines}</td></tr>`,
688
+ (g) =>
689
+ `<li class="${g.present ? 'ok' : 'bad'}"><span class="mark" aria-hidden="true">${g.present ? '✓' : '✗'}</span><b>${esc(g.name)}</b><span class="muted">${g.present ? '' : 'Missing: '}${esc(g.evidence)}</span></li>`,
343
690
  )
344
691
  .join('');
345
- const guardrails = (current.guardrails ?? [])
692
+ const units = current.units;
693
+ const isTest = (u) => u.scores.tests !== undefined;
694
+ const tests = units.filter(isTest).length;
695
+ const low = units.filter((u) => pct(avg(u)) < 50).length;
696
+ const list = units
697
+ .filter((u) =>
698
+ filters.scorecard === 'low'
699
+ ? pct(avg(u)) < 50
700
+ : filters.scorecard === 'tests'
701
+ ? isTest(u)
702
+ : filters.scorecard === 'source'
703
+ ? !isTest(u)
704
+ : true,
705
+ )
706
+ .sort((a, b) => avg(a) - avg(b));
707
+ const shown = list.slice(0, TABLE_ROWS);
708
+ const columns = state.metrics.map((m) => `<th scope="col" title="${esc(m.question)}">${esc(m.name)}</th>`).join('');
709
+ const cell = (score) =>
710
+ score === undefined
711
+ ? '<td class="na"><span class="sr-only">does not apply</span>·</td>'
712
+ : `<td><span class="score ${band(pct(score.score))}">${pct(score.score)}</span></td>`;
713
+ const rows = shown
346
714
  .map(
347
- (g) =>
348
- `<span class="chip ${g.present ? 'ok' : 'bad'}" title="${esc(g.evidence)}"><span class="dot"></span>${esc(g.name)}</span>`,
715
+ (u) =>
716
+ `<tr class="${selected('unit', u.contentHash)}"><th scope="row" class="path"><button type="button" class="link" data-select="unit:${esc(u.contentHash)}" title="${esc(u.path)}">${esc(u.path)}</button></th>${state.metrics.map((m) => cell(u.scores[m.id])).join('')}<td class="num">${number(u.signals.lines)}</td></tr>`,
349
717
  )
350
718
  .join('');
351
- return `<div class="metrics">${overallTile}${tiles}</div>
352
- ${guardrails ? `<h3 class="section">Repo guardrails</h3><div class="pills guardrails">${guardrails}</div>` : ''}
353
- ${coverageHtml(current.coverage)}
354
- <p class="muted">Commit <code>${esc(current.sha.slice(0, 10))}</code> · rubric <code>${esc(current.rubric)}</code> · ${shown.length < current.units.length ? `the ${shown.length} worst of ${current.units.length} scored files` : `${current.units.length} files, worst first`}${common.length ? ` · changes compare the ${common.length} files in both of the last two scorecards` : ''}</p>
355
- <table class="units"><thead><tr><th>File</th>${columns}<th>Lines</th></tr></thead><tbody>${rows}</tbody></table>`;
356
- }
357
-
358
- function avg(unit) {
359
- const scores = Object.values(unit.scores).map((s) => s.score);
360
- return scores.reduce((a, b) => a + b, 0) / (scores.length || 1);
719
+ const table = shown.length
720
+ ? `<div class="table-wrap"><table class="units"><thead><tr><th scope="col">File, lowest first</th>${columns}<th scope="col">Lines</th></tr></thead><tbody>${rows}</tbody></table></div>
721
+ ${list.length > shown.length ? `<p class="muted small">The table shows the ${shown.length} lowest of ${number(list.length)} files.</p>` : ''}`
722
+ : emptyState({
723
+ title: 'No file in this group',
724
+ why: 'No scored file matches this filter. Select All to see each file.',
725
+ compact: true,
726
+ });
727
+ return `<div class="page-actions"><a class="share-link" href="#share">Share the scorecard</a></div>
728
+ ${numbers}
729
+ <div class="metrics">${tiles}</div>
730
+ ${coverage}
731
+ <div class="grid two narrow-right">
732
+ <section class="card">
733
+ <div class="card-head"><h2>Files</h2><span class="muted">Each score is 0 worst to 100 best</span></div>
734
+ ${filterBar('scorecard', [
735
+ { id: 'all', label: 'All', count: units.length },
736
+ { id: 'low', label: 'Below 50', count: low },
737
+ { id: 'source', label: 'Source', count: units.length - tests },
738
+ { id: 'tests', label: 'Tests', count: tests },
739
+ ])}
740
+ ${table}
741
+ </section>
742
+ ${card('Repo guardrails', `${q.guardrails.present} of ${q.guardrails.total}`, `<ul class="guardrails">${guardrails}</ul>`)}
743
+ </div>`;
361
744
  }
362
745
 
363
746
  function unitDetail(hash) {
364
747
  const unit = state.scorecards.at(-1)?.units.find((u) => u.contentHash === hash);
365
- if (!unit) return '<p>Not found.</p>';
748
+ if (!unit) return '<p>This file is not in the newest scorecard.</p>';
366
749
  const s = unit.signals;
367
750
  return `<h2><code>${esc(unit.path)}</code></h2>
368
751
  <p class="muted">${s.lines} lines · nesting ${s.maxNesting} · ${s.commentLines} comment lines · ${s.mocks} mocks · ${s.suppressions} suppressions</p>
@@ -370,112 +753,555 @@ function unitDetail(hash) {
370
753
  .filter((m) => unit.scores[m.id])
371
754
  .map((m) => {
372
755
  const score = unit.scores[m.id];
373
- return `<h3>${esc(m.name)} <span class="score ${band(score.score)}">${pct(score.score)}%</span></h3>
756
+ return `<h3>${esc(m.name)} <span class="score ${band(pct(score.score))}">${pct(score.score)}</span></h3>
374
757
  <p class="muted">Level ${Math.round(score.score)} of 4: ${esc(m.levels[Math.round(score.score)])}</p><p>${esc(score.evidence)}</p>`;
375
758
  })
376
759
  .join('')}`;
377
760
  }
378
761
 
762
+ // ---------- share ----------
763
+ // One SVG card, 1200 by 675 (the 16:9 of a post on X or LinkedIn), in the
764
+ // colours of the banner. It is one image, so the browser saves it as a PNG.
765
+
766
+ const CARD = { w: 1200, h: 675, pad: 64 };
767
+ const CARD_FONT = "-apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Helvetica, Arial, sans-serif";
768
+ const CARD_MONO = "ui-monospace, 'SF Mono', Menlo, Consolas, monospace";
769
+ const CARD_TONE = { low: '#f87171', mid: '#fbbf24', high: '#4ade80' };
770
+
771
+ let measurer;
772
+ /** The width of `text` in pixels, so the card can lay out a row of words. */
773
+ function textWidth(text, font) {
774
+ measurer ??= document.createElement('canvas').getContext('2d');
775
+ measurer.font = font;
776
+ return measurer.measureText(text).width;
777
+ }
778
+
779
+ function clip(text, max) {
780
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
781
+ }
782
+
783
+ /** The logo's sparkle: a 7 by 7 pixel star, `size` pixels wide, at x, y. */
784
+ function sparkle(x, y, size) {
785
+ const cells = [
786
+ [3, 0],
787
+ [3, 1],
788
+ [2, 2],
789
+ [3, 2],
790
+ [4, 2],
791
+ [0, 3],
792
+ [1, 3],
793
+ [2, 3],
794
+ [3, 3],
795
+ [4, 3],
796
+ [5, 3],
797
+ [6, 3],
798
+ [2, 4],
799
+ [3, 4],
800
+ [4, 4],
801
+ [3, 5],
802
+ ];
803
+ const step = size / 7;
804
+ return cells
805
+ .map(
806
+ ([cx, cy]) =>
807
+ `<rect x="${(x + cx * step).toFixed(1)}" y="${(y + cy * step).toFixed(1)}" width="${(step * 0.84).toFixed(1)}" height="${(step * 0.84).toFixed(1)}" rx="${(step * 0.14).toFixed(1)}" fill="url(#c-cyan)"/>`,
808
+ )
809
+ .join('');
810
+ }
811
+
812
+ /** The scorecard as an SVG card: the overall score, each metric, the guardrails, and the coverage. */
813
+ function shareCard(q, current) {
814
+ const { w, h, pad } = CARD;
815
+ const right = w - pad;
816
+ const tone = (value) => CARD_TONE[band(value)];
817
+ const date = new Date(q.at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
818
+
819
+ // The overall score: a ring, its share of a full turn coloured.
820
+ const ring = { x: 250, y: 362, r: 118 };
821
+ const turn = 2 * Math.PI * ring.r;
822
+ const overall = q.overall ?? 0;
823
+ const c = q.coverage;
824
+ const lines = current.coverage?.totalLines ?? 0;
825
+ const covered =
826
+ c.files < c.totalFiles
827
+ ? `Scored ${number(c.files)} of ${number(c.totalFiles)} files · ${c.share}% of the lines`
828
+ : `${plural(c.totalFiles, 'file')} · ${number(lines)} lines`;
829
+
830
+ // The metrics: one row each, name, bar, and score.
831
+ const top = 196;
832
+ const row = q.metrics.length > 6 ? 52 : 60;
833
+ const barX = 560;
834
+ const barW = right - barX;
835
+ const metrics = q.metrics
836
+ .map((m, i) => {
837
+ const y = top + i * row;
838
+ return `<text x="${barX}" y="${y}" font-size="19" fill="#e2e8f0">${esc(m.name)}</text>
839
+ <text x="${right}" y="${y}" font-size="22" font-weight="700" fill="${tone(m.value)}" text-anchor="end">${m.value}</text>
840
+ <rect x="${barX}" y="${y + 12}" width="${barW}" height="8" rx="4" fill="#ffffff" fill-opacity="0.08"/>
841
+ <rect x="${barX}" y="${y + 12}" width="${((barW * m.value) / 100).toFixed(1)}" height="8" rx="4" fill="${tone(m.value)}"/>`;
842
+ })
843
+ .join('');
844
+
845
+ // The footer: the guardrails as a row of marks, then the command to run.
846
+ const command = '$ npx sloppatrol score';
847
+ const commandFont = `16px ${CARD_MONO}`;
848
+ const pillW = textWidth(command, commandFont) + 36;
849
+ const footY = 622;
850
+ const label = 'GUARDRAILS';
851
+ // The label has a letter spacing of 2 that the canvas does not measure.
852
+ const start = pad + textWidth(label, `600 12px ${CARD_FONT}`) + 2 * label.length + 16;
853
+ const itemFont = `14px ${CARD_FONT}`;
854
+ let x = start;
855
+ const room = right - pillW - 24;
856
+ const items = [];
857
+ for (const g of [...(current.guardrails ?? [])].sort((a, b) => Number(b.present) - Number(a.present))) {
858
+ const text = `${g.present ? '✓' : '✗'} ${g.name}`;
859
+ const width = textWidth(text, itemFont);
860
+ if (x + width > room) break;
861
+ items.push(
862
+ `<text x="${x.toFixed(1)}" y="${footY}" font-size="14" fill="${g.present ? '#cbd5e1' : '#f87171'}">${esc(text)}</text>`,
863
+ );
864
+ x += width + 14;
865
+ }
866
+ const guardrails =
867
+ items.length === (current.guardrails ?? []).length
868
+ ? items.join('')
869
+ : `<text x="${start.toFixed(1)}" y="${footY}" font-size="14" fill="#cbd5e1">${q.guardrails.present} of ${q.guardrails.total} present</text>`;
870
+
871
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" font-family="${CARD_FONT}" role="img" aria-label="${esc(`The code quality scorecard of ${state.project}: ${overall} out of 100 overall.`)}">
872
+ <defs>
873
+ <linearGradient id="c-bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#151a33"/><stop offset="1" stop-color="#070914"/></linearGradient>
874
+ <linearGradient id="c-cyan" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cffafe"/><stop offset="1" stop-color="#0891b2"/></linearGradient>
875
+ <radialGradient id="c-glow" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="#22d3ee" stop-opacity="0.18"/><stop offset="1" stop-color="#22d3ee" stop-opacity="0"/></radialGradient>
876
+ <pattern id="c-grid" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M32 0H0V32" fill="none" stroke="#ffffff" stroke-opacity="0.04"/></pattern>
877
+ </defs>
878
+ <rect width="${w}" height="${h}" fill="url(#c-bg)"/>
879
+ <rect width="${w}" height="${h}" fill="url(#c-grid)"/>
880
+ <circle cx="${ring.x}" cy="${ring.y}" r="240" fill="url(#c-glow)"/>
881
+
882
+ ${sparkle(pad, 42, 38)}
883
+ <text x="${pad + 50}" y="72" font-size="30" font-weight="800" letter-spacing="-0.5"><tspan fill="#f8fafc">slop</tspan><tspan fill="#67e8f9">patrol</tspan></text>
884
+ <text x="${right}" y="62" font-size="12" font-weight="600" letter-spacing="2" fill="#94a3b8" text-anchor="end">CODE QUALITY SCORECARD</text>
885
+ <text x="${right}" y="84" font-size="14" fill="#64748b" text-anchor="end">${esc(date)} · <tspan font-family="${CARD_MONO}">${esc(short(q.sha))}</tspan></text>
886
+
887
+ <text x="${pad}" y="150" font-size="40" font-weight="800" fill="#f8fafc" letter-spacing="-0.5">${esc(clip(state.project, 24))}</text>
888
+ <circle cx="${ring.x}" cy="${ring.y}" r="${ring.r}" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="18"/>
889
+ <circle cx="${ring.x}" cy="${ring.y}" r="${ring.r}" fill="none" stroke="${tone(overall)}" stroke-width="18" stroke-linecap="round" stroke-dasharray="${((turn * overall) / 100).toFixed(1)} ${turn.toFixed(1)}" transform="rotate(-90 ${ring.x} ${ring.y})"/>
890
+ <text x="${ring.x}" y="${ring.y + 26}" font-size="84" font-weight="800" fill="#f8fafc" text-anchor="middle" letter-spacing="-2">${q.overall ?? '–'}</text>
891
+ <text x="${ring.x}" y="${ring.y + 58}" font-size="13" font-weight="600" letter-spacing="2" fill="#94a3b8" text-anchor="middle">OVERALL · OF 100</text>
892
+ <text x="${ring.x}" y="${ring.y + ring.r + 58}" font-size="16" fill="#cbd5e1" text-anchor="middle">${esc(covered)}</text>
893
+
894
+ <text x="${barX}" y="150" font-size="12" font-weight="600" letter-spacing="2" fill="#94a3b8">EACH METRIC · 0 WORST TO 100 BEST</text>
895
+ ${metrics}
896
+
897
+ <line x1="${pad}" y1="572" x2="${right}" y2="572" stroke="#ffffff" stroke-opacity="0.08"/>
898
+ <text x="${pad}" y="${footY - 1}" font-size="12" font-weight="600" letter-spacing="2" fill="#94a3b8">${label}</text>
899
+ ${guardrails}
900
+ <rect x="${(right - pillW).toFixed(1)}" y="${footY - 27}" width="${pillW.toFixed(1)}" height="40" rx="10" fill="#0b1020" stroke="#22d3ee" stroke-opacity="0.45"/>
901
+ <text x="${(right - pillW + 18).toFixed(1)}" y="${footY}" font-size="16" font-family="${CARD_MONO}"><tspan fill="#67e8f9">$</tspan><tspan fill="#f8fafc">${esc(command.slice(1))}</tspan></text>
902
+ </svg>`;
903
+ }
904
+
905
+ function renderShare() {
906
+ const q = state.summary.quality;
907
+ const current = state.scorecards.at(-1);
908
+ const back = '<a class="share-back" href="#scorecard">← The dashboard</a>';
909
+ if (!q || !current)
910
+ return `<div class="share">${back}<div class="card">${emptyState({
911
+ title: 'No scorecard yet',
912
+ why: 'The card shows the scores of each file. Score the code to make it.',
913
+ command: 'sloppatrol score',
914
+ })}</div></div>`;
915
+ const status =
916
+ q.coverage.files < q.coverage.totalFiles
917
+ ? `<span class="muted">The scores cover ${q.coverage.share}% of the lines. The card updates while files get scored.</span>`
918
+ : '';
919
+ return `<div class="share">
920
+ <div class="share-bar">${back}${status}<span class="share-actions">
921
+ <button type="button" class="copy" data-share="copy">Copy image</button>
922
+ <button type="button" class="copy" data-share="png">Download PNG</button>
923
+ </span></div>
924
+ <div class="share-card" id="share-card">${shareCard(q, current)}</div>
925
+ </div>`;
926
+ }
927
+
928
+ /** The card as a PNG, at twice its size, so it stays sharp on a phone. */
929
+ function cardPng() {
930
+ const svg = $('share-card').querySelector('svg');
931
+ const image = new Image();
932
+ const loaded = new Promise((done, fail) => {
933
+ image.onload = done;
934
+ image.onerror = fail;
935
+ });
936
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
937
+ return loaded.then(() => {
938
+ const canvas = document.createElement('canvas');
939
+ canvas.width = CARD.w * 2;
940
+ canvas.height = CARD.h * 2;
941
+ canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height);
942
+ return new Promise((done) => canvas.toBlob(done, 'image/png'));
943
+ });
944
+ }
945
+
946
+ async function shareAction(button) {
947
+ const word = button.textContent;
948
+ try {
949
+ if (button.dataset.share === 'copy') {
950
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': cardPng() })]);
951
+ button.textContent = 'Copied';
952
+ } else {
953
+ const link = document.createElement('a');
954
+ link.href = URL.createObjectURL(await cardPng());
955
+ link.download = `sloppatrol-${state.project.replace(/[^\w.-]+/g, '-')}.png`;
956
+ link.click();
957
+ URL.revokeObjectURL(link.href);
958
+ button.textContent = 'Saved';
959
+ }
960
+ } catch {
961
+ button.textContent = 'Failed';
962
+ }
963
+ setTimeout(() => {
964
+ button.textContent = word;
965
+ }, 1500);
966
+ }
967
+
379
968
  // ---------- checks ----------
380
969
 
970
+ const PURPOSE = { base: 'main', gate: 'gate', refactorer: 'refactorer try', mutation: 'mutation' };
971
+ const RUN_TONE = { ok: 'ok', bad: 'bad', run: 'run', stop: 'stop' };
972
+
381
973
  function checkPills(run) {
382
- const done = run.results.map((r) => chip(`${r.name} ${seconds(r.durationMs)}`, r.ok ? 'ok' : 'bad'));
383
- if (run.status === 'running' && run.current) done.push(chip(`${run.current}…`, 'run'));
974
+ const done = run.results.map((r) => chip(`${r.name} ${duration(r.durationMs)}`, r.ok ? 'ok' : 'bad'));
975
+ if (run.status === 'running' && run.current)
976
+ done.push(chip(`${run.current}…`, run.outcome.tone === 'run' ? 'run' : 'stop'));
384
977
  return `<div class="pills">${done.join('')}</div>`;
385
978
  }
386
979
 
387
- const PURPOSE = { base: 'main', gate: 'gate', refactorer: 'refactorer try', mutation: 'mutation' };
980
+ function checkRowHtml(run) {
981
+ const end = run.finishedAt ? Date.parse(run.finishedAt) : Date.parse(state.now);
982
+ const took = duration(end - Date.parse(run.startedAt));
983
+ const where =
984
+ run.purpose === 'base'
985
+ ? `main at ${short(run.sha)}`
986
+ : (refactorById(run.refactorId)?.title ?? run.branch ?? short(run.sha));
987
+ return `<button type="button" class="row ${selected('check', run.id)}" data-select="check:${esc(run.id)}">
988
+ <span class="row-head">${chip(run.outcome.text, RUN_TONE[run.outcome.tone])}${tag(PURPOSE[run.purpose])}<span class="row-title">${esc(where)}</span></span>
989
+ ${checkPills(run)}
990
+ <span class="row-sub"><span>started ${ago(run.startedAt)}</span><span>${run.outcome.tone === 'run' ? 'running for' : 'took'} ${took}</span>${run.branch && run.purpose !== 'base' ? `<code>${esc(run.branch)}</code>` : ''}</span>
991
+ </button>`;
992
+ }
388
993
 
389
994
  function renderChecks() {
995
+ const c = state.summary.checks;
996
+ const main = state.summary.main;
997
+ const numbers = statBand([
998
+ {
999
+ label: 'Checks on main',
1000
+ value: main ? (main.state === 'passed' ? 'Pass' : 'Fail') : '–',
1001
+ note: main
1002
+ ? main.failed
1003
+ ? `${main.failed} fails on ${short(main.sha)}`
1004
+ : `On ${short(main.sha)}`
1005
+ : 'No run yet',
1006
+ tone: main ? (main.state === 'passed' ? 'pass' : 'fail') : undefined,
1007
+ go: 'checks:main',
1008
+ },
1009
+ { label: 'Runs', value: number(c.runs), note: `${plural(c.problems, 'problem')}`, go: 'checks:bad' },
1010
+ {
1011
+ label: 'Gate',
1012
+ value: c.gate.runs ? `${Math.round(((c.gate.runs - c.gate.bad) / c.gate.runs) * 100)}%` : '–',
1013
+ note: `${number(c.gate.runs - c.gate.bad)} of ${plural(c.gate.runs, 'refactor')} passed`,
1014
+ go: 'checks:gate',
1015
+ },
1016
+ {
1017
+ label: 'Refactorer tries',
1018
+ value: number(c.refactorer.runs),
1019
+ note: `${number(c.refactorer.bad)} failed before the gate`,
1020
+ go: 'checks:refactorer',
1021
+ },
1022
+ {
1023
+ label: 'Planted bugs caught',
1024
+ value: c.planted ? `${c.caught} <span class="of">of ${c.planted}</span>` : '–',
1025
+ note: 'A tests refactor must catch its bug',
1026
+ go: 'checks:mutation',
1027
+ },
1028
+ ]);
390
1029
  if (!state.checkRuns.length)
391
- return empty('No check run yet. The first cycle runs the checks on the patrolled commit.');
392
- return `<div class="list">${state.checkRuns
393
- .map((run) => {
394
- const end = run.finishedAt ? Date.parse(run.finishedAt) : Date.parse(state.now);
395
- const took = seconds(end - Date.parse(run.startedAt));
396
- const where = run.branch ? `<code>${esc(run.branch)}</code>` : `<code>${esc(run.sha.slice(0, 10))}</code>`;
397
- return `<div class="row ${selected('check', run.id)}" data-select="check:${esc(run.id)}">
398
- <div class="row-head">${chip(run.status)}<span class="chip kind">${esc(PURPOSE[run.purpose])}</span><span class="row-title">${where}</span></div>
399
- ${checkPills(run)}
400
- <div class="row-sub"><span>started ${ago(run.startedAt)}</span><span>${run.status === 'running' ? 'running for' : 'took'} ${took}</span></div>
401
- </div>`;
402
- })
403
- .join('')}</div>`;
1030
+ return `${numbers}<div class="card">${emptyState({
1031
+ title: 'No check run yet',
1032
+ why: 'Sloppatrol runs your checks on main first. Green checks on main are the oracle for each refactor.',
1033
+ command: 'sloppatrol check',
1034
+ })}</div>`;
1035
+ const match = {
1036
+ all: () => true,
1037
+ bad: (r) => r.problem,
1038
+ main: (r) => r.purpose === 'base',
1039
+ gate: (r) => r.purpose === 'gate',
1040
+ refactorer: (r) => r.purpose === 'refactorer',
1041
+ mutation: (r) => r.purpose === 'mutation',
1042
+ };
1043
+ const list = state.checkRuns.filter(match[filters.checks]);
1044
+ const bar = filterBar('checks', [
1045
+ { id: 'all', label: 'All', count: c.runs },
1046
+ { id: 'bad', label: 'Problems', count: c.problems },
1047
+ { id: 'main', label: 'Main', count: c.base.runs },
1048
+ { id: 'gate', label: 'Gate', count: c.gate.runs },
1049
+ { id: 'refactorer', label: 'Refactorer', count: c.refactorer.runs },
1050
+ { id: 'mutation', label: 'Mutation', count: c.mutation.runs },
1051
+ ]);
1052
+ const problems = list.filter((r) => r.problem);
1053
+ const others = list.filter((r) => !r.problem);
1054
+ const body = !list.length
1055
+ ? emptyState({
1056
+ title: 'No run in this group',
1057
+ why: 'No recent check run matches this filter. Select All to see each run.',
1058
+ compact: true,
1059
+ })
1060
+ : filters.checks === 'all'
1061
+ ? `${problems.length ? group('checks:bad', 'Problems', problems.length, problems.map(checkRowHtml).join(''), 'bad') : ''}${group('checks:other', 'Other runs', others.length, others.map(checkRowHtml).join(''))}`
1062
+ : `<div class="list">${list.map(checkRowHtml).join('')}</div>`;
1063
+ const note =
1064
+ c.runs > state.checkRuns.length
1065
+ ? `<p class="muted small">The list shows the newest ${state.checkRuns.length} of ${number(c.runs)} runs. The numbers above count each run.</p>`
1066
+ : '';
1067
+ return `${numbers}${bar}${note}${body}`;
404
1068
  }
405
1069
 
406
1070
  function checkDetail(id) {
407
- const run = fetched.get(`check:${id}`) ?? state.checkRuns.find((r) => r.id === id);
408
- if (!run) return '<p>Not found.</p>';
1071
+ const row = state.checkRuns.find((r) => r.id === id);
1072
+ const run = { ...row, ...fetched.get(`check:${id}`) };
1073
+ if (!run.id) return '<p>Loading…</p>';
409
1074
  return `
410
- <h2>Checks on ${run.branch ? esc(run.branch) : `main ${esc(run.sha.slice(0, 10))}`}</h2>
411
- <div class="pills">${chip(run.status)}<span class="chip kind">${esc(PURPOSE[run.purpose])}</span></div>
1075
+ <h2>Checks on ${run.purpose === 'base' ? `main ${esc(short(run.sha))}` : esc(run.branch ?? short(run.sha))}</h2>
1076
+ <div class="pills">${run.outcome ? chip(run.outcome.text, RUN_TONE[run.outcome.tone]) : ''}${tag(PURPOSE[run.purpose])}</div>
1077
+ ${run.purpose === 'mutation' ? '<p class="muted">A mutation run plants a bug in the source. A failed test here means that the tests caught the bug.</p>' : ''}
1078
+ ${run.refactorId ? `<p><button type="button" class="link" data-select="refactor:${esc(run.refactorId)}">Open the refactor</button></p>` : ''}
412
1079
  ${run.results
413
1080
  .map(
414
- (r) => `<h3>${r.ok ? '✓' : '✗'} ${esc(r.name)} · ${seconds(r.durationMs)}</h3>
1081
+ (r) => `<h3>${r.ok ? '✓' : '✗'} ${esc(r.name)} · ${duration(r.durationMs)}</h3>
415
1082
  <p><code>${esc(r.command)}</code></p>
416
1083
  ${r.output ? `<pre class="output">${esc(r.output)}</pre>` : '<p class="muted">No output.</p>'}`,
417
1084
  )
418
1085
  .join('')}
419
- ${run.status === 'running' && run.current ? `<h3>Running: ${esc(run.current)}</h3>` : ''}`;
1086
+ ${run.status === 'running' && run.current ? `<h3>Now: ${esc(run.current)}</h3>` : ''}`;
420
1087
  }
421
1088
 
422
1089
  // ---------- backlog ----------
423
1090
 
424
- /** An open opportunity with a verified branch waits to be published: show that. */
1091
+ const BACKLOG_FILTERS = [
1092
+ ['open', 'Open'],
1093
+ ['verified', 'To publish'],
1094
+ ['proposed', 'Pull request open'],
1095
+ ['done', 'Done'],
1096
+ ['exhausted', 'Exhausted'],
1097
+ ['dismissed', 'Dismissed'],
1098
+ ['all', 'All'],
1099
+ ];
1100
+
1101
+ /** An open opportunity with a verified branch waits to be published. */
425
1102
  function backlogStatus(o) {
426
1103
  const verified = state.refactors.some((r) => r.opportunityId === o.id && r.status === 'verified');
427
1104
  return o.status === 'open' && verified ? 'verified' : o.status;
428
1105
  }
429
1106
 
1107
+ const STRENGTH = { strong: ['strong', 'ok'], 'worth-exploring': ['worth a try', ''], speculative: ['speculative', ''] };
1108
+
1109
+ function opportunityRowHtml(o, rank) {
1110
+ const status = state.summary.backlog.working.includes(o.id) ? 'in progress' : backlogStatus(o);
1111
+ return `<button type="button" class="row ${selected('opportunity', o.id)}" data-select="opportunity:${esc(o.id)}">
1112
+ <span class="row-head">${rank ? `<span class="rank" aria-label="Number ${rank} in the pick order">${rank}</span>` : chip(status, { done: 'ok', proposed: 'warn', verified: 'warn', exhausted: 'bad', 'in progress': 'run' }[status] ?? '')}<span class="row-title">${esc(o.title)}</span>${chip(...STRENGTH[o.strength])}${tag(o.kind)}</span>
1113
+ <span class="row-sub"><span>${esc(o.side)} only</span><span>${plural(o.files.length, 'file')}</span><span>${plural(o.attempts, 'attempt')}</span><span>found ${ago(o.createdAt)}</span>${o.reason && status !== 'done' ? `<span class="why">${esc(o.reason.split('\n')[0].slice(0, 140))}</span>` : ''}</span>
1114
+ </button>`;
1115
+ }
1116
+
430
1117
  function renderBacklog() {
431
- const list = state.opportunities.filter((o) => backlogFilter === 'all' || backlogStatus(o) === backlogFilter);
432
- const filters = OPPORTUNITY_FILTERS.map((f) => {
433
- const n =
434
- f === 'all' ? state.opportunities.length : state.opportunities.filter((o) => backlogStatus(o) === f).length;
435
- return `<button data-filter="${f}" class="${f === backlogFilter ? 'active' : ''}">${f} ${n}</button>`;
436
- }).join('');
437
- const rows = list.length
438
- ? `<div class="list">${list
439
- .map(
440
- (o) => `<div class="row ${selected('opportunity', o.id)}" data-select="opportunity:${esc(o.id)}">
441
- <div class="row-head">${chip(backlogStatus(o))}<span class="row-title">${esc(o.title)}</span><span class="chip kind">${esc(o.kind)}</span></div>
442
- <div class="row-sub"><span>${esc(o.strength)}</span><span>${o.files.length} file${o.files.length === 1 ? '' : 's'}</span><span>${o.attempts} attempt${o.attempts === 1 ? '' : 's'}</span><span>found ${ago(o.createdAt)}</span></div>
443
- </div>`,
444
- )
445
- .join('')}</div>`
446
- : empty(`No ${backlogFilter === 'all' ? '' : `${backlogFilter} `}opportunity.`);
447
- return `<div class="filters">${filters}</div>${rows}`;
1118
+ const b = state.summary.backlog;
1119
+ const numbers = statBand([
1120
+ {
1121
+ label: 'Open',
1122
+ value: number(b.open),
1123
+ note: `${b.byStrength.strong} strong · ${b.byStrength['worth-exploring']} worth a try · ${b.byStrength.speculative} speculative`,
1124
+ go: 'backlog:open',
1125
+ },
1126
+ {
1127
+ label: 'Ready',
1128
+ value: number(b.ready.length),
1129
+ note:
1130
+ state.summary.main?.state === 'failed'
1131
+ ? 'None can start while the checks fail on main'
1132
+ : 'The refactorer can take these now',
1133
+ tone: state.summary.main?.state === 'failed' && b.ready.length ? 'fail' : undefined,
1134
+ },
1135
+ {
1136
+ label: 'Waits for a file',
1137
+ value: number(b.busy.length),
1138
+ note: 'A refactor that waits for you changes its files',
1139
+ },
1140
+ {
1141
+ label: 'Done',
1142
+ value: number(b.done),
1143
+ note: `${number(b.proposed)} more in an open pull request`,
1144
+ go: 'backlog:done',
1145
+ },
1146
+ { label: 'Exhausted', value: number(b.exhausted), note: 'Each attempt failed the gate', go: 'backlog:exhausted' },
1147
+ { label: 'Dismissed', value: number(b.dismissed), go: 'backlog:dismissed' },
1148
+ ]);
1149
+ if (!state.opportunities.length)
1150
+ return `${numbers}<div class="card">${emptyState({
1151
+ title: 'The backlog is empty',
1152
+ why:
1153
+ state.summary.main?.state === 'failed'
1154
+ ? 'The surveyor records opportunities only while the checks pass on main. Fix main first.'
1155
+ : 'The surveyor reads the repo and records the improvements that it finds. Each cycle runs it when the backlog is low.',
1156
+ command: state.summary.main?.state === 'failed' ? 'sloppatrol check' : 'sloppatrol survey',
1157
+ })}</div>`;
1158
+ const counts = {
1159
+ open: b.open,
1160
+ verified: b.verified,
1161
+ proposed: b.proposed,
1162
+ done: b.done,
1163
+ exhausted: b.exhausted,
1164
+ dismissed: b.dismissed,
1165
+ all: b.total,
1166
+ };
1167
+ const bar = filterBar(
1168
+ 'backlog',
1169
+ BACKLOG_FILTERS.map(([id, label]) => ({ id, label, count: counts[id] })),
1170
+ );
1171
+ let body;
1172
+ if (filters.backlog === 'open') {
1173
+ const ids = (list) => list.map(opportunityById).filter(Boolean);
1174
+ const working = ids(b.working);
1175
+ const ready = ids(b.ready);
1176
+ const busy = ids(b.busy);
1177
+ body =
1178
+ (working.length
1179
+ ? group(
1180
+ 'backlog:working',
1181
+ 'In progress now',
1182
+ working.length,
1183
+ working.map((o) => opportunityRowHtml(o)).join(''),
1184
+ 'run',
1185
+ )
1186
+ : '') +
1187
+ (ready.length
1188
+ ? group(
1189
+ 'backlog:ready',
1190
+ 'Next for the refactorer, in pick order',
1191
+ ready.length,
1192
+ ready.map((o, i) => opportunityRowHtml(o, i + 1)).join(''),
1193
+ )
1194
+ : '') +
1195
+ (busy.length
1196
+ ? group(
1197
+ 'backlog:busy',
1198
+ 'Waits for a refactor that changes the same files',
1199
+ busy.length,
1200
+ busy.map((o) => opportunityRowHtml(o)).join(''),
1201
+ 'warn',
1202
+ )
1203
+ : '');
1204
+ if (!body)
1205
+ body = emptyState({
1206
+ title: 'No open opportunity',
1207
+ why: 'The refactorer took or closed each opportunity. The surveyor records more when the backlog is low.',
1208
+ command: 'sloppatrol survey',
1209
+ compact: true,
1210
+ });
1211
+ } else {
1212
+ const list = state.opportunities.filter((o) => filters.backlog === 'all' || backlogStatus(o) === filters.backlog);
1213
+ body = list.length
1214
+ ? `<div class="list">${list.map((o) => opportunityRowHtml(o)).join('')}</div>`
1215
+ : emptyState({
1216
+ title: 'No opportunity in this group',
1217
+ why: 'No opportunity has this status now. Select All to see each one.',
1218
+ compact: true,
1219
+ });
1220
+ }
1221
+ return `${numbers}${bar}${body}`;
448
1222
  }
449
1223
 
450
1224
  function opportunityDetail(o) {
1225
+ if (!o) return '<p>This opportunity is not in the data.</p>';
451
1226
  const refactors = state.refactors.filter((r) => r.opportunityId === o.id);
452
1227
  return `
453
1228
  <h2>${esc(o.title)}</h2>
454
- <div class="pills">${chip(backlogStatus(o))}<span class="chip kind">${esc(o.kind)}</span><span class="chip kind">${esc(o.strength)}</span></div>
1229
+ <div class="pills">${chip(backlogStatus(o))}${tag(o.kind)}${tag(STRENGTH[o.strength][0])}</div>
455
1230
  ${o.reason ? `<h3>Note</h3><p>${esc(o.reason)}</p>` : ''}
456
1231
  <h3>Problem</h3><p>${esc(o.problem)}</p>
457
1232
  <h3>Change</h3><p>${esc(o.change)}</p>
458
1233
  <h3>Benefits</h3><p>${esc(o.benefits)}</p>
459
1234
  <h3>Files</h3><div class="files">${o.files.map((f) => `<code>${esc(f)}</code>`).join('')}</div>
460
- <h3>ID</h3><p><code>${esc(o.id)}</code> · <code>sloppatrol refactor ${esc(o.id)}</code></p>
461
- ${refactors.length ? `<h3>Attempts</h3>${refactors.map((r) => `<p><a href="#refactors" data-select="refactor:${esc(r.id)}">${esc(r.status)} · ${ago(r.updatedAt)}</a></p>`).join('')}` : ''}`;
1235
+ ${o.status === 'open' ? `<h3>Try it now</h3>${commandBox(`sloppatrol refactor ${o.id}`)}<h3>Dismiss it</h3>${commandBox(`sloppatrol dismiss ${o.id} --reason "..."`)}` : ''}
1236
+ ${refactors.length ? `<h3>Attempts</h3><div class="list">${refactors.map(refactorRowHtml).join('')}</div>` : ''}`;
462
1237
  }
463
1238
 
464
1239
  // ---------- sessions ----------
465
1240
 
466
- function sessionRows(sessions) {
467
- return `<div class="list">${sessions
468
- .map(
469
- (s) => `<div class="row ${selected('session', s.id)}" data-select="session:${esc(s.id)}">
470
- <div class="row-head">${chip(s.state === 'done' ? 'done' : s.state)}<span class="chip kind">${esc(s.role)}</span><span class="row-title">${esc(s.last)}</span></div>
471
- <div class="row-sub"><span>started ${ago(s.startedAt)}</span>${s.costUsd ? `<span>$${s.costUsd.toFixed(2)}</span>` : ''}</div>
472
- </div>`,
473
- )
474
- .join('')}</div>`;
1241
+ function sessionRowHtml(s) {
1242
+ const tone = { running: 'run', done: 'ok', failed: 'bad', stopped: 'stop' }[s.state];
1243
+ const took = s.endedAt ? duration(Date.parse(s.endedAt) - Date.parse(s.startedAt)) : '';
1244
+ return `<button type="button" class="row ${selected('session', s.id)}" data-select="session:${esc(s.id)}">
1245
+ <span class="row-head">${chip(s.state, tone)}${tag(s.role)}<span class="row-title">${esc(subjectWords(s.subject) || s.last)}</span></span>
1246
+ <span class="row-sub"><span>started ${ago(s.startedAt)}</span>${took ? `<span>took ${took}</span>` : ''}${s.costUsd ? `<span>${money(s.costUsd)}</span>` : ''}<span class="why">${esc(s.last)}</span></span>
1247
+ </button>`;
475
1248
  }
476
1249
 
477
1250
  function renderSessions() {
478
- return state.sessions.length ? sessionRows(state.sessions) : empty('No agent session yet.');
1251
+ const s = state.summary.sessions;
1252
+ const numbers = statBand([
1253
+ { label: 'Sessions', value: number(s.total) },
1254
+ { label: 'Running', value: number(s.running), tone: s.running ? 'run' : undefined },
1255
+ {
1256
+ label: 'Failed',
1257
+ value: number(s.failed),
1258
+ note: `${number(s.failedRecent)} in the last 7 days`,
1259
+ tone: s.failedRecent ? 'fail' : undefined,
1260
+ go: 'sessions:failed',
1261
+ },
1262
+ {
1263
+ label: 'Stopped',
1264
+ value: number(s.stopped),
1265
+ note: 'Sloppatrol stopped during the session',
1266
+ go: 'sessions:stopped',
1267
+ },
1268
+ {
1269
+ label: 'Agent cost',
1270
+ value: money(s.costUsd),
1271
+ note: `${money(s.costRecentUsd)} in the last 7 days. Only the costs that the agent reports.`,
1272
+ },
1273
+ ]);
1274
+ if (!state.sessions.length)
1275
+ return `${numbers}<div class="card">${emptyState({
1276
+ title: 'No agent session yet',
1277
+ why: 'Each agent works in a session. Its tool calls show here as a timeline. A cycle starts the first sessions.',
1278
+ command: 'sloppatrol --once',
1279
+ })}</div>`;
1280
+ const roles = Object.keys(s.byRole);
1281
+ const bar = filterBar('sessions', [
1282
+ { id: 'all', label: 'All', count: s.total },
1283
+ { id: 'failed', label: 'Failed', count: s.failed },
1284
+ { id: 'stopped', label: 'Stopped', count: s.stopped },
1285
+ ...roles.map((role) => ({ id: role, label: capital(role), count: s.byRole[role] })),
1286
+ ]);
1287
+ const f = filters.sessions;
1288
+ const list = state.sessions.filter((x) => f === 'all' || x.state === f || x.role === f);
1289
+ const failed = list.filter((x) => x.state === 'failed');
1290
+ const others = list.filter((x) => x.state !== 'failed');
1291
+ const body = !list.length
1292
+ ? emptyState({
1293
+ title: 'No session in this group',
1294
+ why: 'No recent session matches this filter. Select All to see each session.',
1295
+ compact: true,
1296
+ })
1297
+ : f === 'all' && failed.length
1298
+ ? `${group('sessions:failed', 'Failed', failed.length, failed.map(sessionRowHtml).join(''), 'bad')}${group('sessions:other', 'Other sessions', others.length, others.map(sessionRowHtml).join(''))}`
1299
+ : `<div class="list">${list.map(sessionRowHtml).join('')}</div>`;
1300
+ const note =
1301
+ s.total > state.sessions.length
1302
+ ? `<p class="muted small">The list shows the newest ${state.sessions.length} of ${number(s.total)} sessions. The numbers above count each session.</p>`
1303
+ : '';
1304
+ return `${numbers}${bar}${note}${body}`;
479
1305
  }
480
1306
 
481
1307
  function sessionDetail(id) {
@@ -492,27 +1318,32 @@ function sessionDetail(id) {
492
1318
  return `<li><time>${clock(e.at)}</time><div>${text}</div></li>`;
493
1319
  })
494
1320
  .join('');
1321
+ const role = summary?.role ?? events[0]?.role ?? 'Session';
495
1322
  return `
496
- <h2>${esc(summary?.role ?? 'Session')}</h2>
497
- <div class="pills">${summary ? chip(summary.state) : ''}${summary?.subject ? `<span class="chip kind">${esc(summary.subject)}</span>` : ''}</div>
498
- <h3>Events</h3>
1323
+ <h2>${esc(capital(role))}${summary?.subject ? `: ${esc(subjectWords(summary.subject))}` : ''}</h2>
1324
+ <div class="pills">${summary ? chip(summary.state, { running: 'run', done: 'ok', failed: 'bad', stopped: 'stop' }[summary.state]) : ''}${summary?.costUsd ? tag(money(summary.costUsd)) : ''}</div>
1325
+ ${summary?.subject?.startsWith('refactor:') ? `<p><button type="button" class="link" data-select="refactor:${esc(summary.subject.slice(9))}">Open the refactor</button></p>` : ''}
1326
+ <h3>Timeline</h3>
499
1327
  ${items ? `<ul class="events">${items}</ul>` : '<p>Loading…</p>'}`;
500
1328
  }
501
1329
 
502
1330
  // ---------- shell ----------
503
1331
 
504
- function empty(text) {
505
- return `<div class="empty">${esc(text)}</div>`;
506
- }
507
-
508
1332
  function selected(type, id) {
509
1333
  return selection?.type === type && selection.id === id ? 'selected' : '';
510
1334
  }
511
1335
 
512
- /** `#<tab>` or `#<tab>/<type>:<id>`, so a view and its detail can be linked. */
1336
+ function setHtml(element, html) {
1337
+ if (element.dataset.html !== html) {
1338
+ element.innerHTML = html;
1339
+ element.dataset.html = html;
1340
+ }
1341
+ }
1342
+
1343
+ /** `#<tab>` or `#<tab>/<type>:<id>`, so a page and its detail can be linked. */
513
1344
  function tab() {
514
1345
  const name = location.hash.slice(1).split('/')[0];
515
- return ['refactors', 'scorecard', 'checks', 'backlog', 'sessions'].includes(name) ? name : 'refactors';
1346
+ return TABS.includes(name) ? name : 'overview';
516
1347
  }
517
1348
 
518
1349
  async function loadSelection() {
@@ -521,8 +1352,10 @@ async function loadSelection() {
521
1352
  if (type === 'check') {
522
1353
  const run = state.checkRuns.find((r) => r.id === id);
523
1354
  const cached = fetched.get(`check:${id}`);
524
- if (!cached || cached.status === 'running' || run?.status === 'running')
525
- fetched.set(`check:${id}`, await (await fetch(`/api/checks/${id}`)).json());
1355
+ if (!cached || cached.status === 'running' || run?.status === 'running') {
1356
+ const response = await fetch(`/api/checks/${id}`);
1357
+ if (response.ok) fetched.set(`check:${id}`, await response.json());
1358
+ }
526
1359
  }
527
1360
  if (type === 'session') {
528
1361
  const summary = state.sessions.find((s) => s.id === id);
@@ -540,61 +1373,140 @@ function renderDetail() {
540
1373
  const { type, id } = selection;
541
1374
  const body =
542
1375
  type === 'refactor'
543
- ? refactorDetail(state.refactors.find((r) => r.id === id) ?? {})
1376
+ ? refactorDetail(refactorById(id))
544
1377
  : type === 'opportunity'
545
- ? opportunityDetail(state.opportunities.find((o) => o.id === id) ?? {})
1378
+ ? opportunityDetail(opportunityById(id))
546
1379
  : type === 'check'
547
1380
  ? checkDetail(id)
548
1381
  : type === 'unit'
549
1382
  ? unitDetail(id)
550
1383
  : sessionDetail(id);
551
1384
  panel.hidden = false;
552
- setHtml(panel, `<button class="close" data-close aria-label="Close">×</button>${body}`);
1385
+ // The heading takes the focus when the details open.
1386
+ setHtml(
1387
+ panel,
1388
+ `<button type="button" class="close icon-button" data-close aria-label="Close the details" title="Close">×</button>${body.replace('<h2>', '<h2 tabindex="-1">')}`,
1389
+ );
553
1390
  }
554
1391
 
1392
+ const VIEWS = {
1393
+ overview: renderOverview,
1394
+ refactors: renderRefactors,
1395
+ scorecard: renderScorecard,
1396
+ checks: renderChecks,
1397
+ backlog: renderBacklog,
1398
+ sessions: renderSessions,
1399
+ share: renderShare,
1400
+ };
1401
+
555
1402
  function render() {
556
1403
  if (!state) return;
557
1404
  renderTop();
558
1405
  const current = tab();
559
1406
  for (const link of document.querySelectorAll('.tabs a')) {
560
- link.classList.toggle('active', link.dataset.tab === current);
1407
+ const active = link.dataset.tab === current;
1408
+ link.classList.toggle('active', active);
1409
+ if (active) link.setAttribute('aria-current', 'page');
1410
+ else link.removeAttribute('aria-current');
561
1411
  }
562
- const views = {
563
- refactors: renderRefactors,
564
- scorecard: renderScorecard,
565
- checks: renderChecks,
566
- backlog: renderBacklog,
567
- sessions: renderSessions,
568
- };
569
- setHtml($('view'), views[current]());
1412
+ // A refresh replaces the controls: give the focus back to the same one.
1413
+ const focused = document.activeElement;
1414
+ const key = ['select', 'filter', 'go', 'copy'].find((k) => focused?.dataset?.[k] !== undefined);
1415
+ document.body.classList.toggle('sharing', current === 'share');
1416
+ const name = document.querySelector(`.tabs a[data-tab="${current}"]`)?.textContent ?? 'Share the scorecard';
1417
+ setHtml($('view'), `<h1 class="sr-only">${name}</h1>${VIEWS[current]()}`);
570
1418
  renderDetail();
1419
+ if (key && !document.body.contains(focused))
1420
+ document.querySelector(`[data-${key}="${CSS.escape(focused.dataset[key])}"]`)?.focus();
571
1421
  }
572
1422
 
573
1423
  async function refresh() {
574
1424
  try {
575
- state = await (await fetch('/api/state')).json();
576
- await loadSelection();
577
- render();
1425
+ const response = await fetch('/api/state');
1426
+ if (!response.ok) throw new Error(String(response.status));
1427
+ state = await response.json();
1428
+ $('offline').hidden = true;
578
1429
  } catch {
579
- $('meta').textContent = 'Cannot reach the Sloppatrol dashboard server.';
1430
+ $('offline').hidden = false;
1431
+ return;
580
1432
  }
1433
+ await loadSelection();
1434
+ render();
1435
+ }
1436
+
1437
+ /** The control that opened the details, to give the focus back when they close. */
1438
+ let opener;
1439
+
1440
+ function select(type, id) {
1441
+ selection = { type, id };
1442
+ history.replaceState(null, '', `#${tab()}/${type}:${id}`);
1443
+ }
1444
+
1445
+ function closeDetail() {
1446
+ selection = undefined;
1447
+ history.replaceState(null, '', `#${tab()}`);
1448
+ render();
1449
+ document.querySelector(`[data-select="${CSS.escape(opener ?? '')}"]`)?.focus();
581
1450
  }
582
1451
 
583
1452
  document.addEventListener('click', async (event) => {
584
- const target = event.target.closest('[data-select], [data-filter], [data-close], [data-session]');
1453
+ const target = event.target.closest(
1454
+ '[data-select], [data-filter], [data-close], [data-go], [data-copy], [data-share]',
1455
+ );
585
1456
  if (!target) return;
586
- if (target.dataset.close !== undefined) selection = undefined;
587
- else if (target.dataset.filter) backlogFilter = target.dataset.filter;
588
- else if (target.dataset.session) selection = { type: 'session', id: target.dataset.session };
589
- else {
1457
+ if (target.dataset.share) return shareAction(target);
1458
+ if (target.dataset.copy !== undefined) {
1459
+ try {
1460
+ await navigator.clipboard.writeText(target.dataset.copy);
1461
+ target.textContent = 'Copied';
1462
+ } catch {
1463
+ target.textContent = 'Copy failed';
1464
+ }
1465
+ setTimeout(() => {
1466
+ target.textContent = 'Copy';
1467
+ }, 1500);
1468
+ return;
1469
+ }
1470
+ if (target.dataset.close !== undefined) return closeDetail();
1471
+ if (target.dataset.filter) {
1472
+ const [page, value] = target.dataset.filter.split(':');
1473
+ filters[page] = value;
1474
+ } else if (target.dataset.go) {
1475
+ const [page, value] = target.dataset.go.split(':');
1476
+ if (value) filters[page] = value;
1477
+ if (tab() !== page) {
1478
+ location.hash = page;
1479
+ return;
1480
+ }
1481
+ } else {
590
1482
  const [type, id] = target.dataset.select.split(':');
591
- selection = { type, id };
592
- history.replaceState(null, '', `#${tab()}/${type}:${id}`);
1483
+ select(type, id);
1484
+ if (!target.closest('#detail')) opener = target.dataset.select;
1485
+ await loadSelection();
1486
+ render();
1487
+ $('detail').querySelector('h2')?.focus();
1488
+ return;
593
1489
  }
594
1490
  await loadSelection();
595
1491
  render();
596
1492
  });
597
1493
 
1494
+ // A person's open or closed group stays that way after a refresh.
1495
+ document.addEventListener(
1496
+ 'toggle',
1497
+ (event) => {
1498
+ const id = event.target.dataset?.group;
1499
+ if (!id) return;
1500
+ if (event.target.open) closed.delete(id);
1501
+ else closed.add(id);
1502
+ },
1503
+ true,
1504
+ );
1505
+
1506
+ document.addEventListener('keydown', (event) => {
1507
+ if (event.key === 'Escape' && selection) closeDetail();
1508
+ });
1509
+
598
1510
  function selectFromHash() {
599
1511
  const link = location.hash.slice(1).split('/')[1];
600
1512
  if (!link) return;