@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,15 +5,22 @@
5
5
  * sections for bio, goals, frustrations, and behaviors. Uses Shadow DOM
6
6
  * for encapsulation and VB design tokens for theming.
7
7
  *
8
- * @attr {string} name - Persona name (drives avatar initials and color)
8
+ * Name and quote come from the `name` / `quote` slots; the `name` / `quote`
9
+ * attributes are used when no slot child is present (a slot always wins).
10
+ *
11
+ * @attr {string} name - Persona name when there is no slot="name" child
9
12
  * @attr {string} role - Job title or role
10
13
  * @attr {string} age - Age display
11
14
  * @attr {string} location - Location display
12
15
  * @attr {string} avatar - Optional avatar image URL
13
- * @attr {string} quote - Key quote displayed in highlight block
16
+ * @attr {string} quote - Key quote when there is no slot="quote" child
14
17
  * @attr {boolean} compact - Reduced spacing variant
18
+ * @attr {string} src - URL to a JSON persona record (fetched once)
19
+ * @attr {boolean} data-list-stories - List <user-story persona-id> matching this id
15
20
  *
16
- * @fires persona-ready - After initial render, detail: { name, role }
21
+ * @fires persona-ready - Once, after the first render (after the src data renders when src is set), detail: { name, role }
22
+ * @fires user-persona:upgraded - Once, after first connect
23
+ * @fires user-persona:data-changed - After .data assignment, detail: { data, source: 'property' }
17
24
  *
18
25
  * @example
19
26
  * <user-persona name="Sarah Chen" role="Product Manager" age="34"
@@ -27,15 +34,11 @@
27
34
  */
28
35
  import { styles } from './styles.js';
29
36
  import { registerComponent } from '../../lib/bundle-registry.js';
30
- import { esc, initials, hashColor } from '../_ux-base.js';
37
+ import { cssUrl } from '../../lib/escape.js';
38
+ import { esc, initials, hashColor, UXShadowElement, fillSlot, readSlot, slottedChild } from '../_ux-base.js';
31
39
  import { portraitUrl } from '../../lib/portrait-url.js';
40
+ import { stringHash as hash } from '../../lib/hash.js';
32
41
 
33
- /** Simple string hash → integer (same algorithm as portrait-url.js). */
34
- function hash(str) {
35
- let h = 0;
36
- for (let i = 0; i < str.length; i++) h = str.charCodeAt(i) + ((h << 5) - h);
37
- return h;
38
- }
39
42
 
40
43
  // ── Mock persona corpus (~1 KB) ───────────────────────────────────────
41
44
  const MOCK_NAMES = [
@@ -82,20 +85,19 @@ const MOCK_BEHAVIORS = [
82
85
  'Bookmarks frequently used reports', 'Uses dark mode exclusively',
83
86
  ];
84
87
 
85
- class UserPersona extends HTMLElement {
88
+ /** Text sections that `.data` / `src` turn into slotted children. */
89
+ const SECTION_SLOTS = ['bio', 'goals', 'frustrations', 'behaviors'];
90
+
91
+ class UserPersona extends UXShadowElement {
92
+ static readyEvent = 'persona-ready';
93
+
86
94
  static get observedAttributes() {
87
- return ['role', 'age', 'location', 'avatar', 'compact', 'src', 'data-list-stories', 'id'];
95
+ return ['name', 'role', 'age', 'location', 'avatar', 'quote', 'compact', 'src', 'data-list-stories', 'id'];
88
96
  }
89
97
 
90
- #slotCache = new Map();
91
98
  /** @type {MutationObserver | null} */
92
99
  #storyObserver = null;
93
100
 
94
- constructor() {
95
- super();
96
- this.attachShadow({ mode: 'open' });
97
- }
98
-
99
101
  /**
100
102
  * Find every <user-story> in the document whose `persona-id` matches
101
103
  * this persona's id. Returns elements in document order.
@@ -108,24 +110,12 @@ class UserPersona extends HTMLElement {
108
110
  return Array.from(scope.querySelectorAll(`user-story[persona-id="${cssEscape(this.id)}"]:not([data-detail-clone])`));
109
111
  }
110
112
 
111
- #cacheSlotValues() {
112
- for (const child of [...this.children]) {
113
- const slotName = child.getAttribute('slot');
114
- if (slotName && !this.getAttribute(slotName)) {
115
- this.#slotCache.set(slotName, child.textContent.trim());
116
- }
117
- }
118
- }
119
-
120
- _resolve(attr) {
121
- return this.getAttribute(attr) || this.#slotCache.get(attr) || '';
122
- }
123
-
124
113
  /* ── Data API ──────────────────────────────────── */
125
114
 
126
115
  /**
127
116
  * Read the persona as a plain data object. Mirrors what a consumer
128
- * would assign to .data — useful for diffing or persistence.
117
+ * would assign to .data — useful for diffing or persistence. List
118
+ * sections (`<ul>`/`<ol>`) read back as string arrays, others as text.
129
119
  */
130
120
  get data() {
131
121
  return {
@@ -135,89 +125,67 @@ class UserPersona extends HTMLElement {
135
125
  location: this.location || undefined,
136
126
  avatar: this.avatar || undefined,
137
127
  quote: this.quote || undefined,
138
- bio: this.#slotCache.get('bio') || undefined,
139
- goals: this.#slotCache.get('goals') || undefined,
140
- frustrations: this.#slotCache.get('frustrations') || undefined,
141
- behaviors: this.#slotCache.get('behaviors') || undefined,
128
+ bio: readSlot(this, 'bio'),
129
+ goals: readSlot(this, 'goals'),
130
+ frustrations: readSlot(this, 'frustrations'),
131
+ behaviors: readSlot(this, 'behaviors'),
142
132
  };
143
133
  }
144
134
 
145
135
  /**
146
136
  * Set state attributes and slotted content from a plain object in one
147
- * call. Idempotent for repeat calls.
137
+ * call. Fields present in the object replace the current value (slotted
138
+ * children are updated in place); absent fields are left alone.
139
+ * Emits user-persona:data-changed { data, source: 'property' }.
148
140
  */
149
141
  set data(value) {
150
- if (!value || typeof value !== 'object') return;
151
- this._applyData(value);
152
- if (this.shadowRoot) this.#render();
153
- this.dispatchEvent(new CustomEvent('user-persona:data-changed', {
154
- detail: { data: this.data, source: 'property' },
155
- bubbles: true, composed: true,
156
- }));
142
+ this._setData(value);
157
143
  }
158
144
 
159
145
  /**
160
146
  * Apply a data record to attributes + slotted children. Used by both
161
- * the .data setter and async _loadSrc.
147
+ * the .data setter and the src loader. String fields become `<p>`
148
+ * children, arrays become `<ul>` lists; the name is an `<h2>`.
162
149
  * @param {Record<string, unknown>} data
163
150
  */
164
151
  _applyData(data) {
165
152
  for (const key of ['role', 'age', 'location', 'avatar']) {
166
- if (data[key]) this.setAttribute(key, String(data[key]));
167
- }
168
- if (data.name && !this.querySelector('[slot="name"]')) {
169
- const el = document.createElement('h2');
170
- el.slot = 'name';
171
- el.textContent = String(data.name);
172
- this.appendChild(el);
153
+ if (data[key] != null && data[key] !== '') this.setAttribute(key, String(data[key]));
173
154
  }
174
- if (data.quote && !this.querySelector('[slot="quote"]')) {
175
- const el = document.createElement('p');
176
- el.slot = 'quote';
177
- el.textContent = String(data.quote);
178
- this.appendChild(el);
179
- }
180
- for (const key of ['bio', 'goals', 'frustrations', 'behaviors']) {
181
- if (data[key]) this.#slotCache.set(key, String(data[key]));
155
+ fillSlot(this, 'name', data.name, { tag: 'h2', replace: true });
156
+ fillSlot(this, 'quote', data.quote, { tag: 'p', replace: true });
157
+ for (const key of SECTION_SLOTS) {
158
+ fillSlot(this, key, data[key], { tag: 'p', replace: true });
182
159
  }
183
160
  }
184
161
 
185
- async _loadSrc(url) {
186
- if (!url) return;
187
- try {
188
- const res = await fetch(url);
189
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
190
- const data = await res.json();
191
- this._applyData(data);
192
- this.#render();
193
- } catch (err) {
194
- console.warn(`[user-persona] Failed to load src="${url}":`, err);
195
- }
162
+ /* ── Lifecycle (see UXShadowElement) ──────────── */
163
+
164
+ _setup() {
165
+ if (this.hasAttribute('data-mock')) this.#applyMock();
196
166
  }
197
167
 
198
- connectedCallback() {
199
- this.#cacheSlotValues();
200
- if (this.hasAttribute('data-mock')) {
201
- this.#applyMock();
202
- } else if (this.hasAttribute('src')) {
203
- this._loadSrc(this.getAttribute('src'));
204
- }
205
- this.#render();
168
+ _connect() {
206
169
  this.#syncStoryObserver();
207
- this.setAttribute('data-upgraded', '');
208
- this.dispatchEvent(new CustomEvent('persona-ready', {
209
- bubbles: true,
210
- composed: true,
211
- detail: { name: this.personaName, role: this.personaRole }
212
- }));
170
+ // Stories may have been added or removed while this card was detached.
171
+ this.#renderStoriesSection();
213
172
  }
214
173
 
215
- disconnectedCallback() {
216
- this.removeAttribute('data-upgraded');
174
+ _disconnect() {
217
175
  this.#storyObserver?.disconnect();
218
176
  this.#storyObserver = null;
219
177
  }
220
178
 
179
+ /** @param {string} name */
180
+ _attributeEffects(name) {
181
+ // Also runs after a .data / src record sets one of these.
182
+ if (name === 'data-list-stories' || name === 'id') this.#syncStoryObserver();
183
+ }
184
+
185
+ _readyDetail() {
186
+ return { name: this.personaName, role: this.personaRole };
187
+ }
188
+
221
189
  /**
222
190
  * Watch the document for added/removed/relabeled <user-story> elements
223
191
  * so the auto-rendered Stories section stays current. Only runs when
@@ -260,7 +228,7 @@ class UserPersona extends HTMLElement {
260
228
  <div class="section-icon stories" aria-hidden="true">
261
229
  <svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
262
230
  </div>
263
- <span class="section-title">Stories <span class="section-count">${stories.length}</span></span>
231
+ <h3 class="section-title">Stories <span class="section-count">${stories.length}</span></h3>
264
232
  </div>
265
233
  <div class="section-content">
266
234
  ${stories.length === 0
@@ -285,24 +253,11 @@ class UserPersona extends HTMLElement {
285
253
  `;
286
254
  }
287
255
 
288
- attributeChangedCallback(name, oldValue, newValue) {
289
- if (oldValue === newValue || !this.shadowRoot) return;
290
- if (name === 'src' && this.isConnected) {
291
- this._loadSrc(newValue);
292
- return;
293
- }
294
- if (name === 'data-list-stories' || name === 'id') {
295
- this.#syncStoryObserver();
296
- }
297
- this.#render();
298
- }
299
-
300
256
  // ── Attribute getters ──────────────────────────────────────────────
301
257
 
302
- /** Read name from slotted heading or cache */
258
+ /** Name from the slotted heading, else the `name` attribute. */
303
259
  get personaName() {
304
- const slotted = this.querySelector('[slot="name"]');
305
- return slotted?.textContent?.trim() || this.#slotCache.get('name') || 'Unnamed Persona';
260
+ return this._slotText('name') || this.getAttribute('name')?.trim() || 'Unnamed Persona';
306
261
  }
307
262
 
308
263
  get personaRole() {
@@ -321,10 +276,9 @@ class UserPersona extends HTMLElement {
321
276
  return this.getAttribute('avatar') || '';
322
277
  }
323
278
 
324
- /** Read quote from slotted element or cache */
279
+ /** Quote from the slotted element, else the `quote` attribute. */
325
280
  get quote() {
326
- const slotted = this.querySelector('[slot="quote"]');
327
- return slotted?.textContent?.trim() || this.#slotCache.get('quote') || '';
281
+ return this._slotText('quote') || this.getAttribute('quote')?.trim() || '';
328
282
  }
329
283
 
330
284
  get compact() {
@@ -345,7 +299,7 @@ class UserPersona extends HTMLElement {
345
299
  };
346
300
 
347
301
  // Name and quote are slotted content, not attributes
348
- if (!this.querySelector('[slot="name"]')) {
302
+ if (!slottedChild(this, 'name') && !this.getAttribute('name')) {
349
303
  const h2 = document.createElement('h2');
350
304
  h2.slot = 'name';
351
305
  h2.textContent = pick(MOCK_NAMES);
@@ -355,10 +309,10 @@ class UserPersona extends HTMLElement {
355
309
  if (!this.getAttribute('age')) this.setAttribute('age', String(25 + ((hash(seed) % 30) + 30) % 30));
356
310
  if (!this.getAttribute('location')) this.setAttribute('location', pick(MOCK_LOCATIONS));
357
311
  if (!this.getAttribute('avatar')) {
358
- const name = this.querySelector('[slot="name"]')?.textContent?.trim() || 'Persona';
312
+ const name = this.personaName;
359
313
  this.setAttribute('avatar', portraitUrl(name, 256));
360
314
  }
361
- if (!this.querySelector('[slot="quote"]')) {
315
+ if (!slottedChild(this, 'quote') && !this.getAttribute('quote')) {
362
316
  const p = document.createElement('p');
363
317
  p.slot = 'quote';
364
318
  p.textContent = pick(MOCK_QUOTES);
@@ -367,7 +321,7 @@ class UserPersona extends HTMLElement {
367
321
 
368
322
  // Inject slotted sections as light DOM so native <slot> picks them up
369
323
  const injectList = (slotName, items) => {
370
- if (this.querySelector(`[slot="${slotName}"]`)) return;
324
+ if (slottedChild(this, slotName)) return;
371
325
  const ul = document.createElement('ul');
372
326
  ul.setAttribute('slot', slotName);
373
327
  for (const item of items) {
@@ -384,7 +338,7 @@ class UserPersona extends HTMLElement {
384
338
 
385
339
  // ── Render ─────────────────────────────────────────────────────────
386
340
 
387
- #render() {
341
+ _render() {
388
342
  const name = this.personaName;
389
343
  const role = this.personaRole;
390
344
  const age = this.age;
@@ -394,7 +348,7 @@ class UserPersona extends HTMLElement {
394
348
  const avatarBg = hashColor(name);
395
349
 
396
350
  const avatarStyle = avatarUrl
397
- ? `background:url(${esc(avatarUrl)}) center/cover`
351
+ ? `background:${cssUrl(avatarUrl)} center/cover`
398
352
  : `background:${avatarBg}`;
399
353
 
400
354
  /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = `
@@ -430,7 +384,7 @@ class UserPersona extends HTMLElement {
430
384
  </div>
431
385
  </header>
432
386
 
433
- ${quote || this.querySelector('[slot="quote"]') ? `
387
+ ${quote || slottedChild(this, 'quote') ? `
434
388
  <div class="persona-quote" part="quote">
435
389
  <span class="quote-mark" aria-hidden="true">&ldquo;</span>
436
390
  <div class="quote-text-wrap"><slot name="quote"><p class="quote-text">${esc(quote)}</p></slot></div>
@@ -443,7 +397,7 @@ class UserPersona extends HTMLElement {
443
397
  <div class="section-icon bio" aria-hidden="true">
444
398
  <svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
445
399
  </div>
446
- <span class="section-title">Background</span>
400
+ <h3 class="section-title">Background</h3>
447
401
  </div>
448
402
  <div class="section-content">
449
403
  <slot name="bio">No background information provided.</slot>
@@ -455,7 +409,7 @@ class UserPersona extends HTMLElement {
455
409
  <div class="section-icon goals" aria-hidden="true">
456
410
  <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
457
411
  </div>
458
- <span class="section-title">Goals</span>
412
+ <h3 class="section-title">Goals</h3>
459
413
  </div>
460
414
  <div class="section-content">
461
415
  <slot name="goals">No goals specified.</slot>
@@ -467,7 +421,7 @@ class UserPersona extends HTMLElement {
467
421
  <div class="section-icon frustrations" aria-hidden="true">
468
422
  <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
469
423
  </div>
470
- <span class="section-title">Frustrations</span>
424
+ <h3 class="section-title">Frustrations</h3>
471
425
  </div>
472
426
  <div class="section-content">
473
427
  <slot name="frustrations">No frustrations listed.</slot>
@@ -479,7 +433,7 @@ class UserPersona extends HTMLElement {
479
433
  <div class="section-icon behaviors" aria-hidden="true">
480
434
  <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>
481
435
  </div>
482
- <span class="section-title">Behaviors</span>
436
+ <h3 class="section-title">Behaviors</h3>
483
437
  </div>
484
438
  <div class="section-content">
485
439
  <slot name="behaviors">No behaviors documented.</slot>
@@ -247,6 +247,7 @@ const styles = `
247
247
  }
248
248
 
249
249
  .section-title {
250
+ margin: 0;
250
251
  font-size: var(--_font-sm);
251
252
  font-weight: 600;
252
253
  color: var(--_text);
@@ -8,28 +8,30 @@
8
8
  "permittedContent": ["@flow"]
9
9
  },
10
10
  "attributes": [
11
- { "name": "persona-id", "kind": "host-api", "purpose": "config", "type": "string", "description": "Links to a user-persona element by id" },
12
- { "name": "priority", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["critical", "high", "medium", "low"], "description": "Story priority level" },
13
- { "name": "status", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["backlog", "to-do", "in-progress", "review", "done"], "description": "Workflow status" },
14
- { "name": "points", "kind": "host-api", "purpose": "config", "type": "string", "description": "Story point estimate" },
11
+ { "name": "persona-id", "kind": "host-api", "purpose": "config", "type": "string", "description": "Links to a user-persona element by id. Without a persona slot, the \"As a…\" text uses that persona's role (live-updating)." },
12
+ { "name": "priority", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["critical", "high", "medium", "low"], "default": "medium", "description": "Story priority level" },
13
+ { "name": "status", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["backlog", "to-do", "in-progress", "review", "done"], "default": "backlog", "description": "Workflow status" },
14
+ { "name": "points", "kind": "host-api", "purpose": "config", "type": "string", "description": "Story point estimate (badge reads \"[n] story points\" to screen readers)" },
15
15
  { "name": "epic", "kind": "host-api", "purpose": "config", "type": "string", "description": "Parent epic label" },
16
- { "name": "story-id", "kind": "host-api", "purpose": "config", "type": "string", "description": "Ticket or story identifier, auto-sets HTML id" },
17
- { "name": "detail", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["full", "compact", "minimal"], "description": "Detail level" },
16
+ { "name": "story-id", "kind": "host-api", "purpose": "config", "type": "string", "description": "Ticket or story identifier; sets the HTML id on first connect when none is set" },
17
+ { "name": "detail", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["full", "compact", "minimal"], "default": "full", "description": "Detail level. minimal renders a button that opens the full story in a dialog." },
18
18
  { "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Alias for detail=compact" },
19
- { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON data" }
19
+ { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL of a JSON story record. Fetched once per value; a newer value aborts the older request." }
20
20
  ],
21
21
  "slots": [
22
22
  { "name": "persona", "description": "The 'As a...' role (e.g., <span>)" },
23
23
  { "name": "action", "description": "The 'I want...' description (e.g., <span>)" },
24
24
  { "name": "benefit", "description": "The 'so that...' outcome (e.g., <span>)" },
25
- { "name": "title", "description": "Short label for minimal mode (e.g., <h3>)" },
25
+ { "name": "title", "description": "Short label for minimal mode and the detail dialog heading (e.g., <h3>)" },
26
26
  { "name": "acceptance-criteria", "description": "Acceptance criteria checklist (e.g., <ul>)" },
27
27
  { "name": "tasks", "description": "Implementation task list (e.g., <ul>)" },
28
28
  { "name": "notes", "description": "Additional context (e.g., <p>)" }
29
29
  ],
30
30
  "events": [
31
- { "name": "story-ready", "detail": "{ id, persona, action, benefit, priority, status, points }", "description": "Fired after render" },
32
- { "name": "status-changed", "detail": "{ status, storyId }", "description": "Fired when updateStatus() is called" },
33
- { "name": "priority-changed", "detail": "{ priority, storyId }", "description": "Fired when updatePriority() is called" }
31
+ { "name": "story-ready", "detail": "{ id, persona, action, benefit, priority, status, points }", "description": "Once per element, after the first render. With src, after the fetched data renders (not fired if the fetch fails). Not re-fired by attribute changes or moves." },
32
+ { "name": "status-changed", "detail": "{ status, storyId }", "description": "Fired when updateStatus() is called with a known status" },
33
+ { "name": "priority-changed", "detail": "{ priority, storyId }", "description": "Fired when updatePriority() is called with a known priority" },
34
+ { "name": "user-story:upgraded", "detail": "", "description": "Once, in a microtask after first connect. Wait for this before assigning .data from a framework." },
35
+ { "name": "user-story:data-changed", "detail": "{ data, source: 'property' }", "description": "After .data is assigned" }
34
36
  ]
35
37
  }