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,1139 @@
1
+ // MOVED from inspector/mcp/src/lib/console-shell.mjs — NORTH-STAR §9, stage 0.5,
2
+ // "the console into the harness". Distribution has to ship the console and could
3
+ // not while it lived in a sibling package; `prooflane-harness`'s `files` list
4
+ // ships `src/`, so it ships from here. Nothing in this file changed in the move
5
+ // — it had no imports at all, which is why it crossed first.
6
+ //
7
+ // Note the directory: `src/console/`, not `src/lib/`. scripts/sync-harness.mjs
8
+ // mirrors `src/lib` into `template/qa/lib`, so a console under lib/ would be
9
+ // vendored into every stamped app. REGION_DIRS is one level deep and REGION_TREES
10
+ // covers only `src/lib/profiles`, so `src/console/` is shipped and not vendored.
11
+ //
12
+ // console-shell.mjs — the ONE frame every console section renders inside
13
+ // (docs/STUDIO-REDESIGN.md §2). The shell is the whole design system: the
14
+ // sidebar ordering/coverage rail, the per-page header grammar, the type ramp,
15
+ // the spacing scale, the color roles, and the provenance footer. Sections
16
+ // contribute ONLY a document body — they may not invent their own chrome.
17
+ //
18
+ // Design rules encoded here (§2, non-negotiable for every section):
19
+ // - Ink/paper neutrals with ONE accent. Semantic red/amber/green are RESERVED
20
+ // for drift/reopened/signed (and gate FAIL/PASS, the same three meanings) —
21
+ // never decoration.
22
+ // - A 4-step type ramp and an 8px spacing scale. Readable measure for prose;
23
+ // full-bleed only for galleries.
24
+ // - The rail lists sections in the genesis definition order, each with its
25
+ // live state glyph (● signed · ○ unsigned · ◐ reopened · ⚠ drifted).
26
+ // - The dashboard function is ambient — with ONE exception, added 2026-08-22
27
+ // after the rule failed twice in the same way: the Overview section (§3.7,
28
+ // console-overview.mjs) is the front door, and it is the only section
29
+ // permitted to aggregate. It owns no facts and grows no signature control;
30
+ // everything else stays ambient, in the section that owns it.
31
+ // - Every page: header block (title · status line) → document body →
32
+ // provenance footer ("derived from the live tree … absence = not derivable").
33
+ // - Light + dark via prefers-color-scheme. Pure server-rendered HTML/CSS,
34
+ // zero external dependencies.
35
+ //
36
+ // The one import, added 2026-09-08: the rail foot shows an evidence rung, and
37
+ // NORTH-STAR.md §6.5 requires every surface that shows one to show the pack
38
+ // beside it. That spelling belongs to console-evidence.mjs so that the console
39
+ // has one of it rather than seven (see that file's header). The import is a
40
+ // sibling inside `src/console/`, so the reason this file crossed the package
41
+ // boundary cleanly still holds: nothing here points out of the package.
42
+
43
+ import { rungWithPack, rungPackNote } from "./console-evidence.mjs";
44
+
45
+ const esc = (s) =>
46
+ String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
47
+
48
+ // --- state glyphs -------------------------------------------------------------
49
+
50
+ /**
51
+ * A governed artifact's live status -> its rail/header glyph. The four §2
52
+ * states, plus the express-lane nuance: approved-with-defaults is still
53
+ * SIGNED (●) — the "unshaped" caveat belongs in the status line's words, not
54
+ * in a fifth glyph nobody can read. `null`/unknown statuses get NO glyph
55
+ * (evidence-or-silence: an older project lib that never reports a status is
56
+ * not "unsigned", it's unknown).
57
+ * @param {object|null|undefined} record an approvals status record ({status, mode, ...})
58
+ * @returns {{ch: string, cls: string, label: string}|null}
59
+ */
60
+ export function statusGlyph(record) {
61
+ if (!record || !record.status) return null;
62
+ switch (record.status) {
63
+ case "approved":
64
+ return { ch: "●", cls: "glyph-signed", label: record.mode === "defaults-accepted" ? "signed (defaults accepted — unshaped)" : "signed" };
65
+ case "changed-since-approval":
66
+ return { ch: "⚠", cls: "glyph-drift", label: "drifted — changed since approval" };
67
+ case "reopened":
68
+ return { ch: "◐", cls: "glyph-reopen", label: "reopened for redesign" };
69
+ case "unreviewed":
70
+ return { ch: "○", cls: "glyph-unsigned", label: "unsigned" };
71
+ default:
72
+ return null;
73
+ }
74
+ }
75
+
76
+ const shortHash = (h) => (h ? String(h).slice(0, 8) : null);
77
+
78
+ // --- the human's queue (ONE derivation — the strip, the guided prompt, and ---
79
+ // --- approval_status all read this; two derivations was the 07-28 audit's ----
80
+ // --- finding 3: the card said "waiting on you" while the queue said nothing --
81
+
82
+ /**
83
+ * Everything currently waiting on the HUMAN, as actionable items, derived
84
+ * from the live statuses + feature board. Each item names the action in plain
85
+ * words and the tab where its signature control lives (sign-where-you-read).
86
+ *
87
+ * `reopened` is included ONLY when it has become the human's turn: a reopened
88
+ * feature brief whose feature derives provenDone (the redesign is finished
89
+ * and proven — same derivation acceptance trusts). A reopened artifact still
90
+ * mid-redesign stays out: prompting a re-approval there would invite signing
91
+ * an unfinished redesign.
92
+ * @param {{statuses?: object[], features?: object[]}} data
93
+ * @param {string} [excludeArtifact] the artifact just acted on (its own prompt
94
+ * should not re-list it)
95
+ * @returns {Array<{artifact: string, tab: string, label: string}>}
96
+ */
97
+ export function deriveHumanQueue({ statuses = [], features = [] }, excludeArtifact) {
98
+ const tabOf = (id) =>
99
+ id === "intent" || id === "architecture" || id === "design-system" || id === "components"
100
+ ? id
101
+ : id.startsWith("feature-brief:") || id.startsWith("feature-design:")
102
+ ? "features"
103
+ : id === "exemplar-spec" || id.startsWith("feature-spec:")
104
+ ? "specs"
105
+ : "approvals";
106
+ const readyBriefs = new Set(
107
+ features.filter((f) => f.phase === "reopened" && f.provenDone).map((f) => `feature-brief:${f.name}`),
108
+ );
109
+ const items = [];
110
+ for (const s of statuses) {
111
+ if (s.id === excludeArtifact) continue;
112
+ if (s.status === "unreviewed" && s.resolvable !== false) {
113
+ items.push({ artifact: s.id, tab: tabOf(s.id), label: `Approve ${s.id}` });
114
+ } else if (s.status === "changed-since-approval") {
115
+ items.push({ artifact: s.id, tab: tabOf(s.id), label: `Re-approve ${s.id} — it changed since signing` });
116
+ } else if (s.status === "reopened" && readyBriefs.has(s.id)) {
117
+ items.push({ artifact: s.id, tab: tabOf(s.id), label: `Re-approve ${s.id} — the redesign is proven` });
118
+ }
119
+ }
120
+ for (const f of features) {
121
+ if (f.phase === "proven" && `feature-brief:${f.name}` !== excludeArtifact) {
122
+ items.push({ artifact: `feature-brief:${f.name}`, tab: "features", label: `Accept ${f.name} — proven done` });
123
+ }
124
+ }
125
+ return items;
126
+ }
127
+
128
+ // --- the governance strip (rail-resident — visible on EVERY tab) --------------
129
+
130
+ /**
131
+ * The always-visible aggregate (07-28 audit, fix 5 — the andon-board answer to
132
+ * "I should be able to see the status at all times"): one counts line, plus the
133
+ * door to the page that explains it. Renders in the rail, so no tab choice can
134
+ * hide it. Returns "" when there is nothing derivable (no statuses at all — an
135
+ * ungoverned or older project): silence, not a fabricated dashboard.
136
+ *
137
+ * It deliberately stops at the COUNTS. Naming the next act here as well — which
138
+ * it did until 2026-08-22 — made the rail a lower-fidelity copy of the front
139
+ * door, shown alongside the front door. The rail says THAT something waits; the
140
+ * front door says WHAT, with the evidence, and owns the acting.
141
+ * @param {{statuses?: object[], features?: object[]}} data
142
+ */
143
+ export function governanceStripHtml({ statuses = [], features = [] }) {
144
+ if (statuses.length === 0) return "";
145
+ const queue = deriveHumanQueue({ statuses, features });
146
+ const signed = statuses.filter((s) => s.status === "approved").length;
147
+ const queued = new Set(queue.map((q) => q.artifact));
148
+ const redesigning = statuses.filter((s) => s.status === "reopened" && !queued.has(s.id));
149
+ const drifted = statuses.filter((s) => s.status === "changed-since-approval");
150
+
151
+ // A bare "1 in redesign" is a number with no referent — it names no artifact,
152
+ // no owner, and no way to reach it, which is exactly how a reader ends up
153
+ // asking "what does that mean and where do I see it?" (2026-07-28). So each
154
+ // non-zero count names its artifact when there is only one, and every count is
155
+ // a jump button to the Approvals row that explains itself.
156
+ const countBtn = (cls, text, artifact) =>
157
+ `<button type="button" class="gov-n ${cls} gov-jump" data-go-tab="approvals"${
158
+ artifact ? ` data-go-artifact="${esc(artifact)}"` : ""
159
+ } title="take me there">${esc(text)}</button>`;
160
+ const namesOf = (rows, noun) =>
161
+ rows.length === 1 ? `${noun}: ${rows[0].label || rows[0].id}` : `${rows.length} ${noun}`;
162
+
163
+ const counts = [
164
+ `<span class="gov-n gov-signed">${signed} signed</span>`,
165
+ queue.length > 0 ? `<span class="gov-n gov-awaiting">${queue.length} await${queue.length === 1 ? "s" : ""} you</span>` : null,
166
+ redesigning.length > 0
167
+ ? countBtn("gov-redesign", namesOf(redesigning, "in redesign"), redesigning.length === 1 ? redesigning[0].id : null)
168
+ : null,
169
+ drifted.length > 0 ? countBtn("gov-drift", namesOf(drifted, "drifted"), drifted.length === 1 ? drifted[0].id : null) : null,
170
+ ]
171
+ .filter(Boolean)
172
+ .join(" · ");
173
+
174
+ // The strip is a POINTER now, not a second copy of the front door (2026-08-22).
175
+ // It used to carry the next act in full and the journal's History — which, once
176
+ // the Overview page existed, meant the rail said the same things the page said,
177
+ // on the page itself, at half the fidelity. That duplication is precisely the
178
+ // "so much happening all over the place" complaint. So the division is: the
179
+ // rail tells you THAT something waits (on every tab, the 07-28 requirement);
180
+ // the front door tells you WHAT, with the evidence, and is where you go to act.
181
+ // History moved with it — see console-overview.mjs — so no fact was dropped.
182
+ const next =
183
+ queue.length > 0
184
+ ? // data-go-* (NOT data-tab/data-artifact): a strip jump is not a rail tab
185
+ // button, and overloading the rail's attributes would collide with every
186
+ // selector that treats data-tab as "a tab exists here".
187
+ `<button type="button" class="gov-next" data-go-tab="overview" title="open the front door">${queue.length} thing${queue.length === 1 ? "" : "s"} need${queue.length === 1 ? "s" : ""} you <span class="gov-more">&rarr; Drive</span></button>`
188
+ : `<p class="gov-clear">Nothing waits on you.</p>`;
189
+
190
+ return `<div id="gov-strip">
191
+ <p class="gov-counts">${counts}</p>
192
+ ${next}
193
+ </div>`;
194
+ }
195
+
196
+ /**
197
+ * The header status line for a section governed by ONE artifact — the §2
198
+ * grammar: "● signed a1b2c3 · approved <when>" / "○ unsigned" / "⚠ drifted
199
+ * (a1b2c3 → d4e5f6)" / "◐ reopened for redesign". Returns "" when there is
200
+ * no record (approvals unavailable / older scaffold): silence, not a
201
+ * fabricated state.
202
+ * @param {object|null|undefined} record an approvals status record
203
+ */
204
+ export function artifactStatusHtml(record) {
205
+ const g = statusGlyph(record);
206
+ if (!g) return "";
207
+ const glyph = `<span class="glyph ${g.cls}">${g.ch}</span>`;
208
+ if (record.status === "approved") {
209
+ const unshaped = record.mode === "defaults-accepted" ? " · defaults accepted — unshaped" : "";
210
+ const at = record.approvedAt ? ` · approved ${esc(record.approvedAt)}` : "";
211
+ // The SIGNED hash is `storedHash` — what the human's signature was bound to.
212
+ // `hash` is the live recompute; the two agree for everything signed on the
213
+ // current basis, but on a `hashBasis: "raw-bytes"` row the live value is a
214
+ // number nobody ever signed, and printing it here would be a fabricated
215
+ // provenance claim in the one surface whose whole job is provenance.
216
+ // (`storedHash` is always set on an approved row; the fallback is belt.)
217
+ const signedHash = shortHash(record.storedHash) || shortHash(record.hash) || "?";
218
+ const basis =
219
+ record.hashBasis === "raw-bytes" ? " · signed pre-strip — bytes unchanged since" : "";
220
+ return `${glyph} signed <code>${esc(signedHash)}</code>${at}${unshaped}${basis}`;
221
+ }
222
+ if (record.status === "changed-since-approval") {
223
+ const from = shortHash(record.storedHash);
224
+ const to = shortHash(record.hash);
225
+ const move = from && to ? ` (<code>${esc(from)}</code> &rarr; <code>${esc(to)}</code>)` : "";
226
+ return `${glyph} <span class="status-drift">drifted — changed since approval${move}</span>`;
227
+ }
228
+ if (record.status === "reopened") {
229
+ return `${glyph} <span class="status-reopen">reopened for redesign</span>`;
230
+ }
231
+ return `${glyph} unsigned — not yet approved`;
232
+ }
233
+
234
+ // --- the rail -----------------------------------------------------------------
235
+
236
+ /** Coarse "2h ago" age, from a bridge-computed ageMs (pure — no clock read here). */
237
+ export function formatAgeCoarse(ageMs) {
238
+ if (typeof ageMs !== "number" || Number.isNaN(ageMs)) return "age unknown";
239
+ if (ageMs < 60_000) return "just now";
240
+ const mins = Math.floor(ageMs / 60_000);
241
+ if (mins < 60) return `${mins}m ago`;
242
+ const hours = Math.floor(mins / 60);
243
+ if (hours < 24) return `${hours}h ago`;
244
+ return `${Math.floor(hours / 24)}d ago`;
245
+ }
246
+
247
+ /**
248
+ * A verify receipt's state -> its glyph — ONE derivation for everywhere the
249
+ * receipt shows as a symbol (the rail's Evidence item, the rail foot):
250
+ * ✓ fresh PASS · ✗ FAIL · ⚠ stale (never presented as a live PASS) · ○ none.
251
+ * @param {object|null|undefined} receipt receipt-bridge.mjs getLastReceipt() result
252
+ * @returns {{ch: string, cls: string, label: string}}
253
+ */
254
+ export function receiptGlyph(receipt) {
255
+ if (!receipt || !receipt.available) {
256
+ return { ch: "○", cls: "glyph-unsigned", label: "no verify receipt yet" };
257
+ }
258
+ if (receipt.stale) {
259
+ return { ch: "⚠", cls: "glyph-drift", label: `verify ${receipt.verdict || "?"} — stale, the tree changed since` };
260
+ }
261
+ const verdict = receipt.verdict || "?";
262
+ if (verdict === "PASS") return { ch: "✓", cls: "glyph-signed", label: "verify PASS" };
263
+ if (verdict === "FAIL") return { ch: "✗", cls: "glyph-drift", label: "verify FAIL" };
264
+ return { ch: "○", cls: "glyph-unsigned", label: `verify ${verdict}` };
265
+ }
266
+
267
+ /**
268
+ * The verify-receipt line at the rail's foot (§2: "Bottom of the rail: last
269
+ * verify receipt verdict + age"). Honest about every degraded state: no
270
+ * receipt, unparseable receipt, stale (inputsHash no longer matches the
271
+ * tree), or freshness-unknown — a stale green is NEVER shown as a live PASS.
272
+ * The glyph itself comes from receiptGlyph, the one shared derivation.
273
+ * @param {object|null|undefined} receipt receipt-bridge.mjs getLastReceipt() result
274
+ */
275
+ export function railReceiptHtml(receipt, formatAge = formatAgeCoarse) {
276
+ const g = receiptGlyph(receipt);
277
+ const glyph = `<span class="glyph ${g.cls}">${g.ch}</span>`;
278
+ if (!receipt || !receipt.available) {
279
+ return `${glyph} no verify receipt yet`;
280
+ }
281
+ const verdict = receipt.verdict || "?";
282
+ const age = typeof receipt.ageMs === "number" ? formatAge(receipt.ageMs) : "age unknown";
283
+ if (receipt.stale) {
284
+ return `${glyph} verify ${esc(verdict)} ${esc(age)} &mdash; stale (tree changed since)`;
285
+ }
286
+ const unknown = receipt.stale === null ? " &middot; freshness unverified" : "";
287
+ // The evidence-ladder rung, verbatim from the receipt's own derived
288
+ // evidenceLevel — absent (FAIL / pre-ladder receipt) means no rung shown —
289
+ // AND THE PACK THAT GRADED IT, which is not decoration on the one line the
290
+ // console shows on every page. This foot said "verify PASS · L2 device" until
291
+ // 2026-09-08, and a reader who met that had no way to tell a phone-driven L2
292
+ // from a database-backed one (§8.9). The pack rides the same string so the
293
+ // two can never be separated by a later edit to this template literal.
294
+ const label = rungWithPack(receipt.evidenceLevel, receipt.packId ?? receipt.pack);
295
+ const note = rungPackNote(receipt.evidenceLevel, receipt.packId ?? receipt.pack);
296
+ const rung = label ? ` &middot; <span title="${esc(note)}">${esc(label)}</span>` : "";
297
+ return `${glyph} verify ${esc(verdict)}${rung} ${esc(age)}${unknown}`;
298
+ }
299
+
300
+ /**
301
+ * One rail nav item. Keeps the `.tab-btn`/`data-tab` contract the behavior
302
+ * script and the browser gates already speak — the rail is a restyled
303
+ * navigation, not a new mechanism.
304
+ * @param {{id: string, label: string, glyph: object|null, active?: boolean, badgeHtml?: string}} item
305
+ */
306
+ function railItemHtml(item) {
307
+ const g = item.glyph;
308
+ const glyph = g
309
+ ? `<span class="glyph ${g.cls}" title="${esc(g.label)}">${g.ch}</span>`
310
+ : `<span class="glyph glyph-none">&middot;</span>`;
311
+ return `<button class="tab-btn${item.active ? " active" : ""}" data-tab="${esc(item.id)}">${glyph}<span class="rail-label">${esc(item.label)}</span>${item.badgeHtml || ""}</button>`;
312
+ }
313
+
314
+ // --- the page frame -----------------------------------------------------------
315
+
316
+ /**
317
+ * One section, wrapped in the §2 page grammar: header block (title + status
318
+ * line) → the document body → the provenance footer. The `#tab-<id>` /
319
+ * `.tab-panel` contract is unchanged (SSE in-place swaps target these ids).
320
+ * @param {{id: string, title: string, statusHtml?: string, bodyHtml: string, active?: boolean, fullBleed?: boolean, headExtraHtml?: string}} s
321
+ * @param {string} provenanceHtml the shared footer line (same for every page)
322
+ */
323
+ function sectionHtml(s, provenanceHtml) {
324
+ const status = s.statusHtml ? `<p class="page-status">${s.statusHtml}</p>` : "";
325
+ // Page anatomy (studio-drive-mode): a MIRROR section's default weight is its
326
+ // verdict (the page-status line above), with the complete derived document
327
+ // one disclosure away. The mirror stays byte-complete and stays the drift
328
+ // surface — only its default expansion changes. Tool sections (Drive,
329
+ // Screens, Live device) and any section the caller marks open render as
330
+ // before; the gov-jump handler opens the disclosure when a jump lands
331
+ // inside it, so "take me there" still lands ON the row.
332
+ const body = s.mirror
333
+ ? `<details class="mirror-details"${s.mirrorOpen ? " open" : ""}><summary>Read the full ${esc(s.title)} — every fact, derived from the live tree</summary>
334
+ ${s.bodyHtml}
335
+ </details>`
336
+ : s.bodyHtml;
337
+ return `<section id="tab-${esc(s.id)}" class="tab-panel${s.active ? " active" : ""}${s.fullBleed ? " full-bleed" : ""}" data-tab="${esc(s.id)}">
338
+ <header class="page-head">
339
+ <h2>${esc(s.title)}</h2>
340
+ ${status}${s.headExtraHtml || ""}
341
+ </header>
342
+ <div class="page-body">
343
+ ${body}
344
+ </div>
345
+ <footer class="page-foot">${provenanceHtml}</footer>
346
+ </section>`;
347
+ }
348
+
349
+ /**
350
+ * The provenance footer (§2): what this page was derived FROM, and the
351
+ * standing epistemic rule. `treeHash` is the project's git HEAD when the
352
+ * caller could resolve one — omitted (not faked) otherwise.
353
+ * @param {{treeHash?: string|null, version?: number}} p
354
+ */
355
+ export function provenanceHtml(p = {}) {
356
+ const tree = p.treeHash ? `derived from tree <code>@${esc(p.treeHash)}</code>` : "derived from the live tree";
357
+ const render = typeof p.version === "number" && p.version > 0 ? ` &middot; render #${p.version}` : "";
358
+ // Which build of the CONSOLE drew this page. Provenance already answers
359
+ // "which tree is this derived from"; without this it could not answer "and
360
+ // which code did the deriving" — the question that cost two hours to answer
361
+ // by hand (2026-07-27/28).
362
+ const build = p.build && p.build.id ? ` &middot; console <code>${esc(String(p.build.id).slice(0, 8))}</code>` : "";
363
+ return `${tree}${render}${build} &middot; absence = not derivable`;
364
+ }
365
+
366
+ /**
367
+ * The stale-console banner: this page was drawn by code that is no longer the
368
+ * code on disk. Sits with the other page-top banners because it invalidates
369
+ * everything below it — a reader who trusts a stale page is the exact failure
370
+ * (twice in two days) that this whole handshake exists to prevent.
371
+ *
372
+ * Silent when fresh, and silent when freshness is UNKNOWN (`stale: null`) —
373
+ * an unknown must not be dressed up as either a warning or a clean bill of
374
+ * health; the provenance footer still shows whatever id there is.
375
+ * @param {{id: string|null, mode: string, stale: boolean|null, diskId: string|null}|null|undefined} build
376
+ */
377
+ export function staleConsoleBannerHtml(build) {
378
+ if (!build || build.stale !== true) return "";
379
+ const from = build.id ? `<code>${esc(String(build.id).slice(0, 8))}</code>` : "an unknown build";
380
+ const to = build.diskId ? `<code>${esc(String(build.diskId).slice(0, 8))}</code>` : "a newer build";
381
+ const how =
382
+ build.mode === "bundle"
383
+ ? "the bundle was rebuilt after this console started"
384
+ : "the console's sources changed after it started";
385
+ return `<div class="banner banner-stale-build">This console is running ${from}; the code on disk is now ${to} &mdash; ${how}. Everything below was drawn by the older code. Restart the console to pick it up: <code>node inspector/mcp/bin/console.mjs &lt;projectDir&gt;</code></div>`;
386
+ }
387
+
388
+ /** "HH:MM" in server-local time, from an ISO timestamp — null-safe. */
389
+ function clockTime(iso) {
390
+ if (!iso) return null;
391
+ try {
392
+ return new Date(iso).toTimeString().slice(0, 5);
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+
398
+ /**
399
+ * The renderer's OWN failure banner (FI-9 Change B) — distinct from `p.error`
400
+ * (which also covers compile/reload messages that already have their own
401
+ * presentation, right above each screen). This fires only when the render
402
+ * PIPELINE itself is dead (the last Gradle/daemon render call threw outright):
403
+ * "the eyes are stale", not "your edit didn't build". Amber (--reopen), the
404
+ * same "stale, not necessarily broken" vocabulary the rail-foot receipt line
405
+ * already uses for a stale-but-not-failed verify receipt — kept visually
406
+ * distinct from the red `.banner` a compile/reload failure shows.
407
+ * @param {{lastOutcome: string, lastSuccessAt: string|null, lastAttemptAt: string|null, consecutiveFailures: number, lastError?: string|null}|null} r
408
+ */
409
+ export function rendererDownBannerHtml(r) {
410
+ if (!r || r.lastOutcome !== "failed") return "";
411
+ const since = clockTime(r.lastSuccessAt);
412
+ const headline = since
413
+ ? `Renderer down since ${since} &mdash; screens below are stale.`
414
+ : `Renderer down &mdash; no render has completed yet, so there are no screens to show.`;
415
+ const streak = r.consecutiveFailures > 1 ? ` (${r.consecutiveFailures} renders in a row have failed.)` : "";
416
+ const errTail = r.lastError ? ` Last error: ${esc(r.lastError)}` : "";
417
+ return `<div class="banner banner-renderer">${headline}${streak}${errTail}</div>`;
418
+ }
419
+
420
+ /** "4 minutes" / "2 hours" / "3 days" — how old, in words a reader can act on. */
421
+ function ageWords(ms) {
422
+ if (ms === null || ms === undefined) return null;
423
+ const mins = Math.round(ms / 60000);
424
+ if (mins < 1) return "moments";
425
+ if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"}`;
426
+ const hrs = Math.round(mins / 60);
427
+ if (hrs < 24) return `${hrs} hour${hrs === 1 ? "" : "s"}`;
428
+ const days = Math.round(hrs / 24);
429
+ return `${days} day${days === 1 ? "" : "s"}`;
430
+ }
431
+
432
+ /**
433
+ * The freshness banner — the console's central honesty guarantee: pixels are NEVER shown
434
+ * without saying whether they are current, and when they are not, what is being done.
435
+ *
436
+ * Deliberately not an error dump. A concurrent build is normal and reads as a calm
437
+ * "refreshing"; only a render that stays stuck earns alarm styling, and even then the
438
+ * message says the screens are still being retried, because they are.
439
+ *
440
+ * @param {{state: string, phase: string, detail: string|null, ageMs: number|null}|null} f
441
+ */
442
+ export function freshnessBannerHtml(f) {
443
+ if (!f) return "";
444
+ if (f.state === "never") {
445
+ return `<div class="banner banner-renderer">No render yet &mdash; there are no screens to show until the first one completes.</div>`;
446
+ }
447
+ if (f.state === "fresh") return ""; // current: the pixels speak for themselves
448
+ const age = ageWords(f.ageMs);
449
+ const shown = age ? `Showing the last good render from ${age} ago.` : "Showing the last good render.";
450
+ const because = f.detail ? ` ${esc(f.detail)}.` : "";
451
+ if (f.phase === "rendering") {
452
+ return `<div class="banner banner-stale">Refreshing now &mdash; ${shown.toLowerCase()}</div>`;
453
+ }
454
+ if (f.phase === "waiting-build" || f.phase === "waiting-lane") {
455
+ return `<div class="banner banner-stale">Waiting to refresh:${because} ${shown} This updates itself.</div>`;
456
+ }
457
+ if (f.phase === "stuck") {
458
+ return `<div class="banner banner-renderer">Cannot refresh right now &mdash; still retrying.${because} ${shown}</div>`;
459
+ }
460
+ if (f.phase === "unrefreshed") {
461
+ // Stale with nothing pending: a save did not reach the renderer. Say that plainly
462
+ // rather than promising a refresh that is not coming.
463
+ return `<div class="banner banner-renderer">Out of date and NOT refreshing &mdash; a change has not reached the renderer.${because} ${shown}</div>`;
464
+ }
465
+ return `<div class="banner banner-stale">Out of date &mdash; ${shown} A refresh is queued.</div>`;
466
+ }
467
+
468
+ /**
469
+ * The full page. Everything visible is composed here; the caller supplies
470
+ * only data (rail items, section bodies) and behavior (`bodyScript`).
471
+ * @param {object} p
472
+ * @param {string} p.appName
473
+ * @param {Array} p.railItems railItemHtml inputs, in genesis order
474
+ * @param {string} p.railFootHtml the verify-receipt line
475
+ * @param {Array} p.sections sectionHtml inputs
476
+ * @param {string|null} [p.error] last render failure (banner above the pages)
477
+ * @param {object|null} [p.rendererDown] renderer health when dead (see rendererDownBannerHtml)
478
+ * @param {object|null} [p.freshness] derived freshness (see freshnessBannerHtml) — the
479
+ * provenance of the pixels below; absent only in callers that render no screens
480
+ * @param {string} [p.extraCss] caller-computed rules (viewport-derived sizes)
481
+ * @param {string} p.bodyScript the behavior <script> body (unowned by the shell)
482
+ * @param {{treeHash?: string|null, version?: number, build?: object}} [p.provenance]
483
+ * @param {string} [p.govStripHtml] the governance strip (governanceStripHtml) —
484
+ * rail-resident so it is visible on EVERY tab; "" renders nothing
485
+ * @param {object|null} [p.build] the console's own build handshake
486
+ * (buildStatus) — drives the stale banner; absent renders nothing
487
+ */
488
+ export function renderShellPage(p) {
489
+ const prov = provenanceHtml(p.provenance || {});
490
+ return `<!doctype html>
491
+ <meta charset="utf-8">
492
+ <meta name="viewport" content="width=device-width, initial-scale=1">
493
+ <title>${esc(p.appName)} &middot; studio</title>
494
+ <style>
495
+ ${SHELL_CSS}
496
+ ${p.extraCss || ""}
497
+ </style>
498
+ <aside id="rail">
499
+ <div class="rail-head">
500
+ <p class="rail-app">${esc(p.appName)}</p>
501
+ <p class="rail-sub">studio console</p>
502
+ <span id="pill">live</span>
503
+ </div>
504
+ ${p.govStripHtml || ""}
505
+ <nav class="rail-nav">
506
+ ${p.railItems.map(railItemHtml).join("\n")}
507
+ </nav>
508
+ <div class="rail-foot">${p.railFootHtml}</div>
509
+ </aside>
510
+ <main>
511
+ ${staleConsoleBannerHtml(p.build)}
512
+ ${freshnessBannerHtml(p.freshness)}
513
+ ${rendererDownBannerHtml(p.rendererDown)}
514
+ ${p.error ? `<div class="banner">last render FAILED &mdash; showing previous state\n${esc(p.error)}</div>` : ""}
515
+ ${p.sections.map((s) => sectionHtml(s, prov)).join("\n")}
516
+ </main>
517
+ <script>
518
+ const CMP_CONSOLE_BUILD = ${JSON.stringify(p.build && p.build.id ? String(p.build.id) : null)};
519
+ ${p.bodyScript}
520
+ </script>
521
+ `;
522
+ }
523
+
524
+ // --- the design system (one stylesheet, all sections) -------------------------
525
+ //
526
+ // Color roles: --paper/--surface/--ink/--ink-2/--muted/--line are the
527
+ // neutrals; --accent is the ONE accent (navigation, actions, attention that
528
+ // is not a verdict); --signed/--drift/--reopen (+ -bg) are the reserved
529
+ // semantic trio. Type ramp: --fs-title 18 / --fs-head 14 / --fs-body 13 /
530
+ // --fs-meta 11.5 (+ the uppercase .lbl micro-label). Spacing: 8px steps used
531
+ // literally (8/16/24/32/40). Prose sits in a readable measure via
532
+ // .page-body's max-width; .full-bleed pages (the screens gallery) opt out.
533
+
534
+ export const SHELL_CSS = `
535
+ :root {
536
+ --paper: #FFFFFF; --surface: #F5F6F8; --ink: #1D2126; --ink-2: #4A5361;
537
+ --muted: #8A919C; --line: #E4E7EB;
538
+ --accent: #3557C0; --accent-ink: #FFFFFF; --accent-bg: #EDF1FB;
539
+ --signed: #1E7A44; --signed-bg: #E8F4ED;
540
+ --drift: #C03A2E; --drift-bg: #FBEDEB;
541
+ --reopen: #96690A; --reopen-bg: #FAF2DF;
542
+ --fs-title: 18px; --fs-head: 14px; --fs-body: 13px; --fs-meta: 11.5px;
543
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
544
+ color-scheme: light dark;
545
+ }
546
+ @media (prefers-color-scheme: dark) {
547
+ :root {
548
+ --paper: #17191D; --surface: #1F2329; --ink: #E8EAEE; --ink-2: #B6BDC8;
549
+ --muted: #7E8794; --line: #2B3038;
550
+ --accent: #8AA3F0; --accent-ink: #10131A; --accent-bg: #232D45;
551
+ --signed: #5CB985; --signed-bg: #1B2E23;
552
+ --drift: #E4766A; --drift-bg: #392019;
553
+ --reopen: #D9A845; --reopen-bg: #33290F;
554
+ }
555
+ }
556
+ * { box-sizing: border-box; }
557
+ body { margin: 0; display: flex; min-height: 100vh; background: var(--paper); color: var(--ink);
558
+ font-family: -apple-system, system-ui, "Segoe UI", sans-serif; font-size: var(--fs-body); line-height: 1.55; }
559
+ code { font-family: var(--mono); font-size: 0.92em; }
560
+
561
+ /* --- the rail (§2: ordering + coverage, ambient and permanent) --- */
562
+ #rail { width: 216px; flex: none; position: sticky; top: 0; height: 100vh; overflow-y: auto;
563
+ display: flex; flex-direction: column; gap: 24px; padding: 24px 12px 16px;
564
+ border-right: 1px solid var(--line); background: var(--paper); }
565
+ .rail-head { padding: 0 10px; }
566
+ .rail-app { margin: 0; font-size: var(--fs-head); font-weight: 650; letter-spacing: .01em; }
567
+ .rail-sub { margin: 2px 0 8px; font-size: var(--fs-meta); color: var(--muted); }
568
+ #pill { font-size: 10.5px; font-weight: 650; border-radius: 999px; padding: 2px 9px;
569
+ background: var(--signed-bg); color: var(--signed); }
570
+ #pill.rendering { background: var(--reopen-bg); color: var(--reopen); }
571
+ #pill.error { background: var(--drift-bg); color: var(--drift); }
572
+ /* --- the governance strip (07-28 audit fix 5: status visible at ALL times,
573
+ on every tab — counts, the one next human act, recent history) --- */
574
+ #gov-strip { margin: 0 4px; padding: 10px; border: 1px solid var(--line); border-radius: 10px;
575
+ background: var(--surface); display: flex; flex-direction: column; gap: 8px; }
576
+ .gov-counts { margin: 0; font-size: var(--fs-meta); color: var(--ink-2); line-height: 1.6; }
577
+ .gov-n { white-space: nowrap; }
578
+ /* A count that names an artifact wraps — the name is the point, not the digit. */
579
+ .gov-jump { white-space: normal; text-align: left; appearance: none; border: none; background: none;
580
+ padding: 0; margin: 0; font: inherit; cursor: pointer; text-decoration: underline;
581
+ text-decoration-style: dotted; text-underline-offset: 2px; }
582
+ .gov-jump:hover { text-decoration-style: solid; }
583
+ .gov-signed { color: var(--signed); font-weight: 600; }
584
+ .gov-awaiting { color: var(--accent); font-weight: 650; }
585
+ .gov-redesign { color: var(--reopen); font-weight: 600; }
586
+ .gov-drift { color: var(--drift); font-weight: 650; }
587
+ .gov-next { appearance: none; border: none; border-radius: 8px; padding: 6px 9px; cursor: pointer;
588
+ font: inherit; font-size: var(--fs-meta); font-weight: 600; text-align: left;
589
+ background: var(--accent-bg); color: var(--accent); line-height: 1.45; }
590
+ .gov-next:hover { filter: brightness(0.97); }
591
+ .gov-more { font-weight: 400; color: var(--muted); }
592
+ .gov-clear { margin: 0; font-size: var(--fs-meta); color: var(--muted); }
593
+ .gov-history summary { font-size: var(--fs-meta); color: var(--muted); cursor: pointer; }
594
+ .gov-event { margin: 6px 0 0; font-size: var(--fs-meta); color: var(--ink-2); line-height: 1.4;
595
+ overflow-wrap: anywhere; }
596
+ .gov-event-glyph { color: var(--muted); }
597
+ .gov-event-reason { display: block; color: var(--muted); font-style: italic; }
598
+ /* --- the front door (§3.7) ---------------------------------------------
599
+ Deliberately quiet: this page's job is triage, so the only colour on it is
600
+ the reserved semantic trio carried in by each row's own glyph. The queue
601
+ is an ordered list because the order is the derivation's (deriveHumanQueue),
602
+ not a visual choice. */
603
+ .fd-h { font-size: var(--fs-head); margin: 24px 0 8px; display: flex; align-items: baseline; gap: 8px; }
604
+ .fd-h:first-of-type { margin-top: 8px; }
605
+ .fd-count { font-size: var(--fs-meta); font-weight: 600; color: var(--accent);
606
+ background: var(--accent-bg); border-radius: 999px; padding: 1px 8px; }
607
+ .fd-since { font-size: var(--fs-meta); font-weight: 400; color: var(--muted); }
608
+ .fd-queue { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
609
+ .fd-item { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; background: var(--paper); }
610
+ .fd-act { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
611
+ font-size: var(--fs-body); line-height: 1.5; }
612
+ .fd-label { flex: 1; min-width: 12ch; color: var(--ink); }
613
+ .fd-actions { flex: none; display: inline-flex; align-items: center; gap: 10px; }
614
+ /* The row's own signature control. Accent-filled: it IS the primary act of
615
+ this page. "read it first" stays beside it as the quiet secondary route. */
616
+ .fd-sign { appearance: none; border: none; border-radius: 8px; padding: 5px 12px; cursor: pointer;
617
+ font: inherit; font-size: var(--fs-meta); font-weight: 650;
618
+ background: var(--accent); color: var(--accent-ink); }
619
+ .fd-sign:hover { filter: brightness(1.08); }
620
+ .fd-sign:disabled { opacity: .55; cursor: default; }
621
+ .fd-go { flex: none; color: var(--accent); font-size: var(--fs-meta); font-weight: 600; }
622
+ .fd-evidence { margin: 6px 0 0; font-size: var(--fs-meta); color: var(--ink-2); }
623
+ .fd-evidence summary { cursor: pointer; color: var(--ink-2); }
624
+ .fd-evidence-absent { color: var(--muted); font-style: italic; }
625
+ .fd-files { list-style: none; margin: 6px 0 0; padding: 0 0 0 2px;
626
+ display: flex; flex-direction: column; gap: 3px; }
627
+ .fd-files code { overflow-wrap: anywhere; }
628
+ .fd-status { display: inline-block; width: 1.4em; color: var(--muted); font-weight: 600; }
629
+ .fd-more { color: var(--muted); }
630
+ .fd-clear { margin: 0; font-size: var(--fs-body); color: var(--ink-2); }
631
+ .fd-history { border-top: 1px solid var(--line); padding-top: 8px; }
632
+ /* In flight (walk-status): the six-stage tracker. States: done=signed colour,
633
+ cur=accent + weight, todo=muted, skip=faint with its honesty note in title. */
634
+ .wk-card { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; margin: 0 0 8px; background: var(--paper); }
635
+ .wk-name { margin: 0 0 6px; font-weight: 600; display: flex; align-items: baseline; gap: 10px; }
636
+ .wk-promises { font-size: var(--fs-meta); font-weight: 400; color: var(--ink-2); font-variant-numeric: tabular-nums; }
637
+ .wk-stages { margin: 0 0 6px; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: var(--fs-meta); }
638
+ .wk-stage { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); }
639
+ .wk-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--line); }
640
+ .wk-done { color: var(--ink-2); } .wk-done .wk-dot { background: var(--signed); }
641
+ .wk-cur { color: var(--accent); font-weight: 650; } .wk-cur .wk-dot { background: var(--accent); }
642
+ .wk-skip { opacity: .45; }
643
+ .wk-you { margin: 0; font-size: var(--fs-meta); color: var(--ink-2); }
644
+ .wk-turn { display: inline-block; padding: 1px 8px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); font-weight: 700; font-size: 10.5px; }
645
+ .wk-agent { color: var(--muted); font-weight: 600; }
646
+ .wk-arrival { margin: 4px 0 0; padding: 8px 12px; border-radius: 10px; background: var(--reopen-bg); color: var(--reopen); font-size: var(--fs-meta); }
647
+ /* Walk-legibility L3/L4/L5 additions: the stage gloss, the promise list
648
+ with per-promise kept state, Prove's measured cost, and the arrival
649
+ now-or-after buttons. The list scrolls rather than truncates — a
650
+ collapsed tally is exactly what the rich card replaces. */
651
+ .wk-gloss { font-size: var(--fs-meta); font-weight: 400; color: var(--muted); }
652
+ .wk-note { font-size: 10.5px; color: var(--muted); font-weight: 400; }
653
+ .wk-plist { list-style: none; margin: 0 0 8px; padding: 0 0 0 2px; max-height: 180px; overflow-y: auto;
654
+ display: flex; flex-direction: column; gap: 2px; font-size: var(--fs-meta); }
655
+ .wk-plist code { font-size: 10.5px; }
656
+ .wk-p-glyph { display: inline-block; width: 1.2em; text-align: center; }
657
+ .wk-p-kept { color: var(--muted); } .wk-p-kept .wk-p-glyph { color: var(--signed); }
658
+ .wk-p-cur { color: var(--ink-2); font-weight: 600; } .wk-p-cur .wk-p-glyph { color: var(--accent); }
659
+ .wk-p-todo { color: var(--muted); }
660
+ .wk-stops { color: var(--muted); }
661
+ .wk-lane { margin: 2px 0 0; font-size: var(--fs-meta); color: var(--muted); font-variant-numeric: tabular-nums; }
662
+ .wk-arrival-btn { appearance: none; margin-left: 6px; padding: 2px 10px; border: 1px solid var(--reopen);
663
+ border-radius: 999px; background: none; color: var(--reopen); cursor: pointer;
664
+ font: inherit; font-size: 11px; font-weight: 600; }
665
+ .wk-arrival-btn:hover { background: var(--reopen); color: var(--paper); }
666
+ .wk-arrival-btn[disabled] { opacity: .6; cursor: default; }
667
+ /* The live chain (studio-drive-mode): request · step chain · freshness.
668
+ The one place the studio renders DECLARED state — the .ch-age label says
669
+ so, always. Busy (tier 3, derived) is the only accent that pulses. */
670
+ .ch-strip { border: 1px solid var(--accent); border-radius: 12px; padding: 12px 14px; margin: 0 0 16px;
671
+ background: var(--accent-bg); }
672
+ .ch-request { margin: 0 0 8px; font-size: var(--fs-head); font-weight: 600; color: var(--ink); }
673
+ .ch-request .lbl { margin-right: 8px; }
674
+ .ch-steps { margin: 0 0 6px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px;
675
+ font-size: var(--fs-meta); }
676
+ .ch-step { display: inline-flex; align-items: baseline; gap: 4px; }
677
+ .ch-glyph { font-size: 10.5px; }
678
+ .ch-done { color: var(--muted); } .ch-done .ch-glyph { color: var(--signed); }
679
+ .ch-cur { color: var(--accent); font-weight: 700; }
680
+ .ch-todo { color: var(--muted); }
681
+ .ch-arrow { color: var(--line); margin: 0 2px; }
682
+ .ch-busy { color: var(--accent); font-weight: 650; animation: ch-pulse 1.6s ease-in-out infinite; }
683
+ @keyframes ch-pulse { 50% { opacity: .45; } }
684
+ .ch-meta { margin: 0; font-size: var(--fs-meta); color: var(--ink-2); }
685
+ .ch-age { color: var(--muted); }
686
+ .ch-time { color: var(--muted); font-weight: 400; font-size: 10.5px; }
687
+ /* Provenance chips (drive-narration N3): observed/recorded = the machine's
688
+ word, declared = the agent's — the same fact the .ch-age label carried in
689
+ prose, now legible at a glance. */
690
+ .ch-prov { display: inline-block; padding: 0 5px; border-radius: 7px; font-size: 9.5px;
691
+ font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
692
+ vertical-align: 1px; cursor: help; }
693
+ .ch-prov-obs { color: var(--signed); border: 1px solid var(--signed); }
694
+ .ch-prov-dec { color: var(--muted); border: 1px solid var(--line); }
695
+ /* Recent requests (drive-narration N5): the closed-chain trail, one collapsed
696
+ click in — request → steps → outcome, newest first. */
697
+ .ch-hist { margin: 8px 0 0; }
698
+ .ch-hist > summary { cursor: pointer; color: var(--muted); font-size: var(--fs-meta);
699
+ font-weight: 600; padding: 2px 0; }
700
+ .ch-hist > summary:hover { color: var(--ink-2); }
701
+ .ch-hist-row { margin: 4px 0 0 12px; font-size: var(--fs-meta); color: var(--ink-2); }
702
+ .ch-hist-outcome { font-weight: 650; }
703
+ .ch-hist-outcome.ok { color: var(--signed); }
704
+ .ch-hist-outcome.bad { color: var(--reopen); }
705
+ /* Page anatomy (studio-drive-mode): the collapsed tails. .fd-fold is
706
+ Drive's own digest/history; .mirror-details wraps a mirror section's
707
+ complete body — verdict stays in the page head, the corpus one click in. */
708
+ .fd-fold { margin: 16px 0 0; }
709
+ .fd-fold > summary, .mirror-details > summary { cursor: pointer; color: var(--muted);
710
+ font-size: var(--fs-body); font-weight: 600; padding: 6px 0; }
711
+ .fd-fold > summary:hover, .mirror-details > summary:hover { color: var(--ink-2); }
712
+ .mirror-details { margin: 0; }
713
+ /* The digest keeps its own renderer and its own headings; inside the front
714
+ door it is a block, not a page, so its h3s step down a level. Nothing here
715
+ hides any of its content — the one duplicated line (the window) was deleted
716
+ at the source instead. */
717
+ .fd-digest h3 { font-size: var(--fs-body); margin: 16px 0 6px; color: var(--ink-2); }
718
+ .digest-files summary { cursor: pointer; color: var(--muted); font-size: var(--fs-meta); }
719
+ .digest-filelist { list-style: none; margin: 4px 0 0; padding: 0 0 0 2px;
720
+ display: flex; flex-direction: column; gap: 3px; font-size: var(--fs-meta); }
721
+ .digest-filelist code { overflow-wrap: anywhere; }
722
+ .rail-nav { display: flex; flex-direction: column; gap: 1px; }
723
+ .tab-btn { appearance: none; display: flex; align-items: center; gap: 9px; width: 100%;
724
+ padding: 7px 10px; border: none; border-radius: 8px; background: none; cursor: pointer;
725
+ font: inherit; font-size: var(--fs-body); color: var(--ink-2); text-align: left; }
726
+ .tab-btn:hover { background: var(--surface); }
727
+ .tab-btn.active { background: var(--accent-bg); color: var(--accent); font-weight: 600; }
728
+ .rail-label { flex: 1; }
729
+ .glyph { flex: none; width: 1.1em; text-align: center; font-size: 11px; line-height: 1; }
730
+ .glyph-signed { color: var(--signed); }
731
+ /* Waiting-on-you states are ACCENT, not grey — the rail-truth rule: grey is
732
+ reserved for "truly nothing pending" (glyph-none); anything that needs the
733
+ human's signature (unsigned ○) or acceptance (attn ●) must read as colour. */
734
+ .glyph-unsigned { color: var(--accent); }
735
+ .glyph-attn { color: var(--accent); }
736
+ .glyph-reopen { color: var(--reopen); }
737
+ .glyph-drift { color: var(--drift); }
738
+ .glyph-none { color: var(--line); }
739
+ .rail-foot { margin-top: auto; padding: 12px 10px 0; border-top: 1px solid var(--line);
740
+ font-size: var(--fs-meta); color: var(--muted); line-height: 1.5; }
741
+ /* The rail foot doubles as the deep link to Evidence — same .tab-btn/data-tab
742
+ wiring as the nav items, styled as the quiet meta line it always was. */
743
+ .rail-foot .tab-btn { padding: 0; border-radius: 6px; font-size: var(--fs-meta);
744
+ color: var(--muted); gap: 6px; }
745
+ .rail-foot .tab-btn:hover { background: none; color: var(--ink-2); text-decoration: underline; }
746
+ .rail-foot .tab-btn.active { background: none; color: var(--accent); font-weight: 600; }
747
+ .tab-badge { display: inline-block; min-width: 16px; padding: 1px 6px; border-radius: 999px;
748
+ background: var(--drift); color: #fff; font-size: 10px; font-weight: 700; text-align: center; }
749
+ .tab-badge[hidden] { display: none !important; }
750
+
751
+ /* --- pages (§2 grammar: head → body → provenance foot) --- */
752
+ main { flex: 1; min-width: 0; }
753
+ .banner { margin: 24px 40px 0; padding: 10px 14px; border-radius: 10px; background: var(--drift-bg);
754
+ color: var(--drift); font-size: var(--fs-body); white-space: pre-wrap; }
755
+ /* Renderer-down (FI-9 Change B): amber/--reopen, not red/--drift — "the eyes
756
+ are stale", the same "stale, not broken" vocabulary the rail-foot receipt
757
+ line uses, kept visually distinct from a compile/reload .banner. */
758
+ .banner-renderer { background: var(--reopen-bg); color: var(--reopen); }
759
+ /* Stale-but-working-on-it: informational, NOT alarm. A concurrent build is normal;
760
+ only a render that stays stuck earns the renderer banner's colour. */
761
+ .banner-stale { background: var(--surface-2, rgba(255,255,255,.05)); color: var(--muted); }
762
+ /* A stale console invalidates the whole page below it, so it reads as DRIFT
763
+ (the reserved red), never as the quiet grey a queued refresh gets. */
764
+ .banner-stale-build { background: var(--drift-bg); color: var(--drift); font-weight: 600; }
765
+ .banner-stale-build code { font-weight: 500; }
766
+ .tab-panel { display: none; padding: 32px 40px 48px; }
767
+ .tab-panel.active { display: block; }
768
+ .page-head { margin: 0 0 24px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
769
+ .page-head h2 { margin: 0; font-size: var(--fs-title); font-weight: 650; letter-spacing: .01em; }
770
+ .page-status { margin: 6px 0 0; font-size: var(--fs-meta); color: var(--muted);
771
+ display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
772
+ .page-status .glyph { font-size: 10px; }
773
+ .status-drift { color: var(--drift); font-weight: 600; }
774
+ .status-reopen { color: var(--reopen); font-weight: 600; }
775
+ .page-body { max-width: 860px; }
776
+ .full-bleed .page-body { max-width: none; }
777
+ .page-foot { margin-top: 40px; padding-top: 12px; border-top: 1px solid var(--line);
778
+ font-size: var(--fs-meta); color: var(--muted); }
779
+
780
+ /* --- shared document vocabulary (labels, tables, chips, empty states) --- */
781
+ h3 { font-size: var(--fs-head); font-weight: 650; margin: 24px 0 8px; }
782
+ .page-body > h3:first-child { margin-top: 0; }
783
+ .lbl { font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); margin: 0 0 4px; }
784
+ .meta { color: var(--muted); font-size: var(--fs-meta); margin: 0 0 8px; }
785
+ .empty { padding: 16px; border: 1px dashed var(--line); border-radius: 10px; color: var(--ink-2);
786
+ font-size: var(--fs-body); max-width: 66ch; }
787
+ .empty p { margin: 0 0 6px; } .empty p:last-child { margin: 0; }
788
+ .empty-inline { color: var(--muted); font-size: var(--fs-meta); }
789
+ .badge { font-size: 10.5px; font-weight: 650; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
790
+ .badge-approved { background: var(--signed-bg); color: var(--signed); }
791
+ .badge-unreviewed { background: var(--surface); color: var(--ink-2); }
792
+ .badge-changed { background: var(--drift-bg); color: var(--drift); }
793
+ .badge-reopened { background: var(--reopen-bg); color: var(--reopen); }
794
+ .badge-unshaped { box-shadow: inset 0 0 0 1px var(--reopen); }
795
+ .badge-open { background: var(--accent-bg); color: var(--accent); }
796
+ .badge-resolved { background: var(--signed-bg); color: var(--signed); }
797
+ .evidence-rung { background: var(--accent-bg); color: var(--accent); }
798
+
799
+ /* --- screens (§3.4: the screen × state matrix) --- */
800
+ .screens-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
801
+ #filter { font: inherit; font-size: var(--fs-body); padding: 5px 12px; width: 240px;
802
+ border: 1px solid var(--line); border-radius: 999px; background: var(--paper); color: inherit; }
803
+ .matrix { display: flex; flex-direction: column; }
804
+ .matrix-head { display: flex; gap: 16px; padding-bottom: 8px; }
805
+ .matrix-gutter { flex: 0 0 184px; }
806
+ .matrix-col { flex: none; font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
807
+ .matrix-row { border-top: 1px solid var(--line); padding: 16px 0; }
808
+ .matrix-line { display: flex; gap: 16px; align-items: flex-start; }
809
+ .matrix-rowhead { flex: 0 0 184px; min-width: 0; }
810
+ .matrix-rowhead h3 { margin: 0 0 2px; }
811
+ .matrix-rowhead h3 .flag { font-size: 10px; font-weight: 700; color: var(--accent); vertical-align: middle;
812
+ margin-left: 6px; letter-spacing: .05em; }
813
+ .matrix-cells { display: flex; gap: 16px; }
814
+ .matrix-cell { flex: none; }
815
+ .matrix-cell img { border: 1px solid var(--line); border-radius: 10px; display: block; }
816
+ .matrix-cell.changed img { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-bg); }
817
+ .matrix-cell .lbl { margin: 4px 0 0; }
818
+ .matrix-none { display: grid; place-items: center; min-height: 96px; border: 1px dashed var(--line);
819
+ border-radius: 10px; color: var(--muted); opacity: .6; font-size: 15px; }
820
+ .matrix-rowend { flex: 0 0 190px; }
821
+ .matrix-rowend .meta { margin: 0 0 4px; }
822
+ .matrix-note { margin-top: 16px; max-width: 75ch; }
823
+ .row-detail { margin-top: 8px; }
824
+ .row-detail summary { cursor: pointer; font-size: var(--fs-meta); color: var(--ink-2); width: max-content; }
825
+ .row-detail summary:hover { color: var(--accent); }
826
+ .row-detail-body { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; margin-top: 12px; }
827
+ .row-clauses { flex: 1 1 320px; min-width: 0; }
828
+ .meta .fail { color: var(--drift); font-weight: 600; }
829
+ .meta .pass { color: var(--signed); font-weight: 600; }
830
+ .meta .chg { color: var(--accent); font-weight: 600; }
831
+ .cmp img.prev { display: none; }
832
+ .cmp:hover img.prev { display: block; }
833
+ .cmp:hover img.cur { display: none; }
834
+ .wire { border: 1px dashed var(--line); border-radius: 10px; overflow: hidden; flex: none; }
835
+ .wire svg { height: auto; display: block; }
836
+
837
+ /* --- intent (§3.0: the product strategist's brief) --- */
838
+ .brief-section { margin-bottom: 28px; }
839
+ .brief-section h3 { margin: 0 0 8px; }
840
+ .brief-unfilled h3 { color: var(--ink-2); }
841
+ .brief-pending { padding: 24px; border: 1px dashed var(--line); border-radius: 12px; max-width: 66ch; }
842
+ .brief-pending p { margin: 0 0 8px; color: var(--ink-2); } .brief-pending p:last-child { margin: 0; }
843
+ .brief-pending-state { font-size: var(--fs-head); font-weight: 650; color: var(--ink) !important; }
844
+ .brief-pending-inline { margin: 0 0 6px; color: var(--muted); font-style: italic; }
845
+ .brief-guidance { margin: 0; font-size: var(--fs-meta); color: var(--muted); max-width: 66ch; }
846
+
847
+ /* --- design language (§3.1: the designer's handoff spec) --- */
848
+ .tok-table, .approvals-table, .comments-table { width: 100%; border-collapse: collapse; font-size: var(--fs-body); margin-top: 8px; }
849
+ .tok-table td, .tok-table th, .approvals-table td, .approvals-table th, .comments-table td, .comments-table th {
850
+ padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
851
+ .tok-table th, .approvals-table th, .comments-table th { color: var(--muted); font-weight: 600; font-size: var(--fs-meta); }
852
+ .tok-table td { vertical-align: middle; }
853
+ .tok-swatch-cell { width: 44px; }
854
+ .tok-swatch { display: block; width: 32px; height: 24px; border-radius: 6px; border: 1px solid var(--line); }
855
+ .tok-usage { font-size: var(--fs-meta); color: var(--ink-2); white-space: nowrap; }
856
+ .contrast-sample { display: inline-grid; place-items: center; width: 44px; height: 26px; border-radius: 6px;
857
+ border: 1px solid var(--line); font-size: 12px; font-weight: 650; margin-right: 10px;
858
+ vertical-align: middle; }
859
+ .contrast-ratio { font-family: var(--mono); font-size: var(--fs-meta); white-space: nowrap; }
860
+ .wcag-pass { color: var(--signed); font-weight: 600; font-size: var(--fs-meta); }
861
+ .wcag-fail { color: var(--drift); font-weight: 600; font-size: var(--fs-meta); }
862
+ .scale-list { margin-top: 8px; }
863
+ .scale-row { display: flex; align-items: center; gap: 14px; padding: 7px 0; border-bottom: 1px solid var(--line); }
864
+ .scale-name { flex: 0 0 176px; font-size: var(--fs-meta); }
865
+ .scale-bar { height: 12px; border-radius: 3px; background: var(--accent); flex: none; max-width: 55%; }
866
+ .scale-value { font-size: var(--fs-meta); font-family: var(--mono); color: var(--muted); }
867
+ .candidates-strip { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 8px; }
868
+ .candidate-card { flex: 1 1 260px; border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; background: var(--paper); }
869
+ .candidate-card h4 { margin: 0 0 8px; font-size: var(--fs-body); }
870
+ .candidate-shots { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
871
+ .candidate-shot { width: 90px; }
872
+ .candidate-shot img { width: 100%; border: 1px solid var(--line); border-radius: 8px; display: block; }
873
+
874
+ /* --- buttons (actions use the accent; destructive-direction uses amber) --- */
875
+ .approve-btn, .pick-btn { font: inherit; font-size: var(--fs-meta); font-weight: 600; padding: 6px 12px;
876
+ border-radius: 8px; border: 1px solid var(--accent); background: var(--accent); color: var(--accent-ink); cursor: pointer; }
877
+ .approve-btn:disabled, .pick-btn:disabled { background: var(--surface); border-color: var(--line); color: var(--muted); cursor: not-allowed; }
878
+ .reopen-btn { font: inherit; font-size: var(--fs-meta); font-weight: 600; padding: 6px 12px; border-radius: 8px;
879
+ border: 1px solid var(--reopen); background: var(--paper); color: var(--reopen); cursor: pointer; }
880
+
881
+ /* --- approvals detail --- */
882
+ .artifact-id, .approved-at { font-size: var(--fs-meta); color: var(--muted); }
883
+ .approved-at { margin-top: 2px; }
884
+ .unresolvable-note, .missing-note { font-size: var(--fs-meta); color: var(--reopen); margin: 4px 0 0; }
885
+ .reopen-note { font-size: var(--fs-meta); color: var(--reopen); margin: 4px 0 0; overflow-wrap: anywhere; }
886
+ .order-num { color: var(--muted); }
887
+ .artifact-banner { font-size: var(--fs-meta); margin-top: 4px; padding: 4px 8px; border-radius: 8px; max-width: 340px; }
888
+ .banner-mode { font-weight: 700; text-transform: uppercase; letter-spacing: .04em; margin-right: 4px; font-size: 9.5px; }
889
+ .banner-genesis { background: var(--accent-bg); color: var(--accent); }
890
+ .banner-steward { background: var(--surface); color: var(--ink-2); }
891
+ .banner-unshaped { background: var(--reopen-bg); color: var(--reopen); }
892
+
893
+ /* --- specs / clauses --- */
894
+ .spec-file h3 { margin: 24px 0 8px; }
895
+ .spec-file:first-child h3 { margin-top: 0; }
896
+ .clause-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
897
+ .clause { display: flex; align-items: baseline; gap: 10px; font-size: var(--fs-body); padding: 8px 10px;
898
+ border: 1px solid var(--line); border-radius: 10px; background: var(--paper); }
899
+ .clause.withdrawn { opacity: .55; }
900
+ .clause-id { font-size: var(--fs-meta); color: var(--accent); font-weight: 700; flex: 0 0 auto; }
901
+ .clause-prose { flex: 1; }
902
+ .cov-badge, .receipt-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 999px; flex: 0 0 auto; white-space: nowrap; }
903
+ .cov-yes, .receipt-pass { background: var(--signed-bg); color: var(--signed); }
904
+ .cov-no, .receipt-fail { background: var(--drift-bg); color: var(--drift); }
905
+ .cov-na, .receipt-none { background: var(--surface); color: var(--muted); }
906
+ .receipt-stale { background: var(--reopen-bg); color: var(--reopen); }
907
+ .receipt-age { font-size: 10px; color: var(--muted); white-space: nowrap; }
908
+
909
+ /* --- architecture document --- */
910
+ .arch-section { margin-bottom: 32px; }
911
+ .arch-section h3 { margin: 0 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--line); }
912
+ .arch-section h4 { margin: 16px 0 8px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
913
+ .doc-table { width: 100%; border-collapse: collapse; font-size: var(--fs-meta); margin: 4px 0 12px; }
914
+ .doc-table th, .doc-table td { padding: 7px 9px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
915
+ .doc-table th { color: var(--muted); font-weight: 600; }
916
+ /* Type ramp: the specimen column is set in the rung's OWN size/weight/tracking,
917
+ so the row heights are deliberately uneven — that unevenness is the ramp. */
918
+ .type-ramp td { vertical-align: middle; }
919
+ .type-ramp .ramp-name { width: 16ch; white-space: nowrap; }
920
+ .type-ramp .ramp-specimen { color: var(--ink); overflow: hidden; }
921
+ .type-ramp .ramp-numbers { width: 34ch; color: var(--muted); text-align: right; white-space: nowrap; }
922
+ .doc-prose { font-size: var(--fs-body); max-width: 75ch; }
923
+ .doc-prose p { margin: 0 0 10px; }
924
+ .doc-prose h4, .doc-prose h5 { margin: 14px 0 6px; font-size: var(--fs-meta); color: var(--ink); }
925
+ .doc-list { margin: 0 0 10px; padding-left: 20px; }
926
+ .doc-list li { margin-bottom: 4px; }
927
+ .doc-code, .component-sig { font-family: var(--mono); font-size: var(--fs-meta); line-height: 1.55; background: var(--surface);
928
+ border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; overflow-x: auto; white-space: pre; }
929
+ .doc-quote { margin: 0 0 10px; padding: 6px 10px; border-left: 3px solid var(--line); color: var(--ink-2); }
930
+ .dep-edges { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
931
+ .dep-edge { display: flex; align-items: center; gap: 8px; font-size: var(--fs-body); padding: 6px 10px;
932
+ border: 1px solid var(--line); border-radius: 8px; background: var(--paper); }
933
+ .dep-edge.dep-violation { border-color: var(--drift); background: var(--drift-bg); }
934
+ .dep-count { font-size: var(--fs-meta); color: var(--muted); }
935
+ .dep-violations { margin-top: 6px; }
936
+ .dep-violation-list { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
937
+ .dep-violation-item { font-size: var(--fs-meta); padding: 6px 10px; border: 1px solid var(--drift); border-radius: 8px;
938
+ background: var(--drift-bg); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
939
+ .dep-advisory { font-size: var(--fs-meta); color: var(--muted); margin: 8px 0 0; max-width: 75ch; }
940
+ .layer-map { display: flex; flex-wrap: wrap; gap: 14px; }
941
+ .layer-box { flex: 1 1 220px; border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; background: var(--paper); }
942
+ .layer-box.layer-empty { opacity: .55; border-style: dashed; }
943
+ .layer-box h4 { margin: 0 0 4px; font-size: var(--fs-body); font-weight: 650; text-transform: none; letter-spacing: 0;
944
+ color: var(--ink); display: flex; align-items: center; gap: 6px; }
945
+ .layer-desc { font-size: var(--fs-meta); color: var(--muted); margin: 0 0 8px; }
946
+ .layer-files, .feature-tree, .component-used-in { list-style: none; margin: 0; padding: 0;
947
+ font-size: var(--fs-meta); font-family: var(--mono); display: flex; flex-direction: column; gap: 3px;
948
+ max-height: 220px; overflow-y: auto; }
949
+ .layer-files li, .feature-tree li { display: flex; align-items: center; gap: 6px; }
950
+ .layer-others { margin-top: 14px; }
951
+
952
+ /* --- system context (§3.2: C4 level 1 as clean CSS boxes) --- */
953
+ .ctx-diagram { display: flex; flex-direction: column; align-items: center; margin: 12px 0 16px; }
954
+ .ctx-app { padding: 10px 22px; border: 1.5px solid var(--ink-2); border-radius: 10px;
955
+ background: var(--surface); font-weight: 650; font-size: var(--fs-body); }
956
+ .ctx-nodes { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px; margin-top: 24px; }
957
+ .ctx-node { position: relative; flex: 1 1 150px; max-width: 210px; border: 1px solid var(--line);
958
+ border-radius: 10px; padding: 10px 12px; background: var(--paper); }
959
+ .ctx-node::before { content: ""; position: absolute; top: -24px; left: 50%; width: 1px; height: 24px;
960
+ background: var(--line); }
961
+ .ctx-node h5 { margin: 0 0 4px; font-size: var(--fs-body); font-weight: 650; }
962
+ .ctx-node p { margin: 0; font-size: var(--fs-meta); color: var(--muted); }
963
+
964
+ /* --- specs RTM (§3.5: the QA lead's traceability matrix) --- */
965
+ .rtm-counts { font-size: var(--fs-meta); color: var(--muted); margin: 2px 0 8px; }
966
+ .rtm-table td { vertical-align: top; }
967
+ .rtm-tests { list-style: none; margin: 0; padding: 0; font-family: var(--mono); font-size: 10.5px;
968
+ display: flex; flex-direction: column; gap: 2px; white-space: nowrap; }
969
+ .rtm-gate { font-family: var(--mono); font-size: 10.5px; white-space: nowrap; }
970
+ .rtm-defect { color: var(--drift); font-weight: 600; font-size: var(--fs-meta); white-space: nowrap; }
971
+ .rtm-defect-list { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
972
+ .rtm-defect-item { font-size: var(--fs-meta); padding: 6px 10px; border: 1px solid var(--drift);
973
+ border-radius: 8px; background: var(--drift-bg); display: flex; align-items: center;
974
+ gap: 8px; flex-wrap: wrap; }
975
+
976
+ /* --- evidence (§3.6: the SDET's release-readiness report) --- */
977
+ .evidence-headline { border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; margin: 4px 0 16px; }
978
+ .evidence-headline.evidence-stale { border-color: var(--drift); }
979
+ .evidence-verdict { font-size: var(--fs-title); font-weight: 700; margin-right: 8px; }
980
+ .verdict-pass { color: var(--signed); }
981
+ .verdict-fail { color: var(--drift); }
982
+ .verdict-muted { color: var(--muted); }
983
+ .evidence-facts { list-style: none; display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 10px 0 0;
984
+ padding: 0; font-size: var(--fs-meta); color: var(--ink-2); }
985
+ .evidence-binding-stale { color: var(--drift); font-weight: 600; }
986
+ .step-table .step-reason { white-space: pre-wrap; font-size: var(--fs-meta); color: var(--ink-2); }
987
+ /* The tally leads the Detail cell — it is the one fact present on a green
988
+ row, where reason and note are both empty. */
989
+ .step-table .step-counts { display: block; font-size: var(--fs-meta); color: var(--ink-2);
990
+ font-variant-numeric: tabular-nums; }
991
+ .step-table .step-failed { color: var(--drift); }
992
+ .step-table .step-note { display: block; font-size: var(--fs-meta); color: var(--muted); font-style: italic; }
993
+ .step-verdict-pass { color: var(--signed); font-weight: 650; }
994
+ .step-verdict-fail { color: var(--drift); font-weight: 650; }
995
+ .step-verdict-skip { color: var(--muted); font-weight: 650; }
996
+ .step-verdict-error { color: var(--reopen); font-weight: 600; text-decoration: underline dotted; }
997
+ .evidence-timeline { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
998
+ .evidence-timeline li { display: flex; gap: 10px; align-items: baseline; font-size: var(--fs-body);
999
+ padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; }
1000
+
1001
+ /* --- components (§3.3: the platform engineer's library reference) --- */
1002
+ .component-list { display: flex; flex-direction: column; }
1003
+ .component-entry { padding: 24px 0 28px; border-bottom: 1px solid var(--line); }
1004
+ .component-entry:first-child { padding-top: 4px; }
1005
+ .component-entry:last-child { border-bottom: none; }
1006
+ .component-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin: 0 0 2px; }
1007
+ .component-head h3 { margin: 0; }
1008
+ .component-file { margin: 2px 0 12px; }
1009
+ .params-table { width: 100%; border-collapse: collapse; font-size: var(--fs-body); margin: 4px 0 12px; }
1010
+ .params-table th, .params-table td { padding: 7px 9px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
1011
+ .params-table th { color: var(--muted); font-weight: 600; font-size: var(--fs-meta); }
1012
+ .param-required { color: var(--muted); font-size: var(--fs-meta); }
1013
+ .param-note { font-size: var(--fs-meta); color: var(--ink-2); }
1014
+ .component-facts { list-style: none; margin: 0 0 10px; padding: 0; font-size: var(--fs-meta); display: flex; flex-direction: column; gap: 4px; }
1015
+ .component-facts li { padding-left: 14px; position: relative; }
1016
+ .component-facts li::before { content: "\\2022"; position: absolute; left: 0; color: var(--muted); }
1017
+ .component-kdoc { margin: 4px 0 10px; padding: 8px 10px; border-left: 3px solid var(--line); font-size: var(--fs-meta);
1018
+ color: var(--ink-2); white-space: pre-wrap; }
1019
+ .component-story { margin-bottom: 10px; }
1020
+ .component-story .chg { color: var(--accent); font-weight: 600; }
1021
+ .component-story img { width: 180px; max-width: 100%; border: 1px solid var(--line); border-radius: 6px; display: block; }
1022
+ .component-live-variants { margin-bottom: 10px; }
1023
+ .state-variant-block { margin-bottom: 8px; }
1024
+ .state-variant-thumbs { display: flex; flex-wrap: wrap; gap: 8px; }
1025
+ .state-variant-thumb { width: 90px; }
1026
+ .state-variant-thumb img { width: 100%; border: 1px solid var(--line); border-radius: 6px; display: block; }
1027
+ .violation-chip { margin-left: 4px; }
1028
+
1029
+ /* --- comments (ledger table + the margin-quiet affordance) --- */
1030
+ .comment-text-cell { max-width: 320px; white-space: pre-wrap; }
1031
+ .comment-resolution { margin-top: 4px; }
1032
+ .comment-resolution-note { font-size: var(--fs-meta); color: var(--ink-2); margin: 2px 0 0; }
1033
+ /* §2: comments are a QUIET affordance revealed on hover — no floating emoji
1034
+ over content. The control's emoji glyph is suppressed (font-size: 0) and
1035
+ replaced with a small outlined "+"; it stays invisible until its host
1036
+ element is hovered (or the button itself is keyboard-focused). */
1037
+ .comment-ctl { position: relative; display: inline-block; margin-left: 4px; vertical-align: middle; }
1038
+ .comment-btn { appearance: none; border: 1px solid var(--line); background: var(--paper); cursor: pointer;
1039
+ font-size: 0; width: 16px; height: 16px; line-height: 1; border-radius: 50%; padding: 0;
1040
+ opacity: 0; transition: opacity .12s; position: relative; }
1041
+ .comment-btn::before { content: "+"; font-size: 11px; color: var(--muted);
1042
+ position: absolute; inset: 0; display: grid; place-items: center; }
1043
+ *:hover > .comment-ctl .comment-btn, .comment-ctl:hover .comment-btn,
1044
+ .comment-btn:focus-visible { opacity: 1; }
1045
+ .comment-btn:hover::before { color: var(--accent); }
1046
+ .comment-btn:hover { border-color: var(--accent); }
1047
+ .comment-popover { position: absolute; z-index: 20; top: 100%; left: 0; margin-top: 4px; width: 230px;
1048
+ background: var(--paper); border: 1px solid var(--line); border-radius: 10px; padding: 10px;
1049
+ box-shadow: 0 4px 16px rgba(0,0,0,.14); display: flex; flex-direction: column; gap: 6px; }
1050
+ /* The popover and badge are toggled with the hidden ATTRIBUTE, but their author
1051
+ display rules (flex / inline-block) override the UA stylesheet's [hidden]
1052
+ { display: none } — without these guards every "hidden" popover stays painted,
1053
+ and its children (the textarea especially) overflow the 0x0 box and invisibly
1054
+ intercept clicks (elementFromPoint-verified in the VL-7 browser gate). */
1055
+ .comment-popover[hidden] { display: none !important; }
1056
+ .comment-popover textarea, .comment-popover input { font: inherit; font-size: var(--fs-meta); padding: 6px 8px;
1057
+ border: 1px solid var(--line); border-radius: 8px; resize: vertical; width: 100%; box-sizing: border-box;
1058
+ background: var(--paper); color: inherit; }
1059
+ .comment-popover-actions { display: flex; justify-content: flex-end; gap: 6px; }
1060
+ .comment-popover-actions button { font: inherit; font-size: var(--fs-meta); padding: 4px 10px; border-radius: 6px;
1061
+ border: 1px solid var(--line); background: var(--surface); color: inherit; cursor: pointer; }
1062
+ .comment-submit { border-color: var(--accent) !important; background: var(--accent) !important; color: var(--accent-ink); }
1063
+ .comment-error { color: var(--drift); font-size: var(--fs-meta); margin: 0; }
1064
+
1065
+ /* --- features (the post-genesis delivery board) --- */
1066
+ .feature-board { display: flex; flex-direction: column; gap: 14px; }
1067
+ .feature-card { border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; background: var(--surface); }
1068
+ .feature-card-closed { opacity: 0.65; }
1069
+ .feature-card-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
1070
+ .feature-card-head h3 { margin: 0; }
1071
+ .feature-phase { font-size: var(--fs-meta); font-weight: 600; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); }
1072
+ .phase-proposed { color: var(--muted); }
1073
+ .phase-approved { color: var(--accent); border-color: var(--accent); }
1074
+ .phase-proven { color: var(--reopen); border-color: var(--reopen); }
1075
+ .feature-done-yes { color: var(--ok, #3a8f5a); font-size: var(--fs-meta); font-weight: 600; }
1076
+ .feature-done { margin: 4px 0; font-size: var(--fs-meta); }
1077
+ .feature-next { margin: 6px 0 2px; font-size: var(--fs-meta); font-weight: 600; color: var(--accent); }
1078
+ .feature-decisions { border-left: 3px solid var(--accent); padding: 2px 0 2px 12px; margin: 8px 0; }
1079
+ .feature-decisions h4 { margin: 0 0 4px; font-size: var(--fs-meta); }
1080
+ .feature-brief-full { margin: 8px 0 2px; font-size: var(--fs-meta); }
1081
+ .feature-brief-full h4 { margin: 10px 0 4px; }
1082
+ .feature-brief-full .doc-prose, .feature-decisions .doc-prose { font-size: var(--fs-meta); }
1083
+ .phase-accepted { color: var(--ok, #3a8f5a); border-color: currentColor; }
1084
+ .phase-drift, .phase-reopened { color: var(--drift); border-color: var(--drift); }
1085
+ .feature-tally { color: var(--muted); font-size: var(--fs-meta); }
1086
+ .feature-doc-link { margin-left: auto; color: var(--muted); font-size: var(--fs-meta); text-decoration: none; }
1087
+ .feature-touches { font-size: var(--fs-meta); margin: 6px 0; }
1088
+ .feature-as-declared { color: var(--muted); font-style: italic; }
1089
+ /* Check details are file paths and regexes — long, unbreakable-looking
1090
+ strings. Without an explicit wrap they blow the table past the card and
1091
+ the right-hand end (the part that says WHY a check is unmet) is clipped
1092
+ off-screen, which is the one thing the row exists to tell you. */
1093
+ .feature-checks { margin-top: 6px; table-layout: auto; width: 100%; }
1094
+ .feature-checks td, .feature-checks th { vertical-align: top; }
1095
+ .feature-check-detail { color: var(--muted); overflow-wrap: anywhere; word-break: break-word; }
1096
+ .pending-inline { color: var(--muted); }
1097
+ .feature-actions { margin-top: 10px; display: flex; gap: 8px; align-items: center; }
1098
+ .feature-actions button { font: inherit; font-size: var(--fs-meta); padding: 4px 12px; border-radius: 6px;
1099
+ border: 1px solid var(--accent); background: var(--accent); color: var(--accent-ink); cursor: pointer; }
1100
+ /* The guided-flow prompt: every decision ends with "do you want to …?" */
1101
+ .next-prompt { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
1102
+ max-width: min(720px, calc(100vw - 48px)); z-index: 60;
1103
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
1104
+ border: 1px solid var(--accent); border-radius: 12px; padding: 12px 16px;
1105
+ background: var(--accent-bg); color: var(--ink); font-size: var(--fs-meta);
1106
+ box-shadow: 0 6px 24px rgba(0,0,0,.18); }
1107
+ .next-prompt-did { font-weight: 650; }
1108
+ .next-prompt button { font: inherit; font-size: var(--fs-meta); padding: 4px 12px; border-radius: 6px; cursor: pointer; }
1109
+ .next-prompt-primary { border: 1px solid var(--accent); background: var(--accent); color: var(--accent-ink); }
1110
+ .next-prompt-dismiss { border: 1px solid var(--line); background: var(--paper); color: var(--muted); }
1111
+
1112
+ /* Sign where you read: every governed section's own signature control. */
1113
+ .signature-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
1114
+ border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px;
1115
+ margin: 0 0 14px; background: var(--surface); font-size: var(--fs-meta); }
1116
+ .signature-line { color: var(--muted); }
1117
+ .signature-id { color: var(--muted); }
1118
+ .signature-actions { margin-left: auto; display: flex; gap: 8px; }
1119
+ .signature-actions button { font: inherit; font-size: var(--fs-meta); padding: 4px 12px; border-radius: 6px;
1120
+ border: 1px solid var(--line); background: var(--paper); color: var(--ink); cursor: pointer; }
1121
+ .signature-actions button:hover { border-color: var(--accent); color: var(--accent); }
1122
+ .spec-file .signature-bar { margin-top: 6px; }
1123
+
1124
+ /* The change surface: what changed vs. what is still exactly as signed —
1125
+ rendered at the top of the drifted artifact's own section AND inside the
1126
+ Approvals table (driftPanelHtml — one renderer, everywhere). */
1127
+ .drift-panel { border: 1px solid var(--drift); border-radius: 10px; padding: 12px 14px; margin-bottom: 14px;
1128
+ background: var(--drift-bg); font-size: var(--fs-meta); }
1129
+ .drift-panel .drift-head { margin: 0 0 6px; }
1130
+ .drift-summary { margin: 6px 0 4px; }
1131
+ .drift-files { list-style: none; margin: 0 0 6px; padding: 0; display: flex; flex-direction: column; gap: 2px; }
1132
+ .drift-files code, .drift-still-signed code { overflow-wrap: anywhere; }
1133
+ .drift-still-signed { margin: 4px 0; }
1134
+ .drift-still-signed ul { list-style: none; margin: 4px 0 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
1135
+ .drift-diff { margin: 6px 0 0; }
1136
+ .approvals-table .drift-panel { margin-bottom: 0; }
1137
+ .feature-undeclared { border: 1px solid var(--drift); border-radius: 10px; padding: 10px 12px; margin-bottom: 12px;
1138
+ font-size: var(--fs-meta); }
1139
+ `;