@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
@@ -24,11 +24,17 @@
24
24
  * @attr {string} story-id - Ticket or story identifier
25
25
  * @attr {string} detail - full | compact | minimal
26
26
  * @attr {boolean} compact - Alias for detail="compact"
27
- * @attr {string} src - URL to JSON data
27
+ * @attr {string} src - URL to JSON data (fetched once per value)
28
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
29
+ * @fires story-ready - Once, after the first render (after the src data renders when src is set)
30
+ * @fires status-changed - Fired when updateStatus() is called
31
+ * @fires priority-changed - Fired when updatePriority() is called
32
+ * @fires user-story:upgraded - Once, after first connect
33
+ * @fires user-story:data-changed - After .data assignment, detail: { data, source: 'property' }
34
+ *
35
+ * Keyboard: a minimal card is a button (Enter / Space open the detail
36
+ * dialog). On a board (directly inside a <drag-surface>) the host is the
37
+ * single tab stop: Enter opens the dialog, Space grabs for reordering.
32
38
  *
33
39
  * @example
34
40
  * <user-story
@@ -49,9 +55,16 @@
49
55
 
50
56
  import { styles } from './styles.js';
51
57
  import { registerComponent } from '../../lib/bundle-registry.js';
52
- import { esc, lucideSvg, UX_ICONS } from '../_ux-base.js';
58
+ import {
59
+ esc, lucideSvg, UX_ICONS, UXShadowElement, PRIORITIES, STATUSES, lookup,
60
+ fillSlot, slottedChild, detailLevel, markEmptySections,
61
+ } from '../_ux-base.js';
62
+
63
+ let dialogSeq = 0;
64
+
65
+ class UserStory extends UXShadowElement {
66
+ static readyEvent = 'story-ready';
53
67
 
54
- class UserStory extends HTMLElement {
55
68
  static get observedAttributes() {
56
69
  return [
57
70
  'persona-id', 'priority', 'points',
@@ -59,42 +72,18 @@ class UserStory extends HTMLElement {
59
72
  ];
60
73
  }
61
74
 
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();
75
+ static PRIORITIES = PRIORITIES;
76
+ static STATUSES = STATUSES;
78
77
 
79
78
  /** @type {MutationObserver | null} */
80
79
  #personaObserver = null;
81
80
 
81
+ /** @type {HTMLDialogElement | null} */
82
+ #dialog = null;
83
+
82
84
  constructor() {
83
85
  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) || '';
86
+ this.addEventListener('keydown', this.#onBoardKeydown);
98
87
  }
99
88
 
100
89
  /* ── Data API ──────────────────────────────────── */
@@ -125,19 +114,12 @@ class UserStory extends HTMLElement {
125
114
  * Idempotent for repeat calls.
126
115
  */
127
116
  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
- }));
117
+ this._setData(value);
136
118
  }
137
119
 
138
120
  /**
139
121
  * 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.
122
+ * .data setter and the src loader. Slotted children are created only when absent (authored content wins). No render — caller decides.
141
123
  * @param {Record<string, unknown>} data
142
124
  */
143
125
  _applyData(data) {
@@ -148,92 +130,51 @@ class UserStory extends HTMLElement {
148
130
  if (data[jsonKey] != null) this.setAttribute(attr, String(data[jsonKey]));
149
131
  }
150
132
 
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
- }
133
+ fillSlot(this, 'persona', data.persona, { tag: 'span' });
134
+ fillSlot(this, 'action', data.action, { tag: 'span' });
135
+ fillSlot(this, 'benefit', data.benefit, { tag: 'span' });
136
+ fillSlot(this, 'title', data.title, { tag: 'h3' });
137
+ fillSlot(this, 'acceptance-criteria', data.acceptanceCriteria);
138
+ fillSlot(this, 'tasks', data.tasks);
139
+ fillSlot(this, 'notes', data.notes);
195
140
  }
196
141
 
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
- }
142
+ /* ── Lifecycle (see UXShadowElement) ──────────── */
210
143
 
211
- connectedCallback() {
212
- this.#cacheSlotValues();
144
+ _setup() {
213
145
  if (this.storyId && !this.id && !this.hasAttribute('data-detail-clone')) this.id = this.storyId;
214
- if (this.hasAttribute('src')) {
215
- this._loadSrc(this.getAttribute('src'));
216
- }
217
- this.#render();
146
+ }
147
+
148
+ _connect() {
218
149
  this.#observeLinkedPersona();
219
- this.setAttribute('data-upgraded', '');
150
+ // Moved onto / off a board: switch the minimal card's tab stop.
151
+ this.#syncBoardFocus();
220
152
  }
221
153
 
222
- disconnectedCallback() {
223
- this.removeAttribute('data-upgraded');
154
+ _disconnect() {
224
155
  this.#personaObserver?.disconnect();
225
156
  this.#personaObserver = null;
157
+ // A move (disconnect + connect in one task) keeps the open dialog;
158
+ // a real removal closes and removes it.
159
+ queueMicrotask(() => { if (!this.isConnected) this.#removeDialog(); });
226
160
  }
227
161
 
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
- }
162
+ /** @param {string} name */
163
+ _attributeEffects(name) {
164
+ // Also runs after a .data / src record sets persona-id.
165
+ if (name === 'persona-id') this.#observeLinkedPersona();
166
+ }
167
+
168
+ _readyDetail() {
169
+ return {
170
+ id: this.storyId,
171
+ persona: this.persona,
172
+ action: this.action,
173
+ benefit: this.benefit,
174
+ priority: this.priority,
175
+ status: this.status,
176
+ points: this.points,
177
+ };
237
178
  }
238
179
 
239
180
  /**
@@ -249,7 +190,7 @@ class UserStory extends HTMLElement {
249
190
  const scope = root && typeof root.getElementById === 'function' ? root : document;
250
191
  const persona = scope.getElementById(this.personaId);
251
192
  if (!persona || persona.tagName !== 'USER-PERSONA') return;
252
- this.#personaObserver = new MutationObserver(() => this.#render());
193
+ this.#personaObserver = new MutationObserver(() => this._render());
253
194
  this.#personaObserver.observe(persona, { attributes: true, attributeFilter: ['role'] });
254
195
  }
255
196
 
@@ -271,8 +212,7 @@ class UserStory extends HTMLElement {
271
212
 
272
213
  /** Persona text the author supplied directly via slot or .data (no link resolution). */
273
214
  #explicitPersona() {
274
- const slotted = this.querySelector('[slot="persona"]');
275
- return slotted?.textContent?.trim() || this.#slotCache.get('persona') || '';
215
+ return this._slotText('persona');
276
216
  }
277
217
 
278
218
  /**
@@ -302,14 +242,12 @@ class UserStory extends HTMLElement {
302
242
 
303
243
  /** Read action from slotted element or cache */
304
244
  get action() {
305
- const slotted = this.querySelector('[slot="action"]');
306
- return slotted?.textContent?.trim() || this.#slotCache.get('action') || '';
245
+ return this._slotText('action');
307
246
  }
308
247
 
309
248
  /** Read benefit from slotted element or cache */
310
249
  get benefit() {
311
- const slotted = this.querySelector('[slot="benefit"]');
312
- return slotted?.textContent?.trim() || this.#slotCache.get('benefit') || '';
250
+ return this._slotText('benefit');
313
251
  }
314
252
 
315
253
  get priority() {
@@ -334,8 +272,7 @@ class UserStory extends HTMLElement {
334
272
 
335
273
  /** Read title from slotted heading or cache */
336
274
  get storyTitle() {
337
- const slotted = this.querySelector('[slot="title"]');
338
- return slotted?.textContent?.trim() || this.#slotCache.get('title') || '';
275
+ return this._slotText('title');
339
276
  }
340
277
 
341
278
  /** Label for minimal mode: title if set, otherwise truncated action */
@@ -350,15 +287,13 @@ class UserStory extends HTMLElement {
350
287
  }
351
288
 
352
289
  get _detailLevel() {
353
- if (this.getAttribute('detail')) return this.getAttribute('detail');
354
- if (this.hasAttribute('compact')) return 'compact';
355
- return 'full';
290
+ return detailLevel(this);
356
291
  }
357
292
 
358
293
  // ── Public API ───────────────────────────────────────────────────
359
294
 
360
295
  updateStatus(newStatus) {
361
- if (UserStory.STATUSES[newStatus]) {
296
+ if (Object.hasOwn(STATUSES, newStatus)) {
362
297
  this.setAttribute('status', newStatus);
363
298
  this.dispatchEvent(new CustomEvent('status-changed', {
364
299
  detail: { status: newStatus, storyId: this.storyId },
@@ -369,7 +304,7 @@ class UserStory extends HTMLElement {
369
304
  }
370
305
 
371
306
  updatePriority(newPriority) {
372
- if (UserStory.PRIORITIES[newPriority]) {
307
+ if (Object.hasOwn(PRIORITIES, newPriority)) {
373
308
  this.setAttribute('priority', newPriority);
374
309
  this.dispatchEvent(new CustomEvent('priority-changed', {
375
310
  detail: { priority: newPriority, storyId: this.storyId },
@@ -379,21 +314,24 @@ class UserStory extends HTMLElement {
379
314
  }
380
315
  }
381
316
 
317
+ /**
318
+ * Open a modal <dialog> showing this story at full detail. Each story
319
+ * owns at most one dialog; it is removed from the document when it
320
+ * closes or when the story is removed.
321
+ */
382
322
  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
- }
323
+ this.#removeDialog();
324
+ const dialog = document.createElement('dialog');
325
+ dialog.id = this.#dialogId();
326
+ dialog.setAttribute('data-size', 'l');
391
327
 
392
328
  const form = document.createElement('form');
393
329
  form.method = 'dialog';
394
330
  const header = document.createElement('header');
395
- const title = document.createElement('h3');
331
+ const title = document.createElement('h2');
332
+ title.id = `${dialog.id}-title`;
396
333
  title.textContent = this.storyTitle || this.storyId || 'Story Detail';
334
+ dialog.setAttribute('aria-labelledby', title.id);
397
335
  const closeBtn = document.createElement('button');
398
336
  closeBtn.type = 'submit';
399
337
  closeBtn.setAttribute('aria-label', 'Close');
@@ -407,29 +345,83 @@ class UserStory extends HTMLElement {
407
345
  if (attr === 'detail' || attr === 'compact' || attr === 'data-upgraded' || attr === 'draggable' || attr === 'data-id' || attr === 'data-quadrant') continue;
408
346
  full.setAttribute(attr, this.getAttribute(attr) ?? '');
409
347
  }
410
- const hasSlottedContent = [...this.children].some(c => c.getAttribute('slot') && c.tagName !== 'DIALOG');
348
+ const hasSlottedContent = [...this.children].some(c => c.getAttribute('slot'));
411
349
  full.setAttribute('detail', hasSlottedContent ? 'full' : 'compact');
412
350
  full.removeAttribute('id');
413
351
  full.setAttribute('data-detail-clone', ''); // keep the clone out of id/persona lookups
414
- for (const child of [...this.children]) {
415
- if (child.tagName === 'DIALOG') continue;
416
- full.appendChild(child.cloneNode(true));
417
- }
352
+ for (const child of [...this.children]) full.appendChild(child.cloneNode(true));
418
353
  section.appendChild(full);
419
354
 
420
355
  form.appendChild(header);
421
356
  form.appendChild(section);
422
- dialog.innerHTML = '';
423
357
  dialog.appendChild(form);
424
- dialog.addEventListener('close', () => { dialog.innerHTML = ''; }, { once: true });
358
+ dialog.addEventListener('close', () => {
359
+ if (this.#dialog === dialog) this.#dialog = null;
360
+ dialog.remove();
361
+ }, { once: true });
362
+ document.body.appendChild(dialog);
363
+ this.#dialog = dialog;
425
364
  dialog.showModal();
426
365
  }
427
366
 
367
+ /** `story-dialog-<story-id>`, made unique if another dialog already uses it. */
368
+ #dialogId() {
369
+ const base = `story-dialog-${this.storyId || this.id || 'story'}`;
370
+ let id = base;
371
+ while (document.getElementById(id)) id = `${base}-${++dialogSeq}`;
372
+ return id;
373
+ }
374
+
375
+ #removeDialog() {
376
+ const dialog = this.#dialog;
377
+ if (!dialog) return;
378
+ this.#dialog = null;
379
+ if (dialog.open) dialog.close();
380
+ dialog.remove();
381
+ }
382
+
383
+ // ── Board keyboard model ─────────────────────────────────────────
384
+
385
+ /**
386
+ * True when this card is a direct child of a <drag-surface> (kanban-board,
387
+ * story-map, impact-effort), which makes the host its focusable,
388
+ * keyboard-reorderable list item.
389
+ */
390
+ get #onBoard() {
391
+ return this.parentElement?.localName === 'drag-surface';
392
+ }
393
+
394
+ /**
395
+ * On a board the host (tabindex 0, set by drag-surface) is the card's
396
+ * only tab stop, so the minimal card's inner button leaves the tab order.
397
+ * Off a board the button is the tab stop and works as a normal button.
398
+ */
399
+ #syncBoardFocus() {
400
+ const button = /** @type {HTMLButtonElement | null | undefined} */ (this.shadowRoot?.querySelector('.story-card--minimal'));
401
+ if (button) button.tabIndex = this.#onBoard ? -1 : 0;
402
+ }
403
+
404
+ /**
405
+ * Board keyboard model for a minimal card: Enter opens the detail
406
+ * dialog; Space (drag-surface's grab key) grabs and drops; while grabbed,
407
+ * Enter drops and arrows move. Runs on the host, before the drag-surface's
408
+ * own handler, and stops Enter there so it does not start a grab.
409
+ * @param {KeyboardEvent} e
410
+ */
411
+ #onBoardKeydown = (e) => {
412
+ if (e.key !== 'Enter' || e.defaultPrevented || !this.#onBoard) return;
413
+ if (this._detailLevel !== 'minimal' || this.getAttribute('aria-grabbed') === 'true') return;
414
+ if (!this.shadowRoot?.querySelector('.story-card--minimal')) return;
415
+ e.preventDefault(); // the inner button must not also click
416
+ e.stopPropagation(); // keep drag-surface from starting a grab
417
+ this.showDetail();
418
+ };
419
+
428
420
  // ── Private ──────────────────────────────────────────────────────
429
421
 
430
- #render() {
431
- const priorityInfo = UserStory.PRIORITIES[this.priority] || UserStory.PRIORITIES.medium;
432
- const statusInfo = UserStory.STATUSES[this.status] || UserStory.STATUSES.backlog;
422
+ _render() {
423
+ const priorityInfo = lookup(PRIORITIES, this.priority, 'medium');
424
+ const statusInfo = lookup(STATUSES, this.status, 'backlog');
433
425
  const level = this._detailLevel;
434
426
  const ariaLabel = this.storyId
435
427
  ? `User story: ${esc(this.storyId)}`
@@ -437,25 +429,24 @@ class UserStory extends HTMLElement {
437
429
 
438
430
  if (!this.shadowRoot) return;
439
431
  if (level === 'minimal') {
432
+ const label = this._minimalLabel || 'Untitled story';
433
+ const buttonLabel = this.storyId ? `${this.storyId}: ${label}` : label;
434
+ // A real button: focusable, Enter/Space activate it natively, and
435
+ // aria-haspopup tells screen readers it opens a dialog.
440
436
  this.shadowRoot.innerHTML = `
441
437
  <style>${styles}</style>
442
- <article class="story-card story-card--minimal" role="article" aria-label="${ariaLabel}"
443
- tabindex="0">
444
- <div class="story-body">
438
+ <button type="button" class="story-card story-card--minimal" part="card"
439
+ aria-haspopup="dialog" aria-label="${esc(buttonLabel)}">
440
+ <span class="story-body">
445
441
  ${this.storyId ? `<span class="story-id">${esc(this.storyId)}</span>` : ''}
446
- <div class="story-title-wrap">
442
+ <span class="story-title-wrap">
447
443
  <slot name="title"><span class="story-title-fallback">${esc(this._minimalLabel || '[describe the action]')}</span></slot>
448
- </div>
449
- </div>
450
- </article>
444
+ </span>
445
+ </span>
446
+ </button>
451
447
  `;
452
- const card = this.shadowRoot.querySelector('.story-card--minimal');
453
- if (!card) return;
454
- card.addEventListener('click', () => this.showDetail());
455
- card.addEventListener('keydown', (/** @type {Event} */ e) => {
456
- const ke = /** @type {KeyboardEvent} */ (e);
457
- if (ke.key === 'Enter' || ke.key === ' ') { ke.preventDefault(); this.showDetail(); }
458
- });
448
+ this.shadowRoot.querySelector('.story-card--minimal')?.addEventListener('click', () => this.showDetail());
449
+ this.#syncBoardFocus();
459
450
  } else {
460
451
  this.shadowRoot.innerHTML = `
461
452
  <style>${styles}</style>
@@ -473,7 +464,7 @@ class UserStory extends HTMLElement {
473
464
  <span class="status-badge" part="status"
474
465
  style="color: ${statusInfo.color}; background: ${statusInfo.bg};"
475
466
  >${esc(statusInfo.label)}</span>
476
- ${this.points ? `<span class="points-badge" part="points">${esc(this.points)}</span>` : ''}
467
+ ${this.points ? `<span class="points-badge" part="points">${esc(this.points)}<span class="visually-hidden"> story points</span></span>` : ''}
477
468
  </div>
478
469
  </header>
479
470
 
@@ -484,7 +475,7 @@ class UserStory extends HTMLElement {
484
475
  ? `<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>`
485
476
  : `<span class="persona-text"><slot name="persona"><span>user</span></slot></span>`},
486
477
  <span class="keyword">I want</span>
487
- <span class="action-text"><slot name="action"><span>[describe the action]</span></slot></span>${this.benefit || this.querySelector('[slot="benefit"]') ? `
478
+ <span class="action-text"><slot name="action"><span>[describe the action]</span></slot></span>${this.benefit || slottedChild(this, 'benefit') ? `
488
479
  <span class="keyword">so that</span>
489
480
  <span class="benefit-text"><slot name="benefit"></slot></span>` : ''}
490
481
  </p>
@@ -496,7 +487,7 @@ class UserStory extends HTMLElement {
496
487
  <div class="section-icon acceptance">
497
488
  <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>
498
489
  </div>
499
- <span class="section-title">Acceptance Criteria</span>
490
+ <h3 class="section-title">Acceptance Criteria</h3>
500
491
  </div>
501
492
  <div class="section-content">
502
493
  <slot name="acceptance-criteria">
@@ -510,7 +501,7 @@ class UserStory extends HTMLElement {
510
501
  <div class="section-icon tasks">
511
502
  <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>
512
503
  </div>
513
- <span class="section-title">Tasks</span>
504
+ <h3 class="section-title">Tasks</h3>
514
505
  </div>
515
506
  <div class="section-content">
516
507
  <slot name="tasks">
@@ -524,7 +515,7 @@ class UserStory extends HTMLElement {
524
515
  <div class="section-icon notes">
525
516
  <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>
526
517
  </div>
527
- <span class="section-title">Notes</span>
518
+ <h3 class="section-title">Notes</h3>
528
519
  </div>
529
520
  <div class="section-content">
530
521
  <slot name="notes">
@@ -536,30 +527,8 @@ class UserStory extends HTMLElement {
536
527
  </article>
537
528
  `;
538
529
 
539
- if (level === 'compact') {
540
- const sections = this.shadowRoot.querySelectorAll('.section');
541
- sections.forEach(section => {
542
- const slot = section.querySelector('slot');
543
- if (slot && slot.assignedNodes().length === 0) {
544
- section.setAttribute('data-empty', '');
545
- }
546
- });
547
- }
530
+ if (level === 'compact') markEmptySections(this.shadowRoot, '.section');
548
531
  }
549
-
550
- this.dispatchEvent(new CustomEvent('story-ready', {
551
- detail: {
552
- id: this.storyId,
553
- persona: this.persona,
554
- action: this.action,
555
- benefit: this.benefit,
556
- priority: this.priority,
557
- status: this.status,
558
- points: this.points
559
- },
560
- bubbles: true,
561
- composed: true
562
- }));
563
532
  }
564
533
  }
565
534
 
@@ -210,6 +210,7 @@ const styles = `
210
210
  .section-icon.tasks { background: var(--color-accent, #8b5cf6); }
211
211
 
212
212
  .section-title {
213
+ margin: 0;
213
214
  font-size: var(--_font-xs);
214
215
  font-weight: 600;
215
216
  color: var(--_muted);
@@ -274,13 +275,39 @@ const styles = `
274
275
  margin: 0 !important;
275
276
  }
276
277
 
278
+ .visually-hidden {
279
+ position: absolute;
280
+ inline-size: 1px;
281
+ block-size: 1px;
282
+ padding: 0;
283
+ margin: -1px;
284
+ overflow: hidden;
285
+ clip-path: inset(50%);
286
+ white-space: nowrap;
287
+ border: 0;
288
+ }
289
+
277
290
  .story-title-fallback {
278
291
  font-size: var(--_font-sm);
279
292
  font-weight: 600;
280
293
  color: var(--_text);
281
294
  }
282
295
 
283
- /* Minimal detail level */
296
+ /* Minimal detail level — the card is a <button> that opens the detail dialog */
297
+ button.story-card--minimal {
298
+ display: block;
299
+ inline-size: 100%;
300
+ margin: 0;
301
+ font: inherit;
302
+ color: inherit;
303
+ text-align: start;
304
+ }
305
+
306
+ .story-card--minimal .story-body,
307
+ .story-card--minimal .story-title-wrap {
308
+ display: block;
309
+ }
310
+
284
311
  :host([detail="minimal"]) .story-card {
285
312
  padding: var(--_space-xs);
286
313
  }
@@ -20,13 +20,13 @@ Part of the **UX Planning Pack**. Works as a child of `<kanban-board>`, `<story-
20
20
  ```html
21
21
  <work-item
22
22
  item-id="PROJ-42"
23
- title="Implement search API"
24
23
  type="task"
25
24
  status="in-progress"
26
25
  priority="high"
27
26
  assignee="Sarah Chen"
28
27
  estimate="5"
29
28
  story-ids="PROJ-101,PROJ-102">
29
+ <h3 slot="title">Implement search API</h3>
30
30
  <p slot="description">Build the search endpoint.</p>
31
31
  <ul slot="checklist">
32
32
  <li>Schema design</li>
@@ -41,7 +41,6 @@ Part of the **UX Planning Pack**. Works as a child of `<kanban-board>`, `<story-
41
41
  | Attribute | Type | Default | Description |
42
42
  |-----------|------|---------|-------------|
43
43
  | `item-id` | string | — | Unique identifier (also sets HTML `id`) |
44
- | `title` | string | — | Work item title |
45
44
  | `type` | string | `"task"` | `task`, `bug`, `chore`, `spike`, `feature` |
46
45
  | `priority` | string | `"medium"` | `critical`, `high`, `medium`, `low` |
47
46
  | `status` | string | `"backlog"` | `backlog`, `to-do`, `in-progress`, `review`, `done`, `blocked` |
@@ -56,6 +55,7 @@ Part of the **UX Planning Pack**. Works as a child of `<kanban-board>`, `<story-
56
55
 
57
56
  | Slot | Description |
58
57
  |------|-------------|
58
+ | `title` | Work item heading (e.g. `<h3>`); there is no `title` attribute |
59
59
  | `description` | Work item description |
60
60
  | `checklist` | Subtasks or checklist |
61
61
  | `notes` | Additional notes |
@@ -64,7 +64,7 @@ Part of the **UX Planning Pack**. Works as a child of `<kanban-board>`, `<story-
64
64
 
65
65
  | Event | Detail |
66
66
  |-------|--------|
67
- | `work-item:ready` | `{ itemId, title, type, priority, status }` |
67
+ | `work-item:ready` | `{ itemId, title, type, priority, status }` (once, after the first render) |
68
68
  | `work-item:status` | `{ status, itemId }` |
69
69
  | `work-item:priority` | `{ priority, itemId }` |
70
70