@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,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>
|