create-cmp-cli 0.23.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.
- package/bin/create-cmp.mjs +33 -3
- package/package.json +10 -4
- package/packages/harness/package.json +18 -8
- package/packages/harness/src/approve.mjs +46 -18
- package/packages/harness/src/console/console-data.mjs +117 -0
- package/packages/harness/src/console/console-evidence.mjs +122 -0
- package/packages/harness/src/console/console-overview.mjs +642 -0
- package/packages/harness/src/console/console-shell.mjs +1139 -0
- package/packages/harness/src/console/console-tabs.mjs +2828 -0
- package/packages/harness/src/console/contrast.mjs +74 -0
- package/packages/harness/src/console/preview-service.mjs +1384 -0
- package/packages/harness/src/framework-check.mjs +747 -0
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- package/packages/harness/src/lib/agent-hold.mjs +234 -0
- package/packages/harness/src/lib/approvals.mjs +177 -350
- package/packages/harness/src/lib/audit-cadence.mjs +1 -1
- package/packages/harness/src/lib/comments.mjs +18 -4
- package/packages/harness/src/lib/determinism.mjs +275 -14
- package/packages/harness/src/lib/evidence-badge.mjs +38 -6
- package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
- package/packages/harness/src/lib/evidence-level.mjs +144 -59
- package/packages/harness/src/lib/feature-brief.mjs +53 -16
- package/packages/harness/src/lib/flight-recorder.mjs +72 -8
- package/packages/harness/src/lib/framework-check.mjs +778 -0
- package/packages/harness/src/lib/harness-lock.mjs +87 -19
- package/packages/harness/src/lib/harness-manifest.mjs +208 -0
- package/packages/harness/src/lib/harness-region.mjs +106 -2
- package/packages/harness/src/lib/harness-source.mjs +142 -0
- package/packages/harness/src/lib/inputs-hash.mjs +180 -22
- package/packages/harness/src/lib/lane-markers.mjs +78 -0
- package/packages/harness/src/lib/lane-narrator.mjs +4 -2
- package/packages/harness/src/lib/lane-runner.mjs +72 -6
- package/packages/harness/src/lib/plan.mjs +44 -11
- package/packages/harness/src/lib/plant-calibration.mjs +135 -0
- package/packages/harness/src/lib/profile-loader.mjs +323 -0
- package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
- package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
- package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
- package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
- package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
- package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
- package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
- package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
- package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
- package/packages/harness/src/lib/receipt-validate.mjs +84 -16
- package/packages/harness/src/lib/spec-coverage.mjs +179 -92
- package/packages/harness/src/lib/spec-model.mjs +262 -0
- package/packages/harness/src/lib/step-cache.mjs +11 -2
- package/packages/harness/src/lib/step-outcomes.mjs +84 -151
- package/packages/harness/src/lib/walk.mjs +1 -1
- package/packages/harness/src/plan.mjs +30 -1
- package/packages/harness/src/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +95 -26
- package/packages/harness/src/record-audit.mjs +1 -1
- package/packages/harness/src/scaffold-feature.mjs +10 -2
- package/packages/harness/src/verify.mjs +222 -142
- package/packages/harness/src/watch.mjs +146 -33
- package/packages/receipts/package.json +1 -1
- package/packages/receipts/src/index.mjs +2 -2
- package/packages/receipts/src/inputs-hash.mjs +180 -22
- package/packages/receipts/src/receipt-validate.mjs +84 -16
- package/src/commands/attach.mjs +128 -4
- package/src/commands/harden.mjs +7 -1
- package/src/commands/upgrade.mjs +7 -0
- package/src/lib/minimal.mjs +23 -1
- package/src/scaffold.mjs +8 -0
- package/template/.claude/settings.json +2 -2
- package/template/.claude/skills/add-feature/SKILL.md +7 -3
- package/template/.claude/skills/add-repository/SKILL.md +2 -2
- package/template/.claude/skills/add-screen/SKILL.md +8 -4
- package/template/.githooks/pre-push +9 -0
- package/template/CLAUDE.md +55 -186
- package/template/docs/TESTING.md +1 -1
- package/template/gitignore +4 -0
- package/template/qa/approve.mjs +46 -18
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +747 -0
- package/template/qa/harness-manifest.json +10 -0
- package/template/qa/lib/a11y.mjs +1 -1
- package/template/qa/lib/affected-tests.mjs +99 -44
- package/template/qa/lib/agent-hold.mjs +234 -0
- package/template/qa/lib/approvals.mjs +177 -350
- package/template/qa/lib/audit-cadence.mjs +1 -1
- package/template/qa/lib/comments.mjs +18 -4
- package/template/qa/lib/determinism.mjs +275 -14
- package/template/qa/lib/evidence-badge.mjs +38 -6
- package/template/qa/lib/evidence-ladder.mjs +202 -0
- package/template/qa/lib/evidence-level.mjs +144 -59
- package/template/qa/lib/feature-brief.mjs +53 -16
- package/template/qa/lib/flight-recorder.mjs +72 -8
- package/template/qa/lib/framework-check.mjs +778 -0
- package/template/qa/lib/harness-lock.mjs +87 -19
- package/template/qa/lib/harness-manifest.mjs +208 -0
- package/template/qa/lib/harness-region.mjs +106 -2
- package/template/qa/lib/harness-source.mjs +142 -0
- package/template/qa/lib/inputs-hash.mjs +180 -22
- package/template/qa/lib/lane-markers.mjs +78 -0
- package/template/qa/lib/lane-narrator.mjs +4 -2
- package/template/qa/lib/lane-runner.mjs +72 -6
- package/template/qa/lib/plan.mjs +44 -11
- package/template/qa/lib/plant-calibration.mjs +135 -0
- package/template/qa/lib/profile-loader.mjs +323 -0
- package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
- package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
- package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
- package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
- package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/template/qa/lib/profiles/cmp/index.mjs +47 -0
- package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
- package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
- package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
- package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
- package/template/qa/lib/receipt-validate.mjs +84 -16
- package/template/qa/lib/spec-coverage.mjs +179 -92
- package/template/qa/lib/spec-model.mjs +262 -0
- package/template/qa/lib/step-cache.mjs +11 -2
- package/template/qa/lib/step-outcomes.mjs +84 -151
- package/template/qa/lib/walk.mjs +1 -1
- package/template/qa/plan.mjs +30 -1
- package/template/qa/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +95 -26
- package/template/qa/record-audit.mjs +1 -1
- package/template/qa/scaffold-feature.mjs +10 -2
- package/template/qa/verified-surface.json +17 -0
- package/template/qa/verify.mjs +222 -142
- package/template/qa/watch.mjs +146 -33
- /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
- /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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, """);
|
|
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">💬</button>
|
|
112
|
+
<span class="comment-popover" hidden>
|
|
113
|
+
${testTagField}
|
|
114
|
+
<textarea class="comment-text" rows="2" placeholder="Add a comment…"></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 · <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)} · ${esc(
|
|
277
|
+
anchored.anchorWhen || "",
|
|
278
|
+
)}${anchored.truncated ? " · 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 — ${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> — 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> — <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 — ${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 — 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 ≥ 4.5:1 · AAA ≥ 7:1 · 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 — 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(" · ");
|
|
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 & 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 — 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 “${esc(v.name)}”</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 · ${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 · 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><screenTag>_${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 — <code>${esc(id)}</code></p>
|
|
771
|
+
<p class="empty-inline">no story render yet — 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 — <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 @${esc(state)} render</p>
|
|
814
|
+
<p class="empty-inline">Not derivable statically — 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 @${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">⚠ 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 — 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 — 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 — 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 — the promotion queue</h3>
|
|
971
|
+
<p class="meta">Ungoverned composables (seam wrappers excluded). Signals only — whether each
|
|
972
|
+
is promoted, kept local, or generalized is a judgment call made against the inclusion rubric
|
|
973
|
+
(ARCHITECTURE.md §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)} → unresolvable`
|
|
1039
|
+
: `approved ${shortHash(s.storedHash)} → 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 — 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 — citing tests not indexed</span>`;
|
|
1148
|
+
}
|
|
1149
|
+
return `<span class="rtm-defect">defect — 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(" · ");
|
|
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">—</span>`}</td>
|
|
1191
|
+
<td>${gate ? stepReceiptCellHtml(meta.lastReceipt, gate) : `<span class="empty-inline">—</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 — 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">✓</span> pinned as documented`;
|
|
1442
|
+
if (status === "drift") return `<span class="glyph glyph-drift">⚠</span> drift — the doc says otherwise`;
|
|
1443
|
+
if (status === "undocumented") return `<span class="glyph glyph-unsigned">○</span> not named in §2`;
|
|
1444
|
+
return `<span class="glyph glyph-drift">⚠</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">✓</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">⚠</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 — 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> → <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 — 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)} → ${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) — 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> · 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 — 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 — 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} — 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 ? " · 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 — 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 ? " · 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) — 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 & 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 & deployment view</h3>
|
|
1743
|
+
${platformViewHtml(doc && doc.platformView)}
|
|
1744
|
+
</section>
|
|
1745
|
+
<section class="arch-section" id="arch-building-blocks">
|
|
1746
|
+
<h3>5. Building blocks — 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> → <code>${esc(shortHash(r.currentInputsHash))}</code>)`
|
|
1825
|
+
: "";
|
|
1826
|
+
return `<span class="evidence-binding-stale">inputs no longer match the current tree${move} — 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 — hash <code>${esc(shortHash(r.inputsHash))}</code> still matches${files}`;
|
|
1831
|
+
}
|
|
1832
|
+
return `inputs binding unknown — ${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} · ${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(" · ")}</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> — 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 — 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
|
+
? ` · ${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"} · ${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(" · ");
|
|
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")} — 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> — 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 — 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 ? ` · element <code>${esc(t.testTag)}</code>` : ""}`;
|
|
2057
|
+
case "spec-line":
|
|
2058
|
+
return `spec <code>${esc(t.file)}</code>${t.clauseId ? ` · 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 — use the 💬 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">—</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 ? ` · <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 — 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 · ${summary.tokenized} tokenized · ${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 — no specs/ directory found</p>`;
|
|
2211
|
+
}
|
|
2212
|
+
if (governing.length === 0) {
|
|
2213
|
+
return `<p class="empty-inline">governing clauses: Not derivable statically — 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 — 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 — 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 — 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 & 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 — conversation 0 pending.</p>
|
|
2392
|
+
<p>The genesis walk's first conversation writes <code>specs/intent.md</code> — 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 — 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 — 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> — ${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/<name>.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/<name>.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:<name></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 & 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> <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 → ${esc(f.nextStep.label)}${f.nextStep.owner ? ` <span class="meta">· ${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
|
+
}
|