@profpowell/vb-project-planning 0.2.0 → 0.2.2

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 (73) hide show
  1. package/README.md +5 -2
  2. package/dist/vb-project-planning.css +70 -3
  3. package/dist/vb-project-planning.js +3333 -2565
  4. package/dist/vb-project-planning.min.css +1 -1
  5. package/dist/vb-project-planning.min.js +224 -200
  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/utils/copy-init.js +56 -17
  15. package/src/web-components/_ux-base.js +409 -24
  16. package/src/web-components/adr-wc/README.md +18 -16
  17. package/src/web-components/adr-wc/api.json +7 -5
  18. package/src/web-components/adr-wc/logic.js +50 -136
  19. package/src/web-components/burndown-chart/api.json +8 -6
  20. package/src/web-components/burndown-chart/logic.js +75 -39
  21. package/src/web-components/empathy-map/api.json +13 -13
  22. package/src/web-components/empathy-map/logic.js +161 -168
  23. package/src/web-components/empathy-map/static.html +3 -3
  24. package/src/web-components/empathy-map/styles.js +1 -0
  25. package/src/web-components/gantt-chart/README.md +12 -6
  26. package/src/web-components/gantt-chart/api.json +23 -7
  27. package/src/web-components/gantt-chart/logic.js +456 -431
  28. package/src/web-components/gantt-chart/styles.css +9 -1
  29. package/src/web-components/impact-effort/api.json +9 -7
  30. package/src/web-components/impact-effort/logic.js +238 -209
  31. package/src/web-components/impact-effort/static.html +12 -4
  32. package/src/web-components/iron-triangle/_capacity.js +1 -19
  33. package/src/web-components/iron-triangle/_triangle-geometry.js +1 -1
  34. package/src/web-components/iron-triangle/api.json +2 -2
  35. package/src/web-components/iron-triangle/logic.js +47 -9
  36. package/src/web-components/iron-triangle/styles.css +22 -1
  37. package/src/web-components/kanban-board/api.json +8 -6
  38. package/src/web-components/kanban-board/logic.js +289 -333
  39. package/src/web-components/product-roadmap/api.json +14 -13
  40. package/src/web-components/product-roadmap/logic.js +393 -246
  41. package/src/web-components/product-roadmap/styles.css +40 -0
  42. package/src/web-components/quadrant-grid/api.json +9 -4
  43. package/src/web-components/quadrant-grid/logic.js +80 -12
  44. package/src/web-components/quadrant-grid/styles.css +7 -0
  45. package/src/web-components/quality-target/_quality-utils.js +2 -46
  46. package/src/web-components/quality-target/api.json +2 -3
  47. package/src/web-components/quality-target/logic.js +85 -13
  48. package/src/web-components/requirement-card/api.json +1 -1
  49. package/src/web-components/requirement-card/logic.js +26 -2
  50. package/src/web-components/requirement-card/static.html +1 -1
  51. package/src/web-components/review-surface/README.md +9 -5
  52. package/src/web-components/review-surface/api.json +10 -8
  53. package/src/web-components/review-surface/logic.js +349 -218
  54. package/src/web-components/risk-register/api.json +1 -6
  55. package/src/web-components/risk-register/logic.js +71 -24
  56. package/src/web-components/story-map/api.json +8 -6
  57. package/src/web-components/story-map/logic.js +280 -281
  58. package/src/web-components/story-map/static.html +15 -5
  59. package/src/web-components/traceability-matrix/api.json +10 -3
  60. package/src/web-components/traceability-matrix/logic.js +112 -63
  61. package/src/web-components/traceability-matrix/styles.css +14 -1
  62. package/src/web-components/user-journey/api.json +25 -24
  63. package/src/web-components/user-journey/logic.js +76 -127
  64. package/src/web-components/user-journey/static.html +2 -2
  65. package/src/web-components/user-persona/api.json +15 -14
  66. package/src/web-components/user-persona/logic.js +71 -117
  67. package/src/web-components/user-persona/styles.js +1 -0
  68. package/src/web-components/user-story/api.json +13 -11
  69. package/src/web-components/user-story/logic.js +164 -195
  70. package/src/web-components/user-story/styles.js +28 -1
  71. package/src/web-components/work-item/README.md +3 -3
  72. package/src/web-components/work-item/api.json +13 -11
  73. package/src/web-components/work-item/logic.js +58 -154
@@ -24,34 +24,44 @@
24
24
  * "goals": ["goal1"],
25
25
  * "painPoints": ["pain1"]
26
26
  * }
27
+ *
28
+ * Title and summary are content: `<h2 slot="title">`, `<p slot="summary">`
29
+ * (or the `title` / `summary` JSON fields, which create those children).
30
+ * Once a quadrant has been edited, it renders from the edited items; its
31
+ * slotted light-DOM content is no longer projected.
32
+ *
33
+ * @fires empathy-map:ready - Once, after the first render (after the src data renders when src is set), detail: { title, persona }
34
+ * @fires empathy-map:update - After an edit is committed (Done / Escape / closeQuadrant()), detail: { quadrant, items }
35
+ * @fires empathy-map:upgraded - Once, after first connect
36
+ * @fires empathy-map:data-changed - After .data assignment, detail: { data, source: 'property' }
27
37
  */
28
38
 
29
39
  import { styles } from './styles.js';
30
40
  import { registerComponent } from '../../lib/bundle-registry.js';
31
41
  import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
32
- import { esc, QUADRANT_META, EMOTION_META, lucideSvg, UX_ICONS } from '../_ux-base.js';
42
+ import {
43
+ esc, QUADRANT_META, emotionMeta, isEmotion, lucideSvg, UX_ICONS, UXShadowElement, fillSlot, slottedChild,
44
+ } from '../_ux-base.js';
33
45
 
34
46
  const QUADRANT_KEYS = ['says', 'thinks', 'does', 'feels'];
35
47
 
36
- class EmpathyMap extends HTMLElement {
48
+ class EmpathyMap extends UXShadowElement {
49
+ static readyEvent = 'empathy-map:ready';
50
+
37
51
  static get observedAttributes() {
38
52
  return ['persona', 'persona-id', 'src', 'editable', 'compact'];
39
53
  }
40
54
 
41
- #slotCache = new Map();
42
-
43
- constructor() {
44
- super();
45
- this.attachShadow({ mode: 'open' });
46
- /** @type {Object<string, string[]>|null} */
47
- this.__quadrants = null;
48
- /** @type {string[]|null} */
49
- this.__goals = null;
50
- /** @type {string[]|null} */
51
- this.__painPoints = null;
52
- /** @type {Set<string>} */
53
- this._editingQuadrants = new Set();
54
- }
55
+ /** @type {Object<string, string[]>|null} */
56
+ __quadrants = null;
57
+ /** @type {string[]|null} */
58
+ __goals = null;
59
+ /** @type {string[]|null} */
60
+ __painPoints = null;
61
+ /** @type {Set<string>} quadrants whose editor is open */
62
+ _editingQuadrants = new Set();
63
+ /** @type {Set<string>} quadrants edited at least once (render from data, not the slot) */
64
+ _editedQuadrants = new Set();
55
65
 
56
66
  /* ── Properties ─────────────────────────────────── */
57
67
 
@@ -61,7 +71,7 @@ class EmpathyMap extends HTMLElement {
61
71
  /** @param {Object<string, string[]>|null} data */
62
72
  set quadrants(data) {
63
73
  this.__quadrants = data;
64
- if (this.isConnected) this._render();
74
+ if (this._isSetUp) this._render();
65
75
  }
66
76
 
67
77
  /** @returns {string[]|null} */
@@ -70,7 +80,7 @@ class EmpathyMap extends HTMLElement {
70
80
  /** @param {string[]|null} data */
71
81
  set goals(data) {
72
82
  this.__goals = data;
73
- if (this.isConnected) this._render();
83
+ if (this._isSetUp) this._render();
74
84
  }
75
85
 
76
86
  /** @returns {string[]|null} */
@@ -79,45 +89,20 @@ class EmpathyMap extends HTMLElement {
79
89
  /** @param {string[]|null} data */
80
90
  set painPoints(data) {
81
91
  this.__painPoints = data;
82
- if (this.isConnected) this._render();
83
- }
84
-
85
- /* ── Slot caching ───────────────────────────────── */
86
-
87
- #cacheSlotValues() {
88
- for (const child of this.children) {
89
- const slot = child.getAttribute('slot');
90
- if (slot) this.#slotCache.set(slot, child.textContent.trim());
91
- }
92
+ if (this._isSetUp) this._render();
92
93
  }
93
94
 
95
+ /**
96
+ * Attribute value, else the text of a same-named light-DOM child (e.g.
97
+ * `<span slot="persona">`, read as data; it is not projected).
98
+ * @param {string} attr
99
+ */
94
100
  _resolve(attr) {
95
- return this.getAttribute(attr) || this.#slotCache.get(attr) || '';
96
- }
97
-
98
- /* ── Lifecycle ─────────────────────────────────── */
99
-
100
- connectedCallback() {
101
- this.#cacheSlotValues();
102
- this.setAttribute('data-upgraded', '');
103
- if (this.hasAttribute('src')) {
104
- this._loadSrc(this.getAttribute('src'));
105
- } else {
106
- this._render();
107
- }
108
- }
109
-
110
- disconnectedCallback() {
111
- this.removeAttribute('data-upgraded');
101
+ return this.getAttribute(attr) || this._slotCache.get(attr) || '';
112
102
  }
113
103
 
114
- attributeChangedCallback(name) {
115
- if (!this.isConnected) return;
116
- if (name === 'src') {
117
- this._loadSrc(this.getAttribute('src'));
118
- } else {
119
- this._render();
120
- }
104
+ _readyDetail() {
105
+ return { title: this._slotText('title') || 'Empathy Map', persona: this._resolve('persona') };
121
106
  }
122
107
 
123
108
  // ── Data API (HTML-first / JS-first dual contract) ──────────────
@@ -127,8 +112,8 @@ class EmpathyMap extends HTMLElement {
127
112
  * slotted content. Mirrors what a consumer would assign to .data.
128
113
  */
129
114
  get data() {
130
- const titleEl = this.querySelector('[slot="title"]');
131
- const summaryEl = this.querySelector('[slot="summary"]');
115
+ const titleEl = slottedChild(this, 'title');
116
+ const summaryEl = slottedChild(this, 'summary');
132
117
  return {
133
118
  persona: this.getAttribute('persona') || undefined,
134
119
  personaId: this.getAttribute('persona-id') || undefined,
@@ -146,29 +131,22 @@ class EmpathyMap extends HTMLElement {
146
131
  * Emits empathy-map:data-changed { data, source: 'property' }.
147
132
  */
148
133
  set data(value) {
149
- if (!value || typeof value !== 'object') return;
150
- if (value.persona) this.setAttribute('persona', String(value.persona));
151
- if (value.personaId) this.setAttribute('persona-id', String(value.personaId));
152
- if (value.title && !this.querySelector('[slot="title"]')) {
153
- const el = document.createElement('h2');
154
- el.slot = 'title';
155
- el.textContent = value.title;
156
- this.appendChild(el);
157
- }
158
- if (value.summary && !this.querySelector('[slot="summary"]')) {
159
- const el = document.createElement('p');
160
- el.slot = 'summary';
161
- el.textContent = value.summary;
162
- this.appendChild(el);
163
- }
164
- if (value.quadrants != null) this.__quadrants = value.quadrants;
165
- if (value.goals != null) this.__goals = value.goals;
166
- if (value.painPoints != null) this.__painPoints = value.painPoints;
167
- if (this.isConnected) this._render();
168
- this.dispatchEvent(new CustomEvent('empathy-map:data-changed', {
169
- detail: { data: this.data, source: 'property' },
170
- bubbles: true, composed: true,
171
- }));
134
+ this._setData(value);
135
+ }
136
+
137
+ /**
138
+ * Apply an empathy-map record (from .data or src). Title / summary create
139
+ * slotted children only when none are authored.
140
+ * @param {Record<string, any>} data
141
+ */
142
+ _applyData(data) {
143
+ if (data.persona) this.setAttribute('persona', String(data.persona));
144
+ if (data.personaId) this.setAttribute('persona-id', String(data.personaId));
145
+ fillSlot(this, 'title', data.title, { tag: 'h2' });
146
+ fillSlot(this, 'summary', data.summary);
147
+ if (data.quadrants != null) this.__quadrants = data.quadrants;
148
+ if (data.goals != null) this.__goals = data.goals;
149
+ if (data.painPoints != null) this.__painPoints = data.painPoints;
172
150
  }
173
151
 
174
152
  /* ── Public API ────────────────────────────────── */
@@ -184,7 +162,8 @@ class EmpathyMap extends HTMLElement {
184
162
  }
185
163
 
186
164
  /**
187
- * Close a quadrant edit and save.
165
+ * Close a quadrant edit and save. No-op (and no update event) unless the
166
+ * quadrant's editor is open.
188
167
  * @param {string} name - Quadrant key: says|thinks|does|feels
189
168
  */
190
169
  closeQuadrant(name) {
@@ -192,41 +171,17 @@ class EmpathyMap extends HTMLElement {
192
171
  this._closeEdit(name);
193
172
  }
194
173
 
195
- /* ── JSON loading ──────────────────────────────── */
174
+ /* ── JSON loading states ───────────────────────── */
196
175
 
197
- async _loadSrc(src) {
198
- if (!src || !this.shadowRoot) return;
199
- this.shadowRoot.innerHTML = `<style>${styles}</style>` +
176
+ _renderLoading() {
177
+ /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = `<style>${styles}</style>` +
200
178
  `<div class="state-msg">Loading\u2026</div>`;
201
- try {
202
- const res = await fetch(src);
203
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
204
- const data = await res.json();
205
- if (data.persona) this.setAttribute('persona', data.persona);
206
- if (data.personaId) this.setAttribute('persona-id', data.personaId);
207
- // Content → slotted elements
208
- if (data.title && !this.querySelector('[slot="title"]')) {
209
- const el = document.createElement('h2');
210
- el.slot = 'title';
211
- el.textContent = data.title;
212
- this.appendChild(el);
213
- }
214
- if (data.summary && !this.querySelector('[slot="summary"]')) {
215
- const el = document.createElement('p');
216
- el.slot = 'summary';
217
- el.textContent = data.summary;
218
- this.appendChild(el);
219
- }
220
- this.__quadrants = data.quadrants || null;
221
- this.__goals = data.goals || null;
222
- this.__painPoints = data.painPoints || null;
223
- this._render();
224
- } catch (err) {
225
- if (!this.shadowRoot) return;
226
- const msg = err instanceof Error ? err.message : String(err);
227
- this.shadowRoot.innerHTML = `<style>${styles}</style>` +
228
- `<div class="state-msg state-msg--error">Could not load empathy map: ${esc(msg)}</div>`;
229
- }
179
+ }
180
+
181
+ /** @param {Error} err */
182
+ _renderError(err) {
183
+ /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = `<style>${styles}</style>` +
184
+ `<div class="state-msg state-msg--error">Could not load empathy map: ${esc(err.message)}</div>`;
230
185
  }
231
186
 
232
187
  /* ── Render ────────────────────────────────────── */
@@ -236,11 +191,10 @@ class EmpathyMap extends HTMLElement {
236
191
  const personaId = this._resolve('persona-id') || '';
237
192
  const compact = this.hasAttribute('compact');
238
193
  const editable = this.hasAttribute('editable');
239
- const hasTitle = !!this.querySelector('[slot="title"]') || this.#slotCache.has('title');
240
- const hasSummary = !!this.querySelector('[slot="summary"]') || this.#slotCache.has('summary');
194
+ const hasSummary = !!slottedChild(this, 'summary') || this._slotCache.has('summary');
241
195
 
242
- const hasGoals = this.__goals?.length || this.querySelector('[slot="goals"]');
243
- const hasPains = this.__painPoints?.length || this.querySelector('[slot="pain-points"]');
196
+ const hasGoals = this.__goals?.length || slottedChild(this, 'goals');
197
+ const hasPains = this.__painPoints?.length || slottedChild(this, 'pain-points');
244
198
 
245
199
  const html = `<style>${styles}</style>
246
200
  <article class="empathy-map${compact ? ' empathy-map--compact' : ''}">
@@ -281,21 +235,19 @@ class EmpathyMap extends HTMLElement {
281
235
  swaps. Skip on the first render — nothing to fade from. */
282
236
  const swap = () => {
283
237
  if (!this.shadowRoot) return;
238
+ // An open editor's unsaved text (and focus) survives the re-render.
239
+ const drafts = this.#captureDrafts();
284
240
  this.shadowRoot.innerHTML = html;
285
- if (editable) this._bindEditListeners();
241
+ if (editable) {
242
+ this._bindEditListeners();
243
+ this.#restoreDrafts(drafts);
244
+ }
286
245
  };
287
246
  if (this.shadowRoot?.querySelector('article')) {
288
247
  viewTransitionSwap(this, swap, 'em-vt');
289
248
  } else {
290
249
  swap();
291
250
  }
292
-
293
- /* Dispatch ready event */
294
- this.dispatchEvent(new CustomEvent('empathy-map:ready', {
295
- bubbles: true,
296
- composed: true,
297
- detail: { title: this.querySelector('[slot="title"]')?.textContent?.trim() || 'Empathy Map', persona },
298
- }));
299
251
  }
300
252
 
301
253
  /* ── Render quadrant ───────────────────────────── */
@@ -305,20 +257,18 @@ class EmpathyMap extends HTMLElement {
305
257
  const items = this.__quadrants?.[key];
306
258
  const isEditing = this._editingQuadrants.has(key);
307
259
 
308
- const frontContent = items && items.length
309
- ? (key === 'feels'
310
- ? items.map(item => this._renderEmotion(item)).join('')
311
- : items.map(item => `<p>${esc(item)}</p>`).join(''))
260
+ const frontContent = (items && items.length) || this._editedQuadrants.has(key)
261
+ ? this._quadrantItemsHtml(key)
312
262
  : `<slot name="${key}"><p class="placeholder">Add ${meta.label.toLowerCase()} items\u2026</p></slot>`;
313
263
 
314
- const textareaValue = items?.length ? items.join('\n') : '';
264
+ const textareaValue = this.#editorSeed(key);
315
265
 
316
266
  return `
317
267
  <section class="quadrant quadrant--${key}"${isEditing ? ' data-editing' : ''}>
318
268
  <div class="quadrant__inner">
319
269
  <div class="quadrant__header">
320
270
  <div class="quadrant__icon" aria-hidden="true">${lucideSvg(meta.icon)}</div>
321
- <span class="quadrant__label">${meta.label}</span>
271
+ <h3 class="quadrant__label">${meta.label}</h3>
322
272
  ${editable ? `<button class="quadrant__edit-btn" data-quadrant="${key}"
323
273
  aria-label="Edit ${meta.label}" title="Edit ${meta.label}">${lucideSvg(UX_ICONS.pencil)}</button>` : ''}
324
274
  </div>
@@ -342,12 +292,25 @@ class EmpathyMap extends HTMLElement {
342
292
  </section>`;
343
293
  }
344
294
 
295
+ /**
296
+ * Front-face markup for a quadrant rendered from data (no slot).
297
+ * @param {string} key
298
+ */
299
+ _quadrantItemsHtml(key) {
300
+ const items = this.__quadrants?.[key] || [];
301
+ if (!items.length) {
302
+ return `<p class="placeholder">Add ${QUADRANT_META[key].label.toLowerCase()} items\u2026</p>`;
303
+ }
304
+ return key === 'feels'
305
+ ? items.map(item => this._renderEmotion(item)).join('')
306
+ : items.map(item => `<p>${esc(item)}</p>`).join('');
307
+ }
308
+
345
309
  /* ── Render emotion tag ────────────────────────── */
346
310
 
347
311
  _renderEmotion(item) {
348
- const normalized = item.toLowerCase().trim();
349
- const meta = EMOTION_META[normalized];
350
- if (meta) {
312
+ if (isEmotion(item)) {
313
+ const meta = emotionMeta(item);
351
314
  return `<span class="emotion-tag" style="--ec:${meta.color}"><span role="img" aria-label="${esc(item)}">${meta.emoji}</span> ${esc(item)}</span>`;
352
315
  }
353
316
  return `<p>${esc(item)}</p>`;
@@ -407,10 +370,9 @@ class EmpathyMap extends HTMLElement {
407
370
  }
408
371
 
409
372
  _openEdit(key) {
410
- this._editingQuadrants.add(key);
411
-
412
373
  const section = this.shadowRoot?.querySelector(`.quadrant--${key}`);
413
374
  if (!section) return;
375
+ this._editingQuadrants.add(key);
414
376
 
415
377
  section.setAttribute('data-editing', '');
416
378
 
@@ -421,34 +383,66 @@ class EmpathyMap extends HTMLElement {
421
383
 
422
384
  const textarea = /** @type {HTMLTextAreaElement | null} */ (section.querySelector('.quadrant__editor'));
423
385
  if (textarea) {
424
- /* Pre-fill: prefer JSON data; fall back to slotted text */
425
- const items = this.__quadrants?.[key];
426
- if (items?.length) {
427
- textarea.value = items.join('\n');
428
- } else {
429
- const slot = /** @type {HTMLSlotElement | null} */ (section.querySelector(`slot[name="${key}"]`));
430
- if (slot) {
431
- const assigned = slot.assignedElements();
432
- if (assigned.length) {
433
- /** @type {string[]} */
434
- const lines = [];
435
- assigned.forEach(el => {
436
- const lis = el.querySelectorAll('li');
437
- if (lis.length) {
438
- lis.forEach(li => lines.push(li.textContent?.trim() ?? ''));
439
- } else {
440
- lines.push(el.textContent?.trim() ?? '');
441
- }
442
- });
443
- textarea.value = lines.filter(Boolean).join('\n');
444
- }
445
- }
446
- }
386
+ textarea.value = this.#editorSeed(key);
447
387
  textarea.focus();
448
388
  }
449
389
  }
450
390
 
391
+ /**
392
+ * Text an editor opens with: the saved items, else (for a quadrant never
393
+ * edited) its slotted light-DOM items, one per line.
394
+ * @param {string} key
395
+ * @returns {string}
396
+ */
397
+ #editorSeed(key) {
398
+ const items = this.__quadrants?.[key];
399
+ if (items?.length) return items.join('\n');
400
+ if (this._editedQuadrants.has(key)) return '';
401
+ /** @type {string[]} */
402
+ const lines = [];
403
+ for (const el of this.children) {
404
+ if (el.getAttribute('slot') !== key) continue;
405
+ const lis = el.querySelectorAll('li');
406
+ if (lis.length) lis.forEach(li => lines.push(li.textContent?.trim() ?? ''));
407
+ else lines.push(el.textContent?.trim() ?? '');
408
+ }
409
+ return lines.filter(Boolean).join('\n');
410
+ }
411
+
412
+ /**
413
+ * Snapshot open editors (value, selection, focus) before the shadow
414
+ * tree is replaced.
415
+ * @returns {Map<string, { value: string, start: number, end: number, focused: boolean }>}
416
+ */
417
+ #captureDrafts() {
418
+ const drafts = new Map();
419
+ const root = this.shadowRoot;
420
+ if (!root) return drafts;
421
+ for (const key of this._editingQuadrants) {
422
+ const ta = /** @type {HTMLTextAreaElement | null} */ (root.querySelector(`.quadrant__editor[data-quadrant="${key}"]`));
423
+ if (!ta) continue;
424
+ drafts.set(key, {
425
+ value: ta.value, start: ta.selectionStart, end: ta.selectionEnd, focused: root.activeElement === ta,
426
+ });
427
+ }
428
+ return drafts;
429
+ }
430
+
431
+ /** @param {Map<string, { value: string, start: number, end: number, focused: boolean }>} drafts */
432
+ #restoreDrafts(drafts) {
433
+ const root = this.shadowRoot;
434
+ if (!root) return;
435
+ for (const [key, d] of drafts) {
436
+ const ta = /** @type {HTMLTextAreaElement | null} */ (root.querySelector(`.quadrant__editor[data-quadrant="${key}"]`));
437
+ if (!ta) continue;
438
+ ta.value = d.value;
439
+ ta.setSelectionRange(d.start, d.end);
440
+ if (d.focused) ta.focus();
441
+ }
442
+ }
443
+
451
444
  _closeEdit(key) {
445
+ if (!this._editingQuadrants.has(key)) return;
452
446
  const section = this.shadowRoot?.querySelector(`.quadrant--${key}`);
453
447
  if (!section) return;
454
448
 
@@ -462,19 +456,12 @@ class EmpathyMap extends HTMLElement {
462
456
  /* Ensure __quadrants object exists */
463
457
  if (!this.__quadrants) this.__quadrants = {};
464
458
  this.__quadrants[key] = items;
459
+ /* From now on this quadrant renders from data, even when emptied,
460
+ so later full renders don't resurrect the original slotted items. */
461
+ this._editedQuadrants.add(key);
465
462
 
466
- /* Partially re-render front face content */
467
463
  const contentEl = section.querySelector('.quadrant__content');
468
- if (contentEl) {
469
- if (items.length) {
470
- contentEl.innerHTML = key === 'feels'
471
- ? items.map(item => this._renderEmotion(item)).join('')
472
- : items.map(item => `<p>${esc(item)}</p>`).join('');
473
- } else {
474
- const meta = QUADRANT_META[key];
475
- contentEl.innerHTML = `<p class="placeholder">Add ${meta.label.toLowerCase()} items\u2026</p>`;
476
- }
477
- }
464
+ if (contentEl) contentEl.innerHTML = this._quadrantItemsHtml(key);
478
465
  }
479
466
 
480
467
  this._editingQuadrants.delete(key);
@@ -482,6 +469,12 @@ class EmpathyMap extends HTMLElement {
482
469
 
483
470
  const front = section.querySelector('.quadrant__face--front');
484
471
  const back = section.querySelector('.quadrant__face--back');
472
+ /* Move focus out of the face that is about to become inert, back to
473
+ the quadrant's edit button, so it doesn't fall to <body>. */
474
+ const active = this.shadowRoot?.activeElement;
475
+ if (back && active && back.contains(active)) {
476
+ /** @type {HTMLElement | null} */ (section.querySelector('.quadrant__edit-btn'))?.focus();
477
+ }
485
478
  if (front) front.removeAttribute('inert');
486
479
  if (back) back.setAttribute('inert', '');
487
480
 
@@ -3,10 +3,10 @@
3
3
  Slotted content renders as normal children before upgrade.
4
4
  -->
5
5
  <empathy-map
6
- title="Developer Onboarding"
7
6
  persona="Sarah Chen"
8
- persona-id="persona-sarah-chen"
9
- summary="Based on user interview round 2">
7
+ persona-id="persona-sarah-chen">
8
+ <h2 slot="title">Developer Onboarding</h2>
9
+ <p slot="summary">Based on user interview round 2</p>
10
10
  <ul slot="says">
11
11
  <li>"I just want it to work"</li>
12
12
  <li>"The documentation is confusing"</li>
@@ -234,6 +234,7 @@ const styles = `
234
234
  .quadrant--feels .quadrant__icon { background: var(--_feels-bg); color: var(--_feels); }
235
235
 
236
236
  .quadrant__label {
237
+ margin: 0;
237
238
  font-size: 13px;
238
239
  font-weight: 700;
239
240
  text-transform: uppercase;
@@ -58,26 +58,32 @@ Part of the **UX Planning Pack**.
58
58
  | `data-assignee` | string | Assignee name |
59
59
  | `data-milestone` | boolean | Render as diamond marker |
60
60
  | `data-color` | string | Custom bar color |
61
+ | `data-story-ids` | string | Comma-separated linked user-story IDs |
62
+ | `data-item-ids` | string | Comma-separated linked work-item IDs |
63
+
64
+ Dates are `YYYY-MM-DD` calendar days in local time. Progress in `.tasks` / JSON: > 1 is a percentage, 0–1 a fraction; `<progress>` is `value / max`.
61
65
 
62
66
  ## Events
63
67
 
64
68
  | Event | Detail | Description |
65
69
  |-------|--------|-------------|
66
- | `gantt-chart:ready` | `{ taskCount, dateRange }` | After render |
67
- | `gantt-chart:task-click` | `{ task }` | Bar clicked |
70
+ | `gantt-chart:upgraded` | — | Once per connect (also for an empty chart) |
71
+ | `gantt-chart:ready` | `{ taskCount, dateRange: { start, end } }` | After a render with tasks on connect, and after a `src` load (YYYY-MM-DD) |
72
+ | `gantt-chart:task-click` | `{ task }` | Bar/milestone clicked or activated with Enter/Space |
73
+ | `gantt-chart:tasks-changed` | `{ tasks, source: 'property' \| 'src' }` | After `.tasks` assignment or a `src` load |
68
74
 
69
75
  ## Failure Modes
70
76
 
71
77
  - **No JavaScript:** Table renders normally with progress bars and time elements.
72
78
  - **No CSS:** Table renders as default HTML table.
73
- - **Keyboard Only:** Tab navigates bars; Enter triggers click event.
79
+ - **Keyboard Only:** Tab navigates bars; Enter or Space fires `task-click`.
74
80
  - **Screen Reader:** Original table is preserved (visually hidden) for full table semantics.
75
81
 
76
82
  ## Accessibility
77
83
 
78
- - Original table stays in DOM (sr-only) for screen reader access
79
- - Bars have `role="img"` with descriptive `aria-label`
80
- - Bars are focusable with `tabindex="0"`
84
+ - Original table stays in DOM (sr-only) for screen reader access; for `.tasks`/`src` data a table is generated from the tasks
85
+ - Bars and milestones are focusable `role="button"` with an `aria-label` giving name, dates, status, progress and assignee
86
+ - Status is shown as text in the task list, not by colour alone
81
87
  - `aria-live="polite"` region announces task selection
82
88
  - `prefers-reduced-motion: reduce` disables transitions
83
89
  - Print shows the original table, hides the visual chart
@@ -5,16 +5,32 @@
5
5
  "description": "HTML-first Gantt chart using native table, progress, and time elements with progressive enhancement to an interactive timeline",
6
6
  "htmlvalidate": { "flow": true, "permittedContent": ["@flow"] },
7
7
  "attributes": [
8
- { "name": "title", "kind": "host-api", "purpose": "config", "type": "string", "description": "Chart heading" },
9
- { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON task data" },
10
- { "name": "view", "kind": "host-api", "purpose": "config", "type": "string", "description": "Timeline granularity: auto | day | week | month | quarter" },
8
+ { "name": "title", "kind": "host-api", "purpose": "config", "type": "string", "description": "Chart heading (a src JSON \"title\" sets it)" },
9
+ { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL of JSON task data ({ title?, tasks: [...] }). Fetched once on connect or when the attribute changes; the fetched tasks replace the child table. A newer value aborts an older in-flight request. Skipped on connect when .tasks was already assigned, and an in-flight request is aborted when .tasks is assigned (.tasks takes precedence)" },
10
+ { "name": "view", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["auto", "day", "week", "month", "quarter"], "default": "\"auto\"", "description": "Timeline granularity. auto picks day (≤ 3 weeks), week (≤ 90 days), month (≤ 1 year) or quarter" },
11
11
  { "name": "show-today", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Show vertical today marker line" },
12
12
  { "name": "show-progress", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Show progress fill inside bars" },
13
- { "name": "show-dependencies", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Draw dependency arrows between tasks" },
14
- { "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Reduced spacing variant" }
13
+ { "name": "show-dependencies", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Draw dependency arrows between tasks (row positions are measured, so compact and grouped charts line up)" },
14
+ { "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Reduced row height and spacing" }
15
+ ],
16
+ "structure": [
17
+ { "element": "<table>", "required": false, "description": "Task source: one <tbody> <tr> per task; first cell is the name, two <time datetime=\"YYYY-MM-DD\"> give start and end, an optional <progress> gives progress. Kept in the DOM, visually hidden, for screen readers and print" }
18
+ ],
19
+ "childAttributes": [
20
+ { "name": "data-task-id", "on": "tr", "type": "string", "description": "Unique task identifier (used by data-depends)" },
21
+ { "name": "data-group", "on": "tr", "type": "string", "description": "Swim lane group; with two or more groups each gets a header row" },
22
+ { "name": "data-depends", "on": "tr", "type": "string", "description": "Comma-separated task IDs this task depends on (drawn with show-dependencies)" },
23
+ { "name": "data-status", "on": "tr", "type": "enum", "values": ["not-started", "in-progress", "done", "blocked"], "description": "Task status: shown as text in the task list and in the bar's accessible name; also tints the bar" },
24
+ { "name": "data-assignee", "on": "tr", "type": "string", "description": "Assignee name (included in the bar's accessible name)" },
25
+ { "name": "data-milestone", "on": "tr", "type": "boolean", "description": "Render a diamond marker at the start date instead of a duration bar" },
26
+ { "name": "data-color", "on": "tr", "type": "string", "description": "Custom bar colour (any CSS colour)" },
27
+ { "name": "data-story-ids", "on": "tr", "type": "string", "description": "Comma-separated linked user-story IDs (exposed as task.storyIds)" },
28
+ { "name": "data-item-ids", "on": "tr", "type": "string", "description": "Comma-separated linked work-item IDs (exposed as task.itemIds)" }
15
29
  ],
16
30
  "events": [
17
- { "name": "gantt-chart:ready", "detail": "{ taskCount, dateRange }", "description": "Fired after initial render" },
18
- { "name": "gantt-chart:task-click", "detail": "{ task }", "description": "Fired when a task bar is clicked" }
31
+ { "name": "gantt-chart:upgraded", "detail": "", "description": "Once per connect, in a microtask after setup; a safe signal for the first .tasks assignment (fires for an empty chart too)" },
32
+ { "name": "gantt-chart:ready", "detail": "{ taskCount, dateRange: { start, end } }", "description": "Once per element: after the first connect render that has tasks (table or .tasks), or else after the first src load. Not re-fired on reconnect or by later src loads (those fire tasks-changed with source 'src'). dateRange values are YYYY-MM-DD" },
33
+ { "name": "gantt-chart:task-click", "detail": "{ task }", "description": "When a bar or milestone is clicked or activated with Enter/Space" },
34
+ { "name": "gantt-chart:tasks-changed", "detail": "{ tasks, source: 'property' | 'src' }", "description": "After .tasks is assigned (source 'property') or src data loads (source 'src'), with the normalized tasks" }
19
35
  ]
20
36
  }