rcf-lite 0.28.4 → 0.29.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 (168) hide show
  1. package/CHANGELOG.md +107 -1
  2. package/bin/rcf.js +4 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/guidance/define-draft-shapes.md +89 -0
  5. package/guidance/define-intake.md +97 -0
  6. package/guidance/define-intent.md +107 -0
  7. package/guidance/discover-prototype.md +67 -0
  8. package/guidance/discovery-prototypes.md +69 -0
  9. package/guidance/harness-template.md +34 -0
  10. package/guidance/managed/agent-instructions-block.hash +1 -1
  11. package/guidance/managed/agent-instructions-block.md +34 -0
  12. package/guidance/manifest.json +9 -2
  13. package/package.json +1 -1
  14. package/rcf/adrs/adr-4124-define-freeze-verb.json +30 -0
  15. package/rcf/adrs/adr-4126-persona-tagged-blockers-two-levels.json +40 -0
  16. package/rcf/adrs/adr-4127-readiness-tab-order-and-register.json +40 -0
  17. package/rcf/adrs/adr-4129-questions-harness-driven-loop.json +25 -0
  18. package/rcf/adrs/adr-4130-intake-harness-split-tool-scanned.json +20 -0
  19. package/rcf/adrs/adr-4132-prototypes-in-discovery-as-evidence.json +20 -0
  20. package/rcf/adrs/adr-4133-dag-dependency-depth-layout.json +40 -0
  21. package/rcf/adrs/adr-4134-index-json-route.json +35 -0
  22. package/rcf/adrs/adr-4135-readiness-po-register-and-adapter.json +35 -0
  23. package/rcf/adrs/adr-4136-embedding-host-fixture.json +35 -0
  24. package/rcf/code-nodes/cn-725.json +18 -2
  25. package/rcf/code-nodes/cn-726.json +10 -2
  26. package/rcf/code-nodes/cn-727.json +13 -4
  27. package/rcf/code-nodes/cn-728.json +18 -2
  28. package/rcf/code-nodes/cn-729.json +10 -2
  29. package/rcf/code-nodes/cn-730.json +13 -3
  30. package/rcf/code-nodes/cn-731.json +20 -0
  31. package/rcf/code-nodes/cn-732.json +14 -0
  32. package/rcf/code-nodes/cn-733.json +14 -0
  33. package/rcf/code-nodes/cn-734.json +14 -0
  34. package/rcf/code-nodes/cn-735.json +20 -0
  35. package/rcf/code-nodes/cn-736.json +14 -0
  36. package/rcf/code-nodes/cn-737.json +14 -0
  37. package/rcf/code-nodes/cn-738.json +14 -0
  38. package/rcf/code-nodes/cn-739.json +18 -0
  39. package/rcf/code-nodes/cn-740.json +19 -0
  40. package/rcf/code-nodes/cn-741.json +16 -0
  41. package/rcf/code-nodes/cn-742.json +16 -0
  42. package/rcf/code-nodes/cn-743.json +15 -0
  43. package/rcf/code-nodes/cn-744.json +22 -0
  44. package/rcf/code-nodes/cn-745.json +16 -0
  45. package/rcf/code-nodes/cn-746.json +14 -0
  46. package/rcf/code-nodes/cn-747.json +18 -0
  47. package/rcf/code-nodes/cn-748.json +16 -0
  48. package/rcf/code-nodes/cn-749.json +16 -0
  49. package/rcf/code-nodes/cn-750.json +15 -0
  50. package/rcf/code-nodes/cn-751.json +18 -0
  51. package/rcf/code-nodes/cn-752.json +14 -0
  52. package/rcf/fbs/fbs-196.json +13 -5
  53. package/rcf/fbs/fbs-198.json +42 -0
  54. package/rcf/fbs/fbs-199.json +59 -0
  55. package/rcf/fbs/fbs-200.json +46 -0
  56. package/rcf/fbs/fbs-201.json +49 -0
  57. package/rcf/fbs/fbs-202.json +110 -0
  58. package/rcf/fbs/fbs-203.json +100 -0
  59. package/rcf/requirements/req-002.json +2 -2
  60. package/rcf/requirements/req-173.json +2 -2
  61. package/rcf/requirements/req-174.json +10 -4
  62. package/rcf/requirements/req-175.json +10 -4
  63. package/rcf/requirements/req-176.json +25 -0
  64. package/rcf/requirements/req-180.json +31 -0
  65. package/rcf/requirements/req-181.json +31 -0
  66. package/rcf/requirements/req-182.json +25 -0
  67. package/rcf/requirements/req-186.json +25 -0
  68. package/rcf/requirements/req-187.json +31 -0
  69. package/rcf/requirements/req-188.json +25 -0
  70. package/rcf/tacs/tac-4121-define-ledgers.json +15 -3
  71. package/rcf/tacs/tac-4122-define-gates.json +40 -4
  72. package/rcf/tacs/tac-4123-define-readiness.json +39 -3
  73. package/rcf/tacs/tac-4124-define-freeze.json +28 -0
  74. package/rcf/tacs/tac-4127-readiness-view.json +49 -0
  75. package/rcf/tacs/tac-4128-define-guidance.json +53 -0
  76. package/rcf/tacs/tac-4129-define-questions.json +45 -0
  77. package/rcf/tacs/tac-4130-define-intake-brief.json +35 -0
  78. package/rcf/tacs/tac-4131-build-dag-view.json +39 -0
  79. package/rcf/tacs/tac-4132-id-lookup.json +42 -0
  80. package/rcf/tacs/tac-4133-readiness-po-layer.json +37 -0
  81. package/rcf/tacs/tac-4134-wespa-host-fixture.json +54 -0
  82. package/rcf/test-suites/ts-016.json +3 -3
  83. package/rcf/test-suites/ts-215.json +148 -4
  84. package/rcf/test-suites/ts-216.json +19 -3
  85. package/rcf/test-suites/ts-218.json +78 -0
  86. package/rcf/test-suites/ts-219.json +70 -0
  87. package/rcf/test-suites/ts-220.json +78 -0
  88. package/rcf/test-suites/ts-221.json +78 -0
  89. package/rcf/test-suites/ts-222.json +100 -0
  90. package/rcf/test-suites/ts-223.json +94 -0
  91. package/rcf/test-suites/ts-224.json +70 -0
  92. package/rcf/test-suites/ts-225.json +78 -0
  93. package/rcf/test-suites/ts-226.json +158 -0
  94. package/rcf/test-suites/ts-227.json +38 -0
  95. package/rcf/test-suites/ts-228.json +86 -0
  96. package/rcf/test-suites/ts-229.json +83 -0
  97. package/rcf/user-stories/us-17302.json +50 -0
  98. package/rcf/user-stories/us-17401.json +14 -2
  99. package/rcf/user-stories/us-17402.json +55 -0
  100. package/rcf/user-stories/us-17403.json +60 -0
  101. package/rcf/user-stories/us-17502.json +60 -0
  102. package/rcf/user-stories/us-17503.json +65 -0
  103. package/rcf/user-stories/us-17601.json +58 -0
  104. package/rcf/user-stories/us-18001.json +55 -0
  105. package/rcf/user-stories/us-18002.json +35 -0
  106. package/rcf/user-stories/us-18101.json +35 -0
  107. package/rcf/user-stories/us-18202.json +40 -0
  108. package/rcf/user-stories/us-18601.json +55 -0
  109. package/rcf/user-stories/us-18602.json +50 -0
  110. package/rcf/user-stories/us-18603.json +45 -0
  111. package/rcf/user-stories/us-18701.json +50 -0
  112. package/rcf/user-stories/us-18702.json +50 -0
  113. package/rcf/user-stories/us-18801.json +45 -0
  114. package/rcf/user-stories/us-204.json +59 -0
  115. package/rcf/user-stories/us-205.json +64 -0
  116. package/rcf/user-stories/us-206.json +54 -0
  117. package/releases/releases.yaml +11 -1
  118. package/src/cli/freeze.js +730 -0
  119. package/src/cli/help.js +12 -0
  120. package/src/cli/ledger.js +228 -12
  121. package/src/cli/questions.js +288 -0
  122. package/src/cli/readiness.js +267 -49
  123. package/src/cli/view.js +29 -0
  124. package/src/define/freeze-record.js +30 -15
  125. package/src/define/ledgers.js +210 -11
  126. package/src/intake/index.js +1 -1
  127. package/src/intake/orchestrator.js +117 -0
  128. package/src/mcp/tools.js +354 -0
  129. package/src/query/delta.js +6 -0
  130. package/src/query/gates.js +314 -39
  131. package/src/query/index.js +16 -2
  132. package/src/query/questions.js +438 -0
  133. package/src/query/readiness.js +248 -7
  134. package/src/server/index.js +24 -2
  135. package/src/server/routes.js +68 -1
  136. package/src/setup/identity-seed.js +17 -0
  137. package/src/view/build-dag.js +563 -0
  138. package/src/view/components/badge.js +41 -0
  139. package/src/view/components/diff.js +72 -0
  140. package/src/view/components/doc-row.js +62 -0
  141. package/src/view/components/empty-state.js +35 -0
  142. package/src/view/components/entity-selector.js +137 -0
  143. package/src/view/components/filter-bar.js +97 -0
  144. package/src/view/components/findings-list.js +49 -0
  145. package/src/view/components/index.js +12 -0
  146. package/src/view/components/pill.js +46 -0
  147. package/src/view/components/sub-tab-strip.js +43 -0
  148. package/src/view/components/toast.js +25 -0
  149. package/src/view/components-fixture.js +265 -0
  150. package/src/view/doc-renderers/architecture.js +304 -0
  151. package/src/view/doc-renderers/build.js +160 -0
  152. package/src/view/doc-renderers/index.js +2 -0
  153. package/src/view/doc-renderers/prd.js +35 -6
  154. package/src/view/html-page.js +700 -602
  155. package/src/view/index.js +96 -3
  156. package/src/view/live-client.js +39 -6
  157. package/src/view/needs-work.js +79 -0
  158. package/src/view/page-init.js +2193 -0
  159. package/src/view/readiness/phrasebook.js +115 -0
  160. package/src/view/readiness/po-layer.js +288 -0
  161. package/src/view/readiness/question-adapter.js +329 -0
  162. package/src/view/readiness/stage-legend.js +71 -0
  163. package/src/view/readiness.js +505 -0
  164. package/src/view/style.css +2017 -382
  165. package/src/view/test-host/embed-client.js +121 -0
  166. package/src/view/test-host/fixture.js +183 -0
  167. package/src/view/test-host/lints.js +220 -0
  168. package/src/view/view-index.js +323 -0
@@ -4,30 +4,68 @@
4
4
  // drill-down under Requirements. Phase 3.6 dropped the top-of-overview
5
5
  // diagram - it was redundant with the PRD body's requirementIds list
6
6
  // and unwieldy past ~15 REQs. Overview tab now renders the PRD body
7
- // only. Client-side tabs + hash routing are wired by an inline script
8
- // at the end of `<body>`; if JS is unavailable, every tabpanel is
9
- // visible in DOM order (D12).
7
+ // only. If JS is unavailable, every tabpanel is visible in DOM order (D12).
10
8
  //
11
9
  // Phase 3.8 wraps the swappable tree content in a stable
12
10
  // `<div id="rcf-live-content">` (D13a) and always injects the live
13
- // client script `<script src="/live-client.js" defer>` before `</body>`.
14
- // The tab init routine is exposed as `window.rcfPage.init()` so the
15
- // live client can re-invoke it after every SSE innerHTML swap.
11
+ // client script before `</body>`. The tab init routine is exposed as
12
+ // `window.rcfPage.init()` so the live client can re-invoke it after
13
+ // every SSE innerHTML swap.
14
+ //
15
+ // Viewer UI refresh PR 1 (w-2026-10-02-dave-010, Dex / wespa relay
16
+ // f0384046 on 2026-10-02):
17
+ // - compact Stravica-brand shell: data-theme light|dark|auto on
18
+ // <html>, data-embed=1 suppresses the brand block and the footer
19
+ // and keeps the tab nav sticky, viewport-height scroll box owned
20
+ // by <main>, never scrolls horizontally.
21
+ // - asset and SSE references are RELATIVE (./live-client.js,
22
+ // ./page-init.js, ./style.css, ./mermaid.min.js, ./product-map/,
23
+ // ./events, ./scope.json) so the wespa reverse-proxy mount under
24
+ // /rcf-viewer/ keeps working without rewrites (issue #263).
25
+ // - the single former inline script lives at ./page-init.js;
26
+ // wespa's CSP pins inline scripts by hash and we no longer have
27
+ // any. page-init.js carries the theme/embed boot, origin-checked
28
+ // postMessage listener for { type:"rcf-view-theme", theme } and
29
+ // the Router (#tab=&sub=&entity=, bare #REQ-002, host query
30
+ // preserved on every write).
31
+
32
+ import { readFileSync } from 'node:fs';
33
+ import { dirname, resolve } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
16
35
 
17
36
  import {
18
37
  renderAdr,
19
- renderBuildSequence,
20
- renderFbs,
38
+ renderBuildPageHead,
39
+ renderBuildStats,
21
40
  renderPrd,
22
- renderReq,
41
+ renderSpecBody,
23
42
  renderTac,
24
- renderTad,
25
- renderTestSuite,
43
+ renderTadSections,
26
44
  renderUserStory,
45
+ shortenDocId,
27
46
  } from './doc-renderers/index.js';
28
47
  import { detailsWrap, escapeHtml } from './doc-renderers/helpers.js';
48
+ import { computeQueue } from '../build/queue.js';
29
49
  import { allRequirementSubdiagrams } from './mermaid-diagram.js';
30
50
  import { renderProductMapPanel } from './product-map.js';
51
+ import { renderReadinessPanel } from './readiness.js';
52
+ import { renderToastContainer } from './components/toast.js';
53
+ import { renderDocRow } from './components/doc-row.js';
54
+ import { renderFilterBar } from './components/filter-bar.js';
55
+ import { renderBadge } from './components/badge.js';
56
+ import { pill } from './components/pill.js';
57
+ import { renderEmptyState } from './components/empty-state.js';
58
+ import { renderEntitySelector } from './components/entity-selector.js';
59
+ import { renderSubTabStrip } from './components/sub-tab-strip.js';
60
+ import { buildDagLayout, buildDagInspectorPayload, renderBuildDag } from './build-dag.js';
61
+ import { computeReqNeedsWorkIds, needsWorkReasonFor } from './needs-work.js';
62
+ import { renderLookupModal, renderSearchButton } from './view-index.js';
63
+
64
+ // Umbrella version stamped at module load (same pattern as src/ruleset/index.js).
65
+ // Used by the shell footer so the muted "RCF Lite X.Y.Z" line tracks the
66
+ // installed package without a build step.
67
+ const pkgJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
68
+ const RCF_LITE_VERSION = JSON.parse(readFileSync(pkgJsonPath, 'utf8')).version;
31
69
 
32
70
  // Inline SVG favicon: the Stravica kit mark (deep-navy tile with the
33
71
  // ribbon symbol in Stravica blue), taken from the estate brand kit at
@@ -47,9 +85,9 @@ const LIVE_WRAPPER_CLOSE = '</div>';
47
85
  * @returns {string}
48
86
  */
49
87
  export function renderPage(model) {
50
- const projectName = model.manifest?.projectName ?? model.prd?.productName ?? 'RCF project';
88
+ const header = resolveProductHeader(model);
89
+ const projectName = header.projectName ?? 'RCF project';
51
90
  const contentHtml = renderContent(model);
52
- const script = inlineScript();
53
91
 
54
92
  return `<!DOCTYPE html>
55
93
  <html lang="en-GB">
@@ -58,7 +96,7 @@ export function renderPage(model) {
58
96
  <meta name="viewport" content="width=device-width, initial-scale=1">
59
97
  <title>${escapeHtml(projectName)} - RCF review surface</title>
60
98
  <link rel="icon" type="image/svg+xml" href="${FAVICON_HREF}">
61
- <link rel="stylesheet" href="style.css">
99
+ <link rel="stylesheet" href="./style.css">
62
100
  <noscript>
63
101
  <style>
64
102
  /* Progressive enhancement per D12: no JS -> every tabpanel is
@@ -70,29 +108,48 @@ export function renderPage(model) {
70
108
  </noscript>
71
109
  </head>
72
110
  <body>
73
- <header>
74
- <h1>${escapeHtml(projectName)}</h1>
75
- <p class="subtitle">RCF review surface</p>
76
- <nav class="tabs" role="tablist" aria-label="Document sections">
77
- <button type="button" role="tab" data-tab="overview" aria-selected="true" aria-controls="tab-overview">Overview</button>
78
- <button type="button" role="tab" data-tab="product-map" aria-selected="false" aria-controls="tab-product-map">Product Map</button>
79
- <button type="button" role="tab" data-tab="requirements" aria-selected="false" aria-controls="tab-requirements">Requirements</button>
80
- <button type="button" role="tab" data-tab="architecture" aria-selected="false" aria-controls="tab-architecture">Architecture</button>
81
- <button type="button" role="tab" data-tab="build" aria-selected="false" aria-controls="tab-build">Build sequence</button>
82
- </nav>
111
+ <header class="app-header">
112
+ <div class="bar">
113
+ <div class="brand" title="Review surface">
114
+ <svg class="mark" aria-hidden="true" viewBox="0 0 64 64" width="18" height="18"><rect x="0" y="0" width="64" height="64" rx="14" fill="#080F19"/><g fill="#7295FF"><path d="M38 9L17 21V32L38 20Z"/><path d="M30 27L46 36V46L30 37L23 33Z"/><path d="M46 46L25 58V47L37 40Z"/></g></svg>
115
+ </div>
116
+ ${renderProductBlock(header)}
117
+ <nav class="tabs" role="tablist" aria-label="Document sections">
118
+ <button type="button" role="tab" data-tab="readiness" aria-selected="true" aria-controls="tab-readiness">Readiness</button>
119
+ <button type="button" role="tab" data-tab="overview" aria-selected="false" aria-controls="tab-overview">PRD</button>
120
+ <button type="button" role="tab" data-tab="product-map" aria-selected="false" aria-controls="tab-product-map">Product Map</button>
121
+ <button type="button" role="tab" data-tab="requirements" aria-selected="false" aria-controls="tab-requirements">Requirements</button>
122
+ <button type="button" role="tab" data-tab="architecture" aria-selected="false" aria-controls="tab-architecture">Architecture</button>
123
+ <button type="button" role="tab" data-tab="build" aria-selected="false" aria-controls="tab-build">Build</button>
124
+ </nav>
125
+ <div class="tools" aria-label="Viewer tools">
126
+ ${renderSearchButton()}
127
+ </div>
128
+ </div>
83
129
  </header>
84
130
  <main>
85
131
  ${LIVE_WRAPPER_OPEN}
86
132
  ${contentHtml}
87
133
  ${LIVE_WRAPPER_CLOSE}
88
134
  </main>
89
- <footer>
90
- <p>Generated from the on-disk RCF tree at <code>rcf/</code>. Read-only; changes on disk stream to this tab automatically.</p>
91
- <p>Learn more about the Requirements Confidence Framework at <a href="https://stravica.ai/rcf-methodology" target="_blank" rel="noopener">stravica.ai/rcf-methodology</a>.</p>
135
+ <footer class="app-footer">
136
+ <span class="ver">RCF Lite ${escapeHtml(RCF_LITE_VERSION)}</span>
137
+ <span class="sep" aria-hidden="true">&middot;</span>
138
+ <a href="https://github.com/Stravica/rcf-lite" target="_blank" rel="noopener">rcf-lite on GitHub</a>
139
+ <span class="sep" aria-hidden="true">&middot;</span>
140
+ <a href="https://stravica.ai/docs/rcf/" target="_blank" rel="noopener">RCF Lite docs</a>
141
+ <span class="sep" aria-hidden="true">&middot;</span>
142
+ <a href="https://stravica.ai/rcf-methodology/" target="_blank" rel="noopener">RCF method docs</a>
143
+ <span id="rcf-conn-pill" class="live reconnecting" role="status" aria-live="polite" title="Connecting to view server...">
144
+ <span class="dot" aria-hidden="true"></span>
145
+ <span class="label">Connecting&hellip;</span>
146
+ </span>
92
147
  </footer>
93
- <script src="mermaid.min.js"></script>
94
- <script>${script}</script>
95
- <script src="/live-client.js" defer></script>
148
+ ${renderToastContainer()}
149
+ ${renderLookupModal()}
150
+ <script src="./mermaid.min.js"></script>
151
+ <script src="./page-init.js" defer></script>
152
+ <script src="./live-client.js" defer></script>
96
153
  </body>
97
154
  </html>
98
155
  `;
@@ -109,27 +166,40 @@ export function renderPage(model) {
109
166
  */
110
167
  export function renderContent(model) {
111
168
  const subdiagrams = allRequirementSubdiagrams(model);
169
+ const needsWorkIds = computeReqNeedsWorkIds(model.readiness);
112
170
 
113
171
  const prdSection = model.prd
114
172
  ? renderPrd(model.prd, {
115
173
  raw: model.rawById.get(model.prd.prdId),
116
174
  errors: model.errorsById.get(model.prd.prdId),
117
175
  requirementIds: model.childrenByParent.get(model.prd.prdId) ?? [],
176
+ suppressRequirementList: true,
177
+ collapsibleLists: true,
118
178
  })
119
179
  : '<p><em>No PRD on disk.</em></p>';
120
180
 
121
- const requirementsPanel = renderRequirementsPanel(model, subdiagrams);
181
+ const prdSelector = renderPrdRequirementSelector(model);
182
+ const requirementsPanel = renderRequirementsPanel(model, subdiagrams, needsWorkIds);
122
183
  const architecturePanel = renderArchitecturePanel(model);
123
184
  const buildPanel = renderBuildPanel(model);
124
185
  const productMapPanel = renderProductMapPanel(model);
186
+ const readinessPanel = renderReadinessPanel(model.readiness ?? null, {
187
+ profile: model.profileText ?? null,
188
+ freezeRecord: model.freezeRecord ?? null,
189
+ });
125
190
 
126
191
  const errorBanner = renderErrorBanner(model.errors ?? []);
127
192
 
128
193
  return `${errorBanner}
129
- <section id="tab-overview" role="tabpanel" aria-labelledby="tab-overview-button">
130
- <h2 class="tab-heading">Overview</h2>
194
+ <section id="tab-readiness" role="tabpanel" aria-labelledby="tab-readiness-button">
195
+ <h2 class="tab-heading">Readiness</h2>
196
+ ${readinessPanel}
197
+ </section>
198
+ <section id="tab-overview" role="tabpanel" aria-labelledby="tab-overview-button" hidden>
199
+ <h2 class="tab-heading">PRD</h2>
131
200
  <div class="prd-body">
132
201
  ${prdSection}
202
+ ${prdSelector}
133
203
  </div>
134
204
  </section>
135
205
  <section id="tab-product-map" role="tabpanel" hidden>
@@ -145,48 +215,73 @@ export function renderContent(model) {
145
215
  ${architecturePanel}
146
216
  </section>
147
217
  <section id="tab-build" role="tabpanel" hidden>
148
- <h2 class="tab-heading">Build sequence</h2>
218
+ <h2 class="tab-heading">Build</h2>
149
219
  ${buildPanel}
150
220
  </section>`;
151
221
  }
152
222
 
153
- function renderRequirementsPanel(model, subdiagrams) {
223
+ /**
224
+ * Viewer UI refresh PR 3 (decision 2, decision 3, decision 14): the
225
+ * Requirements tab renders every REQ as a shared DocRow carrying
226
+ * area / US count / AC count badges and a status pill. The FilterBar
227
+ * above drives text / area / priority / status / Needs-work filtering;
228
+ * state is wired to the URL hash in page-init.js so a filtered view
229
+ * is a link. Each row stays closed by default; when opened it shows
230
+ * the renderReq body (description, rationale, tags), a nested row of
231
+ * user stories (with AC counts), and the slice diagram + raw JSON as
232
+ * their own collapsed inner details so the first-paint stays cheap.
233
+ *
234
+ * `#entity=US-304` resolves to REQ-003 and US-304: the hash router
235
+ * opens every ancestor <details> and the US row's data-doc-id anchor
236
+ * is the one the current router already walks (decision 11, PR 1).
237
+ *
238
+ * @param {import('./tree-model.js').BuiltTreeModel} model
239
+ * @param {Map<string, string>} subdiagrams - REQ id -> Mermaid source
240
+ * @param {Set<string>} needsWorkIds - REQ ids still needing PO work
241
+ */
242
+ function renderRequirementsPanel(model, subdiagrams, needsWorkIds) {
154
243
  if (model.requirements.length === 0 && model.userStories.length === 0) {
155
- return '<p><em>No requirements on disk.</em></p>';
156
- }
157
- const reqBlocks = model.requirements.map((r) => {
158
- const reqBody = renderReq(r, {
159
- raw: model.rawById.get(r.reqId),
160
- errors: model.errorsById.get(r.reqId),
161
- subdiagram: subdiagrams.get(r.reqId),
162
- });
163
- const stories = model.storiesByReqId.get(r.reqId) ?? [];
164
- const usBlocks = stories.map((u) => detailsWrap({
165
- id: u.usId,
166
- summary: `${u.usId} - ${u.title ?? ''}`,
167
- className: 'doc-us-wrap',
168
- status: u.status,
169
- body: renderUserStory(u, {
170
- raw: model.rawById.get(u.usId),
171
- errors: model.errorsById.get(u.usId),
172
- fbsByAcId: model.fbsByAcId,
173
- }),
174
- })).join('\n');
175
- const storiesSection = usBlocks
176
- ? `<section class="nested-details"><h4>User stories</h4>${usBlocks}</section>`
177
- : '<p><em>No user stories under this requirement.</em></p>';
178
- return detailsWrap({
179
- id: r.reqId,
180
- summary: `${r.reqId} - ${r.title ?? ''}`,
181
- className: 'doc-req-wrap',
182
- status: r.status,
183
- body: `${reqBody}\n${storiesSection}`,
244
+ return renderEmptyState({
245
+ title: 'No requirements on disk',
246
+ hint: 'Add requirements under rcf/requirements/ and the viewer picks them up on the next walk.',
184
247
  });
185
- }).join('\n');
248
+ }
249
+ const areaCounts = countBy(model.requirements, (r) => normaliseFacet(r.domain));
250
+ const priorityCounts = countBy(model.requirements, (r) => normaliseFacet(r.priority));
251
+ const statusCounts = countBy(model.requirements, (r) => normaliseFacet(r.status));
252
+
253
+ const filterBar = renderFilterBar({
254
+ hashKey: 'requirements',
255
+ placeholder: 'Filter by id, title or text',
256
+ selects: [
257
+ {
258
+ key: 'domain',
259
+ label: 'Area',
260
+ options: [{ value: '', label: 'All areas' }, ...facetOptions(areaCounts)],
261
+ },
262
+ {
263
+ key: 'priority',
264
+ label: 'Priority',
265
+ options: [{ value: '', label: 'Any priority' }, ...facetOptions(priorityCounts)],
266
+ },
267
+ {
268
+ key: 'status',
269
+ label: 'Status',
270
+ options: [{ value: '', label: 'Any status' }, ...facetOptions(statusCounts)],
271
+ },
272
+ ],
273
+ toggles: [
274
+ { key: 'needswork', label: `Needs work (${needsWorkIds.size})` },
275
+ ],
276
+ count: { visible: model.requirements.length, total: model.requirements.length },
277
+ showExpandAll: true,
278
+ });
279
+
280
+ const reqRows = model.requirements.map((r) => renderRequirementRow(r, model, subdiagrams, needsWorkIds)).join('\n');
186
281
 
187
282
  const orphanUs = model.userStories.filter((u) => !u.reqId || !model.requirements.some((r) => r.reqId === u.reqId));
188
283
  const orphanBlock = orphanUs.length > 0
189
- ? `<section class="orphan-us"><h3>Orphan user stories</h3>${orphanUs.map((u) => detailsWrap({
284
+ ? `<section class="orphan-us"><h3 class="group-heading">Orphan user stories</h3>${orphanUs.map((u) => detailsWrap({
190
285
  id: u.usId,
191
286
  summary: `${u.usId} - ${u.title ?? ''}`,
192
287
  className: 'doc-us-wrap',
@@ -199,583 +294,586 @@ function renderRequirementsPanel(model, subdiagrams) {
199
294
  })).join('\n')}</section>`
200
295
  : '';
201
296
 
202
- return `${reqBlocks}\n${orphanBlock}`;
297
+ return `${filterBar}
298
+ <div class="rcf-requirements-list" data-rcf-list="requirements">
299
+ ${reqRows}
300
+ </div>
301
+ ${orphanBlock}`;
203
302
  }
204
303
 
205
- function renderArchitecturePanel(model) {
206
- const tadChildren = model.tad ? (model.childrenByParent.get(model.tad.tadId) ?? []) : [];
207
- const componentIds = tadChildren.filter((id) => id.startsWith('TAC-'));
208
- const architecturalDecisionIds = tadChildren.filter((id) => id.startsWith('ADR-'));
209
- const tadSection = model.tad
210
- ? renderTad(model.tad, {
211
- raw: model.rawById.get(model.tad.tadId),
212
- errors: model.errorsById.get(model.tad.tadId),
213
- componentIds,
214
- architecturalDecisionIds,
215
- })
216
- : '<p><em>No TAD on disk.</em></p>';
217
-
218
- const tacBlocks = model.tacs.map((t) => detailsWrap({
219
- id: t.tacId,
220
- summary: `${t.tacId} - ${t.name ?? ''}`,
221
- className: 'doc-tac-wrap',
222
- status: t.status,
223
- body: renderTac(t, {
224
- raw: model.rawById.get(t.tacId),
225
- errors: model.errorsById.get(t.tacId),
226
- }),
227
- })).join('\n');
228
-
229
- const adrBlocks = model.adrs.map((a) => detailsWrap({
230
- id: a.adrId,
231
- summary: `${a.adrId} - ${a.title ?? ''}`,
232
- className: 'doc-adr-wrap',
233
- status: a.status,
234
- body: renderAdr(a, {
235
- raw: model.rawById.get(a.adrId),
236
- errors: model.errorsById.get(a.adrId),
237
- }),
238
- })).join('\n');
239
-
240
- return `
241
- ${tadSection}
242
- <h3 class="group-heading">Components</h3>
243
- ${tacBlocks || '<p><em>No TAC components on disk.</em></p>'}
244
- <h3 class="group-heading">Architectural decisions</h3>
245
- ${adrBlocks || '<p><em>No ADRs on disk.</em></p>'}
246
- `;
247
- }
304
+ function renderRequirementRow(req, model, subdiagrams, needsWorkIds) {
305
+ const stories = model.storiesByReqId.get(req.reqId) ?? [];
306
+ const acCount = stories.reduce((n, u) => n + (u.acceptanceCriteria?.length ?? 0), 0);
307
+ const area = normaliseFacet(req.domain);
308
+ const priority = normaliseFacet(req.priority);
309
+ const status = normaliseFacet(req.status);
310
+ const needsWork = needsWorkIds.has(req.reqId);
311
+ const needsWorkReason = needsWork ? (needsWorkReasonFor(model.readiness, req.reqId) ?? 'needs engineer-ready detail') : '';
312
+
313
+ const metaParts = [];
314
+ if (area) metaParts.push(renderBadge({ value: area, variant: 'facet', title: 'Area' }));
315
+ metaParts.push(renderBadge({ value: stories.length, label: 'US', variant: 'count', title: 'User stories' }));
316
+ metaParts.push(renderBadge({ value: acCount, label: 'AC', variant: 'count', title: 'Acceptance criteria' }));
317
+ if (status) metaParts.push(pill({ value: status, variant: 'doc-status' }));
318
+ if (needsWork) metaParts.push(renderBadge({ value: 'needs work', variant: 'accent', title: needsWorkReason }));
319
+ const meta = metaParts.join(' ');
320
+
321
+ const usRows = stories.map((u) => renderUserStoryInnerRow(u, model)).join('\n');
322
+ const storiesBlock = stories.length > 0
323
+ ? `<section class="rcf-req-stories"><h4>User stories (${stories.length})</h4>${usRows}</section>`
324
+ : '<p class="muted"><em>No user stories under this requirement yet.</em></p>';
325
+
326
+ const description = typeof req.description === 'string' && req.description.length > 0
327
+ ? `<p class="rcf-req-description">${escapeHtml(req.description)}</p>`
328
+ : '';
329
+ const rationale = typeof req.rationale === 'string' && req.rationale.length > 0
330
+ ? `<p class="rcf-req-rationale muted"><strong>Why:</strong> ${escapeHtml(req.rationale)}</p>`
331
+ : '';
332
+ const tags = Array.isArray(req.tags) && req.tags.length > 0
333
+ ? `<div class="rcf-req-tags">${req.tags.map((t) => renderBadge({ value: t, variant: 'facet' })).join(' ')}</div>`
334
+ : '';
335
+ const needsWorkLine = needsWork
336
+ ? `<p class="rcf-req-needswork"><strong>Needs work:</strong> ${escapeHtml(needsWorkReason)}</p>`
337
+ : '';
248
338
 
249
- function renderBuildPanel(model) {
250
- const bsSlots = model.bs
251
- ? [...model.fbsItems]
252
- .filter((f) => f.bsId === model.bs.bsId)
253
- .sort((a, b) => (a.buildOrder ?? 0) - (b.buildOrder ?? 0))
254
- .map((f) => ({
255
- fbsId: f.fbsId,
256
- buildOrder: f.buildOrder,
257
- executionStatus: f.executionStatus,
258
- title: f.title,
259
- }))
260
- : [];
261
- const bsSection = model.bs
262
- ? renderBuildSequence(model.bs, {
263
- raw: model.rawById.get(model.bs.bsId),
264
- errors: model.errorsById.get(model.bs.bsId),
265
- slots: bsSlots,
266
- })
267
- : '<p><em>No build sequence on disk.</em></p>';
268
-
269
- const fbsBlocks = model.fbsItems.map((f) => detailsWrap({
270
- id: f.fbsId,
271
- summary: `${f.fbsId} - ${f.title ?? ''}`,
272
- className: 'doc-fbs-wrap',
273
- status: f.executionStatus,
274
- body: renderFbs(f, {
275
- raw: model.rawById.get(f.fbsId),
276
- errors: model.errorsById.get(f.fbsId),
277
- usByAcId: model.usByAcId,
278
- }),
279
- })).join('\n');
280
-
281
- const tsBlocks = model.testSuites.map((ts) => detailsWrap({
282
- id: ts.id,
283
- summary: `${ts.id} - ${ts.title ?? 'test suite'}`,
284
- className: 'doc-ts-wrap',
285
- status: ts.status,
286
- body: renderTestSuite(ts, {
287
- raw: model.rawById.get(ts.id),
288
- errors: model.errorsById.get(ts.id),
289
- }),
290
- })).join('\n');
291
-
292
- const tsSection = tsBlocks
293
- ? `<h3 class="group-heading">Test suites</h3>${tsBlocks}`
339
+ const subdiagram = subdiagrams.get(req.reqId);
340
+ const sliceDetails = subdiagram
341
+ ? `<details class="rcf-req-slice"><summary>Slice diagram <span class="muted small">(REQ, stories, criteria and the FBS that deliver them)</span></summary><div class="rcf-req-slice-body"><pre class="mermaid">${escapeHtml(subdiagram)}</pre></div></details>`
294
342
  : '';
295
343
 
296
- return `
297
- ${bsSection}
298
- <h3 class="group-heading">Functional Build Specifications</h3>
299
- ${fbsBlocks || '<p><em>No FBS items on disk.</em></p>'}
300
- ${tsSection}
301
- `;
344
+ const rawJson = model.rawById.get(req.reqId) ?? JSON.stringify(req, null, 2);
345
+ const rawDetails = `<details class="rcf-req-raw raw-json" data-doc-id="${escapeHtml(req.reqId)}::raw"><summary class="muted small">Raw JSON</summary><pre>${escapeHtml(rawJson)}</pre></details>`;
346
+
347
+ const body = `${needsWorkLine}${description}${rationale}${tags}${storiesBlock}${sliceDetails}${rawDetails}`;
348
+
349
+ const extraClasses = ['doc-req-wrap'];
350
+ if (needsWork) extraClasses.push('needs-work');
351
+ const row = renderDocRow({
352
+ id: req.reqId,
353
+ title: req.title ?? '',
354
+ body,
355
+ meta,
356
+ className: extraClasses.join(' '),
357
+ });
358
+
359
+ // Attach the filter-facet data attributes to the <details> opening
360
+ // tag so page-init.js can hide / show rows without touching the body.
361
+ const dataAttrs = ` data-domain="${escapeHtml(area)}" data-priority="${escapeHtml(priority)}" data-status="${escapeHtml(status)}" data-needswork="${needsWork ? '1' : '0'}" data-text="${escapeHtml(buildFilterText(req, stories))}"`;
362
+ return row.replace(/^<details /, `<details${dataAttrs} `);
302
363
  }
303
364
 
304
- function renderErrorBanner(errors) {
305
- if (!errors || errors.length === 0) return '';
306
- const count = errors.length;
307
- const noun = count === 1 ? 'error' : 'errors';
308
- const items = errors.slice(0, 20).map((e) => {
309
- const label = e.documentId ? `${e.documentId}` : e.filePath ?? '';
310
- return `<li><strong>${escapeHtml(e.kind)}</strong> ${escapeHtml(label)} - ${escapeHtml(e.message)}</li>`;
311
- }).join('');
312
- const more = count > 20 ? `<p><em>... and ${count - 20} more</em></p>` : '';
313
- return `<aside class="tree-errors" role="alert">
314
- <h2>Tree has ${count} ${noun}</h2>
315
- <ul>${items}</ul>
316
- ${more}
317
- </aside>`;
365
+ function renderUserStoryInnerRow(us, model) {
366
+ const acCount = us.acceptanceCriteria?.length ?? 0;
367
+ const status = normaliseFacet(us.status);
368
+ const metaParts = [renderBadge({ value: acCount, label: 'AC', variant: 'count', title: 'Acceptance criteria' })];
369
+ if (status) metaParts.push(pill({ value: status, variant: 'doc-status' }));
370
+ const body = renderUserStory(us, {
371
+ raw: model.rawById.get(us.usId),
372
+ errors: model.errorsById.get(us.usId),
373
+ fbsByAcId: model.fbsByAcId,
374
+ });
375
+ return renderDocRow({
376
+ id: us.usId,
377
+ title: us.title ?? '',
378
+ body,
379
+ meta: metaParts.join(' '),
380
+ className: 'doc-us-wrap rcf-row--inner',
381
+ });
318
382
  }
319
383
 
320
- // Inline client-side script: initialises Mermaid, wires tab switching,
321
- // resolves hash routes to a tab + opens any ancestor `<details>` on the
322
- // target. Vanilla; no dependencies beyond the vendored Mermaid loaded above.
323
- // Phase 3.8 note: this script is byte-identical to the Phase 3.6 shape
324
- // (the layout-regression test guards it). Live-swap recovery lives
325
- // entirely in `src/view/live-client.js`, which reads state from the DOM
326
- // after each SSE innerHTML swap - it does not call into this IIFE.
327
- function inlineScript() {
328
- return `
329
- (function () {
330
- var TABS = ['overview', 'requirements', 'architecture', 'build', 'product-map'];
331
- var PM_GROUPS = ['shape', 'component', 'trace', 'capability', 'blueprint'];
332
- var PM_STATUSES = ['all', 'draft', 'review', 'needsRevision', 'approved', 'superseded'];
333
- // Partial-render cache: grouping -> HTML string. First fetch fills it,
334
- // subsequent switches use the cache.
335
- var pmPartialCache = {};
336
- // Track whether an SSE swap has invalidated the cache since the last
337
- // successful hydration - the live-client sets window.__rcfPmDirty
338
- // after each swap, and we clear the cache on the next activation.
339
- function pmCacheKey(group) { return group; }
340
-
341
- function initMermaid() {
342
- if (typeof window.mermaid !== 'undefined') {
343
- window.mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' });
344
- }
384
+ function buildFilterText(req, stories) {
385
+ const parts = [req.reqId, req.title, req.description, req.rationale, req.domain, req.priority, req.status];
386
+ if (Array.isArray(req.tags)) parts.push(req.tags.join(' '));
387
+ for (const u of stories) {
388
+ parts.push(u.usId, u.title, u.asA, u.iWant, u.soThat);
345
389
  }
390
+ return parts.filter((v) => typeof v === 'string' && v.length > 0).join(' ').toLowerCase();
391
+ }
346
392
 
347
- function runMermaidIn(container) {
348
- if (!container || typeof window.mermaid === 'undefined') return;
349
- var pending = container.querySelectorAll('.mermaid:not([data-processed="true"])');
350
- if (pending.length === 0) return;
351
- try {
352
- window.mermaid.run({ nodes: Array.prototype.slice.call(pending) });
353
- } catch (e) { /* swallow */ }
354
- }
393
+ function normaliseFacet(v) {
394
+ if (typeof v !== 'string') return '';
395
+ return v.trim();
396
+ }
355
397
 
356
- function tabButtons() {
357
- return Array.prototype.slice.call(document.querySelectorAll('nav.tabs [role="tab"]'));
398
+ function countBy(items, keyFn) {
399
+ const out = new Map();
400
+ for (const it of items) {
401
+ const k = keyFn(it);
402
+ if (!k) continue;
403
+ out.set(k, (out.get(k) ?? 0) + 1);
358
404
  }
405
+ return out;
406
+ }
359
407
 
360
- function panelFor(name) {
361
- return document.getElementById('tab-' + name);
362
- }
408
+ function facetOptions(counts) {
409
+ const entries = [...counts.entries()];
410
+ entries.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]));
411
+ return entries.map(([value, count]) => ({ value, label: `${value} (${count})` }));
412
+ }
363
413
 
364
- function activateTab(name) {
365
- if (TABS.indexOf(name) === -1) return false;
366
- tabButtons().forEach(function (btn) {
367
- var isTarget = btn.getAttribute('data-tab') === name;
368
- btn.setAttribute('aria-selected', isTarget ? 'true' : 'false');
369
- });
370
- TABS.forEach(function (t) {
371
- var p = panelFor(t);
372
- if (!p) return;
373
- if (t === name) {
374
- p.removeAttribute('hidden');
375
- runMermaidIn(p);
376
- } else {
377
- p.setAttribute('hidden', '');
378
- }
414
+ /**
415
+ * Viewer UI refresh PR 3 (decision 4): the PRD tab replaces the 109
416
+ * inline requirement links with an EntitySelector. Area chips deep-link
417
+ * into the Requirements tab filtered by domain. The jump input picks
418
+ * one REQ by id or title and writes `#tab=requirements&entity=<id>`.
419
+ *
420
+ * @param {import('./tree-model.js').BuiltTreeModel} model
421
+ */
422
+ function renderPrdRequirementSelector(model) {
423
+ const reqs = model.requirements ?? [];
424
+ if (reqs.length === 0) return '';
425
+ const items = reqs.map((r) => ({
426
+ id: r.reqId,
427
+ title: r.title ?? '',
428
+ facet: normaliseFacet(r.domain),
429
+ }));
430
+ const areaCounts = countBy(reqs, (r) => normaliseFacet(r.domain));
431
+ const facets = [...areaCounts.entries()]
432
+ .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
433
+ .map(([value, count]) => ({ value, count }));
434
+ const acTotal = (model.userStories ?? []).reduce((n, u) => n + (u.acceptanceCriteria?.length ?? 0), 0);
435
+ const totalLabel = `${reqs.length} requirements in ${facets.length} area${facets.length === 1 ? '' : 's'}, ${model.userStories.length} stories, ${acTotal} acceptance criteria`;
436
+ const selector = renderEntitySelector({
437
+ hashKey: 'requirements',
438
+ targetTab: 'requirements',
439
+ facetKey: 'domain',
440
+ items,
441
+ facets,
442
+ totalLabel,
443
+ placeholder: `Jump to a requirement by id or title (e.g. REQ-040 or 'readiness')`,
444
+ chipsLabel: 'By area (click to open the Requirements tab filtered):',
445
+ });
446
+ return `<section class="rcf-prd-requirements" id="prd-requirements">
447
+ <div class="rcf-prd-requirements-head">
448
+ <h3>Requirements</h3>
449
+ <a class="rcf-prd-requirements-open" href="#tab=requirements">Open the Requirements tab</a>
450
+ </div>
451
+ ${selector}
452
+ </section>`;
453
+ }
454
+
455
+ /**
456
+ * Viewer UI refresh PR 4 (w-2026-10-02-dave-010 decisions 5 and 14,
457
+ * design doc section 5.5). The Architecture tab renders:
458
+ * 1. A compact page-head carrying the TAD id, its status pill and
459
+ * the TAC / ADR tallies.
460
+ * 2. The TAD as six collapsed sections with one-line previews; an
461
+ * absent section reads "Nothing written yet" and names the
462
+ * `rcf define update TAD-001 --set <section>.<field>="..."` call
463
+ * that authors it (decision 5).
464
+ * 3. Components (TAC) as a FilterBar + a shared DocRow list; rows
465
+ * carry interface and dependency counts and a status pill. Long
466
+ * ids are shortened on the summary; the full id stays on
467
+ * data-doc-id and the title.
468
+ * 4. Architectural decisions (ADR) as a FilterBar + a shared DocRow
469
+ * list; rows carry a status pill. Long ids shortened the same way.
470
+ *
471
+ * The tab never points at Readiness (amendment 12.4) - absence of
472
+ * content is handled inside this tab's own register.
473
+ */
474
+ function renderArchitecturePanel(model) {
475
+ const pageHead = renderArchitecturePageHead(model);
476
+ const tadSection = model.tad
477
+ ? renderTadSections(model.tad)
478
+ : renderEmptyState({
479
+ title: 'No TAD on disk',
480
+ hint: 'Add rcf/tad.json and the viewer picks it up on the next walk.',
379
481
  });
380
- return true;
381
- }
382
482
 
383
- function tabForNode(node) {
384
- var cur = node;
385
- while (cur && cur !== document.body) {
386
- if (cur.getAttribute && cur.getAttribute('role') === 'tabpanel') {
387
- var id = cur.id || '';
388
- if (id.indexOf('tab-') === 0) return id.slice(4);
389
- }
390
- cur = cur.parentNode;
391
- }
392
- return null;
393
- }
483
+ const tacPanel = renderComponentsPanel(model);
484
+ const adrPanel = renderDecisionsPanel(model);
394
485
 
395
- function openAncestorDetails(node) {
396
- var cur = node.parentNode;
397
- while (cur && cur !== document.body) {
398
- if (cur.tagName && cur.tagName.toLowerCase() === 'details') {
399
- cur.open = true;
400
- }
401
- cur = cur.parentNode;
402
- }
403
- }
486
+ // Raw JSON for the whole TAD stays reachable but out of the first
487
+ // paint - the design doc deliberately drops "the long prose dump".
488
+ const rawTadDisclosure = model.tad
489
+ ? `<details class="rcf-tad-raw" data-doc-id="${escapeHtml(model.tad.tadId)}::raw"><summary class="muted small">Raw TAD JSON</summary><pre>${escapeHtml(model.rawById.get(model.tad.tadId) ?? JSON.stringify(model.tad, null, 2))}</pre></details>`
490
+ : '';
404
491
 
405
- function findByDocId(id) {
406
- if (!id) return null;
407
- var byId = document.getElementById(id);
408
- if (byId) return byId;
409
- return document.querySelector('[data-doc-id="' + id.replace(/"/g, '\\\\"') + '"]');
410
- }
492
+ // Hidden anchors so #TAD-001 / #entity=TAD-001 still land on this tab.
493
+ const tadAnchor = model.tad
494
+ ? `<span class="rcf-tad-anchor" data-doc-id="${escapeHtml(model.tad.tadId)}" aria-hidden="true"></span>`
495
+ : '';
411
496
 
412
- function parseHashParams(raw) {
413
- var out = {};
414
- if (!raw) return out;
415
- var parts = raw.split('&');
416
- for (var i = 0; i < parts.length; i += 1) {
417
- var eq = parts[i].indexOf('=');
418
- if (eq === -1) continue;
419
- var k = parts[i].slice(0, eq);
420
- var v = parts[i].slice(eq + 1);
421
- if (k) out[k] = v;
422
- }
423
- return out;
424
- }
497
+ return `
498
+ ${tadAnchor}
499
+ ${pageHead}
500
+ <section class="rcf-architecture-tad" aria-labelledby="arch-tad-heading">
501
+ <h3 id="arch-tad-heading" class="group-heading rcf-tad-group-heading">Technical architecture (TAD)</h3>
502
+ ${tadSection}
503
+ ${rawTadDisclosure}
504
+ </section>
505
+ ${tacPanel}
506
+ ${adrPanel}
507
+ `;
508
+ }
425
509
 
426
- function pmGroupPanel(name) {
427
- return document.getElementById('pm-group-' + name);
510
+ function renderArchitecturePageHead(model) {
511
+ if (!model.tad) {
512
+ return `<div class="rcf-architecture-head"><h3 class="rcf-architecture-head-title">Architecture</h3></div>`;
428
513
  }
514
+ const status = normaliseFacet(model.tad.status);
515
+ const statusPill = status ? pill({ value: status, variant: 'doc-status' }) : '';
516
+ const tacCount = (model.tacs ?? []).length;
517
+ const adrCount = (model.adrs ?? []).length;
518
+ return `<div class="rcf-architecture-head">
519
+ <span class="rcf-architecture-head-id mono">${escapeHtml(model.tad.tadId)}</span>
520
+ ${statusPill}
521
+ <span class="muted small">${tacCount} component${tacCount === 1 ? '' : 's'} - ${adrCount} decision${adrCount === 1 ? '' : 's'}</span>
522
+ </div>`;
523
+ }
429
524
 
430
- function hydrateLazyGroup(name, cb) {
431
- var panel = pmGroupPanel(name);
432
- if (!panel) { if (cb) cb(false); return; }
433
- if (panel.getAttribute('data-pm-lazy') !== 'true' && !pmPartialCache[pmCacheKey(name)]) {
434
- if (cb) cb(true);
435
- return;
436
- }
437
- if (window.__rcfPmDirty) {
438
- pmPartialCache = {};
439
- window.__rcfPmDirty = false;
440
- }
441
- var cached = pmPartialCache[pmCacheKey(name)];
442
- var heading = panel.querySelector('.pm-group-heading');
443
- var headingHtml = heading ? heading.outerHTML : '';
444
- if (cached) {
445
- panel.innerHTML = headingHtml + cached;
446
- panel.removeAttribute('data-pm-lazy');
447
- wireProductMapWithin(panel);
448
- if (cb) cb(true);
449
- return;
450
- }
451
- // Show a lightweight loading state.
452
- panel.setAttribute('data-pm-loading', 'true');
453
- var xhr = new XMLHttpRequest();
454
- xhr.open('GET', '/product-map/' + name, true);
455
- xhr.onreadystatechange = function () {
456
- if (xhr.readyState !== 4) return;
457
- panel.removeAttribute('data-pm-loading');
458
- if (xhr.status >= 200 && xhr.status < 300) {
459
- pmPartialCache[pmCacheKey(name)] = xhr.responseText;
460
- panel.innerHTML = headingHtml + xhr.responseText;
461
- panel.removeAttribute('data-pm-lazy');
462
- wireProductMapWithin(panel);
463
- if (cb) cb(true);
464
- } else {
465
- panel.setAttribute('data-pm-lazy', 'true');
466
- if (cb) cb(false);
467
- }
468
- };
469
- xhr.send();
470
- }
525
+ function renderComponentsPanel(model) {
526
+ const tacs = model.tacs ?? [];
527
+ const statusCounts = countBy(tacs, (t) => normaliseFacet(t.status));
528
+ const filterBar = renderFilterBar({
529
+ hashKey: 'architecture-components',
530
+ placeholder: 'Filter components by id or text',
531
+ selects: [
532
+ {
533
+ key: 'status',
534
+ label: 'Status',
535
+ options: [{ value: '', label: 'Any status' }, ...facetOptions(statusCounts)],
536
+ },
537
+ ],
538
+ count: { visible: tacs.length, total: tacs.length },
539
+ showExpandAll: true,
540
+ });
541
+
542
+ const rows = tacs.map((t) => renderTacRow(t, model)).join('\n');
543
+ const emptyMessage = tacs.length === 0
544
+ ? renderEmptyState({ title: 'No TAC components on disk', hint: 'Add rcf/tacs/<file>.json files and the viewer picks them up.' })
545
+ : '';
471
546
 
472
- function activatePmGroup(name, opts) {
473
- if (PM_GROUPS.indexOf(name) === -1) return false;
474
- var buttons = document.querySelectorAll('.pm-group-btn');
475
- for (var i = 0; i < buttons.length; i += 1) {
476
- var btn = buttons[i];
477
- var isTarget = btn.getAttribute('data-pm-group') === name;
478
- btn.setAttribute('aria-selected', isTarget ? 'true' : 'false');
479
- }
480
- var panels = document.querySelectorAll('.pm-group');
481
- for (var j = 0; j < panels.length; j += 1) {
482
- var p = panels[j];
483
- if (p.getAttribute('data-pm-group') === name) {
484
- p.removeAttribute('hidden');
485
- } else {
486
- p.setAttribute('hidden', '');
487
- }
488
- }
489
- var target = pmGroupPanel(name);
490
- if (target && target.getAttribute('data-pm-lazy') === 'true') {
491
- hydrateLazyGroup(name, function () {
492
- if (opts && opts.after) opts.after();
493
- applyPmStatusFilter(currentStatus());
494
- });
495
- } else {
496
- if (opts && opts.after) opts.after();
497
- applyPmStatusFilter(currentStatus());
498
- }
499
- return true;
500
- }
547
+ return `<section class="rcf-architecture-components" aria-labelledby="arch-tac-heading">
548
+ <h3 id="arch-tac-heading" class="group-heading">Components</h3>
549
+ ${filterBar}
550
+ <div class="rcf-tac-list" data-rcf-list="architecture-components">
551
+ ${rows}
552
+ </div>
553
+ ${emptyMessage}
554
+ </section>`;
555
+ }
501
556
 
502
- function currentGroup() {
503
- var sel = document.querySelector('.pm-group-btn[aria-selected="true"]');
504
- return sel ? sel.getAttribute('data-pm-group') : 'shape';
505
- }
557
+ function renderDecisionsPanel(model) {
558
+ const adrs = model.adrs ?? [];
559
+ const statusCounts = countBy(adrs, (a) => normaliseFacet(a.status));
560
+ const filterBar = renderFilterBar({
561
+ hashKey: 'architecture-decisions',
562
+ placeholder: 'Filter decisions by id or text',
563
+ selects: [
564
+ {
565
+ key: 'status',
566
+ label: 'Status',
567
+ options: [{ value: '', label: 'Any status' }, ...facetOptions(statusCounts)],
568
+ },
569
+ ],
570
+ count: { visible: adrs.length, total: adrs.length },
571
+ showExpandAll: true,
572
+ });
573
+
574
+ const rows = adrs.map((a) => renderAdrRow(a, model)).join('\n');
575
+ const emptyMessage = adrs.length === 0
576
+ ? renderEmptyState({ title: 'No architectural decisions on disk', hint: 'Add rcf/adrs/<file>.json files and the viewer picks them up.' })
577
+ : '';
506
578
 
507
- function currentStatus() {
508
- var s = document.querySelector('.pm-status-select');
509
- return s ? s.value : 'all';
510
- }
579
+ return `<section class="rcf-architecture-decisions" aria-labelledby="arch-adr-heading">
580
+ <h3 id="arch-adr-heading" class="group-heading">Architectural decisions</h3>
581
+ ${filterBar}
582
+ <div class="rcf-adr-list" data-rcf-list="architecture-decisions">
583
+ ${rows}
584
+ </div>
585
+ ${emptyMessage}
586
+ </section>`;
587
+ }
511
588
 
512
- function currentOpenBuckets() {
513
- var group = currentGroup();
514
- var panel = pmGroupPanel(group);
515
- if (!panel) return [];
516
- var nodes = panel.querySelectorAll('details.pm-bucket[open]');
517
- var out = [];
518
- for (var i = 0; i < nodes.length; i += 1) {
519
- var id = nodes[i].getAttribute('data-pm-bucket-id');
520
- if (id) out.push(id);
521
- }
522
- return out;
523
- }
589
+ function renderTacRow(tac, model) {
590
+ const id = tac.tacId ?? 'TAC';
591
+ const displayId = shortenDocId(id);
592
+ const title = tac.name ?? '';
593
+ const interfaceCount = Array.isArray(tac.interfaces) ? tac.interfaces.length : 0;
594
+ const depCount = Array.isArray(tac.dependencies) ? tac.dependencies.length : 0;
595
+ const status = normaliseFacet(tac.status);
596
+ const metaParts = [
597
+ renderBadge({ value: interfaceCount, label: 'interfaces', variant: 'count', title: 'Interface count' }),
598
+ renderBadge({ value: depCount, label: 'deps', variant: 'count', title: 'Dependency count' }),
599
+ ];
600
+ if (status) metaParts.push(pill({ value: status, variant: 'doc-status' }));
601
+ const meta = metaParts.join(' ');
602
+
603
+ const body = renderTac(tac, {
604
+ raw: model.rawById.get(id),
605
+ errors: model.errorsById.get(id),
606
+ });
607
+ const row = renderDocRow({
608
+ id: displayId,
609
+ title,
610
+ body,
611
+ meta,
612
+ className: 'doc-tac-wrap rcf-arch-row',
613
+ dataDocId: id,
614
+ });
615
+ const dataAttrs = ` data-status="${escapeHtml(status)}" data-text="${escapeHtml(buildTacFilterText(tac))}" title="${escapeHtml(id)}"`;
616
+ return row.replace(/^<details /, `<details${dataAttrs} `);
617
+ }
524
618
 
525
- function applyPmStatusFilter(status) {
526
- if (PM_STATUSES.indexOf(status) === -1) return false;
527
- var select = document.querySelector('.pm-status-select');
528
- if (select) select.value = status;
529
- var reqCards = document.querySelectorAll('#tab-product-map [data-req-status]');
530
- for (var i = 0; i < reqCards.length; i += 1) {
531
- var card = reqCards[i];
532
- var s = card.getAttribute('data-req-status') || '';
533
- if (status === 'all' || s === status) {
534
- card.removeAttribute('hidden');
535
- } else {
536
- card.setAttribute('hidden', '');
537
- }
538
- }
539
- // Per AC-17002-1: empty buckets under the filter are HIDDEN by
540
- // default with a "N empty" summary line to reveal them. Also keep
541
- // the legacy in-bucket note for tests / no-JS accessibility.
542
- var buckets = document.querySelectorAll('#tab-product-map .pm-group:not([hidden]) details.pm-bucket');
543
- var emptyCount = 0;
544
- for (var k = 0; k < buckets.length; k += 1) {
545
- var bk = buckets[k];
546
- var visible = bk.querySelectorAll('[data-req-status]:not([hidden])');
547
- var bodyEmpty = bk.querySelector('.pm-bucket-body') && bk.querySelector('.pm-bucket-body').children.length === 0;
548
- var isEmpty = visible.length === 0 && !bodyEmpty; // lazy-empty buckets don't count
549
- var body = bk.querySelector('.pm-bucket-body');
550
- var emptyNote = body ? body.querySelector('.pm-bucket-empty-filter') : null;
551
- if (isEmpty && status !== 'all') {
552
- if (body && !emptyNote) {
553
- emptyNote = document.createElement('p');
554
- emptyNote.className = 'pm-bucket-empty pm-bucket-empty-filter';
555
- emptyNote.innerHTML = '<em>0 requirements match this filter.</em>';
556
- body.appendChild(emptyNote);
557
- }
558
- bk.setAttribute('data-pm-empty-under-filter', 'true');
559
- emptyCount += 1;
560
- } else {
561
- if (emptyNote) emptyNote.parentNode.removeChild(emptyNote);
562
- bk.removeAttribute('data-pm-empty-under-filter');
563
- }
564
- }
565
- // Update the rollup summary.
566
- var activePanel = document.querySelector('#tab-product-map .pm-group:not([hidden])');
567
- if (activePanel) {
568
- var roll = activePanel.querySelector('.pm-empty-roll');
569
- if (roll) {
570
- var showBtn = roll.querySelector('.pm-empty-count');
571
- if (emptyCount > 0 && status !== 'all') {
572
- if (showBtn) showBtn.textContent = String(emptyCount);
573
- roll.removeAttribute('hidden');
574
- activePanel.classList.add('pm-hide-empty');
575
- } else {
576
- roll.setAttribute('hidden', '');
577
- activePanel.classList.remove('pm-hide-empty');
578
- }
579
- }
580
- }
581
- return true;
582
- }
619
+ function renderAdrRow(adr, model) {
620
+ const id = adr.adrId ?? 'ADR';
621
+ const displayId = shortenDocId(id);
622
+ const title = adr.title ?? '';
623
+ const status = normaliseFacet(adr.status);
624
+ const metaParts = [];
625
+ if (status) metaParts.push(pill({ value: status, variant: 'doc-status' }));
626
+ const meta = metaParts.join(' ');
627
+
628
+ const body = renderAdr(adr, {
629
+ raw: model.rawById.get(id),
630
+ errors: model.errorsById.get(id),
631
+ });
632
+ const row = renderDocRow({
633
+ id: displayId,
634
+ title,
635
+ body,
636
+ meta,
637
+ className: 'doc-adr-wrap rcf-arch-row',
638
+ dataDocId: id,
639
+ });
640
+ const dataAttrs = ` data-status="${escapeHtml(status)}" data-text="${escapeHtml(buildAdrFilterText(adr))}" title="${escapeHtml(id)}"`;
641
+ return row.replace(/^<details /, `<details${dataAttrs} `);
642
+ }
583
643
 
584
- function updatePmHash() {
585
- if (!window.history || typeof window.history.pushState !== 'function') return;
586
- var group = currentGroup();
587
- var status = currentStatus();
588
- var open = currentOpenBuckets();
589
- var next = '#tab=product-map&group=' + group + '&status=' + status;
590
- if (open.length > 0) next += '&open=' + encodeURIComponent(open.join(','));
591
- if (window.location.hash === next) return;
592
- window.history.pushState(null, '', next);
644
+ function buildTacFilterText(tac) {
645
+ const parts = [tac.tacId, tac.name, tac.purpose, tac.internalStructure, tac.notes, tac.tradeoffs];
646
+ if (Array.isArray(tac.responsibilities)) parts.push(tac.responsibilities.join(' '));
647
+ if (Array.isArray(tac.interfaces)) {
648
+ for (const i of tac.interfaces) parts.push(i?.name, i?.description);
593
649
  }
594
-
595
- function openPmBuckets(ids) {
596
- if (!ids || ids.length === 0) return;
597
- var group = currentGroup();
598
- var panel = pmGroupPanel(group);
599
- if (!panel) return;
600
- for (var i = 0; i < ids.length; i += 1) {
601
- var raw = ids[i];
602
- if (!raw) continue;
603
- var node = panel.querySelector('details.pm-bucket[data-pm-bucket-id="' + raw.replace(/"/g, '\\"') + '"]');
604
- if (node) node.open = true;
605
- }
650
+ if (Array.isArray(tac.dependencies)) {
651
+ for (const d of tac.dependencies) parts.push(d?.name, d?.description);
606
652
  }
653
+ return parts.filter((v) => typeof v === 'string' && v.length > 0).join(' ').toLowerCase();
654
+ }
607
655
 
608
- function wireProductMapWithin(root) {
609
- root = root || document;
610
- // Bucket toggle -> push hash on user toggle. The 'toggle' event
611
- // fires for both open and close, from user action and from our own
612
- // scripting; guard against feedback via a dirty flag.
613
- var buckets = root.querySelectorAll('details.pm-bucket');
614
- for (var i = 0; i < buckets.length; i += 1) {
615
- var bk = buckets[i];
616
- if (bk.__rcfPmBucketWired) continue;
617
- bk.__rcfPmBucketWired = true;
618
- bk.addEventListener('toggle', function () {
619
- if (window.__rcfPmSuppress) return;
620
- updatePmHash();
621
- });
622
- }
623
- // Jump chip -> open bucket + scroll.
624
- var chips = root.querySelectorAll('.pm-jump-chip');
625
- for (var j = 0; j < chips.length; j += 1) {
626
- var chip = chips[j];
627
- if (chip.__rcfPmChipWired) continue;
628
- chip.__rcfPmChipWired = true;
629
- chip.addEventListener('click', function (ev) {
630
- var target = ev.currentTarget.getAttribute('data-pm-jump');
631
- var groupAttr = ev.currentTarget.closest('.pm-jump-nav').getAttribute('data-pm-jump-group');
632
- var panel = pmGroupPanel(groupAttr);
633
- if (!panel) return;
634
- var node = panel.querySelector('details.pm-bucket[data-pm-bucket-id="' + target.replace(/"/g, '\\"') + '"]');
635
- if (!node) return;
636
- node.open = true;
637
- try { node.scrollIntoView({ block: 'start' }); } catch (e) { node.scrollIntoView(); }
638
- });
639
- }
640
- // Bulk expand/collapse.
641
- var bulk = root.querySelectorAll('.pm-bulk-btn');
642
- for (var b = 0; b < bulk.length; b += 1) {
643
- var btn = bulk[b];
644
- if (btn.__rcfPmBulkWired) continue;
645
- btn.__rcfPmBulkWired = true;
646
- btn.addEventListener('click', function (ev) {
647
- var action = ev.currentTarget.getAttribute('data-pm-bulk');
648
- var panel = document.querySelector('#tab-product-map .pm-group:not([hidden])');
649
- if (!panel) return;
650
- var all = panel.querySelectorAll('details.pm-bucket');
651
- window.__rcfPmSuppress = true;
652
- for (var i = 0; i < all.length; i += 1) all[i].open = (action === 'expand');
653
- window.__rcfPmSuppress = false;
654
- updatePmHash();
655
- });
656
- }
657
- // Empty-roll toggle: reveals hidden empty buckets under the filter.
658
- var rolls = root.querySelectorAll('.pm-empty-toggle');
659
- for (var r = 0; r < rolls.length; r += 1) {
660
- var rt = rolls[r];
661
- if (rt.__rcfPmEmptyWired) continue;
662
- rt.__rcfPmEmptyWired = true;
663
- rt.addEventListener('click', function (ev) {
664
- var panel = ev.currentTarget.closest('.pm-group');
665
- if (!panel) return;
666
- if (panel.classList.contains('pm-hide-empty')) {
667
- panel.classList.remove('pm-hide-empty');
668
- ev.currentTarget.textContent = 'Hide empty buckets';
669
- } else {
670
- panel.classList.add('pm-hide-empty');
671
- var span = document.createElement('span');
672
- span.className = 'pm-empty-count';
673
- span.textContent = String(panel.querySelectorAll('details.pm-bucket[data-pm-empty-under-filter="true"]').length);
674
- ev.currentTarget.textContent = '';
675
- ev.currentTarget.appendChild(document.createTextNode('Show '));
676
- ev.currentTarget.appendChild(span);
677
- ev.currentTarget.appendChild(document.createTextNode(' empty buckets'));
678
- }
679
- });
680
- }
656
+ function buildAdrFilterText(adr) {
657
+ const parts = [adr.adrId, adr.title, adr.status, adr.context, adr.decision, adr.consequences];
658
+ if (Array.isArray(adr.alternativesConsidered)) {
659
+ for (const a of adr.alternativesConsidered) parts.push(a?.name, a?.summary, a?.reasonNotChosen);
681
660
  }
661
+ return parts.filter((v) => typeof v === 'string' && v.length > 0).join(' ').toLowerCase();
662
+ }
682
663
 
683
- function wireProductMap() {
684
- var buttons = document.querySelectorAll('.pm-group-btn');
685
- for (var i = 0; i < buttons.length; i += 1) {
686
- if (buttons[i].__rcfPmWired) continue;
687
- buttons[i].__rcfPmWired = true;
688
- buttons[i].addEventListener('click', function (ev) {
689
- var name = ev.currentTarget.getAttribute('data-pm-group');
690
- if (activatePmGroup(name)) updatePmHash();
691
- });
692
- }
693
- var select = document.querySelector('.pm-status-select');
694
- if (select && !select.__rcfPmWired) {
695
- select.__rcfPmWired = true;
696
- select.addEventListener('change', function () {
697
- applyPmStatusFilter(select.value);
698
- updatePmHash();
699
- });
700
- }
701
- wireProductMapWithin(document);
664
+ /**
665
+ * Viewer UI refresh PR 5 (w-2026-10-02-dave-010 decisions 2, 3, 6, 12,
666
+ * 14; design doc section 5.6). The Build tab renders:
667
+ *
668
+ * 1. BuildStats card: totals from `computeQueue` (items, verified,
669
+ * complete, inProgress, notStarted, buildable now). "Buildable now"
670
+ * equals `queue.totals.actionable` - the authoritative state the
671
+ * queue computes from `dependsOnFbsIds` and `executionStatus`.
672
+ * 2. SubTabStrip Specs | DAG (decision 6 + decision 12). The DAG
673
+ * sub-panel (PR 6) lays FBS by `dependsOnFbsIds` depth with
674
+ * click-to-highlight closure, an inspector and the unconnected
675
+ * lane; `renderBuildDag` emits the full panel HTML string from
676
+ * `buildDagLayout(fbsItems, buildableIds)`.
677
+ * 3. Specs sub-tab: FilterBar (text, status, area, size, Buildable-now
678
+ * toggle) over a shared DocRow list; row meta carries order, AC
679
+ * count, deps count, size, buildable pill, status.
680
+ * 4. Expanded Spec row body: summary, approach, AC and dep links,
681
+ * deliverables, context links, Show in the DAG.
682
+ *
683
+ * Dropped from the old Build sequence tab: the FBS slots block (the
684
+ * 87-row list was repetition of the specs below) and the Test suites
685
+ * section (suites stay reachable through AC links and PR 7's ID
686
+ * lookup).
687
+ *
688
+ * @param {import('./tree-model.js').BuiltTreeModel} model
689
+ */
690
+ function renderBuildPanel(model) {
691
+ const fbsItems = Array.isArray(model.fbsItems) ? model.fbsItems : [];
692
+ if (fbsItems.length === 0 && !model.bs) {
693
+ return renderEmptyState({
694
+ title: 'No build specifications on disk',
695
+ hint: 'Add rcf/fbs/<file>.json entries and the viewer picks them up on the next walk.',
696
+ });
702
697
  }
698
+ const queue = computeQueue({ fbsItems });
699
+ const buildableIds = new Set(
700
+ queue.items.filter((i) => i.state === 'actionable').map((i) => i.fbsId),
701
+ );
702
+ const stats = {
703
+ items: queue.totals.items,
704
+ verified: queue.totals.verified,
705
+ complete: queue.totals.complete,
706
+ inProgress: queue.totals.inProgress,
707
+ notStarted: queue.totals.notStarted,
708
+ buildableNow: queue.totals.actionable,
709
+ };
710
+ const buildStats = renderBuildStats(stats);
711
+ const pageHead = renderBuildPageHead(model.bs);
712
+
713
+ const subTabs = renderSubTabStrip({
714
+ hashKey: 'build',
715
+ items: [
716
+ { key: 'specs', label: 'Specs', controls: 'build-sub-specs' },
717
+ { key: 'dag', label: 'DAG', controls: 'build-sub-dag' },
718
+ ],
719
+ });
720
+
721
+ const specsPanel = renderBuildSpecsPanel(model, fbsItems, buildableIds);
722
+ const dagLayout = buildDagLayout(fbsItems, buildableIds);
723
+ const dagPanel = renderBuildDag({ layout: dagLayout, inspectorPayload: buildDagInspectorPayload(dagLayout) });
724
+ // Hidden anchor so #BS-001 / #entity=BS-001 still resolve to this tab.
725
+ const bsAnchor = model.bs
726
+ ? `<span class="rcf-build-anchor" data-doc-id="${escapeHtml(model.bs.bsId)}" aria-hidden="true"></span>`
727
+ : '';
703
728
 
704
- function resolveHash(hash) {
705
- if (!hash) { activateTab('overview'); return; }
706
- var raw = hash.charAt(0) === '#' ? hash.slice(1) : hash;
707
- if (raw.indexOf('tab=') === 0) {
708
- var afterTab = raw.slice(4);
709
- var amp = afterTab.indexOf('&');
710
- var tab = amp === -1 ? afterTab : afterTab.slice(0, amp);
711
- var rest = amp === -1 ? '' : afterTab.slice(amp + 1);
712
- activateTab(tab);
713
- if (tab === 'product-map') {
714
- var params = parseHashParams(rest);
715
- var openIds = params.open ? decodeURIComponent(params.open).split(',').filter(Boolean) : [];
716
- activatePmGroup(params.group || 'shape', {
717
- after: function () {
718
- if (params.status) applyPmStatusFilter(params.status);
719
- openPmBuckets(openIds);
720
- },
721
- });
722
- }
723
- return;
724
- }
725
- var target = findByDocId(raw);
726
- if (!target) { activateTab('overview'); return; }
727
- var tab = tabForNode(target);
728
- if (tab) activateTab(tab);
729
- openAncestorDetails(target);
730
- if (target.tagName && target.tagName.toLowerCase() === 'details') target.open = true;
731
- try { target.scrollIntoView({ block: 'start' }); } catch (e) { target.scrollIntoView(); }
732
- }
729
+ return `${bsAnchor}
730
+ ${pageHead}
731
+ ${buildStats}
732
+ ${subTabs}
733
+ <section id="build-sub-specs" class="rcf-build-subpanel" data-rcf-subpanel="specs">
734
+ ${specsPanel}
735
+ </section>
736
+ <section id="build-sub-dag" class="rcf-build-subpanel" data-rcf-subpanel="dag" hidden>
737
+ ${dagPanel}
738
+ </section>`;
739
+ }
733
740
 
734
- function onTabClick(ev) {
735
- var btn = ev.currentTarget;
736
- var name = btn.getAttribute('data-tab');
737
- if (!name) return;
738
- activateTab(name);
739
- if (name === 'product-map') {
740
- // Hydrate whichever grouping was selected last (default: shape).
741
- // No-op when the grouping is already inline.
742
- activatePmGroup(currentGroup());
743
- }
744
- if (window.history && typeof window.history.replaceState === 'function') {
745
- window.history.replaceState(null, '', '#tab=' + name);
746
- }
747
- }
741
+ function renderBuildSpecsPanel(model, fbsItems, buildableIds) {
742
+ const sorted = [...fbsItems].sort((a, b) => (a.buildOrder ?? 0) - (b.buildOrder ?? 0));
743
+ const statusCounts = countBy(sorted, (f) => normaliseFacet(f.executionStatus));
744
+ const areaCounts = countBy(sorted, (f) => normaliseFacet(f.domain));
745
+ const sizeCounts = countBy(sorted, (f) => normaliseFacet(f.estimatedSize));
746
+
747
+ const filterBar = renderFilterBar({
748
+ hashKey: 'build-specs',
749
+ placeholder: 'Filter specs by id or title',
750
+ selects: [
751
+ {
752
+ key: 'status',
753
+ label: 'Status',
754
+ options: [{ value: '', label: 'Any status' }, ...facetOptions(statusCounts)],
755
+ },
756
+ {
757
+ key: 'domain',
758
+ label: 'Area',
759
+ options: [{ value: '', label: 'All areas' }, ...facetOptions(areaCounts)],
760
+ },
761
+ {
762
+ key: 'size',
763
+ label: 'Size',
764
+ options: [{ value: '', label: 'Any size' }, ...facetOptions(sizeCounts)],
765
+ },
766
+ ],
767
+ toggles: [
768
+ { key: 'buildable', label: `Buildable now (${buildableIds.size})` },
769
+ ],
770
+ count: { visible: sorted.length, total: sorted.length },
771
+ showExpandAll: true,
772
+ });
773
+
774
+ const rows = sorted.map((f) => renderSpecRow(f, model, buildableIds)).join('\n');
775
+ return `${filterBar}
776
+ <div class="rcf-build-specs-list" data-rcf-list="build-specs">
777
+ ${rows}
778
+ </div>`;
779
+ }
748
780
 
749
- function wireTabs() {
750
- tabButtons().forEach(function (btn) {
751
- if (btn.__rcfTabWired) return;
752
- btn.__rcfTabWired = true;
753
- btn.addEventListener('click', onTabClick);
754
- });
781
+ function renderSpecRow(fbs, model, buildableIds) {
782
+ const id = fbs.fbsId ?? 'FBS';
783
+ const title = fbs.title ?? '';
784
+ const order = typeof fbs.buildOrder === 'number' ? fbs.buildOrder : '';
785
+ const acCount = Array.isArray(fbs.acIds) ? fbs.acIds.length : 0;
786
+ const depCount = Array.isArray(fbs.dependsOnFbsIds) ? fbs.dependsOnFbsIds.length : 0;
787
+ const depTitle = Array.isArray(fbs.dependsOnFbsIds) ? fbs.dependsOnFbsIds.join(', ') : '';
788
+ const area = normaliseFacet(fbs.domain);
789
+ const size = normaliseFacet(fbs.estimatedSize);
790
+ const status = normaliseFacet(fbs.executionStatus);
791
+ const buildable = buildableIds.has(id);
792
+
793
+ const metaParts = [];
794
+ if (area) metaParts.push(renderBadge({ value: area, variant: 'facet', title: 'Area' }));
795
+ metaParts.push(renderBadge({ value: acCount, label: 'AC', variant: 'count', title: 'Acceptance criteria' }));
796
+ if (depCount > 0) {
797
+ metaParts.push(renderBadge({ value: depCount, label: 'deps', variant: 'count', title: depTitle || 'Dependency count' }));
755
798
  }
799
+ if (size) metaParts.push(renderBadge({ value: size, variant: 'facet', title: 'Estimated size' }));
800
+ if (buildable) metaParts.push(pill({ value: 'buildable', variant: 'build-queue', title: 'No unmet dependencies' }));
801
+ if (status) metaParts.push(pill({ value: status, variant: 'doc-status' }));
802
+ const meta = metaParts.join(' ');
803
+
804
+ const idPrefix = order !== '' ? `<span class="rcf-spec-order mono muted small" title="Build order">${escapeHtml(String(order))}</span>` : '';
805
+ const displayIdHtml = `${idPrefix}<span class="rcf-spec-id mono">${escapeHtml(id)}</span>`;
806
+
807
+ const body = renderSpecBody(fbs, model, model.rawById?.get(id));
808
+ // DocRow HTML-escapes the id arg; a sentinel string lets us swap in
809
+ // live markup (order chip beside the mono id) after the row renders.
810
+ const sentinel = '__RCF_SPEC_ID__';
811
+ const row = renderDocRow({
812
+ id: sentinel,
813
+ title,
814
+ body,
815
+ meta,
816
+ className: 'doc-fbs-wrap rcf-build-row',
817
+ dataDocId: id,
818
+ });
819
+ const dataAttrs = ` data-status="${escapeHtml(status)}" data-domain="${escapeHtml(area)}" data-size="${escapeHtml(size)}" data-buildable="${buildable ? '1' : '0'}" data-text="${escapeHtml(buildSpecFilterText(fbs))}" title="${escapeHtml(id)}"`;
820
+ return row
821
+ .replace(/^<details /, `<details${dataAttrs} `)
822
+ .replace(sentinel, displayIdHtml);
823
+ }
756
824
 
757
- var hashchangeWired = false;
758
- function onReady() {
759
- initMermaid();
760
- wireTabs();
761
- wireProductMap();
762
- resolveHash(window.location.hash);
763
- if (!hashchangeWired) {
764
- hashchangeWired = true;
765
- window.addEventListener('hashchange', function () {
766
- resolveHash(window.location.hash);
767
- });
768
- }
769
- }
825
+ function buildSpecFilterText(fbs) {
826
+ const parts = [fbs.fbsId, fbs.title, fbs.summary, fbs.approach, fbs.domain, fbs.estimatedSize, fbs.executionStatus];
827
+ if (Array.isArray(fbs.deliverables)) parts.push(fbs.deliverables.join(' '));
828
+ if (Array.isArray(fbs.acIds)) parts.push(fbs.acIds.join(' '));
829
+ if (Array.isArray(fbs.dependsOnFbsIds)) parts.push(fbs.dependsOnFbsIds.join(' '));
830
+ return parts.filter((v) => typeof v === 'string' && v.length > 0).join(' ').toLowerCase();
831
+ }
770
832
 
771
- window.rcfPage = window.rcfPage || {};
772
- window.rcfPage.init = onReady;
833
+ /**
834
+ * Viewer UI refresh PR 2 (shell polish, Baz 2026-10-02; decision 17):
835
+ * the one-bar header carries the project name only. The project name
836
+ * is sourced from `manifest.projectName`, then `prd.productName`,
837
+ * then `prd.productTitle` (the PRD title the brief names). Decision 17
838
+ * is explicit: project name prominent after the icon mark, tool name
839
+ * out of the bar, no technology chip. When the tree exposes no
840
+ * project name, the whole product block collapses and the brand icon
841
+ * stands on its own (silent empty state; CSS `.product:empty { display:none; }`).
842
+ *
843
+ * @param {import('./tree-model.js').BuiltTreeModel} model
844
+ * @returns {{ projectName: string | null }}
845
+ */
846
+ export function resolveProductHeader(model) {
847
+ const projectName = model.manifest?.projectName
848
+ ?? model.prd?.productName
849
+ ?? model.prd?.productTitle
850
+ ?? null;
851
+ return { projectName };
852
+ }
773
853
 
774
- if (document.readyState === 'loading') {
775
- document.addEventListener('DOMContentLoaded', onReady);
776
- } else {
777
- onReady();
854
+ function renderProductBlock(header) {
855
+ if (!header.projectName) {
856
+ // No project name: collapse. The empty .product block is hidden
857
+ // by CSS so the brand icon stands on its own and PR 2's silent
858
+ // empty state (brief) survives screen-reader output too.
859
+ return '<div class="product"></div>';
778
860
  }
779
- })();
780
- `.trim();
861
+ return `<div class="product"><span class="name">${escapeHtml(header.projectName)}</span>
862
+ <span class="sub">review surface</span></div>`;
863
+ }
864
+
865
+ function renderErrorBanner(errors) {
866
+ if (!errors || errors.length === 0) return '';
867
+ const count = errors.length;
868
+ const noun = count === 1 ? 'error' : 'errors';
869
+ const items = errors.slice(0, 20).map((e) => {
870
+ const label = e.documentId ? `${e.documentId}` : e.filePath ?? '';
871
+ return `<li><strong>${escapeHtml(e.kind)}</strong> ${escapeHtml(label)} - ${escapeHtml(e.message)}</li>`;
872
+ }).join('');
873
+ const more = count > 20 ? `<p><em>... and ${count - 20} more</em></p>` : '';
874
+ return `<aside class="tree-errors" role="alert">
875
+ <h2>Tree has ${count} ${noun}</h2>
876
+ <ul>${items}</ul>
877
+ ${more}
878
+ </aside>`;
781
879
  }