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.
- package/CHANGELOG.md +107 -1
- package/bin/rcf.js +4 -0
- package/fixtures/canary-manifest.json +9 -9
- package/guidance/define-draft-shapes.md +89 -0
- package/guidance/define-intake.md +97 -0
- package/guidance/define-intent.md +107 -0
- package/guidance/discover-prototype.md +67 -0
- package/guidance/discovery-prototypes.md +69 -0
- package/guidance/harness-template.md +34 -0
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +34 -0
- package/guidance/manifest.json +9 -2
- package/package.json +1 -1
- package/rcf/adrs/adr-4124-define-freeze-verb.json +30 -0
- package/rcf/adrs/adr-4126-persona-tagged-blockers-two-levels.json +40 -0
- package/rcf/adrs/adr-4127-readiness-tab-order-and-register.json +40 -0
- package/rcf/adrs/adr-4129-questions-harness-driven-loop.json +25 -0
- package/rcf/adrs/adr-4130-intake-harness-split-tool-scanned.json +20 -0
- package/rcf/adrs/adr-4132-prototypes-in-discovery-as-evidence.json +20 -0
- package/rcf/adrs/adr-4133-dag-dependency-depth-layout.json +40 -0
- package/rcf/adrs/adr-4134-index-json-route.json +35 -0
- package/rcf/adrs/adr-4135-readiness-po-register-and-adapter.json +35 -0
- package/rcf/adrs/adr-4136-embedding-host-fixture.json +35 -0
- package/rcf/code-nodes/cn-725.json +18 -2
- package/rcf/code-nodes/cn-726.json +10 -2
- package/rcf/code-nodes/cn-727.json +13 -4
- package/rcf/code-nodes/cn-728.json +18 -2
- package/rcf/code-nodes/cn-729.json +10 -2
- package/rcf/code-nodes/cn-730.json +13 -3
- package/rcf/code-nodes/cn-731.json +20 -0
- package/rcf/code-nodes/cn-732.json +14 -0
- package/rcf/code-nodes/cn-733.json +14 -0
- package/rcf/code-nodes/cn-734.json +14 -0
- package/rcf/code-nodes/cn-735.json +20 -0
- package/rcf/code-nodes/cn-736.json +14 -0
- package/rcf/code-nodes/cn-737.json +14 -0
- package/rcf/code-nodes/cn-738.json +14 -0
- package/rcf/code-nodes/cn-739.json +18 -0
- package/rcf/code-nodes/cn-740.json +19 -0
- package/rcf/code-nodes/cn-741.json +16 -0
- package/rcf/code-nodes/cn-742.json +16 -0
- package/rcf/code-nodes/cn-743.json +15 -0
- package/rcf/code-nodes/cn-744.json +22 -0
- package/rcf/code-nodes/cn-745.json +16 -0
- package/rcf/code-nodes/cn-746.json +14 -0
- package/rcf/code-nodes/cn-747.json +18 -0
- package/rcf/code-nodes/cn-748.json +16 -0
- package/rcf/code-nodes/cn-749.json +16 -0
- package/rcf/code-nodes/cn-750.json +15 -0
- package/rcf/code-nodes/cn-751.json +18 -0
- package/rcf/code-nodes/cn-752.json +14 -0
- package/rcf/fbs/fbs-196.json +13 -5
- package/rcf/fbs/fbs-198.json +42 -0
- package/rcf/fbs/fbs-199.json +59 -0
- package/rcf/fbs/fbs-200.json +46 -0
- package/rcf/fbs/fbs-201.json +49 -0
- package/rcf/fbs/fbs-202.json +110 -0
- package/rcf/fbs/fbs-203.json +100 -0
- package/rcf/requirements/req-002.json +2 -2
- package/rcf/requirements/req-173.json +2 -2
- package/rcf/requirements/req-174.json +10 -4
- package/rcf/requirements/req-175.json +10 -4
- package/rcf/requirements/req-176.json +25 -0
- package/rcf/requirements/req-180.json +31 -0
- package/rcf/requirements/req-181.json +31 -0
- package/rcf/requirements/req-182.json +25 -0
- package/rcf/requirements/req-186.json +25 -0
- package/rcf/requirements/req-187.json +31 -0
- package/rcf/requirements/req-188.json +25 -0
- package/rcf/tacs/tac-4121-define-ledgers.json +15 -3
- package/rcf/tacs/tac-4122-define-gates.json +40 -4
- package/rcf/tacs/tac-4123-define-readiness.json +39 -3
- package/rcf/tacs/tac-4124-define-freeze.json +28 -0
- package/rcf/tacs/tac-4127-readiness-view.json +49 -0
- package/rcf/tacs/tac-4128-define-guidance.json +53 -0
- package/rcf/tacs/tac-4129-define-questions.json +45 -0
- package/rcf/tacs/tac-4130-define-intake-brief.json +35 -0
- package/rcf/tacs/tac-4131-build-dag-view.json +39 -0
- package/rcf/tacs/tac-4132-id-lookup.json +42 -0
- package/rcf/tacs/tac-4133-readiness-po-layer.json +37 -0
- package/rcf/tacs/tac-4134-wespa-host-fixture.json +54 -0
- package/rcf/test-suites/ts-016.json +3 -3
- package/rcf/test-suites/ts-215.json +148 -4
- package/rcf/test-suites/ts-216.json +19 -3
- package/rcf/test-suites/ts-218.json +78 -0
- package/rcf/test-suites/ts-219.json +70 -0
- package/rcf/test-suites/ts-220.json +78 -0
- package/rcf/test-suites/ts-221.json +78 -0
- package/rcf/test-suites/ts-222.json +100 -0
- package/rcf/test-suites/ts-223.json +94 -0
- package/rcf/test-suites/ts-224.json +70 -0
- package/rcf/test-suites/ts-225.json +78 -0
- package/rcf/test-suites/ts-226.json +158 -0
- package/rcf/test-suites/ts-227.json +38 -0
- package/rcf/test-suites/ts-228.json +86 -0
- package/rcf/test-suites/ts-229.json +83 -0
- package/rcf/user-stories/us-17302.json +50 -0
- package/rcf/user-stories/us-17401.json +14 -2
- package/rcf/user-stories/us-17402.json +55 -0
- package/rcf/user-stories/us-17403.json +60 -0
- package/rcf/user-stories/us-17502.json +60 -0
- package/rcf/user-stories/us-17503.json +65 -0
- package/rcf/user-stories/us-17601.json +58 -0
- package/rcf/user-stories/us-18001.json +55 -0
- package/rcf/user-stories/us-18002.json +35 -0
- package/rcf/user-stories/us-18101.json +35 -0
- package/rcf/user-stories/us-18202.json +40 -0
- package/rcf/user-stories/us-18601.json +55 -0
- package/rcf/user-stories/us-18602.json +50 -0
- package/rcf/user-stories/us-18603.json +45 -0
- package/rcf/user-stories/us-18701.json +50 -0
- package/rcf/user-stories/us-18702.json +50 -0
- package/rcf/user-stories/us-18801.json +45 -0
- package/rcf/user-stories/us-204.json +59 -0
- package/rcf/user-stories/us-205.json +64 -0
- package/rcf/user-stories/us-206.json +54 -0
- package/releases/releases.yaml +11 -1
- package/src/cli/freeze.js +730 -0
- package/src/cli/help.js +12 -0
- package/src/cli/ledger.js +228 -12
- package/src/cli/questions.js +288 -0
- package/src/cli/readiness.js +267 -49
- package/src/cli/view.js +29 -0
- package/src/define/freeze-record.js +30 -15
- package/src/define/ledgers.js +210 -11
- package/src/intake/index.js +1 -1
- package/src/intake/orchestrator.js +117 -0
- package/src/mcp/tools.js +354 -0
- package/src/query/delta.js +6 -0
- package/src/query/gates.js +314 -39
- package/src/query/index.js +16 -2
- package/src/query/questions.js +438 -0
- package/src/query/readiness.js +248 -7
- package/src/server/index.js +24 -2
- package/src/server/routes.js +68 -1
- package/src/setup/identity-seed.js +17 -0
- package/src/view/build-dag.js +563 -0
- package/src/view/components/badge.js +41 -0
- package/src/view/components/diff.js +72 -0
- package/src/view/components/doc-row.js +62 -0
- package/src/view/components/empty-state.js +35 -0
- package/src/view/components/entity-selector.js +137 -0
- package/src/view/components/filter-bar.js +97 -0
- package/src/view/components/findings-list.js +49 -0
- package/src/view/components/index.js +12 -0
- package/src/view/components/pill.js +46 -0
- package/src/view/components/sub-tab-strip.js +43 -0
- package/src/view/components/toast.js +25 -0
- package/src/view/components-fixture.js +265 -0
- package/src/view/doc-renderers/architecture.js +304 -0
- package/src/view/doc-renderers/build.js +160 -0
- package/src/view/doc-renderers/index.js +2 -0
- package/src/view/doc-renderers/prd.js +35 -6
- package/src/view/html-page.js +700 -602
- package/src/view/index.js +96 -3
- package/src/view/live-client.js +39 -6
- package/src/view/needs-work.js +79 -0
- package/src/view/page-init.js +2193 -0
- package/src/view/readiness/phrasebook.js +115 -0
- package/src/view/readiness/po-layer.js +288 -0
- package/src/view/readiness/question-adapter.js +329 -0
- package/src/view/readiness/stage-legend.js +71 -0
- package/src/view/readiness.js +505 -0
- package/src/view/style.css +2017 -382
- package/src/view/test-host/embed-client.js +121 -0
- package/src/view/test-host/fixture.js +183 -0
- package/src/view/test-host/lints.js +220 -0
- package/src/view/view-index.js +323 -0
package/src/view/html-page.js
CHANGED
|
@@ -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.
|
|
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
|
|
20
|
-
|
|
38
|
+
renderBuildPageHead,
|
|
39
|
+
renderBuildStats,
|
|
21
40
|
renderPrd,
|
|
22
|
-
|
|
41
|
+
renderSpecBody,
|
|
23
42
|
renderTac,
|
|
24
|
-
|
|
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
|
|
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
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
<
|
|
91
|
-
<
|
|
135
|
+
<footer class="app-footer">
|
|
136
|
+
<span class="ver">RCF Lite ${escapeHtml(RCF_LITE_VERSION)}</span>
|
|
137
|
+
<span class="sep" aria-hidden="true">·</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">·</span>
|
|
140
|
+
<a href="https://stravica.ai/docs/rcf/" target="_blank" rel="noopener">RCF Lite docs</a>
|
|
141
|
+
<span class="sep" aria-hidden="true">·</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…</span>
|
|
146
|
+
</span>
|
|
92
147
|
</footer>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
<script src="
|
|
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
|
|
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-
|
|
130
|
-
<h2 class="tab-heading">
|
|
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
|
|
218
|
+
<h2 class="tab-heading">Build</h2>
|
|
149
219
|
${buildPanel}
|
|
150
220
|
</section>`;
|
|
151
221
|
}
|
|
152
222
|
|
|
153
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
}
|
|
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 `${
|
|
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
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
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
|
-
|
|
250
|
-
const
|
|
251
|
-
?
|
|
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
|
-
|
|
297
|
-
${
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
}
|
|
522
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
772
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
}
|