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.
Files changed (145) hide show
  1. package/bin/create-cmp.mjs +33 -3
  2. package/package.json +10 -4
  3. package/packages/harness/package.json +18 -8
  4. package/packages/harness/src/approve.mjs +46 -18
  5. package/packages/harness/src/console/console-data.mjs +117 -0
  6. package/packages/harness/src/console/console-evidence.mjs +122 -0
  7. package/packages/harness/src/console/console-overview.mjs +642 -0
  8. package/packages/harness/src/console/console-shell.mjs +1139 -0
  9. package/packages/harness/src/console/console-tabs.mjs +2828 -0
  10. package/packages/harness/src/console/contrast.mjs +74 -0
  11. package/packages/harness/src/console/preview-service.mjs +1384 -0
  12. package/packages/harness/src/framework-check.mjs +747 -0
  13. package/packages/harness/src/lib/a11y.mjs +1 -1
  14. package/packages/harness/src/lib/affected-tests.mjs +99 -44
  15. package/packages/harness/src/lib/agent-hold.mjs +234 -0
  16. package/packages/harness/src/lib/approvals.mjs +177 -350
  17. package/packages/harness/src/lib/audit-cadence.mjs +1 -1
  18. package/packages/harness/src/lib/comments.mjs +18 -4
  19. package/packages/harness/src/lib/determinism.mjs +275 -14
  20. package/packages/harness/src/lib/evidence-badge.mjs +38 -6
  21. package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
  22. package/packages/harness/src/lib/evidence-level.mjs +144 -59
  23. package/packages/harness/src/lib/feature-brief.mjs +53 -16
  24. package/packages/harness/src/lib/flight-recorder.mjs +72 -8
  25. package/packages/harness/src/lib/framework-check.mjs +778 -0
  26. package/packages/harness/src/lib/harness-lock.mjs +87 -19
  27. package/packages/harness/src/lib/harness-manifest.mjs +208 -0
  28. package/packages/harness/src/lib/harness-region.mjs +106 -2
  29. package/packages/harness/src/lib/harness-source.mjs +142 -0
  30. package/packages/harness/src/lib/inputs-hash.mjs +180 -22
  31. package/packages/harness/src/lib/lane-markers.mjs +78 -0
  32. package/packages/harness/src/lib/lane-narrator.mjs +4 -2
  33. package/packages/harness/src/lib/lane-runner.mjs +72 -6
  34. package/packages/harness/src/lib/plan.mjs +44 -11
  35. package/packages/harness/src/lib/plant-calibration.mjs +135 -0
  36. package/packages/harness/src/lib/profile-loader.mjs +323 -0
  37. package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
  38. package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
  39. package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
  40. package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  41. package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
  42. package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
  43. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  44. package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
  45. package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
  46. package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
  47. package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
  48. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
  49. package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
  50. package/packages/harness/src/lib/receipt-validate.mjs +84 -16
  51. package/packages/harness/src/lib/spec-coverage.mjs +179 -92
  52. package/packages/harness/src/lib/spec-model.mjs +262 -0
  53. package/packages/harness/src/lib/step-cache.mjs +11 -2
  54. package/packages/harness/src/lib/step-outcomes.mjs +84 -151
  55. package/packages/harness/src/lib/walk.mjs +1 -1
  56. package/packages/harness/src/plan.mjs +30 -1
  57. package/packages/harness/src/preview-gallery.mjs +1 -1
  58. package/packages/harness/src/receipt-check.mjs +95 -26
  59. package/packages/harness/src/record-audit.mjs +1 -1
  60. package/packages/harness/src/scaffold-feature.mjs +10 -2
  61. package/packages/harness/src/verify.mjs +222 -142
  62. package/packages/harness/src/watch.mjs +146 -33
  63. package/packages/receipts/package.json +1 -1
  64. package/packages/receipts/src/index.mjs +2 -2
  65. package/packages/receipts/src/inputs-hash.mjs +180 -22
  66. package/packages/receipts/src/receipt-validate.mjs +84 -16
  67. package/src/commands/attach.mjs +128 -4
  68. package/src/commands/harden.mjs +7 -1
  69. package/src/commands/upgrade.mjs +7 -0
  70. package/src/lib/minimal.mjs +23 -1
  71. package/src/scaffold.mjs +8 -0
  72. package/template/.claude/settings.json +2 -2
  73. package/template/.claude/skills/add-feature/SKILL.md +7 -3
  74. package/template/.claude/skills/add-repository/SKILL.md +2 -2
  75. package/template/.claude/skills/add-screen/SKILL.md +8 -4
  76. package/template/.githooks/pre-push +9 -0
  77. package/template/CLAUDE.md +55 -186
  78. package/template/docs/TESTING.md +1 -1
  79. package/template/gitignore +4 -0
  80. package/template/qa/approve.mjs +46 -18
  81. package/template/qa/comments.json +1 -1
  82. package/template/qa/evidence/schema.json +258 -34
  83. package/template/qa/framework-check.mjs +747 -0
  84. package/template/qa/harness-manifest.json +10 -0
  85. package/template/qa/lib/a11y.mjs +1 -1
  86. package/template/qa/lib/affected-tests.mjs +99 -44
  87. package/template/qa/lib/agent-hold.mjs +234 -0
  88. package/template/qa/lib/approvals.mjs +177 -350
  89. package/template/qa/lib/audit-cadence.mjs +1 -1
  90. package/template/qa/lib/comments.mjs +18 -4
  91. package/template/qa/lib/determinism.mjs +275 -14
  92. package/template/qa/lib/evidence-badge.mjs +38 -6
  93. package/template/qa/lib/evidence-ladder.mjs +202 -0
  94. package/template/qa/lib/evidence-level.mjs +144 -59
  95. package/template/qa/lib/feature-brief.mjs +53 -16
  96. package/template/qa/lib/flight-recorder.mjs +72 -8
  97. package/template/qa/lib/framework-check.mjs +778 -0
  98. package/template/qa/lib/harness-lock.mjs +87 -19
  99. package/template/qa/lib/harness-manifest.mjs +208 -0
  100. package/template/qa/lib/harness-region.mjs +106 -2
  101. package/template/qa/lib/harness-source.mjs +142 -0
  102. package/template/qa/lib/inputs-hash.mjs +180 -22
  103. package/template/qa/lib/lane-markers.mjs +78 -0
  104. package/template/qa/lib/lane-narrator.mjs +4 -2
  105. package/template/qa/lib/lane-runner.mjs +72 -6
  106. package/template/qa/lib/plan.mjs +44 -11
  107. package/template/qa/lib/plant-calibration.mjs +135 -0
  108. package/template/qa/lib/profile-loader.mjs +323 -0
  109. package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
  110. package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
  111. package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
  112. package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  113. package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
  114. package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
  115. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  116. package/template/qa/lib/profiles/cmp/index.mjs +47 -0
  117. package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
  118. package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
  119. package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
  120. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
  121. package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
  122. package/template/qa/lib/receipt-validate.mjs +84 -16
  123. package/template/qa/lib/spec-coverage.mjs +179 -92
  124. package/template/qa/lib/spec-model.mjs +262 -0
  125. package/template/qa/lib/step-cache.mjs +11 -2
  126. package/template/qa/lib/step-outcomes.mjs +84 -151
  127. package/template/qa/lib/walk.mjs +1 -1
  128. package/template/qa/plan.mjs +30 -1
  129. package/template/qa/preview-gallery.mjs +1 -1
  130. package/template/qa/receipt-check.mjs +95 -26
  131. package/template/qa/record-audit.mjs +1 -1
  132. package/template/qa/scaffold-feature.mjs +10 -2
  133. package/template/qa/verified-surface.json +17 -0
  134. package/template/qa/verify.mjs +222 -142
  135. package/template/qa/watch.mjs +146 -33
  136. /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  137. /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  138. /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  139. /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  140. /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
  141. /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  142. /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  143. /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  144. /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  145. /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
@@ -0,0 +1,1384 @@
1
+ // preview-service.mjs — the console page: (state) -> html, and nothing else.
2
+ //
3
+ // MOVED from inspector/mcp/src/lib/preview-service.mjs — NORTH-STAR §9, stage
4
+ // 0.5, "the console into the harness". The trigger for stage 1 reads "before
5
+ // Stage 1 so the package boundary is drawn with the console inside":
6
+ // distribution has to ship the console, and it could not while the console
7
+ // lived in a sibling package.
8
+ //
9
+ // WHAT CAME AND WHAT DID NOT, because a reader will ask. The file this was cut
10
+ // from had two halves that shared a name and little else:
11
+ //
12
+ // galleryHtml pure. (state) -> html. Imports the three section
13
+ // modules and one manifest constant. Zero file reads,
14
+ // zero processes, zero paths. THIS HALF MOVED.
15
+ // createPreviewService the resident loop: an HTTP server, an fs watcher, a
16
+ // Gradle invoker (`./gradlew :composeApp:renderScreens`),
17
+ // a KSP cache self-heal, a hot-run desktop launcher, and
18
+ // twenty data readers that parse Kotlin. THAT HALF
19
+ // STAYED, and imports galleryHtml from here.
20
+ //
21
+ // The cut is not a convenience. docs/proposals/PACKAGE-SPLIT.md §3 puts "the
22
+ // console" in `prooflane-harness` with the words "Knows no stack. THIS IS THE
23
+ // PRODUCT", and puts "the eyes: preview registry, headless render, live
24
+ // inspector, drift, runtime, data" in a SEPARATE package,
25
+ // `prooflane-studio-cmp`, as "providers behind the console's interfaces".
26
+ // Moving the resident loop here would have put the eyes inside the package that
27
+ // knows no stack, and would have needed `composeApp`, `gradlew` and `kspCaches`
28
+ // excused in test/agnostic-lint.test.mjs — whose own contract is that the
29
+ // exception list shrinks and never grows. Nothing here needs excusing. The
30
+ // arrow now points down (studio -> harness), which is the direction §3 requires.
31
+ //
32
+ // The directory is `src/console/`, not `src/lib/`: scripts/sync-harness.mjs
33
+ // mirrors `src/lib` into `template/qa/lib`, so a console under lib/ would be
34
+ // vendored into every stamped app. REGION_DIRS is one level deep and
35
+ // REGION_TREES covers only `src/lib/profiles`, so `src/console/` ships in the
36
+ // package (package.json `files: ["src", ...]`) and is not vendored.
37
+
38
+ import { MANIFEST_REL_PATH } from "../lib/harness-manifest.mjs";
39
+ import {
40
+ renderShellPage,
41
+ statusGlyph,
42
+ artifactStatusHtml,
43
+ railReceiptHtml,
44
+ receiptGlyph,
45
+ formatAgeCoarse,
46
+ deriveHumanQueue,
47
+ governanceStripHtml,
48
+ } from "./console-shell.mjs";
49
+ import { overviewBodyHtml, overviewStatusHtml, overviewGlyph } from "./console-overview.mjs";
50
+ // The Evidence section's own status line shows a rung, so it shows the pack —
51
+ // one spelling for the whole console lives in console-evidence.mjs (§6.5).
52
+ import { rungWithPack, rungPackNote } from "./console-evidence.mjs";
53
+ import {
54
+ designLanguageBodyHtml,
55
+ componentsBodyHtml,
56
+ approvalsTabHtml,
57
+ specsTabHtml,
58
+ architectureTabHtml,
59
+ evidenceBodyHtml,
60
+ commentsTabHtml,
61
+ screensBodyHtml,
62
+ intentBodyHtml,
63
+ walkthroughTabHtml,
64
+ liveDeviceTabHtml,
65
+ digestTabHtml,
66
+ featuresTabHtml,
67
+ driftPanelHtml,
68
+ signatureBarHtml,
69
+ consoleCopy,
70
+ } from "./console-tabs.mjs";
71
+
72
+ // Component-story registry entries (§3.3): `component.<kebab-name>` ids from
73
+ // ComponentStories.kt. Rendered by the same pipeline as every other entry,
74
+ // but they are component documentation, not screens — the gallery keeps them
75
+ // out of the Screens grid/counts and the Components section shows each at the
76
+ // top of its entry.
77
+ const COMPONENT_STORY_ID_RE = /^component\.(.+)$/;
78
+
79
+ /** True when a registry id is a component story, not a screen. */
80
+ export function isComponentStoryId(id) {
81
+ return COMPONENT_STORY_ID_RE.test(String(id));
82
+ }
83
+
84
+ /**
85
+ * Every CURRENTLY RENDERED component-story entry, keyed by its kebab name
86
+ * (`component.app-header` → `"app-header"`), for componentsBodyHtml. A
87
+ * project whose registry predates component stories yields `{}` — the
88
+ * Components section then states the absence per entry, never an error.
89
+ * @param {Array<{screen: {id: string, title: string, png: string}}>} cards
90
+ * @returns {Record<string, {id: string, title: string, png: string}>}
91
+ */
92
+ export function componentStoryCards(cards) {
93
+ const out = {};
94
+ for (const { screen } of cards) {
95
+ const m = COMPONENT_STORY_ID_RE.exec(screen.id);
96
+ if (!m) continue;
97
+ out[m[1]] = { id: screen.id, title: screen.title, png: screen.png };
98
+ }
99
+ return out;
100
+ }
101
+
102
+ const esc = (s) =>
103
+ String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
104
+ const escAttr = (s) => esc(s).replace(/"/g, "&quot;");
105
+
106
+ /**
107
+ * The studio console page (docs/STUDIO-REDESIGN.md §2): ONE shell — the
108
+ * sidebar ordering/coverage rail, the per-page header grammar, and the
109
+ * provenance footers — with every section contributing only a document body
110
+ * (console-shell.mjs owns all chrome; sections may not invent their own).
111
+ * Pure: (state) -> html. PNGs are referenced via /previews/… with a version
112
+ * cache-buster; wireframe SVGs are inlined (SVG is structured text). Cards
113
+ * changed in THIS render get the CHANGED flag plus a hover before/after
114
+ * compare (screen.prev.png is the pre-render copy); every card keeps a
115
+ * persistent "changed #N" badge from `changedVersions` so attribution
116
+ * outlives the next render.
117
+ * @param {object} state { appName, viewport, cards, version, changed, changedVersions, error,
118
+ * approvals, specs, designSystem, architecture, components, comments, variants, componentsMeta,
119
+ * architectureMeta, lastReceipt, receiptHistory, treeHash, tokenUsage, intent }
120
+ */
121
+ export function galleryHtml(state) {
122
+ const {
123
+ appName,
124
+ viewport,
125
+ cards,
126
+ version,
127
+ changed = [],
128
+ changedVersions = {},
129
+ error = null,
130
+ errorSource = null,
131
+ renderer = { lastOutcome: "never", lastSuccessAt: null, lastAttemptAt: null, consecutiveFailures: 0 },
132
+ rendererLastErrorText = null,
133
+ // Derived provenance of the pixels below (see the service's freshness()). Defaulting
134
+ // to null keeps older callers rendering exactly as before rather than asserting a
135
+ // freshness this page has no basis for.
136
+ freshness = null,
137
+ approvals = { available: false },
138
+ specs = { available: false },
139
+ designSystem = { available: false },
140
+ architecture = { layerMap: { available: false }, governedContract: { available: false }, featureShape: { available: false } },
141
+ components = { available: false },
142
+ comments = { available: false },
143
+ variants = { available: false },
144
+ componentsMeta = {},
145
+ architectureMeta = {},
146
+ lastReceipt = null,
147
+ receiptHistory = { available: false },
148
+ treeHash = null,
149
+ tokenUsage = null,
150
+ intent = { available: false },
151
+ features = { available: false },
152
+ // PW-5: the productization surfaces — each degrades to an honest empty
153
+ // state when its data provider wasn't wired by the caller.
154
+ walkthrough = { available: false, runs: [] },
155
+ // The project's own walk derivation (qa/lib/walk.mjs via the bridge) —
156
+ // the In-flight block's rich rendering; null degrades to the board mirror.
157
+ walks = null,
158
+ liveDevice = null,
159
+ liveSession = null,
160
+ digest = null,
161
+ anchoredDiffs = null,
162
+ governedArtifacts = { available: false },
163
+ // The governance journal (qa/approvals.log.jsonl via the project's own
164
+ // library) — feeds the strip's History; degrades to no history.
165
+ journal = { available: false },
166
+ // The console's own build handshake (buildStatus). Absent = render no
167
+ // stale banner and no build id: unknown freshness is never dressed up as
168
+ // fresh, nor as a warning.
169
+ build = null,
170
+ // What this project can show (detectCapabilities). Older callers get the
171
+ // full console; a governance-only project drops the sections that need
172
+ // pixels and says so on the rail.
173
+ capabilities = { governance: true, screens: true },
174
+ // WHICH SECTIONS this project's console has, declared rather than assumed.
175
+ // `capabilities` can only say "Compose or not" — one bit, chosen here, for
176
+ // every stack there will ever be. A declaration says "these sections, in
177
+ // this order", which is what lets a backend have a console that is about a
178
+ // backend. Absent, every section renders exactly as before.
179
+ sections: declaredSectionIds = null,
180
+ // Where this project keeps its receipt/specs/doc (project-layout.mjs's
181
+ // resolveProjectLayout result). null = older caller; nothing is shown.
182
+ layout = null,
183
+ } = state;
184
+ const width = viewport?.width ?? 411;
185
+ // §3.3: component stories render through the same pipeline but are not
186
+ // screens — the Screens grid, screen count, and changed-count all exclude
187
+ // them; the Components section picks them up via componentsMeta below.
188
+ const screenCards = cards.filter(({ screen }) => !isComponentStoryId(screen.id));
189
+ const changedScreens = changed.filter((id) => !isComponentStoryId(id));
190
+ // Rail badge (§7.3): count of OPEN comments next to the Comments item.
191
+ // Always rendered (hidden at 0) so the SSE "comment" handler can always
192
+ // find #comments-badge to update in place.
193
+ const openCommentCount = comments.available ? comments.comments.filter((c) => c.status === "open").length : 0;
194
+ // §2 mode presentation: the Design-language candidates strip is genesis-
195
+ // mode only — derived from the design-system ARTIFACT's own live status
196
+ // (undefined when approvals data isn't available at all, which reads as
197
+ // steward — the safe default: no strip rather than a fabricated one).
198
+ const designSystemStatus = approvals.available
199
+ ? (approvals.statuses.find((s) => s.id === "design-system") || {}).status
200
+ : undefined;
201
+
202
+ // Governed-artifact records: rail glyphs + page status lines (§2 header
203
+ // grammar). Sections without an exact one-artifact mapping get NO glyph —
204
+ // never a borrowed status.
205
+ const artifactRecord = (id) =>
206
+ approvals.available ? approvals.statuses.find((s) => s.id === id) || null : null;
207
+ const dsRecord = artifactRecord("design-system");
208
+ const archRecord = architectureMeta.approval ?? artifactRecord("architecture");
209
+ const componentsRecord = componentsMeta.approval ?? artifactRecord("components");
210
+ const intentRecord = artifactRecord("intent");
211
+
212
+ // Evidence (§3.6): the receipt the whole console leans on. Some callers wire
213
+ // it via architectureMeta.lastReceipt (the older Wave C path) — one receipt
214
+ // serves the rail glyph, the rail foot, the status lines, the Screens rows'
215
+ // clause badges, and the Evidence section.
216
+ const effectiveReceipt = lastReceipt || architectureMeta.lastReceipt || null;
217
+
218
+ // Open comments, attributed to the section their target lives in — the §2
219
+ // header's open-comment count. Component comments ride the design-system
220
+ // target type with a "component:<Name>" token (the §7.3 contract), so they
221
+ // attribute to the Components section. Unknown/general targets count under
222
+ // Comments.
223
+ const sectionOfTarget = (t) => {
224
+ if (!t || typeof t !== "object") return "comments";
225
+ if (t.type === "design-system") {
226
+ return String(t.token || "").startsWith("component:") ? "components" : "design-system";
227
+ }
228
+ if (t.type === "architecture") return "architecture";
229
+ if (t.type === "screen" || t.type === "element") return "screens";
230
+ // §3.0: the Intent brief's comment affordances ride the spec-line type
231
+ // (file specs/intent.md, clauseId = the section heading) — they attribute
232
+ // to the Intent section, not Specs.
233
+ if (t.type === "spec-line") return t.file === "specs/intent.md" ? "intent" : "specs";
234
+ return "comments";
235
+ };
236
+ const openBySection = {};
237
+ if (comments.available) {
238
+ for (const c of comments.comments) {
239
+ if (c.status !== "open") continue;
240
+ const s = sectionOfTarget(c.target);
241
+ openBySection[s] = (openBySection[s] || 0) + 1;
242
+ }
243
+ }
244
+ const openNote = (id) => {
245
+ const n = openBySection[id] || 0;
246
+ return n ? ` &middot; &#9998; ${n} open comment${n === 1 ? "" : "s"}` : "";
247
+ };
248
+
249
+ // --- section bodies (§3 professional forms, console-tabs.mjs) -------------
250
+
251
+ // §3.4: the screen × state matrix. The expanded rows read the same specs
252
+ // data as the RTM and the same receipt as Evidence — one derivation each.
253
+ const screensBody = screensBodyHtml({
254
+ cards: screenCards,
255
+ changed: changedScreens,
256
+ changedVersions,
257
+ version,
258
+ specs,
259
+ lastReceipt: effectiveReceipt,
260
+ });
261
+
262
+ // --- §2 header status lines: one glance answers "what is this, is it
263
+ // signed, has it moved". Artifact-governed pages use the artifact grammar;
264
+ // the rest state only what their own data shows (evidence-or-silence). ----
265
+
266
+ // The matrix's rows are BASE screens; @state variants render as columns
267
+ // inside their row — the header count must match the rows the reader sees,
268
+ // not the raw card count (caught by the wave-final browser walk: "7
269
+ // screens" over a 4-row matrix).
270
+ const baseScreenCount = screenCards.filter(({ screen }) => !String(screen.id).includes("@")).length;
271
+ const screensStatus = `render #${version} &middot; ${baseScreenCount} screen${baseScreenCount === 1 ? "" : "s"}${
272
+ changedScreens.length ? ` &middot; <span class="chg">${changedScreens.length} changed this render</span>` : ""
273
+ }${openNote("screens")}`;
274
+
275
+ // Intent (§3.0): the artifact status when there is one, plus the brief's
276
+ // own fill state — both derived, neither borrowed. No intent.md at all
277
+ // states the §3.0 pending line here too.
278
+ const intentStatusParts = [];
279
+ {
280
+ const rec = artifactStatusHtml(intentRecord);
281
+ if (rec) intentStatusParts.push(rec);
282
+ if (intent.available && intent.sections) {
283
+ const filled = intent.sections.filter((s) => s.filled).length;
284
+ intentStatusParts.push(`${filled} of ${intent.sections.length} sections captured`);
285
+ }
286
+ }
287
+ const intentStatus =
288
+ (intentStatusParts.join(" &middot; ") || "not yet captured &mdash; conversation 0 pending") + openNote("intent");
289
+
290
+ const dsStatus = (artifactStatusHtml(dsRecord) || "the visual vocabulary — tokens, contrast, candidates") + openNote("design-system");
291
+ const archStatus = (artifactStatusHtml(archRecord) || "the layer contract and its live conformance") + openNote("architecture");
292
+
293
+ // Components: the artifact status when there is one, plus the registry's
294
+ // live size when the scan resolved — both are facts, neither is borrowed.
295
+ const componentsStatusParts = [];
296
+ {
297
+ const rec = artifactStatusHtml(componentsRecord);
298
+ if (rec) componentsStatusParts.push(rec);
299
+ if (components.available && components.components) {
300
+ const n = components.components.length;
301
+ componentsStatusParts.push(`${n} component${n === 1 ? "" : "s"} in the registry`);
302
+ }
303
+ }
304
+ const componentsStatus =
305
+ (componentsStatusParts.join(" &middot; ") || "no components scan available") + openNote("components");
306
+
307
+ // specs/<file> -> its governing artifact's live status, built from the
308
+ // project's OWN registry (id + files) — so app-base maps to `architecture`
309
+ // and the CONFIGURED exemplar's spec to `exemplar-spec`, never guessed from
310
+ // a filename. Absent registry (older lib) simply yields no signature bars.
311
+ // A spec file can appear in MORE than one artifact's file set: the exemplar's
312
+ // spec is governed by `exemplar-spec` (1 file) AND listed inside
313
+ // `exemplar-feature` (the 11-file clone source). The signature control must
314
+ // bind to the MOST SPECIFIC artifact — fewest files — or a click under
315
+ // "foods.spec.md" would sign the entire exemplar feature instead of its
316
+ // contract, the exact silent wrong-transition this console refuses elsewhere.
317
+ const specArtifactByFile = {};
318
+ if (governedArtifacts.available && approvals.available && approvals.statuses) {
319
+ const byId = new Map(approvals.statuses.map((s) => [s.id, s]));
320
+ const winnerSize = {};
321
+ for (const a of governedArtifacts.artifacts) {
322
+ if (!byId.has(a.id)) continue;
323
+ for (const f of a.files) {
324
+ if (!f.endsWith(".spec.md")) continue;
325
+ if (winnerSize[f] === undefined || a.files.length < winnerSize[f]) {
326
+ winnerSize[f] = a.files.length;
327
+ specArtifactByFile[f] = byId.get(a.id);
328
+ }
329
+ }
330
+ }
331
+ }
332
+
333
+ const specsStatus = specs.available
334
+ ? `${specs.files.length} spec file${specs.files.length === 1 ? "" : "s"} &middot; ${specs.files.reduce((n, f) => n + f.clauses.length, 0)} clauses${openNote("specs")}`
335
+ : "no specs/ directory found";
336
+
337
+ let evidenceStatus = "no verify receipt yet";
338
+ if (effectiveReceipt && effectiveReceipt.available) {
339
+ const age = typeof effectiveReceipt.ageMs === "number" ? formatAgeCoarse(effectiveReceipt.ageMs) : "age unknown";
340
+ // The rung (receipt's own derived evidenceLevel) rides the status line —
341
+ // "verify PASS · L2 device · pack cmp · 3m ago" — absent on FAIL/pre-ladder
342
+ // receipts. The pack is part of the rung and not an extra field: §6.5 says
343
+ // every surface showing a rung shows it, and this is the Evidence section's
344
+ // own header line, so a bare grade here would be the console asserting a
345
+ // claim §8.9 says cannot be compared with anyone else's.
346
+ const rungLabel = rungWithPack(effectiveReceipt.evidenceLevel, effectiveReceipt.packId ?? effectiveReceipt.pack);
347
+ const rung = rungLabel
348
+ ? ` &middot; <span title="${escAttr(rungPackNote(effectiveReceipt.evidenceLevel, effectiveReceipt.packId ?? effectiveReceipt.pack))}">${esc(rungLabel)}</span>`
349
+ : "";
350
+ evidenceStatus = `verify ${esc(effectiveReceipt.verdict || "?")}${rung} &middot; ${esc(age)}${
351
+ effectiveReceipt.stale ? ` &middot; <span class="status-drift">stale &mdash; tree changed since</span>` : ""
352
+ }`;
353
+ }
354
+
355
+ let approvalsStatus = "approvals not available in this project";
356
+ if (approvals.available && approvals.statuses) {
357
+ const count = (st) => approvals.statuses.filter((s) => s.status === st).length;
358
+ const parts = [`${approvals.statuses.length} governed artifact${approvals.statuses.length === 1 ? "" : "s"}`];
359
+ if (count("approved")) parts.push(`${count("approved")} signed`);
360
+ if (count("changed-since-approval")) parts.push(`<span class="status-drift">${count("changed-since-approval")} drifted</span>`);
361
+ if (count("reopened")) parts.push(`<span class="status-reopen">${count("reopened")} reopened</span>`);
362
+ if (count("unreviewed")) parts.push(`${count("unreviewed")} unsigned`);
363
+ approvalsStatus = parts.join(" &middot; ");
364
+ }
365
+
366
+ const commentsStatus = comments.available
367
+ ? `${openCommentCount} open &middot; ${comments.comments.length - openCommentCount} resolved`
368
+ : "comments ledger not available in this project";
369
+
370
+ // Features (the per-feature view): phase tallies from the project's own
371
+ // getFeatureBoard — facts only, no fabricated zeros. `proven` is DERIVED
372
+ // doneness (clauses cited + receipt PASS + attests tree), never a claim.
373
+ let featuresStatus = "no feature briefs yet";
374
+ let featuresGlyph = null;
375
+ if (features.available && features.board && features.board.features.length > 0) {
376
+ const briefs = features.board.features;
377
+ const n = (ph) => briefs.filter((f) => f.phase === ph).length;
378
+ const parts = [`${briefs.length} brief${briefs.length === 1 ? "" : "s"}`];
379
+ if (n("proposed")) parts.push(`${n("proposed")} awaiting sign-off`);
380
+ if (n("approved")) parts.push(`${n("approved")} building`);
381
+ if (n("proven")) parts.push(`${n("proven")} proven — acceptance pending`);
382
+ if (n("accepted")) parts.push(`${n("accepted")} accepted`);
383
+ if (n("changed-since-approval")) parts.push(`<span class="status-drift">${n("changed-since-approval")} drifted</span>`);
384
+ if (features.board.undeclared.length > 0) parts.push(`<span class="status-drift">undeclared blast</span>`);
385
+ // The design gate's pending states (brief → design → spec → build): each
386
+ // feature's feature-design:<name> artifact, folded into the same rollup.
387
+ const dn = (pred) => briefs.filter((f) => f.design && pred(f.design)).length;
388
+ const designDrift = dn((d) => d.status === "changed-since-approval");
389
+ const designReopen = dn((d) => d.status === "reopened");
390
+ const designAwait = dn((d) => d.status === "unreviewed" && d.resolvable !== false);
391
+ if (designAwait) parts.push(`${designAwait} design${designAwait === 1 ? "" : "s"} awaiting signature`);
392
+ if (designDrift) parts.push(`<span class="status-drift">${designDrift} design${designDrift === 1 ? "" : "s"} drifted</span>`);
393
+ featuresStatus = parts.join(" &middot; ");
394
+ // The rail-truth rule: a neutral glyph means TRULY nothing pending here.
395
+ // Every phase waiting on a human is colour, the moment it exists — worst
396
+ // state wins: drift (an accident, red) > reopened (a sanctioned redesign,
397
+ // amber — NEVER collapsed into drift; the asymmetry is the product) >
398
+ // unsigned brief (accent — waiting on your signature) > proven-awaiting-
399
+ // accept (accent, filled — waiting on your acceptance). All-green reads
400
+ // signed.
401
+ // Design states fold into the same worst-state ladder: a drifted design
402
+ // is red like any drift, a reopened one amber, one awaiting signature
403
+ // accent — an UNDRAFTED design (unresolvable) is the agent's work, not
404
+ // the human's, so it alone stays out of the colour ladder.
405
+ featuresGlyph =
406
+ n("changed-since-approval") > 0 || designDrift > 0
407
+ ? {
408
+ ch: "⚠",
409
+ cls: "glyph-drift",
410
+ label:
411
+ n("changed-since-approval") > 0
412
+ ? `${n("changed-since-approval")} brief(s) changed since signature`
413
+ : `${designDrift} design(s) changed since signature`,
414
+ }
415
+ : n("reopened") > 0 || designReopen > 0
416
+ ? {
417
+ ch: "◐",
418
+ cls: "glyph-reopen",
419
+ label: n("reopened") > 0 ? `${n("reopened")} brief(s) reopened for redesign` : `${designReopen} design(s) reopened for redesign`,
420
+ }
421
+ : n("proposed") > 0 || designAwait > 0
422
+ ? {
423
+ ch: "○",
424
+ cls: "glyph-unsigned",
425
+ label: n("proposed") > 0 ? `${n("proposed")} brief(s) awaiting signature` : `${designAwait} design(s) awaiting signature`,
426
+ }
427
+ : n("proven") > 0
428
+ ? { ch: "●", cls: "glyph-attn", label: `${n("proven")} proven — acceptance pending` }
429
+ : { ch: "●", cls: "glyph-signed", label: "all briefs signed" };
430
+ }
431
+ // Acceptances the human owes — counted into the Approvals work queue below,
432
+ // so Features and Approvals can never tell different stories about whether
433
+ // anything waits on you.
434
+ const provenAwaiting =
435
+ features.available && features.board ? features.board.features.filter((f) => f.phase === "proven").length : 0;
436
+
437
+ // Specs roll-up (rail-truth): the Specs tab aggregates every spec-family
438
+ // artifact (exemplar-spec + feature-spec:*) so an unreviewed or drifted
439
+ // contract is COLOUR on the rail, not a neutral dot the human must dig for.
440
+ let specsGlyph = null;
441
+ if (approvals.available && approvals.statuses) {
442
+ const specRecords = approvals.statuses.filter((s) => s.id === "exemplar-spec" || s.id.startsWith("feature-spec:"));
443
+ const c = (st) => specRecords.filter((s) => s.status === st).length;
444
+ if (specRecords.length > 0) {
445
+ specsGlyph =
446
+ c("changed-since-approval") > 0
447
+ ? { ch: "⚠", cls: "glyph-drift", label: `${c("changed-since-approval")} spec(s) drifted` }
448
+ : c("reopened") > 0
449
+ ? { ch: "◐", cls: "glyph-reopen", label: `${c("reopened")} spec(s) reopened for redesign` }
450
+ : c("unreviewed") > 0
451
+ ? { ch: "○", cls: "glyph-unsigned", label: `${c("unreviewed")} spec(s) unsigned` }
452
+ : { ch: "●", cls: "glyph-signed", label: "all specs signed" };
453
+ }
454
+ }
455
+
456
+ // Approvals roll-up (rail-truth): this tab IS the work queue — its glyph is
457
+ // the count of decisions currently waiting on the human, colour when > 0.
458
+ // Pending ACCEPTANCES count too (a ledger field, not an artifact status —
459
+ // without this the queue said "nothing waiting on you" while a proven
460
+ // feature sat awaiting the human's accept).
461
+ let approvalsGlyph = null;
462
+ if (approvals.available && approvals.statuses) {
463
+ // An unreviewed artifact that is UNRESOLVABLE (e.g. a declared-but-
464
+ // undrafted feature design) is the agent's work, not a decision the
465
+ // human can make — same exclusion pendingOnHuman applies, so the glyph
466
+ // and the queue never tell different stories. Drift stays counted
467
+ // regardless of resolvability: deleted signed files must surface.
468
+ const c = (st) => approvals.statuses.filter((s) => s.status === st && (st !== "unreviewed" || s.resolvable !== false)).length;
469
+ const pending = c("unreviewed") + c("changed-since-approval") + c("reopened") + provenAwaiting;
470
+ const detail = provenAwaiting > 0 ? ` (${provenAwaiting} acceptance${provenAwaiting === 1 ? "" : "s"})` : "";
471
+ approvalsGlyph =
472
+ c("changed-since-approval") > 0
473
+ ? { ch: "⚠", cls: "glyph-drift", label: `${pending} decision(s) waiting — ${c("changed-since-approval")} drifted${detail}` }
474
+ : pending > 0
475
+ ? { ch: "○", cls: "glyph-unsigned", label: `${pending} decision(s) waiting${detail}` }
476
+ : { ch: "●", cls: "glyph-signed", label: "nothing waiting on you" };
477
+ }
478
+
479
+ // The human queue, derived ONCE for this render and shared by the front door
480
+ // and the governance strip — the same deriveHumanQueue the guided prompt
481
+ // uses. Three surfaces, one derivation: they cannot disagree.
482
+ const overviewStatuses = approvals.available && approvals.statuses ? approvals.statuses : [];
483
+ const overviewFeatures = features.available && features.board ? features.board.features : [];
484
+ const humanQueue = deriveHumanQueue({ statuses: overviewStatuses, features: overviewFeatures });
485
+
486
+ // Rail + sections share one order: the genesis definition order (§2), with
487
+ // the cross-cutting ledgers (Approvals, Comments) after the artifact pages.
488
+ // Overview is the DEFAULT page — the front door (§3.7). Screens keeps the
489
+ // hot-reload loop: the tab is sticky (hash + sessionStorage), so an SSE
490
+ // reload during UI work never bounces the reader off the gallery; only a
491
+ // genuinely fresh session lands on the front door.
492
+ const railItems = [
493
+ // §3.7 (front door): the returning owner's entry point — what needs you,
494
+ // what changed, is it still proven. It owns no facts; it arranges the
495
+ // sections below it. See console-overview.mjs for why this supersedes the
496
+ // "dashboard is ambient — never a separate tab" rule of §2.
497
+ {
498
+ id: "overview",
499
+ label: "Drive",
500
+ glyph: overviewGlyph(humanQueue, overviewStatuses),
501
+ active: true,
502
+ },
503
+ // Intent is genesis order 0 — the root artifact everything else is
504
+ // expressed in — so it leads the rail. The rest follows the REVISED
505
+ // definition order (spec-first behavior, UI-first visuals): architecture,
506
+ // then the exemplar's surfaces (Specs, Screens), then the design system
507
+ // and components — which lock on / are distilled from those screens.
508
+ { id: "intent", label: "Intent", glyph: statusGlyph(intentRecord) },
509
+ // Features sits DIRECTLY after Intent (CHANGE-FLOW-DESIGN.md §6): a
510
+ // feature's walk — brief → contract → build → prove → accept — is the
511
+ // decide layer, and a brief speaks intent's vocabulary; it needs nothing
512
+ // from architecture. The glyph is the rail-truth roll-up: colour for any
513
+ // state waiting on the human, from the moment a brief file exists.
514
+ { id: "features", label: "Features", glyph: featuresGlyph },
515
+ { id: "architecture", label: "Architecture", glyph: statusGlyph(archRecord) },
516
+ { id: "specs", label: "Specs", glyph: specsGlyph },
517
+ // Screens is UNGOVERNED — no signature exists, so it can never be green.
518
+ // Its one honest colour is red: the last render or compile FAILED, so the
519
+ // gallery may be showing stale pixels. Otherwise neutral.
520
+ {
521
+ id: "screens",
522
+ label: "Screens",
523
+ glyph: error
524
+ ? { ch: "✗", cls: "glyph-drift", label: `last ${errorSource || "render"} failed — the gallery may be stale` }
525
+ : null,
526
+ },
527
+ { id: "design-system", label: "Design language", glyph: statusGlyph(dsRecord) },
528
+ { id: "components", label: "Components", glyph: statusGlyph(componentsRecord) },
529
+ // §3.6: the Evidence item's glyph derives from the latest receipt itself
530
+ // (✓ fresh PASS · ✗ FAIL · ⚠ stale · ○ none) — receiptGlyph, the same
531
+ // derivation the rail foot uses.
532
+ { id: "evidence", label: "Evidence", glyph: receiptGlyph(effectiveReceipt) },
533
+ // A2: the walkthrough report is evidence-adjacent — derived from committed
534
+ // manifests, so it sits right after Evidence in the arc.
535
+ { id: "walkthrough", label: "Walkthrough", glyph: null },
536
+ // The work queue itself: colour whenever any decision waits on the human.
537
+ { id: "approvals", label: "Approvals", glyph: approvalsGlyph },
538
+ {
539
+ id: "comments",
540
+ label: "Comments",
541
+ glyph: null,
542
+ badgeHtml: `<span class="tab-badge" id="comments-badge"${openCommentCount === 0 ? " hidden" : ""}>${openCommentCount}</span>`,
543
+ },
544
+ // A1: the console arc ends DRIVE — Live device is deliberately the final
545
+ // section: define → preview → approve → verify → report → drive. The glyph
546
+ // follows statusGlyph's {ch, cls, label} shape (a bare string renders as
547
+ // "undefined" — the rail template reads g.ch/g.cls).
548
+ {
549
+ id: "live-device",
550
+ label: "Live device",
551
+ glyph: liveDevice && liveDevice.reachable ? { ch: "●", cls: "glyph-signed", label: "device connected" } : null,
552
+ },
553
+ ];
554
+
555
+ const sections = [
556
+ // §3.7 — the front door. Composition only: it arranges the queue, the
557
+ // anchored-diff file splits and the digest that other modules derived. It
558
+ // grows NO signature control of its own — sign where you read stands.
559
+ {
560
+ id: "overview",
561
+ // Retitled by studio-drive-mode: the front door is the DRIVING surface
562
+ // (chain + walks + queue); the id stays "overview" — it is pinned by
563
+ // GOVERNED_PANELS, sessionStorage, and the strip's gov-next jump.
564
+ title: "Drive",
565
+ statusHtml: overviewStatusHtml({
566
+ receipt: effectiveReceipt,
567
+ statuses: overviewStatuses,
568
+ receiptGlyph,
569
+ formatAge: formatAgeCoarse,
570
+ }),
571
+ bodyHtml: overviewBodyHtml({
572
+ queue: humanQueue,
573
+ statuses: overviewStatuses,
574
+ features: overviewFeatures,
575
+ walks,
576
+ anchoredDiffs,
577
+ digestHtml: digestTabHtml(digest),
578
+ digestSince: digest && digest.available ? digest.since : null,
579
+ statusGlyph,
580
+ journal: journal.available ? journal.events : [],
581
+ formatAge: formatAgeCoarse,
582
+ }),
583
+ active: true,
584
+ },
585
+ {
586
+ id: "intent",
587
+ title: "Intent",
588
+ statusHtml: intentStatus,
589
+ bodyHtml: intentBodyHtml(intent),
590
+ },
591
+ // Features directly after Intent — same order as the rail (decide layer
592
+ // before the contract layer; CHANGE-FLOW-DESIGN.md §6).
593
+ { id: "features", title: "Features", statusHtml: featuresStatus, bodyHtml: featuresTabHtml(features) },
594
+ {
595
+ id: "architecture",
596
+ title: "Architecture",
597
+ statusHtml: archStatus,
598
+ bodyHtml: architectureTabHtml(architecture, architectureMeta),
599
+ },
600
+ // §3.5: the RTM's last-receipt column reads the same receipt as Evidence.
601
+ {
602
+ id: "specs",
603
+ title: "Specs",
604
+ statusHtml: specsStatus,
605
+ bodyHtml: specsTabHtml(specs, { lastReceipt: effectiveReceipt, artifactByFile: specArtifactByFile }),
606
+ },
607
+ {
608
+ id: "screens",
609
+ title: "Screens",
610
+ statusHtml: screensStatus,
611
+ headExtraHtml: `<div class="screens-toolbar"><input id="filter" type="search" placeholder="filter screens&hellip;"></div>`,
612
+ bodyHtml: screensBody,
613
+ fullBleed: true,
614
+ },
615
+ {
616
+ id: "design-system",
617
+ title: "Design language",
618
+ statusHtml: dsStatus,
619
+ bodyHtml: designLanguageBodyHtml(designSystem, {
620
+ usage: tokenUsage,
621
+ variants,
622
+ artifactStatus: designSystemStatus,
623
+ }),
624
+ },
625
+ {
626
+ id: "components",
627
+ title: "Components",
628
+ statusHtml: componentsStatus,
629
+ // §3.3 story pickup: the section gets the current render's story cards
630
+ // plus the changed-attribution vocabulary the Screens grid uses
631
+ // (version cache-buster, persistent changed-#N chips). componentStories
632
+ // is derived here when the caller didn't pass one, so a bare
633
+ // galleryHtml({cards}) still shows story renders.
634
+ bodyHtml: componentsBodyHtml(components, {
635
+ componentStories: componentStoryCards(cards),
636
+ ...componentsMeta,
637
+ version,
638
+ changedVersions,
639
+ }),
640
+ },
641
+ { id: "evidence", title: "Evidence", statusHtml: evidenceStatus, bodyHtml: evidenceBodyHtml(effectiveReceipt, receiptHistory) },
642
+ {
643
+ id: "walkthrough",
644
+ title: "Walkthrough",
645
+ statusHtml: walkthrough.available
646
+ ? `<span class="status-line">latest run ${walkthrough.runs[0].generatedAt}</span>`
647
+ : `<span class="status-line">no runs yet</span>`,
648
+ bodyHtml: walkthroughTabHtml(walkthrough),
649
+ },
650
+ { id: "approvals", title: "Approvals", statusHtml: approvalsStatus, bodyHtml: approvalsTabHtml(approvals, { anchoredDiffs }) },
651
+ { id: "comments", title: "Comments", statusHtml: commentsStatus, bodyHtml: commentsTabHtml(comments) },
652
+ {
653
+ id: "live-device",
654
+ title: "Live device",
655
+ statusHtml:
656
+ liveDevice && liveDevice.reachable
657
+ ? `<span class="status-line">connected — ${liveDevice.appId}</span>`
658
+ : `<span class="status-line">not connected</span>`,
659
+ bodyHtml: liveDeviceTabHtml(liveDevice, liveSession),
660
+ fullBleed: true,
661
+ },
662
+ ];
663
+
664
+ // Page anatomy (studio-drive-mode): every MIRROR section — a complete
665
+ // signed-doc rendering — collapses to its verdict line by default, with the
666
+ // corpus one disclosure away. A section the human queue currently points at
667
+ // renders open: its exception IS the reason the human is coming. Tool
668
+ // sections (Drive, Screens, Live device) keep their full body — they are
669
+ // instruments, not documents.
670
+ const MIRROR_SECTIONS = new Set([
671
+ "intent", "features", "architecture", "specs", "design-system",
672
+ "components", "evidence", "walkthrough", "approvals", "comments",
673
+ ]);
674
+ const queueTabs = new Set(humanQueue.map((q) => q.tab));
675
+ for (const section of sections) {
676
+ if (!MIRROR_SECTIONS.has(section.id)) continue;
677
+ section.mirror = true;
678
+ section.mirrorOpen = queueTabs.has(section.id);
679
+ }
680
+ // Open feedback is an exception too: a Comments page with open threads
681
+ // greeting the reader with a closed fold would hide the very thing waiting.
682
+ if (openCommentCount > 0) {
683
+ const c = sections.find((x) => x.id === "comments");
684
+ if (c) c.mirrorOpen = true;
685
+ }
686
+
687
+ // Sign where you read: every governed section carries its OWN signature
688
+ // control, so the human reading an artifact is the human who can sign it.
689
+ // The Approvals tab stays the ledger and the queue — it is no longer the
690
+ // only place a decision can be made.
691
+ if (approvals.available && approvals.statuses) {
692
+ const byId = new Map(approvals.statuses.map((s) => [s.id, s]));
693
+ const bar = (id, what) => (byId.has(id) ? signatureBarHtml(byId.get(id), { what }) : "");
694
+ const barBySection = {
695
+ intent: bar("intent", "the intent brief"),
696
+ architecture: bar("architecture", "this architecture"),
697
+ "design-system": bar("design-system", "the design system"),
698
+ components: bar("components", "the component registry"),
699
+ };
700
+ for (const section of sections) {
701
+ const b = barBySection[section.id];
702
+ if (b) section.bodyHtml = `${b}\n${section.bodyHtml}`;
703
+ }
704
+ }
705
+
706
+ // The change surface (spec-mirror-drift): every drifted artifact's panel —
707
+ // what changed vs. the signed bytes, what is still exactly as signed, and
708
+ // the Re-approve button — renders AT THE TOP OF THE SECTION IT BELONGS TO,
709
+ // not only in the Approvals table. The human reads the drift where they
710
+ // read the artifact.
711
+ if (approvals.available && approvals.statuses) {
712
+ const sectionOfArtifact = (id) =>
713
+ id === "intent" || id === "architecture" || id === "design-system" || id === "components"
714
+ ? id
715
+ : id.startsWith("feature-brief:") || id.startsWith("feature-design:")
716
+ ? "features" // a design's card row lives here; its pixels in Screens
717
+ : id === "exemplar-spec" || id.startsWith("feature-spec:")
718
+ ? "specs"
719
+ : id === "exemplar-feature"
720
+ ? "screens" // the exemplar's file set IS the built surface
721
+ : null;
722
+ const panelsBySection = {};
723
+ for (const s of approvals.statuses) {
724
+ if (s.status !== "changed-since-approval") continue;
725
+ const sec = sectionOfArtifact(s.id);
726
+ if (!sec) continue;
727
+ (panelsBySection[sec] ??= []).push(driftPanelHtml(s, anchoredDiffs ? anchoredDiffs[s.id] : null));
728
+ }
729
+ for (const section of sections) {
730
+ if (panelsBySection[section.id]) {
731
+ section.bodyHtml = `${panelsBySection[section.id].join("\n")}\n${section.bodyHtml}`;
732
+ }
733
+ }
734
+ }
735
+
736
+ const bodyScript = `
737
+ const pill = document.getElementById("pill");
738
+ // §2: the rail glyphs and page-status lines are drift surfaces — they must
739
+ // track approval/comment changes without a full reload (the same no-flash
740
+ // rule the panel swaps follow). Buttons and inputs are never replaced, so
741
+ // their listeners survive: only glyph spans and status-line innerHTML move.
742
+ function syncShellFromDoc(doc) {
743
+ doc.querySelectorAll(".rail-nav .tab-btn").forEach((freshBtn) => {
744
+ const curGlyph = document.querySelector('.rail-nav .tab-btn[data-tab="' + freshBtn.dataset.tab + '"] .glyph');
745
+ const freshGlyph = freshBtn.querySelector(".glyph");
746
+ if (curGlyph && freshGlyph) {
747
+ curGlyph.className = freshGlyph.className;
748
+ curGlyph.textContent = freshGlyph.textContent;
749
+ curGlyph.title = freshGlyph.title || "";
750
+ }
751
+ });
752
+ doc.querySelectorAll(".tab-panel").forEach((freshPanel) => {
753
+ const curStatus = document.querySelector('.tab-panel[data-tab="' + freshPanel.dataset.tab + '"] .page-status');
754
+ const freshStatus = freshPanel.querySelector(".page-status");
755
+ if (curStatus && freshStatus) curStatus.innerHTML = freshStatus.innerHTML;
756
+ });
757
+ // The governance strip lives in the rail — visible on every tab, so it
758
+ // must track every ledger transition the panels do.
759
+ const curStrip = document.getElementById("gov-strip");
760
+ const freshStrip = doc.getElementById("gov-strip");
761
+ if (curStrip && freshStrip) curStrip.innerHTML = freshStrip.innerHTML;
762
+ }
763
+ // Every governed panel refreshes IN PLACE (no location.reload()): a full
764
+ // reload flashes the page, drops scroll, and blanks assistive/agent views of
765
+ // the document mid-navigation. ONE routine for every governed-state event —
766
+ // an approval, a comment, or a change to the governed FILES themselves —
767
+ // because they all mean the same thing: the truth this page renders moved.
768
+ // Panels are re-wired individually (never document-wide), so an unswapped
769
+ // panel can never pick up a second listener and double-POST.
770
+ const GOVERNED_PANELS = [
771
+ // The front door leads the list, and it is the one panel that MUST be
772
+ // here: its whole body is the queue of what still waits on you, so an
773
+ // approval that refreshed every other panel while leaving this one stale
774
+ // would leave the console's entry point advertising an act the human just
775
+ // completed — the worst possible place for a stale read.
776
+ "tab-overview",
777
+ "tab-intent", "tab-features", "tab-architecture", "tab-specs",
778
+ "tab-design-system", "tab-components", "tab-approvals", "tab-evidence", "tab-comments",
779
+ ];
780
+ function refreshGovernedPanels() {
781
+ fetch("/").then((r) => r.text()).then((html) => {
782
+ const doc = new DOMParser().parseFromString(html, "text/html");
783
+ const swapped = [];
784
+ for (const id of GOVERNED_PANELS) {
785
+ const fresh = doc.querySelector("#" + id);
786
+ const cur = document.querySelector("#" + id);
787
+ if (!fresh || !cur) continue;
788
+ const wasActive = cur.classList.contains("active");
789
+ cur.innerHTML = fresh.innerHTML;
790
+ if (wasActive) cur.classList.add("active");
791
+ swapped.push(cur);
792
+ }
793
+ if (swapped.length === 0) { location.reload(); return; } // unexpected markup — old behavior
794
+ for (const el of swapped) {
795
+ wireApproveButtons(el);
796
+ wireReopenButtons(el);
797
+ wireFeatureAcceptButtons(el);
798
+ wireCommentButtons(el);
799
+ wirePickButtons(el);
800
+ wireArrivalButtons(el);
801
+ }
802
+ const freshBadge = doc.querySelector("#comments-badge");
803
+ const curBadge = document.querySelector("#comments-badge");
804
+ if (freshBadge && curBadge) {
805
+ curBadge.textContent = freshBadge.textContent;
806
+ curBadge.hidden = freshBadge.hidden;
807
+ }
808
+ syncShellFromDoc(doc);
809
+ }).catch(() => location.reload());
810
+ }
811
+ // The next-act jump: land on the artifact's own signature bar
812
+ // (sign-where-you-read — the same jump the guided prompt's "Take me there"
813
+ // performs). Delegated at DOCUMENT level, because the same contract now
814
+ // serves the rail strip AND the front door's queue, and it must survive
815
+ // every SSE swap (both the strip's innerHTML and the Overview panel are
816
+ // replaced wholesale). One handler, one behavior — a second copy on the
817
+ // front door would be a second mechanism.
818
+ document.addEventListener("click", (e) => {
819
+ // .gov-next is the single next act; .gov-jump is a count or a queue row
820
+ // that names its artifact. Both carry data-go-* and both must land the
821
+ // reader on the row that explains itself.
822
+ const btn = e.target.closest(".gov-next, .gov-jump");
823
+ if (!btn) return;
824
+ const railBtn = document.querySelector('.rail-nav .tab-btn[data-tab="' + btn.dataset.goTab + '"]');
825
+ if (railBtn) railBtn.click();
826
+ const target = document.querySelector('#tab-' + btn.dataset.goTab + ' [data-artifact="' + btn.dataset.goArtifact + '"]');
827
+ if (target) {
828
+ // Page anatomy: the row may live inside a collapsed mirror disclosure —
829
+ // "take me there" opens every ancestor fold so the jump lands ON the row.
830
+ let fold = target.closest("details");
831
+ while (fold) {
832
+ fold.open = true;
833
+ fold = fold.parentElement ? fold.parentElement.closest("details") : null;
834
+ }
835
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
836
+ }
837
+ });
838
+ const es = new EventSource("/events");
839
+ // EventSource reconnects on its own, but nothing ever wrote the pill back to
840
+ // "live" — so a one-second blip read as permanently disconnected, which is a
841
+ // direct cause of "the studio keeps getting disconnected". The open event
842
+ // fires on the initial connect AND on every automatic reconnect — the honest
843
+ // signal. (No backticks in this comment: it lives inside the page's template
844
+ // literal, and a stray one closes the string.)
845
+ es.onopen = () => { pill.textContent = "live"; pill.className = ""; };
846
+ es.onmessage = (e) => {
847
+ const msg = JSON.parse(e.data);
848
+ // studio-self-renewal R6: this page was drawn by CMP_CONSOLE_BUILD; the hello
849
+ // says which build is serving it NOW. A difference means the console renewed
850
+ // itself under this tab (or was restarted by hand) — reload once and the
851
+ // human sees current code without having touched anything. Guarded on the
852
+ // constant existing so an older shell simply keeps the old behavior.
853
+ if (msg.type === "hello" && msg.build && typeof CMP_CONSOLE_BUILD === "string" && msg.build !== CMP_CONSOLE_BUILD) {
854
+ location.reload();
855
+ return;
856
+ }
857
+ if (msg.type === "renewing") { pill.textContent = "renewing…"; pill.className = "rendering"; }
858
+ if (msg.type === "rendering") { pill.textContent = "rendering…"; pill.className = "rendering"; }
859
+ // A concurrent Gradle build (an ad-hoc ./gradlew) holds the classes dir. Not a
860
+ // failure — the render is queued and will run. Say exactly that; never the error pill.
861
+ if (msg.type === "deferred") { pill.textContent = "waiting for another build…"; pill.className = "rendering"; }
862
+ if (msg.type === "render") location.reload();
863
+ // approval (a decision, from the console OR the CLI), comment (added or
864
+ // resolved), governance (a governed FILE changed — an agent wrote a spec
865
+ // or a brief, or the lane wrote a receipt): all one refresh.
866
+ if (msg.type === "approval" || msg.type === "comment" || msg.type === "governance") {
867
+ refreshGovernedPanels();
868
+ }
869
+ if (msg.type === "error") {
870
+ pill.textContent = msg.source === "compile" ? "compile failed" : "render failed";
871
+ pill.className = "error";
872
+ }
873
+ };
874
+ // readyState distinguishes "retrying" (the server blinked; EventSource is
875
+ // already backing off toward a reconnect) from "gone" (CLOSED — it will not
876
+ // retry). Reporting both as "disconnected" told the human a recoverable blip
877
+ // was fatal.
878
+ es.onerror = () => {
879
+ const gone = es.readyState === 2; // CLOSED
880
+ pill.textContent = gone ? "server gone" : "reconnecting…";
881
+ pill.className = gone ? "error" : "rendering";
882
+ };
883
+ // Screen filter — survives the SSE-triggered reloads via sessionStorage.
884
+ // §3.4: it filters matrix ROWS (one row per screen, states stay together).
885
+ const filter = document.getElementById("filter");
886
+ filter.value = sessionStorage.getItem("previewFilter") || "";
887
+ const applyFilter = () => {
888
+ const q = filter.value.trim().toLowerCase();
889
+ sessionStorage.setItem("previewFilter", q);
890
+ document.querySelectorAll(".matrix-row").forEach((c) => {
891
+ c.style.display = !q || c.textContent.toLowerCase().includes(q) ? "" : "none";
892
+ });
893
+ };
894
+ filter.addEventListener("input", applyFilter);
895
+ applyFilter();
896
+ // Tabs — deep-linkable via location.hash (#approvals bookmarks/shares the tab,
897
+ // and automation can land on any tab by URL), with sessionStorage as the
898
+ // fallback so SSE-triggered reloads keep the tab even without a hash.
899
+ const tabBtns = [...document.querySelectorAll(".tab-btn")];
900
+ const panels = [...document.querySelectorAll(".tab-panel")];
901
+ const validTab = (t) => tabBtns.some((b) => b.dataset.tab === t);
902
+ function showTab(tab) {
903
+ sessionStorage.setItem("previewTab", tab);
904
+ if (("#" + tab) !== location.hash) history.replaceState(null, "", "#" + tab);
905
+ tabBtns.forEach((b) => b.classList.toggle("active", b.dataset.tab === tab));
906
+ panels.forEach((p) => p.classList.toggle("active", p.dataset.tab === tab));
907
+ }
908
+ tabBtns.forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
909
+ window.addEventListener("hashchange", () => {
910
+ const t = location.hash.slice(1);
911
+ if (validTab(t)) showTab(t);
912
+ });
913
+ const fromHash = location.hash.slice(1);
914
+ // The front door is the fallback ONLY — hash wins, then the sticky tab. A
915
+ // hot-reload session parked on Screens stays on Screens across every SSE
916
+ // reload; only a genuinely fresh session lands on Overview.
917
+ showTab(validTab(fromHash) ? fromHash : (sessionStorage.getItem("previewTab") || "overview"));
918
+ // Approvals — POST /api/approve; a successful approve is confirmed by the
919
+ // server's SSE "approval" broadcast above (which swaps the Approvals panel
920
+ // in place), not by this handler mutating state itself — the two never race.
921
+ // Wiring lives in a function because the SSE swap replaces the panel's DOM
922
+ // and must re-attach these listeners to the fresh buttons.
923
+ // The guided-flow prompt: every decision ends with "do you want to …?" —
924
+ // the walk's next step offered as an action, never left to inference.
925
+ // A hand-off to the agent is recorded as a COMMENT (the human→agent channel
926
+ // of record — auditable, resolvable), never a hidden side-channel; a next
927
+ // act that is the human's own jumps them to that artifact's signature bar.
928
+ function showNextPrompt(whatNext) {
929
+ if (!whatNext) return;
930
+ document.querySelectorAll(".next-prompt").forEach((el) => el.remove());
931
+ const bar = document.createElement("div");
932
+ bar.className = "next-prompt";
933
+ const head = document.createElement("span");
934
+ head.className = "next-prompt-did";
935
+ head.textContent = whatNext.did + ".";
936
+ bar.appendChild(head);
937
+ const dismiss = () => bar.remove();
938
+
939
+ const mkBtn = (label, onClick, primary) => {
940
+ const b = document.createElement("button");
941
+ b.type = "button";
942
+ b.className = primary ? "next-prompt-primary" : "next-prompt-dismiss";
943
+ b.textContent = label;
944
+ b.addEventListener("click", onClick);
945
+ return b;
946
+ };
947
+
948
+ const next = whatNext.next;
949
+ const pending = Array.isArray(whatNext.pending) ? whatNext.pending : [];
950
+ if (next && next.owner && next.owner.indexOf("agent") === 0) {
951
+ const q = document.createElement("span");
952
+ q.textContent = " Next — " + next.label + ". Do you want to ask the agent to proceed?";
953
+ bar.appendChild(q);
954
+ bar.appendChild(
955
+ mkBtn("Ask the agent to proceed", async () => {
956
+ try {
957
+ const res = await fetch("/api/comment", {
958
+ method: "POST",
959
+ headers: { "Content-Type": "application/json" },
960
+ body: JSON.stringify({
961
+ target: { type: "general" },
962
+ text: "Proceed" + (whatNext.feature ? " with " + whatNext.feature : "") + ": " + next.label,
963
+ }),
964
+ });
965
+ const body = await res.json();
966
+ head.textContent = body.ok
967
+ ? "Requested — recorded in the comments ledger; a listening agent picks it up."
968
+ : "Could not record the request: " + (body.reason || "refused");
969
+ bar.querySelectorAll(".next-prompt-primary").forEach((el) => el.remove());
970
+ } catch (err) {
971
+ head.textContent = "Could not record the request: " + String(err);
972
+ }
973
+ }, true),
974
+ );
975
+ } else if (pending.length > 0) {
976
+ const q = document.createElement("span");
977
+ q.textContent = " Still waiting on you: " + pending[0].label + (pending.length > 1 ? " (+" + (pending.length - 1) + " more)" : "") + ".";
978
+ bar.appendChild(q);
979
+ bar.appendChild(
980
+ mkBtn("Take me there", () => {
981
+ const railBtn = document.querySelector('.rail-nav .tab-btn[data-tab="' + pending[0].tab + '"]');
982
+ if (railBtn) railBtn.click();
983
+ const target = document.querySelector('#tab-' + pending[0].tab + ' [data-artifact="' + pending[0].artifact + '"]');
984
+ if (target) target.scrollIntoView({ behavior: "smooth", block: "center" });
985
+ dismiss();
986
+ }, true),
987
+ );
988
+ } else {
989
+ const q = document.createElement("span");
990
+ q.textContent = " Nothing else waits on you — everything is green.";
991
+ bar.appendChild(q);
992
+ }
993
+ bar.appendChild(mkBtn("Not now", dismiss, false));
994
+ document.body.appendChild(bar);
995
+ }
996
+
997
+ // A refusal must surface where the click happened. The approve/accept
998
+ // controls now live on more than one panel (the front door's queue as well
999
+ // as their owning sections), and the panel-scoped .sig-error box is found
1000
+ // first; the original single ids stay as the fallback so nothing regresses.
1001
+ function errBoxFor(btn, fallbackId) {
1002
+ const panel = btn.closest(".tab-panel");
1003
+ return (panel && panel.querySelector(".sig-error")) || document.getElementById(fallbackId);
1004
+ }
1005
+ function wireApproveButtons(scope) {
1006
+ scope.querySelectorAll(".approve-btn").forEach((btn) => {
1007
+ btn.addEventListener("click", async () => {
1008
+ const artifact = btn.dataset.artifact;
1009
+ const errBox = errBoxFor(btn, "approve-error");
1010
+ if (errBox) { errBox.hidden = true; errBox.textContent = ""; }
1011
+ const original = btn.textContent;
1012
+ btn.disabled = true;
1013
+ btn.textContent = "Approving…";
1014
+ try {
1015
+ // WHO is signing. Asked once per session and kept in sessionStorage —
1016
+ // not localStorage, because a signature should be a deliberate act by
1017
+ // whoever is at the keyboard now, not a name a browser remembers
1018
+ // forever. Cancelling declines to sign, which is a valid answer.
1019
+ let approvedBy = sessionStorage.getItem("approvalSigner") || "";
1020
+ if (!approvedBy) {
1021
+ approvedBy = (window.prompt("Sign this approval as (name and email) — recorded on the approval:", "") || "").trim();
1022
+ if (!approvedBy) {
1023
+ if (errBox) { errBox.hidden = false; errBox.textContent = "not signed — an approval records who signed it"; }
1024
+ btn.disabled = false;
1025
+ btn.textContent = original;
1026
+ return;
1027
+ }
1028
+ sessionStorage.setItem("approvalSigner", approvedBy);
1029
+ }
1030
+ const res = await fetch("/api/approve", {
1031
+ method: "POST",
1032
+ headers: { "Content-Type": "application/json" },
1033
+ body: JSON.stringify({ artifact, approvedBy }),
1034
+ });
1035
+ const body = await res.json();
1036
+ if (!body.ok) {
1037
+ if (errBox) { errBox.hidden = false; errBox.textContent = body.reason || "approval refused"; }
1038
+ btn.disabled = false;
1039
+ btn.textContent = original;
1040
+ } else {
1041
+ showNextPrompt(body.whatNext);
1042
+ }
1043
+ } catch (err) {
1044
+ if (errBox) { errBox.hidden = false; errBox.textContent = String(err); }
1045
+ btn.disabled = false;
1046
+ btn.textContent = original;
1047
+ }
1048
+ });
1049
+ });
1050
+ }
1051
+ wireApproveButtons(document);
1052
+ // Feature acceptance — POST /api/feature/accept; confirmed by the server's
1053
+ // SSE "approval" broadcast (the Features panel swaps in place), same
1054
+ // no-self-mutation contract as approve/reopen. Refusals (checks failing,
1055
+ // not delivered, older project lib) surface in #feature-error verbatim.
1056
+ function wireFeatureAcceptButtons(scope) {
1057
+ scope.querySelectorAll(".feature-accept-btn").forEach((btn) => {
1058
+ btn.addEventListener("click", async () => {
1059
+ const name = btn.dataset.name;
1060
+ const errBox = errBoxFor(btn, "feature-error");
1061
+ if (errBox) { errBox.hidden = true; errBox.textContent = ""; }
1062
+ const original = btn.textContent;
1063
+ btn.disabled = true;
1064
+ btn.textContent = "Accepting…";
1065
+ try {
1066
+ const res = await fetch("/api/feature/accept", {
1067
+ method: "POST",
1068
+ headers: { "Content-Type": "application/json" },
1069
+ body: JSON.stringify({ name }),
1070
+ });
1071
+ const body = await res.json();
1072
+ if (!body.ok) {
1073
+ if (errBox) { errBox.hidden = false; errBox.textContent = body.reason || "acceptance refused"; }
1074
+ btn.disabled = false;
1075
+ btn.textContent = original;
1076
+ } else {
1077
+ showNextPrompt(body.whatNext);
1078
+ }
1079
+ } catch (err) {
1080
+ if (errBox) { errBox.hidden = false; errBox.textContent = String(err); }
1081
+ btn.disabled = false;
1082
+ btn.textContent = original;
1083
+ }
1084
+ });
1085
+ });
1086
+ }
1087
+ wireFeatureAcceptButtons(document);
1088
+ // Reopen (§2/§3) — POST /api/reopen; confirmed the same way approve is: the
1089
+ // server's SSE "approval" broadcast (reopen reuses that event type — it's
1090
+ // still just "an artifact's status changed", the same in-place refresh
1091
+ // covers both) swaps the Approvals panel, not this handler. An older
1092
+ // project lib without reopenArtifact surfaces its refusal in #approve-error
1093
+ // — never a crash (GENESIS-FLOW-DESIGN.md §3 "honest degrade").
1094
+ function wireReopenButtons(scope) {
1095
+ scope.querySelectorAll(".reopen-btn").forEach((btn) => {
1096
+ btn.addEventListener("click", async () => {
1097
+ const artifact = btn.dataset.artifact;
1098
+ // A reopen walks back a signature, so it carries a reason the signer can
1099
+ // read from the ledger later (07-28 audit). Cancel = no transition.
1100
+ const reason = window.prompt("Reopen " + artifact + " — why, in one sentence?\\n(Recorded on the ledger and in the journal.)");
1101
+ if (reason === null || reason.trim() === "") return;
1102
+ const errBox = errBoxFor(btn, "approve-error");
1103
+ if (errBox) { errBox.hidden = true; errBox.textContent = ""; }
1104
+ const original = btn.textContent;
1105
+ btn.disabled = true;
1106
+ btn.textContent = "Reopening…";
1107
+ try {
1108
+ const res = await fetch("/api/reopen", {
1109
+ method: "POST",
1110
+ headers: { "Content-Type": "application/json" },
1111
+ body: JSON.stringify({ artifact, reason: reason.trim() }),
1112
+ });
1113
+ const body = await res.json();
1114
+ if (!body.ok) {
1115
+ if (errBox) { errBox.hidden = false; errBox.textContent = body.reason || "reopen refused"; }
1116
+ btn.disabled = false;
1117
+ btn.textContent = original;
1118
+ } else {
1119
+ showNextPrompt(body.whatNext);
1120
+ }
1121
+ } catch (err) {
1122
+ if (errBox) { errBox.hidden = false; errBox.textContent = String(err); }
1123
+ btn.disabled = false;
1124
+ btn.textContent = original;
1125
+ }
1126
+ });
1127
+ });
1128
+ }
1129
+ wireReopenButtons(document);
1130
+ // Pick (§2 candidates strip) — POSTs the EXISTING /api/comment endpoint with
1131
+ // target {type:"design-system", token:"variant:<name>"}, text "pick:<name>"
1132
+ // — no new decision machinery; the agent observes it via
1133
+ // review_comments{waitForComment}. The token field is REQUIRED by the §7.3
1134
+ // comments contract for design-system targets (the gate proved the library
1135
+ // refuses a token-less pick with 409); "variant:<name>" is the synthetic
1136
+ // token id for a candidate, mirroring the "component:<Name>" convention. A
1137
+ // successful pick is confirmed by the same "comment" SSE broadcast every
1138
+ // other comment produces (refreshes the Comments tab + badge); this handler
1139
+ // just gives immediate button feedback so the human isn't left guessing.
1140
+ function wirePickButtons(scope) {
1141
+ scope.querySelectorAll(".pick-btn").forEach((btn) => {
1142
+ if (btn.dataset.wired) return;
1143
+ btn.dataset.wired = "1";
1144
+ btn.addEventListener("click", async () => {
1145
+ const name = btn.dataset.variant;
1146
+ const errBox = document.getElementById("pick-error");
1147
+ if (errBox) { errBox.hidden = true; errBox.textContent = ""; }
1148
+ const original = btn.textContent;
1149
+ btn.disabled = true;
1150
+ btn.textContent = "Picking…";
1151
+ try {
1152
+ const res = await fetch("/api/comment", {
1153
+ method: "POST",
1154
+ headers: { "Content-Type": "application/json" },
1155
+ body: JSON.stringify({ target: { type: "design-system", token: "variant:" + name }, text: "pick:" + name }),
1156
+ });
1157
+ const body = await res.json();
1158
+ if (!body.ok) {
1159
+ if (errBox) { errBox.hidden = false; errBox.textContent = body.reason || "pick refused"; }
1160
+ btn.disabled = false;
1161
+ btn.textContent = original;
1162
+ } else {
1163
+ btn.textContent = "Picked";
1164
+ }
1165
+ } catch (err) {
1166
+ if (errBox) { errBox.hidden = false; errBox.textContent = String(err); }
1167
+ btn.disabled = false;
1168
+ btn.textContent = original;
1169
+ }
1170
+ });
1171
+ });
1172
+ }
1173
+ wirePickButtons(document);
1174
+ // Arrivals (walk-legibility L5) — the In-flight block's now-or-after choice.
1175
+ // Same contract as wirePickButtons: POST the EXISTING /api/comment endpoint
1176
+ // with a general-target comment the agent observes via
1177
+ // review_comments{waitForComment}. No new decision machinery, no new state —
1178
+ // the button records the human's answer where agent instructions already
1179
+ // flow, and the walk itself stays a pure projection.
1180
+ function wireArrivalButtons(scope) {
1181
+ scope.querySelectorAll(".wk-arrival-btn").forEach((btn) => {
1182
+ if (btn.dataset.wired) return;
1183
+ btn.dataset.wired = "1";
1184
+ btn.addEventListener("click", async () => {
1185
+ const id = btn.dataset.arrival;
1186
+ const choice = btn.dataset.choice === "now" ? "handle it now" : "handle it after the current walk lands";
1187
+ const original = btn.textContent;
1188
+ btn.disabled = true;
1189
+ btn.textContent = "Sending…";
1190
+ try {
1191
+ const res = await fetch("/api/comment", {
1192
+ method: "POST",
1193
+ headers: { "Content-Type": "application/json" },
1194
+ body: JSON.stringify({ target: { type: "general" }, text: "arrival " + id + ": " + choice }),
1195
+ });
1196
+ const body = await res.json();
1197
+ if (!body.ok) {
1198
+ btn.disabled = false;
1199
+ btn.textContent = original;
1200
+ } else {
1201
+ btn.textContent = "Sent to the agent";
1202
+ }
1203
+ } catch (err) {
1204
+ btn.disabled = false;
1205
+ btn.textContent = original;
1206
+ }
1207
+ });
1208
+ });
1209
+ }
1210
+ wireArrivalButtons(document);
1211
+ // Comments (§7.3) — every 💬 control (screens, spec clauses, tokens,
1212
+ // components, architecture nodes) opens the same inline popover and POSTs
1213
+ // to /api/comment; a successful post is confirmed by the server's SSE
1214
+ // "comment" broadcast (which refreshes the Comments tab + badge in place),
1215
+ // not by this handler — same non-racing split as wireApproveButtons.
1216
+ // dataset.wired guards against double-binding across repeated calls
1217
+ // (initial load + no-op re-scans of the same, never-swapped card markup).
1218
+ function wireCommentButtons(scope) {
1219
+ scope.querySelectorAll(".comment-ctl").forEach((ctl) => {
1220
+ const btn = ctl.querySelector(".comment-btn");
1221
+ const pop = ctl.querySelector(".comment-popover");
1222
+ if (!btn || !pop || btn.dataset.wired) return;
1223
+ btn.dataset.wired = "1";
1224
+ btn.addEventListener("click", () => { pop.hidden = !pop.hidden; });
1225
+ const cancelBtn = pop.querySelector(".comment-cancel");
1226
+ if (cancelBtn) cancelBtn.addEventListener("click", () => { pop.hidden = true; });
1227
+ const submitBtn = pop.querySelector(".comment-submit");
1228
+ if (submitBtn) submitBtn.addEventListener("click", async () => {
1229
+ const textEl = pop.querySelector(".comment-text");
1230
+ const ttEl = pop.querySelector(".comment-testtag");
1231
+ const errEl = pop.querySelector(".comment-error");
1232
+ if (errEl) { errEl.hidden = true; errEl.textContent = ""; }
1233
+ let target;
1234
+ try { target = JSON.parse(ctl.dataset.target); } catch { target = { type: "general" }; }
1235
+ if (ttEl && ttEl.value.trim()) target = { type: "element", screen: target.screen, testTag: ttEl.value.trim() };
1236
+ submitBtn.disabled = true;
1237
+ try {
1238
+ const res = await fetch("/api/comment", {
1239
+ method: "POST",
1240
+ headers: { "Content-Type": "application/json" },
1241
+ body: JSON.stringify({ target, text: textEl ? textEl.value : "" }),
1242
+ });
1243
+ const body = await res.json();
1244
+ if (!body.ok) {
1245
+ if (errEl) { errEl.hidden = false; errEl.textContent = body.reason || "comment refused"; }
1246
+ } else {
1247
+ pop.hidden = true;
1248
+ if (textEl) textEl.value = "";
1249
+ if (ttEl) ttEl.value = "";
1250
+ }
1251
+ } catch (err) {
1252
+ if (errEl) { errEl.hidden = false; errEl.textContent = String(err); }
1253
+ } finally {
1254
+ submitBtn.disabled = false;
1255
+ }
1256
+ });
1257
+ });
1258
+ }
1259
+ wireCommentButtons(document);
1260
+ // A1 — the Start-live-session chain: POST kicks it off server-side; the page
1261
+ // then polls /live/status and reloads when the chain finishes (success or
1262
+ // fail — either way the section re-renders the honest per-step outcomes).
1263
+ const liveBtn = document.getElementById("live-start-btn");
1264
+ if (liveBtn) {
1265
+ liveBtn.addEventListener("click", async () => {
1266
+ const errBox = document.getElementById("live-error");
1267
+ if (errBox) { errBox.hidden = true; errBox.textContent = ""; }
1268
+ liveBtn.disabled = true;
1269
+ liveBtn.textContent = "Starting…";
1270
+ try {
1271
+ const res = await fetch("/live/start", { method: "POST" });
1272
+ const body = await res.json();
1273
+ if (!body.started) throw new Error(body.reason || "did not start");
1274
+ const poll = setInterval(async () => {
1275
+ try {
1276
+ const st = await (await fetch("/live/status")).json();
1277
+ if (!st.running) { clearInterval(poll); location.reload(); }
1278
+ } catch { /* transient poll failure — keep polling */ }
1279
+ }, 2000);
1280
+ } catch (err) {
1281
+ liveBtn.disabled = false;
1282
+ liveBtn.textContent = "Start live session";
1283
+ if (errBox) { errBox.hidden = false; errBox.textContent = String(err); }
1284
+ }
1285
+ });
1286
+ }
1287
+ `;
1288
+
1289
+ // Sections that need the render pipeline are absent — not empty, not
1290
+ // greyed — when there is no Compose app to render. Everything else derives
1291
+ // from qa/ and stays. One quiet rail line says what is absent and why, so the
1292
+ // reader never wonders whether Screens failed to load.
1293
+ const railFootPlain = `<button type="button" class="tab-btn" data-tab="evidence" title="open Evidence">${railReceiptHtml(effectiveReceipt)}</button>`;
1294
+ // Sections that need a Compose app to mean anything. `design-system` joined
1295
+ // screens and live-device on 2026-09-07: a design LANGUAGE is Theme.kt and
1296
+ // Tokens.kt, and a service with no UI was being shown a visual vocabulary it
1297
+ // does not have — the console being honest about its own defaults rather than
1298
+ // about the project. This is the fallback for a project that declares no
1299
+ // sections; a profile that declares them gets exactly what it declared.
1300
+ const NEEDS_SCREENS = new Set(["screens", "live-device", "design-system"]);
1301
+ // The declaration comes first: it says which sections this project HAS.
1302
+ // Capability filtering then removes what it cannot show — a project may
1303
+ // declare Screens and still not have a Compose app to render, and that stays
1304
+ // an absence with a stated reason rather than a contradiction. An id declared
1305
+ // but unknown to this console is dropped rather than invented, because a rail
1306
+ // entry leading to an empty panel is the dishonesty this whole section fights.
1307
+ const declared = Array.isArray(declaredSectionIds) && declaredSectionIds.length ? declaredSectionIds : null;
1308
+ const pick = (items) => (declared ? declared.map((id) => items.find((x) => x.id === id)).filter(Boolean) : items);
1309
+ const declaredRail = pick(railItems);
1310
+ const declaredSections = pick(sections);
1311
+ const visibleRail = capabilities.screens ? declaredRail : declaredRail.filter((r) => !NEEDS_SCREENS.has(r.id));
1312
+ const visibleSections = capabilities.screens ? declaredSections : declaredSections.filter((s) => !NEEDS_SCREENS.has(s.id));
1313
+ const capabilityNote = capabilities.screens
1314
+ ? ""
1315
+ : `<p class="rail-sub rail-capability" title="This project declares no screen-rendering capability. The governance window is complete; screens, preview and the live device all need one.">governance only &middot; ${consoleCopy().noRenderableApp}</p>`;
1316
+ // The layout line: which manifest (if any) the console is reading this
1317
+ // project through. A refused manifest is said out loud on the rail — every
1318
+ // section it feeds already carries the reason, but the rail is where a
1319
+ // reader looks first when a pane is unexpectedly empty.
1320
+ const layoutNote = !layout
1321
+ ? ""
1322
+ : !layout.ok
1323
+ ? `<p class="rail-sub rail-capability rail-layout-refused" title="${escAttr(layout.reason || "")}">${esc(layout.relPath || MANIFEST_REL_PATH)} refused &mdash; see Evidence</p>`
1324
+ : layout.source === "manifest"
1325
+ ? `<p class="rail-sub rail-capability" title="${escAttr(`layout from ${layout.relPath}: receipt ${layout.layout.receipt}; specs ${layout.layout.specs}/; doc ${layout.layout.architectureDoc}`)}">layout: ${esc(layout.relPath)} &middot; packs ${esc((layout.layout.packs || []).join(", "))}</p>`
1326
+ : "";
1327
+
1328
+ return renderShellPage({
1329
+ appName,
1330
+ railItems: visibleRail,
1331
+ railFootHtml: `${capabilityNote}${layoutNote}${railFootPlain}`,
1332
+ // The governance strip (07-28 audit, fix 5): counts + the one next human
1333
+ // act + recent history, rail-resident so it is visible on EVERY tab. Its
1334
+ // queue is the SAME deriveHumanQueue the guided prompt uses — one
1335
+ // derivation, so the strip and the prompt can never disagree.
1336
+ govStripHtml: governanceStripHtml({
1337
+ statuses: approvals.available ? approvals.statuses : [],
1338
+ features: features.available ? features.board.features : [],
1339
+ }),
1340
+ // §3.6: the rail-foot verify line doubles as the deep link to Evidence —
1341
+ // the same .tab-btn/data-tab wiring the nav items use (showTab picks it
1342
+ // up with no new JS mechanism), styled back to a quiet meta line by the
1343
+ // shell's .rail-foot .tab-btn rules.
1344
+ sections: visibleSections,
1345
+ error,
1346
+ // FI-9 Change B: the renderer's OWN health banner, additive to `error`
1347
+ // above (which already covers "last render/compile/reload FAILED" as a
1348
+ // point-in-time message) — this one survives a LATER unrelated
1349
+ // compile-check message overwriting `error`/`errorSource`, and states
1350
+ // since-when the (possibly stale) screens below stopped refreshing.
1351
+ rendererDown: renderer && renderer.lastOutcome === "failed" ? { ...renderer, lastError: rendererLastErrorText } : null,
1352
+ // The freshness banner sits ABOVE the renderer/error banners: "are these pixels
1353
+ // current" is the first thing a reader needs, before why they might not be.
1354
+ freshness,
1355
+ // §3.4 geometry from the render viewport: uniform cell width keeps the
1356
+ // matrix's columns aligned without a shared grid; the expanded wireframe
1357
+ // gets the roomier single-pane width.
1358
+ extraCss: ` .matrix-cell img, .matrix-cell.matrix-none { width: ${Math.round(width * 0.38)}px; }
1359
+ .matrix-col { width: ${Math.round(width * 0.38)}px; }
1360
+ .row-detail .wire svg { width: ${Math.round(width * 0.7)}px; }
1361
+ /* PW-5 surfaces: walkthrough grid, live-device embed, digest lists, B5 diff */
1362
+ .wt-grid { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 10px; }
1363
+ .wt-card { width: 210px; }
1364
+ .wt-card img { width: 100%; border-radius: 8px; border: 1px solid var(--line); }
1365
+ .wt-meta { font-size: var(--fs-meta); color: var(--muted); margin-top: 4px; }
1366
+ .wt-notwalked li, .wt-history li, .digest-list li { font-size: var(--fs-meta); color: var(--muted); margin: 3px 0; }
1367
+ .ok-inline { color: var(--ok, #7dc87d); }
1368
+ .bad-inline { color: var(--err, #d07d7d); }
1369
+ .live-remote { width: 100%; height: 72vh; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
1370
+ .live-steps li { font-size: var(--fs-meta); margin: 3px 0; }
1371
+ .live-step-ok { color: var(--ok, #7dc87d); }
1372
+ .live-step-fail { color: var(--err, #d07d7d); }
1373
+ .live-step-running { color: var(--muted); }
1374
+ #live-start-btn { font: inherit; font-size: var(--fs-meta); font-weight: 600; padding: 8px 16px; cursor: pointer; }
1375
+ .approval-diff { max-height: 420px; overflow: auto; font-size: 12px; background: var(--surface); border-radius: 8px; padding: 10px; }
1376
+ .approval-diff-row td { border-top: none; }`,
1377
+ bodyScript,
1378
+ provenance: { treeHash, version, build },
1379
+ // The console's own build handshake — drives the stale banner above every
1380
+ // page. galleryHtml's caller passes the live value; absent (older callers,
1381
+ // tests that render a bare page) renders no banner rather than a fake one.
1382
+ build,
1383
+ });
1384
+ }