@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
@@ -27,12 +27,22 @@
27
27
  * }
28
28
  * ]
29
29
  * }
30
+ *
31
+ * Title and summary are content: `<h2 slot="title">`, `<p slot="summary">`
32
+ * (or the `title` / `summary` JSON fields, which create those children).
33
+ *
34
+ * @fires journey-ready - Once, after the first render (after the src data renders when src is set), detail: { title, persona, phaseCount }
35
+ * @fires user-journey:upgraded - Once, after first connect
36
+ * @fires user-journey:data-changed - After .data assignment, detail: { data, source: 'property' }
37
+ * @fires user-journey:phases-changed - After .phases assignment, detail: { phases, source: 'property' }
30
38
  */
31
39
 
32
40
  import { styles } from './styles.js';
33
41
  import { registerComponent } from '../../lib/bundle-registry.js';
34
42
  import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
35
- import { esc, EMOTION_META, lucideSvg, UX_ICONS } from '../_ux-base.js';
43
+ import {
44
+ esc, emotionMeta, lucideSvg, UX_ICONS, UXShadowElement, fillSlot, slottedChild, splitList, asList, fragmentHref,
45
+ } from '../_ux-base.js';
36
46
 
37
47
  const ROWS = [
38
48
  { key: 'actions', label: 'Actions' },
@@ -42,18 +52,15 @@ const ROWS = [
42
52
  { key: 'opportunities', label: 'Opportunities' },
43
53
  ];
44
54
 
45
- class UserJourney extends HTMLElement {
55
+ class UserJourney extends UXShadowElement {
56
+ static readyEvent = 'journey-ready';
57
+
46
58
  static get observedAttributes() {
47
59
  return ['src', 'persona', 'persona-id', 'story-ids', 'compact'];
48
60
  }
49
61
 
50
- #slotCache = new Map();
51
-
52
- constructor() {
53
- super();
54
- this.attachShadow({ mode: 'open' });
55
- this.__phases = null;
56
- }
62
+ /** @type {Array<Record<string, any>> | null} */
63
+ __phases = null;
57
64
 
58
65
  /** @returns {Array|null} */
59
66
  get phases() { return this.__phases; }
@@ -66,7 +73,7 @@ class UserJourney extends HTMLElement {
66
73
  set phases(data) {
67
74
  if (this.__phases === data) return;
68
75
  this.__phases = data;
69
- if (this.isConnected) this._render();
76
+ if (this._isSetUp) this._render();
70
77
  this.dispatchEvent(new CustomEvent('user-journey:phases-changed', {
71
78
  detail: { phases: data, source: 'property' },
72
79
  bubbles: true, composed: true,
@@ -82,8 +89,8 @@ class UserJourney extends HTMLElement {
82
89
  return {
83
90
  persona: this.getAttribute('persona') || undefined,
84
91
  personaId: this.getAttribute('persona-id') || undefined,
85
- title: this.querySelector('[slot="title"]')?.textContent?.trim() || undefined,
86
- summary: this.querySelector('[slot="summary"]')?.textContent?.trim() || undefined,
92
+ title: slottedChild(this, 'title')?.textContent?.trim() || undefined,
93
+ summary: slottedChild(this, 'summary')?.textContent?.trim() || undefined,
87
94
  phases: this.__phases || undefined,
88
95
  };
89
96
  }
@@ -93,98 +100,56 @@ class UserJourney extends HTMLElement {
93
100
  * Emits user-journey:data-changed { data, source: 'property' }.
94
101
  */
95
102
  set data(value) {
96
- if (!value || typeof value !== 'object') return;
97
- if (value.persona) this.setAttribute('persona', String(value.persona));
98
- if (value.personaId) this.setAttribute('persona-id', String(value.personaId));
99
- if (value.title && !this.querySelector('[slot="title"]')) {
100
- const el = document.createElement('h2');
101
- el.slot = 'title';
102
- el.textContent = value.title;
103
- this.appendChild(el);
104
- }
105
- if (value.summary && !this.querySelector('[slot="summary"]')) {
106
- const el = document.createElement('p');
107
- el.slot = 'summary';
108
- el.textContent = value.summary;
109
- this.appendChild(el);
110
- }
111
- if (value.phases != null) this.__phases = value.phases;
112
- if (this.isConnected) this._render();
113
- this.dispatchEvent(new CustomEvent('user-journey:data-changed', {
114
- detail: { data: this.data, source: 'property' },
115
- bubbles: true, composed: true,
116
- }));
103
+ this._setData(value);
117
104
  }
118
105
 
119
- /* ── Slot caching ──────────────────────────── */
106
+ /**
107
+ * Apply a journey record (from .data or src). Title / summary create
108
+ * slotted children only when none are authored.
109
+ * @param {Record<string, any>} data
110
+ */
111
+ _applyData(data) {
112
+ if (data.persona) this.setAttribute('persona', String(data.persona));
113
+ if (data.personaId) this.setAttribute('persona-id', String(data.personaId));
114
+ fillSlot(this, 'title', data.title, { tag: 'h2' });
115
+ fillSlot(this, 'summary', data.summary);
116
+ if (data.phases != null) this.__phases = data.phases;
117
+ }
120
118
 
121
- #cacheSlotValues() {
122
- for (const child of this.children) {
123
- const slot = child.getAttribute('slot');
124
- if (slot) this.#slotCache.set(slot, child.textContent.trim());
125
- }
119
+ _readyDetail() {
120
+ const phases = this.__phases;
121
+ return {
122
+ title: this._slotText('title'),
123
+ persona: this._resolve('persona'),
124
+ phaseCount: Array.isArray(phases) ? phases.length : 0,
125
+ };
126
126
  }
127
127
 
128
+ /**
129
+ * Attribute value, else the text of a same-named light-DOM child (e.g.
130
+ * `<span slot="persona">`, read as data; it is not projected).
131
+ * @param {string} attr
132
+ */
128
133
  _resolve(attr) {
129
- return this.getAttribute(attr) || this.#slotCache.get(attr) || '';
134
+ return this.getAttribute(attr) || this._slotCache.get(attr) || '';
130
135
  }
131
136
 
132
- /* ── Lifecycle ─────────────────────────────── */
133
-
134
- connectedCallback() {
135
- this.#cacheSlotValues();
136
- this.setAttribute('data-upgraded', '');
137
- if (this.hasAttribute('src')) {
138
- this._loadSrc(this.getAttribute('src'));
139
- } else {
140
- this._render();
141
- }
137
+ /** Journey title: the slotted title's text, or the default heading. */
138
+ #title() {
139
+ return this._slotText('title') || 'User Journey';
142
140
  }
143
141
 
144
- disconnectedCallback() {
145
- this.removeAttribute('data-upgraded');
146
- }
142
+ /* ── JSON loading states ───────────────────── */
147
143
 
148
- attributeChangedCallback(name) {
149
- if (!this.isConnected) return;
150
- if (name === 'src') {
151
- this._loadSrc(this.getAttribute('src'));
152
- } else {
153
- this._render();
154
- }
144
+ _renderLoading() {
145
+ /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = `<style>${styles}</style>` +
146
+ `<div class="state-msg">Loading\u2026</div>`;
155
147
  }
156
148
 
157
- /* ── JSON loading ──────────────────────────── */
158
-
159
- async _loadSrc(src) {
160
- if (!src) return;
161
- const root = /** @type {ShadowRoot} */ (this.shadowRoot);
162
- root.innerHTML = `<style>${styles}</style>` +
163
- `<div class="state-msg">Loading\u2026</div>`;
164
- try {
165
- const res = await fetch(src);
166
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
167
- const data = await res.json();
168
- if (data.persona) this.setAttribute('persona', data.persona);
169
- if (data.personaId) this.setAttribute('persona-id', data.personaId);
170
- if (data.title && !this.querySelector('[slot="title"]')) {
171
- const el = document.createElement('h2');
172
- el.slot = 'title';
173
- el.textContent = data.title;
174
- this.appendChild(el);
175
- }
176
- if (data.summary && !this.querySelector('[slot="summary"]')) {
177
- const el = document.createElement('p');
178
- el.slot = 'summary';
179
- el.textContent = data.summary;
180
- this.appendChild(el);
181
- }
182
- this.__phases = data.phases || [];
183
- this._render();
184
- } catch (err) {
185
- root.innerHTML = `<style>${styles}</style>` +
186
- `<div class="state-msg state-msg--error">Could not load journey: ${esc(/** @type {Error} */ (err).message)}</div>`;
187
- }
149
+ /** @param {Error} err */
150
+ _renderError(err) {
151
+ /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = `<style>${styles}</style>` +
152
+ `<div class="state-msg state-msg--error">Could not load journey: ${esc(err.message)}</div>`;
188
153
  }
189
154
 
190
155
  /* ── Render ────────────────────────────────── */
@@ -192,13 +157,10 @@ class UserJourney extends HTMLElement {
192
157
  _render() {
193
158
  const persona = this._resolve('persona') || '';
194
159
  const personaId = this._resolve('persona-id') || '';
195
- const storyIds = (this.getAttribute('story-ids') || '')
196
- .split(',').map(s => s.trim()).filter(Boolean);
160
+ const storyIds = splitList(this.getAttribute('story-ids'));
197
161
  const compact = this.hasAttribute('compact');
198
- const phases = this.__phases;
199
- const hasSummary = !!this.querySelector('[slot="summary"]') || this.#slotCache.has('summary');
200
- const title = this.querySelector('[slot="title"]')?.textContent?.trim()
201
- || this.#slotCache.get('title') || '';
162
+ const phases = Array.isArray(this.__phases) ? this.__phases : null;
163
+ const hasSummary = !!slottedChild(this, 'summary') || this._slotCache.has('summary');
202
164
 
203
165
  const html = `<style>${styles}</style>
204
166
  <article class="journey${compact ? ' journey--compact' : ''}">
@@ -208,13 +170,13 @@ class UserJourney extends HTMLElement {
208
170
  <div class="journey__chips">
209
171
  <span class="chip chip--type">Journey Map</span>
210
172
  ${storyIds.map(id =>
211
- `<a class="chip chip--story" href="#${id}">${this._label(id)}</a>`
173
+ `<a class="chip chip--story" href="${fragmentHref(id)}">${esc(this._label(id))}</a>`
212
174
  ).join('')}
213
175
  </div>
214
176
  ${persona ? `
215
177
  <div class="journey__persona">
216
178
  ${personaId
217
- ? `<a class="persona-ref" href="#${personaId}">${lucideSvg(UX_ICONS.user)} ${esc(persona)}</a>`
179
+ ? `<a class="persona-ref" href="${fragmentHref(personaId)}">${lucideSvg(UX_ICONS.user)} ${esc(persona)}</a>`
218
180
  : `<span class="persona-ref">${lucideSvg(UX_ICONS.user)} ${esc(persona)}</span>`}
219
181
  </div>` : ''}
220
182
  </div>
@@ -244,17 +206,6 @@ class UserJourney extends HTMLElement {
244
206
  } else {
245
207
  swap();
246
208
  }
247
-
248
- /* Dispatch ready event */
249
- this.dispatchEvent(new CustomEvent('journey-ready', {
250
- bubbles: true,
251
- composed: true,
252
- detail: {
253
- title,
254
- persona,
255
- phaseCount: phases ? phases.length : 0,
256
- },
257
- }));
258
209
  }
259
210
 
260
211
  /* ── SVG curve ─────────────────────────────── */
@@ -265,10 +216,7 @@ class UserJourney extends HTMLElement {
265
216
  const n = phases.length;
266
217
 
267
218
  const toX = i => PX + (n < 2 ? uw / 2 : (i / (n - 1)) * uw);
268
- const toY = ph => {
269
- const m = EMOTION_META[ph.emotion] || EMOTION_META.neutral;
270
- return PY + (1 - m.score) * uh;
271
- };
219
+ const toY = ph => PY + (1 - emotionMeta(ph?.emotion).score) * uh;
272
220
 
273
221
  const pts = phases.map((ph, i) => ({ x: toX(i), y: toY(ph), ph }));
274
222
 
@@ -296,10 +244,9 @@ class UserJourney extends HTMLElement {
296
244
  ${pts.map(({x}) => `<line x1="${x}" y1="0" x2="${x}" y2="${H}" class="vline"/>`).join('')}
297
245
  <path d="${d} L ${last.x},${H} L ${pts[0].x},${H} Z" fill="url(#${uid})"/>
298
246
  <path d="${d}" fill="none" class="curve-line"/>
299
- ${pts.map(({x, y, ph}) => {
300
- const m = EMOTION_META[ph.emotion] || EMOTION_META.neutral;
301
- return `<circle cx="${x}" cy="${y}" r="5" class="dot" style="fill:${m.color}"/>`;
302
- }).join('')}
247
+ ${pts.map(({x, y, ph}) =>
248
+ `<circle cx="${x}" cy="${y}" r="5" class="dot" style="fill:${emotionMeta(ph?.emotion).color}"/>`
249
+ ).join('')}
303
250
  </svg>
304
251
  </div>`;
305
252
  }
@@ -308,17 +255,18 @@ class UserJourney extends HTMLElement {
308
255
 
309
256
  _grid(phases) {
310
257
  const headCells = phases.map((ph, i) => {
311
- const m = EMOTION_META[ph.emotion] || EMOTION_META.neutral;
312
- const stories = ph.storyIds || [];
258
+ const m = emotionMeta(ph?.emotion);
259
+ const emotion = String(ph?.emotion || 'neutral');
260
+ const stories = asList(ph?.storyIds);
313
261
  return `
314
- <th class="phase-head" data-emotion="${ph.emotion || 'neutral'}"
262
+ <th class="phase-head" data-emotion="${esc(emotion)}"
315
263
  style="--ec:${m.color}">
316
264
  <span class="ph-num">${i + 1}</span>
317
- <span class="ph-name">${esc(ph.name || '')}</span>
318
- <span class="ph-emoji" title="${ph.emotion || 'neutral'}"><span role="img" aria-label="${esc(ph.emotion || 'neutral')}">${m.emoji}</span></span>
265
+ <span class="ph-name">${esc(ph?.name || '')}</span>
266
+ <span class="ph-emoji" title="${esc(emotion)}"><span role="img" aria-label="${esc(emotion)}">${m.emoji}</span></span>
319
267
  ${stories.length
320
268
  ? `<div class="ph-stories">${stories.map(id =>
321
- `<a class="chip chip--story" href="#${id}">${this._label(id)}</a>`
269
+ `<a class="chip chip--story" href="${fragmentHref(id)}">${esc(this._label(id))}</a>`
322
270
  ).join('')}</div>`
323
271
  : ''}
324
272
  </th>`;
@@ -326,7 +274,7 @@ class UserJourney extends HTMLElement {
326
274
 
327
275
  const bodyRows = ROWS.map(({ key, label }) => {
328
276
  const cells = phases.map(ph => {
329
- const items = ph[key] || [];
277
+ const items = asList(ph?.[key]);
330
278
  if (!items.length) return `<td class="data-cell data-cell--empty">\u2014</td>`;
331
279
  return `<td class="data-cell data-cell--${key.toLowerCase()}">
332
280
  ${items.map(t => `<p>${esc(t)}</p>`).join('')}
@@ -342,7 +290,7 @@ class UserJourney extends HTMLElement {
342
290
  return `
343
291
  <div class="journey__grid-wrap">
344
292
  <table class="journey__grid"
345
- aria-label="${esc(this.getAttribute('title') || 'User Journey')} \u2014 phase breakdown">
293
+ aria-label="${esc(this.#title())} \u2014 phase breakdown">
346
294
  <thead>
347
295
  <tr>
348
296
  <th class="corner">Stage</th>
@@ -356,8 +304,9 @@ class UserJourney extends HTMLElement {
356
304
 
357
305
  /* ── Helpers ───────────────────────────────── */
358
306
 
307
+ /** @param {string} id */
359
308
  _label(id) {
360
- return id.replace(/^(activity|persona|journey|story|user)-/, '')
309
+ return String(id).replace(/^(activity|persona|journey|story|user)-/, '')
361
310
  .replace(/-/g, ' ')
362
311
  .replace(/\b\w/g, c => c.toUpperCase());
363
312
  }
@@ -3,11 +3,11 @@
3
3
  Without JS, shows the journey metadata. Full visualization requires JS.
4
4
  -->
5
5
  <user-journey
6
- title="API Platform Evaluation"
7
6
  persona="Sarah Chen"
8
7
  persona-id="persona-sarah-chen"
9
- summary="How a developer discovers, evaluates, and adopts our platform"
10
8
  story-ids="PROJ-142,PROJ-156">
9
+ <h2 slot="title">API Platform Evaluation</h2>
10
+ <p slot="summary">How a developer discovers, evaluates, and adopts our platform</p>
11
11
  <noscript>
12
12
  <p>This journey map requires JavaScript to render the emotion curve and phase grid.</p>
13
13
  </noscript>
@@ -2,34 +2,35 @@
2
2
  "$schema": "../../../schemas/api.schema.json",
3
3
  "element": "user-persona",
4
4
  "type": "web-component",
5
- "description": "Displays a user persona card with avatar, demographics, quote, and slotted sections for goals, frustrations, and behaviors",
5
+ "description": "Displays a user persona card with avatar, demographics, quote, and slotted sections for background, goals, frustrations, and behaviors",
6
6
  "htmlvalidate": {
7
7
  "flow": true,
8
8
  "permittedContent": ["@flow"]
9
9
  },
10
10
  "attributes": [
11
- { "name": "name", "kind": "host-api", "purpose": "config", "type": "string", "description": "Persona name (drives avatar initials and color)" },
11
+ { "name": "name", "kind": "host-api", "purpose": "config", "type": "string", "description": "Persona name, used when there is no slot=\"name\" child (the slot wins). Drives the avatar initials and color." },
12
12
  { "name": "role", "kind": "host-api", "purpose": "config", "type": "string", "description": "Job title or role" },
13
- { "name": "age", "kind": "host-api", "purpose": "config", "type": "string", "description": "Age display" },
14
- { "name": "location", "kind": "host-api", "purpose": "config", "type": "string", "description": "Location display" },
15
- { "name": "avatar", "kind": "host-api", "purpose": "config", "type": "string", "description": "Optional avatar image URL" },
16
- { "name": "quote", "kind": "host-api", "purpose": "config", "type": "string", "description": "Key quote displayed in highlight block" },
13
+ { "name": "age", "kind": "host-api", "purpose": "config", "type": "string", "description": "Age, shown as \"[value] years old\"" },
14
+ { "name": "location", "kind": "host-api", "purpose": "config", "type": "string", "description": "Location, shown with a pin icon" },
15
+ { "name": "avatar", "kind": "host-api", "purpose": "config", "type": "string", "description": "Avatar image URL (http(s), relative, or data:image). When omitted, initials are generated from the name." },
16
+ { "name": "quote", "kind": "host-api", "purpose": "config", "type": "string", "description": "Key quote, used when there is no slot=\"quote\" child (the slot wins)" },
17
17
  { "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Reduced spacing variant" },
18
- { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON file containing persona data" },
19
- { "name": "data-list-stories", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Render a Stories section that auto-lists every <user-story persona-id=\"…\"> matching this persona's id. Updates live when stories are added, removed, or relabeled." }
18
+ { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL of a JSON persona record. Fetched once per value; the record sets the attributes above and creates (or replaces) the name, quote, bio, goals, frustrations and behaviors slotted children." },
19
+ { "name": "data-list-stories", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Render a Stories section that auto-lists every <user-story persona-id=\"…\"> matching this persona's id. Updates live when stories are added, removed, or relabeled." },
20
+ { "name": "data-mock", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Fill any missing name, role, age, location, avatar, quote, goals, frustrations and behaviors with deterministic sample data (seeded by data-seed or the data-mock value)." },
21
+ { "name": "id", "kind": "host-api", "purpose": "config", "type": "string", "description": "Observed: the id that <user-story persona-id> matches. Changing it re-renders the Stories section." }
20
22
  ],
21
23
  "slots": [
22
- { "name": "name", "description": "Persona name (slot fallback for attribute)" },
23
- { "name": "role", "description": "Job title or role (slot fallback for attribute)" },
24
- { "name": "age", "description": "Age display (slot fallback for attribute)" },
25
- { "name": "location", "description": "Location display (slot fallback for attribute)" },
26
- { "name": "quote", "description": "Key quote (slot fallback for attribute)" },
24
+ { "name": "name", "description": "Persona name (e.g. <h2>). Defaults to the name attribute, then \"Unnamed Persona\"." },
25
+ { "name": "quote", "description": "Key quote (e.g. <p>). Defaults to the quote attribute." },
27
26
  { "name": "bio", "description": "Background narrative paragraph" },
28
27
  { "name": "goals", "description": "List of what the persona wants to achieve" },
29
28
  { "name": "frustrations", "description": "List of pain points and blockers" },
30
29
  { "name": "behaviors", "description": "List of observable patterns and habits" }
31
30
  ],
32
31
  "events": [
33
- { "name": "persona-ready", "detail": "{ name, role }", "description": "Fired after initial render" }
32
+ { "name": "persona-ready", "detail": "{ name, role }", "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." },
33
+ { "name": "user-persona:upgraded", "detail": "", "description": "Once, in a microtask after first connect. Wait for this before assigning .data from a framework." },
34
+ { "name": "user-persona:data-changed", "detail": "{ data, source: 'property' }", "description": "After .data is assigned" }
34
35
  ]
35
36
  }