@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,502 @@
1
+ /**
2
+ * <empathy-map> — Four-quadrant empathy map web component
3
+ *
4
+ * Visualises Says / Thinks / Does / Feels quadrants with optional
5
+ * flip-to-edit interaction and JSON loading.
6
+ *
7
+ * Data sources:
8
+ * 1. `src` attribute — URL to a JSON file
9
+ * 2. `.quadrants` property — set programmatically
10
+ * 3. Slotted content — `<ul slot="says">`, etc.
11
+ *
12
+ * JSON schema:
13
+ * {
14
+ * "title": "...",
15
+ * "persona": "...",
16
+ * "personaId": "...",
17
+ * "summary": "...",
18
+ * "quadrants": {
19
+ * "says": ["item1", "item2"],
20
+ * "thinks": ["item1", "item2"],
21
+ * "does": ["item1", "item2"],
22
+ * "feels": ["frustrated", "curious"]
23
+ * },
24
+ * "goals": ["goal1"],
25
+ * "painPoints": ["pain1"]
26
+ * }
27
+ */
28
+
29
+ import { styles } from './styles.js';
30
+ import { registerComponent } from '../../lib/bundle-registry.js';
31
+ import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
32
+ import { esc, QUADRANT_META, EMOTION_META, lucideSvg, UX_ICONS } from '../_ux-base.js';
33
+
34
+ const QUADRANT_KEYS = ['says', 'thinks', 'does', 'feels'];
35
+
36
+ class EmpathyMap extends HTMLElement {
37
+ static get observedAttributes() {
38
+ return ['persona', 'persona-id', 'src', 'editable', 'compact'];
39
+ }
40
+
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
+
56
+ /* ── Properties ─────────────────────────────────── */
57
+
58
+ /** @returns {Object<string, string[]>|null} */
59
+ get quadrants() { return this.__quadrants; }
60
+
61
+ /** @param {Object<string, string[]>|null} data */
62
+ set quadrants(data) {
63
+ this.__quadrants = data;
64
+ if (this.isConnected) this._render();
65
+ }
66
+
67
+ /** @returns {string[]|null} */
68
+ get goals() { return this.__goals; }
69
+
70
+ /** @param {string[]|null} data */
71
+ set goals(data) {
72
+ this.__goals = data;
73
+ if (this.isConnected) this._render();
74
+ }
75
+
76
+ /** @returns {string[]|null} */
77
+ get painPoints() { return this.__painPoints; }
78
+
79
+ /** @param {string[]|null} data */
80
+ set painPoints(data) {
81
+ 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
+ }
93
+
94
+ _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');
112
+ }
113
+
114
+ attributeChangedCallback(name) {
115
+ if (!this.isConnected) return;
116
+ if (name === 'src') {
117
+ this._loadSrc(this.getAttribute('src'));
118
+ } else {
119
+ this._render();
120
+ }
121
+ }
122
+
123
+ // ── Data API (HTML-first / JS-first dual contract) ──────────────
124
+
125
+ /**
126
+ * Read the empathy map as a plain data object combining state and
127
+ * slotted content. Mirrors what a consumer would assign to .data.
128
+ */
129
+ get data() {
130
+ const titleEl = this.querySelector('[slot="title"]');
131
+ const summaryEl = this.querySelector('[slot="summary"]');
132
+ return {
133
+ persona: this.getAttribute('persona') || undefined,
134
+ personaId: this.getAttribute('persona-id') || undefined,
135
+ title: titleEl?.textContent?.trim() || undefined,
136
+ summary: summaryEl?.textContent?.trim() || undefined,
137
+ quadrants: this.__quadrants || undefined,
138
+ goals: this.__goals || undefined,
139
+ painPoints: this.__painPoints || undefined,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Set state attributes, slotted content, and quadrant/goals/pain-point
145
+ * arrays from a plain object in one call. Idempotent for repeat calls.
146
+ * Emits empathy-map:data-changed { data, source: 'property' }.
147
+ */
148
+ 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
+ }));
172
+ }
173
+
174
+ /* ── Public API ────────────────────────────────── */
175
+
176
+ /**
177
+ * Open a quadrant for editing.
178
+ * @param {string} name - Quadrant key: says|thinks|does|feels
179
+ */
180
+ editQuadrant(name) {
181
+ if (!QUADRANT_KEYS.includes(name)) return;
182
+ if (!this.hasAttribute('editable')) return;
183
+ this._openEdit(name);
184
+ }
185
+
186
+ /**
187
+ * Close a quadrant edit and save.
188
+ * @param {string} name - Quadrant key: says|thinks|does|feels
189
+ */
190
+ closeQuadrant(name) {
191
+ if (!QUADRANT_KEYS.includes(name)) return;
192
+ this._closeEdit(name);
193
+ }
194
+
195
+ /* ── JSON loading ──────────────────────────────── */
196
+
197
+ async _loadSrc(src) {
198
+ if (!src || !this.shadowRoot) return;
199
+ this.shadowRoot.innerHTML = `<style>${styles}</style>` +
200
+ `<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
+ }
230
+ }
231
+
232
+ /* ── Render ────────────────────────────────────── */
233
+
234
+ _render() {
235
+ const persona = this._resolve('persona') || '';
236
+ const personaId = this._resolve('persona-id') || '';
237
+ const compact = this.hasAttribute('compact');
238
+ 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');
241
+
242
+ const hasGoals = this.__goals?.length || this.querySelector('[slot="goals"]');
243
+ const hasPains = this.__painPoints?.length || this.querySelector('[slot="pain-points"]');
244
+
245
+ const html = `<style>${styles}</style>
246
+ <article class="empathy-map${compact ? ' empathy-map--compact' : ''}">
247
+
248
+ <header class="empathy-map__header">
249
+ <div class="empathy-map__header-top">
250
+ <div class="empathy-map__chips">
251
+ <span class="chip chip--type">Empathy Map</span>
252
+ </div>
253
+ ${persona ? `
254
+ <div class="empathy-map__persona">
255
+ ${personaId
256
+ ? `<a class="persona-ref" href="#${esc(personaId)}">${lucideSvg(UX_ICONS.user)} ${esc(persona)}</a>`
257
+ : `<span class="persona-ref">${lucideSvg(UX_ICONS.user)} ${esc(persona)}</span>`}
258
+ </div>` : ''}
259
+ </div>
260
+ <div class="empathy-map__title-wrap">
261
+ <slot name="title"><h2 class="empathy-map__title">Empathy Map</h2></slot>
262
+ </div>
263
+ ${hasSummary ? `<div class="empathy-map__summary-wrap"><slot name="summary"></slot></div>` : ''}
264
+ </header>
265
+
266
+ <div class="empathy-map__grid">
267
+ ${QUADRANT_KEYS.map(key => this._renderQuadrant(key, editable)).join('')}
268
+ </div>
269
+
270
+ ${hasGoals || hasPains ? `
271
+ <footer class="empathy-map__footer">
272
+ ${this._renderSummaryRow('goals', lucideSvg(UX_ICONS.target), 'Goals')}
273
+ ${this._renderSummaryRow('pain-points', lucideSvg(UX_ICONS.alertTriangle), 'Pain Points')}
274
+ </footer>
275
+ ` : ''}
276
+
277
+ </article>`;
278
+
279
+ /* Pin view-transition-name on the host so the browser crossfades the
280
+ light-DOM snapshot (which includes shadow paint) on data/theme
281
+ swaps. Skip on the first render — nothing to fade from. */
282
+ const swap = () => {
283
+ if (!this.shadowRoot) return;
284
+ this.shadowRoot.innerHTML = html;
285
+ if (editable) this._bindEditListeners();
286
+ };
287
+ if (this.shadowRoot?.querySelector('article')) {
288
+ viewTransitionSwap(this, swap, 'em-vt');
289
+ } else {
290
+ swap();
291
+ }
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
+ }
300
+
301
+ /* ── Render quadrant ───────────────────────────── */
302
+
303
+ _renderQuadrant(key, editable) {
304
+ const meta = QUADRANT_META[key];
305
+ const items = this.__quadrants?.[key];
306
+ const isEditing = this._editingQuadrants.has(key);
307
+
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(''))
312
+ : `<slot name="${key}"><p class="placeholder">Add ${meta.label.toLowerCase()} items\u2026</p></slot>`;
313
+
314
+ const textareaValue = items?.length ? items.join('\n') : '';
315
+
316
+ return `
317
+ <section class="quadrant quadrant--${key}"${isEditing ? ' data-editing' : ''}>
318
+ <div class="quadrant__inner">
319
+ <div class="quadrant__header">
320
+ <div class="quadrant__icon" aria-hidden="true">${lucideSvg(meta.icon)}</div>
321
+ <span class="quadrant__label">${meta.label}</span>
322
+ ${editable ? `<button class="quadrant__edit-btn" data-quadrant="${key}"
323
+ aria-label="Edit ${meta.label}" title="Edit ${meta.label}">${lucideSvg(UX_ICONS.pencil)}</button>` : ''}
324
+ </div>
325
+ <div class="quadrant__faces">
326
+ <div class="quadrant__face quadrant__face--front"${isEditing ? ' inert' : ''}>
327
+ <div class="quadrant__content">
328
+ ${frontContent}
329
+ </div>
330
+ </div>
331
+ ${editable ? `
332
+ <div class="quadrant__face quadrant__face--back"${isEditing ? '' : ' inert'}>
333
+ <textarea class="quadrant__editor" data-quadrant="${key}"
334
+ placeholder="One item per line\u2026"
335
+ aria-label="Edit ${meta.label} items">${esc(textareaValue)}</textarea>
336
+ <button class="quadrant__done-btn" data-quadrant="${key}"
337
+ aria-label="Done editing ${meta.label}">${lucideSvg(UX_ICONS.check)} Done</button>
338
+ </div>
339
+ ` : ''}
340
+ </div>
341
+ </div>
342
+ </section>`;
343
+ }
344
+
345
+ /* ── Render emotion tag ────────────────────────── */
346
+
347
+ _renderEmotion(item) {
348
+ const normalized = item.toLowerCase().trim();
349
+ const meta = EMOTION_META[normalized];
350
+ if (meta) {
351
+ return `<span class="emotion-tag" style="--ec:${meta.color}"><span role="img" aria-label="${esc(item)}">${meta.emoji}</span> ${esc(item)}</span>`;
352
+ }
353
+ return `<p>${esc(item)}</p>`;
354
+ }
355
+
356
+ /* ── Render summary row ────────────────────────── */
357
+
358
+ _renderSummaryRow(slotName, icon, label) {
359
+ const dataKey = slotName === 'pain-points' ? 'painPoints' : slotName;
360
+ const items = dataKey === 'painPoints' ? this.__painPoints : this.__goals;
361
+
362
+ const content = items?.length
363
+ ? items.map(item => `<p>${esc(item)}</p>`).join('')
364
+ : `<slot name="${slotName}"><p class="placeholder">No ${label.toLowerCase()} specified.</p></slot>`;
365
+
366
+ return `
367
+ <div class="summary-row">
368
+ <span class="summary-row__icon" aria-hidden="true">${icon}</span>
369
+ <div class="summary-row__body">
370
+ <div class="summary-row__label">${label}</div>
371
+ <div class="summary-row__content">${content}</div>
372
+ </div>
373
+ </div>`;
374
+ }
375
+
376
+ /* ── Edit interaction ──────────────────────────── */
377
+
378
+ _bindEditListeners() {
379
+ const root = this.shadowRoot;
380
+ if (!root) return;
381
+
382
+ root.querySelectorAll('.quadrant__edit-btn').forEach((/** @type {Element} */ btn) => {
383
+ btn.addEventListener('click', () => {
384
+ const q = /** @type {HTMLElement} */ (btn).dataset.quadrant;
385
+ if (q) this._openEdit(q);
386
+ });
387
+ });
388
+
389
+ root.querySelectorAll('.quadrant__done-btn').forEach((/** @type {Element} */ btn) => {
390
+ btn.addEventListener('click', () => {
391
+ const q = /** @type {HTMLElement} */ (btn).dataset.quadrant;
392
+ if (q) this._closeEdit(q);
393
+ });
394
+ });
395
+
396
+ root.querySelectorAll('.quadrant__editor').forEach((/** @type {Element} */ rawTextarea) => {
397
+ const textarea = /** @type {HTMLTextAreaElement} */ (rawTextarea);
398
+ textarea.addEventListener('keydown', (/** @type {Event} */ e) => {
399
+ const ke = /** @type {KeyboardEvent} */ (e);
400
+ if (ke.key === 'Escape') {
401
+ ke.preventDefault();
402
+ const q = textarea.dataset.quadrant;
403
+ if (q) this._closeEdit(q);
404
+ }
405
+ });
406
+ });
407
+ }
408
+
409
+ _openEdit(key) {
410
+ this._editingQuadrants.add(key);
411
+
412
+ const section = this.shadowRoot?.querySelector(`.quadrant--${key}`);
413
+ if (!section) return;
414
+
415
+ section.setAttribute('data-editing', '');
416
+
417
+ const front = section.querySelector('.quadrant__face--front');
418
+ const back = section.querySelector('.quadrant__face--back');
419
+ if (front) front.setAttribute('inert', '');
420
+ if (back) back.removeAttribute('inert');
421
+
422
+ const textarea = /** @type {HTMLTextAreaElement | null} */ (section.querySelector('.quadrant__editor'));
423
+ 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
+ }
447
+ textarea.focus();
448
+ }
449
+ }
450
+
451
+ _closeEdit(key) {
452
+ const section = this.shadowRoot?.querySelector(`.quadrant--${key}`);
453
+ if (!section) return;
454
+
455
+ const textarea = /** @type {HTMLTextAreaElement | null} */ (section.querySelector('.quadrant__editor'));
456
+ if (textarea) {
457
+ const items = textarea.value
458
+ .split('\n')
459
+ .map(line => line.trim())
460
+ .filter(Boolean);
461
+
462
+ /* Ensure __quadrants object exists */
463
+ if (!this.__quadrants) this.__quadrants = {};
464
+ this.__quadrants[key] = items;
465
+
466
+ /* Partially re-render front face content */
467
+ 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
+ }
478
+ }
479
+
480
+ this._editingQuadrants.delete(key);
481
+ section.removeAttribute('data-editing');
482
+
483
+ const front = section.querySelector('.quadrant__face--front');
484
+ const back = section.querySelector('.quadrant__face--back');
485
+ if (front) front.removeAttribute('inert');
486
+ if (back) back.setAttribute('inert', '');
487
+
488
+ /* Dispatch update event */
489
+ this.dispatchEvent(new CustomEvent('empathy-map:update', {
490
+ bubbles: true,
491
+ composed: true,
492
+ detail: {
493
+ quadrant: key,
494
+ items: this.__quadrants?.[key] || [],
495
+ },
496
+ }));
497
+ }
498
+ }
499
+
500
+ registerComponent('empathy-map', EmpathyMap);
501
+
502
+ export { EmpathyMap };
@@ -0,0 +1,32 @@
1
+ <!--
2
+ empathy-map — static (zero-JS) form
3
+ Slotted content renders as normal children before upgrade.
4
+ -->
5
+ <empathy-map
6
+ title="Developer Onboarding"
7
+ persona="Sarah Chen"
8
+ persona-id="persona-sarah-chen"
9
+ summary="Based on user interview round 2">
10
+ <ul slot="says">
11
+ <li>"I just want it to work"</li>
12
+ <li>"The documentation is confusing"</li>
13
+ </ul>
14
+ <ul slot="thinks">
15
+ <li>There must be a simpler way</li>
16
+ <li>Am I doing this wrong?</li>
17
+ </ul>
18
+ <ul slot="does">
19
+ <li>Searches Stack Overflow frequently</li>
20
+ <li>Copies code from examples</li>
21
+ </ul>
22
+ <ul slot="feels">
23
+ <li>Frustrated with configuration steps</li>
24
+ <li>Curious about alternatives</li>
25
+ </ul>
26
+ <ul slot="goals">
27
+ <li>Complete onboarding in under 10 minutes</li>
28
+ </ul>
29
+ <ul slot="pain-points">
30
+ <li>Too many configuration steps</li>
31
+ </ul>
32
+ </empathy-map>