create-cmp-cli 0.24.0 → 0.25.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 (141) hide show
  1. package/bin/create-cmp.mjs +33 -3
  2. package/package.json +10 -4
  3. package/packages/harness/package.json +18 -8
  4. package/packages/harness/src/approve.mjs +10 -11
  5. package/packages/harness/src/console/console-data.mjs +117 -0
  6. package/packages/harness/src/console/console-evidence.mjs +122 -0
  7. package/packages/harness/src/console/console-overview.mjs +642 -0
  8. package/packages/harness/src/console/console-shell.mjs +1139 -0
  9. package/packages/harness/src/console/console-tabs.mjs +2828 -0
  10. package/packages/harness/src/console/contrast.mjs +74 -0
  11. package/packages/harness/src/console/preview-service.mjs +1384 -0
  12. package/packages/harness/src/framework-check.mjs +271 -37
  13. package/packages/harness/src/lib/a11y.mjs +1 -1
  14. package/packages/harness/src/lib/affected-tests.mjs +99 -44
  15. package/packages/harness/src/lib/approvals.mjs +177 -350
  16. package/packages/harness/src/lib/audit-cadence.mjs +1 -1
  17. package/packages/harness/src/lib/comments.mjs +18 -4
  18. package/packages/harness/src/lib/determinism.mjs +275 -14
  19. package/packages/harness/src/lib/evidence-badge.mjs +34 -2
  20. package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
  21. package/packages/harness/src/lib/evidence-level.mjs +144 -59
  22. package/packages/harness/src/lib/feature-brief.mjs +53 -16
  23. package/packages/harness/src/lib/flight-recorder.mjs +72 -8
  24. package/packages/harness/src/lib/framework-check.mjs +434 -53
  25. package/packages/harness/src/lib/harness-lock.mjs +87 -19
  26. package/packages/harness/src/lib/harness-manifest.mjs +208 -0
  27. package/packages/harness/src/lib/harness-region.mjs +106 -2
  28. package/packages/harness/src/lib/harness-source.mjs +142 -0
  29. package/packages/harness/src/lib/inputs-hash.mjs +175 -22
  30. package/packages/harness/src/lib/lane-markers.mjs +78 -0
  31. package/packages/harness/src/lib/lane-narrator.mjs +4 -2
  32. package/packages/harness/src/lib/lane-runner.mjs +72 -6
  33. package/packages/harness/src/lib/plan.mjs +44 -11
  34. package/packages/harness/src/lib/plant-calibration.mjs +135 -0
  35. package/packages/harness/src/lib/profile-loader.mjs +323 -0
  36. package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
  37. package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
  38. package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
  39. package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  40. package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
  41. package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
  42. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  43. package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
  44. package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
  45. package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
  46. package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
  47. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
  48. package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  49. package/packages/harness/src/lib/receipt-validate.mjs +84 -16
  50. package/packages/harness/src/lib/spec-coverage.mjs +179 -92
  51. package/packages/harness/src/lib/spec-model.mjs +262 -0
  52. package/packages/harness/src/lib/step-cache.mjs +11 -2
  53. package/packages/harness/src/lib/step-outcomes.mjs +84 -151
  54. package/packages/harness/src/lib/walk.mjs +1 -1
  55. package/packages/harness/src/preview-gallery.mjs +1 -1
  56. package/packages/harness/src/receipt-check.mjs +80 -24
  57. package/packages/harness/src/record-audit.mjs +1 -1
  58. package/packages/harness/src/scaffold-feature.mjs +10 -2
  59. package/packages/harness/src/verify.mjs +222 -142
  60. package/packages/harness/src/watch.mjs +146 -33
  61. package/packages/receipts/package.json +1 -1
  62. package/packages/receipts/src/index.mjs +2 -2
  63. package/packages/receipts/src/inputs-hash.mjs +175 -22
  64. package/packages/receipts/src/receipt-validate.mjs +84 -16
  65. package/src/commands/attach.mjs +128 -4
  66. package/src/commands/harden.mjs +7 -1
  67. package/src/commands/upgrade.mjs +7 -0
  68. package/src/lib/minimal.mjs +23 -1
  69. package/src/scaffold.mjs +8 -0
  70. package/template/.claude/settings.json +2 -2
  71. package/template/.claude/skills/add-feature/SKILL.md +7 -3
  72. package/template/.claude/skills/add-repository/SKILL.md +2 -2
  73. package/template/.claude/skills/add-screen/SKILL.md +8 -4
  74. package/template/.githooks/pre-push +9 -0
  75. package/template/CLAUDE.md +55 -186
  76. package/template/docs/TESTING.md +1 -1
  77. package/template/gitignore +3 -0
  78. package/template/qa/approve.mjs +10 -11
  79. package/template/qa/comments.json +1 -1
  80. package/template/qa/evidence/schema.json +258 -34
  81. package/template/qa/framework-check.mjs +271 -37
  82. package/template/qa/harness-manifest.json +10 -0
  83. package/template/qa/lib/a11y.mjs +1 -1
  84. package/template/qa/lib/affected-tests.mjs +99 -44
  85. package/template/qa/lib/approvals.mjs +177 -350
  86. package/template/qa/lib/audit-cadence.mjs +1 -1
  87. package/template/qa/lib/comments.mjs +18 -4
  88. package/template/qa/lib/determinism.mjs +275 -14
  89. package/template/qa/lib/evidence-badge.mjs +34 -2
  90. package/template/qa/lib/evidence-ladder.mjs +202 -0
  91. package/template/qa/lib/evidence-level.mjs +144 -59
  92. package/template/qa/lib/feature-brief.mjs +53 -16
  93. package/template/qa/lib/flight-recorder.mjs +72 -8
  94. package/template/qa/lib/framework-check.mjs +434 -53
  95. package/template/qa/lib/harness-lock.mjs +87 -19
  96. package/template/qa/lib/harness-manifest.mjs +208 -0
  97. package/template/qa/lib/harness-region.mjs +106 -2
  98. package/template/qa/lib/harness-source.mjs +142 -0
  99. package/template/qa/lib/inputs-hash.mjs +175 -22
  100. package/template/qa/lib/lane-markers.mjs +78 -0
  101. package/template/qa/lib/lane-narrator.mjs +4 -2
  102. package/template/qa/lib/lane-runner.mjs +72 -6
  103. package/template/qa/lib/plan.mjs +44 -11
  104. package/template/qa/lib/plant-calibration.mjs +135 -0
  105. package/template/qa/lib/profile-loader.mjs +323 -0
  106. package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
  107. package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
  108. package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
  109. package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  110. package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
  111. package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
  112. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  113. package/template/qa/lib/profiles/cmp/index.mjs +47 -0
  114. package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
  115. package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
  116. package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
  117. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
  118. package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  119. package/template/qa/lib/receipt-validate.mjs +84 -16
  120. package/template/qa/lib/spec-coverage.mjs +179 -92
  121. package/template/qa/lib/spec-model.mjs +262 -0
  122. package/template/qa/lib/step-cache.mjs +11 -2
  123. package/template/qa/lib/step-outcomes.mjs +84 -151
  124. package/template/qa/lib/walk.mjs +1 -1
  125. package/template/qa/preview-gallery.mjs +1 -1
  126. package/template/qa/receipt-check.mjs +80 -24
  127. package/template/qa/record-audit.mjs +1 -1
  128. package/template/qa/scaffold-feature.mjs +10 -2
  129. package/template/qa/verified-surface.json +17 -0
  130. package/template/qa/verify.mjs +222 -142
  131. package/template/qa/watch.mjs +146 -33
  132. /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  133. /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  134. /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  135. /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  136. /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
  137. /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  138. /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  139. /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  140. /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  141. /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
@@ -0,0 +1,2828 @@
1
+ // MOVED from inspector/mcp/src/lib/console-tabs.mjs — NORTH-STAR §9, stage 0.5,
2
+ // "the console into the harness". Two changes came with the move, both forced by
3
+ // the boundary rather than chosen:
4
+ //
5
+ // 1. Its two imports (design-language.mjs, components.mjs) pointed at modules
6
+ // that did NOT move, because both also scan Kotlin source. The three pure
7
+ // functions it actually used came across into ./console-data.mjs and the
8
+ // originals re-export them, so nothing here imports back into inspector/mcp.
9
+ // 2. Five hardcoded `composeApp/...` paths in fallback copy were replaced by
10
+ // what the DATA says. Each sat behind `x.reason || "<a cmp path>"`, so a
11
+ // project with no composeApp/ whose provider returned no reason was told a
12
+ // file was missing from a directory it does not have. That is the §9.1
13
+ // failure class — a wrong answer, not a refusal — and this package may not
14
+ // name a stack (test/agnostic-lint.test.mjs, deny-by-default).
15
+ //
16
+ // console-tabs.mjs — pure (data) -> html generators for the console's section
17
+ // bodies, each in its profession's §3 form (docs/STUDIO-REDESIGN.md): Design
18
+ // language (§3.1), Architecture (§3.2), Components (§3.3), Specs (§3.5, the
19
+ // traceability matrix), Evidence (§3.6, the release-readiness report), plus
20
+ // the Approvals and Comments ledgers. The Screens body is still built inline
21
+ // by preview-service.mjs's galleryHtml.
22
+ //
23
+ // Pure generators, same style as preview-service.mjs's galleryHtml:
24
+ // (state) -> html string, no DOM, no CDN. Derivation math (contrast pairs,
25
+ // dimens classification) comes from design-language.mjs; nothing here reads
26
+ // the filesystem. Every section degrades honestly to an empty-state
27
+ // explanation when its data source isn't available yet — never fabricated
28
+ // values, and every absence uses the one standardized form
29
+ // ("Not derivable statically — <reason>").
30
+
31
+ import { classifyDimens, deriveContrastPairs, componentStoryId } from "./console-data.mjs";
32
+
33
+ /**
34
+ * THE SHELL'S OWN WORDS — neutral, and overridden by the profile's console copy.
35
+ * PATTERN: contribution points (VS Code `contributes.viewsWelcome`, Backstage
36
+ * plugins, Grafana panels): the shell renders section TYPES; the provider
37
+ * supplies rows and the words around them. WHY IT WORKS: the shell stops naming
38
+ * Tokens.kt or @Composable, so a Python profile's console never tells its user
39
+ * to edit a Kotlin file. HOW IT FAILS: a provider forgets a key and the adopter
40
+ * sees a neutral placeholder where they expected help; or a new string is added
41
+ * here without going through `copy`. WHAT WE DO: every key has a neutral
42
+ * default, `resolveCopy` merges the profile's over it, and the widened agnostic
43
+ * lint scans console/ for language-shaped strings so the second failure is
44
+ * caught the day it is written.
45
+ */
46
+ export const NEUTRAL_COPY = Object.freeze({
47
+ usesIn: "the shared sources",
48
+ tokensEditHint: "Edit the design tokens, let the preview re-render, then stash the result with the",
49
+ componentsEmpty: "no components found in the registry directory",
50
+ versionSetFile: "the version catalogue",
51
+ versionSetUnreadable: "the version catalogue is not readable",
52
+ kspPairLabel: "the code-generator version matches the language version",
53
+ kspPrefixWarning: "is not prefixed by the language version",
54
+ previewRegistryFile: "the preview registry",
55
+ noRenderableApp: "no renderable app",
56
+ kspCarriesNote: "",
57
+ kspName: "the code generator",
58
+ kspCarriesLabel: "carries the language version",
59
+ depGraphGatesNote: "The conformance gates (and the receipt they write, below) are authoritative.",
60
+ });
61
+ /**
62
+ * The words in force. Module state, set ONCE by the console host that loaded
63
+ * the profile (`setConsoleCopy(profile.console)`) — the tab helpers take no
64
+ * meta and the gallery has no root, so threading a parameter through eight
65
+ * call sites would have been the same global by a longer road. A host that
66
+ * never sets it renders the neutral words, which is the honest default.
67
+ */
68
+ let COPY = NEUTRAL_COPY;
69
+ export function setConsoleCopy(copy) {
70
+ COPY = Object.freeze({ ...NEUTRAL_COPY, ...(copy && typeof copy === "object" ? copy : {}) });
71
+ return COPY;
72
+ }
73
+ export function consoleCopy() {
74
+ return COPY;
75
+ }
76
+ // Three surfaces in this file show an evidence rung — the Evidence headline,
77
+ // the committed-receipt timeline, and the digest's lane-run table — and
78
+ // NORTH-STAR.md §6.5 requires each of them to show the pack that graded it.
79
+ // console-evidence.mjs holds the console's one spelling of that pair; see its
80
+ // header for why the class is fixed in one place rather than at three call
81
+ // sites.
82
+ import { rungWithPack, rungPackNote } from "./console-evidence.mjs";
83
+
84
+ const esc = (s) =>
85
+ String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
86
+
87
+ // Same as esc(), plus double-quote escaping — required for values embedded
88
+ // inside a double-quoted HTML attribute (the comment target JSON below always
89
+ // contains `"`, being JSON; esc() alone would corrupt the markup).
90
+ const escAttr = (s) => esc(s).replace(/"/g, "&quot;");
91
+
92
+ /**
93
+ * A 💬 control (§7.3): a button that reveals an inline popover (pure JS,
94
+ * wired by preview-service.mjs's wireCommentButtons) posting to
95
+ * POST /api/comment. `target` is a §7.3 Comment target object — serialized
96
+ * as JSON into a data attribute, escaped for attribute-safety (comment text
97
+ * is USER input; the target itself is server-constructed but still escaped
98
+ * rigorously, per this package's style). Pass `{ testTagInput: true }` to
99
+ * additionally show an optional testTag field (screen cards: "with optional
100
+ * testTag field for element-level" — filled in, the console POSTs
101
+ * {type:"element", screen, testTag} instead of {type:"screen", screen}).
102
+ * @param {object} target a §7.3 Comment target ({type, ...fields})
103
+ * @param {{testTagInput?: boolean}} [opts]
104
+ */
105
+ export function commentControlHtml(target, opts = {}) {
106
+ const targetJson = escAttr(JSON.stringify(target));
107
+ const testTagField = opts.testTagInput
108
+ ? `<input type="text" class="comment-testtag" placeholder="testTag (optional — element-level)">`
109
+ : "";
110
+ return `<span class="comment-ctl" data-target="${targetJson}">
111
+ <button type="button" class="comment-btn" title="Add comment">&#128172;</button>
112
+ <span class="comment-popover" hidden>
113
+ ${testTagField}
114
+ <textarea class="comment-text" rows="2" placeholder="Add a comment&hellip;"></textarea>
115
+ <div class="comment-popover-actions">
116
+ <button type="button" class="comment-cancel">Cancel</button>
117
+ <button type="button" class="comment-submit">Post</button>
118
+ </div>
119
+ <p class="comment-error" hidden></p>
120
+ </span>
121
+ </span>`;
122
+ }
123
+
124
+ function shortHash(hash) {
125
+ return hash ? String(hash).slice(0, 8) : "none";
126
+ }
127
+
128
+ // The §1 ordered-walk numbering (GENESIS-FLOW-DESIGN.md §1, superseding
129
+ // VERIFICATION-LAYER-DESIGN.md §1's numbering) — shown so the human sees the
130
+ // intended DEFINITION order (each artifact is the vocabulary the next is
131
+ // written in), not just an alphabetical/registry list. `intent` and
132
+ // `components` are new rows (§1's registry table); a project whose approvals
133
+ // library predates them simply never reports those ids, so their numbers
134
+ // just never appear — no fabrication, no renumbering surprise for the ids
135
+ // that already existed.
136
+ const ORDER_BY_ID = [
137
+ [/^intent$/, 0],
138
+ // Decide-first: a feature's brief is signed before anything else about it
139
+ // exists — it speaks intent's vocabulary, so it sits directly after intent
140
+ // (CHANGE-FLOW-DESIGN.md §6; true at genesis and ever after).
141
+ [/^feature-brief:/, 1],
142
+ [/^architecture$/, 2],
143
+ // Spec-first: the exemplar's clauses are confirmed BEFORE the slice is built.
144
+ [/^exemplar-spec$/, 3],
145
+ [/^exemplar-feature$/, 4],
146
+ // UI-first: the design system locks on — and the components are distilled
147
+ // from — the real exemplar screens, so both FOLLOW the exemplar.
148
+ [/^design-system$/, 5],
149
+ [/^components$/, 6],
150
+ // Brief → design → spec → build (decided 2026-07-25): a feature's screens
151
+ // are signed on RENDERED output before its behavior contract pins them down.
152
+ [/^feature-design:/, 7],
153
+ [/^feature-spec:/, 8],
154
+ ];
155
+ function orderNumber(id) {
156
+ for (const [re, n] of ORDER_BY_ID) if (re.test(id)) return n;
157
+ return "–";
158
+ }
159
+
160
+ // --- sign where you read -----------------------------------------------------
161
+ //
162
+ // Every governed section carries its OWN signature control. The Approvals tab
163
+ // is the ledger — the queue, the audit trail — but it must never be the only
164
+ // place a decision can be made: a human reading a spec, a brief, or a token
165
+ // catalogue is exactly the human who should be able to sign it, without
166
+ // hunting for the artifact's row on another tab. One renderer, reused by
167
+ // every section, emitting the SAME `approve-btn`/`reopen-btn` contract the
168
+ // console's wiring and the refresh routine already speak.
169
+
170
+ /**
171
+ * @param {object|null} status a getApprovalStatuses row for this artifact
172
+ * @param {{what?: string}} [opts] `what` names the thing in the section's own
173
+ * words ("this contract", "the design system") — the button says what the
174
+ * signature means, never a bare verb.
175
+ */
176
+ export function signatureBarHtml(status, opts = {}) {
177
+ if (!status) return "";
178
+ const what = opts.what || status.id;
179
+ const cls =
180
+ status.status === "approved"
181
+ ? "badge-approved"
182
+ : status.status === "changed-since-approval"
183
+ ? "badge-drift"
184
+ : status.status === "reopened"
185
+ ? "badge-reopened"
186
+ : "badge-unreviewed";
187
+ const line =
188
+ status.status === "approved"
189
+ ? `signed${status.approvedAt ? ` ${esc(status.approvedAt)}` : ""}${status.mode ? ` · ${esc(status.mode)}` : ""}`
190
+ : status.status === "changed-since-approval"
191
+ ? "changed since signature — review the diff below, then re-approve"
192
+ : status.status === "reopened"
193
+ ? // The WHY, read straight off the ledger row (07-28 audit: "reopened"
194
+ // with no reason was the state Karel came back to and could not
195
+ // decode). Pre-audit rows carry no reason — the line then says only
196
+ // what it knows.
197
+ `reopened for redesign${status.reopenedAt ? ` ${esc(status.reopenedAt)}` : ""}${status.via ? ` via ${esc(status.via)}` : ""}${status.reason ? ` — <em>${esc(status.reason)}</em>` : ""} — re-approve when the redesign lands`
198
+ : "not signed yet — nothing here is binding until you sign it";
199
+ // Refused states never offer a button that would only fail on click.
200
+ const canApprove = status.resolvable !== false;
201
+ const approveLabel = status.status === "approved" ? `Re-approve ${what}` : status.status === "unreviewed" ? `Approve ${what}` : `Re-approve ${what}`;
202
+ const buttons = [
203
+ canApprove
204
+ ? `<button type="button" class="approve-btn" data-artifact="${escAttr(status.id)}">${esc(approveLabel)}</button>`
205
+ : `<span class="meta">not approvable yet — ${status.fileCount} of its expected files resolved</span>`,
206
+ status.status === "approved" ? `<button type="button" class="reopen-btn" data-artifact="${escAttr(status.id)}">Reopen for redesign</button>` : "",
207
+ ]
208
+ .filter(Boolean)
209
+ .join(" ");
210
+ return ` <div class="signature-bar">
211
+ <span class="badge ${cls}">${esc(status.status)}</span>
212
+ <span class="signature-line">${line}</span>
213
+ <code class="signature-id">${esc(status.id)}</code>
214
+ <span class="signature-actions">${buttons}</span>
215
+ </div>`;
216
+ }
217
+
218
+ // --- the change surface: what changed vs. what is still approved -------------
219
+ //
220
+ // ONE renderer for every drifted artifact, shown IN THE SECTION IT BELONGS TO
221
+ // (spec-mirror-drift: each section is its own drift surface) and in the
222
+ // Approvals table. It answers the two questions a red chip can't:
223
+ // 1. WHAT changed — the diff against the SIGNED bytes (B5's anchored diff:
224
+ // the anchor is the commit whose tree hashes to the stored approval hash,
225
+ // never "roughly then"), per-file M/A/D.
226
+ // 2. What is STILL approved — the artifact files byte-identical to what the
227
+ // human signed, listed, so a drifted components registry reads "9 of 11
228
+ // still exactly as signed · 2 changed", not one undifferentiated alarm.
229
+ // The Re-approve button is the same approve-btn wiring every other surface
230
+ // uses — re-approval happens where the drift is read, not on another tab.
231
+
232
+ /**
233
+ * Renders for the two states that have a signature to compare against:
234
+ * `changed-since-approval` (unsanctioned — drift) and `reopened` (sanctioned —
235
+ * redesign). Both answer "what moved since the bytes I signed"; only the tone
236
+ * and the call to action differ. Before 2026-07-28 a reopened row rendered
237
+ * NOTHING here — it showed a hash, a timestamp, and a bare "reopened" badge,
238
+ * which is unreadable unless you already know which files the artifact governs
239
+ * and why its signature was walked back. The gate's drift/redesign asymmetry is
240
+ * about pass/fail; it was never a reason to withhold the explanation.
241
+ *
242
+ * @param {object} status a getApprovalStatuses row (changed-since-approval or reopened)
243
+ * @param {object|null} anchored getApprovalAnchoredDiff result for this artifact
244
+ * @param {{withApprove?: boolean}} [opts] withApprove=false inside the Approvals
245
+ * table, whose rows already carry the button
246
+ */
247
+ export function driftPanelHtml(status, anchored, opts = {}) {
248
+ if (!status) return "";
249
+ const reopened = status.status === "reopened";
250
+ if (status.status !== "changed-since-approval" && !reopened) return "";
251
+ const withApprove = opts.withApprove !== false;
252
+ const signedLine = status.approvedAt ? ` It was signed ${esc(status.approvedAt)}.` : "";
253
+
254
+ let filesHtml = "";
255
+ let diffHtml = "";
256
+ if (anchored && anchored.available) {
257
+ const { changed = [], unchanged = [] } = anchored.files ?? {};
258
+ const total = changed.length + unchanged.length;
259
+ const verb = { M: "changed", A: "added since signing", D: "deleted" };
260
+ const changedItems = changed
261
+ .map((c) => `<li><span class="status-drift">${esc(verb[c.status] ?? c.status)}</span> <code>${esc(c.path)}</code></li>`)
262
+ .join("\n");
263
+ const stillSigned =
264
+ unchanged.length > 0
265
+ ? ` <details class="drift-still-signed"><summary>${unchanged.length} file(s) still exactly as signed</summary>
266
+ <ul>${unchanged.map((f) => `<li><span class="ok-inline">✓</span> <code>${esc(f)}</code></li>`).join("\n")}</ul>
267
+ </details>`
268
+ : "";
269
+ filesHtml = ` <p class="drift-summary">${
270
+ total > 1 ? `<strong>${unchanged.length} of ${total}</strong> file(s) still exactly as signed &middot; <strong>${changed.length}</strong> changed:` : "what changed:"
271
+ }</p>
272
+ <ul class="drift-files">
273
+ ${changedItems}
274
+ </ul>
275
+ ${stillSigned}`;
276
+ diffHtml = ` <details class="drift-diff"><summary>diff against the signed bytes (anchor ${esc(anchored.anchorSha)} &middot; ${esc(
277
+ anchored.anchorWhen || "",
278
+ )}${anchored.truncated ? " &middot; truncated" : ""})</summary>
279
+ <pre class="approval-diff">${esc(anchored.diff)}</pre>
280
+ </details>`;
281
+ } else if (anchored) {
282
+ diffHtml = ` <p class="empty-inline">anchored diff unavailable &mdash; ${esc(anchored.reason)}</p>`;
283
+ }
284
+
285
+ // A reopened row states the three things its bare badge never did: that this
286
+ // was deliberate (and by whom, and why, when the ledger recorded it), that the
287
+ // lane is not failing because of it, and what the human is being asked to do.
288
+ const who = status.via ? ` via ${esc(status.via)}` : "";
289
+ const why = status.reason
290
+ ? ` Reason given: <em>${esc(status.reason)}</em>.`
291
+ : " No reason was recorded — this reopen predates the reason-required rule.";
292
+ const head = reopened
293
+ ? `<p class="drift-head"><strong>Reopened for redesign</strong> &mdash; the signature on <code>${esc(status.id)}</code> was deliberately walked back${
294
+ status.reopenedAt ? ` ${esc(status.reopenedAt)}` : ""
295
+ }${who}.${why} This is sanctioned, not drift: the verify lane skips it rather than failing. Below is what has moved since the bytes you signed. Approve when the rendered result is what you want.</p>`
296
+ : `<p class="drift-head"><strong>Changed since signature</strong> &mdash; <code>${esc(status.id)}</code> no longer matches the bytes the human signed.${signedLine} Review what changed below, then re-approve — or revert the change.</p>`;
297
+
298
+ return ` <div class="drift-panel${reopened ? " drift-panel-reopened" : ""}" data-artifact="${escAttr(status.id)}">
299
+ ${head}
300
+ ${filesHtml}
301
+ ${diffHtml}
302
+ ${withApprove ? `<div class="feature-actions"><button type="button" class="approve-btn" data-artifact="${escAttr(status.id)}">${reopened ? "Approve" : "Re-approve"} ${esc(status.id)}</button></div>` : ""}
303
+ </div>`;
304
+ }
305
+
306
+ // --- §2 mode presentation: per-artifact genesis/steward banners --------------
307
+ //
308
+ // "unreviewed"/"reopened" ⇒ genesis (workbench affordances + a one-line "what
309
+ // shapes this artifact" guide); "approved" ⇒ steward; "approved" + mode
310
+ // "defaults-accepted" ⇒ steward with the unshaped note. No global mode switch
311
+ // — the per-artifact status IS the mode (§2 "Mode presentation").
312
+
313
+ /** The one-line genesis guide, by artifact id pattern — never fabricated for an unknown id. */
314
+ function genesisGuide(id) {
315
+ if (/^intent$/.test(id)) return "the interview that becomes this app's purpose, audience, platforms, and first screens.";
316
+ if (/^design-system$/.test(id)) return "the palette, type, and shape every screen renders in — react in your own words, never hex codes.";
317
+ if (/^architecture$/.test(id)) return "the layer map and structural decisions this harness enforces — approval means you understand and accept this shape.";
318
+ if (/^components$/.test(id)) return "the component vocabulary this app speaks in — shape each one; once approved it's law for every future feature.";
319
+ if (/^exemplar-feature$/.test(id)) return "this app's first real feature — the DNA every future feature is cloned from.";
320
+ if (/^exemplar-spec$/.test(id)) return "the Given/When/Then spec for that first feature's behavior.";
321
+ if (/^feature-spec:/.test(id)) return "a spec conversation in the frozen vocabulary — this feature's behavior, clause by clause.";
322
+ if (/^feature-brief:/.test(id)) return "this feature's decisions and their why — signed before the build, the card's doc-of-record after.";
323
+ return "shapes this artifact.";
324
+ }
325
+
326
+ /**
327
+ * One artifact's genesis/steward banner. `undefined`/unknown `status` values
328
+ * (an older project lib, or "changed-since-approval") render NO banner —
329
+ * §2 defines the mapping only for unreviewed/reopened/approved; drift is a
330
+ * different concern (the hash mismatch already speaks for itself in the row).
331
+ */
332
+ export function artifactBannerHtml(s) {
333
+ if (s.status === "unreviewed" || s.status === "reopened") {
334
+ return `<div class="artifact-banner banner-genesis"><span class="banner-mode">genesis</span> ${esc(genesisGuide(s.id))}</div>`;
335
+ }
336
+ if (s.status === "approved") {
337
+ const unshaped = s.mode === "defaults-accepted";
338
+ return unshaped
339
+ ? `<div class="artifact-banner banner-steward banner-unshaped"><span class="banner-mode">steward</span> approved with defaults — unshaped; a real approval after shaping clears this note.</div>`
340
+ : `<div class="artifact-banner banner-steward"><span class="banner-mode">steward</span> frozen — drift is detected automatically; reopen for a deliberate redesign.</div>`;
341
+ }
342
+ return "";
343
+ }
344
+
345
+ // --- Design language (§3.1) — the designer's handoff spec --------------------
346
+ //
347
+ // What a design team hands engineering: the color token table with live
348
+ // swatches and per-token usage counts from the real tree, the WCAG 2.2
349
+ // contrast matrix computed from the real token values, the spacing scale
350
+ // drawn to scale, radii/elevation sub-tables, and the type ramp (or its
351
+ // honest absence — the catalog carries no typography today). Genesis mode
352
+ // appends the candidates strip. The Components registry is its OWN section
353
+ // now (componentsBodyHtml) — the vocabulary is artifact 3, not a token
354
+ // appendix.
355
+
356
+ const px = (n) => `${Math.round(n * 100) / 100}px`;
357
+
358
+ // Spacing bars are drawn to a fixed scale so widths compare truthfully
359
+ // within and across projects: 4 CSS px per dp.
360
+ const SPACING_BAR_PX_PER_DP = 4;
361
+
362
+ /** "N uses in commonMain" — 0 is stated plainly, never hidden. */
363
+ function usageText(n) {
364
+ return `${n} use${n === 1 ? "" : "s"} in ${COPY.usesIn}`;
365
+ }
366
+
367
+ /**
368
+ * The color token table: swatch · token · value · usage count. The usage
369
+ * column renders only when the scan resolved a declaring object for these
370
+ * tokens (design-language.mjs getTokenUsage); an unavailable scan states the
371
+ * absence under the table instead. `usage` omitted entirely (a caller that
372
+ * doesn't wire the scan) renders neither column nor claim — silence.
373
+ */
374
+ function colorTokenTableHtml(colors, usage) {
375
+ const entries = Object.entries(colors);
376
+ if (entries.length === 0) return ` <p class="empty-inline">no color tokens declared</p>`;
377
+ const counts = usage && usage.available && usage.colors ? usage.colors.counts : null;
378
+ const rows = entries
379
+ .map(([name, hex]) => {
380
+ const usageCell = counts ? `<td class="tok-usage">${esc(usageText(counts[name] ?? 0))}</td>` : "";
381
+ return ` <tr>
382
+ <td class="tok-swatch-cell"><span class="tok-swatch" style="background:${esc(hex)}"></span></td>
383
+ <td>${esc(name)}${commentControlHtml({ type: "design-system", token: name })}</td>
384
+ <td><code>${esc(hex)}</code></td>
385
+ ${usageCell}
386
+ </tr>`;
387
+ })
388
+ .join("\n");
389
+ let absence = "";
390
+ if (usage && !counts) {
391
+ const reason =
392
+ (usage.available === false && usage.reason) ||
393
+ "no source object declaring these tokens was found";
394
+ absence = `\n <p class="empty-inline">usage counts: Not derivable statically &mdash; ${esc(reason)}</p>`;
395
+ }
396
+ return ` <table class="tok-table">
397
+ <thead><tr><th></th><th>Token</th><th>Value</th>${counts ? "<th>Usage</th>" : ""}</tr></thead>
398
+ <tbody>
399
+ ${rows}
400
+ </tbody>
401
+ </table>${absence}`;
402
+ }
403
+
404
+ /**
405
+ * The WCAG 2.2 contrast matrix — pairs derived from the catalog by the
406
+ * On-convention (design-language.mjs deriveContrastPairs), each row a live
407
+ * sample chip in the actual pair colors, the computed ratio, and the
408
+ * normal-text AA/AAA verdicts. Failures use the semantic drift color — the
409
+ * rule is violated HERE, so it is drawn here. Underivable pairs are absent.
410
+ */
411
+ function contrastMatrixHtml(colors) {
412
+ if (Object.keys(colors).length === 0) return ` <p class="empty-inline">no color tokens declared</p>`;
413
+ const pairs = deriveContrastPairs(colors);
414
+ if (pairs.length === 0) {
415
+ return ` <p class="empty-inline">Not derivable statically &mdash; the catalog names no On-convention pairs to check</p>`;
416
+ }
417
+ const rows = pairs
418
+ .map((p) => {
419
+ const ratio = `${Math.round(p.ratio * 100) / 100}:1`;
420
+ const aa = p.aa ? `<span class="wcag-pass">pass</span>` : `<span class="wcag-fail">fail</span>`;
421
+ const aaa = p.aaa ? `<span class="wcag-pass">pass</span>` : `<span class="wcag-fail">fail</span>`;
422
+ return ` <tr>
423
+ <td><span class="contrast-sample" style="background:${esc(p.bgHex)};color:${esc(p.fgHex)}">Aa</span>
424
+ <code>${esc(p.fg)}</code> on <code>${esc(p.bg)}</code></td>
425
+ <td class="tok-usage">${esc(p.role)}</td>
426
+ <td class="contrast-ratio">${esc(ratio)}</td>
427
+ <td>${aa}</td>
428
+ <td>${aaa}</td>
429
+ </tr>`;
430
+ })
431
+ .join("\n");
432
+ return ` <p class="meta">normal text: AA &ge; 4.5:1 &middot; AAA &ge; 7:1 &middot; computed from the token values above</p>
433
+ <table class="tok-table contrast-table">
434
+ <thead><tr><th>Pair</th><th>Role</th><th>Ratio</th><th>AA</th><th>AAA</th></tr></thead>
435
+ <tbody>
436
+ ${rows}
437
+ </tbody>
438
+ </table>`;
439
+ }
440
+
441
+ /** One name·value sub-table (radii, elevation, unclassified dimens). */
442
+ function dimenSubTableHtml(entries) {
443
+ const rows = entries
444
+ .map(
445
+ (d) =>
446
+ ` <tr><td>${esc(d.name)}${commentControlHtml({ type: "design-system", token: d.name })}</td><td><code>${esc(d.value)}</code></td></tr>`,
447
+ )
448
+ .join("\n");
449
+ return ` <table class="tok-table"><tbody>
450
+ ${rows}
451
+ </tbody></table>`;
452
+ }
453
+
454
+ /** The spacing scale drawn to scale: one bar per token, 4px per dp, ascending. */
455
+ function spacingScaleHtml(spacing) {
456
+ const rows = spacing
457
+ .map(
458
+ (d) => ` <div class="scale-row">
459
+ <span class="scale-name">${esc(d.name)}${commentControlHtml({ type: "design-system", token: d.name })}</span>
460
+ <span class="scale-bar" style="width:${px(d.dp * SPACING_BAR_PX_PER_DP)}"></span>
461
+ <span class="scale-value">${esc(d.value)}</span>
462
+ </div>`,
463
+ )
464
+ .join("\n");
465
+ return ` <div class="scale-list">
466
+ ${rows}
467
+ </div>`;
468
+ }
469
+
470
+ /**
471
+ * The Design language section body (§3.1). `meta`:
472
+ * - `usage`: design-language.mjs getTokenUsage() result (per-token counts).
473
+ * - `variants` + `artifactStatus`: the genesis candidates strip — rendered
474
+ * only while the design-system artifact is unreviewed/reopened (§2 of
475
+ * GENESIS-FLOW-DESIGN.md: the per-artifact status IS the mode; an
476
+ * undefined status reads as steward, the safe no-strip default).
477
+ * Never fabricates: an unavailable catalog gets the honest empty state
478
+ * explaining how to produce one; the type ramp states its absence in the
479
+ * standardized form rather than faking a specimen.
480
+ * @param {{available: boolean, source?: "previews"|"live", catalog?: {colors?: object, dimens?: object, typography?: object}}} ds
481
+ * @param {{usage?: object, variants?: object, artifactStatus?: string}} [meta]
482
+ */
483
+ /**
484
+ * The type ramp, shown the way a designer reads one: a specimen line SET in
485
+ * each rung's real size, weight and tracking, with the numbers beside it —
486
+ * not a table of numbers alone. The catalog publishes the ramp as an ordered
487
+ * array (PreviewHarness's `typography` block, lifted from the same data the
488
+ * Typography factory builds its styles from), which is why order is preserved
489
+ * rather than sorted: display → headline → title → body → label IS the ramp.
490
+ *
491
+ * A flat `{name: value}` object is still accepted — an older catalog that
492
+ * exported typography some other way renders as plain rows rather than
493
+ * nothing. A catalog with no typography at all keeps the honest absence line;
494
+ * this never invents a specimen.
495
+ * @param {Array<object>|object|undefined} typography
496
+ */
497
+ function typeRampHtml(typography) {
498
+ const absent = ` <p class="empty-inline">Not derivable statically &mdash; the design-system catalog carries no typography tokens</p>`;
499
+ if (!typography) return absent;
500
+ if (!Array.isArray(typography)) {
501
+ const entries = Object.entries(typography);
502
+ if (!entries.length) return absent;
503
+ return dimenSubTableHtml(entries.map(([name, value]) => ({ name, value: String(value) })));
504
+ }
505
+ if (!typography.length) return absent;
506
+ const px = (v) => {
507
+ const n = Number.parseFloat(String(v));
508
+ return Number.isFinite(n) ? n : null;
509
+ };
510
+ const rows = typography
511
+ .map((spec) => {
512
+ const size = px(spec.size);
513
+ const weight = Number.isFinite(Number(spec.weight)) ? Number(spec.weight) : 400;
514
+ const tracking = spec.tracking == null ? null : px(spec.tracking);
515
+ const style = [
516
+ size == null ? null : `font-size:${size}px`,
517
+ `font-weight:${weight}`,
518
+ px(spec.lineHeight) == null ? null : `line-height:${px(spec.lineHeight)}px`,
519
+ tracking == null ? null : `letter-spacing:${tracking}px`,
520
+ ]
521
+ .filter(Boolean)
522
+ .join(";");
523
+ const numbers = [
524
+ spec.size ? `${spec.size}` : null,
525
+ `w${weight}`,
526
+ spec.lineHeight ? `lh ${spec.lineHeight}` : null,
527
+ spec.tracking == null ? "tracking unset" : `tracking ${spec.tracking}`,
528
+ ]
529
+ .filter(Boolean)
530
+ .join(" &middot; ");
531
+ return ` <tr>
532
+ <td class="ramp-name"><code>${esc(spec.name ?? "—")}</code></td>
533
+ <td class="ramp-specimen"><span style="${style}">Ag</span></td>
534
+ <td class="ramp-numbers">${numbers}</td>
535
+ </tr>`;
536
+ })
537
+ .join("\n");
538
+ return ` <table class="doc-table type-ramp">
539
+ <tbody>
540
+ ${rows}
541
+ </tbody>
542
+ </table>`;
543
+ }
544
+
545
+ export function designLanguageBodyHtml(ds, meta = {}) {
546
+ const genesisMode = meta.artifactStatus === "unreviewed" || meta.artifactStatus === "reopened";
547
+ const candidatesSection = genesisMode
548
+ ? `\n <h3>Design-language candidates</h3>\n${candidatesStripHtml(meta.variants)}`
549
+ : "";
550
+ if (!ds || !ds.available) {
551
+ return `<div class="empty">
552
+ <p>No design-system catalog available yet.</p>
553
+ <p>Produce one by letting the preview gallery render at least once${
554
+ ds && ds.sourcePath ? ` (writes <code>${esc(ds.sourcePath)}</code>)` : ""
555
+ }, or connect a running
556
+ DEBUG build (<code>connect_live</code>) so it can be read live from
557
+ <code>/inspect/design-system</code>.</p>
558
+ </div>${candidatesSection}`;
559
+ }
560
+ const colors = (ds.catalog && ds.catalog.colors) || {};
561
+ const dimens = (ds.catalog && ds.catalog.dimens) || {};
562
+ const typography = ds.catalog && ds.catalog.typography;
563
+ const sourceLabel =
564
+ ds.source === "live" ? "running app (GET /inspect/design-system)" : ds.sourcePath || "the preview render's design-system catalog";
565
+
566
+ const { spacing, radius, elevation, other } = classifyDimens(dimens);
567
+ const dimenSections = [];
568
+ if (Object.keys(dimens).length === 0) {
569
+ dimenSections.push(` <h3>Spacing &amp; shape</h3>\n <p class="empty-inline">no dimens declared</p>`);
570
+ } else {
571
+ if (spacing.length) dimenSections.push(` <h3>Spacing scale</h3>\n${spacingScaleHtml(spacing)}`);
572
+ if (radius.length) dimenSections.push(` <h3>Corner radii</h3>\n${dimenSubTableHtml(radius)}`);
573
+ if (elevation.length) dimenSections.push(` <h3>Elevation</h3>\n${dimenSubTableHtml(elevation)}`);
574
+ if (other.length) dimenSections.push(` <h3>Other dimensions</h3>\n${dimenSubTableHtml(other)}`);
575
+ }
576
+
577
+ const typeRamp = typeRampHtml(typography);
578
+
579
+ return ` <p class="meta">source: ${esc(sourceLabel)}</p>
580
+ <h3>Color tokens</h3>
581
+ ${colorTokenTableHtml(colors, meta.usage)}
582
+ <h3>Contrast &mdash; WCAG 2.2</h3>
583
+ ${contrastMatrixHtml(colors)}
584
+ ${dimenSections.join("\n")}
585
+ <h3>Type ramp</h3>
586
+ ${typeRamp}${candidatesSection}`;
587
+ }
588
+
589
+ /**
590
+ * The genesis candidates strip (§2): each stashed variant's name + its
591
+ * stashed screen renders side by side (served via the existing /previews/
592
+ * static route — a variant's PNGs live under
593
+ * composeApp/build/previews/variants/<name>/<screenId>/screen.png, same
594
+ * layout as a normal render generation, just nested one level deeper), plus
595
+ * a Pick button. No candidates yet is an honest empty state, not an error —
596
+ * the strip is only ever shown in genesis mode (caller's concern), so this
597
+ * function itself doesn't need to know why it's being asked to render.
598
+ */
599
+ function candidatesStripHtml(variants) {
600
+ if (!variants || !variants.available || !variants.variants || variants.variants.length === 0) {
601
+ return `<div class="empty">
602
+ <p>No design-language candidates stashed yet.</p>
603
+ <p>${COPY.tokensEditHint}
604
+ <code>snapshot_variant</code> tool (e.g. <code>{name: "warmer"}</code>) — repeat per idea, then
605
+ compare them here and Pick one.</p>
606
+ </div>`;
607
+ }
608
+ const cards = variants.variants
609
+ .map((v) => {
610
+ const shots = v.screens
611
+ .map(
612
+ (s) => ` <div class="candidate-shot">
613
+ <img alt="${escAttr(v.name)} — ${escAttr(s.id)}" src="/previews/${escAttr(s.png)}">
614
+ <p class="lbl">${esc(s.id)}</p>
615
+ </div>`,
616
+ )
617
+ .join("\n");
618
+ return ` <div class="candidate-card">
619
+ <h4>${esc(v.name)}</h4>
620
+ <div class="candidate-shots">
621
+ ${shots || ' <p class="empty-inline">no screens stashed for this candidate</p>'}
622
+ </div>
623
+ <button type="button" class="pick-btn" data-variant="${escAttr(v.name)}">Pick &ldquo;${esc(v.name)}&rdquo;</button>
624
+ </div>`;
625
+ })
626
+ .join("\n");
627
+ return ` <div class="candidates-strip">
628
+ ${cards}
629
+ </div>
630
+ <div id="pick-error" class="banner" hidden></div>`;
631
+ }
632
+
633
+ function shortDate(iso) {
634
+ return iso ? esc(String(iso)) : "";
635
+ }
636
+
637
+ /**
638
+ * The approval/drift badge for a component card — the WHOLE `components`
639
+ * artifact's live status (one hash covers every file in the registry glob),
640
+ * plus, when the artifact is `changed-since-approval`, THIS card's own
641
+ * mtime-based drift evidence (component-drift.mjs) — "shown ON the affected
642
+ * component cards, not just a banner" (CV-1 W3b). No approval data at all
643
+ * (older scaffold, or approvals unavailable) renders NOTHING — silence, not
644
+ * a fabricated "unreviewed" claim.
645
+ * @param {object|null|undefined} approval the components artifact's status record
646
+ * @param {{available: boolean, byFile?: object}} [drift]
647
+ * @param {string} file this card's own file (for the per-file drift lookup)
648
+ */
649
+ function componentApprovalBadgeHtml(approval, drift, file) {
650
+ if (!approval) return "";
651
+ const s = approval.status;
652
+ if (s === "approved") {
653
+ const unshaped = approval.mode === "defaults-accepted";
654
+ // storedHash — the signature's own hash. Identical to the live recompute for
655
+ // `components` (only feature briefs can carry a legacy basis), but the rule
656
+ // is the same everywhere: what a badge calls "approved" is what was signed.
657
+ return `<span class="badge badge-approved${unshaped ? " badge-unshaped" : ""}" title="components artifact approved at ${shortDate(approval.approvedAt)}">approved &middot; ${shortHash(approval.storedHash ?? approval.hash)}</span>`;
658
+ }
659
+ if (s === "changed-since-approval") {
660
+ const perFile = drift && drift.available ? drift.byFile && drift.byFile[file] : null;
661
+ const fileNote =
662
+ perFile && perFile.modifiedSinceApproval === true
663
+ ? `<span class="badge badge-changed" title="this file's mtime is after the components artifact's approvedAt">likely changed (mtime)</span>`
664
+ : perFile && perFile.modifiedSinceApproval === false
665
+ ? `<span class="badge badge-approved" title="this file's mtime is at/before the components artifact's approvedAt">unchanged since approval (mtime)</span>`
666
+ : "";
667
+ return `<span class="badge badge-changed" title="the components artifact hash no longer matches its stored approval">drift &middot; artifact changed since approval</span>${fileNote}`;
668
+ }
669
+ if (s === "reopened") {
670
+ return `<span class="badge badge-reopened">reopened for redesign</span>`;
671
+ }
672
+ return `<span class="badge badge-unreviewed">not yet approved</span>`;
673
+ }
674
+
675
+ /**
676
+ * The signature as a params TABLE — name / type / default / notes, the form
677
+ * a library reference actually documents an API in. Parameter ORDER is
678
+ * preserved exactly as declared (the Compose guidelines ordering
679
+ * `component-system-deep-dive.md` §2 cites: required -> modifier ->
680
+ * optional -> trailing slot — this table shows what the source declares,
681
+ * never a reordered "ideal"). Notes come from the component's own KDoc
682
+ * `@param` tags (components.mjs parseKdocSections); a parameter without one
683
+ * gets an empty cell, never invented prose. No default value in the source
684
+ * means the parameter is required — stated as such, a derived fact.
685
+ * @param {Array<{raw: string, name: string, type: (string|null), default: (string|null)}>} paramsParsed
686
+ * @param {Record<string, string>} [paramDocs]
687
+ */
688
+ function paramsTableHtml(paramsParsed, paramDocs = {}) {
689
+ if (!paramsParsed || paramsParsed.length === 0) {
690
+ return `<p class="meta">takes no parameters</p>`;
691
+ }
692
+ const rows = paramsParsed
693
+ .map((p) => {
694
+ const type = p.type
695
+ ? `<code>${esc(p.type)}</code>`
696
+ : `<span class="empty-inline">not parsed</span>`;
697
+ const def = p.default
698
+ ? `<code>${esc(p.default)}</code>`
699
+ : `<span class="param-required">required</span>`;
700
+ const note = paramDocs[p.name] ? esc(paramDocs[p.name]) : "";
701
+ return ` <tr><td><code>${esc(p.name)}</code></td><td>${type}</td><td>${def}</td><td class="param-note">${note}</td></tr>`;
702
+ })
703
+ .join("\n");
704
+ return `<table class="params-table">
705
+ <thead><tr><th>Parameter</th><th>Type</th><th>Default</th><th>Notes</th></tr></thead>
706
+ <tbody>
707
+ ${rows}
708
+ </tbody>
709
+ </table>`;
710
+ }
711
+
712
+ /**
713
+ * The "state contract" bullet list — every fact components.mjs's deriveFacts
714
+ * found IN THIS COMPONENT'S OWN BODY, positive-evidence-only (§ anti-slop:
715
+ * evidence-or-silence — a fact not found is simply not listed, never
716
+ * rendered as a "does NOT" claim). Returns "" when nothing was found, so the
717
+ * caller can omit the whole subsection rather than showing an empty header.
718
+ * @param {object} facts components.mjs's per-component `facts`
719
+ * @param {boolean} hasScreenTagParam derived from paramsParsed, not facts
720
+ * (facts only scans the body — the param itself is a signature fact)
721
+ */
722
+ function stateContractHtml(facts, hasScreenTagParam) {
723
+ const items = [];
724
+ if (hasScreenTagParam) {
725
+ items.push(
726
+ facts.derivedTags && facts.derivedTags.length
727
+ ? `owns testTags derived from <code>screenTag</code>: ${facts.derivedTags.map((t) => `<code>&lt;screenTag&gt;_${esc(t)}</code>`).join(", ")}`
728
+ : `takes a required <code>screenTag</code> parameter (tag suffixes not found in this scan)`,
729
+ );
730
+ }
731
+ if (facts.contentUiStateArms && facts.contentUiStateArms.length) {
732
+ items.push(`renders <code>ContentUiState</code> arms: ${facts.contentUiStateArms.map((a) => `<code>${esc(a)}</code>`).join(", ")}`);
733
+ }
734
+ if (facts.a11yFloorEvidence && facts.a11yFloorEvidence.length) {
735
+ items.push(`enforces the 48dp a11y touch-target floor (evidence: ${facts.a11yFloorEvidence.map((e) => `<code>${esc(e)}</code>`).join(", ")})`);
736
+ }
737
+ if (facts.insetsApis && facts.insetsApis.length) {
738
+ items.push(`owns insets: ${facts.insetsApis.map((a) => `<code>${esc(a)}</code>`).join(", ")}`);
739
+ }
740
+ if (facts.tokensReferenced && facts.tokensReferenced.length) {
741
+ items.push(`tokens: ${facts.tokensReferenced.map((t) => `<code>${esc(t)}</code>`).join(", ")}`);
742
+ }
743
+ if (facts.selfReportsDesignToken) {
744
+ items.push(`self-reports resolved values to the inspector (<code>designToken(...)</code>)`);
745
+ }
746
+ if (items.length === 0) return "";
747
+ return `<p class="lbl">state contract</p><ul class="component-facts">${items.map((i) => `<li>${i}</li>`).join("")}</ul>`;
748
+ }
749
+
750
+ /**
751
+ * The component's OWN story render (§3.3) — the top of every entry's visual
752
+ * strip: the `component.<kebab-name>` preview-registry entry rendered by the
753
+ * same pipeline as every screen, labeled with its registry id. Changed
754
+ * attribution keeps the Screens grid's vocabulary: a persistent
755
+ * "changed #N" chip from `changedVersions` (no hover-compare here). A story
756
+ * not in the current render states the absence in the standardized form —
757
+ * never a broken image, never a fabricated thumbnail.
758
+ * @param {string} name the component's composable name (AppHeader, …)
759
+ * @param {Record<string, {id:string,title:string,png:string}>} [componentStories]
760
+ * preview-service.mjs's componentStoryCards(cards), keyed by kebab name
761
+ * @param {number} [version] current render generation (PNG cache-buster)
762
+ * @param {Record<string, number>} [changedVersions] screen/story id -> render # last changed
763
+ */
764
+ function componentStoryHtml(name, componentStories, version, changedVersions) {
765
+ const id = componentStoryId(name);
766
+ const kebab = id.slice("component.".length);
767
+ const card = componentStories ? componentStories[kebab] : undefined;
768
+ if (!card) {
769
+ return ` <div class="component-story">
770
+ <p class="lbl">story render &mdash; <code>${esc(id)}</code></p>
771
+ <p class="empty-inline">no story render yet &mdash; run the preview render to produce <code>${esc(id)}</code></p>
772
+ </div>`;
773
+ }
774
+ const changedIn = changedVersions ? changedVersions[card.id] : undefined;
775
+ const chip = changedIn ? ` <span class="chg">changed #${Number(changedIn)}</span>` : "";
776
+ const buster = version ? `?v=${Number(version)}` : "";
777
+ return ` <div class="component-story">
778
+ <p class="lbl">story render &mdash; <code>${esc(card.id)}</code>${chip}</p>
779
+ <img alt="${escAttr(card.id)} story render" src="/previews/${escAttr(card.png)}${buster}">
780
+ </div>`;
781
+ }
782
+
783
+ // A component's state suffix -> the preview-registry variant suffix(es) that
784
+ // would exercise it live (CV-1 W3b: "@loading/@empty/@error variants ...
785
+ // integrate defensively"). Keyed off derivedTags (the `${screenTag}_xxx` tag
786
+ // suffix the component itself emits) rather than a hardcoded component-name
787
+ // map, so a renamed/added component that emits e.g. `_empty` is picked up
788
+ // the same way EmptyState is, with no per-component special-casing.
789
+ const STATE_TAG_TO_VARIANT = { loading: "loading", empty: "empty", error: "error", retry: "error" };
790
+
791
+ /**
792
+ * Live variant renders for a state-owning component (CV-1 W3b): thumbnails
793
+ * from any CURRENTLY RENDERED `<screen>@<state>` preview-registry entry whose
794
+ * state matches one of this component's own derived tag suffixes. The
795
+ * `@loading/@empty/@error` variants are being registered by a parallel wave
796
+ * (docs/proposals/component-system-deep-dive.md §6.5) — this degrades
797
+ * HONESTLY when none exist yet (never an error, never a fabricated
798
+ * thumbnail), and picks them up automatically once they land (no re-wiring
799
+ * needed here). Components with no state-suffix tags at all (e.g.
800
+ * `ScreenColumn`, `AppHeader`) render nothing — there is no state to show.
801
+ * @param {string[]} derivedTags this component's own `facts.derivedTags`
802
+ * @param {{loading: object[], empty: object[], error: object[]}} [stateVariants]
803
+ * preview-service.mjs's stateVariantCards(cards) — grouped by state suffix
804
+ */
805
+ function liveVariantsHtml(derivedTags, stateVariants) {
806
+ const states = [...new Set((derivedTags || []).map((t) => STATE_TAG_TO_VARIANT[t]).filter(Boolean))].sort();
807
+ if (states.length === 0) return "";
808
+ const sv = stateVariants || {};
809
+ const blocks = states.map((state) => {
810
+ const entries = sv[state] || [];
811
+ if (entries.length === 0) {
812
+ return ` <div class="state-variant-block">
813
+ <p class="lbl">live &#64;${esc(state)} render</p>
814
+ <p class="empty-inline">Not derivable statically &mdash; no <code>@${esc(state)}</code> preview-registry entry has rendered yet</p>
815
+ </div>`;
816
+ }
817
+ const thumbs = entries
818
+ .map(
819
+ (v) => `<div class="state-variant-thumb"><img alt="${escAttr(v.id)}" src="/previews/${escAttr(v.png)}"><p class="lbl">${esc(v.id)}</p></div>`,
820
+ )
821
+ .join("");
822
+ return ` <div class="state-variant-block">
823
+ <p class="lbl">live &#64;${esc(state)} render</p>
824
+ <div class="state-variant-thumbs">${thumbs}</div>
825
+ </div>`;
826
+ });
827
+ return ` <div class="component-live-variants">\n${blocks.join("\n")}\n </div>`;
828
+ }
829
+
830
+ /**
831
+ * The used-in list, screens FIRST — "which screens compose it" is the
832
+ * used-in question a library reference answers before anything else. A
833
+ * screen that ALSO hand-rolls a state this component owns (the ARCH-11
834
+ * mirror, handrolled-state.mjs) gets a violation chip inline at the
835
+ * offending reference — never just a banner.
836
+ * @param {string[]} usedIn full used-in list
837
+ * @param {string[]} usedInScreens the screen subset
838
+ * @param {Map<string, object>} violationsByFile file -> handrolled-state.mjs violation entry
839
+ */
840
+ function usedInHtml(usedIn, usedInScreens, violationsByFile) {
841
+ if (!usedIn || usedIn.length === 0) {
842
+ return `<p class="empty-inline">no call sites found under presentation/**</p>`;
843
+ }
844
+ const screenSet = new Set(usedInScreens || []);
845
+ const ordered = [...usedIn].sort(
846
+ (a, b) => Number(screenSet.has(b)) - Number(screenSet.has(a)) || a.localeCompare(b),
847
+ );
848
+ const items = ordered
849
+ .map((f) => {
850
+ const v = violationsByFile.get(f);
851
+ const chip = v
852
+ ? `<span class="badge badge-changed violation-chip" title="hand-rolls ${esc(v.indicators.map((i) => i.name).join(", "))} directly instead of via the components registry">&#9888; hand-rolled state</span>`
853
+ : "";
854
+ const kind = screenSet.has(f) ? `<span class="badge badge-open">screen</span>` : "";
855
+ return `<li><code>${esc(f)}</code> ${kind}${chip}</li>`;
856
+ })
857
+ .join("");
858
+ return `<ul class="component-used-in">${items}</ul>`;
859
+ }
860
+
861
+ /**
862
+ * The Components section body (§3.3) — the platform engineer's library
863
+ * reference, one document entry per component in library-docs order: the
864
+ * component's own story render first (its `component.<kebab>` registry entry,
865
+ * componentStoryHtml), then any live @state previews matched via the
866
+ * component's own derived tags · the component's own KDoc description verbatim · the
867
+ * signature as a params table (notes from KDoc @param) · the state contract
868
+ * and what the component owns (derived facts, evidence-or-silence) ·
869
+ * used-in, screens first, with hand-rolled-state violation chips at the
870
+ * offending reference. Approval/drift chips per entry. A signature the
871
+ * scanner couldn't parse cleanly shows name + file + "signature not parsed"
872
+ * — never a guessed parameter list.
873
+ * @param {{available: boolean, reason?: string, components?: Array<object>}} [components]
874
+ * @param {{approval?: object|null, drift?: object, violations?: object, stateVariants?: object}} [meta]
875
+ */
876
+ export function componentsBodyHtml(components, meta = {}) {
877
+ if (!components || !components.available) {
878
+ return `<div class="empty">
879
+ <p>No components scan available yet.</p>
880
+ <p>${esc(
881
+ (components && components.reason) ||
882
+ "No presentation/components directory found in this project.",
883
+ )}</p>
884
+ </div>`;
885
+ }
886
+ if (!components.components || components.components.length === 0) {
887
+ return `<div class="empty-inline">${esc(COPY.componentsEmpty)}</div>`;
888
+ }
889
+ const violationsByFile = new Map(
890
+ meta.violations && meta.violations.available ? meta.violations.violations.map((v) => [v.file, v]) : [],
891
+ );
892
+ const entries = components.components
893
+ .map((c) => {
894
+ const head = `<header class="component-head">
895
+ <h3>${esc(c.name)}${commentControlHtml({ type: "design-system", token: `component:${c.name}` })}</h3>
896
+ ${componentApprovalBadgeHtml(meta.approval, meta.drift, c.file)}
897
+ </header>
898
+ <p class="meta component-file"><code>${esc(c.file)}</code></p>`;
899
+ // The story render is scan-independent evidence (it comes from the
900
+ // render pipeline, not the signature parser), so it shows on
901
+ // parse-error entries too.
902
+ const storyHtml = componentStoryHtml(c.name, meta.componentStories, meta.version, meta.changedVersions);
903
+ if (c.parseError) {
904
+ return ` <article class="component-entry">
905
+ ${head}
906
+ ${storyHtml}
907
+ <p class="unresolvable-note">signature not parsed &mdash; showing name and file only</p>
908
+ </article>`;
909
+ }
910
+ const paramsParsed = c.paramsParsed || [];
911
+ const facts = c.facts || {};
912
+ const hasScreenTagParam = paramsParsed.some((p) => p.name === "screenTag");
913
+ // The description part of the component's own doc comment, verbatim
914
+ // (@param tags render in the table above, not re-quoted here). Older
915
+ // scan data without kdocDescription falls back to the full kdoc.
916
+ const doc = c.kdocDescription ?? c.kdoc;
917
+ const kdocHtml = doc
918
+ ? `<p class="lbl">usage notes &mdash; from the component's own doc comment</p><blockquote class="component-kdoc">${esc(doc)}</blockquote>`
919
+ : "";
920
+ // Entry order is the Storybook/Material-docs reading order: visual
921
+ // states first, then the component's own words, then the API table,
922
+ // then the derived contract and call sites.
923
+ return ` <article class="component-entry">
924
+ ${head}
925
+ ${storyHtml}
926
+ ${liveVariantsHtml(facts.derivedTags, meta.stateVariants)}
927
+ ${kdocHtml}
928
+ <p class="lbl">signature</p>
929
+ ${paramsTableHtml(paramsParsed, c.paramDocs || {})}
930
+ ${stateContractHtml(facts, hasScreenTagParam)}
931
+ <p class="lbl">used in</p>${usedInHtml(c.usedIn, c.usedInScreens, violationsByFile)}
932
+ </article>`;
933
+ })
934
+ .join("\n");
935
+ return `<div class="component-list">
936
+ ${entries}
937
+ </div>
938
+ ${promotionQueueHtml(components.ungoverned)}`;
939
+ }
940
+
941
+ /**
942
+ * The PROMOTION QUEUE — the drift half of the Components section: composables your
943
+ * screens define OUTSIDE the registry, each with signals only (cross-feature use,
944
+ * composes-registry hint). Deliberately verdict-free: promote-vs-keep-local is the
945
+ * agent's rubric call (ARCHITECTURE.md §7, the five questions), ratified at the
946
+ * Components approval — this surface exists so nothing stays invisible to that call,
947
+ * and so the human reviewing the approval can challenge a call without re-deriving
948
+ * the inventory.
949
+ */
950
+ export function promotionQueueHtml(ungoverned) {
951
+ if (!Array.isArray(ungoverned)) return "";
952
+ if (ungoverned.length === 0) {
953
+ return `<section class="promotion-queue">
954
+ <h3>In your screens, not in the registry</h3>
955
+ <p class="meta">none &mdash; every screen composable is either governed or a *Screen/*Route seam wrapper.</p>
956
+ </section>`;
957
+ }
958
+ const rows = ungoverned
959
+ .map(
960
+ (u) => ` <tr>
961
+ <td><code>${esc(u.name)}</code></td>
962
+ <td><code>${esc(u.file)}</code></td>
963
+ <td>${esc(u.feature)}</td>
964
+ <td>${u.crossFeatureUseCount > 0 ? `<strong>${u.crossFeatureUseCount}</strong>` : "0"}</td>
965
+ <td>${u.composesRegistry ? "composes registry components" : "self-contained"}</td>
966
+ </tr>`,
967
+ )
968
+ .join("\n");
969
+ return `<section class="promotion-queue">
970
+ <h3>In your screens, not in the registry &mdash; the promotion queue</h3>
971
+ <p class="meta">Ungoverned composables (seam wrappers excluded). Signals only &mdash; whether each
972
+ is promoted, kept local, or generalized is a judgment call made against the inclusion rubric
973
+ (ARCHITECTURE.md &sect;7) and ratified at the Components approval. Cross-feature use is a signal,
974
+ never the rule.</p>
975
+ <table class="params-table">
976
+ <thead><tr><th>composable</th><th>file</th><th>feature</th><th>cross-feature uses</th><th>composition</th></tr></thead>
977
+ <tbody>
978
+ ${rows}
979
+ </tbody>
980
+ </table>
981
+ </section>`;
982
+ }
983
+
984
+ /**
985
+ * The Approvals tab: every governed artifact, its §1 order number, live status,
986
+ * file count, hash/approvedAt, and an Approve button (disabled + marked
987
+ * "unresolvable" when `resolvable === false`, mirroring qa/approve.mjs --status).
988
+ * @param {{available: boolean, error?: string, statuses?: object[]}} approvals
989
+ */
990
+ export function approvalsTabHtml(approvals, meta = {}) {
991
+ if (!approvals || !approvals.available) {
992
+ const detail = approvals && approvals.error
993
+ ? esc(approvals.error)
994
+ : "This looks like an older scaffold that predates the approvals wave (no qa/lib/approvals.mjs).";
995
+ return `<div class="empty">
996
+ <p>Approvals are not available in this project.</p>
997
+ <p>${detail}</p>
998
+ </div>`;
999
+ }
1000
+ if (!approvals.statuses || approvals.statuses.length === 0) {
1001
+ return `<div class="empty"><p>No governed artifacts resolved in this project yet.</p></div>`;
1002
+ }
1003
+ const rows = approvals.statuses
1004
+ .map((s) => {
1005
+ // `reopened` (§2 "Reopen for redesign") is a DELIBERATE state, visually
1006
+ // distinct from unreviewed — never collapsed into badge-unreviewed even
1007
+ // though the gate treats them the same (SKIP, non-blocking). An older
1008
+ // project lib that predates reopen simply never reports this status, so
1009
+ // this branch is dead code there — no fabrication either way.
1010
+ const unshaped = s.status === "approved" && s.mode === "defaults-accepted";
1011
+ const badgeClass = [
1012
+ s.status === "approved"
1013
+ ? "badge-approved"
1014
+ : s.status === "changed-since-approval"
1015
+ ? "badge-changed"
1016
+ : s.status === "reopened"
1017
+ ? "badge-reopened"
1018
+ : "badge-unreviewed",
1019
+ unshaped ? "badge-unshaped" : "",
1020
+ ]
1021
+ .filter(Boolean)
1022
+ .join(" ");
1023
+ // §2 express lane: "approved · defaults accepted — unshaped" is the
1024
+ // human-facing label for status approved + mode defaults-accepted —
1025
+ // distinct wording from a shaped approval, per §2's "visually distinct
1026
+ // from a shaped approval". A real approval later clears `mode`, which
1027
+ // falls straight back to the plain "approved" label below.
1028
+ const statusLabel = unshaped ? "approved · defaults accepted — unshaped" : s.status;
1029
+ // `resolvable` may be `undefined` against an older project-side approvals.mjs
1030
+ // that predates the field (a stale pre-approvals-refinement scaffold) — treat
1031
+ // that the same as "resolvable" (never FABRICATE an "unresolvable" claim the
1032
+ // library didn't make); only an explicit `false` counts as unresolvable, same
1033
+ // check the disabled-button / unresolvableNote logic below already uses.
1034
+ const unresolvable = s.resolvable === false;
1035
+ const hashInfo =
1036
+ s.status === "changed-since-approval"
1037
+ ? unresolvable
1038
+ ? `approved ${shortHash(s.storedHash)} &rarr; unresolvable`
1039
+ : `approved ${shortHash(s.storedHash)} &rarr; now ${shortHash(s.hash)}`
1040
+ : s.status === "approved"
1041
+ ? // The signature's own hash (storedHash), never the live recompute:
1042
+ // on a legacy raw-bytes row they differ, and only one of them was
1043
+ // ever signed. See resolveArtifactStatus's `hashBasis`.
1044
+ `${shortHash(s.storedHash ?? s.hash)}${
1045
+ s.hashBasis === "raw-bytes" ? " · signed pre-strip, bytes unchanged" : ""
1046
+ }`
1047
+ : unresolvable
1048
+ ? "unresolvable"
1049
+ : `would approve at ${shortHash(s.hash)}`;
1050
+ const unresolvableNote =
1051
+ s.resolvable === false
1052
+ ? `<p class="unresolvable-note">unresolvable (${s.fileCount} of expected files resolved) — not approvable</p>`
1053
+ : "";
1054
+ const missingNote =
1055
+ s.missing && s.missing.length > 0 ? `<p class="missing-note">missing: ${esc(s.missing.join(", "))}</p>` : "";
1056
+ // A reopened row answers the reader's first question — why? — from the
1057
+ // ledger row itself (07-28 audit: attribution + reason recorded on reopen).
1058
+ const reopenNote =
1059
+ s.status === "reopened"
1060
+ ? `<p class="reopen-note">reopened${s.reopenedAt ? ` ${esc(s.reopenedAt)}` : ""}${s.via ? ` via ${esc(s.via)}` : ""}${s.reason ? ` — ${esc(s.reason)}` : ""}</p>`
1061
+ : "";
1062
+ const btnLabel = s.status === "approved" ? "Re-approve" : "Approve";
1063
+ // §2/§3 Reopen control: beside Re-approve on approved rows only — reopening
1064
+ // the unreviewed/reopened/changed-since-approval is meaningless (the
1065
+ // library refuses it too; the button just never offers it here). Always
1066
+ // rendered when approved (never conditioned on lib support the tab data
1067
+ // can't see) — the honest degrade lives in the bridge/endpoint: an older
1068
+ // lib without reopenArtifact surfaces its refusal in #approve-error on
1069
+ // click, never a crash (GENESIS-FLOW-DESIGN.md §3 "honest degrade").
1070
+ const reopenBtn =
1071
+ s.status === "approved"
1072
+ ? `<button class="reopen-btn" data-artifact="${esc(s.id)}">Reopen</button>`
1073
+ : "";
1074
+ // B5 — approval-anchored diff: on a changed-since-approval row, the drift
1075
+ // is shown AGAINST THE APPROVED BYTES (anchor located by hash match in
1076
+ // git history, hashed with the project's own library). When the anchor
1077
+ // can't be found, the honest reason renders instead — the chip alone
1078
+ // said "something changed"; this says WHAT, or exactly why it can't.
1079
+ const anchored = meta.anchoredDiffs ? meta.anchoredDiffs[s.id] : null;
1080
+ // The SAME change-surface panel every section shows (driftPanelHtml) —
1081
+ // what changed, what is still exactly as signed, the anchored diff. The
1082
+ // row already carries the Approve button, so the panel omits its own.
1083
+ const diffRow =
1084
+ s.status === "changed-since-approval" || s.status === "reopened"
1085
+ ? ` <tr class="approval-diff-row"><td colspan="6">
1086
+ ${driftPanelHtml(s, anchored, { withApprove: false })}
1087
+ </td></tr>`
1088
+ : "";
1089
+ return ` <tr class="approval-row" data-artifact="${esc(s.id)}">
1090
+ <td class="order-num">${orderNumber(s.id)}</td>
1091
+ <td>${esc(s.label)}<div class="artifact-id">${esc(s.id)}</div>${artifactBannerHtml(s)}</td>
1092
+ <td><span class="badge ${badgeClass}">${esc(statusLabel)}</span></td>
1093
+ <td>${s.fileCount}</td>
1094
+ <td>${hashInfo}${s.approvedAt ? `<div class="approved-at">${esc(s.approvedAt)}</div>` : ""}${unresolvableNote}${missingNote}${reopenNote}</td>
1095
+ <td><button class="approve-btn" data-artifact="${esc(s.id)}"${s.resolvable === false ? " disabled" : ""}>${btnLabel}</button> ${reopenBtn}</td>
1096
+ </tr>
1097
+ ${diffRow}`;
1098
+ })
1099
+ .join("\n");
1100
+ return ` <table class="approvals-table">
1101
+ <thead><tr><th>#</th><th>Artifact</th><th>Status</th><th>Files</th><th>Hash / approved</th><th></th></tr></thead>
1102
+ <tbody>
1103
+ ${rows}
1104
+ </tbody>
1105
+ </table>
1106
+ <div id="approve-error" class="banner sig-error" hidden></div>`;
1107
+ }
1108
+
1109
+ // --- Specs (§3.5) — the QA lead's traceability matrix ------------------------
1110
+ //
1111
+ // One RTM per spec file: clause ↔ prose ↔ citing test(s) ↔ gate ↔ last-receipt
1112
+ // verdict, coverage counts stated at the top, withdrawn clauses struck-through
1113
+ // and kept, orphans in BOTH directions surfaced as the defects they are. No
1114
+ // prose padding — the matrix is the artifact.
1115
+
1116
+ /**
1117
+ * Which verify-lane gate enforces this clause's row — only mappings the lane
1118
+ * itself states, never a guess:
1119
+ * - ARCH-*: the `conformance` step (*ArchitectureConformanceTest — template/
1120
+ * qa/verify.mjs names it as enforcing "specs/app-base.spec.md ARCH clauses").
1121
+ * - SHELL-04: the `a11y` step (its failure message names SHELL-04 explicitly).
1122
+ * - every other live clause: the `specCoverage` step — the bidirectional
1123
+ * citation gate is exactly what this row's clause↔test link claims.
1124
+ * - withdrawn: no gate (coverage-exempt, mirroring stepSpecCoverage).
1125
+ * @returns {string|null} the receipt step name, or null for withdrawn clauses
1126
+ */
1127
+ function gateForClause(c) {
1128
+ if (c.withdrawn) return null;
1129
+ if (/^ARCH-/i.test(c.id)) return "conformance";
1130
+ if (c.id === "SHELL-04") return "a11y";
1131
+ return "specCoverage";
1132
+ }
1133
+
1134
+ /** The citing-tests cell: real file:line list, or the honest defect/exemption. */
1135
+ function citingTestsCellHtml(c) {
1136
+ if (c.withdrawn) return `<span class="empty-inline">withdrawn &mdash; citation-exempt</span>`;
1137
+ if (c.citedBy && c.citedBy.length) {
1138
+ const items = c.citedBy
1139
+ .map((s) => `<li><code>${esc(s.file)}:${s.line}</code></li>`)
1140
+ .join("");
1141
+ return `<ul class="rtm-tests">${items}</ul>`;
1142
+ }
1143
+ if (c.cited) {
1144
+ // Older data with a `cited` flag but no citedBy sites (a caller that
1145
+ // didn't run the indexed scan): the coverage claim is real, the sites
1146
+ // aren't known here.
1147
+ return `<span class="empty-inline">covered &mdash; citing tests not indexed</span>`;
1148
+ }
1149
+ return `<span class="rtm-defect">defect &mdash; no citing test</span>`;
1150
+ }
1151
+
1152
+ /**
1153
+ * The Specs section body (§3.5). `meta.lastReceipt` (receipt-bridge.mjs's
1154
+ * getLastReceipt() result) drives the per-clause last-receipt column via each
1155
+ * row's own gate; omitted, every row shows the honest "no receipt yet".
1156
+ * `specs.orphanCitations` (specs.mjs) renders the reverse-direction defects;
1157
+ * when the field is absent (older data), the block is silent rather than
1158
+ * claiming a clean scan that never ran.
1159
+ * @param {{available: boolean, files?: Array<{file: string, clauses: object[]}>, orphanCitations?: object[]}} specs
1160
+ * @param {{lastReceipt?: object|null, artifactByFile?: Record<string, object>}} [meta]
1161
+ */
1162
+ export function specsTabHtml(specs, meta = {}) {
1163
+ if (!specs || !specs.available) {
1164
+ const where = (specs && specs.specsDir) || "specs/";
1165
+ const why = specs && specs.reason ? ` ${esc(specs.reason)}` : "";
1166
+ return `<div class="empty"><p>No ${esc(where)} directory found in this project.${why}</p></div>`;
1167
+ }
1168
+ const matrices = specs.files
1169
+ .map((f) => {
1170
+ const live = f.clauses.filter((c) => !c.withdrawn);
1171
+ const covered = live.filter((c) => c.cited).length;
1172
+ const withdrawn = f.clauses.length - live.length;
1173
+ const uncovered = live.length - covered;
1174
+ const counts = [
1175
+ `${f.clauses.length} clause${f.clauses.length === 1 ? "" : "s"}`,
1176
+ `${covered} covered`,
1177
+ `${withdrawn} withdrawn`,
1178
+ uncovered ? `<span class="rtm-defect">${uncovered} uncovered</span>` : null,
1179
+ ]
1180
+ .filter(Boolean)
1181
+ .join(" &middot; ");
1182
+ const rows = f.clauses
1183
+ .map((c) => {
1184
+ const prose = esc(c.prose);
1185
+ const gate = gateForClause(c);
1186
+ return ` <tr class="rtm-row${c.withdrawn ? " rtm-withdrawn" : ""}">
1187
+ <td><span class="clause-id"><code>${esc(c.id)}</code></span>${commentControlHtml({ type: "spec-line", file: f.relPath || `specs/${f.file}`, clauseId: c.id })}</td>
1188
+ <td class="rtm-prose">${c.withdrawn ? `<s>${prose}</s>` : prose}</td>
1189
+ <td>${citingTestsCellHtml(c)}</td>
1190
+ <td class="rtm-gate">${gate ? `<code>${esc(gate)}</code>` : `<span class="empty-inline">&mdash;</span>`}</td>
1191
+ <td>${gate ? stepReceiptCellHtml(meta.lastReceipt, gate) : `<span class="empty-inline">&mdash;</span>`}</td>
1192
+ </tr>`;
1193
+ })
1194
+ .join("\n");
1195
+ // Sign where you read: this file's governing artifact's signature bar
1196
+ // sits directly under its heading. `artifactByFile` is built from the
1197
+ // project's OWN registry (id + files), never guessed from the filename —
1198
+ // so app-base maps to `architecture`, the CONFIGURED exemplar's spec to
1199
+ // `exemplar-spec`, and every other file to its `feature-spec:<name>`.
1200
+ const specRel = f.relPath || `specs/${f.file}`;
1201
+ const specStatus = meta.artifactByFile ? meta.artifactByFile[specRel] : null;
1202
+ return ` <div class="spec-file">
1203
+ <h3>${esc(specRel)}</h3>
1204
+ ${signatureBarHtml(specStatus, { what: "this contract" })}
1205
+ <p class="rtm-counts">${counts}</p>
1206
+ ${
1207
+ rows
1208
+ ? `<table class="doc-table rtm-table">
1209
+ <thead><tr><th>Clause</th><th>Requirement</th><th>Citing tests</th><th>Gate</th><th>Last receipt</th></tr></thead>
1210
+ <tbody>
1211
+ ${rows}
1212
+ </tbody>
1213
+ </table>`
1214
+ : `<p class="empty-inline">no clauses parsed</p>`
1215
+ }
1216
+ </div>`;
1217
+ })
1218
+ .join("\n");
1219
+
1220
+ // Reverse-direction orphans: a `SPEC:` tag pointing at a withdrawn or
1221
+ // nonexistent clause. `undefined` means the indexed scan didn't run
1222
+ // (older caller data) — silence, never a fabricated "clean".
1223
+ let orphansHtml = "";
1224
+ if (Array.isArray(specs.orphanCitations)) {
1225
+ orphansHtml = specs.orphanCitations.length
1226
+ ? ` <div class="rtm-orphans">
1227
+ <h3>Citation defects</h3>
1228
+ <ul class="rtm-defect-list">
1229
+ ${specs.orphanCitations
1230
+ .map(
1231
+ (o) => ` <li class="rtm-defect-item">
1232
+ <code>${esc(o.file)}:${o.line}</code> cites <code>${esc(o.id)}</code>
1233
+ <span class="badge badge-changed">${esc(o.reason)}</span>
1234
+ </li>`,
1235
+ )
1236
+ .join("\n")}
1237
+ </ul>
1238
+ </div>`
1239
+ : ` <p class="empty-inline">no citation defects &mdash; every <code>SPEC:</code> tag cites a live clause</p>`;
1240
+ }
1241
+ return `${matrices}
1242
+ ${orphansHtml}`;
1243
+ }
1244
+
1245
+ // --- Architecture tab (§7.1, rebuilt to the AD-1 three-in-one standard) -----
1246
+ //
1247
+ // Mirrors template/docs/ARCHITECTURE.md's own section shape (authored form):
1248
+ // purpose & quality goals, system context, platform & deployment view,
1249
+ // building blocks (layer map + observed dependency arrows + the governed
1250
+ // contract), runtime view, crosscutting policies, decisions, and the exemplar
1251
+ // feature shape. Every fact is either a real tree walk (layer map, dependency
1252
+ // graph) or a structural parse of the doc itself (tables verbatim, prose
1253
+ // rendered through the small markdown-to-html helpers below) — never
1254
+ // paraphrased, never fabricated. A missing source degrades to an honest
1255
+ // inline note, section by section, so one missing table never hides the rest.
1256
+
1257
+ /** Escape, then apply the doc's own inline markdown (bold/code/links) — safe because escaping runs first. */
1258
+ function inlineMdHtml(text) {
1259
+ let s = esc(String(text));
1260
+ s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
1261
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
1262
+ // Markdown links -> plain text: the console has no route for docs/adr/* files,
1263
+ // so a live link would be dead weight (or worse, a broken href in the console).
1264
+ s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
1265
+ return s;
1266
+ }
1267
+
1268
+ /** A GFM table (already parsed by architecture.mjs) as an HTML table — inline markdown per cell. */
1269
+ function mdTableHtml(table) {
1270
+ if (!table || !table.available) {
1271
+ return `<p class="empty-inline">${esc((table && table.reason) || "not available")}</p>`;
1272
+ }
1273
+ const head = `<tr>${table.headers.map((h) => `<th>${inlineMdHtml(h)}</th>`).join("")}</tr>`;
1274
+ const body = table.rows
1275
+ .map((r) => `<tr>${r.map((c) => `<td>${inlineMdHtml(c)}</td>`).join("")}</tr>`)
1276
+ .join("\n");
1277
+ return `<table class="doc-table"><thead>${head}</thead><tbody>\n${body}\n</tbody></table>`;
1278
+ }
1279
+
1280
+ /**
1281
+ * A small, deliberately non-exhaustive markdown block renderer for
1282
+ * doc-section prose (runtime view, crosscutting policies) — paragraphs,
1283
+ * `### ` subheadings, bullet/numbered lists (with indented continuation
1284
+ * lines folded into the current item), fenced code blocks, and blockquotes.
1285
+ * Not a full CommonMark implementation (no nested lists, no tables — those
1286
+ * are parsed structurally by architecture.mjs instead); good enough to
1287
+ * render this project's own doc faithfully without pulling in a markdown
1288
+ * dependency. Every text span still runs through inlineMdHtml, so escaping
1289
+ * is never skipped.
1290
+ */
1291
+ function mdProseHtml(md) {
1292
+ if (!md || !md.trim()) return "";
1293
+ const lines = md.split("\n");
1294
+ const out = [];
1295
+ let i = 0;
1296
+ let para = [];
1297
+ const flushPara = () => {
1298
+ if (para.length) {
1299
+ out.push(`<p>${inlineMdHtml(para.join(" "))}</p>`);
1300
+ para = [];
1301
+ }
1302
+ };
1303
+ const consumeList = (marker) => {
1304
+ const items = [];
1305
+ while (i < lines.length) {
1306
+ const raw = lines[i];
1307
+ const t = raw.trim();
1308
+ if (marker === "ul" ? /^[-*]\s+/.test(t) : /^\d+\.\s+/.test(t)) {
1309
+ items.push(t.replace(marker === "ul" ? /^[-*]\s+/ : /^\d+\.\s+/, ""));
1310
+ i++;
1311
+ } else if (t !== "" && /^\s/.test(raw) && items.length) {
1312
+ items[items.length - 1] += ` ${t}`;
1313
+ i++;
1314
+ } else {
1315
+ break;
1316
+ }
1317
+ }
1318
+ return items;
1319
+ };
1320
+ while (i < lines.length) {
1321
+ const line = lines[i];
1322
+ const t = line.trim();
1323
+ if (t === "") {
1324
+ flushPara();
1325
+ i++;
1326
+ continue;
1327
+ }
1328
+ if (t.startsWith("```")) {
1329
+ flushPara();
1330
+ i++;
1331
+ const code = [];
1332
+ while (i < lines.length && !lines[i].trim().startsWith("```")) {
1333
+ code.push(lines[i]);
1334
+ i++;
1335
+ }
1336
+ i++; // closing fence
1337
+ out.push(`<pre class="doc-code">${esc(code.join("\n"))}</pre>`);
1338
+ continue;
1339
+ }
1340
+ if (/^####?\s+/.test(t)) {
1341
+ flushPara();
1342
+ const level = t.startsWith("####") ? 5 : 4;
1343
+ out.push(`<h${level}>${inlineMdHtml(t.replace(/^####?\s+/, ""))}</h${level}>`);
1344
+ i++;
1345
+ continue;
1346
+ }
1347
+ if (/^[-*]\s+/.test(t)) {
1348
+ flushPara();
1349
+ const items = consumeList("ul");
1350
+ out.push(`<ul class="doc-list">${items.map((it) => `<li>${inlineMdHtml(it)}</li>`).join("")}</ul>`);
1351
+ continue;
1352
+ }
1353
+ if (/^\d+\.\s+/.test(t)) {
1354
+ flushPara();
1355
+ const items = consumeList("ol");
1356
+ out.push(`<ol class="doc-list">${items.map((it) => `<li>${inlineMdHtml(it)}</li>`).join("")}</ol>`);
1357
+ continue;
1358
+ }
1359
+ if (t.startsWith(">")) {
1360
+ flushPara();
1361
+ const quote = [];
1362
+ while (i < lines.length && lines[i].trim().startsWith(">")) {
1363
+ quote.push(lines[i].trim().replace(/^>\s?/, ""));
1364
+ i++;
1365
+ }
1366
+ out.push(`<blockquote class="doc-quote">${inlineMdHtml(quote.join(" "))}</blockquote>`);
1367
+ continue;
1368
+ }
1369
+ para.push(t);
1370
+ i++;
1371
+ }
1372
+ flushPara();
1373
+ return out.join("\n");
1374
+ }
1375
+
1376
+ function docSectionProseHtml(section) {
1377
+ if (!section || !section.available) {
1378
+ return `<p class="empty-inline">${esc((section && section.reason) || "docs/ARCHITECTURE.md not found")}</p>`;
1379
+ }
1380
+ return `<div class="doc-prose">${mdProseHtml(section.body)}</div>`;
1381
+ }
1382
+
1383
+ /**
1384
+ * C4 level 1 as clean CSS boxes (§3.2), derived from the doc's OWN
1385
+ * integration table: the app in the center, one box per integration row
1386
+ * (first cell = the name, second = the doc's own "what" text), connected by
1387
+ * plain stems. No direction is drawn on the connectors — the table states
1388
+ * WHAT each integration is, not who calls whom, and a fabricated arrowhead
1389
+ * would be a claim the source doesn't make. The table itself stays below as
1390
+ * the detailed record; with no table, there is no diagram (the brief's "don't
1391
+ * force a diagram from thin data").
1392
+ */
1393
+ function contextDiagramHtml(table) {
1394
+ if (!table || !Array.isArray(table.rows) || table.rows.length === 0) return "";
1395
+ const nodes = table.rows
1396
+ .filter((r) => r[0])
1397
+ .map(
1398
+ (r) => ` <div class="ctx-node">
1399
+ <h5>${inlineMdHtml(r[0])}</h5>
1400
+ ${r[1] ? `<p>${inlineMdHtml(r[1])}</p>` : ""}
1401
+ </div>`,
1402
+ )
1403
+ .join("\n");
1404
+ if (!nodes) return "";
1405
+ return ` <div class="ctx-diagram">
1406
+ <div class="ctx-app">This app</div>
1407
+ <div class="ctx-nodes">
1408
+ ${nodes}
1409
+ </div>
1410
+ </div>`;
1411
+ }
1412
+
1413
+ function systemContextHtml(sc) {
1414
+ if (!sc || !sc.available) {
1415
+ return `<p class="empty-inline">${esc((sc && sc.reason) || "docs/ARCHITECTURE.md not found")}</p>`;
1416
+ }
1417
+ const intro = sc.intro ? `<p>${inlineMdHtml(sc.intro)}</p>` : "";
1418
+ const diagram = sc.table ? contextDiagramHtml(sc.table) : "";
1419
+ const table = sc.table
1420
+ ? mdTableHtml({ available: true, headers: sc.table.headers, rows: sc.table.rows })
1421
+ : `<p class="empty-inline">no integration table found under "${esc(sc.heading)}"</p>`;
1422
+ return `${intro}${diagram}${table}`;
1423
+ }
1424
+
1425
+ /**
1426
+ * §2 as spec + mirror at once. The authored bullets are the constraint; the
1427
+ * version-set table under them is the SAME set read live from
1428
+ * `gradle/libs.versions.toml`, with the version §2's prose claims beside it.
1429
+ * A row where those two disagree is drift the doc cannot see about itself —
1430
+ * §2 tags its own version rule `[advisory — no version-drift gate ships
1431
+ * yet]`, so this table is the eyes that rule does not have. The KSP
1432
+ * `<kotlin>-<ksp>` invariant §2 states in bold is checked against the live
1433
+ * values, never against the prose.
1434
+ * @param {object} versionSet architecture.mjs's doc.versionSet
1435
+ */
1436
+ function versionSetHtml(versionSet) {
1437
+ if (!versionSet || !versionSet.available) {
1438
+ return `<p class="empty-inline">${esc((versionSet && versionSet.reason) || COPY.versionSetUnreadable)}</p>`;
1439
+ }
1440
+ const badge = (status) => {
1441
+ if (status === "match") return `<span class="glyph glyph-signed">&#10003;</span> pinned as documented`;
1442
+ if (status === "drift") return `<span class="glyph glyph-drift">&#9888;</span> drift — the doc says otherwise`;
1443
+ if (status === "undocumented") return `<span class="glyph glyph-unsigned">&#9675;</span> not named in §2`;
1444
+ return `<span class="glyph glyph-drift">&#9888;</span> missing from the catalog`;
1445
+ };
1446
+ const rows = versionSet.rows
1447
+ .map(
1448
+ (r) => ` <tr>
1449
+ <td>${esc(r.library)}</td>
1450
+ <td><code>${esc(r.catalogVersion ?? "—")}</code></td>
1451
+ <td><code>${esc(r.docVersion ?? "—")}</code></td>
1452
+ <td>${badge(r.status)}</td>
1453
+ </tr>`,
1454
+ )
1455
+ .join("\n");
1456
+ const inv = versionSet.kspInvariant;
1457
+ const invLine = inv.available
1458
+ ? inv.ok
1459
+ ? `<p class="status-line"><span class="glyph glyph-signed">&#10003;</span> ${COPY.kspPairLabel} — <code>${esc(inv.ksp)}</code> ${COPY.kspCarriesLabel} <code>${esc(inv.kotlin)}</code>.</p>`
1460
+ : `<p class="status-line"><span class="glyph glyph-drift">&#9888;</span> ${COPY.kspName} <code>${esc(inv.ksp)}</code> ${COPY.kspPrefixWarning} <code>${esc(inv.kotlin)}</code>${COPY.kspCarriesNote}</p>`
1461
+ : `<p class="empty-inline">${esc(inv.reason)}</p>`;
1462
+ return ` <h4>The frozen set, as pinned</h4>
1463
+ <table class="doc-table">
1464
+ <thead><tr><th>Library</th><th>${esc(COPY.versionSetFile)}</th><th>§2 says</th><th>Verdict</th></tr></thead>
1465
+ <tbody>
1466
+ ${rows}
1467
+ </tbody>
1468
+ </table>
1469
+ ${invLine}`;
1470
+ }
1471
+
1472
+ function platformViewHtml(pv) {
1473
+ if (!pv || !pv.available) {
1474
+ return `<p class="empty-inline">${esc((pv && pv.reason) || "docs/ARCHITECTURE.md not found")}</p>`;
1475
+ }
1476
+ const main = mdTableHtml({ available: true, headers: pv.headers, rows: pv.rows });
1477
+ const expectActual = pv.expectActual
1478
+ ? `<h4>Expect/actual boundary</h4>${mdTableHtml({ available: true, headers: pv.expectActual.headers, rows: pv.expectActual.rows })}`
1479
+ : "";
1480
+ return `${main}${expectActual}`;
1481
+ }
1482
+
1483
+ /**
1484
+ * The dependency graph (drift surface): every observed cross-layer edge, and
1485
+ * — where the governed contract's own clauses resolved forbidden edges (see
1486
+ * architecture.mjs's deriveLayerRules) — a violation badge on the offending
1487
+ * edge PLUS a file:line list underneath, exactly where a lead architect would
1488
+ * mark it up. `rulesApplied:false` (no governed contract to check against) is
1489
+ * shown as "unchecked", never silently reported as "clean".
1490
+ * @param {object} graph architecture.mjs's getDependencyGraph() result
1491
+ */
1492
+ // Wave C item 2 (architecture-document-standard.md §6's risk row): the console's
1493
+ // dependency graph is a live JS import scan between verify-lane runs — real,
1494
+ // but not the gate. It must never be read as a verdict in its own right, so
1495
+ // every rendered graph carries this line in the section's own vocabulary,
1496
+ // right under the graph itself (never a top-of-tab banner — that visual
1497
+ // weight is reserved for the artifact's own approval status).
1498
+ // A function, not a constant: the copy in force is set by the host after this module loads.
1499
+ const depGraphAdvisoryHtml = () => `<p class="dep-advisory">Advisory preview; the lane is the law &mdash; this is a live scan of real imports between <code>node qa/verify.mjs</code> runs, not a verdict. ${COPY.depGraphGatesNote}</p>`;
1500
+
1501
+ function dependencyGraphHtml(graph) {
1502
+ if (!graph || !graph.available) {
1503
+ return `<div class="empty">
1504
+ <p>No dependency graph available.</p>
1505
+ <p>${esc((graph && graph.reason) || "the scan reported no graph and gave no reason.")}</p>
1506
+ </div>`;
1507
+ }
1508
+ if (graph.edges.length === 0) {
1509
+ return `<p class="empty-inline">no cross-layer imports observed under <code>${esc(graph.appPackage)}</code></p>
1510
+ ${depGraphAdvisoryHtml()}`;
1511
+ }
1512
+ const rows = graph.edges
1513
+ .map((e) => {
1514
+ const chip = e.violation
1515
+ ? `<span class="badge badge-changed violation-chip">violates ${esc(e.clauseId)}</span>`
1516
+ : "";
1517
+ return ` <li class="dep-edge${e.violation ? " dep-violation" : ""}">
1518
+ <code>${esc(e.from)}</code> &rarr; <code>${esc(e.to)}</code>
1519
+ <span class="dep-count">${e.count} import${e.count === 1 ? "" : "s"}</span>
1520
+ ${chip}
1521
+ </li>`;
1522
+ })
1523
+ .join("\n");
1524
+ const violationsHtml = graph.violations.length
1525
+ ? ` <div class="dep-violations">
1526
+ <p class="lbl">violations &mdash; file:line</p>
1527
+ <ul class="dep-violation-list">
1528
+ ${graph.violations
1529
+ .map(
1530
+ (v) => ` <li class="dep-violation-item">
1531
+ <code>${esc(v.file)}:${v.line}</code> imports <code>${esc(v.imported)}</code>
1532
+ <span class="badge badge-changed">${esc(v.from)} &rarr; ${esc(v.to)} violates ${esc(v.clauseId)}</span>
1533
+ </li>`,
1534
+ )
1535
+ .join("\n")}
1536
+ </ul>
1537
+ </div>`
1538
+ : graph.rulesApplied
1539
+ ? ` <p class="empty-inline">no layer violations observed</p>`
1540
+ : ` <p class="empty-inline">no governed layer rules could be derived (the governed contract is unavailable) &mdash; violations are unchecked, not clean</p>`;
1541
+ return ` <ul class="dep-edges">
1542
+ ${rows}
1543
+ </ul>
1544
+ ${violationsHtml}
1545
+ ${depGraphAdvisoryHtml()}`;
1546
+ }
1547
+
1548
+ function layerMapHtml(layerMap) {
1549
+ if (!layerMap || !layerMap.available) {
1550
+ return `<div class="empty">
1551
+ <p>No layer map available.</p>
1552
+ <p>${esc((layerMap && layerMap.reason) || "the scan reported no layer map and gave no reason.")}</p>
1553
+ </div>`;
1554
+ }
1555
+ const boxes = layerMap.layers
1556
+ .map(
1557
+ (l) => ` <div class="layer-box${l.present ? "" : " layer-empty"}">
1558
+ <h4>${esc(l.id)}${commentControlHtml({ type: "architecture", path: l.id })}</h4>
1559
+ <p class="layer-desc">${esc(l.label)}</p>
1560
+ ${
1561
+ l.present
1562
+ ? l.files.length
1563
+ ? `<ul class="layer-files">${l.files.map((f) => `<li><code>${esc(f)}</code></li>`).join("")}</ul>`
1564
+ : `<p class="empty-inline">no files</p>`
1565
+ : `<p class="empty-inline">directory not present</p>`
1566
+ }
1567
+ </div>`,
1568
+ )
1569
+ .join("\n");
1570
+ const others =
1571
+ layerMap.otherPackages && layerMap.otherPackages.length
1572
+ ? ` <div class="layer-others">
1573
+ <p class="lbl">other top-level packages</p>
1574
+ <ul class="layer-files">${layerMap.otherPackages
1575
+ .map((p) => `<li><code>${esc(p.name)}</code> (${p.files.length} file${p.files.length === 1 ? "" : "s"})</li>`)
1576
+ .join("")}</ul>
1577
+ </div>`
1578
+ : "";
1579
+ return ` <p class="meta">package <code>${esc(layerMap.appPackage)}</code> &middot; navigation lives under <code>presentation/navigation</code> (shown as part of presentation, below)</p>
1580
+ <div class="layer-map">
1581
+ ${boxes}
1582
+ </div>
1583
+ ${others}`;
1584
+ }
1585
+
1586
+ /** How long ago, in the coarse "Xm/Xh/Xd ago" shape used across the console's badges. `null`/NaN renders as "age unknown" rather than a fabricated number. */
1587
+ function formatReceiptAge(ageMs) {
1588
+ if (typeof ageMs !== "number" || Number.isNaN(ageMs)) return "age unknown";
1589
+ if (ageMs < 60_000) return "just now";
1590
+ const mins = Math.floor(ageMs / 60_000);
1591
+ if (mins < 60) return `${mins}m ago`;
1592
+ const hours = Math.floor(mins / 60);
1593
+ if (hours < 24) return `${hours}h ago`;
1594
+ return `${Math.floor(hours / 24)}d ago`;
1595
+ }
1596
+
1597
+ /**
1598
+ * Per-ARCH-clause receipt status (architecture-document-standard.md §6, Wave C
1599
+ * item 1): the last verify-lane receipt's "conformance" step — the
1600
+ * *ArchitectureConformanceTest gate that enforces the ARCH-* clauses (see
1601
+ * receipt-bridge.mjs's getLastReceipt) — plus the receipt's age and whether
1602
+ * its inputsHash still matches the current tree. Scoped to ARCH-* clauses
1603
+ * only by the caller (governedContractHtml): SHELL-* clauses are enforced by
1604
+ * a different gate (a11y/e2eSmoke) this one receipt step doesn't attribute
1605
+ * per-clause, so they render without this badge rather than borrow a status
1606
+ * that isn't theirs.
1607
+ *
1608
+ * Never fabricates: no receipt, no conformance step, or a stale inputsHash
1609
+ * all say so explicitly — a stale receipt is labeled "stale receipt", never
1610
+ * presented as a live PASS.
1611
+ * @param {object|null|undefined} lastReceipt receipt-bridge.mjs's getLastReceipt() result
1612
+ */
1613
+ function clauseReceiptStatusHtml(lastReceipt) {
1614
+ if (!lastReceipt || !lastReceipt.available) {
1615
+ const reason = (lastReceipt && lastReceipt.reason) || `no receipt at ${(lastReceipt && lastReceipt.relPath) || "qa/evidence/latest.json"} — run node qa/verify.mjs`;
1616
+ return `<span class="receipt-badge receipt-none" title="${escAttr(reason)}">no receipt yet &mdash; run node qa/verify.mjs</span>`;
1617
+ }
1618
+ if (!lastReceipt.conformance) {
1619
+ return `<span class="receipt-badge receipt-none">last receipt has no conformance step &mdash; run node qa/verify.mjs</span>`;
1620
+ }
1621
+ const age = formatReceiptAge(lastReceipt.ageMs);
1622
+ const generatedTitle = lastReceipt.generatedAt ? ` title="generated ${escAttr(lastReceipt.generatedAt)}"` : "";
1623
+ if (lastReceipt.stale) {
1624
+ return `<span class="receipt-badge receipt-stale"${generatedTitle}>stale receipt</span><span class="receipt-age">conformance was ${esc(lastReceipt.conformance.verdict)} ${age} &mdash; source changed since</span>`;
1625
+ }
1626
+ const verdictClass = lastReceipt.conformance.verdict === "PASS" ? "receipt-pass" : lastReceipt.conformance.verdict === "FAIL" ? "receipt-fail" : "receipt-none";
1627
+ const freshnessNote = lastReceipt.stale === null ? " &middot; freshness unverified" : "";
1628
+ return `<span class="receipt-badge ${verdictClass}"${generatedTitle}>conformance: ${esc(lastReceipt.conformance.verdict)}</span><span class="receipt-age">${age}${freshnessNote}</span>`;
1629
+ }
1630
+
1631
+ /**
1632
+ * One receipt STEP's status as a compact matrix cell (the Specs RTM's
1633
+ * last-receipt column) — the same honesty rules as clauseReceiptStatusHtml,
1634
+ * generalized to any step name: no receipt, step-not-in-receipt (e.g. a
1635
+ * scaffold-profile run never executes `conformance`), stale (never presented
1636
+ * as a live verdict), freshness-unknown, or the real verdict + age.
1637
+ * @param {object|null|undefined} lastReceipt receipt-bridge.mjs's getLastReceipt() result
1638
+ * @param {string} stepName a receipt steps[] name (specCoverage, conformance, a11y, …)
1639
+ */
1640
+ function stepReceiptCellHtml(lastReceipt, stepName) {
1641
+ if (!lastReceipt || !lastReceipt.available) {
1642
+ const reason = (lastReceipt && lastReceipt.reason) || `no receipt at ${(lastReceipt && lastReceipt.relPath) || "qa/evidence/latest.json"} — run node qa/verify.mjs`;
1643
+ return `<span class="receipt-badge receipt-none" title="${escAttr(reason)}">no receipt yet</span>`;
1644
+ }
1645
+ const step = (lastReceipt.steps || []).find((s) => s && s.name === stepName);
1646
+ if (!step) {
1647
+ const profile = lastReceipt.profile ? ` (profile ${esc(lastReceipt.profile)})` : "";
1648
+ return `<span class="receipt-badge receipt-none">not in last receipt${profile}</span>`;
1649
+ }
1650
+ const age = formatReceiptAge(lastReceipt.ageMs);
1651
+ if (lastReceipt.stale) {
1652
+ return `<span class="receipt-badge receipt-stale">stale &mdash; was ${esc(step.verdict)} ${age}</span>`;
1653
+ }
1654
+ const cls = step.verdict === "PASS" ? "receipt-pass" : step.verdict === "FAIL" ? "receipt-fail" : "receipt-none";
1655
+ const freshness = lastReceipt.stale === null ? " &middot; freshness unverified" : "";
1656
+ const title = step.reason ? ` title="${escAttr(step.reason)}"` : "";
1657
+ return `<span class="receipt-badge ${cls}"${title}>${esc(step.verdict)}</span><span class="receipt-age">${age}${freshness}</span>`;
1658
+ }
1659
+
1660
+ /**
1661
+ * @param {object} gc getGovernedContract() result
1662
+ * @param {object|null|undefined} [lastReceipt] receipt-bridge.mjs's getLastReceipt() result. Omitted (callers that don't wire it) is treated exactly like an explicit `{available: false}` — every ARCH-* clause row still gets the honest "no receipt yet" badge, never silence that could be misread as "unattested" rather than "no receipt at all".
1663
+ */
1664
+ function governedContractHtml(gc, lastReceipt) {
1665
+ if (!gc || !gc.available) {
1666
+ return `<div class="empty">
1667
+ <p>No governed contract available.</p>
1668
+ <p>${esc((gc && gc.reason) || "specs/app-base.spec.md not found.")}</p>
1669
+ </div>`;
1670
+ }
1671
+ const items = gc.clauses
1672
+ .map((c) => {
1673
+ const prose = esc(c.prose);
1674
+ const receiptStatus = /^ARCH-/i.test(c.id) ? clauseReceiptStatusHtml(lastReceipt) : "";
1675
+ return ` <li class="clause${c.withdrawn ? " withdrawn" : ""}">
1676
+ <span class="clause-id"><code>${esc(c.id)}</code></span>
1677
+ <span class="clause-prose">${c.withdrawn ? `<s>${prose}</s>` : prose}</span>
1678
+ ${receiptStatus}
1679
+ ${commentControlHtml({ type: "spec-line", file: `specs/${gc.file}`, clauseId: c.id })}
1680
+ </li>`;
1681
+ })
1682
+ .join("\n");
1683
+ return ` <p class="meta">specs/${esc(gc.file)}</p>
1684
+ <ul class="clause-list">
1685
+ ${items || ' <li class="empty-inline">no clauses parsed</li>'}
1686
+ </ul>`;
1687
+ }
1688
+
1689
+ function featureShapeHtml(shape) {
1690
+ if (!shape || !shape.available) {
1691
+ return `<div class="empty">
1692
+ <p>No feature shape available.</p>
1693
+ <p>${esc((shape && shape.reason) || "presentation/home not found.")}</p>
1694
+ </div>`;
1695
+ }
1696
+ const items = shape.files
1697
+ .map((f) => ` <li><code>${esc(f)}</code>${commentControlHtml({ type: "architecture", path: f })}</li>`)
1698
+ .join("\n");
1699
+ return ` <p class="meta">${shape.files.length} file(s) &mdash; the shape <code>qa/scaffold-feature.mjs</code> clones for a new feature</p>
1700
+ <ul class="feature-tree">
1701
+ ${items}
1702
+ </ul>`;
1703
+ }
1704
+
1705
+ /**
1706
+ * The Architecture tab (§7.1, AD-1 rebuild): mirrors template/docs/
1707
+ * ARCHITECTURE.md's own section shape — purpose & quality goals, system
1708
+ * context, platform & deployment view, building blocks (layer map +
1709
+ * dependency arrows + the governed contract), runtime view, crosscutting
1710
+ * policies, decisions — plus the exemplar feature shape at the end. Every
1711
+ * section degrades independently; one missing source never hides the rest.
1712
+ * The artifact's approval status is NOT re-rendered in the body: the shell's
1713
+ * page header (§2 grammar, preview-service.mjs's archStatus line) already
1714
+ * carries it, and a second copy here was pure duplication — drift-in-place
1715
+ * for architecture means violations at their edges (dependencyGraphHtml) and
1716
+ * receipt badges on the ARCH-* clauses, not a repeated banner.
1717
+ * `meta.lastReceipt` (optional, receipt-bridge.mjs's getLastReceipt() result)
1718
+ * drives each ARCH-* clause row's last-receipt status in the governed
1719
+ * contract (Wave C item 1) — an omitted `meta.lastReceipt` is treated exactly
1720
+ * like "no receipt exists", so every ARCH-* clause row still shows the honest
1721
+ * "no receipt yet" badge rather than silently rendering as if receipts don't
1722
+ * apply here.
1723
+ * @param {{layerMap: object, governedContract: object, featureShape: object, dependencyGraph?: object, doc?: object}} data
1724
+ * @param {{approval?: object|null, lastReceipt?: object|null}} [meta] `approval` is accepted for caller compatibility but unused here — the shell header owns that rendering
1725
+ */
1726
+ export function architectureTabHtml(data, meta = {}) {
1727
+ const { layerMap, governedContract, featureShape, dependencyGraph, doc } = data || {};
1728
+ return ` <section class="arch-section" id="arch-purpose">
1729
+ <h3>1. Purpose &amp; quality goals</h3>
1730
+ ${mdTableHtml(doc && doc.qualityAttributes)}
1731
+ </section>
1732
+ <section class="arch-section" id="arch-constraints">
1733
+ <h3>2. Constraints</h3>
1734
+ ${docSectionProseHtml(doc && doc.constraints)}
1735
+ ${versionSetHtml(doc && doc.versionSet)}
1736
+ </section>
1737
+ <section class="arch-section" id="arch-context">
1738
+ <h3>3. System context</h3>
1739
+ ${systemContextHtml(doc && doc.systemContext)}
1740
+ </section>
1741
+ <section class="arch-section" id="arch-platform">
1742
+ <h3>4. Platform &amp; deployment view</h3>
1743
+ ${platformViewHtml(doc && doc.platformView)}
1744
+ </section>
1745
+ <section class="arch-section" id="arch-building-blocks">
1746
+ <h3>5. Building blocks &mdash; the layer model</h3>
1747
+ <h4>Layer map</h4>
1748
+ ${layerMapHtml(layerMap)}
1749
+ <h4>Dependency arrows (observed from real imports)</h4>
1750
+ ${dependencyGraphHtml(dependencyGraph)}
1751
+ <h4>The governed contract</h4>
1752
+ ${governedContractHtml(governedContract, meta.lastReceipt)}
1753
+ </section>
1754
+ <section class="arch-section" id="arch-runtime">
1755
+ <h3>6. Runtime view</h3>
1756
+ ${docSectionProseHtml(doc && doc.runtimeView)}
1757
+ </section>
1758
+ <section class="arch-section" id="arch-crosscutting">
1759
+ <h3>7. Crosscutting policies</h3>
1760
+ ${docSectionProseHtml(doc && doc.crosscuttingPolicies)}
1761
+ </section>
1762
+ <section class="arch-section" id="arch-decisions">
1763
+ <h3>8. Decisions</h3>
1764
+ ${mdTableHtml(doc && doc.decisions)}
1765
+ </section>
1766
+ <section class="arch-section" id="arch-feature-shape">
1767
+ <h3>Feature shape (what <code>add-feature</code> stamps)</h3>
1768
+ ${featureShapeHtml(featureShape)}
1769
+ </section>`;
1770
+ }
1771
+
1772
+ // --- Evidence (§3.6) — the SDET's release-readiness report -------------------
1773
+ //
1774
+ // The page a release manager reads before shipping: the latest receipt as the
1775
+ // headline (verdict, profile, commit, age, inputs binding vs the CURRENT
1776
+ // tree), per-step rows with honest SKIP reasons, and the receipt timeline.
1777
+ // The lane is the law: this page renders ONLY what qa/evidence attests —
1778
+ // nothing here is a live re-derivation presented as a verdict.
1779
+
1780
+ /**
1781
+ * Which console section a receipt step governs — only mappings the lane's own
1782
+ * step definitions state (template/qa/verify.mjs); `build` and `unitTests`
1783
+ * gate the whole tree, not one section, so they get NO link — never a
1784
+ * guessed one.
1785
+ * - specCoverage → Specs (the clause↔test citation gate).
1786
+ * - conformance / archDoc → Architecture (the ARCH-* gates; the doc-drift check).
1787
+ * - componentStories → Components (one story per registry component).
1788
+ * - goldenTrees / a11y / e2eSmoke → Screens (rendered-structure, a11y-floor,
1789
+ * and on-device checks of the screens themselves).
1790
+ * - tokenDrift → Design language (declared catalog vs live values).
1791
+ * - approvals → Approvals (the governed-artifact hash gate).
1792
+ */
1793
+ const STEP_GOVERNS = {
1794
+ specCoverage: { section: "specs", label: "Specs" },
1795
+ conformance: { section: "architecture", label: "Architecture" },
1796
+ archDoc: { section: "architecture", label: "Architecture" },
1797
+ componentStories: { section: "components", label: "Components" },
1798
+ goldenTrees: { section: "screens", label: "Screens" },
1799
+ a11y: { section: "screens", label: "Screens" },
1800
+ e2eSmoke: { section: "screens", label: "Screens" },
1801
+ tokenDrift: { section: "design-system", label: "Design language" },
1802
+ approvals: { section: "approvals", label: "Approvals" },
1803
+ };
1804
+
1805
+ /** A step duration in human units — "" (silence) when the receipt carries none. */
1806
+ function formatDurationMs(ms) {
1807
+ if (typeof ms !== "number" || Number.isNaN(ms) || ms < 0) return "";
1808
+ if (ms < 1000) return `${Math.round(ms)}ms`;
1809
+ const secs = ms / 1000;
1810
+ if (secs < 60) return `${Math.round(secs * 10) / 10}s`;
1811
+ return `${Math.floor(secs / 60)}m ${String(Math.round(secs % 60)).padStart(2, "0")}s`;
1812
+ }
1813
+
1814
+ /**
1815
+ * The inputs-binding line — three-valued, per receipt-bridge.mjs's staleness
1816
+ * contract: confirmed fresh, confirmed stale (drift-colored, with the fix),
1817
+ * or honestly unknown with the bridge's own reason. Unknown is NEVER rendered
1818
+ * as fresh.
1819
+ */
1820
+ function inputsBindingHtml(r) {
1821
+ if (r.stale === true) {
1822
+ const move =
1823
+ r.inputsHash && r.currentInputsHash
1824
+ ? ` (<code>${esc(shortHash(r.inputsHash))}</code> &rarr; <code>${esc(shortHash(r.currentInputsHash))}</code>)`
1825
+ : "";
1826
+ return `<span class="evidence-binding-stale">inputs no longer match the current tree${move} &mdash; re-run <code>node qa/verify.mjs</code></span>`;
1827
+ }
1828
+ if (r.stale === false) {
1829
+ const files = typeof r.inputsFileCount === "number" ? ` over ${r.inputsFileCount} files` : "";
1830
+ return `inputs bound to the current tree &mdash; hash <code>${esc(shortHash(r.inputsHash))}</code> still matches${files}`;
1831
+ }
1832
+ return `inputs binding unknown &mdash; ${esc(r.staleReason || "freshness could not be recomputed")}`;
1833
+ }
1834
+
1835
+ /**
1836
+ * One committed receipt as a timeline row — the compliance record: the verdict
1837
+ * as attested at that commit, plus git's own attribution (commit · author ·
1838
+ * when). Stated facts only; nothing re-derived.
1839
+ */
1840
+ function timelineRowHtml(r) {
1841
+ const cls = r.verdict === "PASS" ? "step-verdict-pass" : r.verdict === "FAIL" ? "step-verdict-fail" : "step-verdict-skip";
1842
+ const age = typeof r.ageMs === "number" ? formatReceiptAge(r.ageMs) : "age unknown";
1843
+ const commit = r.commitSha ? `<span class="meta">commit <code>${esc(String(r.commitSha).slice(0, 7))}</code></span>` : "";
1844
+ const author = r.author ? `<span class="meta">by ${esc(r.author)}</span>` : "";
1845
+ const when = r.committedAt ? esc(r.committedAt) : "commit date unknown";
1846
+ // The rung as attested AT that commit (receipt.evidenceLevel, derived by the
1847
+ // lane) — absent on FAIL receipts and pre-ladder receipts, and then omitted
1848
+ // rather than guessed — WITH THE PACK THAT GRADED IT (§6.5). The audit trail
1849
+ // is where the pack matters most and is hardest to recover: these rows are
1850
+ // reconstructed from old commits, and the tree that could tell a reader which
1851
+ // pack was loaded at that sha is exactly the tree they no longer have in
1852
+ // front of them. `profile` on the row beside this is the RUN profile
1853
+ // (scaffold/local/ci) and never the pack, so it cannot stand in for one.
1854
+ const rungLabel = rungWithPack(r.evidenceLevel, r.packId ?? r.pack);
1855
+ const rung = rungLabel
1856
+ ? `<span class="badge evidence-rung" title="${escAttr(rungPackNote(r.evidenceLevel, r.packId ?? r.pack))}">${esc(rungLabel)}</span>`
1857
+ : "";
1858
+ return ` <li>
1859
+ <span class="${cls}">${esc(r.verdict || "?")}</span>
1860
+ ${rung}
1861
+ ${r.profile ? `<span class="meta">profile <code>${esc(r.profile)}</code></span>` : ""}
1862
+ ${commit}
1863
+ ${author}
1864
+ <span class="meta">committed ${when} &middot; ${esc(age)}</span>
1865
+ </li>`;
1866
+ }
1867
+
1868
+ /**
1869
+ * A step's test tally, when the step ran tests and the lane recorded the count
1870
+ * (qa/verify.mjs writes the JUnit summary into `details` for unitTests and the
1871
+ * instrumented steps). Evidence-or-silence: a step with no tally renders
1872
+ * nothing — never "0 tests", which would read as a finding rather than as an
1873
+ * absence. `failures` and `errors` are summed because the distinction is
1874
+ * JUnit's, not the reader's: both mean a test did not pass.
1875
+ * @param {{details?: object}} step a receipt step, verbatim from the bridge
1876
+ */
1877
+ export function stepTestCountsHtml(step) {
1878
+ const d = step && step.details;
1879
+ if (!d || typeof d !== "object" || typeof d.tests !== "number") return "";
1880
+ const failed = (typeof d.failures === "number" ? d.failures : 0) + (typeof d.errors === "number" ? d.errors : 0);
1881
+ const bits = [`${d.tests} test${d.tests === 1 ? "" : "s"}`];
1882
+ // A green tally says "0 failed" explicitly: on a report, the absence of a
1883
+ // failure count is ambiguous where the number is not.
1884
+ bits.push(failed === 0 ? "0 failed" : `<strong class="step-failed">${failed} failed</strong>`);
1885
+ if (typeof d.skipped === "number" && d.skipped > 0) bits.push(`${d.skipped} skipped`);
1886
+ return `<span class="step-counts">${bits.join(" &middot; ")}</span>`;
1887
+ }
1888
+
1889
+ /**
1890
+ * The Evidence section body (§3.6). Renders ONLY what the receipt attests:
1891
+ * - headline: verdict (visually demoted, never green, when the receipt is
1892
+ * stale), profile, commit, generatedAt + age, the three-valued inputs
1893
+ * binding;
1894
+ * - per-step rows: verdict, honest SKIP/FAIL reasons verbatim, humanized
1895
+ * duration, and a link to the section the step governs where that mapping
1896
+ * is real (STEP_GOVERNS — unmapped steps get no link);
1897
+ * - timeline: the committed receipt audit trail, newest-first, reconstructed
1898
+ * from git (receipt-bridge.mjs listReceiptHistory) — one attributed entry per
1899
+ * verified commit; else the standardized absence line until the first receipt
1900
+ * is committed.
1901
+ * @param {object|null|undefined} lastReceipt receipt-bridge.mjs's getLastReceipt() result
1902
+ * @param {{available: boolean, reason?: string, receipts?: object[]}} [history] listReceiptHistory() result
1903
+ */
1904
+ export function evidenceBodyHtml(lastReceipt, history) {
1905
+ // The receipt path is the PROJECT's (receipt-bridge resolves it from
1906
+ // qa/harness-manifest.json, defaulting to the Compose layout) — every
1907
+ // mention below names that path, never the default by habit.
1908
+ const relPath = (lastReceipt && lastReceipt.relPath) || "qa/evidence/latest.json";
1909
+ if (!lastReceipt || !lastReceipt.available) {
1910
+ const reason = (lastReceipt && lastReceipt.reason) || `no receipt at ${relPath}`;
1911
+ return `<div class="empty">
1912
+ <p>No verify receipt yet.</p>
1913
+ <p>${esc(reason)}</p>
1914
+ <p>Run <code>node qa/verify.mjs</code> &mdash; the lane writes <code>${esc(relPath)}</code>,
1915
+ and this page renders exactly what that receipt attests. Nothing here is derived any other way.</p>
1916
+ </div>`;
1917
+ }
1918
+ const r = lastReceipt;
1919
+ const stale = r.stale === true;
1920
+ // Stale demotion: the verdict keeps its word (it IS what the lane said) but
1921
+ // loses its color — a stale PASS is never presented as a live green.
1922
+ const verdictCls = stale
1923
+ ? "verdict-muted"
1924
+ : r.verdict === "PASS"
1925
+ ? "verdict-pass"
1926
+ : r.verdict === "FAIL"
1927
+ ? "verdict-fail"
1928
+ : "verdict-muted";
1929
+ const staleChip = stale
1930
+ ? ` <span class="badge badge-changed">STALE &mdash; the tree changed since this run</span>`
1931
+ : r.stale === null
1932
+ ? ` <span class="badge badge-unreviewed">freshness unknown</span>`
1933
+ : "";
1934
+ // The evidence-ladder rung — the receipt's own derived coarse grade (L0
1935
+ // scaffold / L1 desktop / L2 device / L3 release), rendered verbatim. A FAIL
1936
+ // or pre-ladder receipt has none, and none is shown — never fabricated. The
1937
+ // per-step table below stays the fine print.
1938
+ //
1939
+ // The pack that graded it is in the chip itself and not only in the tooltip:
1940
+ // §6.5 asks for the pack BESIDE the rung, and a fact a reader has to hover to
1941
+ // find is not beside anything. The tooltip keeps what it always carried (the
1942
+ // steps that earned the grade) and adds why the pack is there.
1943
+ const rungLabelText = rungWithPack(r.evidenceLevel, r.packId ?? r.pack);
1944
+ const rungChip = rungLabelText
1945
+ ? ` <span class="badge evidence-rung" title="${escAttr(`satisfied by: ${(r.evidenceLevel.satisfiedBy || []).join(", ") || "(none recorded)"} — ${rungPackNote(r.evidenceLevel, r.packId ?? r.pack)}`)}">Evidence: ${esc(rungLabelText)}</span>`
1946
+ : "";
1947
+ const age = formatReceiptAge(r.ageMs);
1948
+ const dirty =
1949
+ r.commitDirty && r.commitDirty.length
1950
+ ? ` &middot; ${r.commitDirty.length} uncommitted file${r.commitDirty.length === 1 ? "" : "s"} at run time`
1951
+ : "";
1952
+ const facts = [
1953
+ r.profile ? `<li>profile <code>${esc(r.profile)}</code></li>` : "",
1954
+ r.commitSha ? `<li>commit <code>${esc(shortHash(r.commitSha))}</code>${dirty}</li>` : "",
1955
+ `<li>generated ${r.generatedAt ? esc(r.generatedAt) : "at an unknown time"} &middot; ${esc(age)}</li>`,
1956
+ `<li>${inputsBindingHtml(r)}</li>`,
1957
+ ]
1958
+ .filter(Boolean)
1959
+ .join("\n ");
1960
+
1961
+ const stepRowHtml = (s) => {
1962
+ // ERROR (S4) wears its own class: "could not run" must never read as a
1963
+ // behaviour failure (✗) nor as a quiet skip — it is the row that says
1964
+ // the lane did not get to check this.
1965
+ const cls =
1966
+ s.verdict === "PASS" ? "step-verdict-pass" : s.verdict === "FAIL" ? "step-verdict-fail" : s.verdict === "ERROR" ? "step-verdict-error" : "step-verdict-skip";
1967
+ const governs = STEP_GOVERNS[s.name];
1968
+ const governsCell = governs ? `<a class="step-link" href="#${esc(governs.section)}">${esc(governs.label)}</a>` : "";
1969
+ // Detail, in falling order of what a release manager needs: the test
1970
+ // tally the step actually earned, its own honest fine print, then the
1971
+ // failure reason. All three come from the receipt verbatim.
1972
+ const counts = stepTestCountsHtml(s);
1973
+ const note = s.note ? `<span class="step-note">${esc(s.note)}</span>` : "";
1974
+ const reason = s.reason ? `<span class="step-reason">${esc(s.reason)}</span>` : "";
1975
+ return ` <tr>
1976
+ <td><code>${esc(s.name)}</code></td>
1977
+ <td><span class="${cls}">${esc(s.verdict)}</span></td>
1978
+ <td>${esc(formatDurationMs(s.durationMs))}</td>
1979
+ <td>${governsCell}</td>
1980
+ <td>${counts}${note}${reason}</td>
1981
+ </tr>`;
1982
+ };
1983
+ // Layer grouping: when the pack tagged its steps with the layer of the
1984
+ // stack each one proves (backend, security, spine, frontend, …), the table
1985
+ // groups them under a header row per layer with that layer's own tally —
1986
+ // the per-layer report falls out of the receipt, nothing is re-derived.
1987
+ // Layers appear in first-seen receipt order; rows keep receipt order within
1988
+ // a layer; a step without a layer sits under "other" only when at least one
1989
+ // step HAS one (an untagged receipt renders exactly as it always did).
1990
+ const stepsList = r.steps || [];
1991
+ const anyLayer = stepsList.some((s) => typeof s.layer === "string" && s.layer);
1992
+ let stepRows;
1993
+ if (!anyLayer) {
1994
+ stepRows = stepsList.map(stepRowHtml).join("\n");
1995
+ } else {
1996
+ const groups = new Map();
1997
+ for (const s of stepsList) {
1998
+ const key = typeof s.layer === "string" && s.layer ? s.layer : "other";
1999
+ if (!groups.has(key)) groups.set(key, []);
2000
+ groups.get(key).push(s);
2001
+ }
2002
+ stepRows = [...groups.entries()]
2003
+ .map(([layer, steps]) => {
2004
+ const tally = ["PASS", "FAIL", "ERROR", "SKIP"]
2005
+ .map((v) => [v, steps.filter((s) => s.verdict === v).length])
2006
+ .filter(([, n]) => n > 0)
2007
+ .map(([v, n]) => `${n} ${v}`)
2008
+ .join(" &middot; ");
2009
+ return ` <tr class="step-layer" data-layer="${escAttr(layer)}"><th colspan="5">${esc(layer)} <span class="step-layer-tally">${tally}</span></th></tr>\n${steps
2010
+ .map(stepRowHtml)
2011
+ .join("\n")}`;
2012
+ })
2013
+ .join("\n");
2014
+ }
2015
+ const stepsHtml = stepRows
2016
+ ? ` <table class="doc-table step-table">
2017
+ <thead><tr><th>Step</th><th>Verdict</th><th>Duration</th><th>Governs</th><th>Detail</th></tr></thead>
2018
+ <tbody>
2019
+ ${stepRows}
2020
+ </tbody>
2021
+ </table>`
2022
+ : ` <p class="empty-inline">the receipt carries no steps</p>`;
2023
+
2024
+ const timelineHtml =
2025
+ history && history.available && history.receipts && history.receipts.length
2026
+ ? ` <ul class="evidence-timeline">
2027
+ ${history.receipts.map(timelineRowHtml).join("\n")}
2028
+ </ul>`
2029
+ : ` <p class="empty-inline">${esc((history && history.reason) || "no committed receipt history yet")} &mdash; each commit of <code>${esc(relPath)}</code> becomes one entry in the audit trail</p>`;
2030
+
2031
+ return ` <p class="meta">Rendered from <code>${esc(relPath)}</code> &mdash; the verify lane's own attestation.
2032
+ The lane is the law: nothing on this page is re-derived live.</p>
2033
+ <div class="evidence-headline${stale ? " evidence-stale" : ""}">
2034
+ <p class="lbl">latest receipt</p>
2035
+ <span class="evidence-verdict ${verdictCls}">${esc(r.verdict || "?")}</span>${rungChip}${staleChip}
2036
+ <ul class="evidence-facts">
2037
+ ${facts}
2038
+ </ul>
2039
+ </div>
2040
+ <h3>Steps</h3>
2041
+ ${stepsHtml}
2042
+ <h3>Audit trail &mdash; committed receipts</h3>
2043
+ <p class="meta">Every commit of <code>${esc(relPath)}</code> is one verified state of record, attributed from git. Newest first.</p>
2044
+ ${timelineHtml}`;
2045
+ }
2046
+
2047
+ // --- Comments tab (§7.3) -----------------------------------------------------
2048
+
2049
+ /** Render a §7.3 Comment target readably — never the raw JSON. */
2050
+ function describeTarget(t) {
2051
+ if (!t || typeof t !== "object") return '<span class="empty-inline">unknown target</span>';
2052
+ switch (t.type) {
2053
+ case "screen":
2054
+ return `screen <code>${esc(t.screen)}</code>`;
2055
+ case "element":
2056
+ return `screen <code>${esc(t.screen)}</code>${t.testTag ? ` &middot; element <code>${esc(t.testTag)}</code>` : ""}`;
2057
+ case "spec-line":
2058
+ return `spec <code>${esc(t.file)}</code>${t.clauseId ? ` &middot; clause <code>${esc(t.clauseId)}</code>` : ""}`;
2059
+ case "design-system":
2060
+ return `design system <code>${esc(t.token)}</code>`;
2061
+ case "architecture":
2062
+ return `architecture <code>${esc(t.path)}</code>`;
2063
+ case "general":
2064
+ return "general";
2065
+ default:
2066
+ return `<span class="empty-inline">${esc(t.type || "unknown target")}</span>`;
2067
+ }
2068
+ }
2069
+
2070
+ /**
2071
+ * The Comments tab (§7.3): the full ledger — target (rendered readably, never
2072
+ * raw JSON), text, author, createdAt, status badge, and the resolution note
2073
+ * once resolved. Humans add comments (via the 💬 controls elsewhere in the
2074
+ * console); only an agent resolves them (`resolve_comment`) — this tab never
2075
+ * renders a resolve control, per §4's "the console never edits code" and
2076
+ * §7.3's "humans add/see; agents resolve".
2077
+ * @param {{available: boolean, error?: string, comments?: object[]}} comments
2078
+ */
2079
+ export function commentsTabHtml(comments) {
2080
+ if (!comments || !comments.available) {
2081
+ const detail =
2082
+ comments && comments.error
2083
+ ? esc(comments.error)
2084
+ : "This looks like an older scaffold that predates the comments wave (no qa/lib/comments.mjs).";
2085
+ return `<div class="empty">
2086
+ <p>Comments are not available in this project.</p>
2087
+ <p>${detail}</p>
2088
+ </div>`;
2089
+ }
2090
+ if (!comments.comments || comments.comments.length === 0) {
2091
+ return `<div class="empty"><p>No comments yet &mdash; use the &#128172; control on any screen, spec clause, token, component, or architecture node to leave one.</p></div>`;
2092
+ }
2093
+ const rows = [...comments.comments]
2094
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0)) // newest first
2095
+ .map((c) => {
2096
+ const badgeClass = c.status === "resolved" ? "badge-resolved" : "badge-open";
2097
+ const resolution =
2098
+ c.status === "resolved"
2099
+ ? `<div class="comment-resolution"><span class="lbl">resolved by ${esc(c.resolvedBy || "?")}${c.resolvedAt ? ` at ${esc(c.resolvedAt)}` : ""}</span>${
2100
+ c.resolutionNote ? `<p class="comment-resolution-note">${esc(c.resolutionNote)}</p>` : ""
2101
+ }</div>`
2102
+ : "";
2103
+ return ` <tr class="comment-row" data-id="${esc(c.id)}">
2104
+ <td><code>${esc(c.id)}</code></td>
2105
+ <td>${describeTarget(c.target)}</td>
2106
+ <td class="comment-text-cell">${esc(c.text)}</td>
2107
+ <td>${esc(c.author)}</td>
2108
+ <td>${esc(c.createdAt)}</td>
2109
+ <td><span class="badge ${badgeClass}">${esc(c.status)}</span>${resolution}</td>
2110
+ </tr>`;
2111
+ })
2112
+ .join("\n");
2113
+ return ` <table class="comments-table">
2114
+ <thead><tr><th>ID</th><th>Target</th><th>Comment</th><th>Author</th><th>Created</th><th>Status</th></tr></thead>
2115
+ <tbody>
2116
+ ${rows}
2117
+ </tbody>
2118
+ </table>`;
2119
+ }
2120
+
2121
+ // --- Screens (§3.4) — the design-review gallery ------------------------------
2122
+ //
2123
+ // The screen × state matrix: rows = screens (base preview-registry entries),
2124
+ // columns = default + whichever @loading/@empty/@error states ANY screen
2125
+ // registers, each cell that screen's live render. A cell whose state the
2126
+ // screen doesn't register is a quiet dash — the matrix's own geometry states
2127
+ // the absence; one line under the matrix says where states come from. Each
2128
+ // row ends in the derived chips (nodes/tokenized/tagged, a11y, changed-#N
2129
+ // attribution, the comment affordance) and expands (details/summary, pure
2130
+ // HTML) into the wireframe SVG plus the screen's governing spec clauses with
2131
+ // their receipt status — §3.4's render + wireframe + clauses, without
2132
+ // per-screen routing the console doesn't have.
2133
+
2134
+ // The same state-suffix grammar preview-service.mjs's stateVariantCards uses
2135
+ // (docs/proposals/component-system-deep-dive.md §6.5's `"home@empty"`
2136
+ // convention) — duplicated as a literal rather than imported to keep this
2137
+ // module free of preview-service imports (it's the other way around).
2138
+ const SCREEN_STATE_ID_RE = /^(.+)@(loading|empty|error)$/;
2139
+ const SCREEN_STATE_ORDER = ["loading", "empty", "error"];
2140
+
2141
+ /**
2142
+ * The spec clauses governing one screen — derived, never asserted: a live
2143
+ * clause governs screen `screenId` when at least one of its citing tests'
2144
+ * paths carries a path segment equal to the screen id (e.g.
2145
+ * `…/presentation/home/HomeScreenTest.kt` has segment `home`). That is the
2146
+ * only mapping the tree itself states (tests live in their feature's
2147
+ * directory); no name-similarity guessing. Returns `null` when specs data is
2148
+ * unavailable (not derivable at all — distinct from "derivable and empty").
2149
+ * @param {{available: boolean, files?: Array<{file: string, clauses: object[]}>}} specs specs.mjs getSpecsData() result
2150
+ * @param {string} screenId a BASE screen id (no @state suffix)
2151
+ * @returns {Array<{file: string, clause: object}>|null}
2152
+ */
2153
+ export function clausesForScreen(specs, screenId) {
2154
+ if (!specs || !specs.available || !Array.isArray(specs.files)) return null;
2155
+ const out = [];
2156
+ for (const f of specs.files) {
2157
+ for (const clause of f.clauses) {
2158
+ if (clause.withdrawn) continue;
2159
+ const cites = (clause.citedBy || []).some((site) =>
2160
+ String(site.file).split("/").some((seg) => seg === screenId),
2161
+ );
2162
+ if (cites) out.push({ file: f.file, clause });
2163
+ }
2164
+ }
2165
+ return out;
2166
+ }
2167
+
2168
+ /** One matrix cell: the live render (changed cells keep the hover before/after compare), or the quiet dash for an unregistered state. */
2169
+ function matrixCellHtml(card, state, changedSet, version) {
2170
+ if (!card) {
2171
+ const what = state === "default" ? "no default entry registered" : `no @${escAttr(state)} entry registered`;
2172
+ return `<div class="matrix-cell matrix-none" title="${what} for this screen">&mdash;</div>`;
2173
+ }
2174
+ const id = card.screen.id;
2175
+ const isChanged = changedSet.has(id);
2176
+ const compare = isChanged && version > 1;
2177
+ const buster = `?v=${Number(version)}`;
2178
+ const cur = `<img class="cur" alt="${escAttr(id)} render" src="/previews/${escAttr(card.screen.png)}${buster}">`;
2179
+ let inner = cur;
2180
+ let label = "";
2181
+ if (compare) {
2182
+ const prevPng = String(card.screen.png).replace(/screen\.png$/, "screen.prev.png");
2183
+ inner = `<div class="cmp">${cur}<img class="prev" alt="${escAttr(id)} before" src="/previews/${escAttr(prevPng)}${buster}"></div>`;
2184
+ label = `<p class="lbl">hover = before</p>`;
2185
+ }
2186
+ return `<div class="matrix-cell${isChanged ? " changed" : ""}">${inner}${label}</div>`;
2187
+ }
2188
+
2189
+ /** The row-end chips: derived counts + a11y from the DEFAULT render, changed-#N attribution, the comment affordance. */
2190
+ function matrixRowEndHtml(baseId, baseCard, changedVersions) {
2191
+ const changedIn = changedVersions[baseId];
2192
+ const chgChip = changedIn ? ` &middot; <span class="chg">changed #${Number(changedIn)}</span>` : "";
2193
+ const comment = commentControlHtml({ type: "screen", screen: baseId }, { testTagInput: true });
2194
+ if (!baseCard) {
2195
+ return `<p class="meta">Not derivable statically &mdash; no default render for this screen${chgChip}</p>${comment}`;
2196
+ }
2197
+ const { summary, a11y } = baseCard;
2198
+ const a11yChip = a11y.pass
2199
+ ? `<span class="pass">PASS</span>`
2200
+ : `<span class="fail">${esc(`${a11y.violations.length} violation${a11y.violations.length === 1 ? "" : "s"}`)}</span>`;
2201
+ return `<p class="meta">${summary.nodes} nodes &middot; ${summary.tokenized} tokenized &middot; ${summary.tagged} tagged</p>
2202
+ <p class="meta">a11y ${a11yChip}${chgChip}</p>
2203
+ ${comment}`;
2204
+ }
2205
+
2206
+ /** The expanded row's governing-clauses block — receipt status per clause via its own gate; honest absences in the standardized form. */
2207
+ function rowClausesHtml(specs, baseId, lastReceipt) {
2208
+ const governing = clausesForScreen(specs, baseId);
2209
+ if (governing === null) {
2210
+ return `<p class="empty-inline">governing clauses: Not derivable statically &mdash; no specs/ directory found</p>`;
2211
+ }
2212
+ if (governing.length === 0) {
2213
+ return `<p class="empty-inline">governing clauses: Not derivable statically &mdash; no spec clause's citing tests carry a <code>${esc(baseId)}</code> path segment</p>`;
2214
+ }
2215
+ const items = governing
2216
+ .map(({ file, clause }) => {
2217
+ const gate = gateForClause(clause);
2218
+ return ` <li class="clause">
2219
+ <span class="clause-id"><code>${esc(clause.id)}</code></span>
2220
+ <span class="clause-prose">${esc(clause.prose)}</span>
2221
+ ${gate ? stepReceiptCellHtml(lastReceipt, gate) : ""}
2222
+ ${commentControlHtml({ type: "spec-line", file: `specs/${file}`, clauseId: clause.id })}
2223
+ </li>`;
2224
+ })
2225
+ .join("\n");
2226
+ return `<p class="lbl">governing clauses &mdash; clauses whose citing tests live under <code>${esc(baseId)}</code></p>
2227
+ <ul class="clause-list">
2228
+ ${items}
2229
+ </ul>`;
2230
+ }
2231
+
2232
+ /**
2233
+ * The Screens section body (§3.4): the screen × state matrix. Pure — the
2234
+ * caller (preview-service.mjs's galleryHtml) passes the current render's
2235
+ * screen cards (component stories already excluded), the changed vocabulary,
2236
+ * and the specs/receipt data the expanded rows read.
2237
+ * @param {object} data { cards, changed, changedVersions, version, specs, lastReceipt }
2238
+ * `cards` = [{screen:{id,title,png}, svg, summary, a11y}], base entries and
2239
+ * `<base>@<state>` variants alike — the matrix regroups them by base id.
2240
+ */
2241
+ export function screensBodyHtml(data) {
2242
+ const {
2243
+ cards = [],
2244
+ changed = [],
2245
+ changedVersions = {},
2246
+ version = 0,
2247
+ specs = { available: false },
2248
+ lastReceipt = null,
2249
+ } = data || {};
2250
+ if (cards.length === 0) {
2251
+ return `<div class="empty">
2252
+ <p>No screens rendered yet.</p>
2253
+ <p>The preview loop fills this page on its first render &mdash; every entry in
2254
+ <code>${esc(COPY.previewRegistryFile)}</code> becomes a row.</p>
2255
+ </div>`;
2256
+ }
2257
+ const changedSet = new Set(changed);
2258
+ // Regroup the flat card list into rows: base id -> its default render +
2259
+ // whichever @state variants are registered. A variant whose base entry
2260
+ // isn't registered still gets a row (its default cell is then the dash and
2261
+ // its chips state their own underivability) — never silently dropped.
2262
+ const byBase = new Map();
2263
+ for (const card of cards) {
2264
+ const m = SCREEN_STATE_ID_RE.exec(card.screen.id);
2265
+ const baseId = m ? m[1] : card.screen.id;
2266
+ if (!byBase.has(baseId)) byBase.set(baseId, { base: null, variants: new Map() });
2267
+ if (m) byBase.get(baseId).variants.set(m[2], card);
2268
+ else byBase.get(baseId).base = card;
2269
+ }
2270
+ // Columns: default + only the states at least one screen registers, in the
2271
+ // fixed loading/empty/error order. No screen registers any state -> the
2272
+ // matrix is a single default column; nothing is fabricated.
2273
+ const stateCols = SCREEN_STATE_ORDER.filter((s) =>
2274
+ [...byBase.values()].some((row) => row.variants.has(s)),
2275
+ );
2276
+ const headCols = ["default", ...stateCols]
2277
+ .map((c) => `<span class="matrix-col">${esc(c)}</span>`)
2278
+ .join("");
2279
+ const rows = [...byBase.entries()]
2280
+ .map(([baseId, row]) => {
2281
+ const title = row.base ? row.base.screen.title : baseId;
2282
+ const rowChanged =
2283
+ changedSet.has(baseId) || [...row.variants.values()].some((c) => changedSet.has(c.screen.id));
2284
+ const cells = [
2285
+ matrixCellHtml(row.base, "default", changedSet, version),
2286
+ ...stateCols.map((s) => matrixCellHtml(row.variants.get(s) || null, s, changedSet, version)),
2287
+ ].join("\n ");
2288
+ const wire = row.base
2289
+ ? `<div class="wire">${row.base.svg}</div>`
2290
+ : `<p class="empty-inline">wireframe: Not derivable statically &mdash; no default render for this screen</p>`;
2291
+ return ` <section class="matrix-row${rowChanged ? " changed" : ""}" id="card-${esc(baseId)}">
2292
+ <div class="matrix-line">
2293
+ <div class="matrix-rowhead">
2294
+ <h3>${esc(title)}${rowChanged ? '<span class="flag">CHANGED</span>' : ""}</h3>
2295
+ <p class="meta">id <code>${esc(baseId)}</code></p>
2296
+ </div>
2297
+ <div class="matrix-cells">
2298
+ ${cells}
2299
+ </div>
2300
+ <div class="matrix-rowend">
2301
+ ${matrixRowEndHtml(baseId, row.base, changedVersions)}
2302
+ </div>
2303
+ </div>
2304
+ <details class="row-detail">
2305
+ <summary>structure &amp; governing clauses</summary>
2306
+ <div class="row-detail-body">
2307
+ ${wire}
2308
+ <div class="row-clauses">
2309
+ ${rowClausesHtml(specs, baseId, lastReceipt)}
2310
+ </div>
2311
+ </div>
2312
+ </details>
2313
+ </section>`;
2314
+ })
2315
+ .join("\n");
2316
+ return `<div class="matrix">
2317
+ <div class="matrix-head" aria-hidden="true"><span class="matrix-gutter"></span>${headCols}</div>
2318
+ ${rows}
2319
+ </div>
2320
+ <p class="meta matrix-note">An empty cell means the screen registers no entry for that state. States come from
2321
+ <code>@state</code> preview-registry entries in <code>${esc(COPY.previewRegistryFile)}</code> (e.g. <code>"home@empty"</code>).</p>`;
2322
+ }
2323
+
2324
+ // --- Intent (§3.0) — the product strategist's brief --------------------------
2325
+ //
2326
+ // The working-backwards brief rendered from the project's REAL specs/intent.md
2327
+ // (intent.mjs), section by section in the file's own order. Sections the
2328
+ // interview hasn't filled state themselves plainly — the seed template's own
2329
+ // "_not yet captured_" marker is the evidence, and its guidance prose is
2330
+ // rendered as the document's own prompt, muted. A project with no intent.md
2331
+ // at all gets the §3.0 placeholder styled as the document's own pending
2332
+ // state, never an error box. The ## Glossary section renders as a definition
2333
+ // table when its body is the template's `**Term** — definition` list (or a
2334
+ // GFM table); anything else renders as prose — no forced structure.
2335
+
2336
+ const GLOSSARY_ITEM_RE = /^[-*]\s+\*\*(.+?)\*\*\s*[—–-]\s*(.+)$/;
2337
+
2338
+ /**
2339
+ * Try to read a glossary body as term/definition rows. Returns null unless
2340
+ * EVERY non-blank line parses as either a `- **Term** — definition` item or a
2341
+ * GFM table row — a mixed body renders as prose instead of a half-parsed
2342
+ * table.
2343
+ * @returns {Array<{term: string, def: string}>|null}
2344
+ */
2345
+ function parseGlossaryRows(body) {
2346
+ const rows = [];
2347
+ const lines = String(body).split("\n").map((l) => l.trim()).filter(Boolean);
2348
+ if (lines.length === 0) return null;
2349
+ const tableLines = lines.filter((l) => l.startsWith("|"));
2350
+ if (tableLines.length === lines.length && tableLines.length >= 2) {
2351
+ // GFM table: skip the header + separator rows, take cell 1/2 per row.
2352
+ for (const l of tableLines.slice(2)) {
2353
+ const cells = l.split("|").map((c) => c.trim()).filter((c, i, a) => !(i === 0 && c === "") && !(i === a.length - 1 && c === ""));
2354
+ if (cells.length < 2) return null;
2355
+ rows.push({ term: cells[0], def: cells[1] });
2356
+ }
2357
+ return rows.length ? rows : null;
2358
+ }
2359
+ for (const l of lines) {
2360
+ const m = l.match(GLOSSARY_ITEM_RE);
2361
+ if (!m) return null;
2362
+ rows.push({ term: m[1], def: m[2] });
2363
+ }
2364
+ return rows.length ? rows : null;
2365
+ }
2366
+
2367
+ function glossaryHtml(body) {
2368
+ const rows = parseGlossaryRows(body);
2369
+ if (!rows) return `<div class="doc-prose">${mdProseHtml(body)}</div>`;
2370
+ const trs = rows
2371
+ .map((r) => ` <tr><td><strong>${inlineMdHtml(r.term)}</strong></td><td>${inlineMdHtml(r.def)}</td></tr>`)
2372
+ .join("\n");
2373
+ return `<table class="doc-table glossary-table">
2374
+ <thead><tr><th>Term</th><th>Definition</th></tr></thead>
2375
+ <tbody>
2376
+ ${trs}
2377
+ </tbody>
2378
+ </table>`;
2379
+ }
2380
+
2381
+ /**
2382
+ * The Intent section body (§3.0). Comment affordances target
2383
+ * {type:"spec-line", file:"specs/intent.md", clauseId:<heading>} — the
2384
+ * ledger's spec-line contract requires both fields (qa/lib/comments.mjs), and
2385
+ * for a prose brief the section heading IS the addressable unit.
2386
+ * @param {{available: boolean, reason?: string, sections?: Array<{heading: string, body: string, filled: boolean, guidance: string|null}>}} intent intent.mjs getIntentData() result
2387
+ */
2388
+ export function intentBodyHtml(intent) {
2389
+ if (!intent || !intent.available) {
2390
+ return `<div class="brief-pending">
2391
+ <p class="brief-pending-state">Not yet captured &mdash; conversation 0 pending.</p>
2392
+ <p>The genesis walk's first conversation writes <code>specs/intent.md</code> &mdash; purpose,
2393
+ audience, platforms, brand feel, reference apps, first screens, glossary. Every later
2394
+ artifact is expressed in the vocabulary this brief establishes.</p>
2395
+ </div>`;
2396
+ }
2397
+ if (!intent.sections || intent.sections.length === 0) {
2398
+ return `<div class="brief-pending">
2399
+ <p class="brief-pending-state">Not yet captured &mdash; conversation 0 pending.</p>
2400
+ <p><code>specs/intent.md</code> exists but carries no <code>##</code> sections yet.</p>
2401
+ </div>`;
2402
+ }
2403
+ const sections = intent.sections
2404
+ .map((sec) => {
2405
+ const comment = commentControlHtml({ type: "spec-line", file: "specs/intent.md", clauseId: sec.heading });
2406
+ let body;
2407
+ if (sec.filled) {
2408
+ body = /^glossary$/i.test(sec.heading)
2409
+ ? glossaryHtml(sec.body)
2410
+ : `<div class="doc-prose">${mdProseHtml(sec.body)}</div>`;
2411
+ } else {
2412
+ // The seed's own guidance prose (lead-in stripped) is the document's
2413
+ // words for what belongs here — rendered muted, never invented.
2414
+ const guidance = sec.guidance ? `<p class="brief-guidance">${inlineMdHtml(sec.guidance)}</p>` : "";
2415
+ body = `<p class="brief-pending-inline">Not yet captured &mdash; conversation 0 pending.</p>${guidance}`;
2416
+ }
2417
+ return ` <section class="brief-section${sec.filled ? "" : " brief-unfilled"}">
2418
+ <h3>${esc(sec.heading)}${comment}</h3>
2419
+ ${body}
2420
+ </section>`;
2421
+ })
2422
+ .join("\n");
2423
+ return `<div class="brief">
2424
+ ${sections}
2425
+ </div>`;
2426
+ }
2427
+
2428
+ // --- Walkthrough (A2/A3) — the generated report as a console section ---------
2429
+ //
2430
+ // Derived truth only: everything rendered here is read from a run's committed
2431
+ // manifest (walkthrough-data.mjs) — the console never re-walks or re-computes.
2432
+ // The section is the report's summary + deep links; report.html stays the
2433
+ // full-fidelity artifact.
2434
+
2435
+ export function walkthroughTabHtml(wt) {
2436
+ if (!wt || !wt.available) {
2437
+ return `<div class="empty">
2438
+ <p>No walkthrough runs yet.</p>
2439
+ <p>With the debug app live (adb forward tcp:9500), run <code>node qa/walkthrough.mjs</code> —
2440
+ it walks every tab and parameterless route, captures pixels + tree + a11y from one proven
2441
+ frame per screen, reads the DB at capture time, and writes a committable report under
2442
+ <code>qa/evidence/walkthrough/</code>.</p>
2443
+ </div>`;
2444
+ }
2445
+ const latest = wt.runs[0];
2446
+ const m = latest.manifest;
2447
+ const cards = (m.screens ?? [])
2448
+ .map((s) => {
2449
+ const a11yLine =
2450
+ (s.a11y?.violations ?? []).length === 0
2451
+ ? `<span class="ok-inline">a11y 0 violations</span>`
2452
+ : `<span class="bad-inline">a11y ${s.a11y.violations.length} violations</span>`;
2453
+ const settled = s.settled === false ? ` <span class="bad-inline">captured mid-load</span>` : "";
2454
+ const spec = s.spec ? ` · spec ${esc(s.spec.file)} (${s.spec.clauses.length} clauses)` : "";
2455
+ const variants = (s.variants ?? []).length ? ` · ${s.variants.length} tier-0 variants` : "";
2456
+ return ` <div class="wt-card">
2457
+ <img src="/walkthrough/${esc(latest.relDirBase || "")}/${esc(s.png)}" loading="lazy">
2458
+ <div class="wt-meta"><strong>${esc(s.id)}</strong> <span class="chip">${esc(s.kind)}</span>${settled}<br>
2459
+ route <code>${esc(s.route ?? "—")}</code> · ${s.nodes} nodes · ${a11yLine}${spec}${variants}</div>
2460
+ </div>`;
2461
+ })
2462
+ .join("\n");
2463
+ const notWalked = (m.notWalked ?? []).length
2464
+ ? ` <h3>Not walked</h3>
2465
+ <ul class="wt-notwalked">${m.notWalked.map((n) => `<li><code>${esc(n.target)}</code> — ${esc(n.reason)}</li>`).join("")}</ul>`
2466
+ : "";
2467
+ const db = m.db
2468
+ ? ` <h3>DB at capture time</h3>
2469
+ <table class="params-table"><thead><tr><th>table</th><th>rows</th></tr></thead><tbody>
2470
+ ${m.db.tables.map((t) => ` <tr><td><code>${esc(t.name)}</code></td><td>${t.error ? esc(t.error) : t.rowCount ?? "?"}</td></tr>`).join("\n")}
2471
+ </tbody></table>`
2472
+ : ` <p class="empty-inline">no DB appendix — Room off or the app predates /inspect/db</p>`;
2473
+ const history =
2474
+ wt.runs.length > 1
2475
+ ? ` <h3>Previous runs</h3>
2476
+ <ul class="wt-history">${wt.runs
2477
+ .slice(1)
2478
+ .map((r) =>
2479
+ r.error
2480
+ ? `<li>${esc(r.relDir)} — <span class="bad-inline">${esc(r.error)}</span></li>`
2481
+ : `<li>${esc(r.generatedAt)} — ${r.screenCount} screens, ${r.a11yViolations} a11y violations
2482
+ <span class="empty-inline">(diff: <code>node qa/walkthrough.mjs --compare ${esc(r.relDir)} ${esc(wt.runs[0].relDir)}</code>)</span></li>`
2483
+ )
2484
+ .join("")}</ul>`
2485
+ : "";
2486
+ return ` <p class="meta">latest: ${esc(latest.generatedAt)} · ${latest.screenCount} screens · ${latest.a11yViolations} a11y violations · ${latest.notWalked} not walked${latest.unsettled ? ` · <strong>${latest.unsettled} captured mid-load</strong>` : ""} —
2487
+ <a href="/walkthrough/${esc(latest.relDirBase || "")}/report.html" target="_blank">open full report</a></p>
2488
+ <div class="wt-grid">
2489
+ ${cards}
2490
+ </div>
2491
+ ${notWalked}
2492
+ ${db}
2493
+ ${history}`;
2494
+ }
2495
+
2496
+ // --- Live device (A1) — the console arc ends DRIVE ---------------------------
2497
+ //
2498
+ // Reachable: embed /inspect/remote (already a self-contained page that mirrors
2499
+ // and drives the real device) with the status chip. Unreachable: the honest
2500
+ // state + the Start button, which runs the whole chain (boot AVD if needed →
2501
+ // installDebug → launch → forward → health) server-side; the page polls
2502
+ // /live/status and renders each step's real outcome.
2503
+
2504
+ export function liveDeviceTabHtml(live, session) {
2505
+ const chainHtml = session && session.steps && session.steps.length
2506
+ ? ` <ol class="live-steps">
2507
+ ${session.steps
2508
+ .map(
2509
+ (s) =>
2510
+ ` <li class="live-step-${esc(s.status)}"><code>${esc(s.name)}</code> — ${esc(s.status)}${s.detail ? `: ${esc(s.detail)}` : ""}${s.ms != null ? ` <span class="empty-inline">(${Math.round(s.ms / 100) / 10}s)</span>` : ""}</li>`
2511
+ )
2512
+ .join("\n")}
2513
+ </ol>`
2514
+ : "";
2515
+ if (live && live.reachable) {
2516
+ return ` <p class="meta"><span class="ok-inline">●</span> ${esc(live.appId)} · ${esc(live.buildType)} · process started ${esc(
2517
+ live.processStartedAtMs ? new Date(live.processStartedAtMs).toISOString() : "unknown"
2518
+ )} — <a href="${esc(live.remoteUrl)}" target="_blank">open in its own tab</a></p>
2519
+ <iframe class="live-remote" src="${esc(live.remoteUrl)}" title="live device"></iframe>
2520
+ ${chainHtml}`;
2521
+ }
2522
+ return ` <p class="meta"><span class="bad-inline">○</span> ${esc(live ? live.reason : "status unknown")}</p>
2523
+ <p>Start the whole chain from here — boot a headless AVD if no device is attached, install the
2524
+ debug build, launch it, forward the inspector port, and wait for health:</p>
2525
+ <p><button id="live-start-btn"${session && session.running ? " disabled" : ""}>${session && session.running ? "Starting…" : "Start live session"}</button></p>
2526
+ ${chainHtml}
2527
+ <div id="live-error" class="banner" hidden></div>`;
2528
+ }
2529
+
2530
+ // --- Digest (B4) — what happened since you last looked -----------------------
2531
+ //
2532
+ // No longer a tab of its own (2026-08-22): a since-you-last-looked read that a
2533
+ // human has to go and find is not a returning human's first read. This renderer
2534
+ // is unchanged and now has exactly one caller — the front door's "What changed"
2535
+ // block (console-overview.mjs, STUDIO-REDESIGN.md §3.7).
2536
+ //
2537
+ // The narrative layer over ledgers that already exist (git log, committed
2538
+ // receipts, the approvals + comments ledgers). Every line is derived; the
2539
+ // digest can never disagree with the audit trail because it IS the audit
2540
+ // trail, grouped.
2541
+
2542
+ /**
2543
+ * The evidence cell of one lane-run row: the rung as attested at that commit,
2544
+ * the pack that graded it (§6.5 — this table is a rung surface like any other,
2545
+ * and it is the one a returning owner reads FIRST), then the strength phrase.
2546
+ * A row with no rung keeps the strength phrase alone rather than inventing one.
2547
+ */
2548
+ function laneRunEvidenceHtml(r) {
2549
+ const label = rungWithPack(r.rung, r.packId ?? r.pack);
2550
+ const strength = esc(r.strength ?? "—");
2551
+ if (!label) return strength;
2552
+ return `<span title="${escAttr(rungPackNote(r.rung, r.packId ?? r.pack))}">${esc(label)}</span> &mdash; ${strength}`;
2553
+ }
2554
+
2555
+ export function digestTabHtml(digest) {
2556
+ if (!digest || !digest.available) {
2557
+ return `<div class="empty"><p>No digest — ${esc(digest ? digest.reason : "unavailable")}</p></div>`;
2558
+ }
2559
+ const lane = digest.laneRuns.length
2560
+ ? ` <h3>Lane runs</h3>
2561
+ <table class="params-table"><thead><tr><th>when</th><th>commit</th><th>verdict</th><th>evidence</th></tr></thead><tbody>
2562
+ ${digest.laneRuns
2563
+ .map(
2564
+ (r) =>
2565
+ ` <tr><td>${esc(r.when)}</td><td><code>${esc(r.sha)}</code></td><td><span class="${r.verdict === "PASS" ? "ok-inline" : "bad-inline"}">${esc(r.verdict)}</span></td><td>${laneRunEvidenceHtml(r)}</td></tr>`
2566
+ )
2567
+ .join("\n")}
2568
+ </tbody></table>`
2569
+ : ` <h3>Lane runs</h3>
2570
+ <p class="empty-inline">no committed receipts in the window — the lane has not run (or its receipt was not committed)</p>`;
2571
+ const approvals = digest.approvalEvents.length
2572
+ ? ` <h3>Approval events</h3>
2573
+ <ul class="digest-list">${digest.approvalEvents.map((e) => `<li>${esc(e.when)} · <code>${esc(e.sha)}</code> — ${esc(e.subject)}</li>`).join("")}</ul>`
2574
+ : "";
2575
+ // A commit row states what the author MEANT (the subject) and what actually
2576
+ // MOVED (the file list). The second half is the only console surface a
2577
+ // non-governed change ever gets — nothing in qa/approvals.json covers the
2578
+ // data layer or the version catalog, so without this it changes silently.
2579
+ const commitFilesHtml = (files) => {
2580
+ if (!Array.isArray(files) || files.length === 0) return "";
2581
+ const shown = files.slice(0, 12);
2582
+ const more = files.length > shown.length ? `<li class="fd-more">… and ${files.length - shown.length} more</li>` : "";
2583
+ return `<details class="digest-files"><summary>${files.length} file${files.length === 1 ? "" : "s"}</summary>
2584
+ <ul class="digest-filelist">${shown
2585
+ .map((f) => `<li><span class="fd-status">${esc(f.status)}</span> <code>${esc(f.path)}</code></li>`)
2586
+ .join("")}${more}</ul></details>`;
2587
+ };
2588
+ const commits = digest.commits.length
2589
+ ? ` <h3>Commits</h3>
2590
+ <ul class="digest-list">${digest.commits
2591
+ .map(
2592
+ (c) =>
2593
+ `<li>${esc(c.when)} · <code>${esc(c.sha)}</code> — ${esc(c.subject)}${commitFilesHtml(c.files)}</li>`
2594
+ )
2595
+ .join("")}</ul>`
2596
+ : ` <p class="empty-inline">no commits in the window</p>`;
2597
+ const comments =
2598
+ digest.openComments == null
2599
+ ? ""
2600
+ : ` <p class="meta">${digest.openComments} open comment${digest.openComments === 1 ? "" : "s"} awaiting action</p>`;
2601
+ // The window line belongs to the CALLER now: digestTabHtml renders only
2602
+ // inside the front door (§3.0), whose "What changed" heading already states
2603
+ // it. Printing it twice and hiding one copy with CSS would suppress a fact
2604
+ // to fix a layout — in a console whose rule is evidence-or-silence, the
2605
+ // duplicate gets deleted, not concealed.
2606
+ return `${comments}
2607
+ ${lane}
2608
+ ${approvals}
2609
+ ${commits}`;
2610
+ }
2611
+
2612
+ /**
2613
+ * §Features — the per-feature view (CHANGE-FLOW-DESIGN.md §6): one card per
2614
+ * feature brief (any docs/features/*.md — location is the governance opt-in),
2615
+ * composing the brief's signature with the feature's DERIVED state from the
2616
+ * project's own library (getFeatureBoard — this file composes NOTHING; the
2617
+ * model lives in the project's qa/lib).
2618
+ *
2619
+ * The card is the one loop made visible:
2620
+ * proposed → the human signs the brief (before code — the Approve button)
2621
+ * approved → the agent builds; the clause slice below is the live tally
2622
+ * proven → derived, never claimed: every live clause cited + receipt
2623
+ * PASS + receipt attests THIS tree (the doneReason line)
2624
+ * accepted → the human's bookend — "the proven thing is what I wanted"
2625
+ *
2626
+ * The clause table is this feature's slice of the RTM — the same scan the
2627
+ * lane's specCoverage gate runs, so this view and the lane can never disagree.
2628
+ * Declared-vs-actual blast radius is the spec-mirror-drift principle applied
2629
+ * to PLANS: `touches` is what the brief declared it would invalidate; each
2630
+ * entry renders with the touched artifact's LIVE status — drift on a declared
2631
+ * artifact reads "as declared" (expected re-approval), and drift nowhere
2632
+ * declared surfaces in the `undeclared` banner as plan-drift.
2633
+ */
2634
+ export function featuresTabHtml(features, meta = {}) {
2635
+ if (!features || !features.available) {
2636
+ return ` <p class="empty-inline">feature briefs are not available in this project — its qa/lib/approvals.mjs has no acceptFeature export (it predates the feature-brief wave, or its step pack does not govern briefs), or the library is absent. A feature brief is a <code>docs/features/&lt;name&gt;.md</code>; its location is the governance opt-in.</p>`;
2637
+ }
2638
+ const { board } = features;
2639
+ if (!board || board.features.length === 0) {
2640
+ return ` <p class="empty-inline">no feature briefs yet. A brief is born by writing <code>docs/features/&lt;name&gt;.md</code> — the decisions and their why, signed BEFORE the feature is built. Its location is the governance opt-in; it appears here as a governed <code>feature-brief:&lt;name&gt;</code> artifact the moment the file exists.</p>`;
2641
+ }
2642
+
2643
+ const undeclared =
2644
+ board.undeclared.length > 0
2645
+ ? ` <div class="feature-undeclared"><strong>Undeclared blast:</strong> ${board.undeclared
2646
+ .map((u) => `<code>${esc(u.id)}</code>`)
2647
+ .join(", ")} changed since approval, and no open brief declared touching ${board.undeclared.length === 1 ? "it" : "them"} — either a brief's <code>touches</code> is incomplete, or this drift belongs to no feature. The approvals gate is already failing on it; the plan should say why.</div>`
2648
+ : "";
2649
+
2650
+ const phaseChip = (phase) => {
2651
+ const cls =
2652
+ phase === "accepted"
2653
+ ? "phase-accepted"
2654
+ : phase === "proven"
2655
+ ? "phase-proven"
2656
+ : phase === "approved"
2657
+ ? "phase-approved"
2658
+ : phase === "changed-since-approval"
2659
+ ? "phase-drift"
2660
+ : phase === "reopened"
2661
+ ? "phase-reopened"
2662
+ : "phase-proposed";
2663
+ return `<span class="feature-phase ${cls}">${esc(phase)}</span>`;
2664
+ };
2665
+
2666
+ const cards = board.features
2667
+ .map((f) => {
2668
+ // This feature's slice of the RTM: its spec's clauses with their live
2669
+ // citation state. A withdrawn clause renders struck and never counts.
2670
+ const clauseRows = f.clauses
2671
+ .map((c) => {
2672
+ const mark = c.withdrawn
2673
+ ? `<span class="pending-inline">—</span>`
2674
+ : c.cited
2675
+ ? `<span class="ok-inline">✓</span>`
2676
+ : `<span class="pending-inline">○</span>`;
2677
+ const id = c.withdrawn ? `<s><code>${esc(c.id)}</code></s>` : `<code>${esc(c.id)}</code>`;
2678
+ const state = c.withdrawn ? "withdrawn" : c.cited ? "cited by a test" : "no citing test yet";
2679
+ return ` <tr><td>${mark}</td><td>${id}</td><td class="feature-check-detail">${state}</td></tr>`;
2680
+ })
2681
+ .join("\n");
2682
+ const clauseTable = f.specExists
2683
+ ? f.clauses.length > 0
2684
+ ? ` <table class="params-table feature-checks"><thead><tr><th></th><th>clause</th><th><code>${esc(f.specRel)}</code></th></tr></thead><tbody>\n${clauseRows}\n </tbody></table>`
2685
+ : ` <p class="empty-inline"><code>${esc(f.specRel)}</code> exists but has no clauses yet — behavior starts as clauses there.</p>`
2686
+ : ` <p class="empty-inline">no spec yet (<code>${esc(f.specRel)}</code>) — the contract step: behavior starts as clauses there, signed before the build.</p>`;
2687
+
2688
+ // Derived doneness, stated in one honest line — the same doneReason the
2689
+ // CLI's --status prints; green only when every conjunct holds.
2690
+ const receiptNote = f.receipt.present
2691
+ ? `receipt ${esc(f.receipt.verdict ?? "?")}${f.receipt.verdict === "PASS" ? (f.receipt.attestsTree ? " · attests this tree" : " · attests an OLDER tree") : ""}`
2692
+ : "no receipt yet";
2693
+ const doneLine = ` <p class="feature-done ${f.provenDone ? "feature-done-yes" : "meta"}">${
2694
+ f.provenDone ? "✓ proven done" : "not yet proven done"
2695
+ } — ${esc(f.doneReason)}</p>`;
2696
+
2697
+ // Declared blast, stated as what it MEANS for the existing features —
2698
+ // "feature-spec:today · approved" says nothing; "today's contract will
2699
+ // be reopened and amended" is what the human actually signed up for.
2700
+ const touches =
2701
+ f.touches.length > 0
2702
+ ? ` <p class="feature-touches">declares touching: ${f.touches
2703
+ .map((t) => {
2704
+ const drifted = t.status === "changed-since-approval";
2705
+ const isSpec = t.id.startsWith("feature-spec:");
2706
+ const note = drifted
2707
+ ? ` <span class="feature-as-declared">(as declared — re-approve when shaped)</span>`
2708
+ : isSpec && t.status === "approved" && f.phase !== "accepted"
2709
+ ? ` <span class="feature-as-declared">(this contract will be reopened &amp; amended)</span>`
2710
+ : !isSpec && t.status === "approved" && f.phase !== "accepted"
2711
+ ? ` <span class="feature-as-declared">(re-approval expected when the work lands)</span>`
2712
+ : "";
2713
+ return `<code>${esc(t.id)}</code>&nbsp;<span class="${drifted ? "status-drift" : "meta"}">${esc(t.status)}</span>${note}`;
2714
+ })
2715
+ .join(" · ")}</p>`
2716
+ : ` <p class="feature-touches meta">declares touching nothing beyond its own spec</p>`;
2717
+
2718
+ // The signed substance: the decisions section(s) inline — an approval
2719
+ // moment must show WHAT is being approved — and the full brief a click
2720
+ // away. Evidence-or-silence: no /decision/i heading → no invented
2721
+ // summary, just the full document.
2722
+ const sections = Array.isArray(f.sections) ? f.sections : [];
2723
+ const decisionSections = sections.filter((s) => /decision/i.test(s.heading));
2724
+ const decisionsHtml = decisionSections
2725
+ .map(
2726
+ (s) => ` <div class="feature-decisions">
2727
+ <h4>${esc(s.heading)}</h4>
2728
+ <div class="doc-prose">${mdProseHtml(s.body)}</div>
2729
+ </div>`,
2730
+ )
2731
+ .join("\n");
2732
+ const fullBriefHtml =
2733
+ sections.length > 0
2734
+ ? ` <details class="feature-brief-full"><summary>the full brief (${sections.length} sections — the signed document)</summary>
2735
+ ${sections.map((s) => ` <h4>${esc(s.heading)}</h4>\n <div class="doc-prose">${mdProseHtml(s.body)}</div>`).join("\n")}
2736
+ </details>`
2737
+ : "";
2738
+
2739
+ // The design gate (brief → design → spec → build): a feature with a UI
2740
+ // surface carries a feature-design:<name> artifact, signed on RENDERED
2741
+ // output in the Screens gallery — never on this card's prose. null =
2742
+ // pure-logic feature, no rung to show (the honest skip). The button
2743
+ // speaks the same approve-btn contract as every signature control.
2744
+ const designHtml = f.design
2745
+ ? f.design.status === "approved"
2746
+ ? ` <p class="feature-design meta">design signed — ${f.design.fileCount} screen file(s) under governance</p>`
2747
+ : f.design.status === "reopened"
2748
+ ? ` <p class="feature-design"><span class="status-reopened">design reopened</span> — redesign in progress; re-approve when it lands</p>`
2749
+ : !f.design.resolvable
2750
+ ? ` <p class="feature-design meta">design not drafted yet — the agent drafts the screens on stub data; you sign what renders, in the Screens gallery</p>`
2751
+ : ` <p class="feature-design"><span class="${f.design.status === "changed-since-approval" ? "status-drift" : "pending-inline"}">${
2752
+ f.design.status === "changed-since-approval" ? "design changed since signature" : "design awaits your signature"
2753
+ }</span> — judge it on the rendered screens, then
2754
+ <button type="button" class="approve-btn" data-artifact="${escAttr(f.design.id)}">${f.design.status === "changed-since-approval" ? "Re-approve design" : "Approve design"}</button></p>`
2755
+ : "";
2756
+
2757
+ // The derived next step — computed like provenDone, never claimed. A
2758
+ // signature HANDS OFF: the line names the step and its owner, so the
2759
+ // card always says what happens next and who does it.
2760
+ const nextHtml = f.nextStep
2761
+ ? ` <p class="feature-next">next &rarr; ${esc(f.nextStep.label)}${f.nextStep.owner ? ` <span class="meta">&middot; ${esc(f.nextStep.owner)}</span>` : ""}</p>`
2762
+ : "";
2763
+
2764
+ const stamps = [];
2765
+ if (f.record && f.record.approvedAt) stamps.push(`signed ${esc(f.record.approvedAt)}${f.record.via ? ` via ${esc(f.record.via)}` : ""}`);
2766
+ if (f.record && f.record.accepted) stamps.push(`accepted ${esc(f.record.acceptedAt ?? "?")}`);
2767
+ // A reopened brief answers "why?" right on the card (07-28 audit:
2768
+ // attribution + reason are recorded at reopen time; older rows without
2769
+ // them just say when).
2770
+ if (f.record && f.record.status === "reopened" && f.record.reopenedAt)
2771
+ stamps.push(
2772
+ `reopened ${esc(f.record.reopenedAt)}${f.record.via ? ` via ${esc(f.record.via)}` : ""}${f.record.reason ? ` — ${esc(f.record.reason)}` : ""}`,
2773
+ );
2774
+
2775
+ // The two human moments, each offered only when it is the real next
2776
+ // step: Approve (unsigned, drifted, or REOPENED brief) and Accept
2777
+ // (enabled only at provenDone — the library refuses anything else, the
2778
+ // button just doesn't pretend otherwise). There is no agent verb here.
2779
+ //
2780
+ // `reopened` belongs here for the same reason the other two do: it is a
2781
+ // phase whose next step is a human signature. The card already SAYS so —
2782
+ // its derived next line reads "finish the redesign, then re-approve the
2783
+ // brief · human" — and omitting the control left the one card that
2784
+ // renders the whole brief as the one place you could read it and not act
2785
+ // on it, sending you to the Approvals table to do what this card just
2786
+ // asked for. "Sign where you read" is the rule; this was the gap in it.
2787
+ const briefAwaitsSignature =
2788
+ f.phase === "proposed" || f.phase === "changed-since-approval" || f.phase === "reopened";
2789
+ const actions = [];
2790
+ if (briefAwaitsSignature) {
2791
+ actions.push(`<button type="button" class="approve-btn" data-artifact="feature-brief:${escAttr(f.name)}">${f.phase === "proposed" ? "Approve brief" : "Re-approve brief"}</button>`);
2792
+ }
2793
+ if (f.phase === "proven") {
2794
+ actions.push(`<button type="button" class="feature-accept-btn" data-name="${escAttr(f.name)}">Accept</button>`);
2795
+ }
2796
+ if (f.phase === "approved") {
2797
+ actions.push(`<span class="meta">building — Accept enables when doneness derives (${f.covered}/${f.total} clauses cited, ${receiptNote})</span>`);
2798
+ }
2799
+ if (f.blockError) {
2800
+ actions.push(`<span class="status-drift">cmp:feature block: ${esc(f.blockError)}</span>`);
2801
+ }
2802
+
2803
+ return ` <article class="feature-card${f.phase === "accepted" ? " feature-card-closed" : ""}">
2804
+ <header class="feature-card-head">
2805
+ <h3>${esc(f.name)}</h3>
2806
+ ${phaseChip(f.phase)}
2807
+ <span class="feature-tally">${f.covered}/${f.total} clauses cited</span>
2808
+ <a class="feature-doc-link" href="#" title="${escAttr(f.rel)}">${esc(f.rel)}</a>
2809
+ </header>
2810
+ ${stamps.length > 0 ? `<p class="meta">${stamps.join(" · ")}</p>` : ""}
2811
+ ${nextHtml}
2812
+ ${designHtml}
2813
+ ${doneLine}
2814
+ ${decisionsHtml}
2815
+ ${touches}
2816
+ ${clauseTable}
2817
+ ${fullBriefHtml}
2818
+ ${actions.length > 0 ? `<div class="feature-actions">${actions.join(" ")}</div>` : ""}
2819
+ </article>`;
2820
+ })
2821
+ .join("\n");
2822
+
2823
+ return `${undeclared}
2824
+ <div class="feature-board">
2825
+ ${cards}
2826
+ </div>
2827
+ <p class="empty-inline sig-error" id="feature-error" hidden></p>`;
2828
+ }