@bompus/house-rules 0.11.0 → 0.14.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.
Files changed (112) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/README.md +102 -63
  3. package/compose.mjs +8 -2
  4. package/composition.mjs +11 -3
  5. package/config-view.mjs +18 -3
  6. package/config.mjs +74 -13
  7. package/docs/assets/overview.png +0 -0
  8. package/docs/configuration.md +85 -9
  9. package/package.json +4 -2
  10. package/rules/core.md +118 -209
  11. package/rules/modifiers/backlog-outline.md +25 -0
  12. package/rules/modifiers/backlog-visual.md +16 -0
  13. package/rules/modifiers/coded-offers.md +47 -17
  14. package/rules/modifiers/cross-session-relay.md +23 -0
  15. package/rules/modifiers/low-quota-handoff.md +4 -3
  16. package/rules/modifiers/model-default-questions.md +33 -0
  17. package/rules/modifiers/multi-agent.md +6 -2
  18. package/rules/modifiers/no-attribution.md +7 -1
  19. package/rules/modifiers/periodic-updates.md +25 -0
  20. package/rules/modifiers/plan-files.md +35 -3
  21. package/rules/modifiers/release-batching.md +4 -2
  22. package/rules/modifiers/shared-host-load.md +13 -42
  23. package/rules/modifiers/squash-landing.md +1 -1
  24. package/rules/modifiers/upstream-contributions.md +45 -0
  25. package/rules/modifiers/web-image-formats.md +35 -0
  26. package/rules/references/backlog-view.md +154 -0
  27. package/rules/references/landing.md +14 -14
  28. package/rules/references/multi-agent.md +6 -2
  29. package/rules/references/release-batching.md +37 -4
  30. package/rules/references/squash-landing.md +10 -4
  31. package/rules/references/thread-visuals.md +45 -0
  32. package/setup.mjs +87 -17
  33. package/skills/hr-agent-guidance-audit/SKILL.md +10 -5
  34. package/skills/hr-agent-guidance-audit/references/prune-workflow.md +72 -0
  35. package/skills/hr-agent-guidance-audit/references/records-and-lessons.md +1 -1
  36. package/skills/hr-agent-guidance-audit/references/retirement-verdicts.md +49 -0
  37. package/skills/hr-agent-guidance-refresh/SKILL.md +25 -57
  38. package/skills/hr-agent-guidance-refresh/references/verification.md +1 -1
  39. package/skills/hr-audit-choices/SKILL.md +1 -1
  40. package/skills/hr-benchmarking/SKILL.md +122 -159
  41. package/skills/hr-benchmarking/references/javascript.md +113 -0
  42. package/skills/hr-benchmarking/references/tools.md +62 -57
  43. package/skills/hr-better-accessibility/SKILL.md +1 -1
  44. package/skills/hr-better-accessibility/screen-readers.md +1 -0
  45. package/skills/hr-code-review/SKILL.md +19 -3
  46. package/skills/hr-code-review/agents/openai.yaml +1 -1
  47. package/skills/hr-code-review/references/acp-client.md +43 -0
  48. package/skills/{hr-change-impact/SKILL.md → hr-code-review/references/impact.md} +2 -12
  49. package/skills/hr-code-review/references/panel-adjudication.md +58 -0
  50. package/skills/hr-code-review/references/panel-plan.md +61 -0
  51. package/skills/hr-code-review/references/panel-recommendations.json +105 -0
  52. package/skills/hr-code-review/references/panel-recommendations.md +46 -0
  53. package/skills/hr-code-review/references/panel-setup.md +54 -0
  54. package/skills/hr-code-review/references/reviewer-role.md +31 -0
  55. package/skills/hr-code-review/scripts/acp-panel-client.mjs +299 -0
  56. package/skills/hr-code-review/scripts/check-receipts.mjs +263 -0
  57. package/skills/hr-code-review/scripts/panel-plan.mjs +69 -0
  58. package/skills/hr-code-review/scripts/panel-preferences.mjs +220 -0
  59. package/skills/hr-design-exploration/SKILL.md +24 -1
  60. package/skills/hr-design-exploration/agents/openai.yaml +2 -2
  61. package/skills/hr-diagnosing-bugs/SKILL.md +2 -2
  62. package/skills/hr-diagnosing-bugs/references/reporting.md +29 -4
  63. package/skills/hr-diagnosing-bugs/scripts/check-pr-offer.mjs +47 -0
  64. package/skills/hr-explain-code/SKILL.md +1 -1
  65. package/skills/hr-extract-shared-steps/SKILL.md +1 -1
  66. package/skills/hr-forgotten-work-audit/SKILL.md +130 -0
  67. package/skills/hr-forgotten-work-audit/references/brief-template.md +56 -0
  68. package/skills/hr-forgotten-work-audit/references/result-format.md +46 -0
  69. package/skills/hr-handoff/LICENSE +21 -0
  70. package/skills/hr-handoff/NOTICE.md +29 -0
  71. package/skills/hr-handoff/SKILL.md +24 -1
  72. package/skills/hr-handoff/references/reconciliation.md +25 -6
  73. package/skills/hr-handoff/references/successor-thread.md +76 -0
  74. package/skills/hr-handoff/scripts/check-reconciliation.mjs +48 -6
  75. package/skills/hr-house-rules-setup/SKILL.md +22 -8
  76. package/skills/hr-lean-plan/SKILL.md +4 -0
  77. package/skills/hr-maintainability-review/SKILL.md +14 -4
  78. package/skills/hr-maintainability-review/agents/openai.yaml +1 -1
  79. package/skills/hr-manual-qa/SKILL.md +1 -1
  80. package/skills/hr-ordering-tests/SKILL.md +1 -1
  81. package/skills/hr-plain-prose/SKILL.md +7 -4
  82. package/skills/hr-pr-followup/SKILL.md +6 -3
  83. package/skills/hr-pr-followup/references/review-helpers.md +40 -7
  84. package/skills/hr-pr-followup/scripts/check-landing.py +204 -0
  85. package/skills/hr-pr-followup/scripts/check-pr-review.ts +19 -8
  86. package/skills/hr-pr-followup/scripts/merge-pr.sh +100 -0
  87. package/skills/hr-pr-followup/scripts/verify-landed.sh +184 -0
  88. package/skills/hr-read-reddit/SKILL.md +1 -1
  89. package/skills/hr-research-shared-links/SKILL.md +103 -0
  90. package/skills/hr-sqlite/SKILL.md +174 -0
  91. package/skills/hr-sqlite/agents/openai.yaml +2 -0
  92. package/skills/hr-sqlite/references/evidence.md +94 -0
  93. package/skills/hr-sqlite/references/tool-decisions.md +39 -0
  94. package/skills/hr-sqlite/scripts/inspect-sqlite.py +245 -0
  95. package/skills/hr-stock-ui-audit/SKILL.md +1 -1
  96. package/skills/hr-test-audit/LICENSE +21 -0
  97. package/skills/hr-test-audit/NOTICE.md +67 -0
  98. package/skills/hr-usage-report/SKILL.md +90 -0
  99. package/skills/hr-usage-report/agents/openai.yaml +2 -0
  100. package/skills/hr-usage-report/assets/example-report.json +125 -0
  101. package/skills/hr-usage-report/assets/report-template.html +377 -0
  102. package/skills/hr-usage-report/scripts/render-report.ts +86 -0
  103. package/skills/hr-usage-report/scripts/usage-by-model.ts +499 -0
  104. package/skills/hr-what-next/SKILL.md +100 -10
  105. package/skills/hr-what-next/references/reporting.md +13 -22
  106. package/skills/hr-what-next/scripts/check-open-work.mjs +711 -0
  107. package/skills/hr-writing-pr/SKILL.md +29 -14
  108. package/skills/hr-writing-pr/scripts/benefit-chart.py +200 -0
  109. package/docs/npm-release.md +0 -45
  110. package/rules/modifiers/swarmail.md +0 -12
  111. package/skills/hr-benchmarking/references/priority-findings-20261006.md +0 -115
  112. package/skills/hr-change-impact/agents/openai.yaml +0 -5
@@ -0,0 +1,377 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <!-- Model comparison report. scripts/render-report.ts fills #report-data from a data file;
6
+ section types and rules: SKILL.md. Colors: up to three categorical series
7
+ (validated dark-surface slots) plus one dashed neutral "reference" series. -->
8
+ <style>
9
+ :root {
10
+ --s1: #3987e5;
11
+ --s2: #d95926;
12
+ --s3: #199e70;
13
+ --ref: #a8a7a0;
14
+ --fg: var(--foreground, #f2f2f0);
15
+ --mut: var(--muted-foreground, #b5b4ad);
16
+ --bd: var(--border, #3a3a38);
17
+ --bg: var(--background, #1a1a19);
18
+ --grid: color-mix(in srgb, var(--fg) 12%, transparent);
19
+ }
20
+ html,
21
+ body {
22
+ background: var(--bg);
23
+ }
24
+ body {
25
+ font-family: var(--font-sans, system-ui, sans-serif);
26
+ color: var(--fg);
27
+ margin: 0;
28
+ }
29
+ .wrap {
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: 22px;
33
+ }
34
+ h2 {
35
+ font-size: 18px;
36
+ margin: 0;
37
+ }
38
+ h3 {
39
+ font-size: 15px;
40
+ margin: 0 0 2px;
41
+ }
42
+ .sub {
43
+ color: var(--mut);
44
+ font-size: 12.5px;
45
+ margin: 0 0 8px;
46
+ line-height: 1.4;
47
+ }
48
+ .legend {
49
+ display: flex;
50
+ gap: 14px;
51
+ flex-wrap: wrap;
52
+ font-size: 12.5px;
53
+ color: var(--mut);
54
+ margin-bottom: 6px;
55
+ }
56
+ .legend span {
57
+ display: inline-flex;
58
+ align-items: center;
59
+ gap: 6px;
60
+ }
61
+ .sw {
62
+ width: 18px;
63
+ height: 0;
64
+ border-top: 2px solid;
65
+ }
66
+ .grid2 {
67
+ display: grid;
68
+ grid-template-columns: 1fr 1fr;
69
+ gap: 18px;
70
+ }
71
+ @media (max-width: 560px) {
72
+ .grid2 {
73
+ grid-template-columns: 1fr;
74
+ }
75
+ }
76
+ svg {
77
+ display: block;
78
+ width: 100%;
79
+ overflow: visible;
80
+ }
81
+ svg text {
82
+ font-family: inherit;
83
+ font-size: 11px;
84
+ fill: var(--mut);
85
+ }
86
+ svg .lbl {
87
+ fill: var(--fg);
88
+ }
89
+ .tip {
90
+ position: fixed;
91
+ pointer-events: none;
92
+ background: var(--popover, #262624);
93
+ color: var(--popover-foreground, var(--fg));
94
+ border: 1px solid var(--bd);
95
+ border-radius: 6px;
96
+ padding: 6px 8px;
97
+ font-size: 12px;
98
+ opacity: 0;
99
+ transition: opacity 0.08s;
100
+ z-index: 5;
101
+ max-width: 260px;
102
+ }
103
+ table {
104
+ border-collapse: collapse;
105
+ width: 100%;
106
+ font-size: 12.5px;
107
+ }
108
+ th,
109
+ td {
110
+ text-align: left;
111
+ padding: 6px 8px;
112
+ border-bottom: 1px solid var(--bd);
113
+ vertical-align: top;
114
+ }
115
+ th {
116
+ color: var(--mut);
117
+ font-weight: 600;
118
+ }
119
+ td.n {
120
+ font-variant-numeric: tabular-nums;
121
+ white-space: nowrap;
122
+ }
123
+ .yes {
124
+ color: var(--success, #3fb37f);
125
+ }
126
+ .no {
127
+ color: var(--mut);
128
+ }
129
+ .maybe {
130
+ color: var(--warning, #e0a63a);
131
+ }
132
+ </style>
133
+ </head>
134
+ <body>
135
+ <div class="wrap" id="root"></div>
136
+ <div class="tip" id="tip"></div>
137
+ <script id="report-data" type="application/json">
138
+ __REPORT_DATA__
139
+ </script>
140
+ <script>
141
+ const DATA = JSON.parse(document.getElementById("report-data").textContent);
142
+ const SLOTS = ["var(--s1)", "var(--s2)", "var(--s3)"];
143
+ const NS = "http://www.w3.org/2000/svg";
144
+ const tip = document.getElementById("tip");
145
+ const esc = (s) =>
146
+ String(s).replace(
147
+ /[&<>"]/g,
148
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
149
+ );
150
+ function showTip(e, h) {
151
+ tip.innerHTML = h;
152
+ tip.style.opacity = 1;
153
+ tip.style.left = Math.min(e.clientX + 12, innerWidth - 270) + "px";
154
+ tip.style.top = e.clientY + 12 + "px";
155
+ }
156
+ function hideTip() {
157
+ tip.style.opacity = 0;
158
+ }
159
+ function el(t, a, p) {
160
+ const n = document.createElementNS(NS, t);
161
+ for (const k in a) n.setAttribute(k, a[k]);
162
+ if (p) p.appendChild(n);
163
+ return n;
164
+ }
165
+ function hover(node, html) {
166
+ node.addEventListener("mousemove", (e) => showTip(e, html));
167
+ node.addEventListener("mouseleave", hideTip);
168
+ }
169
+ // Color follows the entity: a series key keeps its slot across every chart in the report.
170
+ const keys = DATA.series || {};
171
+ const color = (k) => (keys[k]?.reference ? "var(--ref)" : SLOTS[keys[k]?.slot ?? 0]);
172
+ const isRef = (k) => !!keys[k]?.reference;
173
+ const name = (k) => keys[k]?.name ?? k;
174
+ const legend = (ks) =>
175
+ `<div class="legend">${ks.map((k) => `<span><i class="sw" style="border-color:${color(k)};${isRef(k) ? "border-top-style:dashed" : ""}"></i>${esc(name(k))}</span>`).join("")}</div>`;
176
+ function marker(svg, x, y, k, html) {
177
+ el(
178
+ "circle",
179
+ {
180
+ cx: x,
181
+ cy: y,
182
+ r: 4.5,
183
+ fill: isRef(k) ? "var(--bg)" : color(k),
184
+ stroke: isRef(k) ? color(k) : "var(--bg)",
185
+ "stroke-width": 2,
186
+ },
187
+ svg,
188
+ );
189
+ hover(el("circle", { cx: x, cy: y, r: 11, fill: "transparent" }, svg), html);
190
+ }
191
+ function endLabels(svg, items) {
192
+ items.sort((a, b) => a.y - b.y);
193
+ let last = -99;
194
+ for (const it of items) {
195
+ const y = Math.max(it.y, last + 13);
196
+ last = y;
197
+ el("text", { x: it.x + 8, y: y + 4, class: "lbl" }, svg).textContent = it.text;
198
+ }
199
+ }
200
+ const draw = {
201
+ // Score against cost (or any two measures): one polyline per series through its effort points.
202
+ curve(s, svg) {
203
+ const W = svg.clientWidth,
204
+ H = s.height || 300,
205
+ m = { l: 44, r: 74, t: 10, b: 34 };
206
+ svg.setAttribute("height", H);
207
+ const xs = s.series.flatMap((r) => r.points.map((p) => p.x)),
208
+ ys = s.series.flatMap((r) => r.points.map((p) => p.y));
209
+ const [x0, x1] = s.xDomain || [Math.min(...xs) * 0.7, Math.max(...xs) * 1.4],
210
+ [y0, y1] = s.yDomain || [
211
+ Math.floor(Math.min(...ys) / 10) * 10,
212
+ Math.ceil(Math.max(...ys) / 10) * 10,
213
+ ];
214
+ const tx = (v) =>
215
+ (s.xLog ? Math.log10(v) - Math.log10(x0) : v - x0) /
216
+ (s.xLog ? Math.log10(x1) - Math.log10(x0) : x1 - x0);
217
+ const x = (v) => m.l + tx(v) * (W - m.l - m.r),
218
+ y = (v) => H - m.b - ((v - y0) / (y1 - y0)) * (H - m.t - m.b);
219
+ for (const v of s.xTicks || []) {
220
+ el("line", { x1: x(v), x2: x(v), y1: m.t, y2: H - m.b, stroke: "var(--grid)" }, svg);
221
+ el("text", { x: x(v), y: H - m.b + 15, "text-anchor": "middle" }, svg).textContent =
222
+ (s.xPrefix || "") + v + (s.xSuffix || "");
223
+ }
224
+ for (const v of s.yTicks || []) {
225
+ el("line", { x1: m.l, x2: W - m.r, y1: y(v), y2: y(v), stroke: "var(--grid)" }, svg);
226
+ el("text", { x: m.l - 6, y: y(v) + 4, "text-anchor": "end" }, svg).textContent =
227
+ v + (s.ySuffix || "");
228
+ }
229
+ el("text", { x: W / 2, y: H - 2, "text-anchor": "middle" }, svg).textContent =
230
+ s.xLabel || "";
231
+ const ends = [];
232
+ for (const r of s.series) {
233
+ el(
234
+ "polyline",
235
+ {
236
+ points: r.points.map((p) => x(p.x) + "," + y(p.y)).join(" "),
237
+ fill: "none",
238
+ stroke: color(r.key),
239
+ "stroke-width": 2,
240
+ "stroke-dasharray": isRef(r.key) ? "5 4" : "",
241
+ },
242
+ svg,
243
+ );
244
+ for (const p of r.points)
245
+ marker(
246
+ svg,
247
+ x(p.x),
248
+ y(p.y),
249
+ r.key,
250
+ `<b>${esc(name(r.key))} ${esc(p.label)}</b><br>${esc(p.tip || "")}`,
251
+ );
252
+ const L = r.points[r.points.length - 1];
253
+ ends.push({ x: x(L.x), y: y(L.y), text: name(r.key).split(" ")[0] });
254
+ }
255
+ endLabels(svg, ends);
256
+ },
257
+ // One value per category per series (effort ladders).
258
+ ladder(s, svg) {
259
+ const W = svg.clientWidth,
260
+ H = s.height || 250,
261
+ m = { l: 34, r: 74, t: 10, b: 26 },
262
+ n = s.categories.length;
263
+ svg.setAttribute("height", H);
264
+ const [y0, y1] = s.yDomain,
265
+ x = (i) => m.l + (i / (n - 1)) * (W - m.l - m.r),
266
+ y = (v) => H - m.b - ((v - y0) / (y1 - y0)) * (H - m.t - m.b);
267
+ for (const v of s.yTicks || []) {
268
+ el("line", { x1: m.l, x2: W - m.r, y1: y(v), y2: y(v), stroke: "var(--grid)" }, svg);
269
+ el("text", { x: m.l - 6, y: y(v) + 4, "text-anchor": "end" }, svg).textContent = v;
270
+ }
271
+ s.categories.forEach((c, i) => {
272
+ el("text", { x: x(i), y: H - m.b + 16, "text-anchor": "middle" }, svg).textContent = c;
273
+ });
274
+ const ends = [];
275
+ for (const r of s.series) {
276
+ const pts = r.values.map((v, i) => [i, v]).filter((p) => p[1] !== null);
277
+ el(
278
+ "polyline",
279
+ {
280
+ points: pts.map(([i, v]) => x(i) + "," + y(v)).join(" "),
281
+ fill: "none",
282
+ stroke: color(r.key),
283
+ "stroke-width": 2,
284
+ "stroke-dasharray": isRef(r.key) ? "5 4" : "",
285
+ },
286
+ svg,
287
+ );
288
+ for (const [i, v] of pts)
289
+ marker(
290
+ svg,
291
+ x(i),
292
+ y(v),
293
+ r.key,
294
+ `<b>${esc(name(r.key))} ${esc(s.categories[i])}</b><br>${esc(v)}${r.notes?.[i] ? " · " + esc(r.notes[i]) : ""}`,
295
+ );
296
+ const L = pts[pts.length - 1];
297
+ ends.push({ x: x(L[0]), y: y(L[1]), text: name(r.key).split(" ")[0] });
298
+ }
299
+ endLabels(svg, ends);
300
+ },
301
+ // Horizontal bars; rows name their series for color.
302
+ bars(s, svg) {
303
+ const W = svg.clientWidth,
304
+ H = s.height || 190,
305
+ lw = s.labelWidth || 96,
306
+ rh = (H - 6) / s.rows.length,
307
+ max = s.max || Math.max(...s.rows.map((r) => r.value)) * 1.1;
308
+ svg.setAttribute("height", H);
309
+ s.rows.forEach((r, i) => {
310
+ const w = ((W - lw - 46) * r.value) / max,
311
+ yy = i * rh + 4,
312
+ bh = rh - 12;
313
+ el("text", { x: 0, y: yy + bh / 2 + 4 }, svg).textContent = r.label;
314
+ const b = el(
315
+ "rect",
316
+ {
317
+ x: lw,
318
+ y: yy,
319
+ width: Math.max(w, 2),
320
+ height: bh,
321
+ rx: 4,
322
+ fill: isRef(r.key) ? "none" : color(r.key),
323
+ stroke: isRef(r.key) ? color(r.key) : "none",
324
+ "stroke-dasharray": isRef(r.key) ? "5 4" : "",
325
+ "stroke-width": isRef(r.key) ? 1.5 : 0,
326
+ },
327
+ svg,
328
+ );
329
+ el("text", { x: lw + w + 6, y: yy + bh / 2 + 4, class: "lbl" }, svg).textContent =
330
+ r.display ?? r.value;
331
+ hover(b, `<b>${esc(r.label)}</b><br>${esc(r.tip || "")}`);
332
+ });
333
+ },
334
+ };
335
+ function section(s) {
336
+ const box = document.createElement("section");
337
+ box.innerHTML = `<h3>${esc(s.title || "")}</h3>${s.sub ? `<p class="sub">${esc(s.sub)}</p>` : ""}`;
338
+ if (s.type === "pair") {
339
+ box.className = "grid2";
340
+ box.innerHTML = "";
341
+ for (const c of s.items) box.appendChild(section(c));
342
+ return box;
343
+ }
344
+ if (s.type === "table") {
345
+ const cls = (v) => ({ yes: "yes", no: "no", maybe: "maybe" })[v] || "";
346
+ box.innerHTML += `<table><thead><tr>${s.columns.map((c) => `<th>${esc(c)}</th>`).join("")}</tr></thead><tbody>${s.rows
347
+ .map(
348
+ (row) =>
349
+ `<tr>${row
350
+ .map((c, i) => {
351
+ const v = typeof c === "object" && c ? c : { text: c };
352
+ return `<td class="${s.numeric?.includes(i) ? "n" : ""} ${cls(v.verdict)}">${esc(v.text)}</td>`;
353
+ })
354
+ .join("")}</tr>`,
355
+ )
356
+ .join("")}</tbody></table>`;
357
+ return box;
358
+ }
359
+ const ks = s.series ? s.series.map((r) => r.key) : [];
360
+ if (ks.length > 1) box.insertAdjacentHTML("beforeend", legend(ks));
361
+ const svg = el("svg", { height: s.height || 200 }, box);
362
+ box._draw = () => {
363
+ svg.innerHTML = "";
364
+ draw[s.type](s, svg);
365
+ };
366
+ return box;
367
+ }
368
+ const root = document.getElementById("root");
369
+ if (DATA.title) root.insertAdjacentHTML("beforeend", `<h2>${esc(DATA.title)}</h2>`);
370
+ const nodes = DATA.sections.map(section);
371
+ nodes.forEach((n) => root.appendChild(n));
372
+ const redraw = () => root.querySelectorAll("section").forEach((n) => n._draw && n._draw());
373
+ redraw();
374
+ addEventListener("resize", redraw);
375
+ </script>
376
+ </body>
377
+ </html>
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bun
2
+ // Render a model comparison report: data JSON in, self-contained HTML out.
3
+ //
4
+ // bun render-report.ts <data.json> <out.html>
5
+ //
6
+ // Data shape and section types: ../SKILL.md. Example data:
7
+ // ../assets/example-report.json.
8
+ import { readFileSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ type Series = { name: string; slot?: number; reference?: boolean };
12
+ type Section = {
13
+ type: string;
14
+ items?: Section[];
15
+ series?: { key: string }[];
16
+ rows?: { key?: string }[];
17
+ };
18
+ export type ReportData = { title?: string; series: Record<string, Series>; sections: Section[] };
19
+
20
+ const TYPES = new Set(["curve", "ladder", "bars", "table", "pair"]);
21
+
22
+ /** Problems that would make the page wrong or unreadable; empty when the data is usable. */
23
+ export function check(data: ReportData): string[] {
24
+ const errors: string[] = [];
25
+ const entries = Object.entries(data.series);
26
+ const colored = entries.filter(([, s]) => !s.reference);
27
+ // The template's three dark-surface slots pass the all-pairs color-vision check;
28
+ // a fourth hue does not, so further series must be a dashed reference or a separate chart.
29
+ if (colored.length > 3) {
30
+ errors.push(
31
+ `at most 3 colored series (got ${colored.length}); mark one more "reference": true or put the rest in a separate report`,
32
+ );
33
+ }
34
+ if (entries.filter(([, s]) => s.reference).length > 1) {
35
+ errors.push("at most one reference series");
36
+ }
37
+ const slots = colored.map(([, s]) => s.slot);
38
+ if (
39
+ slots.some((s) => s === undefined || s < 0 || s > 2) ||
40
+ new Set(slots).size !== slots.length
41
+ ) {
42
+ errors.push("each colored series needs a distinct slot 0, 1 or 2");
43
+ }
44
+ const walk = (sections: Section[], path: string) =>
45
+ sections.forEach((s, i) => {
46
+ const where = `${path}[${i}]`;
47
+ if (!TYPES.has(s.type)) {
48
+ errors.push(`${where}: unknown type ${s.type}`);
49
+ }
50
+ for (const k of [
51
+ ...(s.series ?? []).map((r) => r.key),
52
+ ...(s.rows ?? []).map((r) => r.key),
53
+ ]) {
54
+ if (k !== undefined && !data.series[k]) {
55
+ errors.push(`${where}: series key ${k} is not declared`);
56
+ }
57
+ }
58
+ if (s.items) {
59
+ walk(s.items, `${where}.items`);
60
+ }
61
+ });
62
+ walk(data.sections, "sections");
63
+ return errors;
64
+ }
65
+
66
+ export function render(data: ReportData, template: string): string {
67
+ const errors = check(data);
68
+ if (errors.length) {
69
+ throw new Error(errors.join("\n"));
70
+ }
71
+ // Inside <script>, a literal "</" would close the element early.
72
+ return template.replace("__REPORT_DATA__", () => JSON.stringify(data).replaceAll("</", "<\\/"));
73
+ }
74
+
75
+ if (import.meta.main) {
76
+ const [input, output] = process.argv.slice(2);
77
+ if (!input || !output) {
78
+ throw new Error("usage: bun render-report.ts <data.json> <out.html>");
79
+ }
80
+ const template = readFileSync(
81
+ join(import.meta.dir, "..", "assets", "report-template.html"),
82
+ "utf8",
83
+ );
84
+ writeFileSync(output, render(JSON.parse(readFileSync(input, "utf8")), template));
85
+ console.log(output);
86
+ }