@profpowell/vb-project-planning 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/dist/vb-project-planning.css +1603 -0
  4. package/dist/vb-project-planning.js +11138 -0
  5. package/dist/vb-project-planning.min.css +1 -0
  6. package/dist/vb-project-planning.min.js +3312 -0
  7. package/package.json +80 -0
  8. package/src/index.css +14 -0
  9. package/src/index.js +26 -0
  10. package/src/lib/bundle-registry.js +156 -0
  11. package/src/lib/diff-by-key.js +86 -0
  12. package/src/lib/portrait-url.js +87 -0
  13. package/src/lib/vb-collection.js +122 -0
  14. package/src/lib/vb-element.js +96 -0
  15. package/src/lib/vb-store.js +200 -0
  16. package/src/lib/vb-view-transition.js +44 -0
  17. package/src/utils/copy-init.js +287 -0
  18. package/src/web-components/_ux-base.js +106 -0
  19. package/src/web-components/adr-wc/README.md +77 -0
  20. package/src/web-components/adr-wc/api.json +26 -0
  21. package/src/web-components/adr-wc/logic.js +376 -0
  22. package/src/web-components/adr-wc/static.html +17 -0
  23. package/src/web-components/adr-wc/styles.js +338 -0
  24. package/src/web-components/burndown-chart/api.json +74 -0
  25. package/src/web-components/burndown-chart/logic.js +198 -0
  26. package/src/web-components/burndown-chart/static.html +31 -0
  27. package/src/web-components/burndown-chart/styles.css +59 -0
  28. package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
  29. package/src/web-components/capacity-plan/api.json +20 -0
  30. package/src/web-components/capacity-plan/logic.js +222 -0
  31. package/src/web-components/capacity-plan/static.html +24 -0
  32. package/src/web-components/capacity-plan/styles.css +84 -0
  33. package/src/web-components/empathy-map/api.json +31 -0
  34. package/src/web-components/empathy-map/logic.js +502 -0
  35. package/src/web-components/empathy-map/static.html +32 -0
  36. package/src/web-components/empathy-map/styles.js +534 -0
  37. package/src/web-components/gantt-chart/README.md +91 -0
  38. package/src/web-components/gantt-chart/api.json +20 -0
  39. package/src/web-components/gantt-chart/logic.js +812 -0
  40. package/src/web-components/gantt-chart/static.html +60 -0
  41. package/src/web-components/gantt-chart/styles.css +391 -0
  42. package/src/web-components/impact-effort/api.json +21 -0
  43. package/src/web-components/impact-effort/logic.js +392 -0
  44. package/src/web-components/impact-effort/static.html +23 -0
  45. package/src/web-components/impact-effort/styles.css +193 -0
  46. package/src/web-components/iron-triangle/_capacity.js +83 -0
  47. package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
  48. package/src/web-components/iron-triangle/api.json +26 -0
  49. package/src/web-components/iron-triangle/logic.js +474 -0
  50. package/src/web-components/iron-triangle/static.html +28 -0
  51. package/src/web-components/iron-triangle/styles.css +158 -0
  52. package/src/web-components/kanban-board/api.json +24 -0
  53. package/src/web-components/kanban-board/logic.js +698 -0
  54. package/src/web-components/kanban-board/static.html +20 -0
  55. package/src/web-components/kanban-board/styles.css +242 -0
  56. package/src/web-components/product-roadmap/api.json +118 -0
  57. package/src/web-components/product-roadmap/logic.js +460 -0
  58. package/src/web-components/product-roadmap/static.html +44 -0
  59. package/src/web-components/product-roadmap/styles.css +158 -0
  60. package/src/web-components/quadrant-grid/api.json +125 -0
  61. package/src/web-components/quadrant-grid/logic.js +397 -0
  62. package/src/web-components/quadrant-grid/static.html +49 -0
  63. package/src/web-components/quadrant-grid/styles.css +184 -0
  64. package/src/web-components/quality-target/_quality-utils.js +460 -0
  65. package/src/web-components/quality-target/api.json +30 -0
  66. package/src/web-components/quality-target/logic.js +516 -0
  67. package/src/web-components/quality-target/static.html +22 -0
  68. package/src/web-components/quality-target/styles.css +203 -0
  69. package/src/web-components/requirement-card/api.json +25 -0
  70. package/src/web-components/requirement-card/logic.js +166 -0
  71. package/src/web-components/requirement-card/static.html +36 -0
  72. package/src/web-components/requirement-card/styles.js +114 -0
  73. package/src/web-components/review-surface/README.md +178 -0
  74. package/src/web-components/review-surface/api.json +30 -0
  75. package/src/web-components/review-surface/logic.js +807 -0
  76. package/src/web-components/review-surface/static.html +33 -0
  77. package/src/web-components/review-surface/styles.js +459 -0
  78. package/src/web-components/risk-register/api.json +47 -0
  79. package/src/web-components/risk-register/logic.js +180 -0
  80. package/src/web-components/risk-register/static.html +46 -0
  81. package/src/web-components/risk-register/styles.css +37 -0
  82. package/src/web-components/story-map/api.json +22 -0
  83. package/src/web-components/story-map/logic.js +445 -0
  84. package/src/web-components/story-map/static.html +35 -0
  85. package/src/web-components/story-map/styles.css +169 -0
  86. package/src/web-components/traceability-matrix/api.json +84 -0
  87. package/src/web-components/traceability-matrix/logic.js +243 -0
  88. package/src/web-components/traceability-matrix/static.html +41 -0
  89. package/src/web-components/traceability-matrix/styles.css +94 -0
  90. package/src/web-components/user-journey/api.json +84 -0
  91. package/src/web-components/user-journey/logic.js +368 -0
  92. package/src/web-components/user-journey/static.html +14 -0
  93. package/src/web-components/user-journey/styles.js +317 -0
  94. package/src/web-components/user-persona/api.json +35 -0
  95. package/src/web-components/user-persona/logic.js +502 -0
  96. package/src/web-components/user-persona/static.html +20 -0
  97. package/src/web-components/user-persona/styles.js +299 -0
  98. package/src/web-components/user-story/api.json +35 -0
  99. package/src/web-components/user-story/logic.js +567 -0
  100. package/src/web-components/user-story/static.html +27 -0
  101. package/src/web-components/user-story/styles.js +332 -0
  102. package/src/web-components/work-item/README.md +85 -0
  103. package/src/web-components/work-item/api.json +30 -0
  104. package/src/web-components/work-item/logic.js +414 -0
  105. package/src/web-components/work-item/static.html +24 -0
  106. package/src/web-components/work-item/styles.js +376 -0
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "../../../schemas/api.schema.json",
3
+ "element": "user-persona",
4
+ "type": "web-component",
5
+ "description": "Displays a user persona card with avatar, demographics, quote, and slotted sections for goals, frustrations, and behaviors",
6
+ "htmlvalidate": {
7
+ "flow": true,
8
+ "permittedContent": ["@flow"]
9
+ },
10
+ "attributes": [
11
+ { "name": "name", "kind": "host-api", "purpose": "config", "type": "string", "description": "Persona name (drives avatar initials and color)" },
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" },
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." }
20
+ ],
21
+ "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)" },
27
+ { "name": "bio", "description": "Background narrative paragraph" },
28
+ { "name": "goals", "description": "List of what the persona wants to achieve" },
29
+ { "name": "frustrations", "description": "List of pain points and blockers" },
30
+ { "name": "behaviors", "description": "List of observable patterns and habits" }
31
+ ],
32
+ "events": [
33
+ { "name": "persona-ready", "detail": "{ name, role }", "description": "Fired after initial render" }
34
+ ]
35
+ }
@@ -0,0 +1,502 @@
1
+ /**
2
+ * user-persona: User persona card for UX planning
3
+ *
4
+ * Displays a persona with avatar, demographics, quote, and slotted
5
+ * sections for bio, goals, frustrations, and behaviors. Uses Shadow DOM
6
+ * for encapsulation and VB design tokens for theming.
7
+ *
8
+ * @attr {string} name - Persona name (drives avatar initials and color)
9
+ * @attr {string} role - Job title or role
10
+ * @attr {string} age - Age display
11
+ * @attr {string} location - Location display
12
+ * @attr {string} avatar - Optional avatar image URL
13
+ * @attr {string} quote - Key quote displayed in highlight block
14
+ * @attr {boolean} compact - Reduced spacing variant
15
+ *
16
+ * @fires persona-ready - After initial render, detail: { name, role }
17
+ *
18
+ * @example
19
+ * <user-persona name="Sarah Chen" role="Product Manager" age="34"
20
+ * location="San Francisco, CA"
21
+ * quote="I need tools that help me stay organized.">
22
+ * <p slot="bio">Sarah has 8 years of PM experience.</p>
23
+ * <ul slot="goals"><li>Streamline communication</li></ul>
24
+ * <ul slot="frustrations"><li>Too many disconnected tools</li></ul>
25
+ * <ul slot="behaviors"><li>Checks dashboards every morning</li></ul>
26
+ * </user-persona>
27
+ */
28
+ import { styles } from './styles.js';
29
+ import { registerComponent } from '../../lib/bundle-registry.js';
30
+ import { esc, initials, hashColor } from '../_ux-base.js';
31
+ import { portraitUrl } from '../../lib/portrait-url.js';
32
+
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
+
40
+ // ── Mock persona corpus (~1 KB) ───────────────────────────────────────
41
+ const MOCK_NAMES = [
42
+ 'Sarah Chen', 'Marcus Johnson', 'Aisha Patel', 'James O\'Brien',
43
+ 'Yuki Tanaka', 'Elena Rodriguez', 'David Kim', 'Fatima Al-Hassan',
44
+ 'Lucas Silva', 'Priya Sharma', 'Noah Williams', 'Mei Lin',
45
+ 'Carlos Mendez', 'Amara Osei', 'Henrik Larsson', 'Zara Ahmed',
46
+ ];
47
+ const MOCK_ROLES = [
48
+ 'Product Manager', 'UX Designer', 'Frontend Developer', 'Data Analyst',
49
+ 'Marketing Lead', 'QA Engineer', 'DevOps Lead', 'Content Strategist',
50
+ 'Startup Founder', 'IT Director', 'Customer Success Lead', 'Research Scientist',
51
+ ];
52
+ const MOCK_LOCATIONS = [
53
+ 'San Francisco, CA', 'Austin, TX', 'London, UK', 'Toronto, CA',
54
+ 'Berlin, DE', 'Tokyo, JP', 'Sydney, AU', 'S\u00e3o Paulo, BR',
55
+ ];
56
+ const MOCK_QUOTES = [
57
+ 'I need tools that help me stay organized without slowing me down.',
58
+ 'The dashboard is where I live \u2014 it has to be fast and reliable.',
59
+ 'I want to understand the data, not fight the interface.',
60
+ 'If it takes more than two clicks, I\u2019ll find another way.',
61
+ 'Collaboration shouldn\u2019t mean endless notification noise.',
62
+ 'I just want it to work the way I expect it to.',
63
+ 'Give me the big picture first, then let me drill into details.',
64
+ 'Accessibility isn\u2019t a nice-to-have \u2014 it\u2019s how I use the web.',
65
+ ];
66
+ const MOCK_GOALS = [
67
+ 'Streamline daily workflows', 'Reduce context-switching',
68
+ 'Stay aligned with the team', 'Make data-driven decisions quickly',
69
+ 'Ship features on a predictable cadence', 'Automate repetitive tasks',
70
+ 'Improve onboarding experience', 'Keep documentation up to date',
71
+ ];
72
+ const MOCK_FRUSTRATIONS = [
73
+ 'Too many disconnected tools', 'Slow page loads break focus',
74
+ 'Unclear ownership of tasks', 'Settings that reset unexpectedly',
75
+ 'Notifications that bury important updates', 'Poor mobile experience',
76
+ 'Inconsistent design across features', 'No offline support',
77
+ ];
78
+ const MOCK_BEHAVIORS = [
79
+ 'Checks dashboards every morning', 'Prefers keyboard shortcuts over mouse',
80
+ 'Skims docs, reads deeply only when stuck', 'Shares screenshots in Slack',
81
+ 'Batches email to twice a day', 'Tests features in incognito first',
82
+ 'Bookmarks frequently used reports', 'Uses dark mode exclusively',
83
+ ];
84
+
85
+ class UserPersona extends HTMLElement {
86
+ static get observedAttributes() {
87
+ return ['role', 'age', 'location', 'avatar', 'compact', 'src', 'data-list-stories', 'id'];
88
+ }
89
+
90
+ #slotCache = new Map();
91
+ /** @type {MutationObserver | null} */
92
+ #storyObserver = null;
93
+
94
+ constructor() {
95
+ super();
96
+ this.attachShadow({ mode: 'open' });
97
+ }
98
+
99
+ /**
100
+ * Find every <user-story> in the document whose `persona-id` matches
101
+ * this persona's id. Returns elements in document order.
102
+ * @returns {Element[]}
103
+ */
104
+ relatedStories() {
105
+ if (!this.id) return [];
106
+ const root = /** @type {ParentNode} */ (this.getRootNode());
107
+ const scope = /** @type {ParentNode} */ (/** @type {any} */ (root).querySelectorAll ? root : document);
108
+ return Array.from(scope.querySelectorAll(`user-story[persona-id="${cssEscape(this.id)}"]`));
109
+ }
110
+
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
+ /* ── Data API ──────────────────────────────────── */
125
+
126
+ /**
127
+ * Read the persona as a plain data object. Mirrors what a consumer
128
+ * would assign to .data — useful for diffing or persistence.
129
+ */
130
+ get data() {
131
+ return {
132
+ name: this.personaName,
133
+ role: this.personaRole || undefined,
134
+ age: this.age || undefined,
135
+ location: this.location || undefined,
136
+ avatar: this.avatar || undefined,
137
+ 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,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Set state attributes and slotted content from a plain object in one
147
+ * call. Idempotent for repeat calls.
148
+ */
149
+ 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
+ }));
157
+ }
158
+
159
+ /**
160
+ * Apply a data record to attributes + slotted children. Used by both
161
+ * the .data setter and async _loadSrc.
162
+ * @param {Record<string, unknown>} data
163
+ */
164
+ _applyData(data) {
165
+ 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);
173
+ }
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]));
182
+ }
183
+ }
184
+
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
+ }
196
+ }
197
+
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();
206
+ 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
+ }));
213
+ }
214
+
215
+ disconnectedCallback() {
216
+ this.removeAttribute('data-upgraded');
217
+ this.#storyObserver?.disconnect();
218
+ this.#storyObserver = null;
219
+ }
220
+
221
+ /**
222
+ * Watch the document for added/removed/relabeled <user-story> elements
223
+ * so the auto-rendered Stories section stays current. Only runs when
224
+ * data-list-stories is present.
225
+ */
226
+ #syncStoryObserver() {
227
+ if (!this.hasAttribute('data-list-stories') || !this.id) {
228
+ this.#storyObserver?.disconnect();
229
+ this.#storyObserver = null;
230
+ return;
231
+ }
232
+ if (this.#storyObserver) return;
233
+ this.#storyObserver = new MutationObserver(() => this.#renderStoriesSection());
234
+ this.#storyObserver.observe(document.body, {
235
+ childList: true,
236
+ subtree: true,
237
+ attributes: true,
238
+ attributeFilter: ['persona-id'],
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Re-render only the Stories section in-place so MutationObserver
244
+ * callbacks don't have to rebuild the whole shadow tree.
245
+ */
246
+ #renderStoriesSection() {
247
+ if (!this.shadowRoot) return;
248
+ const slot = this.shadowRoot.querySelector('[data-stories-section]');
249
+ if (!slot) return;
250
+ slot.outerHTML = this.#storiesSectionHtml();
251
+ }
252
+
253
+ /** Markup for the optional Stories section. */
254
+ #storiesSectionHtml() {
255
+ if (!this.hasAttribute('data-list-stories') || !this.id) return '';
256
+ const stories = this.relatedStories();
257
+ return `
258
+ <section class="section" part="section-stories" data-stories-section>
259
+ <div class="section-header">
260
+ <div class="section-icon stories" aria-hidden="true">
261
+ <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
+ </div>
263
+ <span class="section-title">Stories <span class="section-count">${stories.length}</span></span>
264
+ </div>
265
+ <div class="section-content">
266
+ ${stories.length === 0
267
+ ? '<p class="empty-stories">No user stories reference this persona yet.</p>'
268
+ : `<ul class="story-list">${
269
+ stories.map(s => {
270
+ const id = s.id || s.getAttribute('story-id') || '';
271
+ const action = s.querySelector('[slot="action"]')?.textContent?.trim()
272
+ || s.getAttribute('action')
273
+ || (s.id || 'untitled');
274
+ const priority = s.getAttribute('priority') || '';
275
+ const status = s.getAttribute('status') || '';
276
+ return `<li class="story-item" data-priority="${esc(priority)}" data-status="${esc(status)}">${
277
+ id
278
+ ? `<a href="#${esc(id)}">${esc(action)}</a>`
279
+ : `<span>${esc(action)}</span>`
280
+ }${priority ? `<span class="story-meta">${esc(priority)}</span>` : ''}${status ? `<span class="story-meta">${esc(status)}</span>` : ''}</li>`;
281
+ }).join('')
282
+ }</ul>`}
283
+ </div>
284
+ </section>
285
+ `;
286
+ }
287
+
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
+ // ── Attribute getters ──────────────────────────────────────────────
301
+
302
+ /** Read name from slotted heading or cache */
303
+ get personaName() {
304
+ const slotted = this.querySelector('[slot="name"]');
305
+ return slotted?.textContent?.trim() || this.#slotCache.get('name') || 'Unnamed Persona';
306
+ }
307
+
308
+ get personaRole() {
309
+ return this.getAttribute('role') || '';
310
+ }
311
+
312
+ get age() {
313
+ return this.getAttribute('age') || '';
314
+ }
315
+
316
+ get location() {
317
+ return this.getAttribute('location') || '';
318
+ }
319
+
320
+ get avatar() {
321
+ return this.getAttribute('avatar') || '';
322
+ }
323
+
324
+ /** Read quote from slotted element or cache */
325
+ get quote() {
326
+ const slotted = this.querySelector('[slot="quote"]');
327
+ return slotted?.textContent?.trim() || this.#slotCache.get('quote') || '';
328
+ }
329
+
330
+ get compact() {
331
+ return this.hasAttribute('compact');
332
+ }
333
+
334
+ // ── Mock data ──────────────────────────────────────────────────────
335
+
336
+ #applyMock() {
337
+ const seed = this.dataset.seed || this.dataset.mock || String(Date.now());
338
+ const pick = (arr) => arr[((hash(seed + arr.length) % arr.length) + arr.length) % arr.length];
339
+ const pickN = (arr, n) => {
340
+ const out = [];
341
+ for (let i = 0; i < n; i++) {
342
+ out.push(arr[((hash(seed + i + arr.length) % arr.length) + arr.length) % arr.length]);
343
+ }
344
+ return [...new Set(out)];
345
+ };
346
+
347
+ // Name and quote are slotted content, not attributes
348
+ if (!this.querySelector('[slot="name"]')) {
349
+ const h2 = document.createElement('h2');
350
+ h2.slot = 'name';
351
+ h2.textContent = pick(MOCK_NAMES);
352
+ this.appendChild(h2);
353
+ }
354
+ if (!this.getAttribute('role')) this.setAttribute('role', pick(MOCK_ROLES));
355
+ if (!this.getAttribute('age')) this.setAttribute('age', String(25 + ((hash(seed) % 30) + 30) % 30));
356
+ if (!this.getAttribute('location')) this.setAttribute('location', pick(MOCK_LOCATIONS));
357
+ if (!this.getAttribute('avatar')) {
358
+ const name = this.querySelector('[slot="name"]')?.textContent?.trim() || 'Persona';
359
+ this.setAttribute('avatar', portraitUrl(name, 256));
360
+ }
361
+ if (!this.querySelector('[slot="quote"]')) {
362
+ const p = document.createElement('p');
363
+ p.slot = 'quote';
364
+ p.textContent = pick(MOCK_QUOTES);
365
+ this.appendChild(p);
366
+ }
367
+
368
+ // Inject slotted sections as light DOM so native <slot> picks them up
369
+ const injectList = (slotName, items) => {
370
+ if (this.querySelector(`[slot="${slotName}"]`)) return;
371
+ const ul = document.createElement('ul');
372
+ ul.setAttribute('slot', slotName);
373
+ for (const item of items) {
374
+ const li = document.createElement('li');
375
+ li.textContent = item;
376
+ ul.appendChild(li);
377
+ }
378
+ this.appendChild(ul);
379
+ };
380
+ injectList('goals', pickN(MOCK_GOALS, 3));
381
+ injectList('frustrations', pickN(MOCK_FRUSTRATIONS, 3));
382
+ injectList('behaviors', pickN(MOCK_BEHAVIORS, 3));
383
+ }
384
+
385
+ // ── Render ─────────────────────────────────────────────────────────
386
+
387
+ #render() {
388
+ const name = this.personaName;
389
+ const role = this.personaRole;
390
+ const age = this.age;
391
+ const loc = this.location;
392
+ const avatarUrl = this.avatar;
393
+ const quote = this.quote;
394
+ const avatarBg = hashColor(name);
395
+
396
+ const avatarStyle = avatarUrl
397
+ ? `background:url(${esc(avatarUrl)}) center/cover`
398
+ : `background:${avatarBg}`;
399
+
400
+ /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = `
401
+ <style>${styles}</style>
402
+
403
+ <article class="persona-card" part="card" role="article"
404
+ aria-label="User persona: ${esc(name)}">
405
+
406
+ <header class="persona-header" part="header">
407
+ <div class="avatar" part="avatar" style="${avatarStyle}"
408
+ role="img" aria-label="Avatar for ${esc(name)}">
409
+ ${!avatarUrl ? esc(initials(name)) : ''}
410
+ </div>
411
+ <div class="header-info">
412
+ <div class="persona-name-wrap" part="name">
413
+ <slot name="name"><h2 class="persona-name-fallback">${esc(name)}</h2></slot>
414
+ </div>
415
+ ${role ? `<p class="persona-role" part="role">${esc(role)}</p>` : ''}
416
+ <div class="persona-meta" part="meta">
417
+ ${age ? `
418
+ <span class="meta-item">
419
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg>
420
+ ${esc(age)} years old
421
+ </span>
422
+ ` : ''}
423
+ ${loc ? `
424
+ <span class="meta-item">
425
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
426
+ ${esc(loc)}
427
+ </span>
428
+ ` : ''}
429
+ </div>
430
+ </div>
431
+ </header>
432
+
433
+ ${quote || this.querySelector('[slot="quote"]') ? `
434
+ <div class="persona-quote" part="quote">
435
+ <span class="quote-mark" aria-hidden="true">&ldquo;</span>
436
+ <div class="quote-text-wrap"><slot name="quote"><p class="quote-text">${esc(quote)}</p></slot></div>
437
+ </div>
438
+ ` : ''}
439
+
440
+ <div class="persona-body" part="body">
441
+ <div class="section" part="section-bio">
442
+ <div class="section-header">
443
+ <div class="section-icon bio" aria-hidden="true">
444
+ <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
+ </div>
446
+ <span class="section-title">Background</span>
447
+ </div>
448
+ <div class="section-content">
449
+ <slot name="bio">No background information provided.</slot>
450
+ </div>
451
+ </div>
452
+
453
+ <div class="section" part="section-goals">
454
+ <div class="section-header">
455
+ <div class="section-icon goals" aria-hidden="true">
456
+ <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
+ </div>
458
+ <span class="section-title">Goals</span>
459
+ </div>
460
+ <div class="section-content">
461
+ <slot name="goals">No goals specified.</slot>
462
+ </div>
463
+ </div>
464
+
465
+ <div class="section" part="section-frustrations">
466
+ <div class="section-header">
467
+ <div class="section-icon frustrations" aria-hidden="true">
468
+ <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
+ </div>
470
+ <span class="section-title">Frustrations</span>
471
+ </div>
472
+ <div class="section-content">
473
+ <slot name="frustrations">No frustrations listed.</slot>
474
+ </div>
475
+ </div>
476
+
477
+ <div class="section" part="section-behaviors">
478
+ <div class="section-header">
479
+ <div class="section-icon behaviors" aria-hidden="true">
480
+ <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
+ </div>
482
+ <span class="section-title">Behaviors</span>
483
+ </div>
484
+ <div class="section-content">
485
+ <slot name="behaviors">No behaviors documented.</slot>
486
+ </div>
487
+ </div>
488
+
489
+ ${this.#storiesSectionHtml()}
490
+ </div>
491
+ </article>
492
+ `;
493
+ }
494
+ }
495
+
496
+ function cssEscape(s) {
497
+ return typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(String(s)) : String(s).replace(/["\\]/g, '\\$&');
498
+ }
499
+
500
+ registerComponent('user-persona', UserPersona);
501
+
502
+ export { UserPersona };
@@ -0,0 +1,20 @@
1
+ <!--
2
+ user-persona — static (zero-JS) form
3
+ Slotted content renders as normal children before upgrade.
4
+ -->
5
+ <user-persona name="Sarah Chen" role="Product Manager" age="34" location="San Francisco, CA"
6
+ quote="I need tools that help me stay organized across multiple projects.">
7
+ <p slot="bio">Sarah has 8 years of experience in product management at mid-size SaaS companies.</p>
8
+ <ul slot="goals">
9
+ <li>Streamline team communication</li>
10
+ <li>Track project progress efficiently</li>
11
+ </ul>
12
+ <ul slot="frustrations">
13
+ <li>Too many disconnected tools</li>
14
+ <li>Difficulty getting stakeholder buy-in</li>
15
+ </ul>
16
+ <ul slot="behaviors">
17
+ <li>Checks dashboards first thing every morning</li>
18
+ <li>Prefers async communication over meetings</li>
19
+ </ul>
20
+ </user-persona>