@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,567 @@
1
+ /**
2
+ * <user-story> — Agile user story card web component
3
+ *
4
+ * Displays user stories in the classic Agile format:
5
+ * "As a [persona], I want [action] so that [benefit]"
6
+ *
7
+ * Content slots (text the user reads):
8
+ * @slot persona - The "As a..." role (e.g., <span>)
9
+ * @slot action - The "I want..." description (e.g., <span>)
10
+ * @slot benefit - The "so that..." outcome (e.g., <span>)
11
+ * @slot title - Short label for minimal mode (e.g., <h3>)
12
+ * @slot acceptance-criteria - Checklist (e.g., <ul>)
13
+ * @slot tasks - Implementation tasks (e.g., <ul>)
14
+ * @slot notes - Additional context (e.g., <p>)
15
+ *
16
+ * State/config attributes:
17
+ * @attr {string} persona-id - Links to a user-persona element by id. When no
18
+ * slot="persona" is given, the "As a…" text falls
19
+ * back to that persona's role (live-updating).
20
+ * @attr {enum} priority - critical | high | medium | low
21
+ * @attr {enum} status - backlog | to-do | in-progress | review | done
22
+ * @attr {string} points - Story point estimate
23
+ * @attr {string} epic - Parent epic label
24
+ * @attr {string} story-id - Ticket or story identifier
25
+ * @attr {string} detail - full | compact | minimal
26
+ * @attr {boolean} compact - Alias for detail="compact"
27
+ * @attr {string} src - URL to JSON data
28
+ *
29
+ * @fires story-ready - Fired after render
30
+ * @fires status-changed - Fired when updateStatus() is called
31
+ * @fires priority-changed - Fired when updatePriority() is called
32
+ *
33
+ * @example
34
+ * <user-story
35
+ * persona-id="persona-sarah"
36
+ * priority="high"
37
+ * status="to-do"
38
+ * points="5"
39
+ * epic="Dashboard"
40
+ * story-id="PROJ-142">
41
+ * <span slot="persona">Product Manager</span>
42
+ * <span slot="action">view all project timelines in one dashboard</span>
43
+ * <span slot="benefit">I can quickly identify bottlenecks</span>
44
+ * <ul slot="acceptance-criteria">
45
+ * <li>Dashboard loads within 2 seconds</li>
46
+ * </ul>
47
+ * </user-story>
48
+ */
49
+
50
+ import { styles } from './styles.js';
51
+ import { registerComponent } from '../../lib/bundle-registry.js';
52
+ import { esc, lucideSvg, UX_ICONS } from '../_ux-base.js';
53
+
54
+ class UserStory extends HTMLElement {
55
+ static get observedAttributes() {
56
+ return [
57
+ 'persona-id', 'priority', 'points',
58
+ 'status', 'epic', 'story-id', 'compact', 'detail', 'src'
59
+ ];
60
+ }
61
+
62
+ static PRIORITIES = {
63
+ critical: { label: 'Critical', color: '#dc2626', bg: 'rgba(220, 38, 38, 0.1)' },
64
+ high: { label: 'High', color: '#ea580c', bg: 'rgba(234, 88, 12, 0.1)' },
65
+ medium: { label: 'Medium', color: '#ca8a04', bg: 'rgba(202, 138, 4, 0.1)' },
66
+ low: { label: 'Low', color: '#16a34a', bg: 'rgba(22, 163, 74, 0.1)' }
67
+ };
68
+
69
+ static STATUSES = {
70
+ backlog: { label: 'Backlog', color: '#6b7280', bg: 'rgba(107, 114, 128, 0.1)' },
71
+ 'to-do': { label: 'To Do', color: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
72
+ 'in-progress': { label: 'In Progress', color: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.1)' },
73
+ review: { label: 'Review', color: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
74
+ done: { label: 'Done', color: '#22c55e', bg: 'rgba(34, 197, 94, 0.1)' }
75
+ };
76
+
77
+ #slotCache = new Map();
78
+
79
+ /** @type {MutationObserver | null} */
80
+ #personaObserver = null;
81
+
82
+ constructor() {
83
+ super();
84
+ this.attachShadow({ mode: 'open' });
85
+ }
86
+
87
+ #cacheSlotValues() {
88
+ for (const child of [...this.children]) {
89
+ const slotName = child.getAttribute('slot');
90
+ if (slotName) {
91
+ this.#slotCache.set(slotName, child.textContent.trim());
92
+ }
93
+ }
94
+ }
95
+
96
+ _resolve(attr) {
97
+ return this.getAttribute(attr) || this.#slotCache.get(attr) || '';
98
+ }
99
+
100
+ /* ── Data API ──────────────────────────────────── */
101
+
102
+ /**
103
+ * Read the user story as a plain data object. Mirrors what a consumer
104
+ * would assign to .data — useful for diffing, persistence, or echo.
105
+ */
106
+ get data() {
107
+ return {
108
+ storyId: this.storyId || undefined,
109
+ personaId: this.personaId || undefined,
110
+ priority: this.priority,
111
+ status: this.status,
112
+ points: this.points || undefined,
113
+ epic: this.epic || undefined,
114
+ detail: this.getAttribute('detail') || undefined,
115
+ persona: this.persona || undefined,
116
+ action: this.action || undefined,
117
+ benefit: this.benefit || undefined,
118
+ title: this.storyTitle || undefined,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Set state attributes and slotted content from a plain object in one call.
124
+ * Replaces multiple setAttribute calls + manual slotted-child creation.
125
+ * Idempotent for repeat calls.
126
+ */
127
+ set data(value) {
128
+ if (!value || typeof value !== 'object') return;
129
+ this._applyData(value);
130
+ this.#cacheSlotValues();
131
+ if (this.shadowRoot) this.#render();
132
+ this.dispatchEvent(new CustomEvent('user-story:data-changed', {
133
+ detail: { data: this.data, source: 'property' },
134
+ bubbles: true, composed: true,
135
+ }));
136
+ }
137
+
138
+ /**
139
+ * Apply a data record to attributes + slotted children. Used by both the
140
+ * .data setter and async _loadSrc. No render or cache update — caller decides.
141
+ * @param {Record<string, unknown>} data
142
+ */
143
+ _applyData(data) {
144
+ for (const [jsonKey, attr] of [
145
+ ['storyId', 'story-id'], ['personaId', 'persona-id'], ['priority', 'priority'],
146
+ ['status', 'status'], ['points', 'points'], ['epic', 'epic'], ['detail', 'detail']
147
+ ]) {
148
+ if (data[jsonKey] != null) this.setAttribute(attr, String(data[jsonKey]));
149
+ }
150
+
151
+ if (data.persona && !this.querySelector('[slot="persona"]')) {
152
+ const el = document.createElement('span');
153
+ el.slot = 'persona';
154
+ el.textContent = String(data.persona);
155
+ this.appendChild(el);
156
+ }
157
+ if (data.action && !this.querySelector('[slot="action"]')) {
158
+ const el = document.createElement('span');
159
+ el.slot = 'action';
160
+ el.textContent = String(data.action);
161
+ this.appendChild(el);
162
+ }
163
+ if (data.benefit && !this.querySelector('[slot="benefit"]')) {
164
+ const el = document.createElement('span');
165
+ el.slot = 'benefit';
166
+ el.textContent = String(data.benefit);
167
+ this.appendChild(el);
168
+ }
169
+ if (data.title && !this.querySelector('[slot="title"]')) {
170
+ const el = document.createElement('h3');
171
+ el.slot = 'title';
172
+ el.textContent = String(data.title);
173
+ this.appendChild(el);
174
+ }
175
+ for (const key of ['acceptance-criteria', 'tasks', 'notes']) {
176
+ const jsonKey = key === 'acceptance-criteria' ? 'acceptanceCriteria' : key;
177
+ if (data[jsonKey] && !this.querySelector(`[slot="${key}"]`)) {
178
+ if (Array.isArray(data[jsonKey])) {
179
+ const ul = document.createElement('ul');
180
+ ul.slot = key;
181
+ for (const item of data[jsonKey]) {
182
+ const li = document.createElement('li');
183
+ li.textContent = String(item);
184
+ ul.appendChild(li);
185
+ }
186
+ this.appendChild(ul);
187
+ } else {
188
+ const p = document.createElement('p');
189
+ p.slot = key;
190
+ p.textContent = String(data[jsonKey]);
191
+ this.appendChild(p);
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ async _loadSrc(url) {
198
+ if (!url) return;
199
+ try {
200
+ const res = await fetch(url);
201
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
202
+ const data = await res.json();
203
+ this._applyData(data);
204
+ this.#cacheSlotValues();
205
+ this.#render();
206
+ } catch (err) {
207
+ console.warn(`[user-story] Failed to load src="${url}":`, err);
208
+ }
209
+ }
210
+
211
+ connectedCallback() {
212
+ this.#cacheSlotValues();
213
+ if (this.storyId && !this.id) this.id = this.storyId;
214
+ if (this.hasAttribute('src')) {
215
+ this._loadSrc(this.getAttribute('src'));
216
+ }
217
+ this.#render();
218
+ this.#observeLinkedPersona();
219
+ this.setAttribute('data-upgraded', '');
220
+ }
221
+
222
+ disconnectedCallback() {
223
+ this.removeAttribute('data-upgraded');
224
+ this.#personaObserver?.disconnect();
225
+ this.#personaObserver = null;
226
+ }
227
+
228
+ attributeChangedCallback(name, oldValue, newValue) {
229
+ if (oldValue !== newValue && this.shadowRoot) {
230
+ if (name === 'src' && this.isConnected) {
231
+ this._loadSrc(newValue);
232
+ } else {
233
+ this.#render();
234
+ if (name === 'persona-id') this.#observeLinkedPersona();
235
+ }
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Keep the "As a…" text in sync when the linked persona's role is edited.
241
+ * Only observes when persona-id resolves and the author has not supplied an
242
+ * explicit persona slot (an explicit slot always wins, so nothing to track).
243
+ */
244
+ #observeLinkedPersona() {
245
+ this.#personaObserver?.disconnect();
246
+ this.#personaObserver = null;
247
+ if (!this.personaId || this.#explicitPersona()) return;
248
+ const root = /** @type {Document | ShadowRoot} */ (this.getRootNode());
249
+ const scope = root && typeof root.getElementById === 'function' ? root : document;
250
+ const persona = scope.getElementById(this.personaId);
251
+ if (!persona || persona.tagName !== 'USER-PERSONA') return;
252
+ this.#personaObserver = new MutationObserver(() => this.#render());
253
+ this.#personaObserver.observe(persona, { attributes: true, attributeFilter: ['role'] });
254
+ }
255
+
256
+ // ── Getters ────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Read persona from slotted element or cache. When neither is authored but
260
+ * persona-id links to a <user-persona> on the page, fall back to that
261
+ * persona's role ("As a Product Manager…") rather than the generic "user".
262
+ */
263
+ get persona() {
264
+ const explicit = this.#explicitPersona();
265
+ return explicit || this.#linkedPersonaRole() || 'user';
266
+ }
267
+
268
+ get personaId() {
269
+ return this.getAttribute('persona-id') || '';
270
+ }
271
+
272
+ /** Persona text the author supplied directly via slot or .data (no link resolution). */
273
+ #explicitPersona() {
274
+ const slotted = this.querySelector('[slot="persona"]');
275
+ return slotted?.textContent?.trim() || this.#slotCache.get('persona') || '';
276
+ }
277
+
278
+ /**
279
+ * Resolve the role of the <user-persona> referenced by persona-id, if one
280
+ * exists in the same tree. Used as the "As a…" fallback so a linked story
281
+ * reads "As a Product Manager" instead of "As a user". Returns '' when there
282
+ * is no persona-id, no matching persona, or the persona has no role.
283
+ */
284
+ #linkedPersonaRole() {
285
+ if (!this.personaId) return '';
286
+ const root = /** @type {Document | ShadowRoot} */ (this.getRootNode());
287
+ const scope = root && typeof root.getElementById === 'function' ? root : document;
288
+ const persona = scope.getElementById(this.personaId);
289
+ if (!persona || persona.tagName !== 'USER-PERSONA') return '';
290
+ return persona.getAttribute('role')?.trim() || '';
291
+ }
292
+
293
+ /**
294
+ * Indefinite article ("a" / "an") for the persona role so the statement
295
+ * reads naturally — "As an Analyst", "As a Product Manager". Simple
296
+ * vowel-start heuristic (excludes "u", which usually sounds like "you":
297
+ * "a user"). Falls back to "a".
298
+ */
299
+ #personaArticle() {
300
+ return /^[aeio]/i.test(this.persona.trim()) ? 'an' : 'a';
301
+ }
302
+
303
+ /** Read action from slotted element or cache */
304
+ get action() {
305
+ const slotted = this.querySelector('[slot="action"]');
306
+ return slotted?.textContent?.trim() || this.#slotCache.get('action') || '';
307
+ }
308
+
309
+ /** Read benefit from slotted element or cache */
310
+ get benefit() {
311
+ const slotted = this.querySelector('[slot="benefit"]');
312
+ return slotted?.textContent?.trim() || this.#slotCache.get('benefit') || '';
313
+ }
314
+
315
+ get priority() {
316
+ return this.getAttribute('priority') || 'medium';
317
+ }
318
+
319
+ get points() {
320
+ return this.getAttribute('points') || '';
321
+ }
322
+
323
+ get status() {
324
+ return this.getAttribute('status') || 'backlog';
325
+ }
326
+
327
+ get epic() {
328
+ return this.getAttribute('epic') || '';
329
+ }
330
+
331
+ get storyId() {
332
+ return this.getAttribute('story-id') || '';
333
+ }
334
+
335
+ /** Read title from slotted heading or cache */
336
+ get storyTitle() {
337
+ const slotted = this.querySelector('[slot="title"]');
338
+ return slotted?.textContent?.trim() || this.#slotCache.get('title') || '';
339
+ }
340
+
341
+ /** Label for minimal mode: title if set, otherwise truncated action */
342
+ get _minimalLabel() {
343
+ if (this.storyTitle) return this.storyTitle;
344
+ const action = this.action;
345
+ return action.length > 40 ? action.slice(0, 40) + '\u2026' : action;
346
+ }
347
+
348
+ get compact() {
349
+ return this.hasAttribute('compact');
350
+ }
351
+
352
+ get _detailLevel() {
353
+ if (this.getAttribute('detail')) return this.getAttribute('detail');
354
+ if (this.hasAttribute('compact')) return 'compact';
355
+ return 'full';
356
+ }
357
+
358
+ // ── Public API ───────────────────────────────────────────────────
359
+
360
+ updateStatus(newStatus) {
361
+ if (UserStory.STATUSES[newStatus]) {
362
+ this.setAttribute('status', newStatus);
363
+ this.dispatchEvent(new CustomEvent('status-changed', {
364
+ detail: { status: newStatus, storyId: this.storyId },
365
+ bubbles: true,
366
+ composed: true
367
+ }));
368
+ }
369
+ }
370
+
371
+ updatePriority(newPriority) {
372
+ if (UserStory.PRIORITIES[newPriority]) {
373
+ this.setAttribute('priority', newPriority);
374
+ this.dispatchEvent(new CustomEvent('priority-changed', {
375
+ detail: { priority: newPriority, storyId: this.storyId },
376
+ bubbles: true,
377
+ composed: true
378
+ }));
379
+ }
380
+ }
381
+
382
+ showDetail() {
383
+ const dialogId = `story-dialog-${this.storyId || this.id || 'detail'}`;
384
+ let dialog = /** @type {HTMLDialogElement | null} */ (document.getElementById(dialogId));
385
+ if (!dialog) {
386
+ dialog = document.createElement('dialog');
387
+ dialog.id = dialogId;
388
+ dialog.setAttribute('data-size', 'l');
389
+ document.body.appendChild(dialog);
390
+ }
391
+
392
+ const form = document.createElement('form');
393
+ form.method = 'dialog';
394
+ const header = document.createElement('header');
395
+ const title = document.createElement('h3');
396
+ title.textContent = this.storyTitle || this.storyId || 'Story Detail';
397
+ const closeBtn = document.createElement('button');
398
+ closeBtn.type = 'submit';
399
+ closeBtn.setAttribute('aria-label', 'Close');
400
+ closeBtn.textContent = '\u00d7';
401
+ header.appendChild(title);
402
+ header.appendChild(closeBtn);
403
+
404
+ const section = document.createElement('section');
405
+ const full = document.createElement('user-story');
406
+ for (const attr of this.getAttributeNames()) {
407
+ if (attr === 'detail' || attr === 'compact' || attr === 'data-upgraded' || attr === 'draggable' || attr === 'data-id' || attr === 'data-quadrant') continue;
408
+ full.setAttribute(attr, this.getAttribute(attr) ?? '');
409
+ }
410
+ const hasSlottedContent = [...this.children].some(c => c.getAttribute('slot') && c.tagName !== 'DIALOG');
411
+ full.setAttribute('detail', hasSlottedContent ? 'full' : 'compact');
412
+ full.removeAttribute('id');
413
+ for (const child of [...this.children]) {
414
+ if (child.tagName === 'DIALOG') continue;
415
+ full.appendChild(child.cloneNode(true));
416
+ }
417
+ section.appendChild(full);
418
+
419
+ form.appendChild(header);
420
+ form.appendChild(section);
421
+ dialog.innerHTML = '';
422
+ dialog.appendChild(form);
423
+ dialog.addEventListener('close', () => { dialog.innerHTML = ''; }, { once: true });
424
+ dialog.showModal();
425
+ }
426
+
427
+ // ── Private ──────────────────────────────────────────────────────
428
+
429
+ #render() {
430
+ const priorityInfo = UserStory.PRIORITIES[this.priority] || UserStory.PRIORITIES.medium;
431
+ const statusInfo = UserStory.STATUSES[this.status] || UserStory.STATUSES.backlog;
432
+ const level = this._detailLevel;
433
+ const ariaLabel = this.storyId
434
+ ? `User story: ${esc(this.storyId)}`
435
+ : 'User story';
436
+
437
+ if (!this.shadowRoot) return;
438
+ if (level === 'minimal') {
439
+ this.shadowRoot.innerHTML = `
440
+ <style>${styles}</style>
441
+ <article class="story-card story-card--minimal" role="article" aria-label="${ariaLabel}"
442
+ tabindex="0">
443
+ <div class="story-body">
444
+ ${this.storyId ? `<span class="story-id">${esc(this.storyId)}</span>` : ''}
445
+ <div class="story-title-wrap">
446
+ <slot name="title"><span class="story-title-fallback">${esc(this._minimalLabel || '[describe the action]')}</span></slot>
447
+ </div>
448
+ </div>
449
+ </article>
450
+ `;
451
+ const card = this.shadowRoot.querySelector('.story-card--minimal');
452
+ if (!card) return;
453
+ card.addEventListener('click', () => this.showDetail());
454
+ card.addEventListener('keydown', (/** @type {Event} */ e) => {
455
+ const ke = /** @type {KeyboardEvent} */ (e);
456
+ if (ke.key === 'Enter' || ke.key === ' ') { ke.preventDefault(); this.showDetail(); }
457
+ });
458
+ } else {
459
+ this.shadowRoot.innerHTML = `
460
+ <style>${styles}</style>
461
+
462
+ <article class="story-card story-card--${level}" part="card" role="article" aria-label="${ariaLabel}">
463
+ <header class="story-header" part="header">
464
+ <div class="story-meta">
465
+ ${this.storyId ? `<span class="story-id" part="id">${esc(this.storyId)}</span>` : ''}
466
+ ${this.epic ? `<span class="epic-badge" part="epic">${esc(this.epic)}</span>` : ''}
467
+ </div>
468
+ <div class="story-badges">
469
+ <span class="priority-badge" part="priority"
470
+ style="color: ${priorityInfo.color}; background: ${priorityInfo.bg};"
471
+ >${esc(priorityInfo.label)}</span>
472
+ <span class="status-badge" part="status"
473
+ style="color: ${statusInfo.color}; background: ${statusInfo.bg};"
474
+ >${esc(statusInfo.label)}</span>
475
+ ${this.points ? `<span class="points-badge" part="points">${esc(this.points)}</span>` : ''}
476
+ </div>
477
+ </header>
478
+
479
+ <div class="story-body" part="body">
480
+ <p class="story-statement" part="statement">
481
+ <span class="keyword">As ${this.#personaArticle()}</span>
482
+ ${this.personaId
483
+ ? `<a class="persona-text persona-text--link" href="#${esc(this.personaId)}">${lucideSvg(UX_ICONS.user)} <slot name="persona"><span>${esc(this.#linkedPersonaRole() || 'user')}</span></slot></a>`
484
+ : `<span class="persona-text"><slot name="persona"><span>user</span></slot></span>`},
485
+ <span class="keyword">I want</span>
486
+ <span class="action-text"><slot name="action"><span>[describe the action]</span></slot></span>${this.benefit || this.querySelector('[slot="benefit"]') ? `
487
+ <span class="keyword">so that</span>
488
+ <span class="benefit-text"><slot name="benefit"></slot></span>` : ''}
489
+ </p>
490
+ </div>
491
+
492
+ <div class="story-sections" part="sections">
493
+ <div class="section" part="section">
494
+ <div class="section-header">
495
+ <div class="section-icon acceptance">
496
+ <svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
497
+ </div>
498
+ <span class="section-title">Acceptance Criteria</span>
499
+ </div>
500
+ <div class="section-content">
501
+ <slot name="acceptance-criteria">
502
+ <em class="slot-fallback">No acceptance criteria defined.</em>
503
+ </slot>
504
+ </div>
505
+ </div>
506
+
507
+ <div class="section" part="section">
508
+ <div class="section-header">
509
+ <div class="section-icon tasks">
510
+ <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>
511
+ </div>
512
+ <span class="section-title">Tasks</span>
513
+ </div>
514
+ <div class="section-content">
515
+ <slot name="tasks">
516
+ <em class="slot-fallback">No tasks added yet.</em>
517
+ </slot>
518
+ </div>
519
+ </div>
520
+
521
+ <div class="section" part="section">
522
+ <div class="section-header">
523
+ <div class="section-icon notes">
524
+ <svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
525
+ </div>
526
+ <span class="section-title">Notes</span>
527
+ </div>
528
+ <div class="section-content">
529
+ <slot name="notes">
530
+ <em class="slot-fallback">No additional notes.</em>
531
+ </slot>
532
+ </div>
533
+ </div>
534
+ </div>
535
+ </article>
536
+ `;
537
+
538
+ if (level === 'compact') {
539
+ const sections = this.shadowRoot.querySelectorAll('.section');
540
+ sections.forEach(section => {
541
+ const slot = section.querySelector('slot');
542
+ if (slot && slot.assignedNodes().length === 0) {
543
+ section.setAttribute('data-empty', '');
544
+ }
545
+ });
546
+ }
547
+ }
548
+
549
+ this.dispatchEvent(new CustomEvent('story-ready', {
550
+ detail: {
551
+ id: this.storyId,
552
+ persona: this.persona,
553
+ action: this.action,
554
+ benefit: this.benefit,
555
+ priority: this.priority,
556
+ status: this.status,
557
+ points: this.points
558
+ },
559
+ bubbles: true,
560
+ composed: true
561
+ }));
562
+ }
563
+ }
564
+
565
+ registerComponent('user-story', UserStory);
566
+
567
+ export { UserStory };
@@ -0,0 +1,27 @@
1
+ <!--
2
+ user-story — static (zero-JS) form
3
+ Slotted content renders as normal children before upgrade.
4
+ Content (persona, action, benefit, title) uses slots.
5
+ State (priority, status, points, epic) uses attributes.
6
+ -->
7
+ <user-story
8
+ persona-id="persona-sarah"
9
+ priority="high"
10
+ status="to-do"
11
+ points="5"
12
+ epic="Dashboard"
13
+ story-id="PROJ-142">
14
+ <span slot="persona">Product Manager</span>
15
+ <span slot="action">view all project timelines in one dashboard</span>
16
+ <span slot="benefit">I can quickly identify bottlenecks and reallocate resources</span>
17
+ <ul slot="acceptance-criteria">
18
+ <li>Dashboard loads within 2 seconds</li>
19
+ <li>All active projects are displayed</li>
20
+ <li>Timeline view is zoomable</li>
21
+ </ul>
22
+ <ul slot="tasks">
23
+ <li>Design dashboard layout</li>
24
+ <li>Implement timeline component</li>
25
+ </ul>
26
+ <p slot="notes">Consider using the existing charting library for timeline visualization.</p>
27
+ </user-story>