rcf-lite 0.28.2 → 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 (327) hide show
  1. package/CHANGELOG.md +317 -0
  2. package/bin/rcf.js +8 -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-4120-canonical-json-hashing.json +30 -0
  15. package/rcf/adrs/adr-4121-define-sidecars.json +30 -0
  16. package/rcf/adrs/adr-4122-define-gates-blocking-and-optout.json +30 -0
  17. package/rcf/adrs/adr-4123-readiness-single-object.json +30 -0
  18. package/rcf/adrs/adr-4124-define-freeze-verb.json +30 -0
  19. package/rcf/adrs/adr-4126-persona-tagged-blockers-two-levels.json +40 -0
  20. package/rcf/adrs/adr-4127-readiness-tab-order-and-register.json +40 -0
  21. package/rcf/adrs/adr-4129-questions-harness-driven-loop.json +25 -0
  22. package/rcf/adrs/adr-4130-intake-harness-split-tool-scanned.json +20 -0
  23. package/rcf/adrs/adr-4132-prototypes-in-discovery-as-evidence.json +20 -0
  24. package/rcf/adrs/adr-4133-dag-dependency-depth-layout.json +40 -0
  25. package/rcf/adrs/adr-4134-index-json-route.json +35 -0
  26. package/rcf/adrs/adr-4135-readiness-po-register-and-adapter.json +35 -0
  27. package/rcf/adrs/adr-4136-embedding-host-fixture.json +35 -0
  28. package/rcf/code-nodes/cn-700.json +23 -0
  29. package/rcf/code-nodes/cn-701.json +14 -0
  30. package/rcf/code-nodes/cn-702.json +14 -0
  31. package/rcf/code-nodes/cn-703.json +16 -0
  32. package/rcf/code-nodes/cn-720.json +15 -0
  33. package/rcf/code-nodes/cn-721.json +15 -0
  34. package/rcf/code-nodes/cn-722.json +15 -0
  35. package/rcf/code-nodes/cn-723.json +18 -0
  36. package/rcf/code-nodes/cn-724.json +14 -0
  37. package/rcf/code-nodes/cn-725.json +37 -0
  38. package/rcf/code-nodes/cn-726.json +25 -0
  39. package/rcf/code-nodes/cn-727.json +23 -0
  40. package/rcf/code-nodes/cn-728.json +37 -0
  41. package/rcf/code-nodes/cn-729.json +26 -0
  42. package/rcf/code-nodes/cn-730.json +24 -0
  43. package/rcf/code-nodes/cn-731.json +20 -0
  44. package/rcf/code-nodes/cn-732.json +14 -0
  45. package/rcf/code-nodes/cn-733.json +14 -0
  46. package/rcf/code-nodes/cn-734.json +14 -0
  47. package/rcf/code-nodes/cn-735.json +20 -0
  48. package/rcf/code-nodes/cn-736.json +14 -0
  49. package/rcf/code-nodes/cn-737.json +14 -0
  50. package/rcf/code-nodes/cn-738.json +14 -0
  51. package/rcf/code-nodes/cn-739.json +18 -0
  52. package/rcf/code-nodes/cn-740.json +19 -0
  53. package/rcf/code-nodes/cn-741.json +16 -0
  54. package/rcf/code-nodes/cn-742.json +16 -0
  55. package/rcf/code-nodes/cn-743.json +15 -0
  56. package/rcf/code-nodes/cn-744.json +22 -0
  57. package/rcf/code-nodes/cn-745.json +16 -0
  58. package/rcf/code-nodes/cn-746.json +14 -0
  59. package/rcf/code-nodes/cn-747.json +18 -0
  60. package/rcf/code-nodes/cn-748.json +16 -0
  61. package/rcf/code-nodes/cn-749.json +16 -0
  62. package/rcf/code-nodes/cn-750.json +15 -0
  63. package/rcf/code-nodes/cn-751.json +18 -0
  64. package/rcf/code-nodes/cn-752.json +14 -0
  65. package/rcf/fbs/fbs-190.json +37 -0
  66. package/rcf/fbs/fbs-191.json +34 -0
  67. package/rcf/fbs/fbs-192.json +17 -0
  68. package/rcf/fbs/fbs-193.json +37 -0
  69. package/rcf/fbs/fbs-194.json +35 -0
  70. package/rcf/fbs/fbs-195.json +37 -0
  71. package/rcf/fbs/fbs-196.json +45 -0
  72. package/rcf/fbs/fbs-197.json +41 -0
  73. package/rcf/fbs/fbs-198.json +42 -0
  74. package/rcf/fbs/fbs-199.json +59 -0
  75. package/rcf/fbs/fbs-200.json +46 -0
  76. package/rcf/fbs/fbs-201.json +49 -0
  77. package/rcf/fbs/fbs-202.json +110 -0
  78. package/rcf/fbs/fbs-203.json +100 -0
  79. package/rcf/requirements/req-001.json +2 -1
  80. package/rcf/requirements/req-002.json +3 -2
  81. package/rcf/requirements/req-003.json +2 -1
  82. package/rcf/requirements/req-004.json +2 -1
  83. package/rcf/requirements/req-005.json +2 -1
  84. package/rcf/requirements/req-006.json +2 -1
  85. package/rcf/requirements/req-007.json +2 -1
  86. package/rcf/requirements/req-008.json +2 -1
  87. package/rcf/requirements/req-009.json +2 -1
  88. package/rcf/requirements/req-010.json +5 -2
  89. package/rcf/requirements/req-011.json +2 -1
  90. package/rcf/requirements/req-012.json +2 -1
  91. package/rcf/requirements/req-013.json +2 -1
  92. package/rcf/requirements/req-014.json +2 -1
  93. package/rcf/requirements/req-015.json +2 -1
  94. package/rcf/requirements/req-016.json +5 -2
  95. package/rcf/requirements/req-017.json +6 -2
  96. package/rcf/requirements/req-018.json +5 -2
  97. package/rcf/requirements/req-019.json +5 -2
  98. package/rcf/requirements/req-020.json +5 -2
  99. package/rcf/requirements/req-021.json +5 -2
  100. package/rcf/requirements/req-022.json +5 -2
  101. package/rcf/requirements/req-023.json +6 -4
  102. package/rcf/requirements/req-024.json +5 -2
  103. package/rcf/requirements/req-025.json +5 -2
  104. package/rcf/requirements/req-026.json +5 -2
  105. package/rcf/requirements/req-027.json +5 -2
  106. package/rcf/requirements/req-028.json +5 -2
  107. package/rcf/requirements/req-029.json +5 -2
  108. package/rcf/requirements/req-030.json +5 -2
  109. package/rcf/requirements/req-040.json +5 -2
  110. package/rcf/requirements/req-041.json +5 -2
  111. package/rcf/requirements/req-042.json +5 -2
  112. package/rcf/requirements/req-043.json +5 -2
  113. package/rcf/requirements/req-050.json +5 -2
  114. package/rcf/requirements/req-051.json +5 -2
  115. package/rcf/requirements/req-052.json +5 -2
  116. package/rcf/requirements/req-053.json +5 -2
  117. package/rcf/requirements/req-054.json +5 -2
  118. package/rcf/requirements/req-060.json +5 -2
  119. package/rcf/requirements/req-061.json +5 -2
  120. package/rcf/requirements/req-062.json +5 -2
  121. package/rcf/requirements/req-063.json +5 -2
  122. package/rcf/requirements/req-070.json +5 -2
  123. package/rcf/requirements/req-071.json +5 -2
  124. package/rcf/requirements/req-072.json +5 -2
  125. package/rcf/requirements/req-073.json +5 -2
  126. package/rcf/requirements/req-074.json +5 -2
  127. package/rcf/requirements/req-075.json +5 -2
  128. package/rcf/requirements/req-076.json +5 -2
  129. package/rcf/requirements/req-077.json +5 -2
  130. package/rcf/requirements/req-080.json +5 -2
  131. package/rcf/requirements/req-081.json +5 -2
  132. package/rcf/requirements/req-082.json +5 -2
  133. package/rcf/requirements/req-083.json +5 -2
  134. package/rcf/requirements/req-084.json +5 -2
  135. package/rcf/requirements/req-085.json +5 -2
  136. package/rcf/requirements/req-086.json +5 -2
  137. package/rcf/requirements/req-090.json +5 -2
  138. package/rcf/requirements/req-091.json +5 -2
  139. package/rcf/requirements/req-092.json +5 -2
  140. package/rcf/requirements/req-093.json +5 -2
  141. package/rcf/requirements/req-094.json +5 -2
  142. package/rcf/requirements/req-100.json +5 -2
  143. package/rcf/requirements/req-101.json +5 -2
  144. package/rcf/requirements/req-102.json +5 -2
  145. package/rcf/requirements/req-103.json +5 -2
  146. package/rcf/requirements/req-104.json +5 -2
  147. package/rcf/requirements/req-110.json +5 -2
  148. package/rcf/requirements/req-111.json +5 -2
  149. package/rcf/requirements/req-112.json +5 -2
  150. package/rcf/requirements/req-113.json +5 -2
  151. package/rcf/requirements/req-114.json +5 -2
  152. package/rcf/requirements/req-115.json +5 -2
  153. package/rcf/requirements/req-120.json +5 -2
  154. package/rcf/requirements/req-121.json +5 -2
  155. package/rcf/requirements/req-122.json +5 -2
  156. package/rcf/requirements/req-123.json +5 -2
  157. package/rcf/requirements/req-124.json +5 -2
  158. package/rcf/requirements/req-125.json +5 -2
  159. package/rcf/requirements/req-130.json +6 -2
  160. package/rcf/requirements/req-131.json +6 -2
  161. package/rcf/requirements/req-132.json +6 -2
  162. package/rcf/requirements/req-133.json +6 -2
  163. package/rcf/requirements/req-134.json +6 -2
  164. package/rcf/requirements/req-140.json +5 -2
  165. package/rcf/requirements/req-145.json +5 -2
  166. package/rcf/requirements/req-150.json +6 -2
  167. package/rcf/requirements/req-151.json +6 -2
  168. package/rcf/requirements/req-152.json +6 -2
  169. package/rcf/requirements/req-153.json +6 -2
  170. package/rcf/requirements/req-154.json +5 -2
  171. package/rcf/requirements/req-155.json +4 -1
  172. package/rcf/requirements/req-156.json +4 -1
  173. package/rcf/requirements/req-157.json +4 -1
  174. package/rcf/requirements/req-158.json +4 -1
  175. package/rcf/requirements/req-159.json +4 -1
  176. package/rcf/requirements/req-160.json +4 -1
  177. package/rcf/requirements/req-161.json +4 -1
  178. package/rcf/requirements/req-162.json +4 -1
  179. package/rcf/requirements/req-163.json +5 -2
  180. package/rcf/requirements/req-164.json +5 -2
  181. package/rcf/requirements/req-165.json +5 -2
  182. package/rcf/requirements/req-170.json +33 -0
  183. package/rcf/requirements/req-171.json +44 -0
  184. package/rcf/requirements/req-172.json +25 -0
  185. package/rcf/requirements/req-173.json +25 -0
  186. package/rcf/requirements/req-174.json +31 -0
  187. package/rcf/requirements/req-175.json +31 -0
  188. package/rcf/requirements/req-176.json +25 -0
  189. package/rcf/requirements/req-180.json +31 -0
  190. package/rcf/requirements/req-181.json +31 -0
  191. package/rcf/requirements/req-182.json +25 -0
  192. package/rcf/requirements/req-186.json +25 -0
  193. package/rcf/requirements/req-187.json +31 -0
  194. package/rcf/requirements/req-188.json +25 -0
  195. package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
  196. package/rcf/tacs/tac-4121-define-ledgers.json +35 -0
  197. package/rcf/tacs/tac-4122-define-gates.json +61 -0
  198. package/rcf/tacs/tac-4123-define-readiness.json +61 -0
  199. package/rcf/tacs/tac-4124-define-freeze.json +28 -0
  200. package/rcf/tacs/tac-4127-readiness-view.json +49 -0
  201. package/rcf/tacs/tac-4128-define-guidance.json +53 -0
  202. package/rcf/tacs/tac-4129-define-questions.json +45 -0
  203. package/rcf/tacs/tac-4130-define-intake-brief.json +35 -0
  204. package/rcf/tacs/tac-4131-build-dag-view.json +39 -0
  205. package/rcf/tacs/tac-4132-id-lookup.json +42 -0
  206. package/rcf/tacs/tac-4133-readiness-po-layer.json +37 -0
  207. package/rcf/tacs/tac-4134-wespa-host-fixture.json +54 -0
  208. package/rcf/test-suites/ts-016.json +3 -3
  209. package/rcf/test-suites/ts-204.json +30 -0
  210. package/rcf/test-suites/ts-205.json +22 -0
  211. package/rcf/test-suites/ts-206.json +22 -0
  212. package/rcf/test-suites/ts-207.json +22 -0
  213. package/rcf/test-suites/ts-208.json +22 -0
  214. package/rcf/test-suites/ts-209.json +22 -0
  215. package/rcf/test-suites/ts-210.json +22 -0
  216. package/rcf/test-suites/ts-211.json +22 -0
  217. package/rcf/test-suites/ts-212.json +22 -0
  218. package/rcf/test-suites/ts-213.json +50 -0
  219. package/rcf/test-suites/ts-214.json +74 -0
  220. package/rcf/test-suites/ts-215.json +211 -0
  221. package/rcf/test-suites/ts-216.json +94 -0
  222. package/rcf/test-suites/ts-217.json +62 -0
  223. package/rcf/test-suites/ts-218.json +78 -0
  224. package/rcf/test-suites/ts-219.json +70 -0
  225. package/rcf/test-suites/ts-220.json +78 -0
  226. package/rcf/test-suites/ts-221.json +78 -0
  227. package/rcf/test-suites/ts-222.json +100 -0
  228. package/rcf/test-suites/ts-223.json +94 -0
  229. package/rcf/test-suites/ts-224.json +70 -0
  230. package/rcf/test-suites/ts-225.json +78 -0
  231. package/rcf/test-suites/ts-226.json +158 -0
  232. package/rcf/test-suites/ts-227.json +38 -0
  233. package/rcf/test-suites/ts-228.json +86 -0
  234. package/rcf/test-suites/ts-229.json +83 -0
  235. package/rcf/user-stories/us-15601.json +4 -4
  236. package/rcf/user-stories/us-17001.json +27 -0
  237. package/rcf/user-stories/us-17002.json +22 -0
  238. package/rcf/user-stories/us-17003.json +22 -0
  239. package/rcf/user-stories/us-17004.json +22 -0
  240. package/rcf/user-stories/us-17005.json +22 -0
  241. package/rcf/user-stories/us-17006.json +21 -0
  242. package/rcf/user-stories/us-17007.json +21 -0
  243. package/rcf/user-stories/us-17008.json +22 -0
  244. package/rcf/user-stories/us-17101.json +22 -0
  245. package/rcf/user-stories/us-17102.json +22 -0
  246. package/rcf/user-stories/us-17201.json +38 -0
  247. package/rcf/user-stories/us-17301.json +38 -0
  248. package/rcf/user-stories/us-17302.json +50 -0
  249. package/rcf/user-stories/us-17401.json +70 -0
  250. package/rcf/user-stories/us-17402.json +55 -0
  251. package/rcf/user-stories/us-17403.json +60 -0
  252. package/rcf/user-stories/us-17501.json +48 -0
  253. package/rcf/user-stories/us-17502.json +60 -0
  254. package/rcf/user-stories/us-17503.json +65 -0
  255. package/rcf/user-stories/us-17601.json +58 -0
  256. package/rcf/user-stories/us-18001.json +55 -0
  257. package/rcf/user-stories/us-18002.json +35 -0
  258. package/rcf/user-stories/us-18101.json +35 -0
  259. package/rcf/user-stories/us-18202.json +40 -0
  260. package/rcf/user-stories/us-18601.json +55 -0
  261. package/rcf/user-stories/us-18602.json +50 -0
  262. package/rcf/user-stories/us-18603.json +45 -0
  263. package/rcf/user-stories/us-18701.json +50 -0
  264. package/rcf/user-stories/us-18702.json +50 -0
  265. package/rcf/user-stories/us-18801.json +45 -0
  266. package/rcf/user-stories/us-204.json +59 -0
  267. package/rcf/user-stories/us-205.json +64 -0
  268. package/rcf/user-stories/us-206.json +54 -0
  269. package/releases/releases.yaml +31 -1
  270. package/src/cli/feedback.js +126 -10
  271. package/src/cli/freeze.js +730 -0
  272. package/src/cli/help.js +23 -0
  273. package/src/cli/ledger.js +615 -0
  274. package/src/cli/questions.js +288 -0
  275. package/src/cli/readiness.js +498 -0
  276. package/src/cli/view.js +29 -0
  277. package/src/core/store/writer.js +86 -6
  278. package/src/define/freeze-record.js +247 -0
  279. package/src/define/ledgers.js +712 -0
  280. package/src/feedback/redact.js +105 -19
  281. package/src/intake/index.js +1 -1
  282. package/src/intake/orchestrator.js +117 -0
  283. package/src/mcp/tools.js +354 -0
  284. package/src/query/delta.js +282 -0
  285. package/src/query/gates.js +1107 -0
  286. package/src/query/index.js +50 -0
  287. package/src/query/questions.js +438 -0
  288. package/src/query/readiness.js +574 -0
  289. package/src/server/index.js +60 -1
  290. package/src/server/routes.js +133 -4
  291. package/src/setup/identity-seed.js +17 -0
  292. package/src/view/build-dag.js +563 -0
  293. package/src/view/components/badge.js +41 -0
  294. package/src/view/components/diff.js +72 -0
  295. package/src/view/components/doc-row.js +62 -0
  296. package/src/view/components/empty-state.js +35 -0
  297. package/src/view/components/entity-selector.js +137 -0
  298. package/src/view/components/filter-bar.js +97 -0
  299. package/src/view/components/findings-list.js +49 -0
  300. package/src/view/components/index.js +12 -0
  301. package/src/view/components/pill.js +46 -0
  302. package/src/view/components/sub-tab-strip.js +43 -0
  303. package/src/view/components/toast.js +25 -0
  304. package/src/view/components-fixture.js +265 -0
  305. package/src/view/doc-renderers/architecture.js +304 -0
  306. package/src/view/doc-renderers/build.js +160 -0
  307. package/src/view/doc-renderers/helpers.js +2 -2
  308. package/src/view/doc-renderers/index.js +2 -0
  309. package/src/view/doc-renderers/prd.js +35 -6
  310. package/src/view/doc-renderers/req.js +10 -2
  311. package/src/view/doc-renderers/user-story.js +10 -5
  312. package/src/view/html-page.js +717 -300
  313. package/src/view/index.js +109 -3
  314. package/src/view/live-client.js +61 -8
  315. package/src/view/needs-work.js +79 -0
  316. package/src/view/page-init.js +2193 -0
  317. package/src/view/product-map.js +751 -0
  318. package/src/view/readiness/phrasebook.js +115 -0
  319. package/src/view/readiness/po-layer.js +288 -0
  320. package/src/view/readiness/question-adapter.js +329 -0
  321. package/src/view/readiness/stage-legend.js +71 -0
  322. package/src/view/readiness.js +505 -0
  323. package/src/view/style.css +2279 -266
  324. package/src/view/test-host/embed-client.js +121 -0
  325. package/src/view/test-host/fixture.js +183 -0
  326. package/src/view/test-host/lints.js +220 -0
  327. package/src/view/view-index.js +323 -0
@@ -4,29 +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';
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;
30
69
 
31
70
  // Inline SVG favicon: the Stravica kit mark (deep-navy tile with the
32
71
  // ribbon symbol in Stravica blue), taken from the estate brand kit at
@@ -46,9 +85,9 @@ const LIVE_WRAPPER_CLOSE = '</div>';
46
85
  * @returns {string}
47
86
  */
48
87
  export function renderPage(model) {
49
- const projectName = model.manifest?.projectName ?? model.prd?.productName ?? 'RCF project';
88
+ const header = resolveProductHeader(model);
89
+ const projectName = header.projectName ?? 'RCF project';
50
90
  const contentHtml = renderContent(model);
51
- const script = inlineScript();
52
91
 
53
92
  return `<!DOCTYPE html>
54
93
  <html lang="en-GB">
@@ -57,7 +96,7 @@ export function renderPage(model) {
57
96
  <meta name="viewport" content="width=device-width, initial-scale=1">
58
97
  <title>${escapeHtml(projectName)} - RCF review surface</title>
59
98
  <link rel="icon" type="image/svg+xml" href="${FAVICON_HREF}">
60
- <link rel="stylesheet" href="style.css">
99
+ <link rel="stylesheet" href="./style.css">
61
100
  <noscript>
62
101
  <style>
63
102
  /* Progressive enhancement per D12: no JS -> every tabpanel is
@@ -69,28 +108,48 @@ export function renderPage(model) {
69
108
  </noscript>
70
109
  </head>
71
110
  <body>
72
- <header>
73
- <h1>${escapeHtml(projectName)}</h1>
74
- <p class="subtitle">RCF review surface</p>
75
- <nav class="tabs" role="tablist" aria-label="Document sections">
76
- <button type="button" role="tab" data-tab="overview" aria-selected="true" aria-controls="tab-overview">Overview</button>
77
- <button type="button" role="tab" data-tab="requirements" aria-selected="false" aria-controls="tab-requirements">Requirements</button>
78
- <button type="button" role="tab" data-tab="architecture" aria-selected="false" aria-controls="tab-architecture">Architecture</button>
79
- <button type="button" role="tab" data-tab="build" aria-selected="false" aria-controls="tab-build">Build sequence</button>
80
- </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>
81
129
  </header>
82
130
  <main>
83
131
  ${LIVE_WRAPPER_OPEN}
84
132
  ${contentHtml}
85
133
  ${LIVE_WRAPPER_CLOSE}
86
134
  </main>
87
- <footer>
88
- <p>Generated from the on-disk RCF tree at <code>rcf/</code>. Read-only; changes on disk stream to this tab automatically.</p>
89
- <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>
90
147
  </footer>
91
- <script src="mermaid.min.js"></script>
92
- <script>${script}</script>
93
- <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>
94
153
  </body>
95
154
  </html>
96
155
  `;
@@ -107,28 +166,46 @@ export function renderPage(model) {
107
166
  */
108
167
  export function renderContent(model) {
109
168
  const subdiagrams = allRequirementSubdiagrams(model);
169
+ const needsWorkIds = computeReqNeedsWorkIds(model.readiness);
110
170
 
111
171
  const prdSection = model.prd
112
172
  ? renderPrd(model.prd, {
113
173
  raw: model.rawById.get(model.prd.prdId),
114
174
  errors: model.errorsById.get(model.prd.prdId),
115
175
  requirementIds: model.childrenByParent.get(model.prd.prdId) ?? [],
176
+ suppressRequirementList: true,
177
+ collapsibleLists: true,
116
178
  })
117
179
  : '<p><em>No PRD on disk.</em></p>';
118
180
 
119
- const requirementsPanel = renderRequirementsPanel(model, subdiagrams);
181
+ const prdSelector = renderPrdRequirementSelector(model);
182
+ const requirementsPanel = renderRequirementsPanel(model, subdiagrams, needsWorkIds);
120
183
  const architecturePanel = renderArchitecturePanel(model);
121
184
  const buildPanel = renderBuildPanel(model);
185
+ const productMapPanel = renderProductMapPanel(model);
186
+ const readinessPanel = renderReadinessPanel(model.readiness ?? null, {
187
+ profile: model.profileText ?? null,
188
+ freezeRecord: model.freezeRecord ?? null,
189
+ });
122
190
 
123
191
  const errorBanner = renderErrorBanner(model.errors ?? []);
124
192
 
125
193
  return `${errorBanner}
126
- <section id="tab-overview" role="tabpanel" aria-labelledby="tab-overview-button">
127
- <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>
128
200
  <div class="prd-body">
129
201
  ${prdSection}
202
+ ${prdSelector}
130
203
  </div>
131
204
  </section>
205
+ <section id="tab-product-map" role="tabpanel" hidden>
206
+ <h2 class="tab-heading">Product Map</h2>
207
+ ${productMapPanel}
208
+ </section>
132
209
  <section id="tab-requirements" role="tabpanel" hidden>
133
210
  <h2 class="tab-heading">Requirements</h2>
134
211
  ${requirementsPanel}
@@ -138,48 +215,73 @@ export function renderContent(model) {
138
215
  ${architecturePanel}
139
216
  </section>
140
217
  <section id="tab-build" role="tabpanel" hidden>
141
- <h2 class="tab-heading">Build sequence</h2>
218
+ <h2 class="tab-heading">Build</h2>
142
219
  ${buildPanel}
143
220
  </section>`;
144
221
  }
145
222
 
146
- 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) {
147
243
  if (model.requirements.length === 0 && model.userStories.length === 0) {
148
- return '<p><em>No requirements on disk.</em></p>';
149
- }
150
- const reqBlocks = model.requirements.map((r) => {
151
- const reqBody = renderReq(r, {
152
- raw: model.rawById.get(r.reqId),
153
- errors: model.errorsById.get(r.reqId),
154
- subdiagram: subdiagrams.get(r.reqId),
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.',
155
247
  });
156
- const stories = model.storiesByReqId.get(r.reqId) ?? [];
157
- const usBlocks = stories.map((u) => detailsWrap({
158
- id: u.usId,
159
- summary: `${u.usId} - ${u.title ?? ''}`,
160
- className: 'doc-us-wrap',
161
- status: u.status,
162
- body: renderUserStory(u, {
163
- raw: model.rawById.get(u.usId),
164
- errors: model.errorsById.get(u.usId),
165
- fbsByAcId: model.fbsByAcId,
166
- }),
167
- })).join('\n');
168
- const storiesSection = usBlocks
169
- ? `<section class="nested-details"><h4>User stories</h4>${usBlocks}</section>`
170
- : '<p><em>No user stories under this requirement.</em></p>';
171
- return detailsWrap({
172
- id: r.reqId,
173
- summary: `${r.reqId} - ${r.title ?? ''}`,
174
- className: 'doc-req-wrap',
175
- status: r.status,
176
- body: `${reqBody}\n${storiesSection}`,
177
- });
178
- }).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');
179
281
 
180
282
  const orphanUs = model.userStories.filter((u) => !u.reqId || !model.requirements.some((r) => r.reqId === u.reqId));
181
283
  const orphanBlock = orphanUs.length > 0
182
- ? `<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({
183
285
  id: u.usId,
184
286
  summary: `${u.usId} - ${u.title ?? ''}`,
185
287
  className: 'doc-us-wrap',
@@ -192,271 +294,586 @@ function renderRequirementsPanel(model, subdiagrams) {
192
294
  })).join('\n')}</section>`
193
295
  : '';
194
296
 
195
- return `${reqBlocks}\n${orphanBlock}`;
297
+ return `${filterBar}
298
+ <div class="rcf-requirements-list" data-rcf-list="requirements">
299
+ ${reqRows}
300
+ </div>
301
+ ${orphanBlock}`;
302
+ }
303
+
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
+ : '';
338
+
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>`
342
+ : '';
343
+
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} `);
363
+ }
364
+
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
+ });
196
382
  }
197
383
 
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);
389
+ }
390
+ return parts.filter((v) => typeof v === 'string' && v.length > 0).join(' ').toLowerCase();
391
+ }
392
+
393
+ function normaliseFacet(v) {
394
+ if (typeof v !== 'string') return '';
395
+ return v.trim();
396
+ }
397
+
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);
404
+ }
405
+ return out;
406
+ }
407
+
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
+ }
413
+
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
+ */
198
474
  function renderArchitecturePanel(model) {
199
- const tadChildren = model.tad ? (model.childrenByParent.get(model.tad.tadId) ?? []) : [];
200
- const componentIds = tadChildren.filter((id) => id.startsWith('TAC-'));
201
- const architecturalDecisionIds = tadChildren.filter((id) => id.startsWith('ADR-'));
475
+ const pageHead = renderArchitecturePageHead(model);
202
476
  const tadSection = model.tad
203
- ? renderTad(model.tad, {
204
- raw: model.rawById.get(model.tad.tadId),
205
- errors: model.errorsById.get(model.tad.tadId),
206
- componentIds,
207
- architecturalDecisionIds,
208
- })
209
- : '<p><em>No TAD on disk.</em></p>';
210
-
211
- const tacBlocks = model.tacs.map((t) => detailsWrap({
212
- id: t.tacId,
213
- summary: `${t.tacId} - ${t.name ?? ''}`,
214
- className: 'doc-tac-wrap',
215
- status: t.status,
216
- body: renderTac(t, {
217
- raw: model.rawById.get(t.tacId),
218
- errors: model.errorsById.get(t.tacId),
219
- }),
220
- })).join('\n');
221
-
222
- const adrBlocks = model.adrs.map((a) => detailsWrap({
223
- id: a.adrId,
224
- summary: `${a.adrId} - ${a.title ?? ''}`,
225
- className: 'doc-adr-wrap',
226
- status: a.status,
227
- body: renderAdr(a, {
228
- raw: model.rawById.get(a.adrId),
229
- errors: model.errorsById.get(a.adrId),
230
- }),
231
- })).join('\n');
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.',
481
+ });
482
+
483
+ const tacPanel = renderComponentsPanel(model);
484
+ const adrPanel = renderDecisionsPanel(model);
485
+
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
+ : '';
491
+
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
+ : '';
232
496
 
233
497
  return `
234
- ${tadSection}
235
- <h3 class="group-heading">Components</h3>
236
- ${tacBlocks || '<p><em>No TAC components on disk.</em></p>'}
237
- <h3 class="group-heading">Architectural decisions</h3>
238
- ${adrBlocks || '<p><em>No ADRs on disk.</em></p>'}
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}
239
507
  `;
240
508
  }
241
509
 
242
- function renderBuildPanel(model) {
243
- const bsSlots = model.bs
244
- ? [...model.fbsItems]
245
- .filter((f) => f.bsId === model.bs.bsId)
246
- .sort((a, b) => (a.buildOrder ?? 0) - (b.buildOrder ?? 0))
247
- .map((f) => ({
248
- fbsId: f.fbsId,
249
- buildOrder: f.buildOrder,
250
- executionStatus: f.executionStatus,
251
- title: f.title,
252
- }))
253
- : [];
254
- const bsSection = model.bs
255
- ? renderBuildSequence(model.bs, {
256
- raw: model.rawById.get(model.bs.bsId),
257
- errors: model.errorsById.get(model.bs.bsId),
258
- slots: bsSlots,
259
- })
260
- : '<p><em>No build sequence on disk.</em></p>';
261
-
262
- const fbsBlocks = model.fbsItems.map((f) => detailsWrap({
263
- id: f.fbsId,
264
- summary: `${f.fbsId} - ${f.title ?? ''}`,
265
- className: 'doc-fbs-wrap',
266
- status: f.executionStatus,
267
- body: renderFbs(f, {
268
- raw: model.rawById.get(f.fbsId),
269
- errors: model.errorsById.get(f.fbsId),
270
- usByAcId: model.usByAcId,
271
- }),
272
- })).join('\n');
273
-
274
- const tsBlocks = model.testSuites.map((ts) => detailsWrap({
275
- id: ts.id,
276
- summary: `${ts.id} - ${ts.title ?? 'test suite'}`,
277
- className: 'doc-ts-wrap',
278
- status: ts.status,
279
- body: renderTestSuite(ts, {
280
- raw: model.rawById.get(ts.id),
281
- errors: model.errorsById.get(ts.id),
282
- }),
283
- })).join('\n');
284
-
285
- const tsSection = tsBlocks
286
- ? `<h3 class="group-heading">Test suites</h3>${tsBlocks}`
510
+ function renderArchitecturePageHead(model) {
511
+ if (!model.tad) {
512
+ return `<div class="rcf-architecture-head"><h3 class="rcf-architecture-head-title">Architecture</h3></div>`;
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
+ }
524
+
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.' })
287
545
  : '';
288
546
 
289
- return `
290
- ${bsSection}
291
- <h3 class="group-heading">Functional Build Specifications</h3>
292
- ${fbsBlocks || '<p><em>No FBS items on disk.</em></p>'}
293
- ${tsSection}
294
- `;
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>`;
295
555
  }
296
556
 
297
- function renderErrorBanner(errors) {
298
- if (!errors || errors.length === 0) return '';
299
- const count = errors.length;
300
- const noun = count === 1 ? 'error' : 'errors';
301
- const items = errors.slice(0, 20).map((e) => {
302
- const label = e.documentId ? `${e.documentId}` : e.filePath ?? '';
303
- return `<li><strong>${escapeHtml(e.kind)}</strong> ${escapeHtml(label)} - ${escapeHtml(e.message)}</li>`;
304
- }).join('');
305
- const more = count > 20 ? `<p><em>... and ${count - 20} more</em></p>` : '';
306
- return `<aside class="tree-errors" role="alert">
307
- <h2>Tree has ${count} ${noun}</h2>
308
- <ul>${items}</ul>
309
- ${more}
310
- </aside>`;
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
+ : '';
578
+
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>`;
311
587
  }
312
588
 
313
- // Inline client-side script: initialises Mermaid, wires tab switching,
314
- // resolves hash routes to a tab + opens any ancestor `<details>` on the
315
- // target. Vanilla; no dependencies beyond the vendored Mermaid loaded above.
316
- // Phase 3.8 note: this script is byte-identical to the Phase 3.6 shape
317
- // (the layout-regression test guards it). Live-swap recovery lives
318
- // entirely in `src/view/live-client.js`, which reads state from the DOM
319
- // after each SSE innerHTML swap - it does not call into this IIFE.
320
- function inlineScript() {
321
- return `
322
- (function () {
323
- var TABS = ['overview', 'requirements', 'architecture', 'build'];
324
-
325
- function initMermaid() {
326
- if (typeof window.mermaid !== 'undefined') {
327
- // startOnLoad: false. Mermaid can't lay out diagrams inside a
328
- // display:none tabpanel (getBoundingClientRect is 0x0 -> NaN
329
- // transforms). We run each tab's diagrams on activation instead;
330
- // runMermaidIn is idempotent via the data-processed attribute.
331
- window.mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' });
332
- }
333
- }
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
+ }
334
618
 
335
- function runMermaidIn(container) {
336
- if (!container || typeof window.mermaid === 'undefined') return;
337
- var pending = container.querySelectorAll('.mermaid:not([data-processed="true"])');
338
- if (pending.length === 0) return;
339
- try {
340
- window.mermaid.run({ nodes: Array.prototype.slice.call(pending) });
341
- } catch (e) {
342
- // Mermaid.run may throw on init errors; do not block the UI.
343
- }
344
- }
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
+ }
345
643
 
346
- function tabButtons() {
347
- return Array.prototype.slice.call(document.querySelectorAll('[role="tab"]'));
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);
348
649
  }
349
-
350
- function panelFor(name) {
351
- return document.getElementById('tab-' + name);
650
+ if (Array.isArray(tac.dependencies)) {
651
+ for (const d of tac.dependencies) parts.push(d?.name, d?.description);
352
652
  }
653
+ return parts.filter((v) => typeof v === 'string' && v.length > 0).join(' ').toLowerCase();
654
+ }
353
655
 
354
- function activateTab(name) {
355
- if (TABS.indexOf(name) === -1) return false;
356
- tabButtons().forEach(function (btn) {
357
- var isTarget = btn.getAttribute('data-tab') === name;
358
- btn.setAttribute('aria-selected', isTarget ? 'true' : 'false');
359
- });
360
- TABS.forEach(function (t) {
361
- var p = panelFor(t);
362
- if (!p) return;
363
- if (t === name) {
364
- p.removeAttribute('hidden');
365
- runMermaidIn(p);
366
- } else {
367
- p.setAttribute('hidden', '');
368
- }
369
- });
370
- return true;
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);
371
660
  }
661
+ return parts.filter((v) => typeof v === 'string' && v.length > 0).join(' ').toLowerCase();
662
+ }
372
663
 
373
- function tabForNode(node) {
374
- var cur = node;
375
- while (cur && cur !== document.body) {
376
- if (cur.getAttribute && cur.getAttribute('role') === 'tabpanel') {
377
- var id = cur.id || '';
378
- if (id.indexOf('tab-') === 0) return id.slice(4);
379
- }
380
- cur = cur.parentNode;
381
- }
382
- return null;
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
+ });
383
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
+ : '';
384
728
 
385
- function openAncestorDetails(node) {
386
- var cur = node.parentNode;
387
- while (cur && cur !== document.body) {
388
- if (cur.tagName && cur.tagName.toLowerCase() === 'details') {
389
- cur.open = true;
390
- }
391
- cur = cur.parentNode;
392
- }
393
- }
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
+ }
394
740
 
395
- function findByDocId(id) {
396
- if (!id) return null;
397
- var byId = document.getElementById(id);
398
- if (byId) return byId;
399
- return document.querySelector('[data-doc-id="' + id.replace(/"/g, '\\\\"') + '"]');
400
- }
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
+ }
401
780
 
402
- function resolveHash(hash) {
403
- if (!hash) {
404
- activateTab('overview');
405
- return;
406
- }
407
- var raw = hash.charAt(0) === '#' ? hash.slice(1) : hash;
408
- if (raw.indexOf('tab=') === 0) {
409
- activateTab(raw.slice(4));
410
- return;
411
- }
412
- var target = findByDocId(raw);
413
- if (!target) {
414
- activateTab('overview');
415
- return;
416
- }
417
- var tab = tabForNode(target);
418
- if (tab) activateTab(tab);
419
- openAncestorDetails(target);
420
- if (target.tagName && target.tagName.toLowerCase() === 'details') {
421
- target.open = true;
422
- }
423
- try {
424
- target.scrollIntoView({ block: 'start' });
425
- } catch (e) {
426
- target.scrollIntoView();
427
- }
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' }));
428
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
+ }
429
824
 
430
- function onTabClick(ev) {
431
- var btn = ev.currentTarget;
432
- var name = btn.getAttribute('data-tab');
433
- if (!name) return;
434
- activateTab(name);
435
- if (window.history && typeof window.history.replaceState === 'function') {
436
- window.history.replaceState(null, '', '#tab=' + name);
437
- }
438
- }
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
+ }
439
832
 
440
- function wireTabs() {
441
- tabButtons().forEach(function (btn) {
442
- btn.addEventListener('click', onTabClick);
443
- });
444
- }
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
+ }
445
853
 
446
- function onReady() {
447
- initMermaid();
448
- wireTabs();
449
- resolveHash(window.location.hash);
450
- window.addEventListener('hashchange', function () {
451
- resolveHash(window.location.hash);
452
- });
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>';
453
860
  }
861
+ return `<div class="product"><span class="name">${escapeHtml(header.projectName)}</span>
862
+ <span class="sub">review surface</span></div>`;
863
+ }
454
864
 
455
- if (document.readyState === 'loading') {
456
- document.addEventListener('DOMContentLoaded', onReady);
457
- } else {
458
- onReady();
459
- }
460
- })();
461
- `.trim();
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>`;
462
879
  }