rcf-lite 0.28.2 → 0.28.4

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 (196) hide show
  1. package/CHANGELOG.md +211 -0
  2. package/bin/rcf.js +4 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/package.json +1 -1
  5. package/rcf/adrs/adr-4120-canonical-json-hashing.json +30 -0
  6. package/rcf/adrs/adr-4121-define-sidecars.json +30 -0
  7. package/rcf/adrs/adr-4122-define-gates-blocking-and-optout.json +30 -0
  8. package/rcf/adrs/adr-4123-readiness-single-object.json +30 -0
  9. package/rcf/code-nodes/cn-700.json +23 -0
  10. package/rcf/code-nodes/cn-701.json +14 -0
  11. package/rcf/code-nodes/cn-702.json +14 -0
  12. package/rcf/code-nodes/cn-703.json +16 -0
  13. package/rcf/code-nodes/cn-720.json +15 -0
  14. package/rcf/code-nodes/cn-721.json +15 -0
  15. package/rcf/code-nodes/cn-722.json +15 -0
  16. package/rcf/code-nodes/cn-723.json +18 -0
  17. package/rcf/code-nodes/cn-724.json +14 -0
  18. package/rcf/code-nodes/cn-725.json +21 -0
  19. package/rcf/code-nodes/cn-726.json +17 -0
  20. package/rcf/code-nodes/cn-727.json +14 -0
  21. package/rcf/code-nodes/cn-728.json +21 -0
  22. package/rcf/code-nodes/cn-729.json +18 -0
  23. package/rcf/code-nodes/cn-730.json +14 -0
  24. package/rcf/fbs/fbs-190.json +37 -0
  25. package/rcf/fbs/fbs-191.json +34 -0
  26. package/rcf/fbs/fbs-192.json +17 -0
  27. package/rcf/fbs/fbs-193.json +37 -0
  28. package/rcf/fbs/fbs-194.json +35 -0
  29. package/rcf/fbs/fbs-195.json +37 -0
  30. package/rcf/fbs/fbs-196.json +37 -0
  31. package/rcf/fbs/fbs-197.json +41 -0
  32. package/rcf/requirements/req-001.json +2 -1
  33. package/rcf/requirements/req-002.json +2 -1
  34. package/rcf/requirements/req-003.json +2 -1
  35. package/rcf/requirements/req-004.json +2 -1
  36. package/rcf/requirements/req-005.json +2 -1
  37. package/rcf/requirements/req-006.json +2 -1
  38. package/rcf/requirements/req-007.json +2 -1
  39. package/rcf/requirements/req-008.json +2 -1
  40. package/rcf/requirements/req-009.json +2 -1
  41. package/rcf/requirements/req-010.json +5 -2
  42. package/rcf/requirements/req-011.json +2 -1
  43. package/rcf/requirements/req-012.json +2 -1
  44. package/rcf/requirements/req-013.json +2 -1
  45. package/rcf/requirements/req-014.json +2 -1
  46. package/rcf/requirements/req-015.json +2 -1
  47. package/rcf/requirements/req-016.json +5 -2
  48. package/rcf/requirements/req-017.json +6 -2
  49. package/rcf/requirements/req-018.json +5 -2
  50. package/rcf/requirements/req-019.json +5 -2
  51. package/rcf/requirements/req-020.json +5 -2
  52. package/rcf/requirements/req-021.json +5 -2
  53. package/rcf/requirements/req-022.json +5 -2
  54. package/rcf/requirements/req-023.json +6 -4
  55. package/rcf/requirements/req-024.json +5 -2
  56. package/rcf/requirements/req-025.json +5 -2
  57. package/rcf/requirements/req-026.json +5 -2
  58. package/rcf/requirements/req-027.json +5 -2
  59. package/rcf/requirements/req-028.json +5 -2
  60. package/rcf/requirements/req-029.json +5 -2
  61. package/rcf/requirements/req-030.json +5 -2
  62. package/rcf/requirements/req-040.json +5 -2
  63. package/rcf/requirements/req-041.json +5 -2
  64. package/rcf/requirements/req-042.json +5 -2
  65. package/rcf/requirements/req-043.json +5 -2
  66. package/rcf/requirements/req-050.json +5 -2
  67. package/rcf/requirements/req-051.json +5 -2
  68. package/rcf/requirements/req-052.json +5 -2
  69. package/rcf/requirements/req-053.json +5 -2
  70. package/rcf/requirements/req-054.json +5 -2
  71. package/rcf/requirements/req-060.json +5 -2
  72. package/rcf/requirements/req-061.json +5 -2
  73. package/rcf/requirements/req-062.json +5 -2
  74. package/rcf/requirements/req-063.json +5 -2
  75. package/rcf/requirements/req-070.json +5 -2
  76. package/rcf/requirements/req-071.json +5 -2
  77. package/rcf/requirements/req-072.json +5 -2
  78. package/rcf/requirements/req-073.json +5 -2
  79. package/rcf/requirements/req-074.json +5 -2
  80. package/rcf/requirements/req-075.json +5 -2
  81. package/rcf/requirements/req-076.json +5 -2
  82. package/rcf/requirements/req-077.json +5 -2
  83. package/rcf/requirements/req-080.json +5 -2
  84. package/rcf/requirements/req-081.json +5 -2
  85. package/rcf/requirements/req-082.json +5 -2
  86. package/rcf/requirements/req-083.json +5 -2
  87. package/rcf/requirements/req-084.json +5 -2
  88. package/rcf/requirements/req-085.json +5 -2
  89. package/rcf/requirements/req-086.json +5 -2
  90. package/rcf/requirements/req-090.json +5 -2
  91. package/rcf/requirements/req-091.json +5 -2
  92. package/rcf/requirements/req-092.json +5 -2
  93. package/rcf/requirements/req-093.json +5 -2
  94. package/rcf/requirements/req-094.json +5 -2
  95. package/rcf/requirements/req-100.json +5 -2
  96. package/rcf/requirements/req-101.json +5 -2
  97. package/rcf/requirements/req-102.json +5 -2
  98. package/rcf/requirements/req-103.json +5 -2
  99. package/rcf/requirements/req-104.json +5 -2
  100. package/rcf/requirements/req-110.json +5 -2
  101. package/rcf/requirements/req-111.json +5 -2
  102. package/rcf/requirements/req-112.json +5 -2
  103. package/rcf/requirements/req-113.json +5 -2
  104. package/rcf/requirements/req-114.json +5 -2
  105. package/rcf/requirements/req-115.json +5 -2
  106. package/rcf/requirements/req-120.json +5 -2
  107. package/rcf/requirements/req-121.json +5 -2
  108. package/rcf/requirements/req-122.json +5 -2
  109. package/rcf/requirements/req-123.json +5 -2
  110. package/rcf/requirements/req-124.json +5 -2
  111. package/rcf/requirements/req-125.json +5 -2
  112. package/rcf/requirements/req-130.json +6 -2
  113. package/rcf/requirements/req-131.json +6 -2
  114. package/rcf/requirements/req-132.json +6 -2
  115. package/rcf/requirements/req-133.json +6 -2
  116. package/rcf/requirements/req-134.json +6 -2
  117. package/rcf/requirements/req-140.json +5 -2
  118. package/rcf/requirements/req-145.json +5 -2
  119. package/rcf/requirements/req-150.json +6 -2
  120. package/rcf/requirements/req-151.json +6 -2
  121. package/rcf/requirements/req-152.json +6 -2
  122. package/rcf/requirements/req-153.json +6 -2
  123. package/rcf/requirements/req-154.json +5 -2
  124. package/rcf/requirements/req-155.json +4 -1
  125. package/rcf/requirements/req-156.json +4 -1
  126. package/rcf/requirements/req-157.json +4 -1
  127. package/rcf/requirements/req-158.json +4 -1
  128. package/rcf/requirements/req-159.json +4 -1
  129. package/rcf/requirements/req-160.json +4 -1
  130. package/rcf/requirements/req-161.json +4 -1
  131. package/rcf/requirements/req-162.json +4 -1
  132. package/rcf/requirements/req-163.json +5 -2
  133. package/rcf/requirements/req-164.json +5 -2
  134. package/rcf/requirements/req-165.json +5 -2
  135. package/rcf/requirements/req-170.json +33 -0
  136. package/rcf/requirements/req-171.json +44 -0
  137. package/rcf/requirements/req-172.json +25 -0
  138. package/rcf/requirements/req-173.json +25 -0
  139. package/rcf/requirements/req-174.json +25 -0
  140. package/rcf/requirements/req-175.json +25 -0
  141. package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
  142. package/rcf/tacs/tac-4121-define-ledgers.json +23 -0
  143. package/rcf/tacs/tac-4122-define-gates.json +25 -0
  144. package/rcf/tacs/tac-4123-define-readiness.json +25 -0
  145. package/rcf/test-suites/ts-204.json +30 -0
  146. package/rcf/test-suites/ts-205.json +22 -0
  147. package/rcf/test-suites/ts-206.json +22 -0
  148. package/rcf/test-suites/ts-207.json +22 -0
  149. package/rcf/test-suites/ts-208.json +22 -0
  150. package/rcf/test-suites/ts-209.json +22 -0
  151. package/rcf/test-suites/ts-210.json +22 -0
  152. package/rcf/test-suites/ts-211.json +22 -0
  153. package/rcf/test-suites/ts-212.json +22 -0
  154. package/rcf/test-suites/ts-213.json +50 -0
  155. package/rcf/test-suites/ts-214.json +74 -0
  156. package/rcf/test-suites/ts-215.json +67 -0
  157. package/rcf/test-suites/ts-216.json +78 -0
  158. package/rcf/test-suites/ts-217.json +62 -0
  159. package/rcf/user-stories/us-15601.json +4 -4
  160. package/rcf/user-stories/us-17001.json +27 -0
  161. package/rcf/user-stories/us-17002.json +22 -0
  162. package/rcf/user-stories/us-17003.json +22 -0
  163. package/rcf/user-stories/us-17004.json +22 -0
  164. package/rcf/user-stories/us-17005.json +22 -0
  165. package/rcf/user-stories/us-17006.json +21 -0
  166. package/rcf/user-stories/us-17007.json +21 -0
  167. package/rcf/user-stories/us-17008.json +22 -0
  168. package/rcf/user-stories/us-17101.json +22 -0
  169. package/rcf/user-stories/us-17102.json +22 -0
  170. package/rcf/user-stories/us-17201.json +38 -0
  171. package/rcf/user-stories/us-17301.json +38 -0
  172. package/rcf/user-stories/us-17401.json +58 -0
  173. package/rcf/user-stories/us-17501.json +48 -0
  174. package/releases/releases.yaml +21 -1
  175. package/src/cli/feedback.js +126 -10
  176. package/src/cli/help.js +11 -0
  177. package/src/cli/ledger.js +399 -0
  178. package/src/cli/readiness.js +280 -0
  179. package/src/core/store/writer.js +86 -6
  180. package/src/define/freeze-record.js +232 -0
  181. package/src/define/ledgers.js +513 -0
  182. package/src/feedback/redact.js +105 -19
  183. package/src/query/delta.js +276 -0
  184. package/src/query/gates.js +832 -0
  185. package/src/query/index.js +36 -0
  186. package/src/query/readiness.js +333 -0
  187. package/src/server/index.js +37 -0
  188. package/src/server/routes.js +65 -3
  189. package/src/view/doc-renderers/helpers.js +2 -2
  190. package/src/view/doc-renderers/req.js +10 -2
  191. package/src/view/doc-renderers/user-story.js +10 -5
  192. package/src/view/html-page.js +347 -28
  193. package/src/view/index.js +14 -1
  194. package/src/view/live-client.js +22 -2
  195. package/src/view/product-map.js +751 -0
  196. package/src/view/style.css +378 -0
@@ -0,0 +1,751 @@
1
+ // Product Map tab: re-buckets the review-surface tree model by product-
2
+ // shaped axes rather than the flat REQ list the Requirements tab shows.
3
+ // Five re-bucketings + a status filter, all applied to the same
4
+ // in-memory BuiltTreeModel. Pure over the tree model - no I/O, no
5
+ // schema change, no walker work. Groupings ride on the maps the walker
6
+ // already computes (childrenByParent, storiesByReqId, usByTacId,
7
+ // acIdsByUsId, tsByAcId, tcsByAcId, fbsByAcId), on the REQ document's
8
+ // own tags[] and shapeClassification.shapes[] fields, and (for the
9
+ // by-blueprint grouping added under AC-17008-1) on
10
+ // manifest.blueprints[].contributions[] carried on model.manifest.
11
+ //
12
+ // Spec: projects/rcf-lite-wsd/docs/2026-09-21_product-map-proposal.md.
13
+ // Chain: REQ-170 (tab) + REQ-171 (capability tag convention) + US-17008
14
+ // (blueprint badge and by-blueprint grouping, AC-17008-1).
15
+ //
16
+ // Interaction model (AC-17006-1): every bucket is a <details class="pm-bucket">
17
+ // collapsed by default, with a summary row carrying label + total count
18
+ // + per-status mini pills. A jump-nav chip row lists every bucket in
19
+ // the active grouping and clicking a chip opens + scrolls to it. Open-
20
+ // bucket state is encoded in the hash and persisted through SSE swaps
21
+ // via a stable per-bucket data-doc-id.
22
+ //
23
+ // Lazy render (AC-17007-1): the server emits only the DEFAULT grouping's
24
+ // REQ cards fully. The other groupings emit only their bucket headings
25
+ // (label + counts + status pills) with data-pm-lazy="true" on the
26
+ // grouping section. The client fetches /product-map/<group> on first
27
+ // activation and replaces the grouping's innerHTML in place.
28
+ //
29
+ // Blueprint attribution (AC-17008-1): every REQ row an applied blueprint
30
+ // contributed carries a pm-blueprint-badge in its summary in EVERY
31
+ // grouping. The source of truth is manifest.blueprints[].contributions[]
32
+ // (parsed by scope.contributionsForBlueprint on the applied record).
33
+ // When a manifest carries a blueprint record without contributions[]
34
+ // (older applied records that predate the field, or hand-authored
35
+ // manifests), the id-prefix fallback derives the slug from the reqId's
36
+ // leading kebab-slug segment when that slug matches an applied blueprint.
37
+
38
+ import { detailsWrap, escapeHtml } from './doc-renderers/helpers.js';
39
+ import { renderReq, renderUserStory } from './doc-renderers/index.js';
40
+ import { contributionsForBlueprint } from './scope.js';
41
+
42
+ export const GROUPINGS = ['shape', 'component', 'trace', 'capability', 'blueprint'];
43
+ export const STATUSES = ['all', 'draft', 'review', 'needsRevision', 'approved', 'superseded'];
44
+ export const DEFAULT_GROUPING = 'shape';
45
+ export const DEFAULT_STATUS = 'all';
46
+ // Status ids that get a mini pill in the bucket summary. "all" is the
47
+ // select's default option, not a real status, so it is intentionally
48
+ // absent here.
49
+ export const STATUS_MINI = ['draft', 'review', 'needsRevision', 'approved', 'superseded'];
50
+
51
+ const SHAPE_ORDER = ['webUi', 'httpApi', 'auth', 'persistence', 'notifications', 'none', 'unclassified'];
52
+ const SHAPE_LABELS = {
53
+ webUi: 'Web UI',
54
+ httpApi: 'HTTP API',
55
+ auth: 'Auth',
56
+ persistence: 'Persistence',
57
+ notifications: 'Notifications',
58
+ none: 'Business rules',
59
+ unclassified: 'Unclassified',
60
+ };
61
+
62
+ // Bucket id used for the project's own (non-blueprint) REQs inside the
63
+ // by-blueprint grouping. The label is "Application" (Baz 2026-09-22);
64
+ // the id is stable so hashes and data-doc-id anchors round-trip.
65
+ const PROJECT_BUCKET_ID = 'application';
66
+ const PROJECT_BUCKET_LABEL = 'Application';
67
+
68
+ export function shapesForReq(req) {
69
+ const shapes = req?.shapeClassification?.shapes;
70
+ if (!Array.isArray(shapes) || shapes.length === 0) return ['unclassified'];
71
+ return [...shapes];
72
+ }
73
+
74
+ export function groupByShape(model) {
75
+ const buckets = new Map();
76
+ for (const shape of SHAPE_ORDER) buckets.set(shape, []);
77
+ for (const req of model.requirements) {
78
+ const seen = new Set();
79
+ for (const shape of shapesForReq(req)) {
80
+ if (seen.has(shape)) continue;
81
+ seen.add(shape);
82
+ const list = buckets.get(shape) ?? [];
83
+ list.push(req);
84
+ buckets.set(shape, list);
85
+ }
86
+ }
87
+ return SHAPE_ORDER
88
+ .map((id) => ({ id, label: SHAPE_LABELS[id], reqs: buckets.get(id) ?? [] }))
89
+ .filter((b) => b.reqs.length > 0);
90
+ }
91
+
92
+ export function groupByComponent(model) {
93
+ const reachedReqIds = new Set();
94
+ const tacRows = [];
95
+ for (const tac of model.tacs) {
96
+ const usIds = model.usByTacId?.get(tac.tacId) ?? [];
97
+ const stories = [];
98
+ for (const usId of usIds) {
99
+ const us = model.userStories.find((u) => u.usId === usId);
100
+ if (!us) continue;
101
+ const req = model.requirements.find((r) => r.reqId === us.reqId) ?? null;
102
+ if (req) reachedReqIds.add(req.reqId);
103
+ stories.push({ us, req });
104
+ }
105
+ const dependencies = Array.isArray(tac.dependencies)
106
+ ? tac.dependencies.map((d) => (typeof d === 'string' ? d : d?.name ?? '')).filter(Boolean)
107
+ : [];
108
+ tacRows.push({
109
+ tacId: tac.tacId,
110
+ name: tac.name ?? '',
111
+ dependencies,
112
+ stories,
113
+ });
114
+ }
115
+ const unmapped = model.requirements.filter((r) => !reachedReqIds.has(r.reqId));
116
+ return { tacs: tacRows, unmapped };
117
+ }
118
+
119
+ export function groupByTraceCoverage(model) {
120
+ const buckets = {
121
+ 'missing-stories': [],
122
+ 'missing-ac-scope': [],
123
+ 'missing-component': [],
124
+ 'missing-tests': [],
125
+ complete: [],
126
+ };
127
+ const usIdsReachingRealTac = new Set();
128
+ for (const usIds of model.usByTacId?.values() ?? []) {
129
+ for (const usId of usIds) usIdsReachingRealTac.add(usId);
130
+ }
131
+ for (const req of model.requirements) {
132
+ const stories = model.storiesByReqId.get(req.reqId) ?? [];
133
+ if (stories.length === 0) {
134
+ buckets['missing-stories'].push({ req, missing: 'no user stories under this REQ' });
135
+ continue;
136
+ }
137
+ let acScopeGap = null;
138
+ for (const us of stories) {
139
+ const acIds = model.acIdsByUsId.get(us.usId) ?? [];
140
+ if (acIds.length === 0) { acScopeGap = us.usId; break; }
141
+ }
142
+ if (acScopeGap) {
143
+ buckets['missing-ac-scope'].push({ req, missing: `no ACs on ${acScopeGap}` });
144
+ continue;
145
+ }
146
+ const hasTac = stories.some((us) => usIdsReachingRealTac.has(us.usId));
147
+ if (!hasTac) {
148
+ buckets['missing-component'].push({ req, missing: 'no story links to a real TAC (tacIds[] empty or dangling on every US)' });
149
+ continue;
150
+ }
151
+ let missingTests = null;
152
+ for (const us of stories) {
153
+ const acIds = model.acIdsByUsId.get(us.usId) ?? [];
154
+ for (const acId of acIds) {
155
+ const tsList = model.tsByAcId?.get(acId) ?? [];
156
+ const tcList = model.tcsByAcId?.get(acId) ?? [];
157
+ if (tsList.length === 0 && tcList.length === 0) {
158
+ missingTests = acId;
159
+ break;
160
+ }
161
+ }
162
+ if (missingTests) break;
163
+ }
164
+ if (missingTests) {
165
+ buckets['missing-tests'].push({ req, missing: `no TS/TC covers ${missingTests}` });
166
+ continue;
167
+ }
168
+ buckets.complete.push({ req, missing: '' });
169
+ }
170
+ const labels = {
171
+ 'missing-stories': 'Missing stories',
172
+ 'missing-ac-scope': 'Missing AC scope',
173
+ 'missing-component': 'Missing component owner',
174
+ 'missing-tests': 'Missing tests',
175
+ complete: 'Complete',
176
+ };
177
+ return Object.entries(buckets)
178
+ .map(([id, entries]) => ({ id, label: labels[id], entries }))
179
+ .filter((b) => b.entries.length > 0);
180
+ }
181
+
182
+ export function capabilitiesForReq(req) {
183
+ const tags = Array.isArray(req?.tags) ? req.tags : [];
184
+ return tags
185
+ .filter((t) => typeof t === 'string' && t.startsWith('capability:'))
186
+ .map((t) => t.slice('capability:'.length))
187
+ .filter(Boolean);
188
+ }
189
+
190
+ export function groupByCapability(model) {
191
+ const bySlug = new Map();
192
+ const unclassified = [];
193
+ for (const req of model.requirements) {
194
+ const slugs = capabilitiesForReq(req);
195
+ if (slugs.length === 0) { unclassified.push(req); continue; }
196
+ const seen = new Set();
197
+ for (const slug of slugs) {
198
+ if (seen.has(slug)) continue;
199
+ seen.add(slug);
200
+ const list = bySlug.get(slug) ?? [];
201
+ list.push(req);
202
+ bySlug.set(slug, list);
203
+ }
204
+ }
205
+ const sorted = [...bySlug.entries()]
206
+ .map(([slug, reqs]) => ({ id: slug, label: slug, reqs }))
207
+ .sort((a, b) => {
208
+ if (b.reqs.length !== a.reqs.length) return b.reqs.length - a.reqs.length;
209
+ return a.id.localeCompare(b.id);
210
+ });
211
+ if (unclassified.length > 0) {
212
+ sorted.push({ id: 'unclassified', label: 'Unclassified', reqs: unclassified });
213
+ }
214
+ return sorted;
215
+ }
216
+
217
+ /**
218
+ * Build the reqId -> blueprint-slug attribution map from a walked model.
219
+ * Truth is manifest.blueprints[].contributions[] (each entry names the
220
+ * kind + stamped id + on-disk path); we filter to kind === 'req'. When a
221
+ * blueprint record carries no contributions[] we fall back to matching
222
+ * the reqId's leading kebab-slug segment against the applied blueprint
223
+ * slugs so older applied records still light up the badge.
224
+ *
225
+ * @param {import('./tree-model.js').BuiltTreeModel} model
226
+ * @returns {Map<string, string>} reqId -> blueprint slug
227
+ */
228
+ export function buildBlueprintAttribution(model) {
229
+ const attribution = new Map();
230
+ const manifest = model?.manifest ?? null;
231
+ const applied = Array.isArray(manifest?.blueprints) ? manifest.blueprints : [];
232
+ const appliedSlugs = new Set();
233
+ for (const record of applied) {
234
+ if (typeof record?.slug !== 'string' || record.slug.length === 0) continue;
235
+ appliedSlugs.add(record.slug);
236
+ const { contributionIds } = contributionsForBlueprint(manifest, record.slug);
237
+ if (!Array.isArray(contributionIds) || contributionIds.length === 0) continue;
238
+ // contributionIds is every stamped contribution id (any kind); the
239
+ // renderer only cares about REQs, so intersect with the tree's REQ ids.
240
+ for (const id of contributionIds) {
241
+ if (typeof id !== 'string') continue;
242
+ // Keep first-writer-wins: if the same REQ id somehow appears in
243
+ // more than one blueprint's contributions (should not happen
244
+ // under apply.js's stamping rule, but defensive), the first
245
+ // record's slug sticks so the badge is stable.
246
+ if (!attribution.has(id)) attribution.set(id, record.slug);
247
+ }
248
+ }
249
+ // id-prefix fallback: only kick in when a REQ has no direct match
250
+ // above. We look for the longest applied-slug prefix on the reqId
251
+ // (case-sensitive kebab), then confirm the next character is '-' so
252
+ // "auth" does not match "authentication".
253
+ if (appliedSlugs.size > 0) {
254
+ const sortedSlugs = [...appliedSlugs].sort((a, b) => b.length - a.length);
255
+ for (const req of model.requirements) {
256
+ const id = req?.reqId;
257
+ if (typeof id !== 'string') continue;
258
+ if (attribution.has(id)) continue;
259
+ for (const slug of sortedSlugs) {
260
+ if (id.length > slug.length + 1 && id.startsWith(slug) && id[slug.length] === '-') {
261
+ attribution.set(id, slug);
262
+ break;
263
+ }
264
+ }
265
+ }
266
+ }
267
+ return attribution;
268
+ }
269
+
270
+ /**
271
+ * Build the by-blueprint buckets. One bucket per entry in
272
+ * manifest.blueprints[] plus an Application bucket for the project's own (non-blueprint)
273
+ * REQs (REQs with no attribution). Blueprint buckets are ordered by
274
+ * REQ count desc then by slug alphabetically; Project is always last.
275
+ * When the manifest has no applied blueprints, only the Project bucket
276
+ * appears.
277
+ *
278
+ * @param {import('./tree-model.js').BuiltTreeModel} model
279
+ * @returns {Array<{ id: string, label: string, reqs: object[], isProject: boolean }>}
280
+ */
281
+ export function groupByBlueprint(model) {
282
+ const attribution = buildBlueprintAttribution(model);
283
+ const bySlug = new Map();
284
+ const project = [];
285
+ const manifest = model?.manifest ?? null;
286
+ const applied = Array.isArray(manifest?.blueprints) ? manifest.blueprints : [];
287
+ for (const record of applied) {
288
+ if (typeof record?.slug !== 'string' || record.slug.length === 0) continue;
289
+ if (!bySlug.has(record.slug)) bySlug.set(record.slug, { record, reqs: [] });
290
+ }
291
+ for (const req of model.requirements) {
292
+ const slug = attribution.get(req.reqId) ?? null;
293
+ if (slug && bySlug.has(slug)) {
294
+ bySlug.get(slug).reqs.push(req);
295
+ } else {
296
+ project.push(req);
297
+ }
298
+ }
299
+ const sorted = [...bySlug.entries()]
300
+ .map(([slug, entry]) => ({
301
+ id: slug,
302
+ label: slug,
303
+ reqs: entry.reqs,
304
+ isProject: false,
305
+ }))
306
+ .sort((a, b) => {
307
+ if (b.reqs.length !== a.reqs.length) return b.reqs.length - a.reqs.length;
308
+ return a.id.localeCompare(b.id);
309
+ });
310
+ sorted.push({
311
+ id: PROJECT_BUCKET_ID,
312
+ label: PROJECT_BUCKET_LABEL,
313
+ reqs: project,
314
+ isProject: true,
315
+ });
316
+ return sorted;
317
+ }
318
+
319
+ /**
320
+ * Return per-status counts for a REQ list. Keys are the STATUS_MINI ids
321
+ * plus the total across all REQs (as `total`). REQs with an unknown or
322
+ * missing status count towards `total` only.
323
+ */
324
+ function statusMiniCounts(reqs) {
325
+ const out = { total: reqs.length };
326
+ for (const s of STATUS_MINI) out[s] = 0;
327
+ for (const r of reqs) {
328
+ const s = r?.status ?? '';
329
+ if (Object.prototype.hasOwnProperty.call(out, s)) out[s] += 1;
330
+ }
331
+ return out;
332
+ }
333
+
334
+ function renderStatusMini(counts) {
335
+ const nonZero = STATUS_MINI.filter((s) => (counts[s] ?? 0) > 0);
336
+ // Polish (Baz 2026-09-22): when a bucket has exactly one status
337
+ // represented and its count equals the total, the mini pill just
338
+ // repeats the "(n)" total on the summary row. Suppress it so
339
+ // "(21) 21" becomes "(21)"; buckets with a real split (e.g.
340
+ // Unclassified "52 / 7") still render the mini row.
341
+ if (nonZero.length === 1 && counts[nonZero[0]] === counts.total) return '';
342
+ const pills = nonZero
343
+ .map((s) => `<span class="pm-mini pm-mini-${escapeHtml(s)}" title="${escapeHtml(s)}: ${counts[s]}">${counts[s]}</span>`)
344
+ .join('');
345
+ return pills ? `<span class="pm-mini-row">${pills}</span>` : '';
346
+ }
347
+
348
+ /**
349
+ * Render the summary row for a pm-bucket <details>. `label` is the
350
+ * display name, `id` is the bucket key, `total` is the REQ count, and
351
+ * `mini` is the STATUS_MINI counts object.
352
+ */
353
+ function bucketSummary({ label, id, total, mini }) {
354
+ return `<summary class="pm-bucket-heading">`
355
+ + `<span class="pm-bucket-label" title="${escapeHtml(label)}">${escapeHtml(label)}</span>`
356
+ + ` <span class="pm-bucket-count">(${total})</span>`
357
+ + ` ${renderStatusMini(mini)}`
358
+ + `<span class="pm-bucket-anchor" aria-hidden="true"> ${escapeHtml(id)}</span>`
359
+ + `</summary>`;
360
+ }
361
+
362
+ function bucketOpen(group, id, body, { statusCoveredBy } = {}) {
363
+ const dataDocId = `pm-bucket:${group}:${id}`;
364
+ const attr = statusCoveredBy
365
+ ? ` data-pm-bucket-statuses="${escapeHtml(statusCoveredBy.join(','))}"`
366
+ : '';
367
+ return `<details class="pm-bucket" data-pm-bucket-id="${escapeHtml(id)}" data-doc-id="${escapeHtml(dataDocId)}"${attr}>${body}</details>`;
368
+ }
369
+
370
+ /**
371
+ * Render the jump-nav chip row for a grouping. Each chip carries its
372
+ * bucket id + count. The client wires clicks to open + scroll.
373
+ */
374
+ function renderJumpNav(group, entries) {
375
+ if (entries.length === 0) return '';
376
+ const chips = entries.map(({ id, label, total, mini }) => (
377
+ `<button type="button" class="pm-jump-chip" data-pm-jump="${escapeHtml(id)}" data-pm-jump-total="${total}" title="${escapeHtml(label)}">`
378
+ + `<span class="pm-jump-label">${escapeHtml(label)}</span>`
379
+ + `<span class="pm-jump-count">${total}</span>`
380
+ + `</button>`
381
+ )).join('');
382
+ return `<nav class="pm-jump-nav" aria-label="Buckets in this grouping" data-pm-jump-group="${escapeHtml(group)}">${chips}</nav>`;
383
+ }
384
+
385
+ /**
386
+ * Render the empty-buckets-under-filter placeholder. Empty buckets keep
387
+ * their <details> but pick up data-pm-empty-under-filter="true" on the
388
+ * client after a filter change; the client rolls them into a summary
389
+ * line above the grouping.
390
+ */
391
+ function emptyPlaceholder() {
392
+ return `<div class="pm-empty-roll" hidden><button type="button" class="pm-empty-toggle">Show <span class="pm-empty-count">0</span> empty buckets</button></div>`;
393
+ }
394
+
395
+ export function renderProductMapPanel(model, opts = {}) {
396
+ // Lazy render (AC-17007-1): the initial page ships only bucket
397
+ // headings for every grouping. The client fetches the active
398
+ // grouping's REQ cards from /product-map/<name> when it activates.
399
+ // Callers that need a full render (tests, the partial endpoint pre-
400
+ // render on the server) pass `lazy: new Set()`.
401
+ const { lazy = new Set(GROUPINGS) } = opts;
402
+ const attribution = buildBlueprintAttribution(model);
403
+ const shape = groupByShape(model);
404
+ const component = groupByComponent(model);
405
+ const trace = groupByTraceCoverage(model);
406
+ const capability = groupByCapability(model);
407
+ const blueprint = groupByBlueprint(model);
408
+
409
+ const shapeSection = renderShapeGrouping(model, shape, { lazy: lazy.has('shape'), attribution });
410
+ const componentSection = renderComponentGrouping(model, component, { lazy: lazy.has('component'), attribution });
411
+ const traceSection = renderTraceGrouping(model, trace, { lazy: lazy.has('trace'), attribution });
412
+ const capabilitySection = renderCapabilityGrouping(model, capability, { lazy: lazy.has('capability'), attribution });
413
+ const blueprintSection = renderBlueprintGrouping(model, blueprint, { lazy: lazy.has('blueprint'), attribution });
414
+
415
+ const groupButtons = GROUPINGS.map((g) => (
416
+ `<button type="button" role="tab" class="pm-group-btn" data-pm-group="${g}"`
417
+ + `${g === DEFAULT_GROUPING ? ' aria-selected="true"' : ' aria-selected="false"'}`
418
+ + ` aria-controls="pm-group-${g}">${escapeHtml(pmGroupLabel(g))}</button>`
419
+ )).join('');
420
+
421
+ const statusOptions = STATUSES.map((s) => (
422
+ `<option value="${escapeHtml(s)}"${s === DEFAULT_STATUS ? ' selected' : ''}>${escapeHtml(s)}</option>`
423
+ )).join('');
424
+
425
+ return `
426
+ <div class="pm-controls" role="toolbar" aria-label="Product Map controls">
427
+ <div class="pm-group-tabs" role="tablist" aria-label="Product Map grouping">
428
+ ${groupButtons}
429
+ </div>
430
+ <label class="pm-status-filter">
431
+ <span>Status</span>
432
+ <select class="pm-status-select" aria-label="Filter Product Map by REQ status">${statusOptions}</select>
433
+ </label>
434
+ <div class="pm-bulk">
435
+ <button type="button" class="pm-bulk-btn" data-pm-bulk="expand" title="Expand every bucket in this grouping">Expand all</button>
436
+ <button type="button" class="pm-bulk-btn" data-pm-bulk="collapse" title="Collapse every bucket in this grouping">Collapse all</button>
437
+ </div>
438
+ </div>
439
+ <section id="pm-group-shape" class="pm-group" data-pm-group="shape" role="tabpanel"${lazy.has('shape') ? ' data-pm-lazy="true"' : ''}>
440
+ <h3 class="pm-group-heading">By shape</h3>
441
+ ${shapeSection}
442
+ </section>
443
+ <section id="pm-group-component" class="pm-group" data-pm-group="component" role="tabpanel" hidden${lazy.has('component') ? ' data-pm-lazy="true"' : ''}>
444
+ <h3 class="pm-group-heading">By component</h3>
445
+ ${componentSection}
446
+ </section>
447
+ <section id="pm-group-trace" class="pm-group" data-pm-group="trace" role="tabpanel" hidden${lazy.has('trace') ? ' data-pm-lazy="true"' : ''}>
448
+ <h3 class="pm-group-heading">By trace coverage</h3>
449
+ ${traceSection}
450
+ </section>
451
+ <section id="pm-group-capability" class="pm-group" data-pm-group="capability" role="tabpanel" hidden${lazy.has('capability') ? ' data-pm-lazy="true"' : ''}>
452
+ <h3 class="pm-group-heading">By capability</h3>
453
+ ${capabilitySection}
454
+ </section>
455
+ <section id="pm-group-blueprint" class="pm-group" data-pm-group="blueprint" role="tabpanel" hidden${lazy.has('blueprint') ? ' data-pm-lazy="true"' : ''}>
456
+ <h3 class="pm-group-heading">By blueprint</h3>
457
+ ${blueprintSection}
458
+ </section>
459
+ `.trim();
460
+ }
461
+
462
+ /**
463
+ * Render a single grouping section body (the innerHTML that goes into
464
+ * `<section id="pm-group-<name>">`). Used by the /product-map/<name>
465
+ * partial endpoint (AC-17007-1) for on-demand hydration. Full render
466
+ * with jump nav and REQ cards; never lazy.
467
+ */
468
+ export function renderProductMapGrouping(model, group) {
469
+ const attribution = buildBlueprintAttribution(model);
470
+ switch (group) {
471
+ case 'shape': return renderShapeGrouping(model, groupByShape(model), { lazy: false, attribution });
472
+ case 'component': return renderComponentGrouping(model, groupByComponent(model), { lazy: false, attribution });
473
+ case 'trace': return renderTraceGrouping(model, groupByTraceCoverage(model), { lazy: false, attribution });
474
+ case 'capability': return renderCapabilityGrouping(model, groupByCapability(model), { lazy: false, attribution });
475
+ case 'blueprint': return renderBlueprintGrouping(model, groupByBlueprint(model), { lazy: false, attribution });
476
+ default: return '<p><em>Unknown grouping.</em></p>';
477
+ }
478
+ }
479
+
480
+ function pmGroupLabel(g) {
481
+ return {
482
+ shape: 'Shape',
483
+ component: 'Component',
484
+ trace: 'Trace coverage',
485
+ capability: 'Capability',
486
+ blueprint: 'Blueprint',
487
+ }[g] ?? g;
488
+ }
489
+
490
+ function renderReqDrilldown(model, req, prefix) {
491
+ const idPrefix = prefix ?? '';
492
+ const reqBody = renderReq(req, {
493
+ raw: model.rawById.get(req.reqId),
494
+ errors: model.errorsById.get(req.reqId),
495
+ subdiagram: undefined,
496
+ idPrefix,
497
+ suppressRawJson: true,
498
+ });
499
+ const stories = model.storiesByReqId.get(req.reqId) ?? [];
500
+ const usBlocks = stories.map((u) => detailsWrap({
501
+ id: `${idPrefix}${u.usId}`,
502
+ summary: `${u.usId} - ${u.title ?? ''}`,
503
+ className: 'doc-us-wrap pm-us',
504
+ status: u.status,
505
+ body: renderUserStory(u, {
506
+ raw: model.rawById.get(u.usId),
507
+ errors: model.errorsById.get(u.usId),
508
+ fbsByAcId: model.fbsByAcId,
509
+ idPrefix,
510
+ suppressRawJson: true,
511
+ }),
512
+ })).join('\n');
513
+ const storiesSection = usBlocks
514
+ ? `<section class="nested-details"><h4>User stories</h4>${usBlocks}</section>`
515
+ : '<p><em>No user stories under this requirement.</em></p>';
516
+ return detailsWrap({
517
+ id: `${idPrefix}${req.reqId}`,
518
+ summary: `${req.reqId} - ${req.title ?? ''}`,
519
+ className: 'doc-req-wrap pm-req',
520
+ status: req.status,
521
+ body: `<div class="pm-req-body" data-req-id="${escapeHtml(req.reqId)}" data-req-status="${escapeHtml(req.status ?? '')}">${reqBody}\n${storiesSection}</div>`,
522
+ });
523
+ }
524
+
525
+ /**
526
+ * Render a blueprint badge for a REQ, or empty string when the REQ has
527
+ * no blueprint attribution. Slug is HTML-escaped and doubles as the
528
+ * badge's title (hover text) so a reader can see which blueprint the
529
+ * badge represents without opening the by-blueprint tab.
530
+ */
531
+ function renderBlueprintBadge(attribution, reqId) {
532
+ const slug = attribution?.get?.(reqId);
533
+ if (!slug) return '';
534
+ const s = escapeHtml(slug);
535
+ return `<span class="pm-blueprint-badge" data-pm-blueprint-slug="${s}" title="Contributed by blueprint: ${s}">${s}</span>`;
536
+ }
537
+
538
+ function renderReqWrapper(model, req, prefix, { attribution } = {}) {
539
+ const inner = renderReqDrilldown(model, req, prefix);
540
+ const status = req.status ?? '';
541
+ // Splice data-req-status onto the outer details AND inject the
542
+ // blueprint badge into the summary so every grouping's REQ row shows
543
+ // the badge without touching detailsWrap in the shared helpers.
544
+ const badge = renderBlueprintBadge(attribution, req.reqId);
545
+ let out = inner.replace('<details class="doc-details doc-req-wrap pm-req"',
546
+ `<details class="doc-details doc-req-wrap pm-req" data-req-status="${escapeHtml(status)}"`);
547
+ if (badge) {
548
+ out = out.replace('</span><span class="status ', `</span>${badge}<span class="status `);
549
+ // Fallback splice for REQs without a status pill: inject the badge
550
+ // just before </summary>. Keeps the badge visible on statusless
551
+ // fixture rows.
552
+ if (!out.includes('pm-blueprint-badge')) {
553
+ out = out.replace('</summary>', `${badge}</summary>`);
554
+ }
555
+ }
556
+ return out;
557
+ }
558
+
559
+ function renderShapeGrouping(model, buckets, { lazy, attribution }) {
560
+ if (buckets.length === 0) return '<p><em>No requirements on disk.</em></p>';
561
+ const jumpEntries = buckets.map((b) => ({
562
+ id: b.id,
563
+ label: b.label,
564
+ total: b.reqs.length,
565
+ mini: statusMiniCounts(b.reqs),
566
+ }));
567
+ const jump = renderJumpNav('shape', jumpEntries);
568
+ const empty = emptyPlaceholder();
569
+ const bucketsHtml = buckets.map((b) => {
570
+ const prefix = `pm-shape-${b.id}-`;
571
+ const mini = statusMiniCounts(b.reqs);
572
+ const body = lazy
573
+ ? ''
574
+ : b.reqs.map((r) => renderReqWrapper(model, r, prefix, { attribution })).join('\n');
575
+ const summary = bucketSummary({ label: b.label, id: b.id, total: b.reqs.length, mini });
576
+ const contents = `${summary}<div class="pm-bucket-body">${body}</div>`;
577
+ return bucketOpen('shape', b.id, contents, { statusCoveredBy: statusesPresent(b.reqs) });
578
+ }).join('\n');
579
+ return `${jump}${empty}${bucketsHtml}`;
580
+ }
581
+
582
+ function statusesPresent(reqs) {
583
+ const s = new Set();
584
+ for (const r of reqs) if (r?.status) s.add(r.status);
585
+ return [...s];
586
+ }
587
+
588
+ function renderComponentGrouping(model, { tacs, unmapped }, { lazy, attribution }) {
589
+ const tadHeading = model.tad
590
+ ? `<div class="pm-tad-root"><h4>${escapeHtml(model.tad.tadId)} - ${escapeHtml(model.tad.title ?? model.tad.name ?? '')}</h4></div>`
591
+ : '';
592
+ const componentReqs = (t) => t.stories.map((s) => s.req).filter(Boolean);
593
+ const jumpEntries = tacs.map((t) => ({
594
+ id: t.tacId,
595
+ label: `${t.tacId} - ${t.name}`,
596
+ total: t.stories.length,
597
+ mini: statusMiniCounts(componentReqs(t)),
598
+ }));
599
+ if (unmapped.length > 0) {
600
+ jumpEntries.push({
601
+ id: 'unmapped',
602
+ label: 'Unmapped',
603
+ total: unmapped.length,
604
+ mini: statusMiniCounts(unmapped),
605
+ });
606
+ }
607
+ const jump = renderJumpNav('component', jumpEntries);
608
+ const empty = emptyPlaceholder();
609
+ const tacBlocks = tacs.map((t) => {
610
+ const mini = statusMiniCounts(componentReqs(t));
611
+ const summary = bucketSummary({ label: `${t.tacId} - ${t.name}`, id: t.tacId, total: t.stories.length, mini });
612
+ const depsLine = t.dependencies.length > 0
613
+ ? `<p class="pm-tac-deps"><strong>Dependencies:</strong> ${t.dependencies.map((d) => escapeHtml(d)).join(', ')}</p>`
614
+ : '<p class="pm-tac-deps"><em>No dependencies.</em></p>';
615
+ const storyBlocks = lazy ? '' : t.stories.map(({ us, req }) => {
616
+ const badge = renderBlueprintBadge(attribution, req?.reqId ?? '');
617
+ const reqLine = req
618
+ ? `<p class="pm-req-parent"><strong>Parent REQ:</strong> <a href="#pm-comp-${escapeHtml(t.tacId)}-${escapeHtml(req.reqId)}">${escapeHtml(req.reqId)} - ${escapeHtml(req.title ?? '')}</a>${badge} <span class="status ${escapeHtml(req.status ?? '')}">${escapeHtml(req.status ?? '')}</span></p>`
619
+ : '<p class="pm-req-parent"><em>Orphan story (no reqId).</em></p>';
620
+ const usBody = renderUserStory(us, {
621
+ raw: model.rawById.get(us.usId),
622
+ errors: model.errorsById.get(us.usId),
623
+ fbsByAcId: model.fbsByAcId,
624
+ idPrefix: `pm-comp-${t.tacId}-`,
625
+ suppressRawJson: true,
626
+ });
627
+ const usInner = detailsWrap({
628
+ id: `pm-comp-${t.tacId}-${us.usId}`,
629
+ summary: `${us.usId} - ${us.title ?? ''}`,
630
+ className: 'doc-us-wrap pm-us',
631
+ status: us.status,
632
+ body: `${reqLine}${usBody}`,
633
+ });
634
+ return `<div class="pm-comp-story" data-req-status="${escapeHtml(req?.status ?? '')}">${usInner}</div>`;
635
+ }).join('\n');
636
+ const body = `${summary}<div class="pm-bucket-body">${depsLine}${storyBlocks || (lazy ? '' : '<p class="pm-bucket-empty"><em>No stories linked to this component.</em></p>')}</div>`;
637
+ return bucketOpen('component', t.tacId, body);
638
+ }).join('\n');
639
+ const unmappedBlock = unmapped.length > 0
640
+ ? (() => {
641
+ const mini = statusMiniCounts(unmapped);
642
+ const summary = bucketSummary({ label: 'Unmapped', id: 'unmapped', total: unmapped.length, mini });
643
+ const body = lazy ? '' : unmapped.map((r) => renderReqWrapper(model, r, 'pm-comp-unmapped-', { attribution })).join('\n');
644
+ return bucketOpen('component', 'unmapped', `${summary}<div class="pm-bucket-body">${body}</div>`);
645
+ })()
646
+ : '';
647
+ return `${tadHeading}${jump}${empty}${tacBlocks}${unmappedBlock}`.trim();
648
+ }
649
+
650
+ function renderTraceGrouping(model, buckets, { lazy, attribution }) {
651
+ if (buckets.length === 0) return '<p><em>No requirements on disk.</em></p>';
652
+ const jumpEntries = buckets.map((b) => ({
653
+ id: b.id,
654
+ label: b.label,
655
+ total: b.entries.length,
656
+ mini: statusMiniCounts(b.entries.map((e) => e.req)),
657
+ }));
658
+ const jump = renderJumpNav('trace', jumpEntries);
659
+ const empty = emptyPlaceholder();
660
+ const bucketsHtml = buckets.map((b) => {
661
+ const prefix = `pm-trace-${b.id}-`;
662
+ const mini = statusMiniCounts(b.entries.map((e) => e.req));
663
+ const summary = bucketSummary({ label: b.label, id: b.id, total: b.entries.length, mini });
664
+ const rows = lazy ? '' : b.entries.map(({ req, missing }) => {
665
+ const missingLine = missing
666
+ ? `<p class="pm-trace-missing"><strong>Missing:</strong> ${escapeHtml(missing)}</p>`
667
+ : '<p class="pm-trace-missing"><em>Nothing missing.</em></p>';
668
+ const reqInner = renderReqWrapper(model, req, prefix, { attribution });
669
+ return `<div class="pm-trace-row" data-req-status="${escapeHtml(req.status ?? '')}">${missingLine}${reqInner}</div>`;
670
+ }).join('\n');
671
+ return bucketOpen('trace', b.id, `${summary}<div class="pm-bucket-body">${rows}</div>`);
672
+ }).join('\n');
673
+ return `${jump}${empty}${bucketsHtml}`;
674
+ }
675
+
676
+ function renderCapabilityGrouping(model, buckets, { lazy, attribution }) {
677
+ if (buckets.length === 0) return '<p><em>No requirements on disk.</em></p>';
678
+ const jumpEntries = buckets.map((b) => ({
679
+ id: `capability-${b.id}`,
680
+ label: b.label,
681
+ total: b.reqs.length,
682
+ mini: statusMiniCounts(b.reqs),
683
+ }));
684
+ const jump = renderJumpNav('capability', jumpEntries);
685
+ const empty = emptyPlaceholder();
686
+ const bucketsHtml = buckets.map((b) => {
687
+ const idAttr = `capability-${b.id}`;
688
+ const prefix = `pm-${idAttr}-`;
689
+ const mini = statusMiniCounts(b.reqs);
690
+ const summary = bucketSummary({ label: b.label, id: idAttr, total: b.reqs.length, mini });
691
+ const body = lazy ? '' : b.reqs.map((r) => renderReqWrapper(model, r, prefix, { attribution })).join('\n');
692
+ return bucketOpen('capability', idAttr, `${summary}<div class="pm-bucket-body">${body}</div>`);
693
+ }).join('\n');
694
+ return `${jump}${empty}${bucketsHtml}`;
695
+ }
696
+
697
+ /**
698
+ * By-blueprint grouping. Emits one <details class="pm-bucket"> per
699
+ * applied blueprint plus a Project bucket. Inside each bucket the
700
+ * second level is capability (multi-tag REQs appear in each capability
701
+ * sub-heading; untagged REQs collect under an Unclassified sub-heading),
702
+ * and inside each capability sub-heading are the usual pm-req rows with
703
+ * REQ->US->AC drill-down. Each bucket's REQ rows carry the blueprint
704
+ * badge in every grouping via renderReqWrapper.
705
+ */
706
+ function renderBlueprintGrouping(model, buckets, { lazy, attribution }) {
707
+ if (buckets.length === 0) return '<p><em>No requirements on disk.</em></p>';
708
+ const jumpEntries = buckets.map((b) => ({
709
+ id: `blueprint-${b.id}`,
710
+ label: b.label,
711
+ total: b.reqs.length,
712
+ mini: statusMiniCounts(b.reqs),
713
+ }));
714
+ const jump = renderJumpNav('blueprint', jumpEntries);
715
+ const empty = emptyPlaceholder();
716
+ const bucketsHtml = buckets.map((b) => {
717
+ const idAttr = `blueprint-${b.id}`;
718
+ const prefix = `pm-${idAttr}-`;
719
+ const mini = statusMiniCounts(b.reqs);
720
+ const summary = bucketSummary({
721
+ label: b.label,
722
+ id: idAttr,
723
+ total: b.reqs.length,
724
+ mini,
725
+ });
726
+ const body = lazy
727
+ ? ''
728
+ : renderCapabilitySubBuckets(model, b.reqs, prefix, { attribution });
729
+ return bucketOpen('blueprint', idAttr, `${summary}<div class="pm-bucket-body">${body}</div>`);
730
+ }).join('\n');
731
+ return `${jump}${empty}${bucketsHtml}`;
732
+ }
733
+
734
+ /**
735
+ * Render the second level (capability) inside a by-blueprint bucket.
736
+ * Groups the bucket's REQs by capability slug (same rule as
737
+ * groupByCapability), emits a <section class="pm-blueprint-cap"> per
738
+ * capability with an <h4> heading, then the pm-req cards inside.
739
+ */
740
+ function renderCapabilitySubBuckets(model, reqs, prefix, { attribution }) {
741
+ if (reqs.length === 0) return '<p class="pm-bucket-empty"><em>No requirements in this bucket.</em></p>';
742
+ const capSub = groupByCapability({ requirements: reqs });
743
+ return capSub.map((cap) => {
744
+ const capHeading = cap.id === 'unclassified' ? 'Unclassified' : cap.label;
745
+ const capReqs = cap.reqs.map((r) => renderReqWrapper(model, r, `${prefix}${cap.id}-`, { attribution })).join('\n');
746
+ return `<section class="pm-blueprint-cap" data-pm-blueprint-capability="${escapeHtml(cap.id)}">`
747
+ + `<h4 class="pm-blueprint-cap-heading">${escapeHtml(capHeading)} <span class="pm-blueprint-cap-count">(${cap.reqs.length})</span></h4>`
748
+ + `<div class="pm-blueprint-cap-body">${capReqs}</div>`
749
+ + `</section>`;
750
+ }).join('');
751
+ }