@grainulation/mill 1.0.0 → 1.0.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 (49) hide show
  1. package/CODE_OF_CONDUCT.md +25 -0
  2. package/CONTRIBUTING.md +101 -0
  3. package/README.md +90 -42
  4. package/bin/mill.js +233 -67
  5. package/lib/exporters/csv.js +35 -30
  6. package/lib/exporters/json-ld.js +19 -13
  7. package/lib/exporters/markdown.js +83 -44
  8. package/lib/exporters/pdf.js +15 -15
  9. package/lib/formats/bibtex.mjs +83 -0
  10. package/lib/formats/{changelog.js → changelog.mjs} +27 -26
  11. package/lib/formats/confluence-adf.mjs +312 -0
  12. package/lib/formats/{csv.js → csv.mjs} +41 -37
  13. package/lib/formats/{dot.js → dot.mjs} +45 -34
  14. package/lib/formats/{evidence-matrix.js → evidence-matrix.mjs} +17 -16
  15. package/lib/formats/executive-summary.mjs +178 -0
  16. package/lib/formats/github-issues.mjs +96 -0
  17. package/lib/formats/{graphml.js → graphml.mjs} +45 -32
  18. package/lib/formats/html-report.mjs +228 -0
  19. package/lib/formats/{jira-csv.js → jira-csv.mjs} +30 -29
  20. package/lib/formats/{json-ld.js → json-ld.mjs} +6 -6
  21. package/lib/formats/{markdown.js → markdown.mjs} +53 -36
  22. package/lib/formats/{ndjson.js → ndjson.mjs} +6 -6
  23. package/lib/formats/{obsidian.js → obsidian.mjs} +43 -35
  24. package/lib/formats/{opml.js → opml.mjs} +38 -28
  25. package/lib/formats/ris.mjs +76 -0
  26. package/lib/formats/{rss.js → rss.mjs} +31 -28
  27. package/lib/formats/{sankey.js → sankey.mjs} +16 -15
  28. package/lib/formats/slide-deck.mjs +288 -0
  29. package/lib/formats/sql.mjs +120 -0
  30. package/lib/formats/{static-site.js → static-site.mjs} +64 -52
  31. package/lib/formats/{treemap.js → treemap.mjs} +16 -15
  32. package/lib/formats/typescript-defs.mjs +150 -0
  33. package/lib/formats/{yaml.js → yaml.mjs} +58 -40
  34. package/lib/formats.js +16 -16
  35. package/lib/index.js +5 -5
  36. package/lib/json-ld-common.js +37 -31
  37. package/lib/publishers/clipboard.js +21 -19
  38. package/lib/publishers/static.js +27 -12
  39. package/lib/serve-mcp.js +158 -83
  40. package/lib/server.js +252 -142
  41. package/package.json +6 -3
  42. package/lib/formats/bibtex.js +0 -76
  43. package/lib/formats/executive-summary.js +0 -130
  44. package/lib/formats/github-issues.js +0 -89
  45. package/lib/formats/html-report.js +0 -181
  46. package/lib/formats/ris.js +0 -70
  47. package/lib/formats/slide-deck.js +0 -200
  48. package/lib/formats/sql.js +0 -116
  49. package/lib/formats/typescript-defs.js +0 -147
@@ -0,0 +1,178 @@
1
+ /**
2
+ * mill format: executive-summary
3
+ *
4
+ * Single-page HTML showing only active risks, top recommendations,
5
+ * and confidence-weighted findings. Filters out resolved/low-confidence noise.
6
+ * Accessible: semantic landmarks, table captions/scope, skip link.
7
+ * Zero dependencies — node built-in only.
8
+ */
9
+
10
+ export const name = "executive-summary";
11
+ export const extension = ".html";
12
+ export const mimeType = "text/html; charset=utf-8";
13
+ export const description =
14
+ "Compact executive summary: active risks, recommendations, evidence coverage";
15
+
16
+ /**
17
+ * Convert a compilation object to an executive summary HTML page.
18
+ * @param {object} compilation - The compilation.json content
19
+ * @returns {string} HTML output
20
+ */
21
+ export function convert(compilation) {
22
+ const meta = compilation.meta || {};
23
+ const claims = compilation.claims || [];
24
+ const certificate = compilation.certificate || {};
25
+
26
+ const title = meta.sprint || meta.question || "Executive Summary";
27
+
28
+ // Active risks sorted by confidence desc
29
+ const risks = claims
30
+ .filter((c) => c.type === "risk" && c.status === "active")
31
+ .sort((a, b) => (b.confidence || 0) - (a.confidence || 0));
32
+
33
+ // Active recommendations sorted by confidence desc
34
+ const recs = claims
35
+ .filter((c) => c.type === "recommendation" && c.status === "active")
36
+ .sort((a, b) => (b.confidence || 0) - (a.confidence || 0));
37
+
38
+ // Evidence coverage — count by tier
39
+ const tierOrder = ["production", "tested", "documented", "web", "stated"];
40
+ const tierCounts = {};
41
+ for (const c of claims) {
42
+ if (c.status === "reverted") continue;
43
+ const tier =
44
+ typeof c.evidence === "string"
45
+ ? c.evidence
46
+ : c.evidence?.tier || c.evidence_tier || "unknown";
47
+ tierCounts[tier] = (tierCounts[tier] || 0) + 1;
48
+ }
49
+
50
+ const riskRows = risks
51
+ .map((c) => {
52
+ const conf =
53
+ c.confidence != null ? Math.round(c.confidence * 100) + "%" : "--";
54
+ const tier =
55
+ typeof c.evidence === "string" ? c.evidence : c.evidence?.tier || "";
56
+ return `<tr><td class="mono">${esc(c.id)}</td><td>${esc(c.content || c.text || "")}</td><td class="center">${esc(tier)}</td><td class="center">${conf}</td></tr>`;
57
+ })
58
+ .join("\n");
59
+
60
+ const recRows = recs
61
+ .map((c) => {
62
+ const conf =
63
+ c.confidence != null ? Math.round(c.confidence * 100) + "%" : "--";
64
+ const tier =
65
+ typeof c.evidence === "string" ? c.evidence : c.evidence?.tier || "";
66
+ return `<tr><td class="mono">${esc(c.id)}</td><td>${esc(c.content || c.text || "")}</td><td class="center">${esc(tier)}</td><td class="center">${conf}</td></tr>`;
67
+ })
68
+ .join("\n");
69
+
70
+ const evidenceRows = tierOrder
71
+ .filter((t) => tierCounts[t])
72
+ .map(
73
+ (t) =>
74
+ `<tr><td>${capitalize(t)}</td><td class="center">${tierCounts[t]}</td></tr>`,
75
+ )
76
+ .join("\n");
77
+ // Include unknown tiers
78
+ const extraTiers = Object.keys(tierCounts)
79
+ .filter((t) => !tierOrder.includes(t))
80
+ .map(
81
+ (t) =>
82
+ `<tr><td>${capitalize(t)}</td><td class="center">${tierCounts[t]}</td></tr>`,
83
+ )
84
+ .join("\n");
85
+
86
+ return `<!DOCTYPE html>
87
+ <html lang="en">
88
+ <head>
89
+ <meta charset="utf-8">
90
+ <meta name="viewport" content="width=device-width,initial-scale=1">
91
+ <title>${esc(title)} — Executive Summary</title>
92
+ <style>
93
+ :root { --bg:#0a0e1a; --surface:#111827; --border:#1e293b; --text:#e2e8f0; --muted:#94a3b8; }
94
+ * { margin:0; padding:0; box-sizing:border-box; }
95
+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; line-height:1.6; padding:2rem; max-width:900px; margin:0 auto; }
96
+ a.skip-link { position:absolute; top:-40px; left:0; background:var(--surface); color:var(--text); padding:0.5rem 1rem; z-index:100; border-radius:0 0 4px 0; }
97
+ a.skip-link:focus { top:0; }
98
+ h1 { font-size:1.6rem; margin-bottom:0.25rem; }
99
+ .subtitle { color:var(--muted); font-size:0.9rem; margin-bottom:2rem; }
100
+ h2 { font-size:1.1rem; margin:1.5rem 0 0.75rem; padding-bottom:0.4rem; border-bottom:1px solid var(--border); }
101
+ h2 .count { color:var(--muted); font-weight:400; }
102
+ table { width:100%; border-collapse:collapse; margin-bottom:1rem; }
103
+ caption { text-align:left; font-size:0.75rem; color:var(--muted); padding-bottom:0.25rem; caption-side:top; }
104
+ th { text-align:left; font-size:0.75rem; text-transform:uppercase; color:var(--muted); padding:0.5rem; border-bottom:1px solid var(--border); }
105
+ td { padding:0.5rem; border-bottom:1px solid var(--border); font-size:0.88rem; vertical-align:top; }
106
+ .center { text-align:center; }
107
+ .mono { font-family:monospace; font-size:0.8rem; white-space:nowrap; }
108
+ .risk-table tr:hover { background:rgba(243,156,18,0.06); }
109
+ .rec-table tr:hover { background:rgba(46,204,113,0.06); }
110
+ .empty { color:var(--muted); font-style:italic; padding:1rem 0; }
111
+ footer { margin-top:2rem; padding-top:0.75rem; border-top:1px solid var(--border); color:var(--muted); font-size:0.75rem; font-family:monospace; }
112
+ </style>
113
+ </head>
114
+ <body>
115
+ <a class="skip-link" href="#main-content">Skip to content</a>
116
+ <header role="banner">
117
+ <h1>${esc(title)}</h1>
118
+ <p class="subtitle">${meta.question ? esc(meta.question) : ""}${meta.question && certificate.compiled_at ? " | " : ""}${certificate.compiled_at ? "Compiled: " + esc(certificate.compiled_at) : ""}</p>
119
+ </header>
120
+
121
+ <main id="main-content">
122
+ <section aria-label="Key risks">
123
+ <h2>Key Risks <span class="count">(${risks.length})</span></h2>
124
+ ${
125
+ risks.length > 0
126
+ ? `
127
+ <table class="risk-table">
128
+ <caption>Active risks sorted by confidence</caption>
129
+ <thead><tr><th scope="col">ID</th><th scope="col">Description</th><th scope="col">Evidence</th><th scope="col">Confidence</th></tr></thead>
130
+ <tbody>${riskRows}</tbody>
131
+ </table>`
132
+ : '<p class="empty">No active risks.</p>'
133
+ }
134
+ </section>
135
+
136
+ <section aria-label="Recommendations">
137
+ <h2>Recommendations <span class="count">(${recs.length})</span></h2>
138
+ ${
139
+ recs.length > 0
140
+ ? `
141
+ <table class="rec-table">
142
+ <caption>Active recommendations sorted by confidence</caption>
143
+ <thead><tr><th scope="col">ID</th><th scope="col">Description</th><th scope="col">Evidence</th><th scope="col">Confidence</th></tr></thead>
144
+ <tbody>${recRows}</tbody>
145
+ </table>`
146
+ : '<p class="empty">No active recommendations.</p>'
147
+ }
148
+ </section>
149
+
150
+ <section aria-label="Evidence coverage">
151
+ <h2>Evidence Coverage</h2>
152
+ <table>
153
+ <caption>Claims count per evidence tier</caption>
154
+ <thead><tr><th scope="col">Tier</th><th scope="col">Claims</th></tr></thead>
155
+ <tbody>${evidenceRows}${extraTiers}</tbody>
156
+ </table>
157
+ </section>
158
+ </main>
159
+
160
+ <footer role="contentinfo">
161
+ Certificate: ${certificate.claim_count || claims.length} claims | sha256:${(certificate.sha256 || "unknown").slice(0, 16)}
162
+ </footer>
163
+ </body>
164
+ </html>`;
165
+ }
166
+
167
+ function esc(str) {
168
+ if (str == null) return "";
169
+ return String(str)
170
+ .replace(/&/g, "&amp;")
171
+ .replace(/</g, "&lt;")
172
+ .replace(/>/g, "&gt;")
173
+ .replace(/"/g, "&quot;");
174
+ }
175
+
176
+ function capitalize(str) {
177
+ return str.charAt(0).toUpperCase() + str.slice(1);
178
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * mill format: github-issues
3
+ *
4
+ * Converts compilation.json claims to a single markdown file
5
+ * with each claim formatted as a GitHub issue template.
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = "github-issues";
10
+ export const extension = ".md";
11
+ export const mimeType = "text/markdown; charset=utf-8";
12
+ export const description =
13
+ "Claims as GitHub issue templates (one markdown file, horizontal-rule separated)";
14
+
15
+ /**
16
+ * Convert a compilation object to GitHub issue markdown.
17
+ * @param {object} compilation - The compilation.json content
18
+ * @returns {string} Markdown output
19
+ */
20
+ export function convert(compilation) {
21
+ const meta = compilation.meta || {};
22
+ const claims = compilation.claims || [];
23
+ const sprint = meta.sprint || "unknown";
24
+
25
+ const lines = [];
26
+
27
+ lines.push(`# GitHub Issues: ${sprint}`);
28
+ lines.push("");
29
+ lines.push("> Bulk-create with the `gh` CLI:");
30
+ lines.push("> ```");
31
+ lines.push(
32
+ '> # Split this file by "---" and create each section as an issue:',
33
+ );
34
+ lines.push(
35
+ `> # gh issue create --title "TITLE" --body "BODY" --label "TYPE"`,
36
+ );
37
+ lines.push("> ```");
38
+ lines.push("");
39
+
40
+ if (claims.length === 0) {
41
+ lines.push("_No claims to export._");
42
+ return lines.join("\n") + "\n";
43
+ }
44
+
45
+ for (let i = 0; i < claims.length; i++) {
46
+ if (i > 0) {
47
+ lines.push("");
48
+ }
49
+ lines.push("---");
50
+ lines.push("");
51
+ lines.push(...formatClaim(claims[i]));
52
+ }
53
+
54
+ lines.push("");
55
+ return lines.join("\n") + "\n";
56
+ }
57
+
58
+ function formatClaim(claim) {
59
+ const id = claim.id || "???";
60
+ const content = claim.content || claim.text || "";
61
+ const type = claim.type || "unknown";
62
+ const evidence = getEvidence(claim);
63
+ const status = claim.status || "unknown";
64
+ const tags = Array.isArray(claim.tags) ? claim.tags : [];
65
+ const summary = truncate(content, 80);
66
+
67
+ const lines = [];
68
+
69
+ lines.push(`## ${id}: ${summary}`);
70
+ lines.push("");
71
+ lines.push(
72
+ `**Type:** ${type} | **Evidence:** ${evidence} | **Status:** ${status}`,
73
+ );
74
+ lines.push("");
75
+ lines.push(content);
76
+
77
+ if (tags.length > 0) {
78
+ lines.push("");
79
+ lines.push(`**Tags:** ${tags.join(", ")}`);
80
+ }
81
+
82
+ return lines;
83
+ }
84
+
85
+ function getEvidence(claim) {
86
+ if (typeof claim.evidence === "string") return claim.evidence;
87
+ if (typeof claim.evidence === "object" && claim.evidence !== null) {
88
+ return claim.evidence.tier || claim.evidence_tier || "stated";
89
+ }
90
+ return claim.evidence_tier || "stated";
91
+ }
92
+
93
+ function truncate(str, max) {
94
+ if (str.length <= max) return str;
95
+ return str.slice(0, max - 3) + "...";
96
+ }
@@ -6,10 +6,11 @@
6
6
  * Zero dependencies — node built-in only.
7
7
  */
8
8
 
9
- export const name = 'graphml';
10
- export const extension = '.graphml';
11
- export const mimeType = 'application/graphml+xml; charset=utf-8';
12
- export const description = 'Claims as GraphML graph (nodes per claim, edges for shared tags)';
9
+ export const name = "graphml";
10
+ export const extension = ".graphml";
11
+ export const mimeType = "application/graphml+xml; charset=utf-8";
12
+ export const description =
13
+ "Claims as GraphML graph (nodes per claim, edges for shared tags)";
13
14
 
14
15
  /**
15
16
  * Convert a compilation object to GraphML XML.
@@ -22,32 +23,42 @@ export function convert(compilation) {
22
23
  const lines = [];
23
24
  lines.push('<?xml version="1.0" encoding="utf-8"?>');
24
25
  lines.push('<graphml xmlns="http://graphml.graphstruct.org/graphml">');
25
- lines.push(' <key id="type" for="node" attr.name="type" attr.type="string"/>');
26
- lines.push(' <key id="evidence" for="node" attr.name="evidence" attr.type="string"/>');
27
- lines.push(' <key id="content" for="node" attr.name="content" attr.type="string"/>');
28
- lines.push(' <key id="status" for="node" attr.name="status" attr.type="string"/>');
29
- lines.push(' <key id="confidence" for="node" attr.name="confidence" attr.type="double"/>');
26
+ lines.push(
27
+ ' <key id="type" for="node" attr.name="type" attr.type="string"/>',
28
+ );
29
+ lines.push(
30
+ ' <key id="evidence" for="node" attr.name="evidence" attr.type="string"/>',
31
+ );
32
+ lines.push(
33
+ ' <key id="content" for="node" attr.name="content" attr.type="string"/>',
34
+ );
35
+ lines.push(
36
+ ' <key id="status" for="node" attr.name="status" attr.type="string"/>',
37
+ );
38
+ lines.push(
39
+ ' <key id="confidence" for="node" attr.name="confidence" attr.type="double"/>',
40
+ );
30
41
  lines.push(' <key id="tag" for="edge" attr.name="tag" attr.type="string"/>');
31
42
  lines.push(' <graph id="G" edgedefault="undirected">');
32
43
 
33
44
  // Nodes
34
45
  for (const claim of claims) {
35
- const id = esc(claim.id || '');
36
- const type = esc(claim.type || '');
46
+ const id = esc(claim.id || "");
47
+ const type = esc(claim.type || "");
37
48
  const evidence = esc(getEvidence(claim));
38
- const content = esc(claim.content || claim.text || '');
39
- const status = esc(claim.status || '');
40
- const confidence = claim.confidence != null ? claim.confidence : '';
49
+ const content = esc(claim.content || claim.text || "");
50
+ const status = esc(claim.status || "");
51
+ const confidence = claim.confidence != null ? claim.confidence : "";
41
52
 
42
53
  lines.push(` <node id="${id}">`);
43
54
  lines.push(` <data key="type">${type}</data>`);
44
55
  lines.push(` <data key="evidence">${evidence}</data>`);
45
56
  lines.push(` <data key="content">${content}</data>`);
46
57
  lines.push(` <data key="status">${status}</data>`);
47
- if (confidence !== '') {
58
+ if (confidence !== "") {
48
59
  lines.push(` <data key="confidence">${confidence}</data>`);
49
60
  }
50
- lines.push(' </node>');
61
+ lines.push(" </node>");
51
62
  }
52
63
 
53
64
  // Edges: connect claims that share at least one tag
@@ -55,15 +66,17 @@ export function convert(compilation) {
55
66
  let edgeId = 0;
56
67
  for (const edge of edges) {
57
68
  edgeId++;
58
- lines.push(` <edge id="e${edgeId}" source="${esc(edge.source)}" target="${esc(edge.target)}">`);
69
+ lines.push(
70
+ ` <edge id="e${edgeId}" source="${esc(edge.source)}" target="${esc(edge.target)}">`,
71
+ );
59
72
  lines.push(` <data key="tag">${esc(edge.tag)}</data>`);
60
- lines.push(' </edge>');
73
+ lines.push(" </edge>");
61
74
  }
62
75
 
63
- lines.push(' </graph>');
64
- lines.push('</graphml>');
76
+ lines.push(" </graph>");
77
+ lines.push("</graphml>");
65
78
 
66
- return lines.join('\n') + '\n';
79
+ return lines.join("\n") + "\n";
67
80
  }
68
81
 
69
82
  /**
@@ -77,7 +90,7 @@ function buildTagEdges(claims) {
77
90
  const tags = Array.isArray(claim.tags) ? claim.tags : [];
78
91
  for (const tag of tags) {
79
92
  if (!tagMap[tag]) tagMap[tag] = [];
80
- tagMap[tag].push(claim.id || '');
93
+ tagMap[tag].push(claim.id || "");
81
94
  }
82
95
  }
83
96
 
@@ -100,19 +113,19 @@ function buildTagEdges(claims) {
100
113
  }
101
114
 
102
115
  function getEvidence(claim) {
103
- if (typeof claim.evidence === 'string') return claim.evidence;
104
- if (typeof claim.evidence === 'object' && claim.evidence !== null) {
105
- return claim.evidence.tier || claim.evidence_tier || '';
116
+ if (typeof claim.evidence === "string") return claim.evidence;
117
+ if (typeof claim.evidence === "object" && claim.evidence !== null) {
118
+ return claim.evidence.tier || claim.evidence_tier || "";
106
119
  }
107
- return claim.evidence_tier || '';
120
+ return claim.evidence_tier || "";
108
121
  }
109
122
 
110
123
  function esc(str) {
111
- if (str == null) return '';
124
+ if (str == null) return "";
112
125
  return String(str)
113
- .replace(/&/g, '&amp;')
114
- .replace(/</g, '&lt;')
115
- .replace(/>/g, '&gt;')
116
- .replace(/"/g, '&quot;')
117
- .replace(/'/g, '&apos;');
126
+ .replace(/&/g, "&amp;")
127
+ .replace(/</g, "&lt;")
128
+ .replace(/>/g, "&gt;")
129
+ .replace(/"/g, "&quot;")
130
+ .replace(/'/g, "&apos;");
118
131
  }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * mill format: html-report
3
+ *
4
+ * Self-contained HTML report with inline CSS (dark theme).
5
+ * Claims grouped by type with colored badges, filterable via CSS class toggles.
6
+ * Accessible: semantic landmarks, ARIA roles, keyboard support, reduced-motion.
7
+ * Zero dependencies — node built-in only.
8
+ */
9
+
10
+ export const name = "html-report";
11
+ export const extension = ".html";
12
+ export const mimeType = "text/html; charset=utf-8";
13
+ export const description =
14
+ "Self-contained dark-theme HTML report with type filters and claim cards";
15
+
16
+ /**
17
+ * Convert a compilation object to an HTML report.
18
+ * @param {object} compilation - The compilation.json content
19
+ * @returns {string} HTML output
20
+ */
21
+ export function convert(compilation) {
22
+ const meta = compilation.meta || {};
23
+ const claims = compilation.claims || [];
24
+ const conflicts = compilation.conflicts || [];
25
+ const certificate = compilation.certificate || {};
26
+
27
+ const title = meta.sprint || meta.question || "Sprint Report";
28
+ const compiled = certificate.compiled_at || new Date().toISOString();
29
+
30
+ // Group claims by type (skip reverted)
31
+ const byType = {};
32
+ const typeCounts = {};
33
+ for (const c of claims) {
34
+ if (c.status === "reverted") continue;
35
+ const t = c.type || "unknown";
36
+ if (!byType[t]) byType[t] = [];
37
+ byType[t].push(c);
38
+ typeCounts[t] = (typeCounts[t] || 0) + 1;
39
+ }
40
+
41
+ const typeOrder = [
42
+ "constraint",
43
+ "factual",
44
+ "recommendation",
45
+ "risk",
46
+ "estimate",
47
+ "feedback",
48
+ ];
49
+ const sortedTypes = typeOrder.filter((t) => byType[t]);
50
+ for (const t of Object.keys(byType)) {
51
+ if (!sortedTypes.includes(t)) sortedTypes.push(t);
52
+ }
53
+
54
+ const typeColors = {
55
+ constraint: "#e74c3c",
56
+ factual: "#3498db",
57
+ recommendation: "#2ecc71",
58
+ risk: "#f39c12",
59
+ estimate: "#9b59b6",
60
+ feedback: "#1abc9c",
61
+ unknown: "#95a5a6",
62
+ };
63
+
64
+ const active = claims.filter((c) => c.status === "active").length;
65
+
66
+ // Build filter buttons with aria-pressed
67
+ const filterButtons = sortedTypes
68
+ .map((t) => {
69
+ const color = typeColors[t] || typeColors.unknown;
70
+ return `<button class="filter-btn active" data-type="${esc(t)}" style="--badge-color:${color}" aria-pressed="true" onclick="toggleType('${esc(t)}')">${capitalize(t)} (${typeCounts[t]})</button>`;
71
+ })
72
+ .join("\n ");
73
+
74
+ // Build type sections
75
+ const sections = sortedTypes
76
+ .map((t) => {
77
+ const group = byType[t];
78
+ const color = typeColors[t] || typeColors.unknown;
79
+ const cards = group
80
+ .map((c) => {
81
+ const body = esc(c.content || c.text || "");
82
+ const evidenceTier =
83
+ typeof c.evidence === "string"
84
+ ? c.evidence
85
+ : c.evidence?.tier || c.evidence_tier || "";
86
+ const conf =
87
+ c.confidence != null ? Math.round(c.confidence * 100) : null;
88
+ const tags = Array.isArray(c.tags)
89
+ ? c.tags.map((t) => `<span class="tag">${esc(t)}</span>`).join("")
90
+ : "";
91
+ const confBar =
92
+ conf != null
93
+ ? `<div class="conf-bar" role="progressbar" aria-valuenow="${conf}" aria-valuemin="0" aria-valuemax="100" aria-label="Confidence: ${conf}%"><div class="conf-fill" style="width:${conf}%"></div><span class="conf-label">${conf}%</span></div>`
94
+ : "";
95
+ return `
96
+ <article class="claim-card" aria-labelledby="claim-${esc(c.id)}">
97
+ <div class="card-header">
98
+ <span class="claim-id" id="claim-${esc(c.id)}">${esc(c.id)}</span>
99
+ ${evidenceTier ? `<span class="evidence-badge">evidence: ${esc(evidenceTier)}</span>` : ""}
100
+ <span class="status-badge status-${esc(c.status || "active")}">status: ${esc(c.status || "active")}</span>
101
+ </div>
102
+ <p class="card-body">${body}</p>
103
+ ${tags ? `<div class="card-tags" aria-label="Tags">${tags}</div>` : ""}
104
+ ${confBar}
105
+ </article>`;
106
+ })
107
+ .join("\n");
108
+
109
+ return `
110
+ <section class="type-section" data-type="${esc(t)}" aria-label="${capitalize(t)} claims">
111
+ <h2 style="border-left:4px solid ${color};padding-left:12px">${capitalize(t)}s (${group.length})</h2>
112
+ <div class="cards">${cards}
113
+ </div>
114
+ </section>`;
115
+ })
116
+ .join("\n");
117
+
118
+ return `<!DOCTYPE html>
119
+ <html lang="en">
120
+ <head>
121
+ <meta charset="utf-8">
122
+ <meta name="viewport" content="width=device-width,initial-scale=1">
123
+ <title>${esc(title)}</title>
124
+ <style>
125
+ :root { --bg:#0a0e1a; --surface:#111827; --border:#1e293b; --text:#e2e8f0; --muted:#94a3b8; }
126
+ * { margin:0; padding:0; box-sizing:border-box; }
127
+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; line-height:1.6; padding:2rem; }
128
+ a.skip-link { position:absolute; top:-40px; left:0; background:var(--surface); color:var(--text); padding:0.5rem 1rem; z-index:100; border-radius:0 0 4px 0; text-decoration:none; }
129
+ a.skip-link:focus { top:0; outline:2px solid #3b82f6; }
130
+ header { margin-bottom:2rem; }
131
+ header h1 { font-size:1.8rem; margin-bottom:0.5rem; }
132
+ header p { color:var(--muted); font-size:0.9rem; }
133
+ .stats-bar { display:flex; gap:1.5rem; padding:1rem; background:var(--surface); border-radius:8px; margin-bottom:1.5rem; flex-wrap:wrap; }
134
+ .stat { text-align:center; }
135
+ .stat .num { font-size:1.4rem; font-weight:700; }
136
+ .stat .label { font-size:0.75rem; color:var(--muted); text-transform:uppercase; }
137
+ .filters { display:flex; gap:0.5rem; flex-wrap:wrap; margin-bottom:2rem; }
138
+ .filter-btn { background:var(--surface); color:var(--text); border:1px solid var(--border); padding:0.4rem 0.8rem; border-radius:4px; cursor:pointer; font-size:0.85rem; transition:opacity 0.2s; }
139
+ .filter-btn:focus-visible { outline:2px solid #3b82f6; outline-offset:2px; }
140
+ .filter-btn.active { border-color:var(--badge-color); box-shadow:0 0 0 1px var(--badge-color); }
141
+ .filter-btn:not(.active) { opacity:0.4; }
142
+ .type-section { margin-bottom:2rem; }
143
+ .type-section[hidden] { display:none; }
144
+ .type-section h2 { font-size:1.2rem; margin-bottom:1rem; }
145
+ .cards { display:grid; gap:0.75rem; }
146
+ .claim-card { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:1rem; }
147
+ .card-header { display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem; flex-wrap:wrap; }
148
+ .claim-id { font-weight:700; font-family:monospace; font-size:0.85rem; }
149
+ .evidence-badge { background:#1e3a5f; color:#60a5fa; padding:0.15rem 0.5rem; border-radius:3px; font-size:0.75rem; }
150
+ .status-badge { padding:0.15rem 0.5rem; border-radius:3px; font-size:0.7rem; text-transform:uppercase; }
151
+ .status-active { background:#064e3b; color:#34d399; }
152
+ .status-superseded { background:#4a3728; color:#fbbf24; }
153
+ .card-body { color:var(--text); font-size:0.9rem; }
154
+ .card-tags { display:flex; gap:0.3rem; flex-wrap:wrap; margin-top:0.5rem; }
155
+ .tag { background:var(--border); padding:0.1rem 0.4rem; border-radius:3px; font-size:0.7rem; color:var(--muted); }
156
+ .conf-bar { position:relative; height:6px; background:var(--border); border-radius:3px; margin-top:0.5rem; }
157
+ .conf-fill { height:100%; background:#3b82f6; border-radius:3px; }
158
+ .conf-label { position:absolute; right:0; top:-16px; font-size:0.7rem; color:var(--muted); }
159
+ footer { margin-top:3rem; padding-top:1rem; border-top:1px solid var(--border); color:var(--muted); font-size:0.8rem; font-family:monospace; }
160
+ @media (prefers-reduced-motion:reduce) { .filter-btn { transition:none; } }
161
+ @media print { body { background:#fff; color:#000; } .skip-link { display:none; } .filter-btn { border-color:#ccc; } }
162
+ </style>
163
+ </head>
164
+ <body>
165
+ <a class="skip-link" href="#main-content">Skip to content</a>
166
+ <header role="banner">
167
+ <h1>${esc(title)}</h1>
168
+ ${meta.question ? `<p>${esc(meta.question)}</p>` : ""}
169
+ <p>Compiled: <time datetime="${esc(compiled)}">${esc(compiled)}</time>${meta.audience ? ` | Audience: ${esc(meta.audience)}` : ""}</p>
170
+ </header>
171
+
172
+ <main id="main-content" role="main">
173
+ <section aria-label="Summary statistics">
174
+ <h2 class="sr-only">Summary</h2>
175
+ <div class="stats-bar" role="list">
176
+ <div class="stat" role="listitem"><div class="num" aria-label="${claims.length} total claims">${claims.length}</div><div class="label">Total</div></div>
177
+ <div class="stat" role="listitem"><div class="num" aria-label="${active} active claims">${active}</div><div class="label">Active</div></div>
178
+ ${sortedTypes.map((t) => `<div class="stat" role="listitem"><div class="num" aria-label="${typeCounts[t]} ${t} claims">${typeCounts[t]}</div><div class="label">${capitalize(t)}</div></div>`).join("\n ")}
179
+ ${conflicts.length ? `<div class="stat" role="listitem"><div class="num" aria-label="${conflicts.length} conflicts">${conflicts.length}</div><div class="label">Conflicts</div></div>` : ""}
180
+ </div>
181
+ </section>
182
+
183
+ <nav class="filters" aria-label="Filter claims by type">
184
+ ${filterButtons}
185
+ </nav>
186
+
187
+ ${sections}
188
+ </main>
189
+
190
+ <footer role="contentinfo">
191
+ <p>Certificate: ${certificate.claim_count || claims.length} claims | sha256:${(certificate.sha256 || "unknown").slice(0, 16)}</p>
192
+ </footer>
193
+
194
+ <script>
195
+ function toggleType(type) {
196
+ var btn = document.querySelector('.filter-btn[data-type="' + type + '"]');
197
+ var section = document.querySelector('.type-section[data-type="' + type + '"]');
198
+ if (!btn || !section) return;
199
+ var isActive = btn.classList.toggle('active');
200
+ btn.setAttribute('aria-pressed', String(isActive));
201
+ if (isActive) { section.removeAttribute('hidden'); } else { section.setAttribute('hidden', ''); }
202
+ }
203
+ // Keyboard support: Enter/Space toggles filter buttons
204
+ document.querySelectorAll('.filter-btn').forEach(function(btn) {
205
+ btn.addEventListener('keydown', function(e) {
206
+ if (e.key === 'Enter' || e.key === ' ') {
207
+ e.preventDefault();
208
+ btn.click();
209
+ }
210
+ });
211
+ });
212
+ </script>
213
+ </body>
214
+ </html>`;
215
+ }
216
+
217
+ function esc(str) {
218
+ if (str == null) return "";
219
+ return String(str)
220
+ .replace(/&/g, "&amp;")
221
+ .replace(/</g, "&lt;")
222
+ .replace(/>/g, "&gt;")
223
+ .replace(/"/g, "&quot;");
224
+ }
225
+
226
+ function capitalize(str) {
227
+ return str.charAt(0).toUpperCase() + str.slice(1);
228
+ }