@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
@@ -18,8 +18,10 @@
18
18
  *
19
19
  * @fires kanban-board:transfer - Item moved between columns
20
20
  * @fires kanban-board:reorder - Item reordered within a column
21
- * @fires kanban-board:ready - Fired after component initializes
22
- * @fires kanban-board:wip-exceeded - Fired when a column exceeds its WIP limit
21
+ * @fires kanban-board:ready - Once per element, after the first render (with src: after the JSON renders)
22
+ * @fires kanban-board:wip-exceeded - Fired when a drop takes a column over its WIP limit
23
+ * @fires kanban-board:items-changed - { items, source: 'api' | 'drag' | 'src' }
24
+ * @fires kanban-board:upgraded - Each time the board connects and upgrades
23
25
  *
24
26
  * @example
25
27
  * <kanban-board>
@@ -36,6 +38,9 @@ import { registerComponent } from '../../lib/bundle-registry.js';
36
38
  import { VBElement } from '../../lib/vb-element.js';
37
39
  import { VBCollection } from '../../lib/vb-collection.js';
38
40
  import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
41
+ import {
42
+ SrcLoader, announce, createLiveRegion, createStoryCard, isStoryRecord, itemLabel, syncPlaceholder, titleCase,
43
+ } from '../../lib/board-surface.js';
39
44
 
40
45
  class KanbanBoard extends VBCollection(VBElement) {
41
46
  static get observedAttributes() { return ['src', 'compact', 'title']; }
@@ -51,237 +56,174 @@ class KanbanBoard extends VBCollection(VBElement) {
51
56
  #columnEls = {};
52
57
  /** @type {HTMLDivElement | null} */
53
58
  #liveRegion = null;
59
+ /** @type {HTMLHeadingElement | null} */
60
+ #titleEl = null;
54
61
  /** @type {string} */
55
62
  #groupId = '';
56
- /** @type {Array<{ id: string, label: string, wip: number | null, color: string | null, children: HTMLElement[] }>} */
63
+ /** @type {Array<{ id: string, label: string, wip: number | null, color: string | null }>} */
57
64
  #columns = [];
58
65
  /** @type {((item: any) => Element) | null} */
59
66
  #renderItem = null;
67
+ /** Shell built (survives disconnect so a moved board keeps its cards). */
68
+ #built = false;
69
+ /** Listeners attached for the current connection. */
70
+ #wired = false;
71
+ #readyFired = false;
72
+ #loader = new SrcLoader();
60
73
 
61
74
  setup() {
62
- this.#groupId = `kb-${++KanbanBoard.#instanceCount}`;
75
+ this.#ensureGroupId();
63
76
 
64
- // 1. Snapshot <section data-column> children
65
- const sections = /** @type {HTMLElement[]} */ ([
66
- ...this.querySelectorAll(':scope > section[data-column]')
67
- ]);
68
-
69
- this.#columns = sections.map(section => {
70
- const id = section.getAttribute('data-column') || '';
71
- const rawLabel = section.getAttribute('data-column-label') || '';
72
- const label = rawLabel || this.#titleCase(id);
73
- const wipAttr = section.getAttribute('data-wip');
74
- const wip = wipAttr ? parseInt(wipAttr, 10) : null;
75
- const color = section.getAttribute('data-column-color') || null;
76
- const children = /** @type {HTMLElement[]} */ ([...section.children]);
77
- return { id, label, wip, color, children };
78
- });
79
-
80
- // 2. Remove original sections (children already captured)
81
- for (const section of sections) {
82
- section.remove();
77
+ if (!this.#built) {
78
+ this.#built = true;
79
+ this.#buildFromSections();
83
80
  }
84
81
 
85
- // 3. Build columns container
86
- this.#columnsEl = document.createElement('div');
87
- this.#columnsEl.className = 'kb-columns';
88
- this.#columnsEl.setAttribute('role', 'region');
89
- this.#columnsEl.setAttribute('aria-label', 'Kanban board');
90
-
91
- // 4. Build each column
92
- let totalItems = 0;
93
-
94
- for (const col of this.#columns) {
95
- const column = document.createElement('section');
96
- column.className = 'kb-column';
97
- column.setAttribute('data-column-id', col.id);
98
- if (col.color) column.setAttribute('data-column-tint', col.color);
99
-
100
- // Header with count badge and optional WIP
101
- const header = document.createElement('header');
102
- header.className = 'kb-column-header';
103
-
104
- const h3 = document.createElement('h3');
105
- h3.textContent = col.label;
106
-
107
- const countBadge = document.createElement('output');
108
- countBadge.className = 'kb-count';
109
- countBadge.textContent = String(col.children.length);
110
- h3.appendChild(countBadge);
82
+ if (!this.#wired) {
83
+ this.#wired = true;
84
+ this.listen(this, 'drag-surface:reorder', this.#onReorder);
85
+ this.listen(this, 'drag-surface:transfer', this.#onTransfer);
86
+ }
111
87
 
112
- if (col.wip !== null && !isNaN(col.wip)) {
113
- const wipBadge = document.createElement('span');
114
- wipBadge.className = 'kb-wip';
115
- wipBadge.textContent = `/ ${col.wip}`;
116
- wipBadge.setAttribute('aria-label', `WIP limit ${col.wip}`);
117
- h3.appendChild(wipBadge);
118
- }
88
+ // Data mode: fetch once per src value; ready fires after it renders.
89
+ const src = this.getAttribute('src');
90
+ if (src) {
91
+ if (src !== this.#loader.url) this._loadSrc(src);
92
+ return;
93
+ }
119
94
 
120
- header.appendChild(h3);
121
- column.appendChild(header);
95
+ if (!this.#readyFired) this.#dispatchReady();
96
+ }
122
97
 
123
- // Drag surface
124
- const surface = document.createElement('drag-surface');
125
- surface.setAttribute('group', this.#groupId);
126
- surface.setAttribute('aria-label', `${col.label} items`);
127
- surface.setAttribute('data-layout', 'stack');
128
- surface.setAttribute('data-layout-gap', 'xs');
98
+ /**
99
+ * First build: parse the authored `<section data-column>` children,
100
+ * move their cards into drag-surfaces, and add the title + live region.
101
+ */
102
+ #buildFromSections() {
103
+ const sections = /** @type {HTMLElement[]} */ ([
104
+ ...this.querySelectorAll(':scope > section[data-column]')
105
+ ]);
106
+ const parsed = sections.map(section => this.#parseSection(section));
107
+ for (const section of sections) section.remove();
129
108
 
130
- // Move captured children into the drag-surface
131
- if (col.children.length > 0) {
132
- for (const child of col.children) {
133
- if (!child.hasAttribute('draggable')) {
134
- child.setAttribute('draggable', 'true');
135
- }
136
- if (!child.hasAttribute('data-id')) {
137
- child.dataset.id = `kb-item-${totalItems}`;
138
- }
139
- surface.appendChild(child);
140
- }
141
- totalItems += col.children.length;
142
- } else {
143
- const placeholder = document.createElement('p');
144
- placeholder.className = 'kb-empty';
145
- placeholder.textContent = 'No items';
146
- surface.appendChild(placeholder);
147
- }
109
+ this.#syncTitle();
148
110
 
149
- column.appendChild(surface);
150
- this.#columnsEl.appendChild(column);
151
- this.#surfaces[col.id] = surface;
152
- this.#columnEls[col.id] = column;
111
+ this.#liveRegion = createLiveRegion('kb-live-region');
112
+ this.#columns = parsed.map(p => p.meta);
113
+ this.#columnsEl = this.#assembleShell(parsed.map(p => p.children));
114
+ this.append(this.#columnsEl, this.#liveRegion);
115
+ this._postRender();
116
+ this.#seedFromDom();
117
+ }
153
118
 
154
- // Check initial WIP state
155
- if (col.wip !== null && col.children.length > col.wip) {
156
- column.setAttribute('data-wip-exceeded', '');
157
- }
158
- }
119
+ /**
120
+ * @param {HTMLElement} section
121
+ * @returns {{ meta: { id: string, label: string, wip: number | null, color: string | null }, children: HTMLElement[] }}
122
+ */
123
+ #parseSection(section) {
124
+ const id = section.getAttribute('data-column') || '';
125
+ const label = section.getAttribute('data-column-label') || titleCase(id);
126
+ const wipAttr = section.getAttribute('data-wip');
127
+ const wip = wipAttr ? parseInt(wipAttr, 10) : null;
128
+ const color = section.getAttribute('data-column-color') || null;
129
+ const children = /** @type {HTMLElement[]} */ ([...section.children]);
130
+ return { meta: { id, label, wip: wip !== null && isNaN(wip) ? null : wip, color }, children };
131
+ }
159
132
 
160
- // 5. Assemble: title + columns, append to host
133
+ /** Create, update, or remove the optional heading from the title attribute. */
134
+ #syncTitle() {
161
135
  const title = this.getAttribute('title');
162
136
  if (title) {
163
- const heading = document.createElement('h2');
164
- heading.className = 'kb-title';
165
- heading.textContent = title;
166
- this.appendChild(heading);
137
+ if (!this.#titleEl) {
138
+ this.#titleEl = document.createElement('h2');
139
+ this.#titleEl.className = 'kb-title';
140
+ this.prepend(this.#titleEl);
141
+ }
142
+ this.#titleEl.textContent = title;
143
+ } else if (this.#titleEl) {
144
+ this.#titleEl.remove();
145
+ this.#titleEl = null;
167
146
  }
147
+ this.#columnsEl?.setAttribute('aria-label', title || 'Kanban board');
148
+ }
168
149
 
169
- this.appendChild(this.#columnsEl);
150
+ #dispatchReady() {
151
+ this.#readyFired = true;
152
+ this.dispatchEvent(new CustomEvent('kanban-board:ready', {
153
+ bubbles: true,
154
+ detail: {
155
+ columnCount: this.#columns.length,
156
+ itemCount: this.items.length,
157
+ },
158
+ }));
159
+ }
170
160
 
171
- // 6. Create live region
172
- this.#liveRegion = document.createElement('div');
173
- this.#liveRegion.className = 'kb-live-region';
174
- this.#liveRegion.setAttribute('role', 'status');
175
- this.#liveRegion.setAttribute('aria-live', 'polite');
176
- this.#liveRegion.setAttribute('aria-atomic', 'true');
177
- this.appendChild(this.#liveRegion);
161
+ /** @param {Event} e */
162
+ #onReorder = (e) => {
163
+ const detail = /** @type {CustomEvent} */ (e).detail;
164
+ const surface = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('drag-surface'));
165
+ const columnId = this.#findColumnForSurface(surface);
166
+ if (!columnId) return;
178
167
 
179
- // 7. Listen for drag-surface:reorder
180
- this.listen(this, 'drag-surface:reorder', (e) => {
181
- const detail = /** @type {CustomEvent} */ (e).detail;
182
- const surface = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('drag-surface'));
183
- const columnId = this.#findColumnForSurface(surface);
184
- if (!columnId) return;
168
+ this.#updateCount(columnId);
169
+ this.#syncItemsFromDom('drag');
185
170
 
186
- this.#updateCount(columnId);
187
- this.#syncItemsFromDom('drag');
171
+ this.dispatchEvent(new CustomEvent('kanban-board:reorder', {
172
+ bubbles: true,
173
+ detail: {
174
+ itemId: detail.itemId,
175
+ column: columnId,
176
+ oldIndex: detail.oldIndex,
177
+ newIndex: detail.newIndex,
178
+ },
179
+ }));
180
+ };
188
181
 
189
- this.dispatchEvent(new CustomEvent('kanban-board:reorder', {
190
- bubbles: true,
191
- detail: {
192
- itemId: detail.itemId,
193
- column: columnId,
194
- oldIndex: detail.oldIndex,
195
- newIndex: detail.newIndex,
196
- },
197
- }));
198
- });
182
+ /** @param {Event} e */
183
+ #onTransfer = (e) => {
184
+ const detail = /** @type {CustomEvent} */ (e).detail;
185
+ const fromSurface = detail.fromSurface;
186
+ const toSurface = detail.toSurface;
187
+ const fromColumn = this.#findColumnForSurface(fromSurface);
188
+ const toColumn = this.#findColumnForSurface(toSurface);
189
+ if (!fromColumn || !toColumn) return;
199
190
 
200
- // 8. Listen for drag-surface:transfer
201
- this.listen(this, 'drag-surface:transfer', (e) => {
202
- const detail = /** @type {CustomEvent} */ (e).detail;
203
- const fromSurface = detail.fromSurface;
204
- const toSurface = detail.toSurface;
205
- const fromColumn = this.#findColumnForSurface(fromSurface);
206
- const toColumn = this.#findColumnForSurface(toSurface);
207
- if (!fromColumn || !toColumn) return;
208
-
209
- // Update data-column attribute on the transferred item
210
- if (detail.item) {
211
- detail.item.setAttribute('data-column', toColumn);
212
- }
191
+ if (detail.item) detail.item.setAttribute('data-column', toColumn);
213
192
 
214
- // Update count badges and WIP state
215
- this.#updateCount(fromColumn);
216
- this.#updateCount(toColumn);
217
-
218
- // Remove placeholder from receiving column if it had one
219
- const toPlaceholder = toSurface.querySelector('.kb-empty');
220
- if (toPlaceholder) toPlaceholder.remove();
221
-
222
- // Add placeholder to sending column if now empty
223
- const fromDraggables = fromSurface.querySelectorAll(':scope > [draggable="true"]');
224
- if (fromDraggables.length === 0 && !fromSurface.querySelector('.kb-empty')) {
225
- const placeholder = document.createElement('p');
226
- placeholder.className = 'kb-empty';
227
- placeholder.textContent = 'No items';
228
- fromSurface.appendChild(placeholder);
229
- }
193
+ this.#updateCount(fromColumn);
194
+ this.#updateCount(toColumn);
195
+ syncPlaceholder(toSurface, 'kb-empty', 'No items');
196
+ syncPlaceholder(fromSurface, 'kb-empty', 'No items');
197
+
198
+ announce(this.#liveRegion,
199
+ `${itemLabel(detail.item)} moved from ${this.#columnLabel(fromColumn)} to ${this.#columnLabel(toColumn)}`);
230
200
 
231
- // Announce via live region
232
- const itemLabel = this.#itemLabel(detail.item);
233
- const fromLabel = this.#columnLabel(fromColumn);
234
- const toLabel = this.#columnLabel(toColumn);
235
- this.#announce(`${itemLabel} moved from ${fromLabel} to ${toLabel}`);
201
+ this.#syncItemsFromDom('drag');
236
202
 
237
- this.#syncItemsFromDom('drag');
203
+ this.dispatchEvent(new CustomEvent('kanban-board:transfer', {
204
+ bubbles: true,
205
+ detail: {
206
+ itemId: detail.itemId,
207
+ fromColumn,
208
+ toColumn,
209
+ newIndex: detail.newIndex,
210
+ item: detail.item,
211
+ },
212
+ }));
238
213
 
239
- this.dispatchEvent(new CustomEvent('kanban-board:transfer', {
214
+ const toCount = toSurface.querySelectorAll(':scope > [draggable="true"]').length;
215
+ const toColMeta = this.#columns.find(c => c.id === toColumn);
216
+ if (toColMeta && toColMeta.wip != null && toCount > toColMeta.wip) {
217
+ this.dispatchEvent(new CustomEvent('kanban-board:wip-exceeded', {
240
218
  bubbles: true,
241
219
  detail: {
242
- itemId: detail.itemId,
243
- fromColumn,
244
- toColumn,
245
- newIndex: detail.newIndex,
246
- item: detail.item,
220
+ column: toColumn,
221
+ limit: toColMeta.wip,
222
+ count: toCount,
247
223
  },
248
224
  }));
249
-
250
- // Check WIP limit on receiving column
251
- const toCount = toSurface.querySelectorAll(':scope > [draggable="true"]').length;
252
- const toColMeta = this.#columns.find(c => c.id === toColumn);
253
- if (toColMeta?.wip !== null && toColMeta?.wip !== undefined && toCount > toColMeta.wip) {
254
- this.dispatchEvent(new CustomEvent('kanban-board:wip-exceeded', {
255
- bubbles: true,
256
- detail: {
257
- column: toColumn,
258
- limit: toColMeta.wip,
259
- count: toCount,
260
- },
261
- }));
262
- }
263
- });
264
-
265
- // 9. Seed VBCollection state from existing rendered children so future
266
- // .items assignments diff against them instead of recreating.
267
- this.#seedFromDom();
268
-
269
- // 10. Handle src attribute for data mode
270
- const src = this.getAttribute('src');
271
- if (src) {
272
- this._loadSrc(src);
273
- return; // ready event fires after src loads
274
225
  }
275
-
276
- // 11. Dispatch ready event
277
- this.dispatchEvent(new CustomEvent('kanban-board:ready', {
278
- bubbles: true,
279
- detail: {
280
- columnCount: this.#columns.length,
281
- itemCount: totalItems,
282
- },
283
- }));
284
- }
226
+ };
285
227
 
286
228
  /**
287
229
  * Walk the rendered drag-surfaces and register each draggable child by
@@ -335,20 +277,10 @@ class KanbanBoard extends VBCollection(VBElement) {
335
277
  }
336
278
 
337
279
  teardown() {
338
- // Remove title if we created one
339
- const title = this.querySelector('.kb-title');
340
- if (title) title.remove();
341
- if (this.#columnsEl) {
342
- this.#columnsEl.remove();
343
- this.#columnsEl = null;
344
- }
345
- if (this.#liveRegion) {
346
- this.#liveRegion.remove();
347
- this.#liveRegion = null;
348
- }
349
- this.#surfaces = {};
350
- this.#columnEls = {};
351
- this.#columns = [];
280
+ // Listeners are removed by VBElement. The built shell and every card
281
+ // stay in place so a board that is moved in the DOM keeps its content;
282
+ // the next connect only re-attaches listeners.
283
+ this.#wired = false;
352
284
  }
353
285
 
354
286
  // ── Data API (HTML-first / JS-first dual contract) ──────────────
@@ -356,8 +288,10 @@ class KanbanBoard extends VBCollection(VBElement) {
356
288
  /**
357
289
  * The current column definitions. After upgrade this reflects the parsed
358
290
  * `<section data-column>` children. Setting replaces all columns and
359
- * rebuilds the shell — items already in nodes get re-routed to the new
360
- * surfaces; orphaned items (column id no longer present) are dropped.
291
+ * rebuilds the shell: existing cards whose `column` still exists are
292
+ * moved (same DOM nodes) into the new columns; cards whose column is
293
+ * gone are removed from the board and from `.items` (an
294
+ * `items-changed` event with source `api` reports the drop).
361
295
  */
362
296
  get columns() {
363
297
  return this.#columns.map(c => ({
@@ -367,23 +301,52 @@ class KanbanBoard extends VBCollection(VBElement) {
367
301
 
368
302
  set columns(value) {
369
303
  const next = (value || []).map(c => ({
370
- id: c.id, label: c.label || this.#titleCase(c.id),
371
- wip: c.wip ?? null, color: c.color ?? null, children: [],
304
+ id: c.id, label: c.label || titleCase(c.id),
305
+ wip: c.wip ?? null, color: c.color ?? null,
372
306
  }));
307
+ if (!this.#built) {
308
+ // JS-first: columns assigned before the board connected.
309
+ this.#built = true;
310
+ this.#liveRegion = createLiveRegion('kb-live-region');
311
+ this.appendChild(this.#liveRegion);
312
+ }
373
313
  const oldShell = this.#columnsEl;
374
314
  // Build the new shell off-DOM and update internal refs synchronously
375
- // so a chained `.items = …` lookup sees the new surfaces immediately
376
- // (the diff appends into the detached shell; both come together when
377
- // the View Transition callback attaches the new shell to the host).
378
- this.#surfaces = {};
379
- this.#columnEls = {};
315
+ // so a chained `.items = …` sees the new surfaces immediately.
380
316
  this.#columns = next;
381
317
  const newShell = this.#assembleShell();
382
318
  this.#columnsEl = newShell;
319
+ this.#syncTitle();
320
+
321
+ // Re-route existing cards into the new surfaces (node identity kept).
322
+ const before = this.items;
323
+ /** @type {any[]} */
324
+ const kept = [];
325
+ /** @type {Map<unknown, Element>} */
326
+ const nodeMap = new Map();
327
+ for (const it of before) {
328
+ const key = KanbanBoard.keyOf(it);
329
+ const el = this._nodeFor(key);
330
+ const surface = this.#surfaces[it.column];
331
+ if (!el) continue;
332
+ if (surface) {
333
+ surface.appendChild(el);
334
+ kept.push(it);
335
+ nodeMap.set(key, el);
336
+ } else {
337
+ el.remove();
338
+ }
339
+ }
340
+ // Keep .items in the new column order (matching the DOM).
341
+ const order = new Map(next.map((c, i) => [c.id, i]));
342
+ kept.sort((a, b) => (order.get(a.column) ?? 0) - (order.get(b.column) ?? 0));
343
+ this._seedCollection(kept, nodeMap);
344
+ this._postRender();
345
+ if (kept.length !== before.length) this._emit('items-changed', { items: kept }, 'api');
383
346
 
384
347
  const swap = () => {
385
348
  if (oldShell) oldShell.remove();
386
- this.appendChild(newShell);
349
+ this.#attachShell(newShell);
387
350
  };
388
351
  if (this.hasAttribute('data-upgraded') && oldShell) {
389
352
  viewTransitionSwap(this, swap, 'kb-vt');
@@ -442,19 +405,9 @@ class KanbanBoard extends VBCollection(VBElement) {
442
405
  * placeholders after every diff.
443
406
  */
444
407
  _postRender() {
445
- for (const id of Object.keys(this.#surfaces)) {
408
+ for (const [id, surface] of Object.entries(this.#surfaces)) {
446
409
  this.#updateCount(id);
447
- const surface = this.#surfaces[id];
448
- const draggables = surface.querySelectorAll(':scope > [draggable="true"]');
449
- const placeholder = surface.querySelector(':scope > .kb-empty');
450
- if (draggables.length === 0 && !placeholder) {
451
- const p = document.createElement('p');
452
- p.className = 'kb-empty';
453
- p.textContent = 'No items';
454
- surface.appendChild(p);
455
- } else if (draggables.length > 0 && placeholder) {
456
- placeholder.remove();
457
- }
410
+ syncPlaceholder(surface, 'kb-empty', 'No items');
458
411
  }
459
412
  }
460
413
 
@@ -489,17 +442,27 @@ class KanbanBoard extends VBCollection(VBElement) {
489
442
 
490
443
  /**
491
444
  * Build the kb-columns shell from the current `#columns` metas and
492
- * populate `#surfaces` / `#columnEls`. Returns the detached shell so
493
- * the caller decides when to attach it (sync, or inside a View
494
- * Transition callback).
445
+ * populate `#surfaces` / `#columnEls`. `children[i]` (optional) are the
446
+ * authored cards for column i; they are moved into its drag-surface.
447
+ * Returns the detached shell so the caller decides when to attach it
448
+ * (sync, or inside a View Transition callback).
449
+ *
450
+ * @param {HTMLElement[][]} [children]
451
+ * @returns {HTMLDivElement}
495
452
  */
496
- #assembleShell() {
453
+ #assembleShell(children = []) {
454
+ // `.columns` may be set before the first connect (JS-first), so the
455
+ // drag-surface group id is assigned here, not only in setup().
456
+ this.#ensureGroupId();
457
+ this.#surfaces = {};
458
+ this.#columnEls = {};
497
459
  const shell = document.createElement('div');
498
460
  shell.className = 'kb-columns';
499
461
  shell.setAttribute('role', 'region');
500
- shell.setAttribute('aria-label', 'Kanban board');
462
+ shell.setAttribute('aria-label', this.getAttribute('title') || 'Kanban board');
501
463
 
502
- for (const col of this.#columns) {
464
+ let seq = 0;
465
+ this.#columns.forEach((col, i) => {
503
466
  const column = document.createElement('section');
504
467
  column.className = 'kb-column';
505
468
  column.setAttribute('data-column-id', col.id);
@@ -513,7 +476,7 @@ class KanbanBoard extends VBCollection(VBElement) {
513
476
  countBadge.className = 'kb-count';
514
477
  countBadge.textContent = '0';
515
478
  h3.appendChild(countBadge);
516
- if (col.wip != null && !isNaN(col.wip)) {
479
+ if (col.wip != null) {
517
480
  const wipBadge = document.createElement('span');
518
481
  wipBadge.className = 'kb-wip';
519
482
  wipBadge.textContent = `/ ${col.wip}`;
@@ -528,16 +491,18 @@ class KanbanBoard extends VBCollection(VBElement) {
528
491
  surface.setAttribute('aria-label', `${col.label} items`);
529
492
  surface.setAttribute('data-layout', 'stack');
530
493
  surface.setAttribute('data-layout-gap', 'xs');
531
- const placeholder = document.createElement('p');
532
- placeholder.className = 'kb-empty';
533
- placeholder.textContent = 'No items';
534
- surface.appendChild(placeholder);
494
+ for (const child of children[i] || []) {
495
+ if (!child.hasAttribute('draggable')) child.setAttribute('draggable', 'true');
496
+ if (!child.hasAttribute('data-id')) child.dataset.id = `kb-item-${seq}`;
497
+ seq += 1;
498
+ surface.appendChild(child);
499
+ }
535
500
 
536
501
  column.appendChild(surface);
537
502
  shell.appendChild(column);
538
503
  this.#surfaces[col.id] = surface;
539
504
  this.#columnEls[col.id] = column;
540
- }
505
+ });
541
506
 
542
507
  return shell;
543
508
  }
@@ -545,72 +510,98 @@ class KanbanBoard extends VBCollection(VBElement) {
545
510
  // ── Attribute changes ────────────────────────────────
546
511
 
547
512
  attributeChangedCallback(name, oldValue, newValue) {
548
- if (oldValue === newValue || !this.isConnected) return;
549
- if (name === 'src') this._loadSrc(newValue);
513
+ if (oldValue === newValue || !this.#built) return;
514
+ if (name === 'src' && newValue && newValue !== this.#loader.url) this._loadSrc(newValue);
515
+ if (name === 'src' && !newValue) this.#loader.cancel(); // src removed: drop a pending load, keep the cards
516
+ if (name === 'title') this.#syncTitle();
550
517
  }
551
518
 
552
519
  // ── JSON loading ────────────────────────────────────
553
520
 
521
+ /**
522
+ * Fetch `{ columns: [{ id, label?, wip?, color?, items: [...] }] }` and
523
+ * replace the board with it. One request per src value; a newer src
524
+ * aborts an older in-flight one. Once the data has rendered, fires
525
+ * `kanban-board:items-changed` (source 'src'), and `kanban-board:ready`
526
+ * if it has not fired yet (ready is once per element).
527
+ * @param {string} url
528
+ */
554
529
  async _loadSrc(url) {
555
530
  if (!url) return;
556
531
  try {
557
- const res = await fetch(url);
558
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
559
- const data = await res.json();
560
-
561
- // Clear existing children
562
- while (this.firstChild) this.firstChild.remove();
563
-
564
- // Create sections from columns
565
- for (const col of (data.columns || [])) {
566
- const section = document.createElement('section');
567
- section.setAttribute('data-column', col.id);
568
- if (col.label) section.setAttribute('data-column-label', col.label);
569
- if (col.wip != null) section.setAttribute('data-wip', String(col.wip));
570
- if (col.color) section.setAttribute('data-column-color', col.color);
571
-
572
- for (const item of (col.items || [])) {
573
- let el;
574
- if (item.persona || item.action || item.storyId) {
575
- el = document.createElement('user-story');
576
- el.setAttribute('detail', item.detail || 'minimal');
577
- if (item.storyId) el.setAttribute('story-id', item.storyId);
578
- if (item.persona) el.setAttribute('persona', item.persona);
579
- if (item.action) el.setAttribute('action', item.action);
580
- if (item.benefit) el.setAttribute('benefit', item.benefit);
581
- if (item.priority) el.setAttribute('priority', item.priority);
582
- if (item.status) el.setAttribute('status', item.status);
583
- if (item.points) el.setAttribute('points', String(item.points));
584
- } else {
585
- el = document.createElement('article');
586
- el.className = 'kb-card';
587
- el.textContent = item.text || item.label || '';
588
- }
589
-
590
- el.setAttribute('draggable', 'true');
591
- el.dataset.id = item.id || item.storyId || '';
592
- section.appendChild(el);
593
- }
594
- this.appendChild(section);
595
- }
596
-
597
- const swap = () => {
598
- this.teardown();
599
- this.removeAttribute('data-upgraded');
600
- this.setup();
532
+ const result = await this.#loader.fetchJson(url);
533
+ if (!result) return;
534
+ const { data, isCurrent } = result;
535
+
536
+ /** @type {HTMLElement[][]} */
537
+ const children = [];
538
+ const metas = (data.columns || []).map((/** @type {any} */ col) => {
539
+ children.push((col.items || []).map((/** @type {any} */ item) => this.#cardFromJson(item)));
540
+ const wip = col.wip != null ? parseInt(String(col.wip), 10) : null;
541
+ return {
542
+ id: String(col.id ?? ''),
543
+ label: col.label || titleCase(String(col.id ?? '')),
544
+ wip: wip !== null && isNaN(wip) ? null : wip,
545
+ color: col.color || null,
546
+ };
547
+ });
548
+
549
+ const render = () => {
550
+ this.#columnsEl?.remove();
551
+ this.#columns = metas;
552
+ this.#columnsEl = this.#assembleShell(children);
553
+ this.#attachShell(this.#columnsEl);
554
+ this._postRender();
555
+ this.#seedFromDom();
601
556
  };
602
- if (this.hasAttribute('data-upgraded') && this.#columnsEl) {
603
- viewTransitionSwap(this, swap, 'kb-vt');
604
- } else {
605
- swap();
606
- }
557
+ // viewTransitionSwap runs render synchronously when it can't animate.
558
+ const tx = viewTransitionSwap(this, render, 'kb-vt');
559
+ if (tx) await tx.updateCallbackDone;
560
+ if (!isCurrent()) return;
561
+ this._emit('items-changed', { items: this.items }, 'src');
562
+ if (!this.#readyFired) this.#dispatchReady();
607
563
  } catch (err) {
608
564
  console.warn(`[kanban-board] Failed to load src="${url}":`, err);
609
565
  }
610
566
  }
611
567
 
568
+ /**
569
+ * JSON item → card element. Story-shaped records become a `<user-story>`
570
+ * with slotted persona/action/benefit/title; anything else a plain card.
571
+ * @param {Record<string, any>} item
572
+ * @returns {HTMLElement}
573
+ */
574
+ #cardFromJson(item) {
575
+ let el;
576
+ if (isStoryRecord(item)) {
577
+ el = createStoryCard(item, 'minimal');
578
+ } else {
579
+ el = document.createElement('article');
580
+ el.className = 'kb-card';
581
+ el.textContent = item.text || item.label || item.title || '';
582
+ }
583
+ el.setAttribute('draggable', 'true');
584
+ const id = item.id || item.storyId;
585
+ if (id) el.dataset.id = String(id);
586
+ return el;
587
+ }
588
+
612
589
  // ── Helpers ──────────────────────────────────────────
613
590
 
591
+ /** Assign this board's drag-surface group id once. */
592
+ #ensureGroupId() {
593
+ if (!this.#groupId) this.#groupId = `kb-${++KanbanBoard.#instanceCount}`;
594
+ }
595
+
596
+ /**
597
+ * Insert the columns shell just before the live region (after the title).
598
+ * @param {HTMLElement} shell
599
+ */
600
+ #attachShell(shell) {
601
+ const region = this.#liveRegion;
602
+ this.insertBefore(shell, region?.parentNode ? region : null);
603
+ }
604
+
614
605
  /**
615
606
  * Reverse-lookup: find the column id that owns a given drag-surface.
616
607
  * @param {HTMLElement | null} surface
@@ -655,42 +646,7 @@ class KanbanBoard extends VBCollection(VBElement) {
655
646
  */
656
647
  #columnLabel(columnId) {
657
648
  const col = this.#columns.find(c => c.id === columnId);
658
- return col?.label || this.#titleCase(columnId);
659
- }
660
-
661
- /**
662
- * Announce a message via the live region.
663
- * @param {string} msg
664
- */
665
- #announce(msg) {
666
- if (!this.#liveRegion) return;
667
- this.#liveRegion.textContent = '';
668
- requestAnimationFrame(() => {
669
- if (this.#liveRegion) this.#liveRegion.textContent = msg;
670
- });
671
- }
672
-
673
- /**
674
- * Get a human-readable label for a board item.
675
- * @param {HTMLElement} item
676
- * @returns {string}
677
- */
678
- #itemLabel(item) {
679
- return item.getAttribute('story-id')
680
- || item.getAttribute('data-id')
681
- || item.textContent?.trim().slice(0, 40)
682
- || 'item';
683
- }
684
-
685
- /**
686
- * Convert a hyphenated or lowercase id to Title Case.
687
- * @param {string} str
688
- * @returns {string}
689
- */
690
- #titleCase(str) {
691
- return str
692
- .replace(/[-_]/g, ' ')
693
- .replace(/\b\w/g, c => c.toUpperCase());
649
+ return col?.label || titleCase(columnId);
694
650
  }
695
651
  }
696
652