@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.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/vb-project-planning.css +1603 -0
- package/dist/vb-project-planning.js +11138 -0
- package/dist/vb-project-planning.min.css +1 -0
- package/dist/vb-project-planning.min.js +3312 -0
- package/package.json +80 -0
- package/src/index.css +14 -0
- package/src/index.js +26 -0
- package/src/lib/bundle-registry.js +156 -0
- package/src/lib/diff-by-key.js +86 -0
- package/src/lib/portrait-url.js +87 -0
- package/src/lib/vb-collection.js +122 -0
- package/src/lib/vb-element.js +96 -0
- package/src/lib/vb-store.js +200 -0
- package/src/lib/vb-view-transition.js +44 -0
- package/src/utils/copy-init.js +287 -0
- package/src/web-components/_ux-base.js +106 -0
- package/src/web-components/adr-wc/README.md +77 -0
- package/src/web-components/adr-wc/api.json +26 -0
- package/src/web-components/adr-wc/logic.js +376 -0
- package/src/web-components/adr-wc/static.html +17 -0
- package/src/web-components/adr-wc/styles.js +338 -0
- package/src/web-components/burndown-chart/api.json +74 -0
- package/src/web-components/burndown-chart/logic.js +198 -0
- package/src/web-components/burndown-chart/static.html +31 -0
- package/src/web-components/burndown-chart/styles.css +59 -0
- package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
- package/src/web-components/capacity-plan/api.json +20 -0
- package/src/web-components/capacity-plan/logic.js +222 -0
- package/src/web-components/capacity-plan/static.html +24 -0
- package/src/web-components/capacity-plan/styles.css +84 -0
- package/src/web-components/empathy-map/api.json +31 -0
- package/src/web-components/empathy-map/logic.js +502 -0
- package/src/web-components/empathy-map/static.html +32 -0
- package/src/web-components/empathy-map/styles.js +534 -0
- package/src/web-components/gantt-chart/README.md +91 -0
- package/src/web-components/gantt-chart/api.json +20 -0
- package/src/web-components/gantt-chart/logic.js +812 -0
- package/src/web-components/gantt-chart/static.html +60 -0
- package/src/web-components/gantt-chart/styles.css +391 -0
- package/src/web-components/impact-effort/api.json +21 -0
- package/src/web-components/impact-effort/logic.js +392 -0
- package/src/web-components/impact-effort/static.html +23 -0
- package/src/web-components/impact-effort/styles.css +193 -0
- package/src/web-components/iron-triangle/_capacity.js +83 -0
- package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
- package/src/web-components/iron-triangle/api.json +26 -0
- package/src/web-components/iron-triangle/logic.js +474 -0
- package/src/web-components/iron-triangle/static.html +28 -0
- package/src/web-components/iron-triangle/styles.css +158 -0
- package/src/web-components/kanban-board/api.json +24 -0
- package/src/web-components/kanban-board/logic.js +698 -0
- package/src/web-components/kanban-board/static.html +20 -0
- package/src/web-components/kanban-board/styles.css +242 -0
- package/src/web-components/product-roadmap/api.json +118 -0
- package/src/web-components/product-roadmap/logic.js +460 -0
- package/src/web-components/product-roadmap/static.html +44 -0
- package/src/web-components/product-roadmap/styles.css +158 -0
- package/src/web-components/quadrant-grid/api.json +125 -0
- package/src/web-components/quadrant-grid/logic.js +397 -0
- package/src/web-components/quadrant-grid/static.html +49 -0
- package/src/web-components/quadrant-grid/styles.css +184 -0
- package/src/web-components/quality-target/_quality-utils.js +460 -0
- package/src/web-components/quality-target/api.json +30 -0
- package/src/web-components/quality-target/logic.js +516 -0
- package/src/web-components/quality-target/static.html +22 -0
- package/src/web-components/quality-target/styles.css +203 -0
- package/src/web-components/requirement-card/api.json +25 -0
- package/src/web-components/requirement-card/logic.js +166 -0
- package/src/web-components/requirement-card/static.html +36 -0
- package/src/web-components/requirement-card/styles.js +114 -0
- package/src/web-components/review-surface/README.md +178 -0
- package/src/web-components/review-surface/api.json +30 -0
- package/src/web-components/review-surface/logic.js +807 -0
- package/src/web-components/review-surface/static.html +33 -0
- package/src/web-components/review-surface/styles.js +459 -0
- package/src/web-components/risk-register/api.json +47 -0
- package/src/web-components/risk-register/logic.js +180 -0
- package/src/web-components/risk-register/static.html +46 -0
- package/src/web-components/risk-register/styles.css +37 -0
- package/src/web-components/story-map/api.json +22 -0
- package/src/web-components/story-map/logic.js +445 -0
- package/src/web-components/story-map/static.html +35 -0
- package/src/web-components/story-map/styles.css +169 -0
- package/src/web-components/traceability-matrix/api.json +84 -0
- package/src/web-components/traceability-matrix/logic.js +243 -0
- package/src/web-components/traceability-matrix/static.html +41 -0
- package/src/web-components/traceability-matrix/styles.css +94 -0
- package/src/web-components/user-journey/api.json +84 -0
- package/src/web-components/user-journey/logic.js +368 -0
- package/src/web-components/user-journey/static.html +14 -0
- package/src/web-components/user-journey/styles.js +317 -0
- package/src/web-components/user-persona/api.json +35 -0
- package/src/web-components/user-persona/logic.js +502 -0
- package/src/web-components/user-persona/static.html +20 -0
- package/src/web-components/user-persona/styles.js +299 -0
- package/src/web-components/user-story/api.json +35 -0
- package/src/web-components/user-story/logic.js +567 -0
- package/src/web-components/user-story/static.html +27 -0
- package/src/web-components/user-story/styles.js +332 -0
- package/src/web-components/work-item/README.md +85 -0
- package/src/web-components/work-item/api.json +30 -0
- package/src/web-components/work-item/logic.js +414 -0
- package/src/web-components/work-item/static.html +24 -0
- package/src/web-components/work-item/styles.js +376 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <user-journey> — User journey map web component
|
|
3
|
+
*
|
|
4
|
+
* Visualises a multi-phase user journey with an SVG emotion curve
|
|
5
|
+
* and a structured phase-breakdown grid table.
|
|
6
|
+
*
|
|
7
|
+
* Data sources:
|
|
8
|
+
* 1. `src` attribute — URL to a JSON file containing phase data
|
|
9
|
+
* 2. `.phases` property — set programmatically
|
|
10
|
+
*
|
|
11
|
+
* JSON schema:
|
|
12
|
+
* {
|
|
13
|
+
* "title": "...",
|
|
14
|
+
* "persona": "...",
|
|
15
|
+
* "personaId": "...",
|
|
16
|
+
* "summary": "...",
|
|
17
|
+
* "phases": [
|
|
18
|
+
* {
|
|
19
|
+
* "name": "Awareness",
|
|
20
|
+
* "emotion": "curious",
|
|
21
|
+
* "storyIds": ["PROJ-142"],
|
|
22
|
+
* "actions": ["Searches for solutions"],
|
|
23
|
+
* "thoughts": ["There must be a better way"],
|
|
24
|
+
* "touchpoints": ["Google", "Hacker News"],
|
|
25
|
+
* "painPoints": ["Too many options to compare"],
|
|
26
|
+
* "opportunities": ["Clear landing page"]
|
|
27
|
+
* }
|
|
28
|
+
* ]
|
|
29
|
+
* }
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { styles } from './styles.js';
|
|
33
|
+
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
34
|
+
import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
|
|
35
|
+
import { esc, EMOTION_META, lucideSvg, UX_ICONS } from '../_ux-base.js';
|
|
36
|
+
|
|
37
|
+
const ROWS = [
|
|
38
|
+
{ key: 'actions', label: 'Actions' },
|
|
39
|
+
{ key: 'thoughts', label: 'Thoughts' },
|
|
40
|
+
{ key: 'touchpoints', label: 'Touchpoints' },
|
|
41
|
+
{ key: 'painPoints', label: 'Pain Points' },
|
|
42
|
+
{ key: 'opportunities', label: 'Opportunities' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
class UserJourney extends HTMLElement {
|
|
46
|
+
static get observedAttributes() {
|
|
47
|
+
return ['src', 'persona', 'persona-id', 'story-ids', 'compact'];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#slotCache = new Map();
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
super();
|
|
54
|
+
this.attachShadow({ mode: 'open' });
|
|
55
|
+
this.__phases = null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @returns {Array|null} */
|
|
59
|
+
get phases() { return this.__phases; }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Replace the phase list. Idempotent (skip on same array reference).
|
|
63
|
+
* Emits user-journey:phases-changed { phases, source: 'property' }.
|
|
64
|
+
* @param {Array|null} data
|
|
65
|
+
*/
|
|
66
|
+
set phases(data) {
|
|
67
|
+
if (this.__phases === data) return;
|
|
68
|
+
this.__phases = data;
|
|
69
|
+
if (this.isConnected) this._render();
|
|
70
|
+
this.dispatchEvent(new CustomEvent('user-journey:phases-changed', {
|
|
71
|
+
detail: { phases: data, source: 'property' },
|
|
72
|
+
bubbles: true, composed: true,
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read the journey as a plain data object combining state, slotted
|
|
78
|
+
* content, and the phase array. Mirrors what a consumer would assign
|
|
79
|
+
* to .data.
|
|
80
|
+
*/
|
|
81
|
+
get data() {
|
|
82
|
+
return {
|
|
83
|
+
persona: this.getAttribute('persona') || undefined,
|
|
84
|
+
personaId: this.getAttribute('persona-id') || undefined,
|
|
85
|
+
title: this.querySelector('[slot="title"]')?.textContent?.trim() || undefined,
|
|
86
|
+
summary: this.querySelector('[slot="summary"]')?.textContent?.trim() || undefined,
|
|
87
|
+
phases: this.__phases || undefined,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Set state attributes, slotted content, and phases in one assignment.
|
|
93
|
+
* Emits user-journey:data-changed { data, source: 'property' }.
|
|
94
|
+
*/
|
|
95
|
+
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
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ── Slot caching ──────────────────────────── */
|
|
120
|
+
|
|
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
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_resolve(attr) {
|
|
129
|
+
return this.getAttribute(attr) || this.#slotCache.get(attr) || '';
|
|
130
|
+
}
|
|
131
|
+
|
|
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
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
disconnectedCallback() {
|
|
145
|
+
this.removeAttribute('data-upgraded');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
attributeChangedCallback(name) {
|
|
149
|
+
if (!this.isConnected) return;
|
|
150
|
+
if (name === 'src') {
|
|
151
|
+
this._loadSrc(this.getAttribute('src'));
|
|
152
|
+
} else {
|
|
153
|
+
this._render();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
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
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ── Render ────────────────────────────────── */
|
|
191
|
+
|
|
192
|
+
_render() {
|
|
193
|
+
const persona = this._resolve('persona') || '';
|
|
194
|
+
const personaId = this._resolve('persona-id') || '';
|
|
195
|
+
const storyIds = (this.getAttribute('story-ids') || '')
|
|
196
|
+
.split(',').map(s => s.trim()).filter(Boolean);
|
|
197
|
+
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') || '';
|
|
202
|
+
|
|
203
|
+
const html = `<style>${styles}</style>
|
|
204
|
+
<article class="journey${compact ? ' journey--compact' : ''}">
|
|
205
|
+
|
|
206
|
+
<header class="journey__header">
|
|
207
|
+
<div class="journey__header-top">
|
|
208
|
+
<div class="journey__chips">
|
|
209
|
+
<span class="chip chip--type">Journey Map</span>
|
|
210
|
+
${storyIds.map(id =>
|
|
211
|
+
`<a class="chip chip--story" href="#${id}">${this._label(id)}</a>`
|
|
212
|
+
).join('')}
|
|
213
|
+
</div>
|
|
214
|
+
${persona ? `
|
|
215
|
+
<div class="journey__persona">
|
|
216
|
+
${personaId
|
|
217
|
+
? `<a class="persona-ref" href="#${personaId}">${lucideSvg(UX_ICONS.user)} ${esc(persona)}</a>`
|
|
218
|
+
: `<span class="persona-ref">${lucideSvg(UX_ICONS.user)} ${esc(persona)}</span>`}
|
|
219
|
+
</div>` : ''}
|
|
220
|
+
</div>
|
|
221
|
+
<div class="journey__title-wrap">
|
|
222
|
+
<slot name="title"><h2 class="journey__title">User Journey</h2></slot>
|
|
223
|
+
</div>
|
|
224
|
+
${hasSummary ? `<div class="journey__summary-wrap"><slot name="summary"></slot></div>` : ''}
|
|
225
|
+
</header>
|
|
226
|
+
|
|
227
|
+
${phases && phases.length
|
|
228
|
+
? this._curve(phases) + this._grid(phases)
|
|
229
|
+
: `<div class="journey__placeholder">
|
|
230
|
+
<p>Add phase data via <code>src</code> (JSON) or set <code>.phases</code> programmatically.</p>
|
|
231
|
+
</div>`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
</article>`;
|
|
235
|
+
|
|
236
|
+
/* Pin view-transition-name on the host so the browser crossfades the
|
|
237
|
+
light-DOM snapshot (which includes the shadow paint) when the
|
|
238
|
+
shadowRoot innerHTML swap replaces all content. Skip on the first
|
|
239
|
+
render — there's nothing to fade from. */
|
|
240
|
+
const root = /** @type {ShadowRoot} */ (this.shadowRoot);
|
|
241
|
+
const swap = () => { root.innerHTML = html; };
|
|
242
|
+
if (root.querySelector('article')) {
|
|
243
|
+
viewTransitionSwap(this, swap, 'uj-vt');
|
|
244
|
+
} else {
|
|
245
|
+
swap();
|
|
246
|
+
}
|
|
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
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ── SVG curve ─────────────────────────────── */
|
|
261
|
+
|
|
262
|
+
_curve(phases) {
|
|
263
|
+
const W = 1000, H = 100, PX = 28, PY = 14;
|
|
264
|
+
const uw = W - PX * 2, uh = H - PY * 2;
|
|
265
|
+
const n = phases.length;
|
|
266
|
+
|
|
267
|
+
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
|
+
};
|
|
272
|
+
|
|
273
|
+
const pts = phases.map((ph, i) => ({ x: toX(i), y: toY(ph), ph }));
|
|
274
|
+
|
|
275
|
+
let d = `M ${pts[0].x},${pts[0].y}`;
|
|
276
|
+
for (let i = 1; i < pts.length; i++) {
|
|
277
|
+
const a = pts[i - 1], b = pts[i], cx = (a.x + b.x) / 2;
|
|
278
|
+
d += ` C ${cx},${a.y} ${cx},${b.y} ${b.x},${b.y}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const uid = `uj-${Math.random().toString(36).slice(2, 8)}`;
|
|
282
|
+
const last = pts.at(-1);
|
|
283
|
+
|
|
284
|
+
return `
|
|
285
|
+
<div class="journey__curve" aria-hidden="true">
|
|
286
|
+
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="curve-svg">
|
|
287
|
+
<defs>
|
|
288
|
+
<linearGradient id="${uid}" x1="0" y1="0" x2="0" y2="1">
|
|
289
|
+
<stop offset="0%" stop-color="#6366f1" stop-opacity="0.22"/>
|
|
290
|
+
<stop offset="100%" stop-color="#6366f1" stop-opacity="0"/>
|
|
291
|
+
</linearGradient>
|
|
292
|
+
</defs>
|
|
293
|
+
<rect x="0" y="0" width="${W}" height="${H * 0.4}" class="zone zone--pos"/>
|
|
294
|
+
<rect x="0" y="${H * 0.4}" width="${W}" height="${H * 0.2}" class="zone zone--neu"/>
|
|
295
|
+
<rect x="0" y="${H * 0.6}" width="${W}" height="${H * 0.4}" class="zone zone--neg"/>
|
|
296
|
+
${pts.map(({x}) => `<line x1="${x}" y1="0" x2="${x}" y2="${H}" class="vline"/>`).join('')}
|
|
297
|
+
<path d="${d} L ${last.x},${H} L ${pts[0].x},${H} Z" fill="url(#${uid})"/>
|
|
298
|
+
<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('')}
|
|
303
|
+
</svg>
|
|
304
|
+
</div>`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* ── Phase grid ────────────────────────────── */
|
|
308
|
+
|
|
309
|
+
_grid(phases) {
|
|
310
|
+
const headCells = phases.map((ph, i) => {
|
|
311
|
+
const m = EMOTION_META[ph.emotion] || EMOTION_META.neutral;
|
|
312
|
+
const stories = ph.storyIds || [];
|
|
313
|
+
return `
|
|
314
|
+
<th class="phase-head" data-emotion="${ph.emotion || 'neutral'}"
|
|
315
|
+
style="--ec:${m.color}">
|
|
316
|
+
<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>
|
|
319
|
+
${stories.length
|
|
320
|
+
? `<div class="ph-stories">${stories.map(id =>
|
|
321
|
+
`<a class="chip chip--story" href="#${id}">${this._label(id)}</a>`
|
|
322
|
+
).join('')}</div>`
|
|
323
|
+
: ''}
|
|
324
|
+
</th>`;
|
|
325
|
+
}).join('');
|
|
326
|
+
|
|
327
|
+
const bodyRows = ROWS.map(({ key, label }) => {
|
|
328
|
+
const cells = phases.map(ph => {
|
|
329
|
+
const items = ph[key] || [];
|
|
330
|
+
if (!items.length) return `<td class="data-cell data-cell--empty">\u2014</td>`;
|
|
331
|
+
return `<td class="data-cell data-cell--${key.toLowerCase()}">
|
|
332
|
+
${items.map(t => `<p>${esc(t)}</p>`).join('')}
|
|
333
|
+
</td>`;
|
|
334
|
+
}).join('');
|
|
335
|
+
return `
|
|
336
|
+
<tr class="grid-row grid-row--${key.toLowerCase()}">
|
|
337
|
+
<th class="row-label">${label}</th>
|
|
338
|
+
${cells}
|
|
339
|
+
</tr>`;
|
|
340
|
+
}).join('');
|
|
341
|
+
|
|
342
|
+
return `
|
|
343
|
+
<div class="journey__grid-wrap">
|
|
344
|
+
<table class="journey__grid"
|
|
345
|
+
aria-label="${esc(this.getAttribute('title') || 'User Journey')} \u2014 phase breakdown">
|
|
346
|
+
<thead>
|
|
347
|
+
<tr>
|
|
348
|
+
<th class="corner">Stage</th>
|
|
349
|
+
${headCells}
|
|
350
|
+
</tr>
|
|
351
|
+
</thead>
|
|
352
|
+
<tbody>${bodyRows}</tbody>
|
|
353
|
+
</table>
|
|
354
|
+
</div>`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* ── Helpers ───────────────────────────────── */
|
|
358
|
+
|
|
359
|
+
_label(id) {
|
|
360
|
+
return id.replace(/^(activity|persona|journey|story|user)-/, '')
|
|
361
|
+
.replace(/-/g, ' ')
|
|
362
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
registerComponent('user-journey', UserJourney);
|
|
367
|
+
|
|
368
|
+
export { UserJourney };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
user-journey — static (zero-JS) form
|
|
3
|
+
Without JS, shows the journey metadata. Full visualization requires JS.
|
|
4
|
+
-->
|
|
5
|
+
<user-journey
|
|
6
|
+
title="API Platform Evaluation"
|
|
7
|
+
persona="Sarah Chen"
|
|
8
|
+
persona-id="persona-sarah-chen"
|
|
9
|
+
summary="How a developer discovers, evaluates, and adopts our platform"
|
|
10
|
+
story-ids="PROJ-142,PROJ-156">
|
|
11
|
+
<noscript>
|
|
12
|
+
<p>This journey map requires JavaScript to render the emotion curve and phase grid.</p>
|
|
13
|
+
</noscript>
|
|
14
|
+
</user-journey>
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow DOM styles for <user-journey>
|
|
3
|
+
*
|
|
4
|
+
* Theme integration:
|
|
5
|
+
* --user-journey-* (per-instance author override)
|
|
6
|
+
* --color-* (VB theme token — adapts to data-theme and color mode)
|
|
7
|
+
* #fallback (last-resort hex for component used outside VB)
|
|
8
|
+
*
|
|
9
|
+
* VB tokens already swap on color-scheme change and across themes, so we
|
|
10
|
+
* deliberately DO NOT define our own @media (prefers-color-scheme: dark)
|
|
11
|
+
* overrides — those would override the user's chosen VB theme. Semantic
|
|
12
|
+
* tints (positive/neutral/negative emotion zones, painpoint/opportunity
|
|
13
|
+
* row tints) are derived via color-mix from the corresponding theme
|
|
14
|
+
* tokens (--color-success/--color-warning/--color-error) so they shift
|
|
15
|
+
* automatically with the active theme.
|
|
16
|
+
*/
|
|
17
|
+
const styles = `
|
|
18
|
+
:host {
|
|
19
|
+
display: block;
|
|
20
|
+
font-family: var(--_font-sans);
|
|
21
|
+
line-height: 1.6;
|
|
22
|
+
color: var(--_text);
|
|
23
|
+
container-type: inline-size;
|
|
24
|
+
|
|
25
|
+
--_bg: var(--user-journey-bg, var(--color-surface-raised, #f8f9fa));
|
|
26
|
+
--_card: var(--user-journey-card, var(--color-surface, #ffffff));
|
|
27
|
+
--_border: var(--user-journey-border, var(--color-border, #e0e0e0));
|
|
28
|
+
--_muted: var(--user-journey-muted, var(--color-text-muted, #666666));
|
|
29
|
+
--_text: var(--user-journey-text, var(--color-text, #1a1a1a));
|
|
30
|
+
--_inverted: var(--user-journey-text-inverted, var(--color-text-inverted, #ffffff));
|
|
31
|
+
--_primary: var(--user-journey-primary, var(--color-primary, var(--color-interactive, #6366f1)));
|
|
32
|
+
--_accent: var(--user-journey-accent, var(--color-accent, #8b5cf6));
|
|
33
|
+
--_link: var(--user-journey-link, var(--color-interactive, var(--color-primary, #6366f1)));
|
|
34
|
+
--_curve-stroke: var(--user-journey-curve-stroke, var(--_primary));
|
|
35
|
+
--_radius: var(--user-journey-radius, var(--radius-l, 0.75rem));
|
|
36
|
+
|
|
37
|
+
--_font-sans: var(--user-journey-font, var(--font-sans, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif));
|
|
38
|
+
--_font-mono: var(--user-journey-font-mono, var(--font-mono, ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, monospace));
|
|
39
|
+
--_font-xs: var(--user-journey-font-xs, var(--font-size-xs, 0.75rem));
|
|
40
|
+
--_font-sm: var(--user-journey-font-sm, var(--font-size-sm, 0.875rem));
|
|
41
|
+
--_font-md: var(--user-journey-font-md, var(--font-size-md, 1rem));
|
|
42
|
+
--_font-xl: var(--user-journey-font-xl, var(--font-size-xl, 1.25rem));
|
|
43
|
+
--_space-2xs: var(--user-journey-space-2xs, var(--size-2xs, 0.25rem));
|
|
44
|
+
--_space-xs: var(--user-journey-space-xs, var(--size-xs, 0.5rem));
|
|
45
|
+
--_space-s: var(--user-journey-space-s, var(--size-s, 0.75rem));
|
|
46
|
+
--_space-m: var(--user-journey-space-m, var(--size-m, 1rem));
|
|
47
|
+
--_space-l: var(--user-journey-space-l, var(--size-l, 1.5rem));
|
|
48
|
+
|
|
49
|
+
/* Semantic tints derived from theme tokens via color-mix.
|
|
50
|
+
Subtle backgrounds (10–14%) for body cells, header at 22%. */
|
|
51
|
+
--_tint-pos: color-mix(in oklch, var(--color-success, #22c55e) 14%, var(--_card));
|
|
52
|
+
--_tint-neu: color-mix(in oklch, var(--color-warning, #f59e0b) 12%, var(--_card));
|
|
53
|
+
--_tint-neg: color-mix(in oklch, var(--color-error, #ef4444) 12%, var(--_card));
|
|
54
|
+
--_tint-row-pain: color-mix(in oklch, var(--color-error, #ef4444) 8%, var(--_card));
|
|
55
|
+
--_tint-row-opp: color-mix(in oklch, var(--color-success, #22c55e) 8%, var(--_card));
|
|
56
|
+
--_chip-type-bg: color-mix(in oklch, var(--_accent) 18%, var(--_card));
|
|
57
|
+
--_chip-type-fg: var(--color-accent-text, var(--_accent));
|
|
58
|
+
--_chip-story-bg: color-mix(in oklch, var(--_link) 18%, var(--_card));
|
|
59
|
+
--_chip-story-bg-hover: color-mix(in oklch, var(--_link) 28%, var(--_card));
|
|
60
|
+
--_chip-story-fg: var(--_link);
|
|
61
|
+
--_grid-head-bg: color-mix(in oklch, var(--_primary) 85%, var(--color-text, #000));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; }
|
|
65
|
+
|
|
66
|
+
/* Card */
|
|
67
|
+
.journey {
|
|
68
|
+
background: var(--_card);
|
|
69
|
+
border: 1px solid var(--_border);
|
|
70
|
+
border-radius: var(--_radius);
|
|
71
|
+
overflow: hidden;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Header */
|
|
75
|
+
.journey__header {
|
|
76
|
+
padding: 20px 24px 16px 28px;
|
|
77
|
+
border-block-end: 1px solid var(--_border);
|
|
78
|
+
position: relative;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Left accent bar */
|
|
82
|
+
.journey__header::before {
|
|
83
|
+
content: '';
|
|
84
|
+
position: absolute;
|
|
85
|
+
inset-block: 0;
|
|
86
|
+
inset-inline-start: 0;
|
|
87
|
+
width: 4px;
|
|
88
|
+
background: linear-gradient(135deg, var(--_primary), var(--_accent));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.journey__header-top {
|
|
92
|
+
display: flex;
|
|
93
|
+
align-items: flex-start;
|
|
94
|
+
justify-content: space-between;
|
|
95
|
+
gap: var(--_space-s);
|
|
96
|
+
flex-wrap: wrap;
|
|
97
|
+
margin-block-end: var(--_space-xs);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.journey__chips {
|
|
101
|
+
display: flex;
|
|
102
|
+
flex-wrap: wrap;
|
|
103
|
+
align-items: center;
|
|
104
|
+
gap: 6px;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* Chips */
|
|
108
|
+
.chip {
|
|
109
|
+
display: inline-block;
|
|
110
|
+
font-size: 11px;
|
|
111
|
+
font-weight: 700;
|
|
112
|
+
letter-spacing: 0.05em;
|
|
113
|
+
padding: 2px 9px;
|
|
114
|
+
border-radius: 99px;
|
|
115
|
+
text-decoration: none;
|
|
116
|
+
line-height: 1.6;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.chip--type {
|
|
120
|
+
color: var(--_chip-type-fg);
|
|
121
|
+
background: var(--_chip-type-bg);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.chip--story {
|
|
125
|
+
color: var(--_chip-story-fg);
|
|
126
|
+
background: var(--_chip-story-bg);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.chip--story:hover { background: var(--_chip-story-bg-hover); }
|
|
130
|
+
|
|
131
|
+
/* Persona ref */
|
|
132
|
+
.persona-ref {
|
|
133
|
+
display: inline-flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
gap: 4px;
|
|
136
|
+
font-size: 13px;
|
|
137
|
+
font-weight: 600;
|
|
138
|
+
color: var(--_muted);
|
|
139
|
+
text-decoration: none;
|
|
140
|
+
white-space: nowrap;
|
|
141
|
+
}
|
|
142
|
+
.persona-ref svg { width: 14px; height: 14px; flex-shrink: 0; }
|
|
143
|
+
a.persona-ref:hover { color: var(--_link); text-decoration: underline; }
|
|
144
|
+
|
|
145
|
+
/* Title & summary (slotted content) */
|
|
146
|
+
.journey__title-wrap {
|
|
147
|
+
margin-block-end: var(--_space-2xs);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.journey__title {
|
|
151
|
+
font-size: var(--_font-xl);
|
|
152
|
+
font-weight: 700;
|
|
153
|
+
color: var(--_text);
|
|
154
|
+
margin: 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
::slotted([slot="title"]) {
|
|
158
|
+
font-size: var(--_font-xl) !important;
|
|
159
|
+
font-weight: 700 !important;
|
|
160
|
+
color: var(--_text) !important;
|
|
161
|
+
margin: 0 !important;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.journey--compact .journey__title,
|
|
165
|
+
.journey--compact ::slotted([slot="title"]) { font-size: var(--_font-md) !important; }
|
|
166
|
+
|
|
167
|
+
.journey__summary-wrap {
|
|
168
|
+
max-width: 72ch;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
::slotted([slot="summary"]) {
|
|
172
|
+
font-size: var(--_font-sm) !important;
|
|
173
|
+
color: var(--_muted) !important;
|
|
174
|
+
margin: 0 !important;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* Emotion curve */
|
|
178
|
+
.journey__curve {
|
|
179
|
+
background: var(--_bg);
|
|
180
|
+
border-block-end: 1px solid var(--_border);
|
|
181
|
+
overflow: hidden;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.curve-svg {
|
|
185
|
+
display: block;
|
|
186
|
+
width: 100%;
|
|
187
|
+
height: 80px;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.journey--compact .curve-svg { height: 54px; }
|
|
191
|
+
|
|
192
|
+
.zone { opacity: 0.55; }
|
|
193
|
+
.zone--pos { fill: var(--_tint-pos); }
|
|
194
|
+
.zone--neu { fill: var(--_tint-neu); }
|
|
195
|
+
.zone--neg { fill: var(--_tint-neg); }
|
|
196
|
+
|
|
197
|
+
.vline { stroke: var(--_border); stroke-width: 1; stroke-dasharray: 3 4; }
|
|
198
|
+
.curve-line { stroke: var(--_curve-stroke); stroke-width: 2.5; stroke-linecap: round; }
|
|
199
|
+
.dot { stroke: var(--_card); stroke-width: 2.5; }
|
|
200
|
+
|
|
201
|
+
/* Grid */
|
|
202
|
+
.journey__grid-wrap { overflow-x: auto; }
|
|
203
|
+
|
|
204
|
+
.journey__grid {
|
|
205
|
+
width: 100%;
|
|
206
|
+
border-collapse: collapse;
|
|
207
|
+
font-size: 13px;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* Head row */
|
|
211
|
+
.journey__grid thead tr {
|
|
212
|
+
background: var(--_grid-head-bg);
|
|
213
|
+
color: var(--_inverted);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.corner {
|
|
217
|
+
padding: 10px 12px;
|
|
218
|
+
font-size: 10px;
|
|
219
|
+
font-weight: 700;
|
|
220
|
+
text-transform: uppercase;
|
|
221
|
+
letter-spacing: 0.08em;
|
|
222
|
+
opacity: 0.55;
|
|
223
|
+
text-align: left;
|
|
224
|
+
white-space: nowrap;
|
|
225
|
+
min-width: 100px;
|
|
226
|
+
position: sticky;
|
|
227
|
+
left: 0;
|
|
228
|
+
background: var(--_grid-head-bg);
|
|
229
|
+
z-index: 2;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.phase-head {
|
|
233
|
+
padding: 10px 14px;
|
|
234
|
+
text-align: left;
|
|
235
|
+
vertical-align: top;
|
|
236
|
+
border-inline-start: 1px solid color-mix(in oklch, var(--_inverted) 12%, transparent);
|
|
237
|
+
min-width: 160px;
|
|
238
|
+
position: relative;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* Emotion-coloured top accent per phase */
|
|
242
|
+
.phase-head::before {
|
|
243
|
+
content: '';
|
|
244
|
+
position: absolute;
|
|
245
|
+
inset-block-start: 0;
|
|
246
|
+
inset-inline: 0;
|
|
247
|
+
height: 3px;
|
|
248
|
+
background: var(--ec, var(--_primary));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.ph-num { display: block; font-size: 10px; opacity: 0.5; margin-block-end: 2px; }
|
|
252
|
+
.ph-name { display: block; font-size: var(--_font-sm); font-weight: 700; line-height: 1.2; }
|
|
253
|
+
.ph-emoji { display: block; font-size: var(--_font-xl); line-height: 1; margin-block: 4px 2px; }
|
|
254
|
+
|
|
255
|
+
.ph-stories { display: flex; flex-wrap: wrap; gap: var(--_space-2xs); margin-block-start: var(--_space-2xs); }
|
|
256
|
+
|
|
257
|
+
/* Body rows */
|
|
258
|
+
.grid-row th,
|
|
259
|
+
.grid-row td {
|
|
260
|
+
padding: 10px 12px;
|
|
261
|
+
border-block-end: 1px solid var(--_border);
|
|
262
|
+
border-inline-start: 1px solid var(--_border);
|
|
263
|
+
vertical-align: top;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.grid-row th:first-child { border-inline-start: none; }
|
|
267
|
+
|
|
268
|
+
.row-label {
|
|
269
|
+
font-size: 11px;
|
|
270
|
+
font-weight: 700;
|
|
271
|
+
text-transform: uppercase;
|
|
272
|
+
letter-spacing: 0.07em;
|
|
273
|
+
color: var(--_muted);
|
|
274
|
+
white-space: nowrap;
|
|
275
|
+
background: var(--_bg);
|
|
276
|
+
text-align: left;
|
|
277
|
+
position: sticky;
|
|
278
|
+
left: 0;
|
|
279
|
+
z-index: 1;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.data-cell { font-size: 13px; line-height: 1.45; }
|
|
283
|
+
.data-cell p { margin-block: 0 4px; }
|
|
284
|
+
.data-cell p:last-child { margin-block-end: 0; }
|
|
285
|
+
.data-cell--empty { color: var(--_muted); opacity: 0.35; }
|
|
286
|
+
|
|
287
|
+
/* Semantic row tints */
|
|
288
|
+
.grid-row--painpoints .data-cell { background: var(--_tint-row-pain); }
|
|
289
|
+
.grid-row--opportunities .data-cell { background: var(--_tint-row-opp); }
|
|
290
|
+
|
|
291
|
+
/* Compact */
|
|
292
|
+
.journey--compact .phase-head { min-width: 120px; padding: 8px 10px; }
|
|
293
|
+
.journey--compact .data-cell { font-size: 12px; padding: 7px 10px; }
|
|
294
|
+
.journey--compact .corner { min-width: 80px; }
|
|
295
|
+
|
|
296
|
+
/* Utility */
|
|
297
|
+
.state-msg { padding: var(--_space-l); font-size: var(--_font-sm); color: var(--_muted); font-style: italic; }
|
|
298
|
+
.state-msg--error { color: var(--color-error-text, var(--color-error, #dc2626)); }
|
|
299
|
+
.journey__placeholder { padding: 20px 24px; font-size: var(--_font-sm); color: var(--_muted); }
|
|
300
|
+
code { font-family: var(--_font-mono); font-size: 0.88em; }
|
|
301
|
+
|
|
302
|
+
/* Responsive */
|
|
303
|
+
@container (max-width: 500px) {
|
|
304
|
+
.journey__header { padding: 14px 16px 12px 20px; }
|
|
305
|
+
.journey__title { font-size: 17px; }
|
|
306
|
+
.corner, .row-label { min-width: 72px; font-size: 9px; }
|
|
307
|
+
.phase-head { min-width: 110px; padding: 8px 10px; }
|
|
308
|
+
.data-cell { font-size: 12px; padding: 8px 10px; }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
@media print {
|
|
312
|
+
.journey__grid-wrap { overflow: visible; }
|
|
313
|
+
.row-label, .corner { position: relative; }
|
|
314
|
+
}
|
|
315
|
+
`;
|
|
316
|
+
|
|
317
|
+
export { styles };
|