@anselmdk/feature-spec-md 0.8.2 → 0.9.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +48 -1
  2. package/SPEC_FORMAT.md +89 -11
  3. package/dist/cli.js +16 -8
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config.d.ts +5 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +97 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/featureSpecs.d.ts +2 -0
  10. package/dist/featureSpecs.d.ts.map +1 -1
  11. package/dist/featureSpecs.js +86 -1
  12. package/dist/featureSpecs.js.map +1 -1
  13. package/dist/githubActionDiffReport.d.ts +5 -0
  14. package/dist/githubActionDiffReport.d.ts.map +1 -1
  15. package/dist/githubActionDiffReport.js +154 -46
  16. package/dist/githubActionDiffReport.js.map +1 -1
  17. package/dist/githubActionFtp.d.ts.map +1 -1
  18. package/dist/githubActionFtp.js +14 -2
  19. package/dist/githubActionFtp.js.map +1 -1
  20. package/dist/githubActionOutput.d.ts.map +1 -1
  21. package/dist/githubActionOutput.js.map +1 -1
  22. package/dist/githubReportMetadata.js +14 -5
  23. package/dist/githubReportMetadata.js.map +1 -1
  24. package/dist/index.d.ts +6 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/mockReports.d.ts.map +1 -1
  29. package/dist/mockReports.js +41 -8
  30. package/dist/mockReports.js.map +1 -1
  31. package/dist/mocks/current/tests/supportDesk.spec.d.ts.map +1 -1
  32. package/dist/mocks/current/tests/supportDesk.spec.js +15 -3
  33. package/dist/mocks/current/tests/supportDesk.spec.js.map +1 -1
  34. package/dist/mocks/previous/tests/supportDesk.spec.d.ts.map +1 -1
  35. package/dist/mocks/previous/tests/supportDesk.spec.js +14 -2
  36. package/dist/mocks/previous/tests/supportDesk.spec.js.map +1 -1
  37. package/dist/playwright.d.ts.map +1 -1
  38. package/dist/playwright.js +3 -1
  39. package/dist/playwright.js.map +1 -1
  40. package/dist/reportArtifacts.d.ts.map +1 -1
  41. package/dist/reportArtifacts.js.map +1 -1
  42. package/dist/reportHtml.d.ts.map +1 -1
  43. package/dist/reportHtml.js +75 -2
  44. package/dist/reportHtml.js.map +1 -1
  45. package/dist/reportTemplate.d.ts +4 -1
  46. package/dist/reportTemplate.d.ts.map +1 -1
  47. package/dist/reportTemplate.js +290 -53
  48. package/dist/reportTemplate.js.map +1 -1
  49. package/dist/reportTemplates/shared.d.ts.map +1 -1
  50. package/dist/reportTemplates/shared.js.map +1 -1
  51. package/dist/screenshots.d.ts.map +1 -1
  52. package/dist/screenshots.js +3 -1
  53. package/dist/screenshots.js.map +1 -1
  54. package/dist/specDocuments.d.ts +3 -1
  55. package/dist/specDocuments.d.ts.map +1 -1
  56. package/dist/specDocuments.js +12 -1
  57. package/dist/specDocuments.js.map +1 -1
  58. package/dist/types.d.ts +29 -0
  59. package/dist/types.d.ts.map +1 -1
  60. package/docs/evidence-policy.md +1 -0
  61. package/docs/report-development.md +9 -9
  62. package/docs/spec-driven-flow.md +1 -0
  63. package/package.json +1 -1
  64. package/src/mocks/current/specs/support-desk.design.md +1 -0
  65. package/src/mocks/current/specs/support-desk.model.md +1 -0
  66. package/src/mocks/current/specs/support-platform.stack.md +1 -0
  67. package/src/mocks/current/specs/ticket-inbox.feature.md +1 -0
  68. package/src/mocks/current/specs/ticket-reply.feature.md +1 -0
  69. package/src/mocks/current/tests/supportDesk.spec.ts +19 -3
  70. package/src/mocks/previous/specs/support-desk.model.md +1 -0
  71. package/src/mocks/previous/specs/ticket-inbox.feature.md +1 -0
  72. package/src/mocks/previous/specs/ticket-reply.feature.md +1 -0
  73. package/src/mocks/previous/tests/supportDesk.spec.ts +14 -2
@@ -26,10 +26,19 @@ export function renderHtmlReport(specs, options = {}) {
26
26
  }
27
27
  function featureReportBody({ specs, options, evidence, sourceLinks, title, }) {
28
28
  const documents = allReportDocuments(specs, options);
29
+ if (options.layers?.length) {
30
+ return `
31
+ <h1>${renderReportTitle(title, options.repositoryUrl)}</h1>
32
+ <p>Generated ${html(formatGeneratedAt(options.generatedAt))}.</p>
33
+ ${renderIssues(options.validationIssues ?? [])}
34
+ ${renderLayeredDocuments(documents, options, evidence, sourceLinks)}
35
+ `;
36
+ }
29
37
  return `
30
38
  <h1>${renderReportTitle(title, options.repositoryUrl)}</h1>
31
39
  <p>Generated ${html(formatGeneratedAt(options.generatedAt))}.</p>
32
40
  ${renderOpenQuestionsAndAssumptions(documents, sourceLinks)}
41
+ ${renderJourneyOverview(specs, options.coverage, sourceLinks)}
33
42
  ${renderIssues(options.validationIssues ?? [])}
34
43
  ${renderModels(options.models ?? [], options.coverage, sourceLinks)}
35
44
  ${renderStacks(options.stacks ?? [], options.coverage, sourceLinks)}
@@ -37,52 +46,76 @@ ${renderDesigns(options.designs ?? [], options.coverage, sourceLinks)}
37
46
  ${specs.map((spec) => renderSpec(spec, options.coverage, evidence, sourceLinks)).join("\n")}
38
47
  `;
39
48
  }
49
+ function renderJourneyOverview(specs, coverage, sourceLinks) {
50
+ const journeys = specs.flatMap((spec) => spec.scenarios
51
+ .filter((scenario) => scenario.journey)
52
+ .map((scenario) => ({ spec, scenario })));
53
+ if (!journeys.length)
54
+ return "";
55
+ return `<details class="panel report-section" open>
56
+ <summary class="report-section-summary"><h2>End-to-end journeys</h2><span class="badge">${journeys.length}</span></summary>
57
+ <div class="report-section-body"><ul>${journeys
58
+ .map(({ scenario }) => {
59
+ const item = coverage?.scenarioCoverage.find((candidate) => candidate.id === scenario.id);
60
+ const metadata = scenario.journey;
61
+ const systems = metadata.systems.length
62
+ ? ` <span class="muted">${html(metadata.systems.join(" → "))}</span>`
63
+ : "";
64
+ return `<li><a href="#${html(scenario.id.toLowerCase())}"><code>${html(scenario.id)}</code>: ${html(scenario.title)}</a> <span class="badge">${html(metadata.path)}</span>${metadata.critical ? ' <span class="badge">critical</span>' : ""}${systems} ${coverageBadge(item?.covered, [], item, sourceLinks)}</li>`;
65
+ })
66
+ .join("")}</ul></div>
67
+ </details>`;
68
+ }
40
69
  function featureReportStyles() {
41
- return `.panel{border:1px solid #d0d7de;border-radius:8px;margin:18px 0;overflow:hidden}
70
+ return `.panel{border:1px solid var(--border);border-radius:8px;margin:18px 0;overflow:hidden;background:var(--surface)}
71
+ .report-layers{margin-top:18px}.report-layers>.details-section-header{margin-bottom:8px}.layer-section{border:1px solid var(--border);border-radius:10px;margin:12px 0;background:var(--surface);overflow:hidden}
72
+ .layer-summary{cursor:pointer;display:flex;gap:12px;align-items:center;padding:18px 20px;list-style:none}.layer-summary::-webkit-details-marker{display:none}.layer-summary::before{content:"▶";color:var(--muted);font-size:12px;transition:transform .15s ease}.layer-section[open]>.layer-summary::before{transform:rotate(90deg)}
73
+ .layer-summary-copy{flex:1}.layer-summary h2{font-size:1.35em;margin:0}.layer-summary p{color:var(--muted);margin:3px 0 0}.layer-badges{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.layer-body{padding:0 18px 18px}.layer-body>.details-section-header{margin:0 0 10px}.document-section{margin:12px 0}.document-kind{color:var(--muted)}
42
74
  .report-section-summary{cursor:pointer;display:flex;gap:12px;align-items:center;padding:20px;list-style:none}.report-section-summary::-webkit-details-marker{display:none}
43
- .report-section-summary::before{content:"▶";color:#57606a;font-size:12px;transition:transform .15s ease}.report-section[open]>.report-section-summary::before{transform:rotate(90deg)}
75
+ .report-section-summary::before{content:"▶";color:var(--muted);font-size:12px;transition:transform .15s ease}.report-section[open]>.report-section-summary::before{transform:rotate(90deg)}
44
76
  .report-section-summary h2{font-size:1.5em;margin:0;flex:1}.report-section-body{padding:0 20px 20px}
45
- .ok{color:#1a7f37}.missing,.error{color:#cf222e}.warning{color:#9a6700}.muted{color:#57606a}
46
- .badge{border:1px solid #d0d7de;border-radius:999px;padding:2px 8px;font-size:12px;white-space:nowrap}
77
+ .ok{color:var(--success)}.missing,.error{color:var(--danger)}.warning{color:var(--warning)}.muted{color:var(--muted)}
78
+ .badge{border:1px solid var(--border);border-radius:999px;padding:2px 8px;font-size:12px;white-space:nowrap}
47
79
  .feature-header{display:flex;gap:12px;align-items:center;justify-content:space-between}
48
80
  .details-section-header{display:flex;gap:12px;align-items:center;justify-content:space-between}
49
81
  .details-section-header h2,.details-section-header h3{margin-bottom:0}
50
- .details-toggle-button{appearance:none;border:1px solid #d0d7de;border-radius:6px;background:#f6f8fa;color:#24292f;cursor:pointer;font:inherit;font-size:13px;font-weight:600;padding:5px 12px;white-space:nowrap}
51
- .details-toggle-button:hover{background:#eef1f4}.details-toggle-button:focus-visible{outline:2px solid #0969da;outline-offset:2px}
82
+ .details-toggle-button{appearance:none;border:1px solid var(--border);border-radius:6px;background:var(--surface-muted);color:var(--fg);cursor:pointer;font:inherit;font-size:13px;font-weight:600;padding:5px 12px;white-space:nowrap}
83
+ .details-toggle-button:hover{background:var(--surface-hover)}.details-toggle-button:focus-visible{outline:2px solid var(--link);outline-offset:2px}
52
84
  .feature-policy{display:flex;gap:8px;flex-wrap:wrap;margin:-4px 0 12px}.feature-policy .badge{display:inline-flex;gap:5px;align-items:center}
53
- .scenario{border:1px solid #d0d7de;border-radius:8px;margin:12px 0;background:#fff}
85
+ .scenario{border:1px solid var(--border);border-radius:8px;margin:12px 0;background:var(--surface)}
54
86
  .scenario summary{cursor:pointer;padding:14px 16px;font-weight:600}
55
87
  .scenario-body{padding:0 16px 16px}
56
88
  .scenario-body.compact-steps .step{margin:4px 0}.scenario-body.compact-steps .step p{margin:2px 0}
57
- .model-item{border:1px solid #d0d7de;border-radius:8px;margin:12px 0;background:#fff}
89
+ .model-item{border:1px solid var(--border);border-radius:8px;margin:12px 0;background:var(--surface)}
58
90
  .model-item summary{cursor:pointer;padding:14px 16px;font-weight:600}
59
91
  .model-item-body{padding:0 16px 16px}.model-item-body p{margin:8px 0}
60
92
  .model-entry{padding:0 0 12px}.model-entry h4{font-size:14px;margin:14px 0 8px}
61
93
  .table-wrap{overflow-x:auto;margin:12px 0}table{border-collapse:collapse;width:100%;font-size:14px}
62
- th,td{border:1px solid #d0d7de;padding:6px 8px;text-align:left;vertical-align:top}th{background:#f6f8fa}
63
- h1 a{color:#0969da;text-decoration:underline;text-underline-offset:3px}h1 a:hover{text-decoration-thickness:2px}
64
- .step{border-left:3px solid #d0d7de;margin:12px 0;padding:2px 0 2px 12px}.step p{margin:8px 0}
94
+ th,td{border:1px solid var(--border);padding:6px 8px;text-align:left;vertical-align:top}th{background:var(--surface-muted)}
95
+ h1 a{color:var(--link);text-decoration:underline;text-underline-offset:3px}h1 a:hover{text-decoration-thickness:2px}
96
+ .step{border-left:3px solid var(--border);margin:12px 0;padding:2px 0 2px 12px}.step p{margin:8px 0}
65
97
  .screenshots{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px;margin:10px 0 14px}
66
- .screenshot{border:1px solid #d0d7de;border-radius:8px;overflow:hidden;background:#f6f8fa}
67
- .screenshot img{display:block;width:100%;height:auto}.screenshot figcaption{font-size:12px;padding:8px;color:#57606a}
98
+ .screenshot{border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--surface-muted)}
99
+ .screenshot img{display:block;width:100%;height:auto}.screenshot figcaption{font-size:12px;padding:8px;color:var(--muted)}
68
100
  .coverage-refs{display:inline-flex;gap:2px;margin-left:4px}.coverage-ref{color:inherit;text-decoration:underline;text-underline-offset:2px}
69
101
  .line-link{color:inherit;text-decoration:underline;text-underline-offset:2px}
70
102
  .flag-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}
71
- .flag-card{border:1px solid #d0d7de;border-left:4px solid #d0d7de;border-radius:8px;padding:14px;background:#fff}
103
+ .flag-card{border:1px solid var(--border);border-left:4px solid var(--border);border-radius:8px;padding:14px;background:var(--surface)}
72
104
  .flag-card h3{font-size:16px;margin:0 0 8px}.flag-card p{margin:8px 0}
73
- .flag-item-link{color:inherit;text-decoration:underline;text-decoration-color:#8c959f;text-underline-offset:2px}
74
- .flag-card.openQuestions{border-left-color:#9a6700}.flag-card.assumptions{border-left-color:#57606a}
75
- .extension-section{border:1px solid #d0d7de;border-radius:8px;padding:14px;margin:12px 0;background:#fff}
105
+ .flag-item-link{color:inherit;text-decoration:underline;text-decoration-color:var(--muted);text-underline-offset:2px}
106
+ .flag-card.openQuestions{border-left-color:var(--warning)}.flag-card.assumptions{border-left-color:var(--muted)}
107
+ .extension-section{border:1px solid var(--border);border-radius:8px;padding:14px;margin:12px 0;background:var(--surface)}
76
108
  .extension-section h4{margin:0 0 8px}.extension-section p{margin:8px 0}
77
- .mermaid-wrap{overflow-x:auto;margin:12px 0;padding:12px;border:1px solid #d0d7de;border-radius:8px;background:#fff}
109
+ .mermaid-wrap{overflow-x:auto;margin:12px 0;padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--surface)}
78
110
  .mermaid{min-width:max-content;text-align:center}.mermaid svg{display:block;max-width:none;height:auto;margin:0 auto}
79
- .mermaid-error{color:#cf222e;text-align:left;white-space:pre-wrap}
80
- @media(max-width:720px){.flag-grid{grid-template-columns:1fr}}`;
111
+ .mermaid-error{color:var(--danger);text-align:left;white-space:pre-wrap}
112
+ @media(max-width:720px){.flag-grid{grid-template-columns:1fr}.layer-summary{align-items:flex-start;flex-wrap:wrap}.layer-badges{justify-content:flex-start;width:100%}}`;
81
113
  }
82
114
  function featureReportScripts() {
83
115
  const openTag = "<" + "script>";
84
116
  const closeTag = "<" + "/script>";
85
- const mermaidOpenTag = "<" + "script src=\"https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js\" crossorigin=\"anonymous\">";
117
+ const mermaidOpenTag = "<" +
118
+ 'script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js" crossorigin="anonymous">';
86
119
  return `${openTag}
87
120
  const bulkToggledDetails = new WeakSet();
88
121
 
@@ -133,10 +166,24 @@ document.addEventListener("toggle", (event) => {
133
166
  window.scrollBy(0, topAfter - topBefore);
134
167
  });
135
168
  }, true);
169
+
170
+ function revealHashTarget() {
171
+ const id = decodeURIComponent(window.location.hash.slice(1));
172
+ if (!id) return;
173
+ const target = document.getElementById(id);
174
+ if (!target) return;
175
+ let ancestor = target instanceof HTMLDetailsElement ? target : target.closest("details");
176
+ while (ancestor) {
177
+ ancestor.open = true;
178
+ ancestor = ancestor.parentElement?.closest("details");
179
+ }
180
+ }
181
+ window.addEventListener("hashchange", revealHashTarget);
182
+ document.addEventListener("DOMContentLoaded", revealHashTarget);
136
183
  ${closeTag}
137
184
  ${mermaidOpenTag}${closeTag}
138
185
  ${openTag}
139
- document.addEventListener("DOMContentLoaded", async () => {
186
+ async function renderMermaidDiagrams() {
140
187
  const diagrams = document.querySelectorAll(".mermaid");
141
188
  if (!diagrams.length) return;
142
189
  if (!window.mermaid) {
@@ -144,13 +191,20 @@ document.addEventListener("DOMContentLoaded", async () => {
144
191
  return;
145
192
  }
146
193
  try {
147
- window.mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "default" });
194
+ diagrams.forEach((diagram) => {
195
+ diagram.dataset.mermaidSource ||= diagram.textContent || "";
196
+ diagram.textContent = diagram.dataset.mermaidSource;
197
+ diagram.removeAttribute("data-processed");
198
+ });
199
+ window.mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: document.documentElement.dataset.theme === "dark" ? "dark" : "default" });
148
200
  await window.mermaid.run({ nodes: diagrams });
149
201
  } catch (error) {
150
202
  diagrams.forEach((diagram) => diagram.classList.add("mermaid-error"));
151
203
  console.error("Unable to render Mermaid diagram", error);
152
204
  }
153
- });
205
+ }
206
+ document.addEventListener("DOMContentLoaded", renderMermaidDiagrams);
207
+ document.addEventListener("feature-spec-theme-change", renderMermaidDiagrams);
154
208
  ${closeTag}`;
155
209
  }
156
210
  function renderReportTitle(title, repositoryUrl) {
@@ -168,7 +222,20 @@ function formatGeneratedAt(value) {
168
222
  if (Number.isNaN(date.getTime()))
169
223
  return value ?? "";
170
224
  const day = date.getDate();
171
- const month = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][date.getMonth()];
225
+ const month = [
226
+ "January",
227
+ "February",
228
+ "March",
229
+ "April",
230
+ "May",
231
+ "June",
232
+ "July",
233
+ "August",
234
+ "September",
235
+ "October",
236
+ "November",
237
+ "December",
238
+ ][date.getMonth()];
172
239
  const hours = String(date.getHours()).padStart(2, "0");
173
240
  const minutes = String(date.getMinutes()).padStart(2, "0");
174
241
  return `${day}${ordinalSuffix(day)} ${month} ${date.getFullYear()} at ${hours}:${minutes}`;
@@ -184,8 +251,117 @@ function ordinalSuffix(day) {
184
251
  return "rd";
185
252
  return "th";
186
253
  }
254
+ function renderLayeredDocuments(documents, options, evidence, sourceLinks) {
255
+ const configured = options.layers ?? [];
256
+ const configuredIds = new Set(configured.map((layer) => layer.id));
257
+ const layers = configured.map((layer) => ({
258
+ ...layer,
259
+ documents: documents.filter((document) => document.frontmatter.layer === layer.id),
260
+ }));
261
+ const otherDocuments = documents.filter((document) => !document.frontmatter.layer ||
262
+ !configuredIds.has(document.frontmatter.layer));
263
+ if (otherDocuments.length) {
264
+ layers.push({
265
+ id: "other",
266
+ title: "Other",
267
+ description: "Documents without a configured report layer",
268
+ documents: otherDocuments,
269
+ });
270
+ }
271
+ return `<section class="report-layers" data-details-section>
272
+ <div class="details-section-header"><h2>Specification layers</h2>${renderDetailsToggleButton("details.layer-section", "layers")}</div>
273
+ ${layers
274
+ .map((layer) => renderLayer(layer, options, evidence, sourceLinks, options.layersDefaultOpen ?? false, options.documentsDefaultOpen ?? false))
275
+ .join("\n")}
276
+ </section>`;
277
+ }
278
+ function renderLayer(layer, options, evidence, sourceLinks, open, documentsOpen) {
279
+ const metrics = layerMetrics(layer.documents, options);
280
+ const description = layer.description
281
+ ? `<p>${html(layer.description)}</p>`
282
+ : "";
283
+ return `<details id="layer-${html(layer.id)}" class="layer-section" data-details-section${openAttribute(open)}>
284
+ <summary class="layer-summary">
285
+ <div class="layer-summary-copy"><h2>${html(layer.title)}</h2>${description}</div>
286
+ <span class="layer-badges"><span class="badge">${layer.documents.length} document${layer.documents.length === 1 ? "" : "s"}</span>${metrics.coverageTotal ? `<span class="badge ${metrics.coverageMissing ? "warning" : "ok"}">${metrics.coverageTotal - metrics.coverageMissing}/${metrics.coverageTotal} covered</span>` : ""}${metrics.issues ? `<span class="badge ${metrics.errors ? "error" : "warning"}">${metrics.issues} issue${metrics.issues === 1 ? "" : "s"}</span>` : ""}${metrics.flags ? `<span class="badge muted">${metrics.flags} open item${metrics.flags === 1 ? "" : "s"}</span>` : ""}</span>
287
+ </summary>
288
+ <div class="layer-body">
289
+ <div class="details-section-header">${renderDetailsToggleButton(".layer-body > details.document-section", "documents")}</div>
290
+ ${layer.documents.map((document) => renderLayerDocument(document, options, evidence, sourceLinks, documentsOpen)).join("\n")}
291
+ </div>
292
+ </details>`;
293
+ }
294
+ function renderLayerDocument(document, options, evidence, sourceLinks, open) {
295
+ if (document.kind === "model")
296
+ return renderModelDocument(document, options.coverage, sourceLinks, open);
297
+ if (document.kind === "stack")
298
+ return renderContextDocument(document, "Stack", [
299
+ ["Stack", document.stack],
300
+ ["Context", document.context],
301
+ ["Rationale", document.rationale],
302
+ ["Consequences", document.consequences],
303
+ ], options.coverage, sourceLinks, open, true);
304
+ if (document.kind === "design")
305
+ return renderContextDocument(document, "Design", [
306
+ ["Design", document.design],
307
+ ["Principles", document.principles],
308
+ ["Layout", document.layout],
309
+ ["Interaction", document.interaction],
310
+ ["Visual style", document.visualStyle],
311
+ ], options.coverage, sourceLinks, open, true);
312
+ return renderSpec(document, options.coverage, evidence, sourceLinks, open, true);
313
+ }
314
+ function renderModelDocument(model, coverage, sourceLinks, open) {
315
+ const ruleCoverage = coverage?.ruleCoverage ?? [];
316
+ const scenarioCoverage = coverage?.scenarioCoverage ?? [];
317
+ return `<details id="${html(model.frontmatter.id.toLowerCase())}" class="panel report-section document-section" data-details-section${openAttribute(open)}>
318
+ <summary class="report-section-summary"><h2>${html(model.title)}</h2><span class="badge document-kind">Model</span><span class="badge">${html(model.frontmatter.status ?? "draft")}</span></summary>
319
+ <div class="report-section-body">
320
+ <div class="details-section-header">${renderDetailsToggleButton("details.model-item", "model items")}</div>
321
+ ${renderModel(model, coverage?.modelCoverage ?? [], ruleCoverage, buildRuleScenarioLinks(ruleCoverage, scenarioCoverage), sourceLinks)}
322
+ </div>
323
+ </details>`;
324
+ }
325
+ function layerMetrics(documents, options) {
326
+ const filePaths = new Set(documents.map((document) => document.filePath));
327
+ const ids = new Set(documents.flatMap((document) => [
328
+ ...(document.kind === "model"
329
+ ? document.modelItems.map((item) => item.id)
330
+ : []),
331
+ ...document.rules.map((rule) => rule.id),
332
+ ...(document.kind === "feature" || document.kind === undefined
333
+ ? document.scenarios.map((scenario) => scenario.id)
334
+ : []),
335
+ ]));
336
+ const coverageItems = [
337
+ ...(options.coverage?.modelCoverage ?? []),
338
+ ...(options.coverage?.ruleCoverage ?? []),
339
+ ...(options.coverage?.scenarioCoverage ?? []),
340
+ ].filter((item) => ids.has(item.id));
341
+ const issues = (options.validationIssues ?? []).filter((issue) => !issue.filePath || filePaths.has(issue.filePath));
342
+ const flags = documents.reduce((sum, document) => sum +
343
+ (document.source.match(/^## (?:Open Questions|Assumptions)$/gim)
344
+ ?.length ?? 0), 0);
345
+ return {
346
+ coverageTotal: coverageItems.length,
347
+ coverageMissing: coverageItems.filter((item) => !item.covered).length,
348
+ issues: issues.length,
349
+ errors: issues.filter((issue) => issue.severity === "error").length,
350
+ flags,
351
+ };
352
+ }
353
+ function openAttribute(open) {
354
+ return open ? " open" : "";
355
+ }
356
+ function documentDetailsAttributes(id, open, nested) {
357
+ return nested
358
+ ? ` id="${html(id.toLowerCase())}" class="panel report-section document-section"${openAttribute(open)}`
359
+ : ` class="panel report-section"${openAttribute(open)}`;
360
+ }
187
361
  function renderOpenQuestionsAndAssumptions(documents, sourceLinks) {
188
- const sections = documents.flatMap((document) => extensionSections(document)).filter((section) => section.kind === "openQuestions" || section.kind === "assumptions");
362
+ const sections = documents
363
+ .flatMap((document) => extensionSections(document))
364
+ .filter((section) => section.kind === "openQuestions" || section.kind === "assumptions");
189
365
  if (!sections.length)
190
366
  return "";
191
367
  const openQuestionCount = sections.filter((section) => section.kind === "openQuestions").length;
@@ -232,7 +408,8 @@ function flaggedSectionItems(section) {
232
408
  }
233
409
  function flaggedItemAnchor(section, item) {
234
410
  const stableId = item.text.match(/^([A-Z][A-Z0-9-]*-(?:Q|A)\d{3}):/)?.[1];
235
- return (stableId ?? `${section.document.frontmatter.id}-${section.kind}-${item.line}`).toLowerCase();
411
+ return (stableId ??
412
+ `${section.document.frontmatter.id}-${section.kind}-${item.line}`).toLowerCase();
236
413
  }
237
414
  function renderIssues(issues) {
238
415
  if (!issues.length)
@@ -290,26 +467,30 @@ function renderModelRules(model, ruleCoverage, ruleScenarioLinks, sourceLinks) {
290
467
  .join("")}</ul>`;
291
468
  }
292
469
  function renderStacks(stacks, coverage, sourceLinks = {}) {
293
- return stacks.map((stack) => renderContextDocument(stack, "Stack", [
470
+ return stacks
471
+ .map((stack) => renderContextDocument(stack, "Stack", [
294
472
  ["Stack", stack.stack],
295
473
  ["Context", stack.context],
296
474
  ["Rationale", stack.rationale],
297
475
  ["Consequences", stack.consequences],
298
- ], coverage, sourceLinks)).join("\n");
476
+ ], coverage, sourceLinks))
477
+ .join("\n");
299
478
  }
300
479
  function renderDesigns(designs, coverage, sourceLinks = {}) {
301
- return designs.map((design) => renderContextDocument(design, "Design", [
480
+ return designs
481
+ .map((design) => renderContextDocument(design, "Design", [
302
482
  ["Design", design.design],
303
483
  ["Principles", design.principles],
304
484
  ["Layout", design.layout],
305
485
  ["Interaction", design.interaction],
306
486
  ["Visual style", design.visualStyle],
307
- ], coverage, sourceLinks)).join("\n");
487
+ ], coverage, sourceLinks))
488
+ .join("\n");
308
489
  }
309
- function renderContextDocument(document, kindLabel, sections, coverage, sourceLinks) {
490
+ function renderContextDocument(document, kindLabel, sections, coverage, sourceLinks, open = true, nested = false) {
310
491
  const ruleCoverage = coverage?.ruleCoverage ?? [];
311
492
  const ruleScenarioLinks = buildRuleScenarioLinks(ruleCoverage, coverage?.scenarioCoverage ?? []);
312
- return `<details class="panel report-section" open>
493
+ return `<details${documentDetailsAttributes(document.frontmatter.id, open, nested)}>
313
494
  <summary class="report-section-summary">
314
495
  <h2>${html(document.title)}</h2>
315
496
  <span class="badge">${html(kindLabel)}</span>
@@ -317,7 +498,10 @@ function renderContextDocument(document, kindLabel, sections, coverage, sourceLi
317
498
  </summary>
318
499
  <div class="report-section-body">
319
500
  <p>${html(document.purpose)}</p>
320
- ${sections.filter(([, body]) => body).map(([title, body]) => `<section><h3>${html(title)}</h3>${renderMarkdownBlock(body, 1)}</section>`).join("")}
501
+ ${sections
502
+ .filter(([, body]) => body)
503
+ .map(([title, body]) => `<section><h3>${html(title)}</h3>${renderMarkdownBlock(body, 1)}</section>`)
504
+ .join("")}
321
505
  ${renderContextRules(document.rules, ruleCoverage, ruleScenarioLinks, sourceLinks)}
322
506
  ${renderDocumentExtensionSections(document, sourceLinks)}
323
507
  </div>
@@ -326,17 +510,19 @@ function renderContextDocument(document, kindLabel, sections, coverage, sourceLi
326
510
  function renderContextRules(rules, ruleCoverage, ruleScenarioLinks, sourceLinks) {
327
511
  if (!rules.length)
328
512
  return "";
329
- return `<h3>Rules</h3><ul>${rules.map((rule) => {
513
+ return `<h3>Rules</h3><ul>${rules
514
+ .map((rule) => {
330
515
  const item = ruleCoverage.find((coverageItem) => coverageItem.id === rule.id);
331
516
  return `<li><code>${html(rule.id)}</code>: ${html(rule.text)} ${ruleCoverageBadge(item, ruleScenarioIds(rule.id, ruleScenarioLinks), sourceLinks)}</li>`;
332
- }).join("")}</ul>`;
517
+ })
518
+ .join("")}</ul>`;
333
519
  }
334
- function renderSpec(spec, coverage, evidence = [], sourceLinks = {}) {
520
+ function renderSpec(spec, coverage, evidence = [], sourceLinks = {}, open = true, nested = false) {
335
521
  const evidenceByLine = groupEvidenceByLine(evidence);
336
522
  const ruleCoverage = coverage?.ruleCoverage ?? [];
337
523
  const scenarioCoverage = coverage?.scenarioCoverage ?? [];
338
524
  const ruleScenarioLinks = buildRuleScenarioLinks(ruleCoverage, scenarioCoverage);
339
- return `<details class="panel report-section" open>
525
+ return `<details${documentDetailsAttributes(spec.frontmatter.id, open, nested)}>
340
526
  <summary class="report-section-summary">
341
527
  <h2>${html(spec.title)}</h2>
342
528
  <span class="badge">${html(spec.frontmatter.status ?? "draft")}</span>
@@ -360,7 +546,16 @@ function renderSpec(spec, coverage, evidence = [], sourceLinks = {}) {
360
546
  function renderFeaturePolicy(spec) {
361
547
  const items = [
362
548
  spec.frontmatter.test ? ["test", spec.frontmatter.test] : undefined,
363
- spec.frontmatter.screenshots ? ["screenshots", spec.frontmatter.screenshots] : undefined,
549
+ spec.frontmatter.screenshots
550
+ ? ["screenshots", spec.frontmatter.screenshots]
551
+ : undefined,
552
+ spec.frontmatter.journey
553
+ ? ["journey", spec.frontmatter.journey]
554
+ : undefined,
555
+ spec.frontmatter.path ? ["path", spec.frontmatter.path] : undefined,
556
+ spec.frontmatter.critical !== undefined
557
+ ? ["critical", String(spec.frontmatter.critical)]
558
+ : undefined,
364
559
  ].filter((item) => Boolean(item));
365
560
  if (!items.length)
366
561
  return "";
@@ -377,11 +572,24 @@ function renderScenario(spec, scenario, scenarioCoverage, ruleScenarioLinks, evi
377
572
  const changedCount = scenarioEvidence.filter((entry) => entry.changed && entry.path).length;
378
573
  const scenarioRuleIds = ruleIdsForScenario(scenario.id, spec.rules.map((rule) => rule.id), ruleScenarioLinks);
379
574
  const scenarioCoverageItem = scenarioCoverage.find((item) => item.id === scenario.id);
380
- return `<details class="scenario" data-has-images="${changedCount > 0 ? "true" : "false"}">
575
+ return `<details id="${html(scenario.id.toLowerCase())}" class="scenario" data-has-images="${changedCount > 0 ? "true" : "false"}">
381
576
  <summary><code>${html(scenario.id)}</code>: ${html(scenario.title)} ${coverageBadge(scenarioCoverageItem?.covered, [], scenarioCoverageItem, sourceLinks)}</summary>
382
- <div class="scenario-body${changedCount === 0 ? " compact-steps" : ""}">${renderScenarioRuleCoverage(scenarioRuleIds)}${scenario.steps.map((step) => renderStep(spec, step, evidenceByLine, sourceLinks, scenario.evidence.screenshots)).join("")}</div>
577
+ <div class="scenario-body${changedCount === 0 ? " compact-steps" : ""}">${renderJourneyMetadata(scenario)}${renderScenarioRuleCoverage(scenarioRuleIds)}${scenario.steps.map((step) => renderStep(spec, step, evidenceByLine, sourceLinks, scenario.evidence.screenshots)).join("")}</div>
383
578
  </details>`;
384
579
  }
580
+ function renderJourneyMetadata(scenario) {
581
+ if (!scenario.journey)
582
+ return "";
583
+ const items = [
584
+ ["journey", scenario.journey.scope],
585
+ ["path", scenario.journey.path],
586
+ scenario.journey.critical ? ["critical", "true"] : undefined,
587
+ scenario.journey.systems.length
588
+ ? ["systems", scenario.journey.systems.join(", ")]
589
+ : undefined,
590
+ ].filter((item) => Boolean(item));
591
+ return `<div class="feature-policy">${items.map(([label, value]) => `<span class="badge"><span class="muted">${html(label)}</span> <code>${html(value)}</code></span>`).join("")}</div>`;
592
+ }
385
593
  function renderScenarioRuleCoverage(ruleIds) {
386
594
  if (!ruleIds.length)
387
595
  return `<p><strong>Rules covered by this scenario:</strong> <span class="muted">none referenced</span></p>`;
@@ -496,20 +704,30 @@ function renderCodeBlock(language, source) {
496
704
  function renderInlineMarkdown(source) {
497
705
  return source
498
706
  .split(/(`[^`]+`)/g)
499
- .map((part) => part.startsWith("`") && part.endsWith("`") ? `<code>${html(part.slice(1, -1))}</code>` : html(part))
707
+ .map((part) => part.startsWith("`") && part.endsWith("`")
708
+ ? `<code>${html(part.slice(1, -1))}</code>`
709
+ : html(part))
500
710
  .join("");
501
711
  }
502
712
  function isTableStart(lines, index) {
503
- return isPipeTableRow(lines[index]) && index + 1 < lines.length && isTableSeparator(lines[index + 1]);
713
+ return (isPipeTableRow(lines[index]) &&
714
+ index + 1 < lines.length &&
715
+ isTableSeparator(lines[index + 1]));
504
716
  }
505
717
  function isPipeTableRow(line) {
506
718
  return line.trim().startsWith("|") && line.trim().endsWith("|");
507
719
  }
508
720
  function isTableSeparator(line) {
509
- return isPipeTableRow(line) && splitTableRow(line).every((cell) => /^:?-{3,}:?$/.test(cell));
721
+ return (isPipeTableRow(line) &&
722
+ splitTableRow(line).every((cell) => /^:?-{3,}:?$/.test(cell)));
510
723
  }
511
724
  function splitTableRow(line) {
512
- return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
725
+ return line
726
+ .trim()
727
+ .replace(/^\|/, "")
728
+ .replace(/\|$/, "")
729
+ .split("|")
730
+ .map((cell) => cell.trim());
513
731
  }
514
732
  function renderTable(lines) {
515
733
  const headers = splitTableRow(lines[0]);
@@ -530,7 +748,9 @@ function renderStep(spec, step, evidenceByLine, sourceLinks, screenshotPolicy) {
530
748
  return `<div class="step"><p><strong>${html(step.keyword)}</strong> ${html(step.text)} ${renderLineBadge(spec.filePath, step.line, sourceLinks)} ${evidenceBadge}</p>${renderScreenshots(screenshots)}</div>`;
531
749
  }
532
750
  function renderComparedWith(entry) {
533
- return entry.comparedWithLine ? ` as line ${html(String(entry.comparedWithLine))}` : " as previous screen";
751
+ return entry.comparedWithLine
752
+ ? ` as line ${html(String(entry.comparedWithLine))}`
753
+ : " as previous screen";
534
754
  }
535
755
  function renderLineBadge(filePath, line, sourceLinks) {
536
756
  const label = `line ${line}`;
@@ -542,10 +762,14 @@ function renderLineBadge(filePath, line, sourceLinks) {
542
762
  function renderScreenshots(screenshots) {
543
763
  if (!screenshots.length)
544
764
  return "";
545
- return `<div class="screenshots">${screenshots.map((screenshot) => `<figure class="screenshot"><img src="${html(screenshot.path ?? "")}" alt="${html(screenshot.title ?? `Screenshot for ${screenshot.specPath}:${screenshot.line}`)}"><figcaption>${html(screenshot.title ?? `${screenshot.specPath}:${screenshot.line}`)}</figcaption></figure>`).join("")}</div>`;
765
+ return `<div class="screenshots">${screenshots.map((screenshot) => `<figure class="screenshot"><img src="${html(screenshot.path ?? "")}" alt="${html(screenshot.title ?? `Screenshot for ${screenshot.specPath}:${screenshot.line}`)}" data-lightbox tabindex="0"><figcaption>${html(screenshot.title ?? `${screenshot.specPath}:${screenshot.line}`)}</figcaption></figure>`).join("")}</div>`;
546
766
  }
547
767
  function coverageBadge(covered, suffixes = [], item, sourceLinks = {}) {
548
- return covered === undefined ? "" : covered ? `<span class="badge ok">covered${suffixes.length ? ` by ${suffixes.map(html).join(" ")}` : ""}${renderCoverageReferenceMarkers(item, sourceLinks)}</span>` : `<span class="badge missing">missing coverage</span>`;
768
+ return covered === undefined
769
+ ? ""
770
+ : covered
771
+ ? `<span class="badge ok">covered${suffixes.length ? ` by ${suffixes.map(html).join(" ")}` : ""}${renderCoverageReferenceMarkers(item, sourceLinks)}</span>`
772
+ : `<span class="badge missing">missing coverage</span>`;
549
773
  }
550
774
  function ruleCoverageBadge(ruleCoverage, scenarioIds, sourceLinks) {
551
775
  if (ruleCoverage?.covered && !scenarioIds.length)
@@ -591,7 +815,10 @@ function sourceLineUrl(filePath, line, sourceLinks) {
591
815
  return undefined;
592
816
  const baseUrl = sourceLinks.githubBaseUrl.replace(/\/$/, "");
593
817
  const ref = encodeURIComponent(sourceLinks.githubRef);
594
- const encodedFilePath = filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
818
+ const encodedFilePath = filePath
819
+ .split("/")
820
+ .map((segment) => encodeURIComponent(segment))
821
+ .join("/");
595
822
  return `${baseUrl}/blob/${ref}/${encodedFilePath}#L${line}`;
596
823
  }
597
824
  function allReportDocuments(specs, options) {
@@ -639,14 +866,22 @@ function trimSectionLines(lines) {
639
866
  return lines.slice(start, end);
640
867
  }
641
868
  function ruleScenarioIds(ruleId, ruleScenarioLinks) {
642
- return Array.from(new Set(ruleScenarioLinks.filter((link) => link.ruleId === ruleId).map((link) => link.scenarioId))).sort();
869
+ return Array.from(new Set(ruleScenarioLinks
870
+ .filter((link) => link.ruleId === ruleId)
871
+ .map((link) => link.scenarioId))).sort();
643
872
  }
644
873
  function ruleIdsForScenario(scenarioId, specRuleIds, ruleScenarioLinks) {
645
- const ruleIds = new Set(ruleScenarioLinks.filter((link) => link.scenarioId === scenarioId).map((link) => link.ruleId));
874
+ const ruleIds = new Set(ruleScenarioLinks
875
+ .filter((link) => link.scenarioId === scenarioId)
876
+ .map((link) => link.ruleId));
646
877
  return specRuleIds.filter((ruleId) => ruleIds.has(ruleId));
647
878
  }
648
879
  function buildRuleScenarioLinks(ruleCoverage, scenarioCoverage) {
649
- const scenarioReferences = scenarioCoverage.flatMap((scenario) => scenario.references.map((reference) => ({ scenarioId: scenario.id, filePath: reference.filePath, line: reference.line })));
880
+ const scenarioReferences = scenarioCoverage.flatMap((scenario) => scenario.references.map((reference) => ({
881
+ scenarioId: scenario.id,
882
+ filePath: reference.filePath,
883
+ line: reference.line,
884
+ })));
650
885
  const links = [];
651
886
  const seen = new Set();
652
887
  for (const rule of ruleCoverage) {
@@ -666,7 +901,9 @@ function buildRuleScenarioLinks(ruleCoverage, scenarioCoverage) {
666
901
  function nearestScenarioReference(filePath, line, scenarioReferences) {
667
902
  const maxLineDistance = 8;
668
903
  return scenarioReferences
669
- .filter((reference) => reference.filePath === filePath && reference.line <= line && line - reference.line <= maxLineDistance)
904
+ .filter((reference) => reference.filePath === filePath &&
905
+ reference.line <= line &&
906
+ line - reference.line <= maxLineDistance)
670
907
  .sort((left, right) => right.line - left.line)[0];
671
908
  }
672
909
  function groupEvidenceByLine(evidence) {