@profpowell/vb-project-planning 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/dist/vb-project-planning.css +1603 -0
  4. package/dist/vb-project-planning.js +11138 -0
  5. package/dist/vb-project-planning.min.css +1 -0
  6. package/dist/vb-project-planning.min.js +3312 -0
  7. package/package.json +80 -0
  8. package/src/index.css +14 -0
  9. package/src/index.js +26 -0
  10. package/src/lib/bundle-registry.js +156 -0
  11. package/src/lib/diff-by-key.js +86 -0
  12. package/src/lib/portrait-url.js +87 -0
  13. package/src/lib/vb-collection.js +122 -0
  14. package/src/lib/vb-element.js +96 -0
  15. package/src/lib/vb-store.js +200 -0
  16. package/src/lib/vb-view-transition.js +44 -0
  17. package/src/utils/copy-init.js +287 -0
  18. package/src/web-components/_ux-base.js +106 -0
  19. package/src/web-components/adr-wc/README.md +77 -0
  20. package/src/web-components/adr-wc/api.json +26 -0
  21. package/src/web-components/adr-wc/logic.js +376 -0
  22. package/src/web-components/adr-wc/static.html +17 -0
  23. package/src/web-components/adr-wc/styles.js +338 -0
  24. package/src/web-components/burndown-chart/api.json +74 -0
  25. package/src/web-components/burndown-chart/logic.js +198 -0
  26. package/src/web-components/burndown-chart/static.html +31 -0
  27. package/src/web-components/burndown-chart/styles.css +59 -0
  28. package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
  29. package/src/web-components/capacity-plan/api.json +20 -0
  30. package/src/web-components/capacity-plan/logic.js +222 -0
  31. package/src/web-components/capacity-plan/static.html +24 -0
  32. package/src/web-components/capacity-plan/styles.css +84 -0
  33. package/src/web-components/empathy-map/api.json +31 -0
  34. package/src/web-components/empathy-map/logic.js +502 -0
  35. package/src/web-components/empathy-map/static.html +32 -0
  36. package/src/web-components/empathy-map/styles.js +534 -0
  37. package/src/web-components/gantt-chart/README.md +91 -0
  38. package/src/web-components/gantt-chart/api.json +20 -0
  39. package/src/web-components/gantt-chart/logic.js +812 -0
  40. package/src/web-components/gantt-chart/static.html +60 -0
  41. package/src/web-components/gantt-chart/styles.css +391 -0
  42. package/src/web-components/impact-effort/api.json +21 -0
  43. package/src/web-components/impact-effort/logic.js +392 -0
  44. package/src/web-components/impact-effort/static.html +23 -0
  45. package/src/web-components/impact-effort/styles.css +193 -0
  46. package/src/web-components/iron-triangle/_capacity.js +83 -0
  47. package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
  48. package/src/web-components/iron-triangle/api.json +26 -0
  49. package/src/web-components/iron-triangle/logic.js +474 -0
  50. package/src/web-components/iron-triangle/static.html +28 -0
  51. package/src/web-components/iron-triangle/styles.css +158 -0
  52. package/src/web-components/kanban-board/api.json +24 -0
  53. package/src/web-components/kanban-board/logic.js +698 -0
  54. package/src/web-components/kanban-board/static.html +20 -0
  55. package/src/web-components/kanban-board/styles.css +242 -0
  56. package/src/web-components/product-roadmap/api.json +118 -0
  57. package/src/web-components/product-roadmap/logic.js +460 -0
  58. package/src/web-components/product-roadmap/static.html +44 -0
  59. package/src/web-components/product-roadmap/styles.css +158 -0
  60. package/src/web-components/quadrant-grid/api.json +125 -0
  61. package/src/web-components/quadrant-grid/logic.js +397 -0
  62. package/src/web-components/quadrant-grid/static.html +49 -0
  63. package/src/web-components/quadrant-grid/styles.css +184 -0
  64. package/src/web-components/quality-target/_quality-utils.js +460 -0
  65. package/src/web-components/quality-target/api.json +30 -0
  66. package/src/web-components/quality-target/logic.js +516 -0
  67. package/src/web-components/quality-target/static.html +22 -0
  68. package/src/web-components/quality-target/styles.css +203 -0
  69. package/src/web-components/requirement-card/api.json +25 -0
  70. package/src/web-components/requirement-card/logic.js +166 -0
  71. package/src/web-components/requirement-card/static.html +36 -0
  72. package/src/web-components/requirement-card/styles.js +114 -0
  73. package/src/web-components/review-surface/README.md +178 -0
  74. package/src/web-components/review-surface/api.json +30 -0
  75. package/src/web-components/review-surface/logic.js +807 -0
  76. package/src/web-components/review-surface/static.html +33 -0
  77. package/src/web-components/review-surface/styles.js +459 -0
  78. package/src/web-components/risk-register/api.json +47 -0
  79. package/src/web-components/risk-register/logic.js +180 -0
  80. package/src/web-components/risk-register/static.html +46 -0
  81. package/src/web-components/risk-register/styles.css +37 -0
  82. package/src/web-components/story-map/api.json +22 -0
  83. package/src/web-components/story-map/logic.js +445 -0
  84. package/src/web-components/story-map/static.html +35 -0
  85. package/src/web-components/story-map/styles.css +169 -0
  86. package/src/web-components/traceability-matrix/api.json +84 -0
  87. package/src/web-components/traceability-matrix/logic.js +243 -0
  88. package/src/web-components/traceability-matrix/static.html +41 -0
  89. package/src/web-components/traceability-matrix/styles.css +94 -0
  90. package/src/web-components/user-journey/api.json +84 -0
  91. package/src/web-components/user-journey/logic.js +368 -0
  92. package/src/web-components/user-journey/static.html +14 -0
  93. package/src/web-components/user-journey/styles.js +317 -0
  94. package/src/web-components/user-persona/api.json +35 -0
  95. package/src/web-components/user-persona/logic.js +502 -0
  96. package/src/web-components/user-persona/static.html +20 -0
  97. package/src/web-components/user-persona/styles.js +299 -0
  98. package/src/web-components/user-story/api.json +35 -0
  99. package/src/web-components/user-story/logic.js +567 -0
  100. package/src/web-components/user-story/static.html +27 -0
  101. package/src/web-components/user-story/styles.js +332 -0
  102. package/src/web-components/work-item/README.md +85 -0
  103. package/src/web-components/work-item/api.json +30 -0
  104. package/src/web-components/work-item/logic.js +414 -0
  105. package/src/web-components/work-item/static.html +24 -0
  106. package/src/web-components/work-item/styles.js +376 -0
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Pure geometry + SVG builder for the click-to-edit `<iron-triangle>`.
3
+ *
4
+ * Triangle layout matches the design sketch:
5
+ * - SCOPE at the top (angle -π/2)
6
+ * - TIME at the bottom-left (5π/6)
7
+ * - COST at the bottom-right (π/6)
8
+ *
9
+ * Each vertex is pushed outward from the centroid by a per-constraint
10
+ * stretch factor in [MIN_STRETCH, MAX_STRETCH]. The capacity number
11
+ * sits at the centroid. The whole SVG is the UI: each vertex group
12
+ * is a clickable hit target that opens a per-axis dialog; the center
13
+ * "Quality" group fires `iron-triangle:open-quality` (and optionally
14
+ * navigates to data-quality-href).
15
+ */
16
+
17
+ const MIN_STRETCH = 0.55;
18
+ const MAX_STRETCH = 1.45;
19
+
20
+ /**
21
+ * Reduce each constraint's snapshot to one comparable scalar.
22
+ * @param {{ time?: any, cost?: any, scope?: any }} [value]
23
+ */
24
+ export function relativeMagnitudes({ time = {}, cost = {}, scope = {} } = {}) {
25
+ return {
26
+ t: numeric(time.sprintCount, 1) * numeric(time.sprintWeeks, 1),
27
+ c: numeric(cost.teamFTE, 0) * numeric(cost.hoursPerWeek, 40),
28
+ s: numeric(scope.mustHaveCount, 0) + 0.5 * numeric(scope.shouldHaveCount, 0),
29
+ };
30
+ }
31
+
32
+ /** Map magnitudes to per-vertex stretch factors clamped to [0.55, 1.45]. */
33
+ export function stretchFactors(mag) {
34
+ const positives = [mag.t, mag.c, mag.s].filter(v => v > 0);
35
+ if (positives.length === 0) return { time: 1, cost: 1, scope: 1 };
36
+ const max = Math.max(...positives);
37
+ const norm = v => v <= 0
38
+ ? MIN_STRETCH
39
+ : MIN_STRETCH + (MAX_STRETCH - MIN_STRETCH) * (v / max);
40
+ return { time: norm(mag.t), cost: norm(mag.c), scope: norm(mag.s) };
41
+ }
42
+
43
+ /**
44
+ * Returns three SVG-coordinate vertices.
45
+ * scope at top, time bottom-left, cost bottom-right.
46
+ */
47
+ export function triangleVertices(value, radius = 80) {
48
+ const baseAngles = {
49
+ scope: -Math.PI / 2, // top
50
+ time: 5 * Math.PI / 6, // bottom-left
51
+ cost: Math.PI / 6, // bottom-right (SVG-y down)
52
+ };
53
+ const fs = stretchFactors(relativeMagnitudes(value));
54
+ const at = key => ({
55
+ x: Math.cos(baseAngles[key]) * radius * fs[key],
56
+ y: Math.sin(baseAngles[key]) * radius * fs[key],
57
+ factor: fs[key],
58
+ });
59
+ return { scope: at('scope'), time: at('time'), cost: at('cost') };
60
+ }
61
+
62
+ // ── Per-vertex summary formatters ─────────────────────────────────
63
+
64
+ export function formatTimeSummary(time = {}) {
65
+ const sprintCount = numeric(time.sprintCount, 0);
66
+ const sprintWeeks = numeric(time.sprintWeeks, 0);
67
+ if (sprintCount > 0 && sprintWeeks > 0) {
68
+ const total = sprintCount * sprintWeeks;
69
+ return sprintCount > 1
70
+ ? `${total} weeks (${sprintCount} × ${sprintWeeks}wk)`
71
+ : `${total} week${total === 1 ? '' : 's'}`;
72
+ }
73
+ if (sprintWeeks > 0) return `${sprintWeeks} week${sprintWeeks === 1 ? '' : 's'}`;
74
+ if (time.deadline) return `until ${time.deadline}`;
75
+ return 'TBD';
76
+ }
77
+
78
+ export function formatCostSummary(cost = {}) {
79
+ const fte = Number(cost.teamFTE);
80
+ const tier = cost.budgetTier;
81
+ const parts = [];
82
+ if (Number.isFinite(fte) && fte > 0) parts.push(`${fte} FTE`);
83
+ if (tier && tier !== 'unset') parts.push(tier);
84
+ return parts.length > 0 ? parts.join(' · ') : 'TBD';
85
+ }
86
+
87
+ export function formatScopeSummary(scope = {}) {
88
+ const must = numeric(scope.mustHaveCount, 0);
89
+ const should = numeric(scope.shouldHaveCount, 0);
90
+ if (must > 0 && should > 0) return `${must} must · ${should} should`;
91
+ if (must > 0) return `${must} must-have`;
92
+ if (should > 0) return `${should} should-have`;
93
+ return 'TBD';
94
+ }
95
+
96
+ export function formatQualitySummary(qualitySummary) {
97
+ if (typeof qualitySummary === 'string' && qualitySummary.trim().length > 0) {
98
+ return qualitySummary;
99
+ }
100
+ return 'TBD — click to set';
101
+ }
102
+
103
+ // ── SVG builder ───────────────────────────────────────────────────
104
+
105
+ const SVG_NS = 'http://www.w3.org/2000/svg';
106
+
107
+ const VERTEX_LABEL_OFFSETS = {
108
+ scope: { dx: 0, dy: -22, anchor: 'middle' },
109
+ time: { dx: -10, dy: 14, anchor: 'end' },
110
+ cost: { dx: 10, dy: 14, anchor: 'start' },
111
+ };
112
+
113
+ const VERTEX_SUMMARY_OFFSETS = {
114
+ scope: { dx: 0, dy: -8, anchor: 'middle' },
115
+ time: { dx: -10, dy: 28, anchor: 'end' },
116
+ cost: { dx: 10, dy: 28, anchor: 'start' },
117
+ };
118
+
119
+ const VERTEX_LABELS = {
120
+ scope: { name: 'Scope', unit: '# features / story points' },
121
+ time: { name: 'Time', unit: 'hours / days / weeks' },
122
+ cost: { name: 'Cost', unit: 'FTE / $' },
123
+ };
124
+
125
+ /**
126
+ * Build the entire interactive triangle SVG. Each vertex group is
127
+ * `<g class="vertex" data-axis="…" tabindex="0">` so click + keyboard
128
+ * activation work; the center group is `<g class="center"
129
+ * data-target="quality" tabindex="0">`.
130
+ *
131
+ * The component (logic.js) attaches click/keydown listeners to these
132
+ * groups by `data-axis` / `data-target`.
133
+ */
134
+ /**
135
+ * @param {{ value?: any, vertices?: any, capacityPoints?: number, capacitySource?: string, qualitySummary?: string }} [options]
136
+ */
137
+ export function buildTriangleSvg({
138
+ value = {},
139
+ vertices,
140
+ capacityPoints = 0,
141
+ capacitySource = 'formula',
142
+ qualitySummary = '',
143
+ } = {}) {
144
+ const v = vertices || triangleVertices(value);
145
+ const svg = document.createElementNS(SVG_NS, 'svg');
146
+ // viewBox padded generously so even the longest summary
147
+ // ("6 weeks (3 × 2wk)") rendered at TIME's bottom-left vertex
148
+ // (max stretched to x ≈ -116) plus its ~80px text width never
149
+ // clips the left edge.
150
+ svg.setAttribute('viewBox', '-220 -170 440 340');
151
+ svg.setAttribute('role', 'group');
152
+ svg.setAttribute('aria-label',
153
+ `Project shape: ${capacityPoints || 0} capacity points. Click each corner to edit, or the center to open the quality compass.`);
154
+
155
+ // 1. The triangle polygon — decorative; hit-targets are the vertex groups.
156
+ const tri = document.createElementNS(SVG_NS, 'polygon');
157
+ tri.setAttribute('class', 'triangle');
158
+ tri.setAttribute('points', [
159
+ `${round(v.scope.x)},${round(v.scope.y)}`,
160
+ `${round(v.time.x)},${round(v.time.y)}`,
161
+ `${round(v.cost.x)},${round(v.cost.y)}`,
162
+ ].join(' '));
163
+ svg.append(tri);
164
+
165
+ // 2. Center "Quality" hit target. Order top→bottom:
166
+ // "Quality" header (small) → big capacity number → "pts" subtitle.
167
+ // A backdrop circle keeps the polygon stroke out of the text area.
168
+ const center = document.createElementNS(SVG_NS, 'g');
169
+ center.setAttribute('class', 'center');
170
+ center.setAttribute('data-target', 'quality');
171
+ center.setAttribute('tabindex', '0');
172
+ center.setAttribute('role', 'button');
173
+ center.setAttribute(
174
+ 'aria-label',
175
+ `Quality — ${formatQualitySummary(qualitySummary)}. Activate to open the NFR compass.`,
176
+ );
177
+
178
+ // Backdrop circle — sits behind the text, masks the polygon stroke.
179
+ const backdrop = document.createElementNS(SVG_NS, 'circle');
180
+ backdrop.setAttribute('class', 'capacity-backdrop');
181
+ backdrop.setAttribute('cx', '0');
182
+ backdrop.setAttribute('cy', '4');
183
+ backdrop.setAttribute('r', '32');
184
+ center.append(backdrop);
185
+
186
+ // Hit-rect overlays the backdrop so the entire center is clickable.
187
+ const centerHit = document.createElementNS(SVG_NS, 'rect');
188
+ centerHit.setAttribute('class', 'hit');
189
+ centerHit.setAttribute('x', '-36');
190
+ centerHit.setAttribute('y', '-30');
191
+ centerHit.setAttribute('width', '72');
192
+ centerHit.setAttribute('height', '68');
193
+ centerHit.setAttribute('rx', '34');
194
+ center.append(centerHit);
195
+
196
+ // "Quality" header (small, top of stack).
197
+ const quality = document.createElementNS(SVG_NS, 'text');
198
+ quality.setAttribute('class', 'quality-label');
199
+ quality.setAttribute('x', '0');
200
+ quality.setAttribute('y', '-15');
201
+ quality.setAttribute('text-anchor', 'middle');
202
+ quality.textContent = 'Quality';
203
+ center.append(quality);
204
+
205
+ // Capacity number (hero).
206
+ const cap = document.createElementNS(SVG_NS, 'text');
207
+ cap.setAttribute('class', 'capacity');
208
+ cap.setAttribute('x', '0');
209
+ cap.setAttribute('y', '7');
210
+ cap.setAttribute('text-anchor', 'middle');
211
+ cap.setAttribute('dominant-baseline', 'middle');
212
+ cap.textContent = capacityPoints > 0 ? String(capacityPoints) : '—';
213
+ center.append(cap);
214
+
215
+ // "pts" subtitle.
216
+ const unit = document.createElementNS(SVG_NS, 'text');
217
+ unit.setAttribute('class', 'capacity-unit');
218
+ unit.setAttribute('x', '0');
219
+ unit.setAttribute('y', '26');
220
+ unit.setAttribute('text-anchor', 'middle');
221
+ unit.textContent = capacitySource === 'manual' ? 'pts (manual)' : 'pts';
222
+ center.append(unit);
223
+
224
+ // Native <title> tooltip — the quality summary.
225
+ const centerTitle = document.createElementNS(SVG_NS, 'title');
226
+ centerTitle.textContent = formatQualitySummary(qualitySummary);
227
+ center.append(centerTitle);
228
+ svg.append(center);
229
+
230
+ // 3. Three vertex hit targets.
231
+ for (const axis of ['scope', 'time', 'cost']) {
232
+ svg.append(buildVertex(axis, v[axis], value));
233
+ }
234
+
235
+ return svg;
236
+ }
237
+
238
+ function buildVertex(axis, vertex, value) {
239
+ const labelMeta = VERTEX_LABEL_OFFSETS[axis];
240
+ const summaryMeta = VERTEX_SUMMARY_OFFSETS[axis];
241
+ const labels = VERTEX_LABELS[axis];
242
+
243
+ const summary = axis === 'scope'
244
+ ? formatScopeSummary(value.scope)
245
+ : axis === 'cost'
246
+ ? formatCostSummary(value.cost)
247
+ : formatTimeSummary(value.time);
248
+
249
+ const g = document.createElementNS(SVG_NS, 'g');
250
+ g.setAttribute('class', 'vertex');
251
+ g.setAttribute('data-axis', axis);
252
+ g.setAttribute('tabindex', '0');
253
+ g.setAttribute('role', 'button');
254
+ g.setAttribute('aria-label', `${labels.name} — ${summary}. Activate to edit.`);
255
+
256
+ // Invisible hit area: a roomy rect that fully contains the
257
+ // {label, summary} text cluster. The longest summary
258
+ // ("6 weeks (3 × 2wk)") needs ≈ 130px at 0.7rem.
259
+ const hit = document.createElementNS(SVG_NS, 'rect');
260
+ hit.setAttribute('class', 'hit');
261
+ const hitW = 160;
262
+ const hitH = 50;
263
+ // Position the hit rect by aligning its inner edge with the vertex
264
+ // and accounting for each label's anchor offset.
265
+ let hitX;
266
+ if (labelMeta.anchor === 'end') {
267
+ // Right edge of hit aligns with vertex (text grows leftward).
268
+ hitX = vertex.x - hitW + 6;
269
+ } else if (labelMeta.anchor === 'start') {
270
+ // Left edge of hit aligns with vertex (text grows rightward).
271
+ hitX = vertex.x - 6;
272
+ } else {
273
+ // Centered on vertex for the SCOPE corner.
274
+ hitX = vertex.x - hitW / 2;
275
+ }
276
+ // Vertical position depends on whether the labels render above
277
+ // (scope, top corner) or below (time/cost, bottom corners) the vertex.
278
+ const hitY = axis === 'scope'
279
+ ? vertex.y - hitH + 6
280
+ : vertex.y - 6;
281
+ hit.setAttribute('x', String(round(hitX)));
282
+ hit.setAttribute('y', String(round(hitY)));
283
+ hit.setAttribute('width', String(hitW));
284
+ hit.setAttribute('height', String(hitH));
285
+ hit.setAttribute('rx', '6');
286
+ g.append(hit);
287
+
288
+ // Vertex name (Scope / Time / Cost).
289
+ const name = document.createElementNS(SVG_NS, 'text');
290
+ name.setAttribute('class', 'vertex-name');
291
+ name.setAttribute('x', String(round(vertex.x + labelMeta.dx)));
292
+ name.setAttribute('y', String(round(vertex.y + labelMeta.dy)));
293
+ name.setAttribute('text-anchor', labelMeta.anchor);
294
+ name.textContent = labels.name;
295
+ g.append(name);
296
+
297
+ // Computed summary or "TBD".
298
+ const summaryEl = document.createElementNS(SVG_NS, 'text');
299
+ summaryEl.setAttribute('class', 'vertex-summary');
300
+ summaryEl.setAttribute('x', String(round(vertex.x + summaryMeta.dx)));
301
+ summaryEl.setAttribute('y', String(round(vertex.y + summaryMeta.dy)));
302
+ summaryEl.setAttribute('text-anchor', summaryMeta.anchor);
303
+ summaryEl.textContent = summary;
304
+ g.append(summaryEl);
305
+
306
+ // Native <title> tooltip — unit hint.
307
+ const title = document.createElementNS(SVG_NS, 'title');
308
+ title.textContent = `${labels.name}: ${summary} — ${labels.unit}`;
309
+ g.append(title);
310
+
311
+ return g;
312
+ }
313
+
314
+ // ── helpers ────────────────────────────────────────────────────────
315
+
316
+ function numeric(value, fallback) {
317
+ if (value === '' || value == null) return fallback;
318
+ const n = Number(value);
319
+ return Number.isFinite(n) && n > 0 ? n : fallback;
320
+ }
321
+
322
+ function round(n) { return Math.round(n * 100) / 100; }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "../../../schemas/api.schema.json",
3
+ "element": "iron-triangle",
4
+ "type": "web-component",
5
+ "description": "Triangle-as-UI project-shape constraint surface. Three vertex hit-targets (Scope/Time/Cost) open native <dialog> editors; the center 'Quality' target fires iron-triangle:open-quality with a data-quality-href fallback. Computes capacityPoints from sprintWeeks × sprintCount × teamFTE × focusFactor. Form-associated.",
6
+ "htmlvalidate": {
7
+ "flow": true,
8
+ "permittedContent": ["@flow"]
9
+ },
10
+ "attributes": [
11
+ { "name": "name", "kind": "native", "purpose": "config", "type": "string", "default": "triangle", "description": "Form-association field name" },
12
+ { "name": "data-focus-factor", "kind": "data", "purpose": "config", "type": "number", "default": "0.6", "description": "Multiplier in the default capacity formula" },
13
+ { "name": "data-min-capacity", "kind": "data", "purpose": "config", "type": "number", "default": "1", "description": "Floor for capacityPoints" },
14
+ { "name": "data-quality-href", "kind": "data", "purpose": "config", "type": "string", "description": "Navigation fallback when iron-triangle:open-quality isn't preventDefault-ed" },
15
+ { "name": "data-quality-summary", "kind": "data", "purpose": "config", "type": "string", "description": "Short summary text shown in the center's native tooltip + aria-label (e.g. '3 critical: perf, sec, a11y')" },
16
+ { "name": "disabled", "kind": "native", "purpose": "config", "type": "boolean", "description": "All inputs disabled" },
17
+ { "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped vectors" }
18
+ ],
19
+ "slots": [],
20
+ "events": [
21
+ { "name": "iron-triangle:change", "detail": "{ time, cost, scope, capacityPoints, capacitySource, hash, source, field }", "description": "Any input or property change" },
22
+ { "name": "iron-triangle:revise", "detail": "{ field, from, to, reason }", "description": "A revision is committed via revise()" },
23
+ { "name": "iron-triangle:mode", "detail": "{ from, to }", "description": "Capacity source flips between formula and manual" },
24
+ { "name": "iron-triangle:open-quality", "detail": "{ qualitySummary, capacityPoints }", "description": "Center 'Quality' target activated; cancelable. If not prevented and data-quality-href is set, navigates to that URL." }
25
+ ]
26
+ }