create-cmp-cli 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +10 -11
- 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 +271 -37
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- 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 +34 -2
- 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 +434 -53
- 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 +175 -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} +229 -27
- 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/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +80 -24
- 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 +175 -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 +3 -0
- package/template/qa/approve.mjs +10 -11
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +271 -37
- 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/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 +34 -2
- 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 +434 -53
- 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 +175 -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} +229 -27
- 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/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +80 -24
- 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,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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
104
|
+
const escAttr = (s) => esc(s).replace(/"/g, """);
|
|
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 ? ` · ✎ ${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} · ${baseScreenCount} screen${baseScreenCount === 1 ? "" : "s"}${
|
|
272
|
+
changedScreens.length ? ` · <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(" · ") || "not yet captured — 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(" · ") || "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"} · ${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
|
+
? ` · <span title="${escAttr(rungPackNote(effectiveReceipt.evidenceLevel, effectiveReceipt.packId ?? effectiveReceipt.pack))}">${esc(rungLabel)}</span>`
|
|
349
|
+
: "";
|
|
350
|
+
evidenceStatus = `verify ${esc(effectiveReceipt.verdict || "?")}${rung} · ${esc(age)}${
|
|
351
|
+
effectiveReceipt.stale ? ` · <span class="status-drift">stale — 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(" · ");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const commentsStatus = comments.available
|
|
367
|
+
? `${openCommentCount} open · ${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(" · ");
|
|
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…"></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 · ${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 — 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)} · 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
|
+
}
|