@profpowell/vb-project-planning 0.2.1 → 0.2.3

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 (66) hide show
  1. package/README.md +10 -5
  2. package/dist/vb-project-planning.css +69 -3
  3. package/dist/vb-project-planning.js +3191 -2544
  4. package/dist/vb-project-planning.min.css +1 -1
  5. package/dist/vb-project-planning.min.js +221 -197
  6. package/package.json +7 -4
  7. package/src/lib/board-surface.js +158 -0
  8. package/src/lib/bundle-registry.js +2 -75
  9. package/src/lib/dates.js +125 -0
  10. package/src/lib/escape.js +34 -0
  11. package/src/lib/hash.js +16 -0
  12. package/src/lib/portrait-url.js +3 -29
  13. package/src/lib/src-loader.js +78 -0
  14. package/src/lib/vb-view-transition.js +7 -1
  15. package/src/utils/copy-init.js +56 -17
  16. package/src/web-components/_ux-base.js +409 -24
  17. package/src/web-components/adr-wc/README.md +18 -16
  18. package/src/web-components/adr-wc/api.json +7 -5
  19. package/src/web-components/adr-wc/logic.js +50 -136
  20. package/src/web-components/burndown-chart/api.json +8 -6
  21. package/src/web-components/burndown-chart/logic.js +75 -39
  22. package/src/web-components/empathy-map/api.json +13 -13
  23. package/src/web-components/empathy-map/logic.js +161 -168
  24. package/src/web-components/empathy-map/static.html +3 -3
  25. package/src/web-components/empathy-map/styles.js +1 -0
  26. package/src/web-components/gantt-chart/README.md +12 -6
  27. package/src/web-components/gantt-chart/api.json +23 -7
  28. package/src/web-components/gantt-chart/logic.js +456 -431
  29. package/src/web-components/gantt-chart/styles.css +14 -1
  30. package/src/web-components/impact-effort/api.json +9 -7
  31. package/src/web-components/impact-effort/logic.js +238 -209
  32. package/src/web-components/impact-effort/static.html +12 -4
  33. package/src/web-components/iron-triangle/_capacity.js +0 -18
  34. package/src/web-components/kanban-board/api.json +8 -6
  35. package/src/web-components/kanban-board/logic.js +289 -333
  36. package/src/web-components/product-roadmap/api.json +14 -13
  37. package/src/web-components/product-roadmap/logic.js +393 -246
  38. package/src/web-components/product-roadmap/styles.css +40 -0
  39. package/src/web-components/quadrant-grid/api.json +9 -4
  40. package/src/web-components/quadrant-grid/logic.js +80 -12
  41. package/src/web-components/quadrant-grid/styles.css +7 -0
  42. package/src/web-components/quality-target/_quality-utils.js +2 -46
  43. package/src/web-components/review-surface/README.md +9 -5
  44. package/src/web-components/review-surface/api.json +10 -8
  45. package/src/web-components/review-surface/logic.js +349 -218
  46. package/src/web-components/risk-register/api.json +1 -6
  47. package/src/web-components/risk-register/logic.js +71 -24
  48. package/src/web-components/risk-register/styles.css +9 -1
  49. package/src/web-components/story-map/api.json +8 -6
  50. package/src/web-components/story-map/logic.js +280 -281
  51. package/src/web-components/story-map/static.html +15 -5
  52. package/src/web-components/traceability-matrix/api.json +10 -3
  53. package/src/web-components/traceability-matrix/logic.js +112 -63
  54. package/src/web-components/traceability-matrix/styles.css +22 -1
  55. package/src/web-components/user-journey/api.json +25 -24
  56. package/src/web-components/user-journey/logic.js +76 -127
  57. package/src/web-components/user-journey/static.html +2 -2
  58. package/src/web-components/user-persona/api.json +15 -14
  59. package/src/web-components/user-persona/logic.js +71 -117
  60. package/src/web-components/user-persona/styles.js +1 -0
  61. package/src/web-components/user-story/api.json +13 -11
  62. package/src/web-components/user-story/logic.js +164 -195
  63. package/src/web-components/user-story/styles.js +28 -1
  64. package/src/web-components/work-item/README.md +3 -3
  65. package/src/web-components/work-item/api.json +13 -11
  66. package/src/web-components/work-item/logic.js +58 -154
@@ -5,26 +5,28 @@
5
5
  "description": "General-purpose work unit card — tasks, bugs, chores, spikes, and features with slotted title, status, priority, assignee, and linked stories",
6
6
  "htmlvalidate": { "flow": true, "permittedContent": ["@flow"] },
7
7
  "attributes": [
8
- { "name": "item-id", "kind": "host-api", "purpose": "config", "type": "string", "description": "Unique work item identifier, auto-sets HTML id" },
9
- { "name": "type", "kind": "host-api", "purpose": "config", "type": "string", "description": "Item type: task | bug | chore | spike | feature" },
10
- { "name": "priority", "kind": "host-api", "purpose": "config", "type": "string", "description": "Priority: critical | high | medium | low" },
11
- { "name": "status", "kind": "host-api", "purpose": "config", "type": "string", "description": "Status: backlog | to-do | in-progress | review | done | blocked" },
8
+ { "name": "item-id", "kind": "host-api", "purpose": "config", "type": "string", "description": "Unique work item identifier; sets the HTML id on first connect when none is set" },
9
+ { "name": "type", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["task", "bug", "chore", "spike", "feature"], "default": "task", "description": "Item type" },
10
+ { "name": "priority", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["critical", "high", "medium", "low"], "default": "medium", "description": "Priority" },
11
+ { "name": "status", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["backlog", "to-do", "in-progress", "review", "done", "blocked"], "default": "backlog", "description": "Workflow status" },
12
12
  { "name": "estimate", "kind": "host-api", "purpose": "config", "type": "string", "description": "Effort estimate (points, hours, t-shirt size)" },
13
- { "name": "assignee", "kind": "host-api", "purpose": "config", "type": "string", "description": "Assignee name" },
13
+ { "name": "assignee", "kind": "host-api", "purpose": "config", "type": "string", "description": "Assignee name (shown with initials avatar)" },
14
14
  { "name": "story-ids", "kind": "host-api", "purpose": "config", "type": "string", "description": "Comma-separated linked user-story IDs" },
15
- { "name": "detail", "kind": "host-api", "purpose": "config", "type": "string", "description": "Detail level: full | compact | minimal" },
15
+ { "name": "detail", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["full", "compact", "minimal"], "default": "full", "description": "Detail level. minimal shows id, type and title only." },
16
16
  { "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Alias for detail=compact" },
17
- { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON data" }
17
+ { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL of a JSON work-item record. Fetched once per value; a newer value aborts the older request." }
18
18
  ],
19
19
  "slots": [
20
- { "name": "title", "description": "Work item heading (e.g., <h3>)" },
20
+ { "name": "title", "description": "Work item heading (e.g., <h3>). There is no title attribute." },
21
21
  { "name": "description", "description": "Work item description (e.g., <p>)" },
22
22
  { "name": "checklist", "description": "Checklist or subtasks (e.g., <ul>)" },
23
23
  { "name": "notes", "description": "Additional notes (e.g., <p>)" }
24
24
  ],
25
25
  "events": [
26
- { "name": "work-item:ready", "detail": "{ itemId, title, type, priority, status }", "description": "Fired after render" },
27
- { "name": "work-item:status", "detail": "{ status, itemId }", "description": "Fired when status changes" },
28
- { "name": "work-item:priority", "detail": "{ priority, itemId }", "description": "Fired when priority changes" }
26
+ { "name": "work-item:ready", "detail": "{ itemId, title, type, priority, status }", "description": "Once per element, after the first render at any detail level. With src, after the fetched data renders (not fired if the fetch fails). Not re-fired by attribute changes or moves." },
27
+ { "name": "work-item:status", "detail": "{ status, itemId }", "description": "When updateStatus() is called with a known status" },
28
+ { "name": "work-item:priority", "detail": "{ priority, itemId }", "description": "When updatePriority() is called with a known priority" },
29
+ { "name": "work-item:upgraded", "detail": "", "description": "Once, in a microtask after first connect. Wait for this before assigning .data from a framework." },
30
+ { "name": "work-item:data-changed", "detail": "{ data, source: 'property' }", "description": "After .data is assigned" }
29
31
  ]
30
32
  }
@@ -22,22 +22,24 @@
22
22
  * @attr {string} story-ids - Comma-separated linked story IDs
23
23
  * @attr {string} detail - full | compact | minimal
24
24
  * @attr {boolean} compact - Alias for detail="compact"
25
- * @attr {string} src - URL to JSON data
25
+ * @attr {string} src - URL to JSON data (fetched once per value)
26
26
  *
27
- * @fires work-item:ready - After render
28
- * @fires work-item:status - When updateStatus() is called
29
- * @fires work-item:priority - When updatePriority() is called
27
+ * @fires work-item:ready - Once, after the first render (after the src data renders when src is set)
28
+ * @fires work-item:status - When updateStatus() is called
29
+ * @fires work-item:priority - When updatePriority() is called
30
+ * @fires work-item:upgraded - Once, after first connect
31
+ * @fires work-item:data-changed - After .data assignment, detail: { data, source: 'property' }
30
32
  *
31
33
  * @example
32
34
  * <work-item
33
35
  * item-id="PROJ-42"
34
- * title="Implement search API"
35
36
  * type="task"
36
37
  * status="in-progress"
37
38
  * priority="high"
38
39
  * assignee="Sarah Chen"
39
40
  * estimate="5"
40
41
  * story-ids="PROJ-101,PROJ-102">
42
+ * <h3 slot="title">Implement search API</h3>
41
43
  * <p slot="description">Build the /search endpoint with filters.</p>
42
44
  * <ul slot="checklist">
43
45
  * <li>Schema design</li>
@@ -49,7 +51,10 @@
49
51
 
50
52
  import { styles } from './styles.js';
51
53
  import { registerComponent } from '../../lib/bundle-registry.js';
52
- import { esc, initials, hashColor, lucideSvg, UX_ICONS } from '../_ux-base.js';
54
+ import {
55
+ esc, initials, hashColor, lucideSvg, UXShadowElement,
56
+ PRIORITIES, STATUSES as BASE_STATUSES, lookup, fillSlot, splitList, detailLevel, markEmptySections,
57
+ } from '../_ux-base.js';
53
58
 
54
59
  /* ── Type icons (Lucide SVG fragments) ─────────────── */
55
60
 
@@ -63,25 +68,16 @@ const TYPE_ICONS = {
63
68
 
64
69
  /* ── Static lookups ────────────────────────────────── */
65
70
 
66
- const PRIORITIES = {
67
- critical: { label: 'Critical', color: '#dc2626', bg: 'rgba(220, 38, 38, 0.1)' },
68
- high: { label: 'High', color: '#ea580c', bg: 'rgba(234, 88, 12, 0.1)' },
69
- medium: { label: 'Medium', color: '#ca8a04', bg: 'rgba(202, 138, 4, 0.1)' },
70
- low: { label: 'Low', color: '#16a34a', bg: 'rgba(22, 163, 74, 0.1)' },
71
- };
72
-
73
- const STATUSES = {
74
- backlog: { label: 'Backlog', color: '#6b7280', bg: 'rgba(107, 114, 128, 0.1)' },
75
- 'to-do': { label: 'To Do', color: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
76
- 'in-progress': { label: 'In Progress', color: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.1)' },
77
- review: { label: 'Review', color: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
78
- done: { label: 'Done', color: '#22c55e', bg: 'rgba(34, 197, 94, 0.1)' },
79
- blocked: { label: 'Blocked', color: '#dc2626', bg: 'rgba(220, 38, 38, 0.1)' },
80
- };
71
+ const STATUSES = Object.freeze({
72
+ ...BASE_STATUSES,
73
+ blocked: { label: 'Blocked', color: '#dc2626', bg: 'rgba(220, 38, 38, 0.1)' },
74
+ });
81
75
 
82
76
  /* ── Component ─────────────────────────────────────── */
83
77
 
84
- class WorkItem extends HTMLElement {
78
+ class WorkItem extends UXShadowElement {
79
+ static readyEvent = 'work-item:ready';
80
+
85
81
  static get observedAttributes() {
86
82
  return [
87
83
  'item-id', 'type', 'priority', 'status',
@@ -89,77 +85,35 @@ class WorkItem extends HTMLElement {
89
85
  ];
90
86
  }
91
87
 
92
- #slotCache = new Map();
93
-
94
- constructor() {
95
- super();
96
- this.attachShadow({ mode: 'open' });
97
- }
98
-
99
- /* ── Slot caching ───────────────────────────────── */
100
-
101
- #cacheSlotValues() {
102
- for (const child of [...this.children]) {
103
- const slotName = child.getAttribute('slot');
104
- if (slotName) {
105
- this.#slotCache.set(slotName, child.textContent.trim());
106
- }
107
- }
108
- }
109
-
110
- _resolve(attr) {
111
- return this.getAttribute(attr) || this.#slotCache.get(attr) || '';
112
- }
88
+ /* ── Lifecycle (see UXShadowElement) ──────────── */
113
89
 
114
- /* ── Lifecycle ─────────────────────────────────── */
115
-
116
- connectedCallback() {
117
- this.#cacheSlotValues();
90
+ _setup() {
118
91
  if (this.itemId && !this.id) this.id = this.itemId;
119
- if (this.hasAttribute('src')) {
120
- this._loadSrc(this.getAttribute('src'));
121
- }
122
- this.#render();
123
- this.setAttribute('data-upgraded', '');
124
92
  }
125
93
 
126
- disconnectedCallback() {
127
- this.removeAttribute('data-upgraded');
128
- }
129
-
130
- attributeChangedCallback(name, oldValue, newValue) {
131
- if (oldValue !== newValue && this.shadowRoot) {
132
- if (name === 'src' && this.isConnected) {
133
- this._loadSrc(newValue);
134
- } else {
135
- this.#render();
136
- }
137
- }
94
+ _readyDetail() {
95
+ return {
96
+ itemId: this.itemId,
97
+ title: this.itemTitle,
98
+ type: this.itemType,
99
+ priority: this.priority,
100
+ status: this.status,
101
+ };
138
102
  }
139
103
 
140
104
  /* ── Getters ───────────────────────────────────── */
141
105
 
142
106
  get itemId() { return this.getAttribute('item-id') || ''; }
143
- get itemTitle() {
144
- const slotted = this.querySelector('[slot="title"]');
145
- return slotted?.textContent?.trim() || this.#slotCache.get('title') || '';
146
- }
107
+ get itemTitle() { return this._slotText('title'); }
147
108
  get itemType() { return this.getAttribute('type') || 'task'; }
148
109
  get priority() { return this.getAttribute('priority') || 'medium'; }
149
110
  get status() { return this.getAttribute('status') || 'backlog'; }
150
111
  get estimate() { return this.getAttribute('estimate') || ''; }
151
112
  get assignee() { return this.getAttribute('assignee') || ''; }
152
113
 
153
- get storyIds() {
154
- const raw = this.getAttribute('story-ids') || '';
155
- return raw ? raw.split(',').map(s => s.trim()).filter(Boolean) : [];
156
- }
114
+ get storyIds() { return splitList(this.getAttribute('story-ids')); }
157
115
 
158
- get _detailLevel() {
159
- if (this.getAttribute('detail')) return this.getAttribute('detail');
160
- if (this.hasAttribute('compact')) return 'compact';
161
- return 'full';
162
- }
116
+ get _detailLevel() { return detailLevel(this); }
163
117
 
164
118
  get _minimalLabel() {
165
119
  return this.itemTitle || this.itemId || 'Work item';
@@ -168,7 +122,7 @@ class WorkItem extends HTMLElement {
168
122
  /* ── Public API ────────────────────────────────── */
169
123
 
170
124
  updateStatus(newStatus) {
171
- if (STATUSES[newStatus]) {
125
+ if (Object.hasOwn(STATUSES, newStatus)) {
172
126
  this.setAttribute('status', newStatus);
173
127
  this.dispatchEvent(new CustomEvent('work-item:status', {
174
128
  detail: { status: newStatus, itemId: this.itemId },
@@ -178,7 +132,7 @@ class WorkItem extends HTMLElement {
178
132
  }
179
133
 
180
134
  updatePriority(newPriority) {
181
- if (PRIORITIES[newPriority]) {
135
+ if (Object.hasOwn(PRIORITIES, newPriority)) {
182
136
  this.setAttribute('priority', newPriority);
183
137
  this.dispatchEvent(new CustomEvent('work-item:priority', {
184
138
  detail: { priority: newPriority, itemId: this.itemId },
@@ -213,18 +167,13 @@ class WorkItem extends HTMLElement {
213
167
  * Idempotent for repeat calls.
214
168
  */
215
169
  set data(value) {
216
- if (!value || typeof value !== 'object') return;
217
- this._applyData(value);
218
- if (this.shadowRoot) this.#render();
219
- this.dispatchEvent(new CustomEvent('work-item:data-changed', {
220
- detail: { data: this.data, source: 'property' },
221
- bubbles: true, composed: true,
222
- }));
170
+ this._setData(value);
223
171
  }
224
172
 
225
173
  /**
226
174
  * Apply a data record to attributes + slotted children. Used by both the
227
- * .data setter and async _loadSrc. No render or event emit — caller decides.
175
+ * .data setter and the src loader. Slotted children are created only when
176
+ * absent (authored content wins). No render or event emit — caller decides.
228
177
  * @param {Record<string, unknown>} data
229
178
  */
230
179
  _applyData(data) {
@@ -239,66 +188,39 @@ class WorkItem extends HTMLElement {
239
188
  this.setAttribute('story-ids', Array.isArray(data.storyIds) ? data.storyIds.join(',') : String(data.storyIds));
240
189
  }
241
190
 
242
- if (data.title && !this.querySelector('[slot="title"]')) {
243
- const el = document.createElement('h3');
244
- el.slot = 'title';
245
- el.textContent = String(data.title);
246
- this.appendChild(el);
247
- }
248
- for (const key of ['description', 'notes']) {
249
- if (data[key] && !this.querySelector(`[slot="${key}"]`)) {
250
- const el = document.createElement('p');
251
- el.slot = key;
252
- el.textContent = String(data[key]);
253
- this.appendChild(el);
254
- }
255
- }
256
- if (data.checklist && !this.querySelector('[slot="checklist"]')) {
257
- const ul = document.createElement('ul');
258
- ul.slot = 'checklist';
259
- const items = Array.isArray(data.checklist) ? data.checklist : [data.checklist];
260
- for (const item of items) {
261
- const li = document.createElement('li');
262
- li.textContent = String(item);
263
- ul.appendChild(li);
264
- }
265
- this.appendChild(ul);
266
- }
191
+ fillSlot(this, 'title', data.title, { tag: 'h3' });
192
+ fillSlot(this, 'description', data.description);
193
+ fillSlot(this, 'notes', data.notes);
194
+ fillSlot(this, 'checklist', data.checklist, { list: true });
267
195
  }
268
196
 
269
- /* ── JSON loading ──────────────────────────────── */
197
+ /* ── JSON loading states ───────────────────────── */
270
198
 
271
- async _loadSrc(url) {
272
- if (!url) return;
273
- const root = /** @type {ShadowRoot} */ (this.shadowRoot);
274
- root.innerHTML = `<style>${styles}</style><div class="state-msg">Loading\u2026</div>`;
275
- try {
276
- const res = await fetch(url);
277
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
278
- const data = await res.json();
279
- this._applyData(data);
280
- this.#render();
281
- } catch (err) {
282
- root.innerHTML = `<style>${styles}</style>` +
283
- `<div class="state-msg state-msg--error">Could not load: ${esc(/** @type {Error} */ (err).message)}</div>`;
284
- }
199
+ _renderLoading() {
200
+ /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML =
201
+ `<style>${styles}</style><div class="state-msg">Loading\u2026</div>`;
202
+ }
203
+
204
+ /** @param {Error} err */
205
+ _renderError(err) {
206
+ /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = `<style>${styles}</style>` +
207
+ `<div class="state-msg state-msg--error">Could not load: ${esc(err.message)}</div>`;
285
208
  }
286
209
 
287
210
  /* ── Render ────────────────────────────────────── */
288
211
 
289
- #render() {
212
+ _render() {
290
213
  const root = /** @type {ShadowRoot} */ (this.shadowRoot);
291
- const priorityInfo = PRIORITIES[this.priority] || PRIORITIES.medium;
292
- const statusInfo = STATUSES[this.status] || STATUSES.backlog;
214
+ const priorityInfo = lookup(PRIORITIES, this.priority, 'medium');
215
+ const statusInfo = lookup(STATUSES, this.status, 'backlog');
293
216
  const type = this.itemType;
294
- const typeIcon = TYPE_ICONS[type] || TYPE_ICONS.task;
217
+ const typeIcon = lookup(TYPE_ICONS, type, 'task');
295
218
  const level = this._detailLevel;
296
219
  const ariaLabel = this.itemId ? `Work item: ${esc(this.itemId)}` : 'Work item';
297
220
 
298
221
  if (level === 'minimal') {
299
222
  root.innerHTML = `<style>${styles}</style>
300
- <article class="wi-card wi-card--minimal" role="article" aria-label="${ariaLabel}"
301
- tabindex="0">
223
+ <article class="wi-card wi-card--minimal" role="article" aria-label="${ariaLabel}">
302
224
  <div class="wi-body">
303
225
  <div class="wi-meta">
304
226
  ${this.itemId ? `<span class="wi-id">${esc(this.itemId)}</span>` : ''}
@@ -312,6 +234,7 @@ class WorkItem extends HTMLElement {
312
234
  return;
313
235
  }
314
236
 
237
+
315
238
  root.innerHTML = `<style>${styles}</style>
316
239
  <article class="wi-card wi-card--${level}" role="article" aria-label="${ariaLabel}">
317
240
 
@@ -334,7 +257,7 @@ class WorkItem extends HTMLElement {
334
257
 
335
258
  ${this.assignee ? `
336
259
  <div class="wi-assignee">
337
- <span class="wi-assignee__avatar" style="background:${hashColor(this.assignee)}">${initials(this.assignee)}</span>
260
+ <span class="wi-assignee__avatar" style="background:${hashColor(this.assignee)}">${esc(initials(this.assignee))}</span>
338
261
  ${esc(this.assignee)}
339
262
  </div>` : ''}
340
263
 
@@ -386,26 +309,7 @@ class WorkItem extends HTMLElement {
386
309
 
387
310
  </article>`;
388
311
 
389
- // Compact: mark empty slot sections
390
- if (level === 'compact') {
391
- for (const section of root.querySelectorAll('.wi-section')) {
392
- const slot = section.querySelector('slot');
393
- if (slot && slot.assignedNodes().length === 0) {
394
- section.setAttribute('data-empty', '');
395
- }
396
- }
397
- }
398
-
399
- this.dispatchEvent(new CustomEvent('work-item:ready', {
400
- detail: {
401
- itemId: this.itemId,
402
- title: this.itemTitle,
403
- type,
404
- priority: this.priority,
405
- status: this.status,
406
- },
407
- bubbles: true, composed: true,
408
- }));
312
+ if (level === 'compact') markEmptySections(root, '.wi-section');
409
313
  }
410
314
  }
411
315