create-religion 1.0.0 → 1.2.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.
@@ -5,14 +5,17 @@
5
5
  * writes, runs a workflow command, or starts the project.
6
6
  */
7
7
  import http from "node:http";
8
- import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { runDoctor } from "./doctor.js";
10
+ import { readManifest } from "./install.js";
9
11
  import { readIfPresent, statePath } from "./paths.js";
10
- import { readProjectState } from "./state.js";
12
+ import { parseInbox, parseSpec, readHistory, readProjectState } from "./state.js";
11
13
  import { computeStatus } from "./status.js";
12
- export async function startDashboard(root) {
14
+ /** `version` is the running tool's, shown beside the version that installed the project. */
15
+ export async function startDashboard(root, version = null) {
13
16
  let port = 0;
14
17
  const server = http.createServer((request, response) => {
15
- void handle(root, port, request, response);
18
+ void handle(root, version, port, request, response);
16
19
  });
17
20
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
18
21
  const address = server.address();
@@ -36,15 +39,21 @@ export function isOwnHost(host, port) {
36
39
  }
37
40
  /** What every response allows a browser to load: inline script and style, and its own server. */
38
41
  export const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'";
39
- async function handle(root, port, request, response) {
42
+ async function handle(root, version, port, request, response) {
40
43
  if (!isOwnHost(request.headers.host, port)) {
41
44
  send(response, 403, "text/plain; charset=utf-8", "Forbidden: this dashboard only answers requests addressed to itself.");
42
45
  return;
43
46
  }
44
47
  if (request.url === "/state.json") {
45
- const state = await readProjectState(root);
46
- const activity = await readActivity(root);
47
- const body = JSON.stringify({ status: computeStatus(state), plan: state.plan, findings: state.findings, activity });
48
+ // An unreadable project must answer, not take the server down with an unhandled rejection.
49
+ let body;
50
+ try {
51
+ body = JSON.stringify(await readState(root, version));
52
+ }
53
+ catch (error) {
54
+ send(response, 500, "application/json", JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
55
+ return;
56
+ }
48
57
  send(response, 200, "application/json", body);
49
58
  return;
50
59
  }
@@ -55,6 +64,38 @@ function send(response, status, type, body) {
55
64
  response.writeHead(status, { "content-type": type, "content-security-policy": CONTENT_SECURITY_POLICY });
56
65
  response.end(body);
57
66
  }
67
+ async function readState(root, version) {
68
+ const state = await readProjectState(root);
69
+ return {
70
+ project: path.basename(root),
71
+ status: computeStatus(state),
72
+ plan: state.plan,
73
+ findings: state.findings,
74
+ activity: await readActivity(root),
75
+ work: parseSpec(await readIfPresent(statePath(root, "context", "current-work.md"))),
76
+ history: await readHistory(root),
77
+ health: {
78
+ checks: await runDoctor(root),
79
+ config: state.config,
80
+ install: await readInstall(root),
81
+ tool: version,
82
+ inbox: parseInbox(await readIfPresent(statePath(root, "context", "inbox.md"))),
83
+ questions: state.openQuestions
84
+ }
85
+ };
86
+ }
87
+ /** The manifest is the project's file and may be hand-edited, so only well-formed parts are shown. */
88
+ async function readInstall(root) {
89
+ const manifest = await readManifest(root);
90
+ if (!manifest || typeof manifest !== "object")
91
+ return null;
92
+ const { version, adapters, managed } = manifest;
93
+ return {
94
+ version: typeof version === "string" ? version : null,
95
+ adapters: Array.isArray(adapters) ? adapters.filter((a) => typeof a === "string") : [],
96
+ managed: managed && typeof managed === "object" ? Object.keys(managed).length : 0
97
+ };
98
+ }
58
99
  async function readActivity(root) {
59
100
  const raw = await readIfPresent(statePath(root, ".state", "run.json"));
60
101
  if (!raw)
@@ -66,81 +107,879 @@ async function readActivity(root) {
66
107
  return null;
67
108
  }
68
109
  }
69
- export async function historyCount(root) {
70
- let total = 0;
71
- for (const kind of ["features", "fixes", "rollbacks"]) {
72
- try {
73
- const entries = await fs.readdir(statePath(root, "history", kind));
74
- total += entries.filter((e) => e.endsWith(".md") && e !== "README.md").length;
75
- }
76
- catch {
77
- // absent directory contributes nothing
78
- }
79
- }
80
- return total;
81
- }
82
110
  const PAGE = `<!doctype html>
83
111
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
84
112
  <title>Religion</title>
85
113
  <style>
86
- :root { color-scheme: light dark; --bg:#fbfbfa; --fg:#1a1a19; --dim:#6b6b68; --line:#e5e5e2; --card:#fff; --warn:#b45309; --block:#b91c1c; }
87
- @media (prefers-color-scheme: dark) { :root { --bg:#16161a; --fg:#e8e8e6; --dim:#9a9a96; --line:#2a2a30; --card:#1e1e24; } }
88
- * { box-sizing: border-box; }
89
- body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
90
- font:14px/1.6 ui-sans-serif,system-ui,-apple-system,sans-serif; }
91
- main { max-width:56rem; margin:0 auto; }
92
- h1 { font-size:1.1rem; margin:0 0 1.5rem; letter-spacing:-.01em; }
93
- h1 span { color:var(--dim); font-weight:400; }
94
- .next { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:1rem 1.25rem; margin-bottom:1.25rem; }
95
- .next b { font-size:1.15rem; font-family:ui-monospace,monospace; }
96
- .next p { margin:.35rem 0 0; color:var(--dim); }
97
- .grid { display:grid; gap:1rem; grid-template-columns:repeat(auto-fit,minmax(15rem,1fr)); }
98
- .card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:1rem 1.25rem; }
99
- .card h2 { font-size:.7rem; text-transform:uppercase; letter-spacing:.08em; color:var(--dim); margin:0 0 .6rem; }
100
- .bar { height:5px; background:var(--line); border-radius:3px; overflow:hidden; margin:.5rem 0; }
101
- .bar i { display:block; height:100%; background:var(--fg); }
102
- ul { margin:.4rem 0 0; padding-left:1.1rem; }
103
- li { margin:.2rem 0; }
104
- .warn { color:var(--warn); } .block { color:var(--block); font-weight:600; }
105
- .dim { color:var(--dim); }
106
- code { font-family:ui-monospace,monospace; }
114
+ /* Tokens: every colour, size and space the page uses. */
115
+ :root {
116
+ color-scheme: light dark;
117
+
118
+ /* Type */
119
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
120
+ --font-mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
121
+ --text-2xs: 10px;
122
+ --text-xs: 11px;
123
+ --text-sm: 12px;
124
+ --text-base: 13px;
125
+ --text-lg: 15px;
126
+ --text-xl: 20px;
127
+ --text-2xl: 26px;
128
+ --leading-tight: 1.25;
129
+ --leading-base: 1.5;
130
+ --weight-regular: 400;
131
+ --weight-medium: 500;
132
+ --weight-semibold: 600;
133
+ --tracking-label: 0.06em;
134
+
135
+ /* Space */
136
+ --space-1: 2px;
137
+ --space-2: 4px;
138
+ --space-3: 6px;
139
+ --space-4: 8px;
140
+ --space-5: 12px;
141
+ --space-6: 16px;
142
+ --space-7: 20px;
143
+ --space-8: 24px;
144
+ --space-9: 32px;
145
+
146
+ /* Shape */
147
+ --radius-sm: 3px;
148
+ --radius-md: 5px;
149
+ --radius-lg: 8px;
150
+ --border-width: 1px;
151
+ --bar-height: 4px;
152
+ --dot-size: 7px;
153
+ --mark-size: 12px;
154
+ --mark-ring: 2px;
155
+ --tracking-tight: -0.015em;
156
+
157
+ /* Layout */
158
+ --rail-width: 208px;
159
+ --content-max: 1280px;
160
+ --row-height: 28px;
161
+ --tile-min: 168px;
162
+ --mark-column: 16px;
163
+ --number-column: 32px;
164
+ --label-column: 96px;
165
+ --grid-split: 1.15fr 1fr;
166
+ --aside-width: 340px;
167
+ --key-column: 132px;
168
+ --chart-height: 8px;
169
+
170
+ /* Light */
171
+ --bg: #f6f6f7;
172
+ --surface: #ffffff;
173
+ --surface-2: #f2f2f4;
174
+ --surface-hover: #ececf0;
175
+ --border: #e4e4e9;
176
+ --border-strong: #d1d1d9;
177
+ --text: #17171c;
178
+ --text-2: #55555f;
179
+ --text-3: #8b8b96;
180
+ --accent: #4f46e5;
181
+ --accent-weak: #eceeff;
182
+ --ok: #15803d;
183
+ --ok-weak: #e7f6ec;
184
+ --progress: #2563eb;
185
+ --progress-weak: #e8f0fe;
186
+ --warn: #b45309;
187
+ --warn-weak: #fdf3e2;
188
+ --block: #dc2626;
189
+ --block-weak: #fdeaea;
190
+ --sev-p0: #dc2626;
191
+ --sev-p1: #ea580c;
192
+ --sev-p2: #ca8a04;
193
+ --sev-p3: #64748b;
194
+ --heat-0: transparent;
195
+ --heat-1: #eef0f6;
196
+ --heat-2: #dde2ee;
197
+ --heat-3: #c3cbe0;
198
+ }
199
+
200
+ @media (prefers-color-scheme: dark) {
201
+ :root {
202
+ --bg: #0d0d10;
203
+ --surface: #15151a;
204
+ --surface-2: #1b1b21;
205
+ --surface-hover: #22222a;
206
+ --border: #25252d;
207
+ --border-strong: #33333d;
208
+ --text: #ececf1;
209
+ --text-2: #a3a3ad;
210
+ --text-3: #6f6f7a;
211
+ --accent: #8b8cf8;
212
+ --accent-weak: #1f1f3a;
213
+ --ok: #4ade80;
214
+ --ok-weak: #12261a;
215
+ --progress: #60a5fa;
216
+ --progress-weak: #13213a;
217
+ --warn: #f59e0b;
218
+ --warn-weak: #2a1f0c;
219
+ --block: #f87171;
220
+ --block-weak: #2c1414;
221
+ --sev-p0: #f87171;
222
+ --sev-p1: #fb923c;
223
+ --sev-p2: #facc15;
224
+ --sev-p3: #94a3b8;
225
+ --heat-1: #1d1f2b;
226
+ --heat-2: #262a3c;
227
+ --heat-3: #323853;
228
+ }
229
+ }
230
+
231
+ /* Components, shared by every screen. */
232
+
233
+ * { box-sizing: border-box; }
234
+ body { margin: 0; background: var(--bg); color: var(--text); font: var(--text-base)/var(--leading-base) var(--font-sans); }
235
+ .app { display: grid; grid-template-columns: var(--rail-width) 1fr; min-height: 100vh; }
236
+ .mono { font-family: var(--font-mono); }
237
+ .dim { color: var(--text-2); } .faint { color: var(--text-3); }
238
+ .label { font-size: var(--text-2xs); text-transform: uppercase; letter-spacing: var(--tracking-label); color: var(--text-3); font-weight: var(--weight-medium); }
239
+
240
+ /* Rail */
241
+ .rail { border-right: var(--border-width) solid var(--border); background: var(--surface); padding: var(--space-6) var(--space-4); display: flex; flex-direction: column; gap: var(--space-6); position: sticky; top: 0; height: 100vh; }
242
+ .brand { padding: 0 var(--space-4); }
243
+ .brand b { font-size: var(--text-lg); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-tight); }
244
+ .brand div { font-size: var(--text-xs); color: var(--text-3); font-family: var(--font-mono); margin-top: var(--space-1); }
245
+ .nav { display: flex; flex-direction: column; gap: var(--space-1); }
246
+ .nav a { display: flex; align-items: center; justify-content: space-between; padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); color: var(--text-2); text-decoration: none; font-weight: var(--weight-medium); }
247
+ .nav a:hover { background: var(--surface-hover); color: var(--text); }
248
+ .count { font-size: var(--text-xs); font-family: var(--font-mono); color: var(--text-3); }
249
+ .count.warn { color: var(--warn); }
250
+ .rail-foot { margin-top: auto; padding: 0 var(--space-4); font-size: var(--text-xs); color: var(--text-3); display: grid; gap: var(--space-2); }
251
+ .live { display: inline-flex; align-items: center; gap: var(--space-3); }
252
+ .live::before { content: ""; width: var(--dot-size); height: var(--dot-size); border-radius: 50%; background: var(--ok); }
253
+
254
+ /* Main */
255
+ main { padding: var(--space-7) var(--space-8) var(--space-9); max-width: var(--content-max); width: 100%; }
256
+ .head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-6); margin-bottom: var(--space-6); }
257
+ .head h1 { font-size: var(--text-xl); margin: 0; font-weight: var(--weight-semibold); letter-spacing: var(--tracking-tight); }
258
+ .head .meta { font-size: var(--text-xs); color: var(--text-3); font-family: var(--font-mono); display: flex; gap: var(--space-6); }
259
+
260
+ .next { display: flex; align-items: center; gap: var(--space-6); background: var(--accent-weak); border: var(--border-width) solid var(--accent); border-radius: var(--radius-lg); padding: var(--space-5) var(--space-6); margin-bottom: var(--space-6); }
261
+ .next .cmd { font-family: var(--font-mono); font-size: var(--text-lg); font-weight: var(--weight-semibold); color: var(--accent); }
262
+ .next p { margin: 0; color: var(--text-2); }
263
+
264
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--tile-min), 1fr)); gap: var(--space-4); margin-bottom: var(--space-6); }
265
+ .tile { background: var(--surface); border: var(--border-width) solid var(--border); border-radius: var(--radius-lg); padding: var(--space-5) var(--space-6); }
266
+ .tile .big { font-size: var(--text-2xl); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); margin: var(--space-3) 0 var(--space-2); font-variant-numeric: tabular-nums; }
267
+ .tile .big small { font-size: var(--text-base); color: var(--text-3); font-weight: var(--weight-regular); }
268
+ .tile .sub { font-size: var(--text-xs); color: var(--text-2); }
269
+ .tile.good .big { color: var(--ok); }
270
+ .bar { height: var(--bar-height); background: var(--surface-2); border-radius: var(--radius-sm); overflow: hidden; display: flex; margin-top: var(--space-4); }
271
+ .bar i { display: block; height: 100%; }
272
+
273
+ .grid { display: grid; grid-template-columns: var(--grid-split); gap: var(--space-4); }
274
+ .panel { background: var(--surface); border: var(--border-width) solid var(--border); border-radius: var(--radius-lg); }
275
+ .panel > header { display: flex; align-items: baseline; justify-content: space-between; padding: var(--space-5) var(--space-6) var(--space-4); border-bottom: var(--border-width) solid var(--border); }
276
+ .panel > header h2 { margin: 0; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
277
+ .panel > header a { font-size: var(--text-xs); color: var(--text-3); text-decoration: none; }
278
+ .panel .body { padding: var(--space-4) var(--space-6) var(--space-5); }
279
+
280
+ /* Steps */
281
+ .work-title { font-size: var(--text-lg); font-weight: var(--weight-semibold); margin: var(--space-2) 0 var(--space-2); }
282
+ .tags { display: flex; gap: var(--space-3); margin-bottom: var(--space-4); }
283
+ .tag { font-size: var(--text-2xs); padding: var(--space-1) var(--space-3); border-radius: var(--radius-sm); background: var(--surface-2); color: var(--text-2); font-weight: var(--weight-medium); text-transform: uppercase; letter-spacing: var(--tracking-label); }
284
+ .tag.progress { background: var(--progress-weak); color: var(--progress); }
285
+ .goal { color: var(--text-2); margin: 0 0 var(--space-5); font-size: var(--text-sm); }
286
+ .steps { list-style: none; margin: 0; padding: 0; }
287
+ .steps li { display: grid; grid-template-columns: var(--mark-column) 1fr auto; gap: var(--space-4); align-items: center; min-height: var(--row-height); border-top: var(--border-width) solid var(--border); font-size: var(--text-sm); }
288
+ .steps li:first-child { border-top: 0; }
289
+ .mark { width: var(--mark-size); height: var(--mark-size); border-radius: 50%; border: var(--border-width) solid var(--border-strong); }
290
+ .done .mark { background: var(--ok); border-color: var(--ok); }
291
+ .done .name { color: var(--text-2); }
292
+ .now { background: var(--progress-weak); margin: 0 calc(-1 * var(--space-6)); padding: 0 var(--space-6); }
293
+ .now .mark { border: var(--mark-ring) solid var(--progress); }
294
+ .now .name { font-weight: var(--weight-medium); }
295
+ .steps .side { font-size: var(--text-xs); color: var(--text-3); font-family: var(--font-mono); }
296
+
297
+ /* Matrix */
298
+ table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
299
+ th { font-weight: var(--weight-medium); color: var(--text-3); font-size: var(--text-2xs); text-transform: uppercase; letter-spacing: var(--tracking-label); text-align: right; padding: var(--space-3) var(--space-4); }
300
+ th:first-child { text-align: left; }
301
+ td { padding: var(--space-3) var(--space-4); text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; border-top: var(--border-width) solid var(--border); }
302
+ td:first-child { text-align: left; font-family: var(--font-sans); }
303
+ .sev { display: inline-flex; align-items: center; gap: var(--space-3); font-weight: var(--weight-medium); }
304
+ .sev::before { content: ""; width: var(--dot-size); height: var(--dot-size); border-radius: 50%; background: currentColor; }
305
+ .p0 { color: var(--sev-p0); } .p1 { color: var(--sev-p1); } .p2 { color: var(--sev-p2); } .p3 { color: var(--sev-p3); }
306
+ .h0 { color: var(--text-3); } .h1 { background: var(--heat-1); } .h2 { background: var(--heat-2); } .h3 { background: var(--heat-3); }
307
+ .callout { display: flex; align-items: center; gap: var(--space-3); margin-top: var(--space-5); font-size: var(--text-xs); color: var(--ok); }
308
+ .callout::before { content: ""; width: var(--dot-size); height: var(--dot-size); border-radius: 50%; background: var(--ok); }
309
+
310
+ /* Plan */
311
+ .plan { list-style: none; margin: 0; padding: 0; font-size: var(--text-sm); }
312
+ .plan li { display: grid; grid-template-columns: var(--number-column) 1fr auto; gap: var(--space-4); align-items: center; min-height: var(--row-height); }
313
+ .plan .num { font-family: var(--font-mono); color: var(--text-3); font-size: var(--text-xs); }
314
+ .plan .sub { padding-left: var(--space-7); }
315
+ .plan .done .t { color: var(--text-2); }
316
+ .plan .next-item .t { color: var(--accent); font-weight: var(--weight-medium); }
317
+ .check { font-size: var(--text-xs); color: var(--ok); }
318
+ .queued { font-size: var(--text-xs); color: var(--accent); font-family: var(--font-mono); }
319
+
320
+ /* Activity and health */
321
+ .kv { display: grid; grid-template-columns: var(--label-column) 1fr; gap: var(--space-2) var(--space-5); font-size: var(--text-sm); }
322
+ .kv dt { color: var(--text-3); }
323
+ .kv dd { margin: 0; }
324
+ .run { display: inline-flex; align-items: center; gap: var(--space-3); font-weight: var(--weight-medium); color: var(--progress); }
325
+ .run::before { content: ""; width: var(--dot-size); height: var(--dot-size); border-radius: 50%; background: var(--progress); }
326
+ .checks { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-2) var(--space-6); margin-top: var(--space-5); font-size: var(--text-xs); }
327
+ .checks span { display: flex; align-items: center; gap: var(--space-3); color: var(--text-2); }
328
+ .checks span::before { content: ""; width: var(--dot-size); height: var(--dot-size); border-radius: 50%; background: var(--ok); }
329
+ .divider { border-top: var(--border-width) solid var(--border); margin: var(--space-5) 0; }
330
+
331
+ /* History */
332
+ .hist { width: 100%; }
333
+ .hist td:first-child { width: var(--number-column); color: var(--text-3); font-family: var(--font-mono); font-size: var(--text-xs); }
334
+ .hist td.t { text-align: left; font-family: var(--font-sans); }
335
+ .stack { grid-column: 1 / -1; }
336
+
337
+ /* Detail views */
338
+ .layout { display: grid; grid-template-columns: 1fr var(--aside-width); gap: var(--space-4); align-items: start; }
339
+ .col { display: grid; gap: var(--space-4); }
340
+ .prose { margin: 0; color: var(--text-2); font-size: var(--text-sm); }
341
+ .prose + .prose { margin-top: var(--space-4); }
342
+ .prose b { color: var(--text); font-weight: var(--weight-semibold); }
343
+ code { font-family: var(--font-mono); font-size: var(--text-xs); background: var(--surface-2); padding: 0 var(--space-2); border-radius: var(--radius-sm); }
344
+ .list { margin: 0; padding-left: var(--space-6); font-size: var(--text-sm); color: var(--text-2); display: grid; gap: var(--space-2); }
345
+ .list b { color: var(--text); font-weight: var(--weight-medium); }
346
+ .section-label { margin: var(--space-6) 0 var(--space-3); }
347
+ .section-label:first-child { margin-top: 0; }
348
+
349
+ /* Step cards */
350
+ .step { display: grid; grid-template-columns: var(--mark-column) 1fr auto; gap: var(--space-2) var(--space-4); padding: var(--space-5) var(--space-6); border-top: var(--border-width) solid var(--border); }
351
+ .step:first-child { border-top: 0; }
352
+ .step .mark { margin-top: var(--space-1); }
353
+ .step h3 { margin: 0; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
354
+ .step .n { color: var(--text-3); font-family: var(--font-mono); font-weight: var(--weight-regular); margin-right: var(--space-3); }
355
+ .step .what, .step .when { grid-column: 2 / -1; margin: 0; font-size: var(--text-sm); color: var(--text-2); }
356
+ .step .when { font-size: var(--text-xs); }
357
+ .step .when b { color: var(--text-3); font-weight: var(--weight-medium); text-transform: uppercase; letter-spacing: var(--tracking-label); font-size: var(--text-2xs); margin-right: var(--space-3); }
358
+ .step .side { font-size: var(--text-xs); color: var(--text-3); font-family: var(--font-mono); }
359
+ .step.done .mark { background: var(--ok); border-color: var(--ok); }
360
+ .step.done h3 { color: var(--text-2); }
361
+ .step.now { background: var(--progress-weak); margin: 0; padding: var(--space-5) var(--space-6); }
362
+ .step.now .mark { border: var(--mark-ring) solid var(--progress); }
363
+ .step.now .side { color: var(--progress); font-weight: var(--weight-medium); }
364
+
365
+ /* Horizontal bars */
366
+ .hbars { display: grid; gap: var(--space-3); font-size: var(--text-sm); }
367
+ .hbar { display: grid; grid-template-columns: var(--key-column) 1fr var(--number-column); gap: var(--space-4); align-items: center; }
368
+ .hbar .k { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
369
+ .hbar .v { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--text-2); font-size: var(--text-xs); }
370
+ .track { height: var(--chart-height); background: var(--surface-2); border-radius: var(--radius-sm); overflow: hidden; display: flex; }
371
+ .track i { display: block; height: 100%; }
372
+
373
+ /* Filters and status */
374
+ .toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-4) var(--space-6); border-bottom: var(--border-width) solid var(--border); flex-wrap: wrap; }
375
+ .chips { display: flex; gap: var(--space-2); flex-wrap: wrap; }
376
+ .chip { font-size: var(--text-xs); padding: var(--space-1) var(--space-4); border-radius: var(--radius-md); border: var(--border-width) solid var(--border); color: var(--text-2); background: var(--surface); font-weight: var(--weight-medium); }
377
+ .chip.on { background: var(--text); border-color: var(--text); color: var(--surface); }
378
+ .chip .count { margin-left: var(--space-2); }
379
+ .chip.on .count { color: var(--surface); }
380
+ .status { font-size: var(--text-2xs); text-transform: uppercase; letter-spacing: var(--tracking-label); font-weight: var(--weight-medium); padding: var(--space-1) var(--space-3); border-radius: var(--radius-sm); font-family: var(--font-sans); white-space: nowrap; }
381
+ .status.open { background: var(--warn-weak); color: var(--warn); }
382
+ .status.fixed { background: var(--progress-weak); color: var(--progress); }
383
+ .status.unverified { background: var(--surface-2); color: var(--text-2); }
384
+ .status.closed { background: var(--ok-weak); color: var(--ok); }
385
+
386
+ /* Dense tables */
387
+ .rows td { font-family: var(--font-sans); text-align: left; vertical-align: baseline; }
388
+ .rows th { text-align: left; }
389
+ .rows th:first-child, .rows td:first-child { padding-left: var(--space-6); }
390
+ .rows th:last-child, .rows td:last-child { padding-right: var(--space-6); }
391
+ .rows tr:hover td { background: var(--surface-hover); }
392
+ .rows tr.sel td { background: var(--accent-weak); }
393
+ .rows tr.sel td:first-child { box-shadow: inset var(--mark-ring) 0 0 var(--accent); }
394
+ .rows .id, .rows .file { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-3); white-space: nowrap; }
395
+ .rows .num, .rows th.num { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--text-2); }
396
+ .more { padding: var(--space-4) var(--space-6); font-size: var(--text-xs); color: var(--text-3); border-top: var(--border-width) solid var(--border); }
397
+
398
+ /* Health */
399
+ .checklist { list-style: none; margin: 0; padding: 0; }
400
+ .checklist li { display: grid; grid-template-columns: var(--mark-column) var(--key-column) 1fr auto; gap: var(--space-4); align-items: center; min-height: var(--row-height); padding: var(--space-3) var(--space-6); border-top: var(--border-width) solid var(--border); font-size: var(--text-sm); }
401
+ .checklist li:first-child { border-top: 0; }
402
+ .checklist .ok { width: var(--dot-size); height: var(--dot-size); border-radius: 50%; background: var(--ok); }
403
+ .checklist .detail { color: var(--text-2); }
404
+ .checklist .blocks { font-size: var(--text-xs); color: var(--text-3); }
405
+ .notice { display: flex; align-items: center; gap: var(--space-6); padding: var(--space-5) var(--space-6); border: var(--border-width) solid var(--warn); background: var(--warn-weak); border-radius: var(--radius-lg); margin-bottom: var(--space-6); }
406
+ .notice .cmd { font-family: var(--font-mono); font-size: var(--text-lg); font-weight: var(--weight-semibold); color: var(--warn); white-space: nowrap; }
407
+ .notice p { margin: 0; color: var(--text-2); }
408
+ .grid.thirds { grid-template-columns: repeat(3, 1fr); margin-bottom: var(--space-4); }
409
+ .sticky { position: sticky; top: var(--space-7); }
410
+ .legend { gap: var(--space-5); font-size: var(--text-xs); margin-top: var(--space-2); }
411
+ .kv.spaced { margin: var(--space-4) 0; }
412
+ .rows .track { min-width: var(--key-column); }
413
+ .steps.flat li { grid-template-columns: 1fr auto; }
414
+
415
+ /* Views: one per hash, the overview when none is targeted, so no script reads the address. */
416
+ .view { display: none; scroll-margin-top: 100vh; }
417
+ .view:target, main:not(:has(.view:target)) #overview { display: block; }
418
+ .app:not(:has(.view:target)) .to-overview, .app:has(#overview:target) .to-overview, .app:has(#work:target) .to-work,
419
+ .app:has(#findings:target) .to-findings, .app:has(#history:target) .to-history, .app:has(#health:target) .to-health { background: var(--surface-2); color: var(--text); }
420
+ .tile.bad .big { color: var(--block); }
421
+ .checks span { align-items: flex-start; }
422
+ .checks span::before { flex-shrink: 0; margin-top: calc((var(--leading-base) * 1em - var(--dot-size)) / 2); }
423
+ .checks span.bad::before { background: var(--block); }
424
+ .checklist .bad { width: var(--dot-size); height: var(--dot-size); border-radius: 50%; background: var(--block); }
425
+ .callout.bad { color: var(--block); }
426
+ .callout.bad::before { background: var(--block); }
427
+ .live.off { color: var(--block); }
428
+ .live.off::before { background: var(--block); }
429
+ .problem { border: var(--border-width) solid var(--block); background: var(--block-weak); color: var(--block); border-radius: var(--radius-lg); padding: var(--space-5) var(--space-6); margin-bottom: var(--space-6); font-size: var(--text-sm); }
430
+ .empty { margin: 0; color: var(--text-3); font-size: var(--text-sm); }
431
+ button.chip { font-family: inherit; cursor: pointer; }
432
+ [data-pick] { cursor: pointer; }
433
+ .nowrap { white-space: nowrap; }
434
+ :focus-visible { outline: var(--mark-ring) solid var(--accent); outline-offset: var(--space-1); }
435
+ @media (max-width: 900px) {
436
+ .app { grid-template-columns: 1fr; }
437
+ .rail { position: static; height: auto; }
438
+ .grid, .grid.thirds, .layout { grid-template-columns: 1fr; }
439
+ }
107
440
  </style>
108
- <main>
109
- <h1>Religion <span id="root"></span></h1>
110
- <div class="next"><b id="next">...</b><p id="why"></p></div>
111
- <div class="grid">
112
- <div class="card"><h2>Active work</h2><div id="work"></div></div>
113
- <div class="card"><h2>Plan</h2><div id="plan"></div></div>
114
- <div class="card"><h2>Findings</h2><div id="findings"></div></div>
115
- <div class="card"><h2>Warnings</h2><div id="warnings"></div></div>
116
- </div>
117
- </main>
441
+ <div class="app">
442
+ <nav class="rail">
443
+ <div class="brand"><b>Religion</b><div id="project"></div></div>
444
+ <div class="nav">
445
+ <a class="to-overview" href="#overview">Overview</a>
446
+ <a class="to-work" href="#work">Work <span class="count" id="count-work"></span></a>
447
+ <a class="to-findings" href="#findings">Findings <span class="count" id="count-findings"></span></a>
448
+ <a class="to-history" href="#history">History <span class="count" id="count-history"></span></a>
449
+ <a class="to-health" href="#health">Health <span class="count" id="count-health"></span></a>
450
+ </div>
451
+ <div class="rail-foot">
452
+ <span class="live off" id="live">Connecting</span>
453
+ <span class="mono" id="tool"></span>
454
+ </div>
455
+ </nav>
456
+ <main>
457
+ <div class="problem" id="problem" hidden></div>
458
+ <section class="view" id="overview">
459
+ <div class="head"><h1>Overview</h1><div class="meta" id="ov-meta"></div></div>
460
+ <div class="next"><span class="label">Next</span><span class="cmd" id="next-command">...</span><p id="next-because"></p></div>
461
+ <div class="tiles" id="ov-tiles"></div>
462
+ <div class="grid">
463
+ <section class="panel"><header><h2>Active work</h2><a href="#work">Open spec</a></header><div class="body" id="ov-work"></div></section>
464
+ <section class="panel"><header><h2>Findings</h2><a href="#findings">Ledger</a></header><div class="body" id="ov-findings"></div></section>
465
+ <section class="panel"><header><h2>Plan</h2><a href="#history">Plan and history</a></header><div class="body" id="ov-plan"></div></section>
466
+ <section class="panel"><header><h2>Activity and health</h2><a href="#health">Health</a></header><div class="body" id="ov-health"></div></section>
467
+ <section class="panel stack"><header><h2>Recently shipped</h2><a href="#history">History</a></header><div class="body" id="ov-shipped"></div></section>
468
+ </div>
469
+ </section>
470
+ <section class="view" id="work">
471
+ <div class="head"><h1 id="wk-title">Work</h1><div class="meta"><span>context/current-work.md</span></div></div>
472
+ <div id="wk-body"></div>
473
+ </section>
474
+ <section class="view" id="findings">
475
+ <div class="head"><h1>Findings</h1><div class="meta"><span>context/findings.md</span><span id="fd-meta"></span></div></div>
476
+ <div class="tiles" id="fd-tiles"></div>
477
+ <div class="grid thirds" id="fd-charts"></div>
478
+ <div class="layout">
479
+ <section class="panel"><div class="toolbar"><div class="chips" id="fd-status"></div><div class="chips" id="fd-severity"></div></div><div id="fd-table"></div></section>
480
+ <aside class="panel sticky" id="fd-detail"></aside>
481
+ </div>
482
+ </section>
483
+ <section class="view" id="history">
484
+ <div class="head"><h1>Plan and history</h1><div class="meta"><span>build-plan.md</span><span>history/</span></div></div>
485
+ <div class="tiles" id="hs-tiles"></div>
486
+ <div class="layout">
487
+ <div class="col">
488
+ <section class="panel"><header><h2>Shipped</h2><span class="count">newest first</span></header><div id="hs-table"></div></section>
489
+ <section class="panel"><header><h2>Build plan</h2><span class="count">build-plan.md</span></header><div class="body" id="hs-plan"></div></section>
490
+ </div>
491
+ <aside class="panel sticky" id="hs-detail"></aside>
492
+ </div>
493
+ </section>
494
+ <section class="view" id="health">
495
+ <div class="head"><h1>Activity and health</h1><div class="meta"><span>religion/.state/</span><span>religion/config.json</span></div></div>
496
+ <div id="hl-notice"></div>
497
+ <div class="grid">
498
+ <section class="panel"><header><h2>Now</h2><span id="hl-run"></span></header><div class="body" id="hl-now"></div></section>
499
+ <section class="panel"><header><h2>Doctor</h2><span class="count" id="hl-pass"></span></header><ul class="checklist" id="hl-checks"></ul></section>
500
+ <section class="panel"><header><h2>Configuration</h2><span class="count">config.json</span></header><div id="hl-config"></div></section>
501
+ <section class="panel"><header><h2>Install</h2><span class="count">.state/manifest.json</span></header><div class="body" id="hl-install"></div></section>
502
+ </div>
503
+ </section>
504
+ </main>
505
+ </div>
118
506
  <script>
119
- const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;" }[c]));
120
- async function load() {
121
- let d; try { d = await (await fetch("/state.json")).json(); } catch { return; }
122
- const s = d.status;
123
- next.textContent = "/" + s.next.command;
124
- why.textContent = s.next.because;
125
- work.innerHTML = s.work.active
126
- ? "<b>" + esc(s.work.title || "untitled") + "</b><div class=bar><i style='width:" +
127
- (s.work.stepsTotal ? Math.round(s.work.stepsDone / s.work.stepsTotal * 100) : 0) +
128
- "%'></i></div><span class=dim>step " + s.work.stepsDone + " of " + s.work.stepsTotal +
129
- (s.work.status ? " &middot; " + esc(s.work.status) : "") + "</span>"
130
- : "<span class=dim>nothing in progress</span>";
131
- plan.innerHTML = "<div class=bar><i style='width:" +
132
- (s.plan.total ? Math.round(s.plan.done / s.plan.total * 100) : 0) + "%'></i></div><span class=dim>" +
133
- s.plan.done + " of " + s.plan.total + " complete</span>" +
134
- (s.plan.nextItem ? "<ul><li>" + esc(s.plan.nextItem) + "</li></ul>" : "");
135
- findings.innerHTML = s.findings.total === 0
136
- ? "<span class=dim>none</span>"
137
- : (s.findings.blocking.length
138
- ? "<ul>" + s.findings.blocking.map((f) => "<li class=block>" + esc(f) + "</li>").join("") + "</ul>"
139
- : "") + "<span class=dim>" +
140
- Object.entries(s.findings.byStatus).map(([k, v]) => v + " " + k).join(", ") + "</span>";
141
- warnings.innerHTML = s.warnings.length
142
- ? "<ul>" + s.warnings.map((w) => "<li class=warn>" + esc(w) + "</li>").join("") + "</ul>"
143
- : "<span class=dim>none</span>";
144
- }
145
- load(); setInterval(load, 3000);
507
+ (function () {
508
+ const $ = (id) => document.getElementById(id);
509
+ const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
510
+ let last = "";
511
+ let data = null;
512
+ const ui = { status: "unresolved", severity: null, finding: null, archive: null };
513
+
514
+ function setLive(ok, text) {
515
+ $("live").className = ok ? "live" : "live off";
516
+ $("live").textContent = text;
517
+ }
518
+
519
+ function showProblem(text) {
520
+ $("problem").hidden = !text;
521
+ $("problem").textContent = text || "";
522
+ }
523
+
524
+ // One request at a time, so a slow answer cannot queue more behind it or land out of order.
525
+ let busy = false;
526
+ async function load() {
527
+ if (busy) return;
528
+ busy = true;
529
+ try {
530
+ await refresh();
531
+ } finally {
532
+ busy = false;
533
+ }
534
+ }
535
+
536
+ async function refresh() {
537
+ let response;
538
+ let text;
539
+ try {
540
+ // Cancelled after ten seconds, so a request that never answers neither freezes polling nor lands late.
541
+ response = await fetch("/state.json", { signal: AbortSignal.timeout(10000) });
542
+ text = await response.text();
543
+ } catch {
544
+ setLive(false, "Disconnected, retrying");
545
+ return;
546
+ }
547
+ if (!response.ok) {
548
+ let reason = "the server answered " + response.status;
549
+ try { reason = JSON.parse(text).error || reason; } catch {}
550
+ showProblem("The project could not be read: " + reason);
551
+ setLive(false, "Cannot read the project");
552
+ return;
553
+ }
554
+ showProblem("");
555
+ setLive(true, "Live, updated " + new Date().toLocaleTimeString());
556
+ if (text === last) return;
557
+ last = text;
558
+ data = JSON.parse(text);
559
+ render();
560
+ }
561
+
562
+ const SEVERITIES = ["P0", "P1", "P2", "P3"];
563
+ const UNRESOLVED = ["open", "fixed", "unverified"];
564
+ const sum = (list, value) => list.reduce((total, item) => total + value(item), 0);
565
+ const share = (count, total) => (total ? Math.round((count / total) * 1000) / 10 : 0);
566
+ const short = (title) => String(title || "").split(" - ")[0];
567
+ const unresolved = () => data.findings.filter((f) => UNRESOLVED.indexOf(f.status) >= 0);
568
+ const config = () => (data.health.config && typeof data.health.config === "object" ? data.health.config : {});
569
+ const setting = (group, key, fallback) => (config()[group] && config()[group][key] !== undefined ? config()[group][key] : fallback);
570
+
571
+ // Escaped first, so the only tags are the ones added here: the repository's text never becomes markup.
572
+ function md(text) {
573
+ const tick = String.fromCharCode(96);
574
+ return esc(text)
575
+ .split(tick)
576
+ .map((part, i) => (i % 2 ? "<code>" + part + "</code>" : part.split("**").map((p, j) => (j % 2 ? "<b>" + p + "</b>" : p)).join("")))
577
+ .join("");
578
+ }
579
+
580
+ function bar(parts, total) {
581
+ return "<div class='bar'>" + parts.map((p) => "<i style='width:" + share(p[0], total) + "%;background:var(" + p[1] + ")'></i>").join("") + "</div>";
582
+ }
583
+
584
+ function tile(label, big, sub, extra, kind) {
585
+ return "<div class='tile " + (kind || "") + "'><div class='label'>" + esc(label) + "</div><div class='big'>" + big + "</div><div class='sub'>" + sub + "</div>" + (extra || "") + "</div>";
586
+ }
587
+
588
+ const sentence = (text) => (text.indexOf(". ") < 0 ? text : text.slice(0, text.indexOf(". ") + 1));
589
+ const empty = (text) => "<p class='empty'>" + esc(text) + "</p>";
590
+
591
+ function bySeverity(list) {
592
+ return SEVERITIES.map((s) => [list.filter((f) => f.severity === s).length, s]).filter((p) => p[0] > 0);
593
+ }
594
+
595
+ function severitySummary(list) {
596
+ const parts = bySeverity(list).map((p) => p[0] + " " + p[1]);
597
+ const above = list.some((f) => f.severity === "P0" || f.severity === "P1");
598
+ return parts.length ? parts.join(", ") + (above ? "" : ", none above") : "none";
599
+ }
600
+
601
+ function render() {
602
+ $("project").textContent = data.project;
603
+ $("tool").textContent = data.health.tool ? "create-religion " + data.health.tool : "";
604
+ $("next-command").textContent = data.status.next.command;
605
+ $("next-because").textContent = data.status.next.because;
606
+ renderRail();
607
+ renderOverview();
608
+ renderWork();
609
+ renderFindings();
610
+ renderHistory();
611
+ renderHealth();
612
+ }
613
+
614
+ function renderRail() {
615
+ const work = data.status.work;
616
+ const pending = unresolved().length;
617
+ const passing = data.health.checks.filter((c) => c.ok).length;
618
+ $("count-work").textContent = work.active ? work.stepsDone + "/" + work.stepsTotal : "";
619
+ $("count-findings").textContent = pending ? String(pending) : "";
620
+ $("count-findings").className = data.status.findings.blocking.length ? "count warn" : "count";
621
+ $("count-history").textContent = data.history.length ? String(data.history.length) : "";
622
+ $("count-health").textContent = passing + "/" + data.health.checks.length;
623
+ $("count-health").className = passing < data.health.checks.length ? "count warn" : "count";
624
+ }
625
+
626
+ function renderOverview() {
627
+ const s = data.status;
628
+ const pending = unresolved();
629
+ const blocking = s.findings.blocking.length;
630
+ const commits = sum(data.history, (a) => a.commits.length);
631
+ const closed = sum(data.history, (a) => a.findings.filter((f) => f.status === "closed").length);
632
+ $("ov-meta").innerHTML = "<span>" + esc(setting("git", "mode", "trunk")) + " mode</span><span>review " + esc(setting("workflow", "stepReview", "every")) + "</span>";
633
+ $("ov-tiles").innerHTML =
634
+ tile("Plan", s.plan.done + "<small> / " + s.plan.total + " items</small>", s.plan.nextItem ? "Next: " + esc(short(s.plan.nextItem)) : s.plan.total ? "Complete" : "No items yet", bar([[s.plan.done, "--ok"]], s.plan.total)) +
635
+ (s.work.active
636
+ ? tile("Active item", s.work.stepsDone + "<small> / " + s.work.stepsTotal + " steps</small>", esc(s.work.title || "Untitled"), bar([[s.work.stepsDone, "--progress"]], s.work.stepsTotal))
637
+ : tile("Active item", "None", "Nothing in progress")) +
638
+ tile("Blocking", String(blocking), blocking ? "A P0 or P1 stops completion" : "Nothing stops completion", "", blocking ? "bad" : "good") +
639
+ tile("Unresolved findings", String(pending.length), esc(severitySummary(pending)), bar(bySeverity(pending).map((p) => [p[0], "--sev-" + p[1].toLowerCase()]), pending.length)) +
640
+ tile("Shipped", data.history.length + "<small> items</small>", commits + " commits, " + closed + " findings closed");
641
+ $("ov-work").innerHTML = overviewWork();
642
+ $("ov-findings").innerHTML = overviewFindings(pending);
643
+ $("ov-plan").innerHTML = planList();
644
+ $("ov-health").innerHTML = overviewHealth();
645
+ $("ov-shipped").innerHTML = data.history.length
646
+ ? "<table class='hist'><tr><th></th><th style='text-align:left'>Item</th><th>Kind</th><th>Commits</th><th>Findings closed</th></tr>" +
647
+ data.history.slice(0, 5).map((a) =>
648
+ "<tr><td>" + esc(a.number || "") + "</td><td class='t'>" + esc(a.title || a.file) + "</td><td class='t'>" + esc(a.type || a.kind) + "</td><td>" + a.commits.length + "</td><td>" + a.findings.filter((f) => f.status === "closed").length + "</td></tr>"
649
+ ).join("") + "</table>"
650
+ : empty("Nothing has shipped yet. Completed work is archived here.");
651
+ }
652
+
653
+ function tags(work) {
654
+ const status = String(work.status || "");
655
+ return "<div class='tags'>" + (work.type ? "<span class='tag'>" + esc(work.type) + "</span>" : "") +
656
+ (status ? "<span class='tag" + (/progress/i.test(status) ? " progress" : "") + "'>" + esc(status) + "</span>" : "") +
657
+ (work.planItem ? "<span class='tag'>Plan " + esc(work.planItem) + "</span>" : "") + "</div>";
658
+ }
659
+
660
+ function overviewWork() {
661
+ const work = data.work;
662
+ if (!work) return empty("Nothing in progress. Next: " + data.status.next.command + ", " + data.status.next.because + ".");
663
+ const next = work.steps.findIndex((step) => !step.done);
664
+ return "<div class='work-title'>" + esc(work.title || "Untitled") + "</div>" + tags(work) +
665
+ (work.goal[0] ? "<p class='goal'>" + md(sentence(work.goal[0])) + "</p>" : "") +
666
+ (work.steps.length
667
+ ? "<ul class='steps'>" + work.steps.map((step, i) =>
668
+ "<li class='" + (step.done ? "done" : i === next ? "now" : "") + "'><span class='mark'></span><span class='name'>" + md(step.title) +
669
+ "</span><span class='side'>" + (i === next ? "next" : esc(step.label)) + "</span></li>"
670
+ ).join("") + "</ul>"
671
+ : empty("The spec has no build steps yet."));
672
+ }
673
+
674
+ const GATES = { "pull-request": "Pull request", "branch-per-item": "Merge on completion", trunk: "On this branch" };
675
+ const CHECKPOINTS = { none: "One at completion", "every-step": "One per step", squash: "One per step, squashed" };
676
+
677
+ function renderWork() {
678
+ const work = data.work;
679
+ $("wk-title").textContent = work ? work.title || "Untitled" : "Work";
680
+ if (!work) {
681
+ $("wk-body").innerHTML = "<section class='panel'><div class='body'>" + empty("Nothing in progress. Next: " + data.status.next.command + ", " + data.status.next.because + ".") + "</div></section>";
682
+ return;
683
+ }
684
+ const repairs = work.steps.filter((step) => /^repair/i.test(step.label));
685
+ const done = work.steps.filter((step) => step.done).length;
686
+ const next = work.steps.findIndex((step) => !step.done);
687
+ const blocking = data.status.findings.blocking.length;
688
+ const list = (items) => (items.length ? "<ul class='list'>" + items.map((item) => "<li>" + md(item) + "</li>").join("") + "</ul>" : empty("None listed."));
689
+ $("wk-body").innerHTML = tags(work) +
690
+ "<div class='tiles'>" +
691
+ tile("Steps", done + "<small> / " + work.steps.length + "</small>", next < 0 ? "Every step is ticked" : esc(work.steps[next].label || "Step " + (next + 1)) + " is next", bar([[done, "--progress"]], work.steps.length)) +
692
+ tile("Repairs", String(repairs.length), repairs.length ? repairs.filter((step) => step.done).length + " done, added after an audit" : "No audit has added any") +
693
+ tile("Blocking", String(blocking), blocking ? "A P0 or P1 stops completion" : "Nothing stops completion", "", blocking ? "bad" : "good") +
694
+ tile("Status", esc(work.status || "unknown"), esc(work.type || "Work item") + (work.planItem ? ", plan item " + esc(work.planItem) : "")) +
695
+ "</div><div class='layout'><div class='col'>" +
696
+ "<section class='panel'><header><h2>Goal</h2></header><div class='body'>" + (work.goal.length ? work.goal.map((g) => "<p class='prose'>" + md(g) + "</p>").join("") : empty("The spec has no goal yet.")) + "</div></section>" +
697
+ "<section class='panel'><header><h2>Build steps</h2><span class='count'>" + done + " of " + work.steps.length + " done</span></header>" +
698
+ (work.steps.length
699
+ ? work.steps.map((step, i) =>
700
+ "<div class='step " + (step.done ? "done" : i === next ? "now" : "") + "'><span class='mark'></span><h3>" + (step.label ? "<span class='n'>" + esc(step.label) + "</span>" : "") + md(step.title) +
701
+ "</h3><span class='side'>" + (step.done ? "done" : i === next ? "next" : "") + "</span>" +
702
+ (step.what ? "<p class='what'>" + md(step.what) + "</p>" : "") +
703
+ (step.doneWhen ? "<p class='when'><b>Done when</b>" + md(step.doneWhen) + "</p>" : "") + "</div>"
704
+ ).join("")
705
+ : "<div class='body'>" + empty("The spec has no build steps yet.") + "</div>") +
706
+ "</section></div><div class='col'>" +
707
+ "<section class='panel'><header><h2>Scope</h2></header><div class='body'><div class='label section-label'>In</div>" + list(work.inScope) + "<div class='label section-label'>Out</div>" + list(work.outOfScope) + "</div></section>" +
708
+ "<section class='panel'><header><h2>Files and areas</h2><span class='count'>" + work.files.length + "</span></header><div class='body'>" + list(work.files) + "</div></section>" +
709
+ "<section class='panel'><header><h2>How it is built</h2></header><div class='body'><dl class='kv'>" +
710
+ "<dt>Review</dt><dd>" + (setting("workflow", "stepReview", "every") === "item" ? "Once, at the end" : "Every step") + "</dd>" +
711
+ "<dt>Commits</dt><dd>" + esc(CHECKPOINTS[setting("git", "checkpoints", "none")] || setting("git", "checkpoints", "")) + "</dd>" +
712
+ "<dt>Lands by</dt><dd>" + esc(GATES[setting("git", "mode", "trunk")] || setting("git", "mode", "")) + "</dd>" +
713
+ "<dt>Audit</dt><dd class='mono'>" + esc(setting("qualityGates", "audit", "when-sensitive")) + "</dd>" +
714
+ "<dt>Check</dt><dd class='mono'>" + esc(setting("qualityGates", "check", "when-behavioral")) + "</dd>" +
715
+ "</dl></div></section></div></div>";
716
+ }
717
+
718
+ const STATUS_ORDER = ["open", "fixed", "unverified", "accepted", "closed", "invalid"];
719
+ const idNumber = (f) => Number(String(f.id).slice(2)) || 0;
720
+ const pill = (status) => "<span class='status " + (["open", "fixed", "closed"].indexOf(status) >= 0 ? status : "unverified") + "'>" + esc(status) + "</span>";
721
+ const stacked = (parts, max) => "<span class='track'>" + parts.map((p) => "<i style='width:" + share(p[0], max) + "%;background:var(--sev-" + p[1].toLowerCase() + ")'></i>").join("") + "</span>";
722
+
723
+ function hbars(rows, max, mono) {
724
+ if (!rows.length) return empty("Nothing to chart yet.");
725
+ return rows.map((r) => "<div class='hbar'><span class='k" + (mono ? " mono" : "") + "' title='" + esc(r.key) + "'>" + esc(r.key) + "</span>" + r.track + "<span class='v'>" + r.count + "</span></div>").join("");
726
+ }
727
+
728
+ function lenses(f) {
729
+ if (!f.lens) return [f.found ? "while building" : "none"];
730
+ if (/re-review|confirmation/i.test(f.lens)) return ["re-review"];
731
+ return f.lens.split(",").map((l) => l.trim()).filter(Boolean);
732
+ }
733
+
734
+ function renderFindings() {
735
+ const all = data.findings;
736
+ const pending = unresolved();
737
+ const closed = data.history.flatMap((a) => a.findings.filter((f) => f.status === "closed"));
738
+ const blocking = data.status.findings.blocking.length;
739
+ const count = (status) => all.filter((f) => f.status === status).length;
740
+ const live = all.filter((f) => f.status === "open");
741
+ $("fd-meta").textContent = all.length + " in the ledger, " + closed.length + " archived";
742
+ $("fd-tiles").innerHTML =
743
+ tile("Blocking", String(blocking), blocking ? "A P0 or P1 is open or fixed" : "No P0 or P1 open or fixed", "", blocking ? "bad" : "good") +
744
+ tile("Open", String(live.length), esc(severitySummary(live)), bar(bySeverity(live).map((p) => [p[0], "--sev-" + p[1].toLowerCase()]), live.length)) +
745
+ tile("Awaiting re-review", String(count("fixed")), "Repaired, not yet looked at again") +
746
+ tile("Unverified", String(count("unverified")), "Plausible, not reproduced") +
747
+ tile("Closed", String(closed.length), closed.length ? esc(severitySummary(closed).replace(", none above", "")) + ", archived with their items" : "None archived yet");
748
+
749
+ // Keyed by repository text, so no inherited member such as "constructor" can collide.
750
+ const lensCounts = Object.create(null);
751
+ pending.forEach((f) => lenses(f).forEach((l) => (lensCounts[l] = (lensCounts[l] || 0) + 1)));
752
+ const lensRows = Object.keys(lensCounts).sort((a, b) => lensCounts[b] - lensCounts[a]).slice(0, 6);
753
+ const lensMax = Math.max(1, ...lensRows.map((l) => lensCounts[l]));
754
+ const files = Object.create(null);
755
+ pending.forEach((f) => {
756
+ const file = String(f.file || "no file").split(":")[0].split("/").pop();
757
+ (files[file] = files[file] || []).push(f);
758
+ });
759
+ const fileRows = Object.keys(files).sort((a, b) => files[b].length - files[a].length).slice(0, 6);
760
+ const fileMax = Math.max(1, ...fileRows.map((k) => files[k].length));
761
+ const items = data.history.filter((a) => a.findings.some((f) => f.status === "closed")).slice(0, 5);
762
+ const itemMax = Math.max(1, ...items.map((a) => a.findings.filter((f) => f.status === "closed").length));
763
+ $("fd-charts").innerHTML =
764
+ "<section class='panel'><header><h2>By lens</h2><span class='count'>unresolved, a finding can carry two</span></header><div class='body hbars'>" +
765
+ hbars(lensRows.map((l) => ({ key: l, count: lensCounts[l], track: "<span class='track'><i style='width:" + share(lensCounts[l], lensMax) + "%;background:var(--accent)'></i></span>" })), lensMax) + "</div></section>" +
766
+ "<section class='panel'><header><h2>Where they cluster</h2><span class='count'>unresolved, by file</span></header><div class='body hbars'>" +
767
+ hbars(fileRows.map((k) => ({ key: k, count: files[k].length, track: stacked(bySeverity(files[k]), fileMax) })), fileMax, true) + "</div></section>" +
768
+ "<section class='panel'><header><h2>Closed with each item</h2><span class='count'>by severity</span></header><div class='body hbars'>" +
769
+ hbars(items.map((a) => {
770
+ const done = a.findings.filter((f) => f.status === "closed");
771
+ return { key: (a.number ? a.number + " " : "") + short(a.title || a.file), count: done.length, track: stacked(bySeverity(done), itemMax) };
772
+ }), itemMax) +
773
+ "<div class='chips legend'>" + SEVERITIES.map((s) => "<span class='sev " + s.toLowerCase() + "'>" + s + "</span>").join("") + "</div></div></section>";
774
+
775
+ const statuses = [["unresolved", "Unresolved", pending.length], ["open", "Open", count("open")], ["fixed", "Fixed", count("fixed")], ["unverified", "Unverified", count("unverified")], ["all", "All", all.length]];
776
+ $("fd-status").innerHTML = statuses.map((s) => "<button class='chip" + (ui.status === s[0] ? " on" : "") + "' data-filter='status' data-value='" + s[0] + "'>" + s[1] + " <span class='count'>" + s[2] + "</span></button>").join("");
777
+ $("fd-severity").innerHTML = SEVERITIES.filter((s) => all.some((f) => f.severity === s)).map((s) =>
778
+ "<button class='chip" + (ui.severity === s ? " on" : "") + "' data-filter='severity' data-value='" + s + "'>" + s + " <span class='count'>" + all.filter((f) => f.severity === s).length + "</span></button>"
779
+ ).join("");
780
+
781
+ const shown = all
782
+ .filter((f) => (ui.status === "all" ? true : ui.status === "unresolved" ? UNRESOLVED.indexOf(f.status) >= 0 : f.status === ui.status))
783
+ .filter((f) => !ui.severity || f.severity === ui.severity)
784
+ .sort((a, b) => a.severity.localeCompare(b.severity) || STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || idNumber(a) - idNumber(b));
785
+ const picked = shown.find((f) => f.id === ui.finding) || shown[0] || null;
786
+ $("fd-table").innerHTML = shown.length
787
+ ? "<table class='rows'><tr><th>ID</th><th>Sev</th><th>Finding</th><th>Status</th><th>File</th><th>Lens</th></tr>" +
788
+ shown.map((f) =>
789
+ "<tr tabindex='0' data-pick='finding' data-value='" + esc(f.id) + "'" + (picked && f.id === picked.id ? " class='sel'" : "") + "><td class='id'>" + esc(f.id) +
790
+ "</td><td><span class='sev " + f.severity.toLowerCase() + "'>" + f.severity + "</span></td><td>" + md(f.title) + "</td><td>" + pill(f.status) +
791
+ "</td><td class='file' title='" + esc(f.file || "") + "'>" + esc(String(f.file || "").split("/").pop()) + "</td><td class='dim nowrap'>" + esc(lenses(f).join(", ")) + "</td></tr>"
792
+ ).join("") + "</table><div class='more'>Showing " + shown.length + " of " + all.length + ", most severe first</div>"
793
+ : "<div class='body'>" + empty(all.length ? "No finding matches these filters." : "The ledger is empty. An audit records what it finds here.") + "</div>";
794
+
795
+ const section = (label, text) => "<div class='label section-label'>" + label + "</div>" + (text ? "<p class='prose'>" + md(text) + "</p>" : "<p class='prose faint'>None yet</p>");
796
+ $("fd-detail").innerHTML = picked
797
+ ? "<header><h2 class='mono'>" + esc(picked.id) + "</h2>" + pill(picked.status) + "</header><div class='body'><div class='work-title'>" + md(picked.title) + "</div>" +
798
+ "<dl class='kv spaced'><dt>Severity</dt><dd><span class='sev " + picked.severity.toLowerCase() + "'>" + picked.severity + "</span></dd>" +
799
+ "<dt>File</dt><dd class='mono'>" + esc(picked.file || "none") + "</dd><dt>Found</dt><dd>" + esc(picked.found || "unknown") + "</dd>" +
800
+ "<dt>Lens</dt><dd>" + esc(picked.lens || "none") + "</dd></dl>" +
801
+ section("Why it matters", picked.why) + section("Suggested fix", picked.fix) + section("Resolution", picked.resolution) + "</div>"
802
+ : "<header><h2>Detail</h2></header><div class='body'>" + empty("Select a finding to read it in full.") + "</div>";
803
+ }
804
+
805
+ function renderHistory() {
806
+ const shipped = data.history;
807
+ const closedOf = (a) => a.findings.filter((f) => f.status === "closed");
808
+ const closed = shipped.flatMap(closedOf);
809
+ const commits = sum(shipped, (a) => a.commits.length);
810
+ const kinds = Object.create(null);
811
+ shipped.forEach((a) => (kinds[a.kind] = (kinds[a.kind] || 0) + 1));
812
+ const s = data.status;
813
+ $("hs-tiles").innerHTML =
814
+ tile("Plan", s.plan.done + "<small> / " + s.plan.total + " items</small>", s.plan.nextItem ? "Next: " + esc(short(s.plan.nextItem)) : s.plan.total ? "Complete" : "No items yet", bar([[s.plan.done, "--ok"]], s.plan.total)) +
815
+ tile("Shipped", String(shipped.length), shipped.length ? Object.keys(kinds).map((k) => kinds[k] + " " + esc(k)).join(", ") : "Nothing archived yet") +
816
+ tile("Commits", String(commits), shipped.length ? Math.round(commits / shipped.length) + " per item on average" : "None recorded") +
817
+ tile("Repairs", String(sum(shipped, (a) => a.repairs)), "Steps added after an audit") +
818
+ tile("Findings closed", String(closed.length), closed.length ? esc(severitySummary(closed).replace(", none above", "")) : "None yet");
819
+
820
+ const max = Math.max(1, ...shipped.map((a) => closedOf(a).length));
821
+ const key = (a) => a.kind + "/" + a.file;
822
+ const picked = shipped.find((a) => key(a) === ui.archive) || shipped[0] || null;
823
+ $("hs-table").innerHTML = shipped.length
824
+ ? "<table class='rows'><tr><th></th><th>Item</th><th>Kind</th><th class='num'>Steps</th><th class='num'>Repairs</th><th class='num'>Commits</th><th>Findings closed</th></tr>" +
825
+ shipped.map((a) =>
826
+ "<tr tabindex='0' data-pick='archive' data-value='" + esc(key(a)) + "'" + (picked && key(a) === key(picked) ? " class='sel'" : "") + "><td class='id'>" + esc(a.number || "") +
827
+ "</td><td>" + esc(a.title || a.file) + "</td><td class='dim'>" + esc(a.type || a.kind) + "</td><td class='num'>" + a.steps + "</td><td class='num'>" + a.repairs +
828
+ "</td><td class='num'>" + a.commits.length + "</td><td>" + stacked(bySeverity(closedOf(a)), max) + "</td></tr>"
829
+ ).join("") + "</table>"
830
+ : "<div class='body'>" + empty("Nothing has shipped yet. Completing a work item archives it here.") + "</div>";
831
+ $("hs-plan").innerHTML = planList();
832
+
833
+ if (!picked) {
834
+ $("hs-detail").innerHTML = "<header><h2>Detail</h2></header><div class='body'>" + empty("Select a shipped item to read what it taught.") + "</div>";
835
+ return;
836
+ }
837
+ const range = picked.commits.length ? picked.commits[0].slice(0, 7) + (picked.commits.length > 1 ? ".." + picked.commits[picked.commits.length - 1].slice(0, 7) : "") : "none recorded";
838
+ const paragraphs = (items, none) => (items.length ? items.map((p) => "<p class='prose'>" + md(p) + "</p>").join("") : empty(none));
839
+ $("hs-detail").innerHTML =
840
+ "<header><h2>" + esc((picked.number ? picked.number + " " : "") + (picked.title || picked.file)) + "</h2>" +
841
+ (picked.status ? "<span class='status " + (/verified|done|complete/i.test(picked.status) ? "closed" : "unverified") + "'>" + esc(picked.status) + "</span>" : "") +
842
+ "</header><div class='body'><dl class='kv spaced'><dt>Kind</dt><dd>" + esc(picked.type || picked.kind) + "</dd><dt>Commits</dt><dd class='mono'>" + picked.commits.length + ", " + esc(range) +
843
+ "</dd><dt>Archive</dt><dd class='mono'>" + esc(picked.file) + "</dd></dl>" +
844
+ "<div class='label section-label'>What went wrong</div>" + paragraphs(picked.wentWrong, "Nothing recorded.") +
845
+ "<div class='label section-label'>Deferred</div>" + (picked.deferred.length ? "<ul class='list'>" + picked.deferred.map((d) => "<li>" + md(d) + "</li>").join("") + "</ul>" : empty("Nothing deferred.")) +
846
+ "<div class='label section-label'>Findings closed</div>" +
847
+ (closedOf(picked).length
848
+ ? "<ul class='steps'>" + closedOf(picked).map((f) => "<li><span class='sev " + f.severity.toLowerCase() + "'></span><span class='name'>" + md(f.title) + "</span><span class='side'>" + esc(f.id) + "</span></li>").join("") + "</ul>"
849
+ : empty("None.")) +
850
+ "</div>";
851
+ }
852
+
853
+ const ADAPTER_NAMES = { claude: "Claude Code", codex: "Codex", copilot: "GitHub Copilot", opencode: "OpenCode" };
854
+ const when = (iso) => (iso && !isNaN(Date.parse(iso)) ? new Date(iso).toLocaleString() : esc(iso || ""));
855
+ const semver = (v) => String(v || "").split("-")[0].split(".").map(Number);
856
+
857
+ function older(installed, tool) {
858
+ const a = semver(installed);
859
+ const b = semver(tool);
860
+ if (a.length !== 3 || b.length !== 3 || a.concat(b).some(isNaN)) return false;
861
+ return a[0] - b[0] < 0 || (a[0] === b[0] && (a[1] - b[1] < 0 || (a[1] === b[1] && a[2] < b[2])));
862
+ }
863
+
864
+ function flatten(value, prefix, rows) {
865
+ if (value && typeof value === "object" && !Array.isArray(value)) {
866
+ Object.keys(value).forEach((k) => flatten(value[k], prefix ? prefix + "." + k : k, rows));
867
+ } else {
868
+ rows.push([prefix, typeof value === "string" ? value : JSON.stringify(value)]);
869
+ }
870
+ return rows;
871
+ }
872
+
873
+ function renderHealth() {
874
+ const h = data.health;
875
+ const a = data.activity && typeof data.activity === "object" ? data.activity : null;
876
+ const install = h.install;
877
+ $("hl-notice").innerHTML = install && h.tool && older(install.version, h.tool)
878
+ ? "<div class='notice'><span class='cmd'>religion update</span><p>This project was installed by " + esc(install.version) + ", and this tool is " + esc(h.tool) +
879
+ ". Updating refreshes the managed files and keeps any you have edited.</p></div>"
880
+ : "";
881
+ $("hl-run").innerHTML = a ? "<span class='run'>" + esc(a.status) + "</span>" : "";
882
+ const field = (label, value, cls) => (value === undefined || value === null || value === "" ? "" : "<dt>" + label + "</dt><dd" + (cls ? " class='" + cls + "'" : "") + ">" + value + "</dd>");
883
+ const progress = a && a.progress && typeof a.progress === "object" ? a.progress.current + " of " + a.progress.total + " " + (a.progress.label || "") : "";
884
+ const item = a && a.item && typeof a.item === "object" ? esc(a.item.id) + ", " + esc(a.item.title) : "";
885
+ $("hl-now").innerHTML = a
886
+ ? "<dl class='kv'>" + field("Command", esc(a.command), "mono") + field("Doing", esc(a.summary)) + field("Detail", esc(a.detail), "dim") + field("Item", item) +
887
+ field("Progress", esc(progress)) + field("Boundary", esc(a.boundary)) + field("Started", when(a.startedAt), "mono") + field("Updated", when(a.updatedAt), "mono") +
888
+ field("Resume", esc(a.resumeCommand), "mono") + "</dl>"
889
+ : empty("No run recorded. A skill that changes something records its activity in religion/.state/run.json.");
890
+ const passing = h.checks.filter((c) => c.ok).length;
891
+ $("hl-pass").textContent = passing + " of " + h.checks.length + " pass";
892
+ $("hl-checks").innerHTML = h.checks.map((c) =>
893
+ "<li><span class='" + (c.ok ? "ok" : "bad") + "'></span><span>" + esc(c.name) + "</span><span class='detail'>" + md(c.detail) + "</span><span class='blocks'>" +
894
+ (c.blocks === "nothing" ? "advisory" : "blocks " + esc(c.blocks)) + "</span></li>"
895
+ ).join("");
896
+ const settings = flatten(config(), "", []);
897
+ $("hl-config").innerHTML = settings.length
898
+ ? "<table class='rows'><tr><th>Setting</th><th>Value</th></tr>" + settings.map((r) => "<tr><td class='file'>" + esc(r[0]) + "</td><td class='mono'>" + esc(r[1]) + "</td></tr>").join("") + "</table>"
899
+ : "<div class='body'>" + empty("No configuration. The built-in defaults apply.") + "</div>";
900
+ $("hl-install").innerHTML =
901
+ (install
902
+ ? "<dl class='kv'>" + field("Installed by", esc(install.version || "unknown"), "mono") + field("This tool", esc(h.tool || "unknown"), "mono") +
903
+ field("Adapters", esc(install.adapters.map((x) => ADAPTER_NAMES[x] || x).join(", ") || "none")) + field("Managed", install.managed + " files") + "</dl>"
904
+ : empty("No install record. Installing or updating writes religion/.state/manifest.json.")) +
905
+ "<div class='divider'></div><div class='label section-label'>Inbox</div>" +
906
+ (h.inbox.length ? h.inbox.map((n) => "<p class='prose'>" + (n.date ? "<span class='faint mono'>" + esc(n.date) + "</span> " : "") + md(n.text) + "</p>").join("") : empty("Empty.")) +
907
+ "<div class='label section-label'>Open questions</div>" +
908
+ (h.questions.length ? "<ul class='list'>" + h.questions.map((q) => "<li>" + md(q) + "</li>").join("") + "</ul>" : empty("None."));
909
+ }
910
+
911
+ function heat(value, max) {
912
+ if (!value) return "h0";
913
+ return value * 3 <= max ? "h1" : value * 3 <= max * 2 ? "h2" : "h3";
914
+ }
915
+
916
+ function overviewFindings(pending) {
917
+ if (!data.findings.length) return empty("The ledger is empty. An audit records what it finds here.");
918
+ const columns = ["open", "fixed", "unverified"];
919
+ const count = (severity, status) => data.findings.filter((f) => f.severity === severity && (!status || f.status === status)).length;
920
+ const max = Math.max(1, ...SEVERITIES.flatMap((s) => columns.map((c) => count(s, c))));
921
+ const blocking = data.status.findings.blocking.length;
922
+ const oldest = pending.slice().sort((a, b) => idNumber(a) - idNumber(b)).slice(0, 3);
923
+ return "<table><tr><th>Severity</th><th>Open</th><th>Fixed</th><th>Unverified</th><th>Total</th></tr>" +
924
+ SEVERITIES.map((s) =>
925
+ "<tr><td><span class='sev " + s.toLowerCase() + "'>" + s + "</span></td>" + columns.map((c) => "<td class='" + heat(count(s, c), max) + "'>" + count(s, c) + "</td>").join("") +
926
+ "<td class='" + (count(s) ? "" : "h0") + "'>" + count(s) + "</td></tr>"
927
+ ).join("") + "</table>" +
928
+ (blocking
929
+ ? "<div class='callout bad'>" + blocking + " blocking: a P0 or P1 is open or fixed, so completion is blocked.</div>"
930
+ : "<div class='callout'>No P0 or P1 is open or fixed, so completion is not blocked.</div>") +
931
+ (oldest.length
932
+ ? "<div class='divider'></div><div class='label section-label'>Oldest unresolved</div><ul class='steps'>" +
933
+ oldest.map((f) => "<li><span class='sev " + f.severity.toLowerCase() + "'></span><span class='name'>" + md(f.title) + "</span><span class='side'>" + esc(f.id) + "</span></li>").join("") + "</ul>"
934
+ : "");
935
+ }
936
+
937
+ function planList() {
938
+ if (!data.plan.length) return empty("The build plan has no items yet.");
939
+ return "<ul class='plan'>" + data.plan.map((item) => {
940
+ const next = !item.done && item.title === data.status.plan.nextItem;
941
+ return "<li class='" + (item.done ? "done" : next ? "next-item" : "") + (item.depth ? " sub" : "") + "'><span class='num'>" + esc(item.number || "") +
942
+ "</span><span class='t'>" + md(short(item.title)) + "</span>" + (item.done ? "<span class='check'>done</span>" : next ? "<span class='queued'>next</span>" : "<span></span>") + "</li>";
943
+ }).join("") + "</ul>";
944
+ }
945
+
946
+ function overviewHealth() {
947
+ const a = data.activity && typeof data.activity === "object" ? data.activity : null;
948
+ const h = data.health;
949
+ const run = a
950
+ ? "<dl class='kv'><dt>Now</dt><dd><span class='run'>" + esc(a.status) + "</span> <span class='mono dim'>" + esc(a.command) + "</span></dd>" +
951
+ (a.summary ? "<dt>Doing</dt><dd>" + esc(a.summary) + "</dd>" : "") +
952
+ (a.detail ? "<dt>Detail</dt><dd class='dim'>" + esc(a.detail) + "</dd>" : "") +
953
+ (a.resumeCommand ? "<dt>Resume</dt><dd class='mono'>" + esc(a.resumeCommand) + "</dd>" : "") + "</dl>"
954
+ : empty("No run recorded. A skill that changes something records its activity here.");
955
+ return run +
956
+ "<div class='checks'>" + h.checks.map((c) => "<span class='" + (c.ok ? "" : "bad") + "'>" + esc(c.name) + ", " + esc(c.detail) + "</span>").join("") + "</div>" +
957
+ "<div class='divider'></div><dl class='kv'><dt>Questions</dt><dd class='" + (h.questions.length ? "" : "dim") + "'>" + (h.questions.length ? esc(h.questions.join(", ")) : "None open") + "</dd>" +
958
+ "<dt>Inbox</dt><dd>" + (h.inbox.length ? h.inbox.length + " note" + (h.inbox.length > 1 ? "s" : "") : "<span class='dim'>Empty</span>") + "</dd></dl>";
959
+ }
960
+
961
+ document.addEventListener("click", (event) => {
962
+ const target = event.target.closest("[data-filter], [data-pick]");
963
+ if (!target || !data) return;
964
+ const value = target.dataset.value;
965
+ if (target.dataset.filter === "status") ui.status = value;
966
+ else if (target.dataset.filter === "severity") ui.severity = ui.severity === value ? null : value;
967
+ else if (target.dataset.pick === "finding") ui.finding = value;
968
+ else if (target.dataset.pick === "archive") ui.archive = value;
969
+ const focused = document.activeElement === target;
970
+ const role = target.dataset.pick || target.dataset.filter;
971
+ render();
972
+ // Rendering replaced the element; put focus back on its replacement so a keyboard user keeps their place.
973
+ const again = Array.from(document.querySelectorAll("[data-pick], [data-filter]")).find((el) => el.dataset.value === value && (el.dataset.pick || el.dataset.filter) === role);
974
+ if (focused && again) again.focus();
975
+ });
976
+ document.addEventListener("keydown", (event) => {
977
+ if ((event.key === "Enter" || event.key === " ") && event.target.dataset && event.target.dataset.pick) {
978
+ event.preventDefault();
979
+ event.target.click();
980
+ }
981
+ });
982
+
983
+ load(); setInterval(load, 3000);
984
+ })();
146
985
  </script>`;