@profpowell/vb-project-planning 0.2.1 → 0.2.3

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 (66) hide show
  1. package/README.md +10 -5
  2. package/dist/vb-project-planning.css +69 -3
  3. package/dist/vb-project-planning.js +3191 -2544
  4. package/dist/vb-project-planning.min.css +1 -1
  5. package/dist/vb-project-planning.min.js +221 -197
  6. package/package.json +7 -4
  7. package/src/lib/board-surface.js +158 -0
  8. package/src/lib/bundle-registry.js +2 -75
  9. package/src/lib/dates.js +125 -0
  10. package/src/lib/escape.js +34 -0
  11. package/src/lib/hash.js +16 -0
  12. package/src/lib/portrait-url.js +3 -29
  13. package/src/lib/src-loader.js +78 -0
  14. package/src/lib/vb-view-transition.js +7 -1
  15. package/src/utils/copy-init.js +56 -17
  16. package/src/web-components/_ux-base.js +409 -24
  17. package/src/web-components/adr-wc/README.md +18 -16
  18. package/src/web-components/adr-wc/api.json +7 -5
  19. package/src/web-components/adr-wc/logic.js +50 -136
  20. package/src/web-components/burndown-chart/api.json +8 -6
  21. package/src/web-components/burndown-chart/logic.js +75 -39
  22. package/src/web-components/empathy-map/api.json +13 -13
  23. package/src/web-components/empathy-map/logic.js +161 -168
  24. package/src/web-components/empathy-map/static.html +3 -3
  25. package/src/web-components/empathy-map/styles.js +1 -0
  26. package/src/web-components/gantt-chart/README.md +12 -6
  27. package/src/web-components/gantt-chart/api.json +23 -7
  28. package/src/web-components/gantt-chart/logic.js +456 -431
  29. package/src/web-components/gantt-chart/styles.css +14 -1
  30. package/src/web-components/impact-effort/api.json +9 -7
  31. package/src/web-components/impact-effort/logic.js +238 -209
  32. package/src/web-components/impact-effort/static.html +12 -4
  33. package/src/web-components/iron-triangle/_capacity.js +0 -18
  34. package/src/web-components/kanban-board/api.json +8 -6
  35. package/src/web-components/kanban-board/logic.js +289 -333
  36. package/src/web-components/product-roadmap/api.json +14 -13
  37. package/src/web-components/product-roadmap/logic.js +393 -246
  38. package/src/web-components/product-roadmap/styles.css +40 -0
  39. package/src/web-components/quadrant-grid/api.json +9 -4
  40. package/src/web-components/quadrant-grid/logic.js +80 -12
  41. package/src/web-components/quadrant-grid/styles.css +7 -0
  42. package/src/web-components/quality-target/_quality-utils.js +2 -46
  43. package/src/web-components/review-surface/README.md +9 -5
  44. package/src/web-components/review-surface/api.json +10 -8
  45. package/src/web-components/review-surface/logic.js +349 -218
  46. package/src/web-components/risk-register/api.json +1 -6
  47. package/src/web-components/risk-register/logic.js +71 -24
  48. package/src/web-components/risk-register/styles.css +9 -1
  49. package/src/web-components/story-map/api.json +8 -6
  50. package/src/web-components/story-map/logic.js +280 -281
  51. package/src/web-components/story-map/static.html +15 -5
  52. package/src/web-components/traceability-matrix/api.json +10 -3
  53. package/src/web-components/traceability-matrix/logic.js +112 -63
  54. package/src/web-components/traceability-matrix/styles.css +22 -1
  55. package/src/web-components/user-journey/api.json +25 -24
  56. package/src/web-components/user-journey/logic.js +76 -127
  57. package/src/web-components/user-journey/static.html +2 -2
  58. package/src/web-components/user-persona/api.json +15 -14
  59. package/src/web-components/user-persona/logic.js +71 -117
  60. package/src/web-components/user-persona/styles.js +1 -0
  61. package/src/web-components/user-story/api.json +13 -11
  62. package/src/web-components/user-story/logic.js +164 -195
  63. package/src/web-components/user-story/styles.js +28 -1
  64. package/src/web-components/work-item/README.md +3 -3
  65. package/src/web-components/work-item/api.json +13 -11
  66. package/src/web-components/work-item/logic.js +58 -154
@@ -107,6 +107,14 @@ gantt-chart:not([data-upgraded]) > table {
107
107
  text-overflow: ellipsis;
108
108
  }
109
109
 
110
+ .gc-task-status {
111
+ flex: none;
112
+ margin-inline-start: auto;
113
+ font-size: 11px;
114
+ font-weight: 600;
115
+ color: var(--color-text-muted, #666666);
116
+ }
117
+
110
118
  .gc-task-dates {
111
119
  position: absolute;
112
120
  width: 1px;
@@ -291,12 +299,16 @@ gantt-chart:not([data-upgraded]) > table {
291
299
  fill: none;
292
300
  stroke: var(--color-text-muted, #666666);
293
301
  stroke-width: 1.5;
294
- marker-end: url(#gc-arrowhead);
302
+ /* marker-end is set per path to this chart's own marker id. */
295
303
  }
296
304
 
297
305
  /* ── Visually hidden (original table + live region) ── */
306
+ /* Visually hidden, still read by screen readers. table-layout: fixed is
307
+ required: a table ignores width: 1px and grows to its content, which made
308
+ the page scroll sideways on phones. */
298
309
  .gc-sr-only {
299
310
  position: absolute;
311
+ table-layout: fixed;
300
312
  width: 1px;
301
313
  height: 1px;
302
314
  padding: 0;
@@ -369,6 +381,7 @@ gantt-chart[compact] .gc-group-spacer {
369
381
 
370
382
  gantt-chart[data-upgraded] .gc-sr-only {
371
383
  position: static;
384
+ table-layout: auto;
372
385
  width: auto;
373
386
  height: auto;
374
387
  margin: 0;
@@ -5,17 +5,19 @@
5
5
  "description": "2x2 prioritization matrix with drag-and-drop between quadrants (Quick Wins, Big Bets, Fill-Ins, Money Pit)",
6
6
  "htmlvalidate": { "flow": true, "permittedContent": ["@flow"] },
7
7
  "attributes": [
8
- { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON data for items" },
8
+ { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to a JSON array of items ({ id, quadrant, label }). Fetched once per value; a new value replaces the items and aborts an older in-flight request" },
9
9
  { "name": "title", "kind": "host-api", "purpose": "config", "type": "string", "description": "Optional heading above the matrix" },
10
- { "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Reduced spacing variant" }
10
+ { "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Reduces grid height, padding, and label sizes for denser layouts" }
11
11
  ],
12
12
  "childAttributes": [
13
- { "name": "data-quadrant", "on": "any child", "type": "enum", "values": ["quick-wins", "big-bets", "fill-ins", "money-pit"], "description": "Target quadrant for this item" },
14
- { "name": "draggable", "on": "any child", "type": "boolean", "description": "Required for drag capability" },
15
- { "name": "data-id", "on": "any child", "type": "string", "description": "Stable identifier for the item" }
13
+ { "name": "data-quadrant", "on": "any child", "type": "enum", "values": ["quick-wins", "big-bets", "fill-ins", "money-pit"], "description": "Target quadrant for this item (default quick-wins); kept up to date as the item is dragged" },
14
+ { "name": "draggable", "on": "any child", "type": "boolean", "description": "Required for drag capability (added if missing)" },
15
+ { "name": "data-id", "on": "any child", "type": "string", "description": "Stable identifier for the item, used as the .items key (generated if missing)" }
16
16
  ],
17
17
  "events": [
18
- { "name": "impact-effort:move", "detail": "{ itemId, from, to, item }", "description": "Fired when an item is dragged between quadrants" },
19
- { "name": "impact-effort:ready", "detail": "{ quadrantCounts }", "description": "Fired after component initializes" }
18
+ { "name": "impact-effort:move", "detail": "{ itemId, from, to, item }", "description": "Fired when an item is dragged between quadrants; from and to are quadrant names" },
19
+ { "name": "impact-effort:ready", "detail": "{ quadrantCounts }", "description": "Fired once after the first render, with the number of items per quadrant. With src, fired once the loaded cards are in the DOM (after the View Transition's update). Once per element: not re-fired by a later src change (watch items-changed with source 'src') or when the matrix is moved in the DOM" },
20
+ { "name": "impact-effort:items-changed", "detail": "{ items, source: 'property' | 'src' }", "description": "Fired after each .items assignment (source 'property') and after each src load's cards are in the DOM (source 'src')" },
21
+ { "name": "impact-effort:upgraded", "detail": "", "description": "Fired (in a microtask) each time the matrix connects and upgrades" }
20
22
  ]
21
23
  }
@@ -9,6 +9,7 @@
9
9
  * registered by the main bundle or pack entry.
10
10
  *
11
11
  * @attr {string} src - URL to JSON data for items
12
+ * @attr {string} title - Optional heading above the matrix
12
13
  * @attr {boolean} compact - Reduced spacing variant
13
14
  *
14
15
  * Children use:
@@ -17,13 +18,16 @@
17
18
  * @attr {string} data-id - Stable identifier for the item
18
19
  *
19
20
  * @fires impact-effort:move - When an item is dragged between quadrants
20
- * @fires impact-effort:ready - After component initializes with quadrant counts
21
+ * @fires impact-effort:ready - Once per element, after the first render (with src: after the loaded cards are in the DOM)
22
+ * @fires impact-effort:items-changed - { items, source: 'property' | 'src' } after .items is set or src loads
23
+ * @fires impact-effort:upgraded - Each time the matrix connects and upgrades
21
24
  */
22
25
 
23
26
  import { registerComponent } from '../../lib/bundle-registry.js';
24
27
  import { VBElement } from '../../lib/vb-element.js';
25
28
  import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
26
29
  import { diffByKey } from '../../lib/diff-by-key.js';
30
+ import { SrcLoader, announce, createLiveRegion, createStoryCard, isStoryRecord, itemLabel } from '../../lib/board-surface.js';
27
31
 
28
32
  class ImpactEffort extends VBElement {
29
33
  static QUADRANTS = ['quick-wins', 'big-bets', 'fill-ins', 'money-pit'];
@@ -36,10 +40,10 @@ class ImpactEffort extends VBElement {
36
40
  };
37
41
 
38
42
  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
+ 'quick-wins': 'High impact · Low effort',
44
+ 'big-bets': 'High impact · High effort',
45
+ 'fill-ins': 'Low impact · Low effort',
46
+ 'money-pit': 'Low impact · High effort',
43
47
  };
44
48
 
45
49
  static #instanceCount = 0;
@@ -48,20 +52,68 @@ class ImpactEffort extends VBElement {
48
52
  return ['src', 'compact', 'title'];
49
53
  }
50
54
 
51
- /** @type {HTMLElement | null} */
52
- #grid = null;
53
55
  /** @type {Record<string, HTMLElement>} */
54
56
  #surfaces = {};
55
57
  /** @type {HTMLElement | null} */
56
58
  #liveRegion = null;
59
+ /** @type {HTMLHeadingElement | null} */
60
+ #titleEl = null;
57
61
  /** @type {string} */
58
62
  #groupId = '';
59
- /** @type {HTMLElement | null} */
60
- #wrapper = null;
63
+ /** Shell built (survives disconnect so a moved matrix keeps its cards). */
64
+ #built = false;
65
+ /** Listeners attached for the current connection. */
66
+ #wired = false;
67
+ #readyFired = false;
68
+ #loader = new SrcLoader();
69
+ /** Items assigned before upgrade, applied on first setup. */
70
+ /** @type {any[] | null} */
71
+ #pending = null;
72
+
73
+ /** key → rendered card (authored, assigned, or loaded). */
74
+ /** @type {Map<unknown, Element>} */
75
+ #nodes = new Map();
76
+ /** key → last record assigned via `.items` or `src`. */
77
+ /** @type {Map<unknown, any>} */
78
+ #records = new Map();
61
79
 
62
80
  setup() {
63
- this.#groupId = `ie-${++ImpactEffort.#instanceCount}`;
81
+ if (!this.#groupId) this.#groupId = `ie-${++ImpactEffort.#instanceCount}`;
82
+
83
+ if (!this.#built) {
84
+ this.#built = true;
85
+ this.#build();
86
+ if (this.#pending) {
87
+ const pending = this.#pending;
88
+ this.#pending = null;
89
+ this.items = pending;
90
+ }
91
+ }
64
92
 
93
+ if (!this.#wired) {
94
+ this.#wired = true;
95
+ this.listen(this, 'drag-surface:transfer', this.#onTransfer);
96
+ }
97
+
98
+ // Data mode: fetch once per src value; ready fires after it renders.
99
+ const src = this.getAttribute('src');
100
+ if (src) {
101
+ if (src !== this.#loader.url) this.#loadFromSrc(src);
102
+ return;
103
+ }
104
+
105
+ if (!this.#readyFired) this.#dispatchReady();
106
+ }
107
+
108
+ teardown() {
109
+ // Listeners are removed by VBElement. The grid, title and cards stay
110
+ // in place so a matrix that is moved in the DOM keeps its content;
111
+ // the next connect only re-attaches listeners.
112
+ this.#wired = false;
113
+ }
114
+
115
+ /** First build: wrapper, quadrants, live region; authored cards moved in. */
116
+ #build() {
65
117
  // 1. Snapshot children that belong in quadrants
66
118
  const children = [...this.querySelectorAll(':scope > [data-quadrant], :scope > [draggable]')];
67
119
 
@@ -72,7 +124,7 @@ class ImpactEffort extends VBElement {
72
124
  const yLabel = document.createElement('div');
73
125
  yLabel.className = 'ie-y-label';
74
126
  yLabel.setAttribute('aria-hidden', 'true');
75
- yLabel.textContent = 'Impact \u2191';
127
+ yLabel.textContent = 'Impact ↑';
76
128
 
77
129
  const column = document.createElement('div');
78
130
  column.style.cssText = 'display:flex;flex-direction:column;flex:1;min-width:0;';
@@ -85,7 +137,7 @@ class ImpactEffort extends VBElement {
85
137
  const xLabel = document.createElement('div');
86
138
  xLabel.className = 'ie-x-label';
87
139
  xLabel.setAttribute('aria-hidden', 'true');
88
- xLabel.textContent = 'Effort \u2192';
140
+ xLabel.textContent = 'Effort →';
89
141
 
90
142
  // 3. Build quadrant sections
91
143
  for (const q of ImpactEffort.QUADRANTS) {
@@ -96,8 +148,10 @@ class ImpactEffort extends VBElement {
96
148
 
97
149
  const header = document.createElement('header');
98
150
  header.className = 'ie-quadrant-label';
99
- header.innerHTML =
100
- `${ImpactEffort.LABELS[q]}<br><span class="ie-quadrant-desc">${ImpactEffort.DESCRIPTIONS[q]}</span>`;
151
+ const desc = document.createElement('span');
152
+ desc.className = 'ie-quadrant-desc';
153
+ desc.textContent = ImpactEffort.DESCRIPTIONS[q];
154
+ header.append(ImpactEffort.LABELS[q], document.createElement('br'), desc);
101
155
 
102
156
  const surface = document.createElement('drag-surface');
103
157
  surface.setAttribute('group', this.#groupId);
@@ -112,124 +166,88 @@ class ImpactEffort extends VBElement {
112
166
  this.#surfaces[q] = surface;
113
167
  }
114
168
 
115
- // 4. Distribute captured children into matching surfaces
169
+ // 4. Distribute captured children into matching surfaces and register
170
+ // them so a later `.items` assignment diffs against them.
116
171
  children.forEach((c, i) => {
117
172
  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);
173
+ const quadrant = this.#surfaces[child.getAttribute('data-quadrant') || ''] ? child.getAttribute('data-quadrant') : 'quick-wins';
174
+ child.setAttribute('data-quadrant', /** @type {string} */ (quadrant));
175
+ if (!child.hasAttribute('draggable')) child.setAttribute('draggable', 'true');
176
+ if (!child.hasAttribute('data-id')) child.dataset.id = `ie-item-${i}`;
177
+ this.#surfaces[/** @type {string} */ (quadrant)].appendChild(child);
178
+ this.#nodes.set(child.dataset.id, child);
130
179
  });
131
180
 
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
-
181
+ // 5. Assemble DOM
182
+ this.#liveRegion = createLiveRegion('ie-live-region');
183
+ this.#syncTitle();
148
184
  column.appendChild(grid);
149
185
  column.appendChild(xLabel);
150
186
  wrapper.appendChild(yLabel);
151
187
  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;
188
+ this.append(wrapper, this.#liveRegion);
189
+ }
166
190
 
167
- // Update the item's quadrant attribute
168
- item.setAttribute('data-quadrant', toQuadrant);
191
+ /** Create, update, or remove the optional heading from the title attribute. */
192
+ #syncTitle() {
193
+ const title = this.getAttribute('title');
194
+ if (title) {
195
+ if (!this.#titleEl) {
196
+ this.#titleEl = document.createElement('h3');
197
+ this.#titleEl.className = 'ie-title';
198
+ this.prepend(this.#titleEl);
199
+ }
200
+ this.#titleEl.textContent = title;
201
+ } else if (this.#titleEl) {
202
+ this.#titleEl.remove();
203
+ this.#titleEl = null;
204
+ }
205
+ }
169
206
 
170
- // Announce the move
171
- this.#announce(`Moved ${this.#itemLabel(item)} to ${ImpactEffort.LABELS[toQuadrant]}`);
207
+ /** @param {Event} e */
208
+ #onTransfer = (e) => {
209
+ const { item, fromSurface, toSurface } = /** @type {CustomEvent} */ (e).detail;
210
+ const fromQuadrant = this.#findQuadrantForSurface(fromSurface);
211
+ const toQuadrant = this.#findQuadrantForSurface(toSurface);
172
212
 
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
- });
213
+ if (!fromQuadrant || !toQuadrant) return;
184
214
 
185
- // 8. Handle src attribute for data mode
186
- const src = this.getAttribute('src');
187
- if (src) {
188
- this.#loadFromSrc(src);
189
- }
215
+ // Update the item's quadrant attribute
216
+ item.setAttribute('data-quadrant', toQuadrant);
190
217
 
191
- // 9. Dispatch ready event with counts
192
- this.#dispatchReady();
193
- }
218
+ announce(this.#liveRegion, `Moved ${itemLabel(item)} to ${ImpactEffort.LABELS[toQuadrant]}`);
194
219
 
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
- }
220
+ this.dispatchEvent(new CustomEvent('impact-effort:move', {
221
+ bubbles: true,
222
+ detail: {
223
+ itemId: item.dataset.id,
224
+ from: fromQuadrant,
225
+ to: toQuadrant,
226
+ item,
227
+ },
228
+ }));
229
+ };
209
230
 
210
231
  // ── Data API (HTML-first / JS-first dual contract) ──────────────
211
232
 
212
- /** @type {any[]} */
213
- #items = [];
214
- /** @type {Map<unknown, Element>} */
215
- #nodes = new Map();
216
-
217
233
  /**
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.
234
+ * The matrix items in DOM order, reflecting drags. Each entry is the
235
+ * record last assigned for that id (via `.items` or `src`) with its
236
+ * current `quadrant`; authored cards read as `{ id, quadrant, label }`.
237
+ * Assigning the result back to `.items` keeps every card in place.
222
238
  */
223
239
  get items() {
224
- if (this.#items.length) return this.#items;
225
240
  const result = [];
226
241
  for (const [q, surface] of Object.entries(this.#surfaces)) {
227
242
  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
- });
243
+ const id = el.getAttribute('data-id') || undefined;
244
+ const key = this.#keyForNode(el);
245
+ const known = key !== undefined ? this.#records.get(key) : undefined;
246
+ if (known) {
247
+ result.push({ ...known, quadrant: q });
248
+ } else {
249
+ result.push({ id, quadrant: q, label: itemLabel(el) });
250
+ }
233
251
  }
234
252
  }
235
253
  return result;
@@ -237,111 +255,136 @@ class ImpactEffort extends VBElement {
237
255
 
238
256
  /**
239
257
  * 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
258
+ * Each item: `{ id, quadrant, label }` (`text` is accepted as an alias of
259
+ * `label`; story fields — persona, action, benefit, storyId — render a
260
+ * `<user-story>` card). Runs a keyed diff by `id`, so cards whose id
261
+ * persists (including authored ones) are moved, not recreated. Items
262
+ * without an `id` are keyed by their label (the nth repeat of a label
263
+ * keeps the nth such card), so they also survive drags and round-trips;
264
+ * give items an `id` if their labels change. New ids
265
+ * render as `<article class="ie-card">` by default; override via
243
266
  * `.renderItem`.
244
267
  *
245
268
  * Emits impact-effort:items-changed { items, source: 'property' }.
246
269
  */
247
270
  set items(value) {
248
- if (!this.#grid) return;
249
271
  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;
272
+ if (!this.#built) {
273
+ this.#pending = next;
274
+ return;
275
+ }
276
+ this.#applyItems(next);
274
277
  this.dispatchEvent(new CustomEvent('impact-effort:items-changed', {
275
278
  detail: { items: next, source: 'property' },
276
279
  bubbles: true,
277
280
  }));
278
281
  }
279
282
 
280
- attributeChangedCallback(name, oldVal, newVal) {
281
- if (!this.hasAttribute('data-upgraded')) return;
283
+ /**
284
+ * Diff keys for a list of items: the `id` when present; otherwise the
285
+ * label (`label` or `text`), with a counter for repeats, so the nth
286
+ * id-less item with a given label keeps the nth such card. The quadrant
287
+ * is not part of the key, so a dragged id-less card survives an
288
+ * `.items` round-trip.
289
+ * @param {any[]} list
290
+ * @returns {Map<any, unknown>}
291
+ */
292
+ static #keysFor(list) {
293
+ /** @type {Map<string, number>} */
294
+ const seen = new Map();
295
+ /** @type {Map<any, unknown>} */
296
+ const keys = new Map();
297
+ for (const it of list) {
298
+ if (it.id != null) { keys.set(it, it.id); continue; }
299
+ const label = String(it.label ?? it.text ?? '');
300
+ const n = seen.get(label) ?? 0;
301
+ seen.set(label, n + 1);
302
+ keys.set(it, n ? `label:${label}#${n}` : `label:${label}`);
303
+ }
304
+ return keys;
305
+ }
306
+
307
+ /** @param {Element} el */
308
+ #keyForNode(el) {
309
+ for (const [key, node] of this.#nodes) if (node === el) return key;
310
+ return undefined;
311
+ }
282
312
 
283
- if (name === 'src' && newVal && newVal !== oldVal) {
284
- this.#loadFromSrc(newVal);
313
+ /** @param {any[]} next */
314
+ #applyItems(next) {
315
+ const keys = ImpactEffort.#keysFor(next);
316
+ diffByKey({
317
+ newItems: next,
318
+ nodes: this.#nodes,
319
+ keyOf: (it) => keys.get(it),
320
+ renderItem: (it) => this.#renderCard(it),
321
+ containerFor: (it) => this.#surfaces[it.quadrant] || this.#surfaces['quick-wins'],
322
+ });
323
+ this.#records.clear();
324
+ for (const it of next) {
325
+ const key = keys.get(it);
326
+ this.#records.set(key, it);
327
+ const el = this.#nodes.get(key);
328
+ el?.setAttribute('data-quadrant', this.#surfaces[it.quadrant] ? it.quadrant : 'quick-wins');
285
329
  }
286
330
  }
287
331
 
332
+ /**
333
+ * @param {any} it
334
+ * @returns {Element}
335
+ */
336
+ #renderCard(it) {
337
+ const self = /** @type {any} */ (this);
338
+ let el;
339
+ if (typeof self.renderItem === 'function') {
340
+ const out = self.renderItem(it);
341
+ if (out instanceof Element) el = out;
342
+ }
343
+ if (!el && isStoryRecord(it)) el = createStoryCard(it, 'minimal');
344
+ if (!el) {
345
+ el = document.createElement('article');
346
+ el.className = 'ie-card';
347
+ el.textContent = it.label ?? it.text ?? it.id ?? '';
348
+ }
349
+ if (!el.hasAttribute('draggable')) el.setAttribute('draggable', 'true');
350
+ if (it.id != null && !el.hasAttribute('data-id')) el.setAttribute('data-id', String(it.id));
351
+ return el;
352
+ }
353
+
354
+ attributeChangedCallback(name, oldVal, newVal) {
355
+ if (oldVal === newVal || !this.#built) return;
356
+ if (name === 'src' && newVal && newVal !== this.#loader.url) this.#loadFromSrc(newVal);
357
+ if (name === 'src' && !newVal) this.#loader.cancel(); // src removed: drop a pending load, keep the cards
358
+ if (name === 'title') this.#syncTitle();
359
+ }
360
+
288
361
  // ── Data loading ──────────────────────────────────────────────────────────
289
362
 
363
+ /**
364
+ * Fetch an array of items (same shape as `.items`) and replace the
365
+ * matrix with it. One request per src value; a newer src aborts an
366
+ * older in-flight one. Once the cards are in the DOM (after the View
367
+ * Transition's update callback) `impact-effort:items-changed` fires with
368
+ * source 'src', and `impact-effort:ready` if it has not fired yet.
369
+ * @param {string} url
370
+ */
290
371
  async #loadFromSrc(url) {
291
372
  try {
292
- const res = await fetch(url);
293
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
294
- const data = await res.json();
295
-
373
+ const result = await this.#loader.fetchJson(url);
374
+ if (!result) return;
375
+ const { data, isCurrent } = result;
296
376
  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();
377
+ const entries = data.map((entry, i) => ({ ...entry, id: entry.id ?? entry.storyId ?? `ie-item-${i}` }));
378
+
379
+ // viewTransitionSwap runs the swap synchronously when it can't animate.
380
+ const tx = viewTransitionSwap(this, () => this.#applyItems(entries), 'ie-vt');
381
+ if (tx) await tx.updateCallbackDone;
382
+ if (!isCurrent()) return;
383
+ this.dispatchEvent(new CustomEvent('impact-effort:items-changed', {
384
+ detail: { items: this.items, source: 'src' },
385
+ bubbles: true,
386
+ }));
387
+ if (!this.#readyFired) this.#dispatchReady();
345
388
  } catch (err) {
346
389
  console.warn(`[impact-effort] Failed to load src="${url}":`, err);
347
390
  }
@@ -357,28 +400,14 @@ class ImpactEffort extends VBElement {
357
400
  return null;
358
401
  }
359
402
 
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
403
  /** Dispatch the ready event with counts per quadrant */
376
404
  #dispatchReady() {
405
+ this.#readyFired = true;
377
406
  const quadrantCounts = {};
378
407
  for (const q of ImpactEffort.QUADRANTS) {
379
408
  const surface = this.#surfaces[q];
380
409
  quadrantCounts[q] = surface
381
- ? surface.querySelectorAll('[draggable="true"]').length
410
+ ? surface.querySelectorAll(':scope > [draggable="true"]').length
382
411
  : 0;
383
412
  }
384
413
  this.dispatchEvent(new CustomEvent('impact-effort:ready', {