@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
@@ -15,7 +15,9 @@
15
15
  *
16
16
  * @fires story-map:reorder - Item reordered within a column
17
17
  * @fires story-map:transfer - Item moved between columns
18
- * @fires story-map:ready - Fired after component initializes
18
+ * @fires story-map:ready - Once after the first render (with src: after the JSON renders)
19
+ * @fires story-map:activities-changed - { activities, source: 'property' } after .activities is set
20
+ * @fires story-map:upgraded - Each time the map connects and upgrades
19
21
  *
20
22
  * @example
21
23
  * <story-map>
@@ -28,6 +30,13 @@
28
30
  import { registerComponent } from '../../lib/bundle-registry.js';
29
31
  import { VBElement } from '../../lib/vb-element.js';
30
32
  import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
33
+ import {
34
+ SrcLoader, announce, createLiveRegion, createStoryCard, itemLabel, syncPlaceholder, titleCase,
35
+ } from '../../lib/board-surface.js';
36
+
37
+ /**
38
+ * @typedef {{ id: string, label: string, journeyPhase: string | null }} ActivityMeta
39
+ */
31
40
 
32
41
  class StoryMap extends VBElement {
33
42
  static get observedAttributes() { return ['src', 'compact', 'title']; }
@@ -42,207 +51,211 @@ class StoryMap extends VBElement {
42
51
  #surfaces = {};
43
52
  /** @type {HTMLDivElement | null} */
44
53
  #liveRegion = null;
54
+ /** @type {HTMLHeadingElement | null} */
55
+ #titleEl = null;
45
56
  /** @type {string} */
46
57
  #groupId = '';
47
- /** @type {Array<{ id: string, label: string, journeyPhase: string | null, children: HTMLElement[] }>} */
58
+ /** @type {ActivityMeta[]} */
48
59
  #activities = [];
60
+ /** Last story records assigned via `.activities` / src, keyed by data-id. */
61
+ /** @type {Map<string, Record<string, any>>} */
62
+ #records = new Map();
63
+ /** Shell built (survives disconnect so a moved map keeps its stories). */
64
+ #built = false;
65
+ /** Listeners attached for the current connection. */
66
+ #wired = false;
67
+ #readyFired = false;
68
+ #loader = new SrcLoader();
49
69
 
50
70
  setup() {
51
- this.#groupId = `sm-${++StoryMap.#instanceCount}`;
52
-
53
- // 1. Snapshot <section data-activity> children
54
- const sections = /** @type {HTMLElement[]} */ ([
55
- ...this.querySelectorAll(':scope > section[data-activity]')
56
- ]);
57
-
58
- this.#activities = sections.map(section => {
59
- const id = section.getAttribute('data-activity') || '';
60
- const rawLabel = section.getAttribute('data-activity-label') || '';
61
- const label = rawLabel || this.#titleCase(id);
62
- const journeyPhase = section.getAttribute('data-journey-phase') || null;
63
- const children = /** @type {HTMLElement[]} */ ([...section.children]);
64
- return { id, label, journeyPhase, children };
65
- });
71
+ if (!this.#groupId) this.#groupId = `sm-${++StoryMap.#instanceCount}`;
72
+
73
+ if (!this.#built) {
74
+ this.#built = true;
75
+ const sections = /** @type {HTMLElement[]} */ ([
76
+ ...this.querySelectorAll(':scope > section[data-activity]')
77
+ ]);
78
+ const parsed = sections.map(section => StoryMap.#parseSection(section));
79
+ for (const section of sections) section.remove();
80
+
81
+ this.#syncTitle();
82
+ this.#scroll = document.createElement('div');
83
+ this.#scroll.className = 'sm-scroll';
84
+ this.#scroll.setAttribute('role', 'region');
85
+ this.#scroll.setAttribute('tabindex', '0');
86
+ this.#liveRegion = createLiveRegion('sm-live-region');
87
+ this.#renderColumns(parsed.map(p => p.meta), parsed.map(p => p.children));
88
+ this.append(this.#scroll, this.#liveRegion);
89
+ }
66
90
 
67
- // 2. Remove original sections (children already captured)
68
- for (const section of sections) {
69
- section.remove();
91
+ if (!this.#wired) {
92
+ this.#wired = true;
93
+ this.listen(this, 'drag-surface:reorder', this.#onReorder);
94
+ this.listen(this, 'drag-surface:transfer', this.#onTransfer);
70
95
  }
71
96
 
72
- // 3. Build scroll container
73
- this.#scroll = document.createElement('div');
74
- this.#scroll.className = 'sm-scroll';
75
- this.#scroll.setAttribute('role', 'region');
76
- this.#scroll.setAttribute('aria-label', 'Story map');
77
- this.#scroll.setAttribute('tabindex', '0');
97
+ // Data mode: fetch once per src value; ready fires after it renders.
98
+ const src = this.getAttribute('src');
99
+ if (src) {
100
+ if (src !== this.#loader.url) this._loadSrc(src);
101
+ return;
102
+ }
103
+
104
+ if (!this.#readyFired) this.#dispatchReady();
105
+ }
106
+
107
+ teardown() {
108
+ // Listeners are removed by VBElement. The title, columns and stories
109
+ // stay in place so a map that is moved in the DOM keeps its content;
110
+ // the next connect only re-attaches listeners.
111
+ this.#wired = false;
112
+ }
78
113
 
79
- // 4. Build columns container
80
- this.#columnsEl = document.createElement('div');
81
- this.#columnsEl.className = 'sm-columns';
114
+ /**
115
+ * @param {HTMLElement} section
116
+ * @returns {{ meta: ActivityMeta, children: HTMLElement[] }}
117
+ */
118
+ static #parseSection(section) {
119
+ const id = section.getAttribute('data-activity') || '';
120
+ const label = section.getAttribute('data-activity-label') || titleCase(id);
121
+ const journeyPhase = section.getAttribute('data-journey-phase') || null;
122
+ const children = /** @type {HTMLElement[]} */ ([...section.children]);
123
+ return { meta: { id, label, journeyPhase }, children };
124
+ }
82
125
 
83
- // 5. Build each activity column
84
- let totalStories = 0;
126
+ /**
127
+ * Replace the columns inside the scroll container. `children[i]` are the
128
+ * story cards for activity i (moved, not cloned).
129
+ * @param {ActivityMeta[]} metas
130
+ * @param {HTMLElement[][]} children
131
+ */
132
+ #renderColumns(metas, children) {
133
+ this.#activities = metas;
134
+ this.#surfaces = {};
135
+ const columns = document.createElement('div');
136
+ columns.className = 'sm-columns';
85
137
 
86
- for (const activity of this.#activities) {
138
+ metas.forEach((activity, i) => {
87
139
  const column = document.createElement('section');
88
140
  column.className = 'sm-column';
89
141
  column.setAttribute('data-activity-column', activity.id);
90
142
 
91
- // Header with count badge
92
143
  const header = document.createElement('header');
93
144
  header.className = 'sm-activity-header';
94
145
  const h3 = document.createElement('h3');
95
146
  h3.textContent = activity.label;
96
147
  const countBadge = document.createElement('span');
97
148
  countBadge.className = 'sm-activity-count';
98
- countBadge.textContent = String(activity.children.length);
99
149
  h3.appendChild(countBadge);
100
150
  header.appendChild(h3);
101
151
  column.appendChild(header);
102
152
 
103
- // Drag surface
104
153
  const surface = document.createElement('drag-surface');
105
154
  surface.setAttribute('group', this.#groupId);
106
155
  surface.setAttribute('aria-label', `${activity.label} stories`);
107
156
  surface.setAttribute('data-layout', 'stack');
108
157
  surface.setAttribute('data-layout-gap', 'xs');
109
-
110
- // Move captured children into the drag-surface
111
- if (activity.children.length > 0) {
112
- for (const child of activity.children) {
113
- surface.appendChild(child);
114
- }
115
- totalStories += activity.children.length;
116
- } else {
117
- const placeholder = document.createElement('p');
118
- placeholder.className = 'sm-empty';
119
- placeholder.textContent = 'No stories yet';
120
- surface.appendChild(placeholder);
121
- }
158
+ for (const child of children[i] || []) surface.appendChild(child);
122
159
 
123
160
  column.appendChild(surface);
124
- this.#columnsEl.appendChild(column);
161
+ columns.appendChild(column);
125
162
  this.#surfaces[activity.id] = surface;
126
- }
163
+ this.#updateCount(activity.id);
164
+ syncPlaceholder(surface, 'sm-empty', 'No stories yet');
165
+ });
127
166
 
128
- // 6. Assemble: title + scroll > columns, append to this
167
+ this.#columnsEl?.remove();
168
+ this.#columnsEl = columns;
169
+ this.#scroll?.appendChild(columns);
170
+ this.#scroll?.setAttribute('aria-label', this.getAttribute('title') || 'Story map');
171
+ }
172
+
173
+ /** Create, update, or remove the optional heading from the title attribute. */
174
+ #syncTitle() {
129
175
  const title = this.getAttribute('title');
130
176
  if (title) {
131
- const heading = document.createElement('h2');
132
- heading.className = 'sm-title';
133
- heading.textContent = title;
134
- this.appendChild(heading);
135
- }
136
-
137
- this.#scroll.appendChild(this.#columnsEl);
138
- this.appendChild(this.#scroll);
139
-
140
- // 7. Create live region
141
- this.#liveRegion = document.createElement('div');
142
- this.#liveRegion.className = 'sm-live-region';
143
- this.#liveRegion.setAttribute('role', 'status');
144
- this.#liveRegion.setAttribute('aria-live', 'polite');
145
- this.#liveRegion.setAttribute('aria-atomic', 'true');
146
- this.appendChild(this.#liveRegion);
147
-
148
- // 8. Listen for drag-surface:reorder
149
- this.listen(this, 'drag-surface:reorder', (e) => {
150
- const detail = /** @type {CustomEvent} */ (e).detail;
151
- const surface = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('drag-surface'));
152
- const activityId = this.#findActivityForSurface(surface);
153
- if (!activityId) return;
154
-
155
- this.#updateCount(activityId);
156
-
157
- this.dispatchEvent(new CustomEvent('story-map:reorder', {
158
- bubbles: true,
159
- detail: {
160
- itemId: detail.itemId,
161
- activity: activityId,
162
- oldIndex: detail.oldIndex,
163
- newIndex: detail.newIndex,
164
- },
165
- }));
166
- });
167
-
168
- // 9. Listen for drag-surface:transfer
169
- this.listen(this, 'drag-surface:transfer', (e) => {
170
- const detail = /** @type {CustomEvent} */ (e).detail;
171
- const fromSurface = detail.fromSurface;
172
- const toSurface = detail.toSurface;
173
- const fromActivity = this.#findActivityForSurface(fromSurface);
174
- const toActivity = this.#findActivityForSurface(toSurface);
175
- if (!fromActivity || !toActivity) return;
176
-
177
- // Update count badges
178
- this.#updateCount(fromActivity);
179
- this.#updateCount(toActivity);
180
-
181
- // Remove placeholder from receiving column if it had one
182
- const toPlaceholder = toSurface.querySelector('.sm-empty');
183
- if (toPlaceholder) toPlaceholder.remove();
184
-
185
- // Add placeholder to sending column if now empty
186
- const fromDraggables = fromSurface.querySelectorAll(':scope > [draggable="true"]');
187
- if (fromDraggables.length === 0 && !fromSurface.querySelector('.sm-empty')) {
188
- const placeholder = document.createElement('p');
189
- placeholder.className = 'sm-empty';
190
- placeholder.textContent = 'No stories yet';
191
- fromSurface.appendChild(placeholder);
177
+ if (!this.#titleEl) {
178
+ this.#titleEl = document.createElement('h2');
179
+ this.#titleEl.className = 'sm-title';
180
+ this.prepend(this.#titleEl);
192
181
  }
182
+ this.#titleEl.textContent = title;
183
+ } else if (this.#titleEl) {
184
+ this.#titleEl.remove();
185
+ this.#titleEl = null;
186
+ }
187
+ this.#scroll?.setAttribute('aria-label', title || 'Story map');
188
+ }
193
189
 
194
- // Announce via live region
195
- const itemLabel = this.#itemLabel(detail.item);
196
- this.#announce(`${itemLabel} moved from ${fromActivity} to ${toActivity}`);
190
+ #dispatchReady() {
191
+ this.#readyFired = true;
192
+ let storyCount = 0;
193
+ for (const surface of Object.values(this.#surfaces)) {
194
+ storyCount += surface.querySelectorAll(':scope > [draggable="true"]').length;
195
+ }
196
+ this.dispatchEvent(new CustomEvent('story-map:ready', {
197
+ bubbles: true,
198
+ detail: { activityCount: this.#activities.length, storyCount },
199
+ }));
200
+ }
197
201
 
198
- this.dispatchEvent(new CustomEvent('story-map:transfer', {
199
- bubbles: true,
200
- detail: {
201
- itemId: detail.itemId,
202
- fromActivity,
203
- toActivity,
204
- newIndex: detail.newIndex,
205
- },
206
- }));
207
- });
202
+ /** @param {Event} e */
203
+ #onReorder = (e) => {
204
+ const detail = /** @type {CustomEvent} */ (e).detail;
205
+ const surface = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('drag-surface'));
206
+ const activityId = this.#findActivityForSurface(surface);
207
+ if (!activityId) return;
208
208
 
209
- // 10. Handle src attribute for data mode
210
- const src = this.getAttribute('src');
211
- if (src) {
212
- this._loadSrc(src);
213
- return; // ready event fires after src loads
214
- }
209
+ this.#updateCount(activityId);
215
210
 
216
- // 11. Dispatch ready event
217
- this.dispatchEvent(new CustomEvent('story-map:ready', {
211
+ this.dispatchEvent(new CustomEvent('story-map:reorder', {
218
212
  bubbles: true,
219
213
  detail: {
220
- activityCount: this.#activities.length,
221
- storyCount: totalStories,
214
+ itemId: detail.itemId,
215
+ activity: activityId,
216
+ oldIndex: detail.oldIndex,
217
+ newIndex: detail.newIndex,
222
218
  },
223
219
  }));
224
- }
225
-
226
- teardown() {
227
- if (this.#scroll) {
228
- this.#scroll.remove();
229
- this.#scroll = null;
230
- }
231
- if (this.#liveRegion) {
232
- this.#liveRegion.remove();
233
- this.#liveRegion = null;
234
- }
235
- this.#columnsEl = null;
236
- this.#surfaces = {};
237
- this.#activities = [];
238
- }
220
+ };
221
+
222
+ /** @param {Event} e */
223
+ #onTransfer = (e) => {
224
+ const detail = /** @type {CustomEvent} */ (e).detail;
225
+ const fromSurface = detail.fromSurface;
226
+ const toSurface = detail.toSurface;
227
+ const fromActivity = this.#findActivityForSurface(fromSurface);
228
+ const toActivity = this.#findActivityForSurface(toSurface);
229
+ if (!fromActivity || !toActivity) return;
230
+
231
+ this.#updateCount(fromActivity);
232
+ this.#updateCount(toActivity);
233
+ syncPlaceholder(toSurface, 'sm-empty', 'No stories yet');
234
+ syncPlaceholder(fromSurface, 'sm-empty', 'No stories yet');
235
+
236
+ announce(this.#liveRegion,
237
+ `${itemLabel(detail.item)} moved from ${this.#activityLabel(fromActivity)} to ${this.#activityLabel(toActivity)}`);
238
+
239
+ this.dispatchEvent(new CustomEvent('story-map:transfer', {
240
+ bubbles: true,
241
+ detail: {
242
+ itemId: detail.itemId,
243
+ fromActivity,
244
+ toActivity,
245
+ newIndex: detail.newIndex,
246
+ },
247
+ }));
248
+ };
239
249
 
240
250
  // ── Data API (HTML-first / JS-first dual contract) ──────────────
241
251
 
242
252
  /**
243
- * Read the current activities + stories as a nested data array.
244
- * Each activity: `{ id, label, journeyPhase?, stories: [...] }`.
245
- * Each story: `{ id?, title?, ... }` (passed through to renderItem).
253
+ * Read the current activities + stories as a nested data array, in DOM
254
+ * order (drags are reflected):
255
+ * `[{ id, label, journeyPhase?, stories: [{ id, ...record }] }]`.
256
+ * A story assigned through `.activities` or `src` returns the record it
257
+ * was given (so the getter round-trips through the setter); an authored
258
+ * `<user-story>` returns its `.data`; any other card `{ id, title }`.
246
259
  */
247
260
  get activities() {
248
261
  return this.#activities.map(a => ({
@@ -250,69 +263,47 @@ class StoryMap extends VBElement {
250
263
  label: a.label,
251
264
  journeyPhase: a.journeyPhase || undefined,
252
265
  stories: [...(this.#surfaces[a.id]?.querySelectorAll(':scope > [draggable="true"]') || [])]
253
- .map(el => ({
254
- id: el.getAttribute('data-id') || undefined,
255
- storyId: el.getAttribute('story-id') || undefined,
256
- title: el.querySelector('[slot="title"]')?.textContent?.trim() || undefined,
257
- })),
266
+ .map(el => this.#storyRecord(/** @type {HTMLElement} */ (el))),
258
267
  }));
259
268
  }
260
269
 
261
270
  /**
262
271
  * Replace the entire story map. Accepts a nested tree:
263
- * [{ id, label, journeyPhase?, stories: [{ id, ... }] }]
264
- * Stories are rendered as <user-story> elements (via .data) by default;
265
- * override via `.renderStory`.
272
+ * `[{ id, label?, journeyPhase?, stories: [{ id, title?, persona?, action?, benefit?, ... }] }]`
273
+ * Stories render as `<user-story>` cards (persona/action/benefit/title
274
+ * become slotted children) by default; override via `.renderStory`.
266
275
  *
267
- * v1 is record-shaped — full rebuild on assignment. Per-story
268
- * preservation across diffs is on the roadmap.
276
+ * Record-shaped: the columns are rebuilt on assignment (no per-story
277
+ * node preservation). Assigning before the element upgrades is fine —
278
+ * the tree is rendered on connect.
269
279
  *
270
- * Emits story-map:activities-changed { activities, source: 'property' }.
280
+ * Emits `story-map:activities-changed { activities, source: 'property' }`.
271
281
  */
272
282
  set activities(value) {
273
283
  const next = Array.isArray(value) ? value : [];
284
+ this.#records.clear();
285
+ /** @type {HTMLElement[][]} */
286
+ const children = [];
287
+ /** @type {ActivityMeta[]} */
288
+ const metas = next.map(a => {
289
+ const id = String(a.id ?? '');
290
+ children.push((a.stories || []).map((/** @type {any} */ story) => this.#cardFromRecord(story, undefined)));
291
+ return { id, label: a.label || titleCase(id), journeyPhase: a.journeyPhase || null };
292
+ });
274
293
 
275
- // Clear children, then synthesize the <section data-activity> markup
276
- // that setup() expects. Re-run setup().
277
- while (this.firstChild) this.firstChild.remove();
278
- for (const a of next) {
279
- const section = document.createElement('section');
280
- section.setAttribute('data-activity', a.id || '');
281
- if (a.label) section.setAttribute('data-activity-label', a.label);
282
- if (a.journeyPhase) section.setAttribute('data-journey-phase', a.journeyPhase);
283
- for (const story of (a.stories || [])) {
284
- let el;
285
- const self = /** @type {any} */ (this);
286
- if (typeof self.renderStory === 'function') {
287
- const out = self.renderStory(story);
288
- el = out instanceof Element ? out : null;
289
- }
290
- if (!el) {
291
- if (customElements.get('user-story')) {
292
- el = document.createElement('user-story');
293
- /** @type {any} */ (el).data = story;
294
- } else {
295
- el = document.createElement('article');
296
- el.className = 'sm-card';
297
- el.textContent = story.title || story.id || '';
298
- }
299
- }
300
- if (!el.hasAttribute('draggable')) el.setAttribute('draggable', 'true');
301
- if (story.id && !el.hasAttribute('data-id')) el.setAttribute('data-id', String(story.id));
302
- section.appendChild(el);
303
- }
304
- this.appendChild(section);
305
- }
306
-
307
- const swap = () => {
308
- this.teardown();
309
- this.removeAttribute('data-upgraded');
310
- this.setup();
311
- };
312
- if (this.hasAttribute('data-upgraded') && this.#scroll) {
313
- viewTransitionSwap(this, swap, 'sm-vt');
294
+ if (!this.#built) {
295
+ // Not upgraded yet: leave sections for setup() to parse.
296
+ for (const section of this.querySelectorAll(':scope > section[data-activity]')) section.remove();
297
+ metas.forEach((meta, i) => {
298
+ const section = document.createElement('section');
299
+ section.setAttribute('data-activity', meta.id);
300
+ section.setAttribute('data-activity-label', meta.label);
301
+ if (meta.journeyPhase) section.setAttribute('data-journey-phase', meta.journeyPhase);
302
+ section.append(...children[i]);
303
+ this.appendChild(section);
304
+ });
314
305
  } else {
315
- swap();
306
+ viewTransitionSwap(this, () => this.#renderColumns(metas, children), 'sm-vt');
316
307
  }
317
308
 
318
309
  this.dispatchEvent(new CustomEvent('story-map:activities-changed', {
@@ -321,56 +312,95 @@ class StoryMap extends VBElement {
321
312
  }));
322
313
  }
323
314
 
315
+ /**
316
+ * Build a draggable card for a story record and remember the record.
317
+ * @param {Record<string, any>} story
318
+ * @param {string | undefined} defaultDetail
319
+ * @returns {HTMLElement}
320
+ */
321
+ #cardFromRecord(story, defaultDetail) {
322
+ /** @type {Element | null} */
323
+ let el = null;
324
+ const self = /** @type {any} */ (this);
325
+ if (typeof self.renderStory === 'function') {
326
+ const out = self.renderStory(story);
327
+ el = out instanceof Element ? out : null;
328
+ }
329
+ if (!el) el = createStoryCard(story, defaultDetail);
330
+ const card = /** @type {HTMLElement} */ (el);
331
+ if (!card.hasAttribute('draggable')) card.setAttribute('draggable', 'true');
332
+ const id = story.id ?? story.storyId;
333
+ if (id != null && !card.hasAttribute('data-id')) card.setAttribute('data-id', String(id));
334
+ const key = card.getAttribute('data-id');
335
+ if (key) this.#records.set(key, story);
336
+ return card;
337
+ }
338
+
339
+ /**
340
+ * @param {HTMLElement} el
341
+ * @returns {Record<string, any>}
342
+ */
343
+ #storyRecord(el) {
344
+ const id = el.getAttribute('data-id') || undefined;
345
+ const known = id ? this.#records.get(id) : undefined;
346
+ if (known) return { ...known, id: known.id ?? id };
347
+ const data = /** @type {any} */ (el).data;
348
+ if (el.localName === 'user-story' && data && typeof data === 'object') {
349
+ /** @type {Record<string, any>} */
350
+ const out = { id };
351
+ for (const [k, v] of Object.entries(data)) if (v !== undefined) out[k] = v;
352
+ return out;
353
+ }
354
+ return {
355
+ id,
356
+ storyId: el.getAttribute('story-id') || undefined,
357
+ title: (el.querySelector('[slot="title"]')?.textContent || el.textContent || '').replace(/\s+/g, ' ').trim() || undefined,
358
+ };
359
+ }
360
+
324
361
  // ── Attribute changes ────────────────────────────────
325
362
 
326
363
  attributeChangedCallback(name, oldValue, newValue) {
327
- if (oldValue === newValue || !this.isConnected) return;
328
- if (name === 'src') this._loadSrc(newValue);
364
+ if (oldValue === newValue || !this.#built) return;
365
+ if (name === 'src' && newValue && newValue !== this.#loader.url) this._loadSrc(newValue);
366
+ if (name === 'src' && !newValue) this.#loader.cancel(); // src removed: drop a pending load, keep the cards
367
+ if (name === 'title') this.#syncTitle();
329
368
  }
330
369
 
331
370
  // ── JSON loading ────────────────────────────────────
332
371
 
372
+ /**
373
+ * Fetch `{ activities: [{ id, label?, journeyPhase?, stories: [...] }] }`
374
+ * and replace the map with it. One request per src value; a newer src
375
+ * aborts an older in-flight one. Once the data has rendered, fires
376
+ * `story-map:activities-changed` (source 'src'), and `story-map:ready`
377
+ * if it has not fired yet (ready is once per element).
378
+ * @param {string} url
379
+ */
333
380
  async _loadSrc(url) {
334
381
  if (!url) return;
335
382
  try {
336
- const res = await fetch(url);
337
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
338
- const data = await res.json();
339
- // Clear existing children
340
- while (this.firstChild) this.firstChild.remove();
341
- // Create sections from activities
342
- for (const activity of (data.activities || [])) {
343
- const section = document.createElement('section');
344
- section.setAttribute('data-activity', activity.id);
345
- section.setAttribute('data-activity-label', activity.label || activity.id);
346
- if (activity.journeyPhase) section.setAttribute('data-journey-phase', activity.journeyPhase);
347
- for (const story of (activity.stories || [])) {
348
- const el = document.createElement('user-story');
349
- el.setAttribute('draggable', 'true');
350
- el.dataset.id = story.id || story.storyId;
351
- if (story.storyId || story.id) el.setAttribute('story-id', story.storyId || story.id);
352
- if (story.title) el.setAttribute('title', story.title);
353
- if (story.persona) el.setAttribute('persona', story.persona);
354
- if (story.action) el.setAttribute('action', story.action);
355
- if (story.benefit) el.setAttribute('benefit', story.benefit);
356
- if (story.priority) el.setAttribute('priority', story.priority);
357
- if (story.status) el.setAttribute('status', story.status);
358
- if (story.points) el.setAttribute('points', String(story.points));
359
- el.setAttribute('detail', story.detail || 'compact');
360
- section.appendChild(el);
361
- }
362
- this.appendChild(section);
363
- }
364
- const swap = () => {
365
- this.teardown();
366
- this.removeAttribute('data-upgraded');
367
- this.setup();
368
- };
369
- if (this.hasAttribute('data-upgraded') && this.#scroll) {
370
- viewTransitionSwap(this, swap, 'sm-vt');
371
- } else {
372
- swap();
373
- }
383
+ const result = await this.#loader.fetchJson(url);
384
+ if (!result) return;
385
+ const { data, isCurrent } = result;
386
+ this.#records.clear();
387
+ /** @type {HTMLElement[][]} */
388
+ const children = [];
389
+ /** @type {ActivityMeta[]} */
390
+ const metas = (data.activities || []).map((/** @type {any} */ activity) => {
391
+ const id = String(activity.id ?? '');
392
+ children.push((activity.stories || []).map((/** @type {any} */ story) => this.#cardFromRecord(story, 'compact')));
393
+ return { id, label: activity.label || titleCase(id), journeyPhase: activity.journeyPhase || null };
394
+ });
395
+ // viewTransitionSwap runs the swap synchronously when it can't animate.
396
+ const tx = viewTransitionSwap(this, () => this.#renderColumns(metas, children), 'sm-vt');
397
+ if (tx) await tx.updateCallbackDone;
398
+ if (!isCurrent()) return;
399
+ this.dispatchEvent(new CustomEvent('story-map:activities-changed', {
400
+ detail: { activities: this.activities, source: 'src' },
401
+ bubbles: true,
402
+ }));
403
+ if (!this.#readyFired) this.#dispatchReady();
374
404
  } catch (err) {
375
405
  console.warn(`[story-map] Failed to load src="${url}":`, err);
376
406
  }
@@ -391,6 +421,11 @@ class StoryMap extends VBElement {
391
421
  return null;
392
422
  }
393
423
 
424
+ /** @param {string} activityId */
425
+ #activityLabel(activityId) {
426
+ return this.#activities.find(a => a.id === activityId)?.label || titleCase(activityId);
427
+ }
428
+
394
429
  /**
395
430
  * Update the count badge in the column header for a given activity.
396
431
  * @param {string} activityId
@@ -400,45 +435,9 @@ class StoryMap extends VBElement {
400
435
  if (!surface) return;
401
436
  const count = surface.querySelectorAll(':scope > [draggable="true"]').length;
402
437
  const column = surface.closest('[data-activity-column]');
403
- if (!column) return;
404
- const badge = column.querySelector('.sm-activity-count');
438
+ const badge = column?.querySelector('.sm-activity-count');
405
439
  if (badge) badge.textContent = String(count);
406
440
  }
407
-
408
- /**
409
- * Announce a message via the live region.
410
- * @param {string} msg
411
- */
412
- #announce(msg) {
413
- if (!this.#liveRegion) return;
414
- this.#liveRegion.textContent = '';
415
- requestAnimationFrame(() => {
416
- if (this.#liveRegion) this.#liveRegion.textContent = msg;
417
- });
418
- }
419
-
420
- /**
421
- * Get a human-readable label for a story item.
422
- * @param {HTMLElement} item
423
- * @returns {string}
424
- */
425
- #itemLabel(item) {
426
- return item.getAttribute('story-id')
427
- || item.getAttribute('data-id')
428
- || item.textContent?.trim().slice(0, 40)
429
- || 'item';
430
- }
431
-
432
- /**
433
- * Convert a hyphenated or lowercase id to Title Case.
434
- * @param {string} str
435
- * @returns {string}
436
- */
437
- #titleCase(str) {
438
- return str
439
- .replace(/[-_]/g, ' ')
440
- .replace(/\b\w/g, c => c.toUpperCase());
441
- }
442
441
  }
443
442
 
444
443
  registerComponent('story-map', StoryMap);