create-pathfinder 4.2.0 → 4.3.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 (39) hide show
  1. package/CLAUDE.md +2 -0
  2. package/package.json +1 -1
  3. package/skills/learn-codebase/SKILL.md +188 -17
  4. package/skills/learn-feature/SKILL.md +136 -15
  5. package/skills/map-system/SKILL.md +293 -0
  6. package/skills/render-artifact/SKILL.md +187 -0
  7. package/skills/render-artifact/engine/bin/render.mjs +225 -0
  8. package/skills/render-artifact/engine/deliver.mjs +197 -0
  9. package/skills/render-artifact/engine/doctor.mjs +96 -0
  10. package/skills/render-artifact/engine/examples/diagram.json +223 -0
  11. package/skills/render-artifact/engine/examples/lesson.json +242 -0
  12. package/skills/render-artifact/engine/references/determinism.md +71 -0
  13. package/skills/render-artifact/engine/references/specification.md +149 -0
  14. package/skills/render-artifact/engine/references/validation.md +268 -0
  15. package/skills/render-artifact/engine/render/behavior.mjs +128 -0
  16. package/skills/render-artifact/engine/render/diagram.mjs +342 -0
  17. package/skills/render-artifact/engine/render/escape.mjs +34 -0
  18. package/skills/render-artifact/engine/render/graph/behavior.mjs +394 -0
  19. package/skills/render-artifact/engine/render/graph/draw.mjs +204 -0
  20. package/skills/render-artifact/engine/render/graph/interaction.mjs +174 -0
  21. package/skills/render-artifact/engine/render/graph/layout.mjs +698 -0
  22. package/skills/render-artifact/engine/render/graph/style.mjs +200 -0
  23. package/skills/render-artifact/engine/render/graph/width.mjs +204 -0
  24. package/skills/render-artifact/engine/render/index.mjs +50 -0
  25. package/skills/render-artifact/engine/render/lesson.mjs +294 -0
  26. package/skills/render-artifact/engine/render/shell.mjs +275 -0
  27. package/skills/render-artifact/engine/render/theme.mjs +592 -0
  28. package/skills/render-artifact/engine/schemas/common.schema.json +101 -0
  29. package/skills/render-artifact/engine/schemas/diagram.schema.json +176 -0
  30. package/skills/render-artifact/engine/schemas/lesson.schema.json +210 -0
  31. package/skills/render-artifact/engine/validate/composition.mjs +395 -0
  32. package/skills/render-artifact/engine/validate/diagnostics.mjs +83 -0
  33. package/skills/render-artifact/engine/validate/diagram-parts.mjs +68 -0
  34. package/skills/render-artifact/engine/validate/evidence.mjs +302 -0
  35. package/skills/render-artifact/engine/validate/index.mjs +132 -0
  36. package/skills/render-artifact/engine/validate/jsonschema.mjs +312 -0
  37. package/skills/render-artifact/engine/validate/structural.mjs +241 -0
  38. package/skills/render-artifact/engine/verification.mjs +76 -0
  39. package/skills/render-artifact/engine/version.mjs +24 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The `lesson` renderer.
3
+ *
4
+ * One path serves both consumers. `learn-feature` supplies one module and
5
+ * `learn-codebase` supplies many, and nothing below asks which: the module list
6
+ * is rendered by iteration, the navigation is built from the same list, and
7
+ * there is no mode flag, no consumer branch, and no count-dependent layout.
8
+ * When 50.4 brings the multi-module case it proves this rather than adding to
9
+ * it.
10
+ *
11
+ * Every label here is renderer-supplied interface language. The producer names
12
+ * modules, concepts, steps, and questions; the renderer names the furniture
13
+ * around them and never touches what they assert.
14
+ */
15
+
16
+ import { esc, domId } from "./escape.mjs";
17
+ import { renderShell, renderNav } from "./shell.mjs";
18
+
19
+ /** Renderer-owned interface language. The producer supplies none of this. */
20
+ const UI = Object.freeze({
21
+ eyebrow: "Pathfinder lesson",
22
+ navLabel: "Modules",
23
+ objectives: "What you should be able to do",
24
+ evidence: "Evidence",
25
+ requires: "Builds on",
26
+ leadsTo: "Leads to",
27
+ concept: "Concept",
28
+ walkthrough: "Walkthrough",
29
+ practice: "Practice",
30
+ quiz: "Check your understanding",
31
+ showAnswer: "Show the answer",
32
+ hints: "Hints",
33
+ correct: "Correct",
34
+ });
35
+
36
+ // Note what is *not* in UI: any sentence about the artifact having been
37
+ // checked. That claim describes the engine's own work rather than the lesson's
38
+ // subject, so it belongs to the shell and is gated on an attestation there. A
39
+ // kind renderer has nothing to say about whether it was validated.
40
+
41
+ /**
42
+ * @param {object} spec a `lesson` specification
43
+ * @param {object} [verification] an attestation, passed through to the shell.
44
+ * Absent for an ordinary render, which then makes no claim about having
45
+ * been checked.
46
+ * @returns {string} a complete HTML document
47
+ */
48
+ export function renderLesson(spec, verification) {
49
+ const modules = spec.lesson.modules;
50
+ const titleById = new Map(modules.map((module) => [module.id, module.title]));
51
+
52
+ const nav = renderNav(
53
+ modules.map((module) => ({ id: domId("m", module.id), label: module.title })),
54
+ UI.navLabel,
55
+ );
56
+
57
+ const body = [
58
+ renderLead(spec),
59
+ ...modules.map((module, index) => renderModule(module, index, titleById)),
60
+ ].join("\n");
61
+
62
+ return renderShell({
63
+ lang: spec.artifact.locale ?? "en",
64
+ title: spec.artifact.title,
65
+ eyebrow: UI.eyebrow,
66
+ description: spec.artifact.summary ?? spec.artifact.subtitle,
67
+ nav,
68
+ body,
69
+ source: spec.source,
70
+ verification,
71
+ });
72
+ }
73
+
74
+ function renderLead(spec) {
75
+ const { artifact, lesson } = spec;
76
+ const out = ['<div class="pf-lead">', `<h1>${esc(artifact.title)}</h1>`];
77
+
78
+ if (artifact.subtitle) {
79
+ out.push(`<p class="pf-lead-sub">${esc(artifact.subtitle)}</p>`);
80
+ }
81
+ if (artifact.summary) {
82
+ out.push(`<div class="pf-lead-summary"><p>${esc(artifact.summary)}</p></div>`);
83
+ }
84
+ if (lesson.objectives) {
85
+ out.push(
86
+ '<div class="pf-objectives">',
87
+ `<div class="pf-kicker">${esc(UI.objectives)}</div>`,
88
+ "<ul>",
89
+ ...lesson.objectives.map((objective) => `<li>${esc(objective)}</li>`),
90
+ "</ul>",
91
+ "</div>",
92
+ );
93
+ }
94
+ out.push("</div>");
95
+ return out.join("\n");
96
+ }
97
+
98
+ function renderModule(module, index, titleById) {
99
+ const id = domId("m", module.id);
100
+ const out = [
101
+ `<section class="pf-module" id="${esc(id)}" aria-labelledby="${esc(domId(id, "h"))}">`,
102
+ '<div class="pf-module-head">',
103
+ `<div class="pf-module-index">Module ${index + 1}</div>`,
104
+ `<h2 class="pf-module-title" id="${esc(domId(id, "h"))}">${esc(module.title)}</h2>`,
105
+ ];
106
+
107
+ if (module.summary) {
108
+ out.push(`<p class="pf-module-summary">${esc(module.summary)}</p>`);
109
+ }
110
+ if ((module.requires ?? []).length > 0) {
111
+ const links = module.requires.map((required) =>
112
+ `<a href="#${esc(domId("m", required))}">${esc(titleById.get(required))}</a>`);
113
+ out.push(`<p class="pf-requires">${esc(UI.requires)}: ${links.join(", ")}</p>`);
114
+ }
115
+ out.push("</div>");
116
+
117
+ for (const section of module.sections) out.push(renderSection(section));
118
+ out.push("</section>");
119
+ return out.join("\n");
120
+ }
121
+
122
+ function renderSection(section) {
123
+ switch (section.type) {
124
+ case "prose": return renderProse(section);
125
+ case "concept": return renderConcept(section);
126
+ case "code": return renderCode(section);
127
+ case "flow": return renderFlow(section);
128
+ case "quiz": return renderQuiz(section);
129
+ case "exercise": return renderExercise(section);
130
+ // Unreachable: the structural layer rejects any other type before a
131
+ // specification reaches the renderer. Thrown rather than rendered as a
132
+ // fallback, because the one thing a renderer must never do with an
133
+ // unsupported shape is improvise something.
134
+ default: throw new Error(`no renderer for section type ${JSON.stringify(section.type)}`);
135
+ }
136
+ }
137
+
138
+ /** The section wrapper every type shares: id, optional heading, one card. */
139
+ function wrap(section, kicker, inner, extraClass = "") {
140
+ const id = domId("s", section.id);
141
+ const out = [`<section class="pf-section" id="${esc(id)}">`];
142
+ if (section.title) {
143
+ out.push(`<h3 class="pf-section-title" id="${esc(domId(id, "h"))}">${esc(section.title)}</h3>`);
144
+ }
145
+ out.push(`<div class="pf-card${extraClass}">`);
146
+ if (kicker) out.push(`<span class="pf-kicker">${esc(kicker)}</span>`);
147
+ out.push(inner, "</div>", "</section>");
148
+ return out.join("\n");
149
+ }
150
+
151
+ function paragraphs(body) {
152
+ return body.map((text) => `<p>${esc(text)}</p>`).join("\n");
153
+ }
154
+
155
+ function renderProse(section) {
156
+ return wrap(section, null, paragraphs(section.body));
157
+ }
158
+
159
+ function renderConcept(section) {
160
+ return wrap(
161
+ section,
162
+ UI.concept,
163
+ [paragraphs(section.body), renderEvidence(section.evidence)].filter(Boolean).join("\n"),
164
+ " pf-card--concept",
165
+ );
166
+ }
167
+
168
+ /**
169
+ * A code excerpt, emitted verbatim and escaped. The `language` names the
170
+ * language for the reader; it selects no highlighter, because highlighting is
171
+ * either a dependency or a hand-rolled tokeniser, and both are ways for the
172
+ * renderer to start asserting things about content it was given literally.
173
+ */
174
+ function renderCode(section) {
175
+ const first = section.first_line ?? 1;
176
+ const inner = [
177
+ `<span class="pf-code-lang">${esc(section.language)}</span>`,
178
+ "<pre><code>",
179
+ ...section.lines.map((line, index) =>
180
+ `<span class="pf-code-line"><span class="pf-code-no">${first + index}</span>` +
181
+ `${esc(line)}</span>`),
182
+ "</code></pre>",
183
+ ].join("\n");
184
+
185
+ const out = [`<section class="pf-section" id="${esc(domId("s", section.id))}">`];
186
+ if (section.title) out.push(`<h3 class="pf-section-title">${esc(section.title)}</h3>`);
187
+ out.push('<figure class="pf-code">');
188
+ if (section.caption) out.push(`<figcaption>${esc(section.caption)}</figcaption>`);
189
+ out.push(`<div class="pf-code-frame">${inner}</div>`);
190
+ const evidence = renderEvidence(section.evidence);
191
+ if (evidence) out.push(`<div class="pf-card">${evidence}</div>`);
192
+ out.push("</figure>", "</section>");
193
+ return out.join("\n");
194
+ }
195
+
196
+ function renderFlow(section) {
197
+ const titleById = new Map(section.steps.map((step) => [step.id, step.title]));
198
+ const steps = section.steps.map((step) => {
199
+ const out = ["<li>", `<div class="pf-step-title">${esc(step.title)}</div>`];
200
+ if (step.detail) out.push(`<p class="pf-step-detail">${esc(step.detail)}</p>`);
201
+ if ((step.next ?? []).length > 0) {
202
+ const names = step.next.map((id) => esc(titleById.get(id) ?? id)).join(", ");
203
+ out.push(`<p class="pf-step-next">${esc(UI.leadsTo)}: ${names}</p>`);
204
+ }
205
+ const evidence = renderEvidence(step.evidence);
206
+ if (evidence) out.push(evidence);
207
+ out.push("</li>");
208
+ return out.join("\n");
209
+ });
210
+ return wrap(section, UI.walkthrough, `<ol class="pf-flow">\n${steps.join("\n")}\n</ol>`);
211
+ }
212
+
213
+ function renderQuiz(section) {
214
+ const questions = section.questions.map((question) => {
215
+ const name = domId("q", section.id, question.id);
216
+ const options = question.options.map((option, index) => {
217
+ const optionId = domId(name, String(index));
218
+ const correct = index === question.answer;
219
+ return [
220
+ `<label class="pf-option" for="${esc(optionId)}">`,
221
+ `<input type="radio" id="${esc(optionId)}" name="${esc(name)}" ` +
222
+ `value="${index}" data-pf-answer="${correct ? "correct" : "other"}">`,
223
+ `<span>${esc(option)}</span>`,
224
+ '<span class="pf-verdict" aria-live="polite"></span>',
225
+ "</label>",
226
+ ].join("");
227
+ });
228
+
229
+ const reveal = [
230
+ '<details class="pf-reveal">',
231
+ `<summary>${esc(UI.showAnswer)}</summary>`,
232
+ `<p><strong>${esc(UI.correct)}:</strong> ${esc(question.options[question.answer])}</p>`,
233
+ question.explanation ? `<p>${esc(question.explanation)}</p>` : null,
234
+ renderEvidence(question.evidence),
235
+ "</details>",
236
+ ].filter(Boolean).join("\n");
237
+
238
+ return [
239
+ `<fieldset class="pf-question" data-pf-question="${esc(name)}">`,
240
+ `<legend>${esc(question.prompt)}</legend>`,
241
+ `<div class="pf-options">${options.join("")}</div>`,
242
+ reveal,
243
+ "</fieldset>",
244
+ ].join("\n");
245
+ });
246
+
247
+ return wrap(section, UI.quiz, `<div class="pf-quiz">\n${questions.join("\n")}\n</div>`);
248
+ }
249
+
250
+ function renderExercise(section) {
251
+ const out = [paragraphs(section.body)];
252
+ if (section.hints) {
253
+ out.push([
254
+ '<details class="pf-reveal">',
255
+ `<summary>${esc(UI.hints)}</summary>`,
256
+ "<ol>",
257
+ ...section.hints.map((hint) => `<li>${esc(hint)}</li>`),
258
+ "</ol>",
259
+ "</details>",
260
+ ].join("\n"));
261
+ }
262
+ const evidence = renderEvidence(section.evidence);
263
+ if (evidence) out.push(evidence);
264
+ return wrap(section, UI.practice, out.join("\n"));
265
+ }
266
+
267
+ /**
268
+ * Citations, in one presentation used everywhere evidence appears.
269
+ *
270
+ * The shape is the same in a concept, a flow step, and a quiz answer, and it
271
+ * will be the same in whatever kind comes next. A reader who learns to read one
272
+ * of these has learnt to read all of them.
273
+ */
274
+ function renderEvidence(evidence) {
275
+ if (!evidence || evidence.length === 0) return "";
276
+ return [
277
+ '<div class="pf-evidence">',
278
+ `<div class="pf-evidence-label">${esc(UI.evidence)}</div>`,
279
+ "<ul>",
280
+ ...evidence.map((citation) => {
281
+ const parts = [`<span class="pf-cite-path">${esc(citation.path)}</span>`];
282
+ if (citation.lines) {
283
+ parts.push(`<span class="pf-cite-lines">lines ${citation.lines[0]}` +
284
+ `–${citation.lines[1]}</span>`);
285
+ }
286
+ if (citation.commit) {
287
+ parts.push(`<span class="pf-cite-commit">@ ${esc(citation.commit)}</span>`);
288
+ }
289
+ return `<li>${parts.join("")}</li>`;
290
+ }),
291
+ "</ul>",
292
+ "</div>",
293
+ ].join("\n");
294
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The shared shell every artifact kind renders into.
3
+ *
4
+ * A kind supplies a lead, a navigation list, and a body. Everything else on the
5
+ * page — the document head, the header, the mark, the theme control, the skip
6
+ * link, the provenance block, and every word of interface language in them — is
7
+ * the renderer's, defined once here. That is what "adding a kind must not
8
+ * require restating the identity" means in practice: a second kind imports this
9
+ * function and passes strings to it.
10
+ *
11
+ * Every string the renderer emits about the artifact's *subject* comes from the
12
+ * specification. Every string it emits about the *interface* is written here.
13
+ * The renderer asserts no domain content: it never summarises, rewords, or
14
+ * invents a producer's claim.
15
+ */
16
+
17
+ import { RENDERER_VERSION } from "../version.mjs";
18
+ import { isAttestation } from "../verification.mjs";
19
+ import { esc } from "./escape.mjs";
20
+ import { THEME_CSS } from "./theme.mjs";
21
+ import { BEHAVIOR_JS } from "./behavior.mjs";
22
+
23
+ /**
24
+ * The Pathfinder mark: the blaze stack from `assets/logo.svg`, drawn in
25
+ * `currentColor` so it takes the accent in both themes. Inlined rather than
26
+ * linked — an artifact that opens from `file://` cannot fetch a logo, and a
27
+ * broken image in the header is worse than no header.
28
+ */
29
+ const MARK = '<svg class="pf-mark" width="26" height="26" viewBox="0 0 32 32" ' +
30
+ 'aria-hidden="true" focusable="false"><g fill="currentColor">' +
31
+ '<rect x="4" y="23" width="24" height="6.4" rx="3.2" transform="rotate(-3 16 26.2)"/>' +
32
+ '<rect x="7" y="14.6" width="18" height="6" rx="3" transform="rotate(4 16 17.6)"/>' +
33
+ '<rect x="9.6" y="7" width="12.8" height="5.4" rx="2.7" transform="rotate(-6 16 9.7)"/>' +
34
+ '<rect x="12.4" y="1" width="7.2" height="4.4" rx="2.2" transform="rotate(5 16 3.2)"/>' +
35
+ '</g></svg>';
36
+
37
+ /** The same mark as a favicon, percent-encoded inline. No network request. */
38
+ const FAVICON = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20" +
39
+ "viewBox='0%200%2032%2032'%3E%3Cg%20fill='%23E0611F'%3E" +
40
+ "%3Crect%20x='4'%20y='23'%20width='24'%20height='6.4'%20rx='3.2'%20transform='rotate(-3%2016%2026.2)'/%3E" +
41
+ "%3Crect%20x='7'%20y='14.6'%20width='18'%20height='6'%20rx='3'%20transform='rotate(4%2016%2017.6)'/%3E" +
42
+ "%3Crect%20x='9.6'%20y='7'%20width='12.8'%20height='5.4'%20rx='2.7'%20transform='rotate(-6%2016%209.7)'/%3E" +
43
+ "%3Crect%20x='12.4'%20y='1'%20width='7.2'%20height='4.4'%20rx='2.2'%20transform='rotate(5%2016%203.2)'/%3E" +
44
+ "%3C/g%3E%3C/svg%3E";
45
+
46
+ /**
47
+ * The verification sentence below is deliberately here rather than in a kind
48
+ * renderer: it describes what the *engine* did, not what a lesson is about, so
49
+ * every kind that renders into this shell says it the same way or not at all.
50
+ *
51
+ * @param {object} parts
52
+ * @param {string} parts.lang BCP 47 tag from the specification, or "en"
53
+ * @param {string} parts.title artifact title
54
+ * @param {string} parts.eyebrow what kind of artifact this is
55
+ * @param {string} [parts.description] meta description
56
+ * @param {string} [parts.style] a kind's own stylesheet, appended to the
57
+ * shared theme inside the one `<style>` element. Absent for a kind that
58
+ * needs none, and then not one byte of the document changes.
59
+ * @param {string} [parts.behavior] a kind's own inline script, appended to the
60
+ * shared behaviour inside the one `<script>` element, on the same terms
61
+ * and for the same reason: a lesson has no graph to traverse, and
62
+ * shipping it traversal code would put script in an artifact that can
63
+ * never run it. Absent for a kind that needs none, and then not one
64
+ * byte of the document changes.
65
+ * @param {string} parts.nav rendered navigation HTML
66
+ * @param {string} parts.body rendered lead and content HTML
67
+ * @param {object} [parts.source] the specification's source identity, when
68
+ * it declares one. A kind describing a system with no repository behind
69
+ * it has none, and then no provenance row is invented for it.
70
+ * @param {"derived"|"proposed"} [parts.provenance] what kind of claim the
71
+ * artifact makes, for the kinds that distinguish. Absent for `lesson`.
72
+ * @param {object} [parts.verification] an attestation, when — and only when —
73
+ * this engine validated the specification and the validation passed
74
+ * @returns {string} a complete HTML document
75
+ */
76
+ export function renderShell(parts) {
77
+ const lines = [
78
+ "<!doctype html>",
79
+ `<html lang="${esc(parts.lang)}" data-pf-theme="auto">`,
80
+ "<head>",
81
+ '<meta charset="utf-8">',
82
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
83
+ `<title>${esc(parts.title)}</title>`,
84
+ parts.description
85
+ ? `<meta name="description" content="${esc(parts.description)}">`
86
+ : null,
87
+ `<meta name="generator" content="Pathfinder render-artifact ${esc(RENDERER_VERSION)}">`,
88
+ `<link rel="icon" href="${FAVICON}">`,
89
+ `<style>${THEME_CSS}${parts.style ? `\n${parts.style}\n` : ""}</style>`,
90
+ "</head>",
91
+ "<body>",
92
+ '<a class="pf-skip" href="#pf-content">Skip to content</a>',
93
+ '<header class="pf-header">',
94
+ MARK,
95
+ '<div class="pf-identity">',
96
+ `<div class="pf-eyebrow">${esc(parts.eyebrow)}</div>`,
97
+ `<div class="pf-title">${esc(parts.title)}</div>`,
98
+ "</div>",
99
+ // Hidden until the script un-hides it: a control that cannot work should
100
+ // not be offered. Without scripting the theme still follows the system.
101
+ '<button type="button" class="pf-theme-toggle" id="pf-theme-toggle" hidden>Theme: auto</button>',
102
+ "</header>",
103
+ '<div class="pf-layout">',
104
+ parts.nav,
105
+ '<main class="pf-main" id="pf-content">',
106
+ parts.body,
107
+ "</main>",
108
+ "</div>",
109
+ renderProvenance(parts.source, parts.verification, parts.provenance),
110
+ `<script>${BEHAVIOR_JS}${parts.behavior ? `\n${parts.behavior}` : ""}</script>`,
111
+ "</body>",
112
+ "</html>",
113
+ "",
114
+ ];
115
+ return lines.filter((line) => line !== null).join("\n");
116
+ }
117
+
118
+ /**
119
+ * What this artifact claims about itself, and when it is entitled to.
120
+ *
121
+ * The provenance rows are facts copied out of the specification, and only the
122
+ * ones it actually carries. The verification sentence is a claim about work the
123
+ * engine performed, so it appears only against a real attestation — which only
124
+ * `verification.attest()` can mint, and only from a validation that passed.
125
+ *
126
+ * Rendering without one emits no verification language whatsoever. Not a
127
+ * hedge, not "unverified", not a placeholder: an artifact that cannot vouch for
128
+ * itself says nothing on the subject, and the absence of the sentence is the
129
+ * signal. Saying "unverified" would still be the renderer making a claim about
130
+ * a process it did not observe.
131
+ *
132
+ * Even with the attestation, note what the sentences are careful not to say.
133
+ * Each scopes itself to what was checked deterministically and hands the
134
+ * question of whether the page reads well back to a person, because
135
+ * deterministic validation never establishes that and must never be reported as
136
+ * if it did. None of them says the system drawn is correct, and the `derived`
137
+ * one in particular does not: what was verified is that the cited material is
138
+ * there at the cited commit. Provenance, not truth.
139
+ *
140
+ * **Three states, three sentences, and none of them selectable.** There is no
141
+ * field in any specification that chooses between these, softens one, or asks
142
+ * for one it has not earned. The words belong to the renderer for the same
143
+ * reason the claim does — a producer vouching for its own work is precisely
144
+ * what this design exists to make impossible — and they live in the shared
145
+ * shell rather than in a kind renderer because they describe what the *engine*
146
+ * did, which is the same whatever kind rendered into it.
147
+ */
148
+
149
+ /**
150
+ * `lesson`, and any kind that draws no provenance distinction. Unchanged from
151
+ * the wording that shipped, deliberately: the lesson artifact's language is not
152
+ * what this contract set out to alter.
153
+ */
154
+ const CHECKED =
155
+ "This artifact was checked deterministically: its specification satisfied " +
156
+ "the schema, its references and graphs resolved, and every citation above " +
157
+ "was verified against the commit named here. That is what was checked. It " +
158
+ "is not a judgement that the page reads well or looks right — a person has " +
159
+ "to open it to know that.";
160
+
161
+ /**
162
+ * `derived`: the strongest sentence the engine can write, and still not a
163
+ * statement that the architecture is right. Every node, edge and claim carried
164
+ * a citation, and each resolved — which establishes that the cited material is
165
+ * in the repository at that commit, not that the reading of it is correct.
166
+ */
167
+ const CHECKED_DERIVED =
168
+ "This diagram was checked deterministically against the commit named here: " +
169
+ "its specification satisfied the schema, its identifiers, references and " +
170
+ "graphs resolved, and every component, relationship and claim in it carried " +
171
+ "a citation that was verified at that commit. What that establishes is that " +
172
+ "the cited material is there to be read. It is not a finding that the " +
173
+ "architecture drawn here is correct, complete, or the best reading of that " +
174
+ "material, and it is not a judgement that the page reads well — a person " +
175
+ "has to open it to know either.";
176
+
177
+ /**
178
+ * `proposed`, with a source and at least one citation that resolved. The
179
+ * citations were checked; the design was not. The second half of this sentence
180
+ * is the load-bearing half, and it is why this state cannot borrow the wording
181
+ * above: a reader who met "verified against the commit" on a proposed
182
+ * architecture would reasonably conclude the architecture was the thing found
183
+ * in the repository.
184
+ */
185
+ const CHECKED_PROPOSED =
186
+ "This diagram describes a proposed design. Its specification satisfied the " +
187
+ "schema, its identifiers, references and graphs resolved, and the citations " +
188
+ "it supplied were verified against the commit named here. Those citations " +
189
+ "establish that the material they name exists at that commit — they do not " +
190
+ "establish that the system drawn here exists. Nothing was checked about " +
191
+ "whether it does, and nothing here should be read as saying it does.";
192
+
193
+ /**
194
+ * `proposed`, with no source at all. Not a verification sentence: there is
195
+ * nothing to verify and the artifact says so in place of claiming anything.
196
+ * Shown whether or not an attestation exists, because it is a fact about the
197
+ * specification's own shape rather than a report of work the engine did.
198
+ */
199
+ const NO_SOURCE =
200
+ "This diagram describes an intended system. It names no repository and no " +
201
+ "commit, so it carries no citations and none were checked — there is " +
202
+ "nothing here that was verified against a source. It is a proposal, and it " +
203
+ "makes no claim about what currently exists.";
204
+
205
+ function renderProvenance(source, verification, provenance) {
206
+ // Built from what is present rather than from a fixed shape. A specification
207
+ // with no source gets no repository row, no commit row and no timestamp —
208
+ // none of them invented from the working directory, the environment, or a
209
+ // clock, because there is no honest value for them and a plausible one would
210
+ // be worse than none.
211
+ const rows = [];
212
+ if (source?.repo) rows.push(["Repository", source.repo]);
213
+ if (source?.commit) rows.push(["Commit", source.commit]);
214
+ if (source?.generated_at) rows.push(["Specification generated", source.generated_at]);
215
+ rows.push(["Renderer", `Pathfinder render-artifact ${RENDERER_VERSION}`]);
216
+
217
+ return [
218
+ '<footer class="pf-provenance">',
219
+ "<dl>",
220
+ ...rows.flatMap(([term, value]) =>
221
+ [`<dt>${esc(term)}</dt>`, `<dd>${esc(value)}</dd>`]),
222
+ "</dl>",
223
+ provenanceNote(source, verification, provenance),
224
+ "</footer>",
225
+ ].filter((line) => line !== null).join("\n");
226
+ }
227
+
228
+ /**
229
+ * The one sentence this artifact is entitled to, or none.
230
+ *
231
+ * Two conditions gate every verification claim, and both are necessary. The
232
+ * attestation proves this engine validated the specification and that the
233
+ * validation passed. The resolved-citation count proves there was something to
234
+ * check: a specification carrying no citations passes the evidence layer by
235
+ * having nothing to fail, and a claim resting on that would be a stronger
236
+ * statement than anybody made. Vacuous success earns no sentence.
237
+ */
238
+ function provenanceNote(source, verification, provenance) {
239
+ const caveat = (text) => `<p class="pf-caveat">${esc(text)}</p>`;
240
+
241
+ // A proposal with no repository behind it. Said plainly, and said whatever
242
+ // else is true, because it is a fact about the specification rather than a
243
+ // report of anything the engine did.
244
+ if (provenance === "proposed" && !source) return caveat(NO_SOURCE);
245
+
246
+ if (!isAttestation(verification)) return null;
247
+ if (!(verification.resolvedCitations >= 1)) return null;
248
+
249
+ if (provenance === "derived") return caveat(CHECKED_DERIVED);
250
+ if (provenance === "proposed") return caveat(CHECKED_PROPOSED);
251
+ return caveat(CHECKED);
252
+ }
253
+
254
+ /**
255
+ * The navigation list. One markup shape whatever the module count: a single
256
+ * module renders the same list a sixty-module portal does, so nothing branches
257
+ * on the number of entries and a later kind inherits the behaviour for free.
258
+ */
259
+ export function renderNav(entries, label) {
260
+ if (entries.length === 0) return "";
261
+ return [
262
+ `<nav class="pf-nav" aria-label="${esc(label)}">`,
263
+ `<div class="pf-nav-label">${esc(label)}</div>`,
264
+ '<ul class="pf-nav-list">',
265
+ ...entries.map((entry, index) => [
266
+ "<li>",
267
+ `<a class="pf-nav-link" href="#${esc(entry.id)}" data-pf-nav="${esc(entry.id)}">`,
268
+ `<span class="pf-nav-index">${index + 1}</span>${esc(entry.label)}`,
269
+ "</a>",
270
+ "</li>",
271
+ ].join("")),
272
+ "</ul>",
273
+ "</nav>",
274
+ ].join("\n");
275
+ }