@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.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/vb-project-planning.css +1603 -0
- package/dist/vb-project-planning.js +11138 -0
- package/dist/vb-project-planning.min.css +1 -0
- package/dist/vb-project-planning.min.js +3312 -0
- package/package.json +80 -0
- package/src/index.css +14 -0
- package/src/index.js +26 -0
- package/src/lib/bundle-registry.js +156 -0
- package/src/lib/diff-by-key.js +86 -0
- package/src/lib/portrait-url.js +87 -0
- package/src/lib/vb-collection.js +122 -0
- package/src/lib/vb-element.js +96 -0
- package/src/lib/vb-store.js +200 -0
- package/src/lib/vb-view-transition.js +44 -0
- package/src/utils/copy-init.js +287 -0
- package/src/web-components/_ux-base.js +106 -0
- package/src/web-components/adr-wc/README.md +77 -0
- package/src/web-components/adr-wc/api.json +26 -0
- package/src/web-components/adr-wc/logic.js +376 -0
- package/src/web-components/adr-wc/static.html +17 -0
- package/src/web-components/adr-wc/styles.js +338 -0
- package/src/web-components/burndown-chart/api.json +74 -0
- package/src/web-components/burndown-chart/logic.js +198 -0
- package/src/web-components/burndown-chart/static.html +31 -0
- package/src/web-components/burndown-chart/styles.css +59 -0
- package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
- package/src/web-components/capacity-plan/api.json +20 -0
- package/src/web-components/capacity-plan/logic.js +222 -0
- package/src/web-components/capacity-plan/static.html +24 -0
- package/src/web-components/capacity-plan/styles.css +84 -0
- package/src/web-components/empathy-map/api.json +31 -0
- package/src/web-components/empathy-map/logic.js +502 -0
- package/src/web-components/empathy-map/static.html +32 -0
- package/src/web-components/empathy-map/styles.js +534 -0
- package/src/web-components/gantt-chart/README.md +91 -0
- package/src/web-components/gantt-chart/api.json +20 -0
- package/src/web-components/gantt-chart/logic.js +812 -0
- package/src/web-components/gantt-chart/static.html +60 -0
- package/src/web-components/gantt-chart/styles.css +391 -0
- package/src/web-components/impact-effort/api.json +21 -0
- package/src/web-components/impact-effort/logic.js +392 -0
- package/src/web-components/impact-effort/static.html +23 -0
- package/src/web-components/impact-effort/styles.css +193 -0
- package/src/web-components/iron-triangle/_capacity.js +83 -0
- package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
- package/src/web-components/iron-triangle/api.json +26 -0
- package/src/web-components/iron-triangle/logic.js +474 -0
- package/src/web-components/iron-triangle/static.html +28 -0
- package/src/web-components/iron-triangle/styles.css +158 -0
- package/src/web-components/kanban-board/api.json +24 -0
- package/src/web-components/kanban-board/logic.js +698 -0
- package/src/web-components/kanban-board/static.html +20 -0
- package/src/web-components/kanban-board/styles.css +242 -0
- package/src/web-components/product-roadmap/api.json +118 -0
- package/src/web-components/product-roadmap/logic.js +460 -0
- package/src/web-components/product-roadmap/static.html +44 -0
- package/src/web-components/product-roadmap/styles.css +158 -0
- package/src/web-components/quadrant-grid/api.json +125 -0
- package/src/web-components/quadrant-grid/logic.js +397 -0
- package/src/web-components/quadrant-grid/static.html +49 -0
- package/src/web-components/quadrant-grid/styles.css +184 -0
- package/src/web-components/quality-target/_quality-utils.js +460 -0
- package/src/web-components/quality-target/api.json +30 -0
- package/src/web-components/quality-target/logic.js +516 -0
- package/src/web-components/quality-target/static.html +22 -0
- package/src/web-components/quality-target/styles.css +203 -0
- package/src/web-components/requirement-card/api.json +25 -0
- package/src/web-components/requirement-card/logic.js +166 -0
- package/src/web-components/requirement-card/static.html +36 -0
- package/src/web-components/requirement-card/styles.js +114 -0
- package/src/web-components/review-surface/README.md +178 -0
- package/src/web-components/review-surface/api.json +30 -0
- package/src/web-components/review-surface/logic.js +807 -0
- package/src/web-components/review-surface/static.html +33 -0
- package/src/web-components/review-surface/styles.js +459 -0
- package/src/web-components/risk-register/api.json +47 -0
- package/src/web-components/risk-register/logic.js +180 -0
- package/src/web-components/risk-register/static.html +46 -0
- package/src/web-components/risk-register/styles.css +37 -0
- package/src/web-components/story-map/api.json +22 -0
- package/src/web-components/story-map/logic.js +445 -0
- package/src/web-components/story-map/static.html +35 -0
- package/src/web-components/story-map/styles.css +169 -0
- package/src/web-components/traceability-matrix/api.json +84 -0
- package/src/web-components/traceability-matrix/logic.js +243 -0
- package/src/web-components/traceability-matrix/static.html +41 -0
- package/src/web-components/traceability-matrix/styles.css +94 -0
- package/src/web-components/user-journey/api.json +84 -0
- package/src/web-components/user-journey/logic.js +368 -0
- package/src/web-components/user-journey/static.html +14 -0
- package/src/web-components/user-journey/styles.js +317 -0
- package/src/web-components/user-persona/api.json +35 -0
- package/src/web-components/user-persona/logic.js +502 -0
- package/src/web-components/user-persona/static.html +20 -0
- package/src/web-components/user-persona/styles.js +299 -0
- package/src/web-components/user-story/api.json +35 -0
- package/src/web-components/user-story/logic.js +567 -0
- package/src/web-components/user-story/static.html +27 -0
- package/src/web-components/user-story/styles.js +332 -0
- package/src/web-components/work-item/README.md +85 -0
- package/src/web-components/work-item/api.json +30 -0
- package/src/web-components/work-item/logic.js +414 -0
- package/src/web-components/work-item/static.html +24 -0
- 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>
|