@profpowell/vb-project-planning 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/dist/vb-project-planning.css +1603 -0
  4. package/dist/vb-project-planning.js +11138 -0
  5. package/dist/vb-project-planning.min.css +1 -0
  6. package/dist/vb-project-planning.min.js +3312 -0
  7. package/package.json +80 -0
  8. package/src/index.css +14 -0
  9. package/src/index.js +26 -0
  10. package/src/lib/bundle-registry.js +156 -0
  11. package/src/lib/diff-by-key.js +86 -0
  12. package/src/lib/portrait-url.js +87 -0
  13. package/src/lib/vb-collection.js +122 -0
  14. package/src/lib/vb-element.js +96 -0
  15. package/src/lib/vb-store.js +200 -0
  16. package/src/lib/vb-view-transition.js +44 -0
  17. package/src/utils/copy-init.js +287 -0
  18. package/src/web-components/_ux-base.js +106 -0
  19. package/src/web-components/adr-wc/README.md +77 -0
  20. package/src/web-components/adr-wc/api.json +26 -0
  21. package/src/web-components/adr-wc/logic.js +376 -0
  22. package/src/web-components/adr-wc/static.html +17 -0
  23. package/src/web-components/adr-wc/styles.js +338 -0
  24. package/src/web-components/burndown-chart/api.json +74 -0
  25. package/src/web-components/burndown-chart/logic.js +198 -0
  26. package/src/web-components/burndown-chart/static.html +31 -0
  27. package/src/web-components/burndown-chart/styles.css +59 -0
  28. package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
  29. package/src/web-components/capacity-plan/api.json +20 -0
  30. package/src/web-components/capacity-plan/logic.js +222 -0
  31. package/src/web-components/capacity-plan/static.html +24 -0
  32. package/src/web-components/capacity-plan/styles.css +84 -0
  33. package/src/web-components/empathy-map/api.json +31 -0
  34. package/src/web-components/empathy-map/logic.js +502 -0
  35. package/src/web-components/empathy-map/static.html +32 -0
  36. package/src/web-components/empathy-map/styles.js +534 -0
  37. package/src/web-components/gantt-chart/README.md +91 -0
  38. package/src/web-components/gantt-chart/api.json +20 -0
  39. package/src/web-components/gantt-chart/logic.js +812 -0
  40. package/src/web-components/gantt-chart/static.html +60 -0
  41. package/src/web-components/gantt-chart/styles.css +391 -0
  42. package/src/web-components/impact-effort/api.json +21 -0
  43. package/src/web-components/impact-effort/logic.js +392 -0
  44. package/src/web-components/impact-effort/static.html +23 -0
  45. package/src/web-components/impact-effort/styles.css +193 -0
  46. package/src/web-components/iron-triangle/_capacity.js +83 -0
  47. package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
  48. package/src/web-components/iron-triangle/api.json +26 -0
  49. package/src/web-components/iron-triangle/logic.js +474 -0
  50. package/src/web-components/iron-triangle/static.html +28 -0
  51. package/src/web-components/iron-triangle/styles.css +158 -0
  52. package/src/web-components/kanban-board/api.json +24 -0
  53. package/src/web-components/kanban-board/logic.js +698 -0
  54. package/src/web-components/kanban-board/static.html +20 -0
  55. package/src/web-components/kanban-board/styles.css +242 -0
  56. package/src/web-components/product-roadmap/api.json +118 -0
  57. package/src/web-components/product-roadmap/logic.js +460 -0
  58. package/src/web-components/product-roadmap/static.html +44 -0
  59. package/src/web-components/product-roadmap/styles.css +158 -0
  60. package/src/web-components/quadrant-grid/api.json +125 -0
  61. package/src/web-components/quadrant-grid/logic.js +397 -0
  62. package/src/web-components/quadrant-grid/static.html +49 -0
  63. package/src/web-components/quadrant-grid/styles.css +184 -0
  64. package/src/web-components/quality-target/_quality-utils.js +460 -0
  65. package/src/web-components/quality-target/api.json +30 -0
  66. package/src/web-components/quality-target/logic.js +516 -0
  67. package/src/web-components/quality-target/static.html +22 -0
  68. package/src/web-components/quality-target/styles.css +203 -0
  69. package/src/web-components/requirement-card/api.json +25 -0
  70. package/src/web-components/requirement-card/logic.js +166 -0
  71. package/src/web-components/requirement-card/static.html +36 -0
  72. package/src/web-components/requirement-card/styles.js +114 -0
  73. package/src/web-components/review-surface/README.md +178 -0
  74. package/src/web-components/review-surface/api.json +30 -0
  75. package/src/web-components/review-surface/logic.js +807 -0
  76. package/src/web-components/review-surface/static.html +33 -0
  77. package/src/web-components/review-surface/styles.js +459 -0
  78. package/src/web-components/risk-register/api.json +47 -0
  79. package/src/web-components/risk-register/logic.js +180 -0
  80. package/src/web-components/risk-register/static.html +46 -0
  81. package/src/web-components/risk-register/styles.css +37 -0
  82. package/src/web-components/story-map/api.json +22 -0
  83. package/src/web-components/story-map/logic.js +445 -0
  84. package/src/web-components/story-map/static.html +35 -0
  85. package/src/web-components/story-map/styles.css +169 -0
  86. package/src/web-components/traceability-matrix/api.json +84 -0
  87. package/src/web-components/traceability-matrix/logic.js +243 -0
  88. package/src/web-components/traceability-matrix/static.html +41 -0
  89. package/src/web-components/traceability-matrix/styles.css +94 -0
  90. package/src/web-components/user-journey/api.json +84 -0
  91. package/src/web-components/user-journey/logic.js +368 -0
  92. package/src/web-components/user-journey/static.html +14 -0
  93. package/src/web-components/user-journey/styles.js +317 -0
  94. package/src/web-components/user-persona/api.json +35 -0
  95. package/src/web-components/user-persona/logic.js +502 -0
  96. package/src/web-components/user-persona/static.html +20 -0
  97. package/src/web-components/user-persona/styles.js +299 -0
  98. package/src/web-components/user-story/api.json +35 -0
  99. package/src/web-components/user-story/logic.js +567 -0
  100. package/src/web-components/user-story/static.html +27 -0
  101. package/src/web-components/user-story/styles.js +332 -0
  102. package/src/web-components/work-item/README.md +85 -0
  103. package/src/web-components/work-item/api.json +30 -0
  104. package/src/web-components/work-item/logic.js +414 -0
  105. package/src/web-components/work-item/static.html +24 -0
  106. package/src/web-components/work-item/styles.js +376 -0
@@ -0,0 +1,698 @@
1
+ /**
2
+ * kanban-board: Columnar drag-and-drop board
3
+ *
4
+ * Light DOM component that creates a board with user-defined columns,
5
+ * each containing a <drag-surface> for cross-column drag-and-drop.
6
+ * Works with any draggable content — user-story cards, articles, or
7
+ * any element with draggable="true" and data-id.
8
+ *
9
+ * @attr {string} src - URL to JSON data for columns and items
10
+ * @attr {string} title - Optional heading above the board
11
+ * @attr {boolean} compact - Reduced spacing variant
12
+ *
13
+ * Children use:
14
+ * @attr {string} data-column - Column identifier (on <section>)
15
+ * @attr {string} data-column-label - Display label for the column header (on <section>)
16
+ * @attr {number} data-wip - Optional WIP limit for the column (on <section>)
17
+ * @attr {string} data-column-color - Optional color token for column tint (on <section>)
18
+ *
19
+ * @fires kanban-board:transfer - Item moved between columns
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
23
+ *
24
+ * @example
25
+ * <kanban-board>
26
+ * <section data-column="backlog" data-column-label="Backlog">
27
+ * <article draggable="true" data-id="task-1">Design review</article>
28
+ * </section>
29
+ * <section data-column="doing" data-column-label="In Progress" data-wip="3">
30
+ * <article draggable="true" data-id="task-2">API integration</article>
31
+ * </section>
32
+ * </kanban-board>
33
+ */
34
+
35
+ import { registerComponent } from '../../lib/bundle-registry.js';
36
+ import { VBElement } from '../../lib/vb-element.js';
37
+ import { VBCollection } from '../../lib/vb-collection.js';
38
+ import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
39
+
40
+ class KanbanBoard extends VBCollection(VBElement) {
41
+ static get observedAttributes() { return ['src', 'compact', 'title']; }
42
+ static keyOf(item) { return item.id ?? item.storyId; }
43
+
44
+ static #instanceCount = 0;
45
+
46
+ /** @type {HTMLDivElement | null} */
47
+ #columnsEl = null;
48
+ /** @type {Record<string, HTMLElement>} */
49
+ #surfaces = {};
50
+ /** @type {Record<string, HTMLElement>} */
51
+ #columnEls = {};
52
+ /** @type {HTMLDivElement | null} */
53
+ #liveRegion = null;
54
+ /** @type {string} */
55
+ #groupId = '';
56
+ /** @type {Array<{ id: string, label: string, wip: number | null, color: string | null, children: HTMLElement[] }>} */
57
+ #columns = [];
58
+ /** @type {((item: any) => Element) | null} */
59
+ #renderItem = null;
60
+
61
+ setup() {
62
+ this.#groupId = `kb-${++KanbanBoard.#instanceCount}`;
63
+
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();
83
+ }
84
+
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);
111
+
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
+ }
119
+
120
+ header.appendChild(h3);
121
+ column.appendChild(header);
122
+
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');
129
+
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
+ }
148
+
149
+ column.appendChild(surface);
150
+ this.#columnsEl.appendChild(column);
151
+ this.#surfaces[col.id] = surface;
152
+ this.#columnEls[col.id] = column;
153
+
154
+ // Check initial WIP state
155
+ if (col.wip !== null && col.children.length > col.wip) {
156
+ column.setAttribute('data-wip-exceeded', '');
157
+ }
158
+ }
159
+
160
+ // 5. Assemble: title + columns, append to host
161
+ const title = this.getAttribute('title');
162
+ if (title) {
163
+ const heading = document.createElement('h2');
164
+ heading.className = 'kb-title';
165
+ heading.textContent = title;
166
+ this.appendChild(heading);
167
+ }
168
+
169
+ this.appendChild(this.#columnsEl);
170
+
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);
178
+
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;
185
+
186
+ this.#updateCount(columnId);
187
+ this.#syncItemsFromDom('drag');
188
+
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
+ });
199
+
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
+ }
213
+
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
+ }
230
+
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}`);
236
+
237
+ this.#syncItemsFromDom('drag');
238
+
239
+ this.dispatchEvent(new CustomEvent('kanban-board:transfer', {
240
+ bubbles: true,
241
+ detail: {
242
+ itemId: detail.itemId,
243
+ fromColumn,
244
+ toColumn,
245
+ newIndex: detail.newIndex,
246
+ item: detail.item,
247
+ },
248
+ }));
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
+ }
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
+ }
285
+
286
+ /**
287
+ * Walk the rendered drag-surfaces and register each draggable child by
288
+ * its data-id so VBCollection's diff treats them as managed nodes.
289
+ */
290
+ #seedFromDom() {
291
+ /** @type {any[]} */
292
+ const items = [];
293
+ /** @type {Map<unknown, Element>} */
294
+ const nodeMap = new Map();
295
+ for (const [columnId, surface] of Object.entries(this.#surfaces)) {
296
+ const draggables = surface.querySelectorAll(':scope > [draggable="true"]');
297
+ for (const el of draggables) {
298
+ const id = el.getAttribute('data-id');
299
+ if (!id) continue;
300
+ items.push({ id, column: columnId });
301
+ nodeMap.set(id, el);
302
+ }
303
+ }
304
+ this._seedCollection(items, nodeMap);
305
+ }
306
+
307
+ /**
308
+ * Re-derive the internal items array from current DOM order (used after
309
+ * a drag mutates the DOM directly). Preserves consumer-supplied fields
310
+ * by carrying over each item's prior shape and just updating column.
311
+ * Emits `kanban-board:items-changed` with the given source.
312
+ *
313
+ * @param {'drag' | 'api' | 'attribute'} source
314
+ */
315
+ #syncItemsFromDom(source) {
316
+ /** @type {any[]} */
317
+ const next = [];
318
+ /** @type {Map<string, any>} */
319
+ const previous = new Map();
320
+ for (const it of this.items) {
321
+ const k = String(it.id ?? it.storyId ?? '');
322
+ if (k) previous.set(k, it);
323
+ }
324
+ for (const [columnId, surface] of Object.entries(this.#surfaces)) {
325
+ const draggables = surface.querySelectorAll(':scope > [draggable="true"]');
326
+ for (const el of draggables) {
327
+ const id = el.getAttribute('data-id');
328
+ if (!id) continue;
329
+ const prior = previous.get(id);
330
+ next.push(prior ? { ...prior, column: columnId } : { id, column: columnId });
331
+ }
332
+ }
333
+ this._setItemsSilently(next);
334
+ this._emit('items-changed', { items: next }, source);
335
+ }
336
+
337
+ 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 = [];
352
+ }
353
+
354
+ // ── Data API (HTML-first / JS-first dual contract) ──────────────
355
+
356
+ /**
357
+ * The current column definitions. After upgrade this reflects the parsed
358
+ * `<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.
361
+ */
362
+ get columns() {
363
+ return this.#columns.map(c => ({
364
+ id: c.id, label: c.label, wip: c.wip ?? undefined, color: c.color ?? undefined,
365
+ }));
366
+ }
367
+
368
+ set columns(value) {
369
+ 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: [],
372
+ }));
373
+ const oldShell = this.#columnsEl;
374
+ // 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 = {};
380
+ this.#columns = next;
381
+ const newShell = this.#assembleShell();
382
+ this.#columnsEl = newShell;
383
+
384
+ const swap = () => {
385
+ if (oldShell) oldShell.remove();
386
+ this.appendChild(newShell);
387
+ };
388
+ if (this.hasAttribute('data-upgraded') && oldShell) {
389
+ viewTransitionSwap(this, swap, 'kb-vt');
390
+ } else {
391
+ swap();
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Optional custom item renderer. When set, replaces the default
397
+ * `<work-item>` factory used by the `.items` setter.
398
+ */
399
+ get renderItem() { return this.#renderItem; }
400
+ set renderItem(fn) { this.#renderItem = typeof fn === 'function' ? fn : null; }
401
+
402
+ /**
403
+ * VBCollection hook: build a fresh element for a new key.
404
+ * Default: a `<work-item>` with the item's fields applied via .data.
405
+ * Always returns a draggable element with data-id set for drag-surface.
406
+ */
407
+ _renderItem(item) {
408
+ let el;
409
+ if (this.#renderItem) {
410
+ const out = this.#renderItem(item);
411
+ if (!(out instanceof Element)) {
412
+ throw new Error('kanban-board: renderItem must return an Element');
413
+ }
414
+ el = out;
415
+ } else {
416
+ el = this.#defaultRenderItem(item);
417
+ }
418
+ if (!el.hasAttribute('draggable')) el.setAttribute('draggable', 'true');
419
+ if (!el.hasAttribute('data-id')) el.setAttribute('data-id', String(KanbanBoard.keyOf(item)));
420
+ return el;
421
+ }
422
+
423
+ /**
424
+ * VBCollection hook: route this item to the drag-surface for its column.
425
+ * @param {any} item
426
+ * @param {any} _existing
427
+ * @returns {any}
428
+ */
429
+ _containerFor(item, _existing) {
430
+ const surface = this.#surfaces[item.column];
431
+ if (!surface) {
432
+ throw new Error(`kanban-board: no column "${item.column}" — set .columns first or include item.column matching an existing column id`);
433
+ }
434
+ // Remove the empty placeholder before inserting managed children.
435
+ const placeholder = surface.querySelector(':scope > .kb-empty');
436
+ if (placeholder) placeholder.remove();
437
+ return surface;
438
+ }
439
+
440
+ /**
441
+ * VBCollection hook: refresh count badges, WIP markers, and empty
442
+ * placeholders after every diff.
443
+ */
444
+ _postRender() {
445
+ for (const id of Object.keys(this.#surfaces)) {
446
+ 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
+ }
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Default renderer: build a `<work-item>` and apply item fields via .data.
463
+ * Falls back to a plain `<article>` if work-item isn't registered.
464
+ */
465
+ #defaultRenderItem(item) {
466
+ const tag = customElements.get('work-item') ? 'work-item' : 'article';
467
+ const el = /** @type {any} */ (document.createElement(tag));
468
+ if (tag === 'work-item') {
469
+ // Map the kanban item shape to work-item's data shape.
470
+ el.data = {
471
+ itemId: item.id ?? item.storyId,
472
+ type: item.type,
473
+ priority: item.priority,
474
+ status: item.status,
475
+ estimate: item.estimate != null ? String(item.estimate) : undefined,
476
+ assignee: item.assignee,
477
+ title: item.title,
478
+ description: item.description,
479
+ checklist: item.checklist,
480
+ notes: item.notes,
481
+ detail: item.detail,
482
+ };
483
+ } else {
484
+ el.className = 'kb-card';
485
+ el.textContent = item.title || item.label || item.id;
486
+ }
487
+ return el;
488
+ }
489
+
490
+ /**
491
+ * 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).
495
+ */
496
+ #assembleShell() {
497
+ const shell = document.createElement('div');
498
+ shell.className = 'kb-columns';
499
+ shell.setAttribute('role', 'region');
500
+ shell.setAttribute('aria-label', 'Kanban board');
501
+
502
+ for (const col of this.#columns) {
503
+ const column = document.createElement('section');
504
+ column.className = 'kb-column';
505
+ column.setAttribute('data-column-id', col.id);
506
+ if (col.color) column.setAttribute('data-column-tint', col.color);
507
+
508
+ const header = document.createElement('header');
509
+ header.className = 'kb-column-header';
510
+ const h3 = document.createElement('h3');
511
+ h3.textContent = col.label;
512
+ const countBadge = document.createElement('output');
513
+ countBadge.className = 'kb-count';
514
+ countBadge.textContent = '0';
515
+ h3.appendChild(countBadge);
516
+ if (col.wip != null && !isNaN(col.wip)) {
517
+ const wipBadge = document.createElement('span');
518
+ wipBadge.className = 'kb-wip';
519
+ wipBadge.textContent = `/ ${col.wip}`;
520
+ wipBadge.setAttribute('aria-label', `WIP limit ${col.wip}`);
521
+ h3.appendChild(wipBadge);
522
+ }
523
+ header.appendChild(h3);
524
+ column.appendChild(header);
525
+
526
+ const surface = document.createElement('drag-surface');
527
+ surface.setAttribute('group', this.#groupId);
528
+ surface.setAttribute('aria-label', `${col.label} items`);
529
+ surface.setAttribute('data-layout', 'stack');
530
+ 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);
535
+
536
+ column.appendChild(surface);
537
+ shell.appendChild(column);
538
+ this.#surfaces[col.id] = surface;
539
+ this.#columnEls[col.id] = column;
540
+ }
541
+
542
+ return shell;
543
+ }
544
+
545
+ // ── Attribute changes ────────────────────────────────
546
+
547
+ attributeChangedCallback(name, oldValue, newValue) {
548
+ if (oldValue === newValue || !this.isConnected) return;
549
+ if (name === 'src') this._loadSrc(newValue);
550
+ }
551
+
552
+ // ── JSON loading ────────────────────────────────────
553
+
554
+ async _loadSrc(url) {
555
+ if (!url) return;
556
+ 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();
601
+ };
602
+ if (this.hasAttribute('data-upgraded') && this.#columnsEl) {
603
+ viewTransitionSwap(this, swap, 'kb-vt');
604
+ } else {
605
+ swap();
606
+ }
607
+ } catch (err) {
608
+ console.warn(`[kanban-board] Failed to load src="${url}":`, err);
609
+ }
610
+ }
611
+
612
+ // ── Helpers ──────────────────────────────────────────
613
+
614
+ /**
615
+ * Reverse-lookup: find the column id that owns a given drag-surface.
616
+ * @param {HTMLElement | null} surface
617
+ * @returns {string | null}
618
+ */
619
+ #findColumnForSurface(surface) {
620
+ if (!surface) return null;
621
+ for (const [id, ref] of Object.entries(this.#surfaces)) {
622
+ if (ref === surface) return id;
623
+ }
624
+ return null;
625
+ }
626
+
627
+ /**
628
+ * Update the count badge and WIP state for a given column.
629
+ * @param {string} columnId
630
+ */
631
+ #updateCount(columnId) {
632
+ const surface = this.#surfaces[columnId];
633
+ const columnEl = this.#columnEls[columnId];
634
+ if (!surface || !columnEl) return;
635
+
636
+ const count = surface.querySelectorAll(':scope > [draggable="true"]').length;
637
+ const badge = columnEl.querySelector('.kb-count');
638
+ if (badge) badge.textContent = String(count);
639
+
640
+ // Update WIP state
641
+ const colMeta = this.#columns.find(c => c.id === columnId);
642
+ if (colMeta?.wip !== null && colMeta?.wip !== undefined) {
643
+ if (count > colMeta.wip) {
644
+ columnEl.setAttribute('data-wip-exceeded', '');
645
+ } else {
646
+ columnEl.removeAttribute('data-wip-exceeded');
647
+ }
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Get the display label for a column.
653
+ * @param {string} columnId
654
+ * @returns {string}
655
+ */
656
+ #columnLabel(columnId) {
657
+ 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());
694
+ }
695
+ }
696
+
697
+ registerComponent('kanban-board', /** @type {any} */ (KanbanBoard));
698
+ export { KanbanBoard };