@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,392 @@
1
+ /**
2
+ * impact-effort: 2x2 prioritization matrix with drag-and-drop
3
+ *
4
+ * Coordinates four <drag-surface> elements in a Quick Wins / Big Bets /
5
+ * Fill-Ins / Money Pit grid. Items move between quadrants via native
6
+ * drag-and-drop or keyboard reorder (provided by drag-surface).
7
+ *
8
+ * Light DOM component — does NOT import drag-surface; expects it to be
9
+ * registered by the main bundle or pack entry.
10
+ *
11
+ * @attr {string} src - URL to JSON data for items
12
+ * @attr {boolean} compact - Reduced spacing variant
13
+ *
14
+ * Children use:
15
+ * @attr {string} data-quadrant - Target quadrant: quick-wins | big-bets | fill-ins | money-pit
16
+ * @attr {boolean} draggable="true" - Required for drag capability
17
+ * @attr {string} data-id - Stable identifier for the item
18
+ *
19
+ * @fires impact-effort:move - When an item is dragged between quadrants
20
+ * @fires impact-effort:ready - After component initializes with quadrant counts
21
+ */
22
+
23
+ import { registerComponent } from '../../lib/bundle-registry.js';
24
+ import { VBElement } from '../../lib/vb-element.js';
25
+ import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
26
+ import { diffByKey } from '../../lib/diff-by-key.js';
27
+
28
+ class ImpactEffort extends VBElement {
29
+ static QUADRANTS = ['quick-wins', 'big-bets', 'fill-ins', 'money-pit'];
30
+
31
+ static LABELS = {
32
+ 'quick-wins': 'Quick Wins',
33
+ 'big-bets': 'Big Bets',
34
+ 'fill-ins': 'Fill-Ins',
35
+ 'money-pit': 'Money Pit',
36
+ };
37
+
38
+ static DESCRIPTIONS = {
39
+ 'quick-wins': 'High impact \u00b7 Low effort',
40
+ 'big-bets': 'High impact \u00b7 High effort',
41
+ 'fill-ins': 'Low impact \u00b7 Low effort',
42
+ 'money-pit': 'Low impact \u00b7 High effort',
43
+ };
44
+
45
+ static #instanceCount = 0;
46
+
47
+ static get observedAttributes() {
48
+ return ['src', 'compact', 'title'];
49
+ }
50
+
51
+ /** @type {HTMLElement | null} */
52
+ #grid = null;
53
+ /** @type {Record<string, HTMLElement>} */
54
+ #surfaces = {};
55
+ /** @type {HTMLElement | null} */
56
+ #liveRegion = null;
57
+ /** @type {string} */
58
+ #groupId = '';
59
+ /** @type {HTMLElement | null} */
60
+ #wrapper = null;
61
+
62
+ setup() {
63
+ this.#groupId = `ie-${++ImpactEffort.#instanceCount}`;
64
+
65
+ // 1. Snapshot children that belong in quadrants
66
+ const children = [...this.querySelectorAll(':scope > [data-quadrant], :scope > [draggable]')];
67
+
68
+ // 2. Build wrapper: .ie-wrapper > .ie-y-label + column-div > .ie-grid + .ie-x-label
69
+ const wrapper = document.createElement('div');
70
+ wrapper.className = 'ie-wrapper';
71
+
72
+ const yLabel = document.createElement('div');
73
+ yLabel.className = 'ie-y-label';
74
+ yLabel.setAttribute('aria-hidden', 'true');
75
+ yLabel.textContent = 'Impact \u2191';
76
+
77
+ const column = document.createElement('div');
78
+ column.style.cssText = 'display:flex;flex-direction:column;flex:1;min-width:0;';
79
+
80
+ const grid = document.createElement('div');
81
+ grid.className = 'ie-grid';
82
+ grid.setAttribute('role', 'region');
83
+ grid.setAttribute('aria-label', 'Impact-Effort prioritization matrix');
84
+
85
+ const xLabel = document.createElement('div');
86
+ xLabel.className = 'ie-x-label';
87
+ xLabel.setAttribute('aria-hidden', 'true');
88
+ xLabel.textContent = 'Effort \u2192';
89
+
90
+ // 3. Build quadrant sections
91
+ for (const q of ImpactEffort.QUADRANTS) {
92
+ const section = document.createElement('section');
93
+ section.className = 'ie-quadrant';
94
+ section.dataset.quadrantZone = q;
95
+ section.setAttribute('aria-label', `${ImpactEffort.LABELS[q]}: ${ImpactEffort.DESCRIPTIONS[q]}`);
96
+
97
+ const header = document.createElement('header');
98
+ header.className = 'ie-quadrant-label';
99
+ header.innerHTML =
100
+ `${ImpactEffort.LABELS[q]}<br><span class="ie-quadrant-desc">${ImpactEffort.DESCRIPTIONS[q]}</span>`;
101
+
102
+ const surface = document.createElement('drag-surface');
103
+ surface.setAttribute('group', this.#groupId);
104
+ surface.setAttribute('aria-label', ImpactEffort.LABELS[q]);
105
+ surface.setAttribute('data-layout', 'stack');
106
+ surface.setAttribute('data-layout-gap', 'xs');
107
+
108
+ section.appendChild(header);
109
+ section.appendChild(surface);
110
+ grid.appendChild(section);
111
+
112
+ this.#surfaces[q] = surface;
113
+ }
114
+
115
+ // 4. Distribute captured children into matching surfaces
116
+ children.forEach((c, i) => {
117
+ const child = /** @type {HTMLElement} */ (c);
118
+ const quadrant = child.getAttribute('data-quadrant') || 'quick-wins';
119
+ const target = this.#surfaces[quadrant] || this.#surfaces['quick-wins'];
120
+
121
+ // Ensure draggable and data-id
122
+ if (!child.hasAttribute('draggable')) {
123
+ child.setAttribute('draggable', 'true');
124
+ }
125
+ if (!child.hasAttribute('data-id')) {
126
+ child.dataset.id = `ie-item-${i}`;
127
+ }
128
+
129
+ target.appendChild(child);
130
+ });
131
+
132
+ // 5. Create live region
133
+ const liveRegion = document.createElement('div');
134
+ liveRegion.className = 'ie-live-region';
135
+ liveRegion.setAttribute('role', 'status');
136
+ liveRegion.setAttribute('aria-live', 'polite');
137
+ liveRegion.setAttribute('aria-atomic', 'true');
138
+
139
+ // 6. Assemble DOM
140
+ const title = this.getAttribute('title');
141
+ if (title) {
142
+ const heading = document.createElement('h3');
143
+ heading.className = 'ie-title';
144
+ heading.textContent = title;
145
+ this.prepend(heading);
146
+ }
147
+
148
+ column.appendChild(grid);
149
+ column.appendChild(xLabel);
150
+ wrapper.appendChild(yLabel);
151
+ wrapper.appendChild(column);
152
+ this.appendChild(wrapper);
153
+ this.appendChild(liveRegion);
154
+
155
+ this.#grid = grid;
156
+ this.#liveRegion = liveRegion;
157
+ this.#wrapper = wrapper;
158
+
159
+ // 7. Listen for cross-surface transfers
160
+ this.listen(this, 'drag-surface:transfer', (/** @type {CustomEvent} */ e) => {
161
+ const { item, fromSurface, toSurface } = e.detail;
162
+ const fromQuadrant = this.#findQuadrantForSurface(fromSurface);
163
+ const toQuadrant = this.#findQuadrantForSurface(toSurface);
164
+
165
+ if (!fromQuadrant || !toQuadrant) return;
166
+
167
+ // Update the item's quadrant attribute
168
+ item.setAttribute('data-quadrant', toQuadrant);
169
+
170
+ // Announce the move
171
+ this.#announce(`Moved ${this.#itemLabel(item)} to ${ImpactEffort.LABELS[toQuadrant]}`);
172
+
173
+ // Dispatch component-level event
174
+ this.dispatchEvent(new CustomEvent('impact-effort:move', {
175
+ bubbles: true,
176
+ detail: {
177
+ itemId: item.dataset.id,
178
+ from: fromQuadrant,
179
+ to: toQuadrant,
180
+ item,
181
+ },
182
+ }));
183
+ });
184
+
185
+ // 8. Handle src attribute for data mode
186
+ const src = this.getAttribute('src');
187
+ if (src) {
188
+ this.#loadFromSrc(src);
189
+ }
190
+
191
+ // 9. Dispatch ready event with counts
192
+ this.#dispatchReady();
193
+ }
194
+
195
+ teardown() {
196
+ if (this.#wrapper) {
197
+ this.#wrapper.remove();
198
+ this.#wrapper = null;
199
+ }
200
+ if (this.#liveRegion) {
201
+ this.#liveRegion.remove();
202
+ this.#liveRegion = null;
203
+ }
204
+ this.#grid = null;
205
+ this.#surfaces = {};
206
+ this.#nodes.clear();
207
+ this.#items = [];
208
+ }
209
+
210
+ // ── Data API (HTML-first / JS-first dual contract) ──────────────
211
+
212
+ /** @type {any[]} */
213
+ #items = [];
214
+ /** @type {Map<unknown, Element>} */
215
+ #nodes = new Map();
216
+
217
+ /**
218
+ * Read the matrix items as a plain array. Each entry:
219
+ * `{ id, quadrant: 'quick-wins'|'big-bets'|'fill-ins'|'money-pit', text? }`.
220
+ * After upgrade reflects the parsed children; after assignment reflects
221
+ * what was passed in.
222
+ */
223
+ get items() {
224
+ if (this.#items.length) return this.#items;
225
+ const result = [];
226
+ for (const [q, surface] of Object.entries(this.#surfaces)) {
227
+ for (const el of surface.querySelectorAll(':scope > [draggable="true"]')) {
228
+ result.push({
229
+ id: el.getAttribute('data-id') || undefined,
230
+ quadrant: q,
231
+ text: el.textContent?.trim() || undefined,
232
+ });
233
+ }
234
+ }
235
+ return result;
236
+ }
237
+
238
+ /**
239
+ * Replace the matrix items and route each into its quadrant surface.
240
+ * Runs a keyed diff so existing nodes whose id persists are preserved
241
+ * across re-assignment (in-flight drag state, focus, animations survive).
242
+ * New ids render as <article class="ie-card"> by default; override via
243
+ * `.renderItem`.
244
+ *
245
+ * Emits impact-effort:items-changed { items, source: 'property' }.
246
+ */
247
+ set items(value) {
248
+ if (!this.#grid) return;
249
+ const next = Array.isArray(value) ? value : [];
250
+ diffByKey({
251
+ newItems: next,
252
+ nodes: this.#nodes,
253
+ keyOf: (it) => it.id ?? `${it.quadrant}:${it.text}`,
254
+ renderItem: (it) => {
255
+ const self = /** @type {any} */ (this);
256
+ if (typeof self.renderItem === 'function') {
257
+ const out = self.renderItem(it);
258
+ if (out instanceof Element) {
259
+ if (!out.hasAttribute('draggable')) out.setAttribute('draggable', 'true');
260
+ if (!out.hasAttribute('data-id')) out.setAttribute('data-id', String(it.id ?? ''));
261
+ return out;
262
+ }
263
+ }
264
+ const article = document.createElement('article');
265
+ article.className = 'ie-card';
266
+ article.setAttribute('draggable', 'true');
267
+ if (it.id) article.setAttribute('data-id', String(it.id));
268
+ article.textContent = it.text || it.id || '';
269
+ return article;
270
+ },
271
+ containerFor: (it) => this.#surfaces[it.quadrant] || this.#surfaces['quick-wins'],
272
+ });
273
+ this.#items = next;
274
+ this.dispatchEvent(new CustomEvent('impact-effort:items-changed', {
275
+ detail: { items: next, source: 'property' },
276
+ bubbles: true,
277
+ }));
278
+ }
279
+
280
+ attributeChangedCallback(name, oldVal, newVal) {
281
+ if (!this.hasAttribute('data-upgraded')) return;
282
+
283
+ if (name === 'src' && newVal && newVal !== oldVal) {
284
+ this.#loadFromSrc(newVal);
285
+ }
286
+ }
287
+
288
+ // ── Data loading ──────────────────────────────────────────────────────────
289
+
290
+ async #loadFromSrc(url) {
291
+ try {
292
+ const res = await fetch(url);
293
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
294
+ const data = await res.json();
295
+
296
+ if (!Array.isArray(data)) return;
297
+
298
+ const swap = () => {
299
+ // Clear existing items from all surfaces
300
+ for (const q of ImpactEffort.QUADRANTS) {
301
+ const surface = this.#surfaces[q];
302
+ if (!surface) continue;
303
+ for (const child of [...surface.querySelectorAll('[draggable]')]) {
304
+ child.remove();
305
+ }
306
+ }
307
+
308
+ // Create and distribute items — use <user-story> when story data present
309
+ data.forEach((entry, i) => {
310
+ const quadrant = entry.quadrant || 'quick-wins';
311
+ const id = entry.id || `ie-item-${i}`;
312
+ let el;
313
+
314
+ if (entry.persona || entry.action || entry.storyId) {
315
+ el = document.createElement('user-story');
316
+ el.setAttribute('detail', 'minimal');
317
+ if (entry.storyId) el.setAttribute('story-id', entry.storyId);
318
+ if (entry.persona) el.setAttribute('persona', entry.persona);
319
+ if (entry.action) el.setAttribute('action', entry.action);
320
+ if (entry.benefit) el.setAttribute('benefit', entry.benefit);
321
+ if (entry.priority) el.setAttribute('priority', entry.priority);
322
+ if (entry.status) el.setAttribute('status', entry.status);
323
+ if (entry.points) el.setAttribute('points', String(entry.points));
324
+ } else {
325
+ el = document.createElement('article');
326
+ el.textContent = entry.label || entry.text || '';
327
+ }
328
+
329
+ el.setAttribute('draggable', 'true');
330
+ el.dataset.id = id;
331
+ el.dataset.quadrant = quadrant;
332
+
333
+ const target = this.#surfaces[quadrant] || this.#surfaces['quick-wins'];
334
+ target.appendChild(el);
335
+ });
336
+ };
337
+
338
+ if (this.hasAttribute('data-upgraded') && this.#grid) {
339
+ viewTransitionSwap(this, swap, 'ie-vt');
340
+ } else {
341
+ swap();
342
+ }
343
+
344
+ this.#dispatchReady();
345
+ } catch (err) {
346
+ console.warn(`[impact-effort] Failed to load src="${url}":`, err);
347
+ }
348
+ }
349
+
350
+ // ── Helpers ───────────────────────────────────────────────────────────────
351
+
352
+ /** Reverse lookup: find which quadrant name owns a given drag-surface element */
353
+ #findQuadrantForSurface(surface) {
354
+ for (const [q, s] of Object.entries(this.#surfaces)) {
355
+ if (s === surface) return q;
356
+ }
357
+ return null;
358
+ }
359
+
360
+ /** Set live region text content (with clear-and-set for re-announcement) */
361
+ #announce(msg) {
362
+ const region = this.#liveRegion;
363
+ if (!region) return;
364
+ region.textContent = '';
365
+ requestAnimationFrame(() => {
366
+ region.textContent = msg;
367
+ });
368
+ }
369
+
370
+ /** Derive a label for an item from data-id or textContent */
371
+ #itemLabel(item) {
372
+ return item.dataset.id || item.textContent.trim().slice(0, 40);
373
+ }
374
+
375
+ /** Dispatch the ready event with counts per quadrant */
376
+ #dispatchReady() {
377
+ const quadrantCounts = {};
378
+ for (const q of ImpactEffort.QUADRANTS) {
379
+ const surface = this.#surfaces[q];
380
+ quadrantCounts[q] = surface
381
+ ? surface.querySelectorAll('[draggable="true"]').length
382
+ : 0;
383
+ }
384
+ this.dispatchEvent(new CustomEvent('impact-effort:ready', {
385
+ bubbles: true,
386
+ detail: { quadrantCounts },
387
+ }));
388
+ }
389
+ }
390
+
391
+ registerComponent('impact-effort', ImpactEffort);
392
+ export { ImpactEffort };
@@ -0,0 +1,23 @@
1
+ <!--
2
+ impact-effort — static (zero-JS) form
3
+ User-story children compose naturally. Before JS upgrades,
4
+ they display as a flat list; after, they're distributed into the 2x2 grid.
5
+ -->
6
+ <impact-effort>
7
+ <user-story data-quadrant="quick-wins" draggable="true" data-id="PROJ-201"
8
+ story-id="PROJ-201" persona="PM" action="add CSV export to reports"
9
+ priority="medium" status="to-do" points="2" detail="minimal">
10
+ </user-story>
11
+ <user-story data-quadrant="big-bets" draggable="true" data-id="PROJ-301"
12
+ story-id="PROJ-301" persona="Developer" action="migrate to new API gateway"
13
+ priority="high" status="in-progress" points="13" detail="minimal">
14
+ </user-story>
15
+ <user-story data-quadrant="fill-ins" draggable="true" data-id="PROJ-401"
16
+ story-id="PROJ-401" persona="Developer" action="update README with new setup steps"
17
+ priority="low" status="backlog" points="1" detail="minimal">
18
+ </user-story>
19
+ <user-story data-quadrant="money-pit" draggable="true" data-id="PROJ-501"
20
+ story-id="PROJ-501" persona="Admin" action="rewrite legacy auth module"
21
+ priority="medium" status="backlog" points="21" detail="minimal">
22
+ </user-story>
23
+ </impact-effort>
@@ -0,0 +1,193 @@
1
+ /* ── impact-effort ─────────────────────────────────────────────────────────── */
2
+ /* Light DOM styles — element selector pattern */
3
+
4
+ impact-effort {
5
+ display: block;
6
+ container-type: inline-size;
7
+ }
8
+
9
+ impact-effort:not(:defined) {
10
+ display: block;
11
+ }
12
+
13
+ /* ── Wrapper (y-label + column) ──────────────────────────────────────────── */
14
+
15
+ .ie-wrapper {
16
+ display: flex;
17
+ align-items: stretch;
18
+ }
19
+
20
+ .ie-y-label {
21
+ writing-mode: vertical-rl;
22
+ transform: rotate(180deg);
23
+ font-size: 0.7rem;
24
+ font-weight: 600;
25
+ color: var(--color-text-muted, #666);
26
+ text-align: center;
27
+ padding: 0 var(--size-xs, 8px);
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ user-select: none;
32
+ }
33
+
34
+ .ie-x-label {
35
+ font-size: 0.7rem;
36
+ font-weight: 600;
37
+ color: var(--color-text-muted, #666);
38
+ text-align: center;
39
+ padding: var(--size-xs, 8px) 0;
40
+ user-select: none;
41
+ }
42
+
43
+ /* ── 2x2 Grid ────────────────────────────────────────────────────────────── */
44
+
45
+ .ie-grid {
46
+ grid-template-columns: 1fr 1fr;
47
+ grid-template-rows: 1fr 1fr;
48
+ gap: 1px;
49
+ background: var(--color-border, #e0e0e0);
50
+ border: 1px solid var(--color-border, #e0e0e0);
51
+ border-radius: var(--radius-l, 0.75rem);
52
+ overflow: hidden;
53
+ min-height: 24rem;
54
+ display: grid;
55
+ flex: 1;
56
+ }
57
+
58
+ /* ── Quadrant sections ───────────────────────────────────────────────────── */
59
+
60
+ .ie-quadrant {
61
+ background: var(--color-surface, #fff);
62
+ padding: var(--size-m, 16px);
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: var(--size-xs, 8px);
66
+ }
67
+
68
+ .ie-quadrant-label {
69
+ font-size: 0.75rem;
70
+ font-weight: 700;
71
+ text-transform: uppercase;
72
+ letter-spacing: 0.05em;
73
+ color: var(--color-text-muted, #666);
74
+ margin-bottom: var(--size-xs, 8px);
75
+ }
76
+
77
+ .ie-quadrant-desc {
78
+ font-size: 0.65rem;
79
+ font-weight: 400;
80
+ text-transform: none;
81
+ letter-spacing: normal;
82
+ color: var(--color-text-muted, #999);
83
+ display: block;
84
+ }
85
+
86
+ /* ── Quadrant zone tints ─────────────────────────────────────────────────── */
87
+
88
+ [data-quadrant-zone="quick-wins"] {
89
+ background: color-mix(in oklch, var(--color-success, #22c55e) 6%, var(--color-surface, #fff));
90
+ }
91
+
92
+ [data-quadrant-zone="big-bets"] {
93
+ background: color-mix(in oklch, var(--color-warning, #f59e0b) 6%, var(--color-surface, #fff));
94
+ }
95
+
96
+ [data-quadrant-zone="fill-ins"] {
97
+ background: color-mix(in oklch, var(--color-info, #3b82f6) 6%, var(--color-surface, #fff));
98
+ }
99
+
100
+ [data-quadrant-zone="money-pit"] {
101
+ background: color-mix(in oklch, var(--color-error, #ef4444) 6%, var(--color-surface, #fff));
102
+ }
103
+
104
+ /* ── Inner drag-surfaces ─────────────────────────────────────────────────── */
105
+
106
+ impact-effort drag-surface {
107
+ min-block-size: 6rem;
108
+ flex: 1;
109
+ }
110
+
111
+ /* ── Live region (visually hidden) ───────────────────────────────────────── */
112
+
113
+ .ie-live-region {
114
+ position: absolute;
115
+ width: 1px;
116
+ height: 1px;
117
+ overflow: hidden;
118
+ clip: rect(0, 0, 0, 0);
119
+ white-space: nowrap;
120
+ border: 0;
121
+ padding: 0;
122
+ margin: -1px;
123
+ }
124
+
125
+ /* ── Compact variant ─────────────────────────────────────────────────────── */
126
+
127
+ impact-effort[compact] .ie-grid {
128
+ min-height: 16rem;
129
+ }
130
+
131
+ impact-effort[compact] .ie-quadrant {
132
+ padding: var(--size-xs, 8px);
133
+ gap: var(--size-2xs, 4px);
134
+ }
135
+
136
+ impact-effort[compact] .ie-quadrant-label {
137
+ font-size: 0.65rem;
138
+ margin-bottom: var(--size-2xs, 4px);
139
+ }
140
+
141
+ impact-effort[compact] .ie-quadrant-desc {
142
+ font-size: 0.6rem;
143
+ }
144
+
145
+ impact-effort[compact] drag-surface {
146
+ min-block-size: 4rem;
147
+ }
148
+
149
+ impact-effort[compact] .ie-y-label,
150
+ impact-effort[compact] .ie-x-label {
151
+ font-size: 0.6rem;
152
+ }
153
+
154
+ /* ── Responsive: single column ───────────────────────────────────────────── */
155
+
156
+ @container (max-width: 30rem) {
157
+ .ie-grid {
158
+ grid-template-columns: 1fr;
159
+ grid-template-rows: auto;
160
+ }
161
+ }
162
+
163
+ /* ── Reduced motion ──────────────────────────────────────────────────────── */
164
+
165
+ @media (prefers-reduced-motion: reduce) {
166
+ impact-effort,
167
+ impact-effort * {
168
+ transition: none;
169
+ }
170
+ }
171
+
172
+ /* ── Print ───────────────────────────────────────────────────────────────── */
173
+
174
+ @media print {
175
+ .ie-grid {
176
+ border: 1px solid #ccc;
177
+ gap: 0;
178
+ min-height: auto;
179
+ }
180
+
181
+ .ie-quadrant {
182
+ border: 0.5pt solid #ccc;
183
+ break-inside: avoid;
184
+ }
185
+
186
+ impact-effort drag-surface {
187
+ min-block-size: auto;
188
+ }
189
+
190
+ impact-effort [draggable] {
191
+ cursor: default;
192
+ }
193
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Pure capacity-calculation helpers for <iron-triangle>.
3
+ *
4
+ * Kept separate from the component so the component stays slim and so
5
+ * the math is unit-testable without DOM stubs.
6
+ */
7
+
8
+ /**
9
+ * Default capacity formula: sprintWeeks × teamFTE × focusFactor, ceilinged.
10
+ * Returns 0 when inputs are missing or non-positive.
11
+ */
12
+ export function defaultFormula(time, cost, focusFactor) {
13
+ const weeks = num(time?.sprintCount) > 0
14
+ ? num(time.sprintCount) * (num(time.sprintWeeks) || 1)
15
+ : num(time?.sprintWeeks);
16
+ const fte = num(cost?.teamFTE);
17
+ const f = clamp(num(focusFactor), 0, 1);
18
+ if (!(weeks > 0) || !(fte > 0) || !(f > 0)) return 0;
19
+ return Math.ceil(weeks * fte * f);
20
+ }
21
+
22
+ /**
23
+ * Format the formula string used as the human-readable explanation
24
+ * shown beneath the readout. Matches the formula above; if either
25
+ * factor is missing the string degrades gracefully.
26
+ */
27
+ export function defaultFormulaText(time, cost, focusFactor) {
28
+ const sprintCount = num(time?.sprintCount);
29
+ const sprintWeeks = num(time?.sprintWeeks) || 1;
30
+ const totalWeeks = sprintCount > 0 ? sprintCount * sprintWeeks : sprintWeeks;
31
+ const fte = num(cost?.teamFTE);
32
+ const f = clamp(num(focusFactor), 0, 1);
33
+ const points = defaultFormula(time, cost, focusFactor);
34
+ if (!points) return 'Set sprint length, team size, and focus factor to compute capacity.';
35
+ const weeksLabel = sprintCount > 0
36
+ ? `${sprintCount} × ${sprintWeeks}wk = ${totalWeeks}wk`
37
+ : `${sprintWeeks}wk`;
38
+ return `${weeksLabel} × ${fte} FTE × ${f} = ${(totalWeeks * fte * f).toFixed(1)} → ${points} points`;
39
+ }
40
+
41
+ /**
42
+ * FNV-1a (32-bit) hash over a stable JSON serialization of the triangle.
43
+ * Used for drift detection by downstream consumers (notably <nfr-compass>'s
44
+ * ironTriangleHash field).
45
+ *
46
+ * Matches the fnv1a in src/web-components/color-picker/_color-utils.js so
47
+ * the two surfaces share one hash function.
48
+ */
49
+ export function fnv1a(str) {
50
+ let hash = 0x811c9dc5;
51
+ for (let i = 0; i < str.length; i++) {
52
+ hash ^= str.charCodeAt(i);
53
+ hash = Math.imul(hash, 0x01000193);
54
+ }
55
+ return (hash >>> 0).toString(36);
56
+ }
57
+
58
+ /**
59
+ * Stable JSON serialization (sorted keys) so the hash is deterministic
60
+ * across browsers and key-insertion order.
61
+ */
62
+ export function stableStringify(value) {
63
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
64
+ if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
65
+ const keys = Object.keys(value).sort();
66
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
67
+ }
68
+
69
+ export function triangleHash({ time, cost, scope }) {
70
+ return fnv1a(stableStringify({ time: time || {}, cost: cost || {}, scope: scope || {} }));
71
+ }
72
+
73
+ // ── helpers ────────────────────────────────────────────────────────
74
+
75
+ function num(value) {
76
+ if (value === '' || value == null) return 0;
77
+ const n = Number(value);
78
+ return Number.isFinite(n) ? n : 0;
79
+ }
80
+
81
+ function clamp(n, min, max) {
82
+ return Math.max(min, Math.min(max, n));
83
+ }