@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,460 @@
1
+ /**
2
+ * roadmap: Interactive themes × quarters product roadmap
3
+ *
4
+ * Renders a horizontal time axis (quarters or months) with one
5
+ * swimlane per <section data-lane>. Each <article data-start data-end>
6
+ * inside a lane becomes an absolutely-positioned bar spanning its
7
+ * date range.
8
+ *
9
+ * Distinct from <gantt-chart>: roadmap is theme-centric (initiatives
10
+ * in lanes, status as enum, no dependency arrows). Gantt is task-
11
+ * scheduling. Use roadmap for quarterly product roadmaps; gantt for
12
+ * project plans.
13
+ *
14
+ * @attr {string} start - First date in the range (ISO 8601 or "YYYY-Qn"; required)
15
+ * @attr {string} end - Last date in the range (ISO 8601 or "YYYY-Qn"; required, inclusive)
16
+ * @attr {string} view - "quarter" (default) or "month" axis granularity
17
+ * @attr {boolean} editable - Wires drag-to-reschedule, drag-to-resize, lane drop
18
+ * @attr {boolean} today-marker - Render a vertical line at today's date if it falls in range
19
+ *
20
+ * Children:
21
+ * <section data-lane="..."> — one swimlane row
22
+ * <article data-start="..." data-end="..." data-status="..."> — one initiative bar
23
+ * <h3>Title</h3>
24
+ * <p>Optional description (visible in detail / tooltip)</p>
25
+ * </article>
26
+ * </section>
27
+ *
28
+ * @fires roadmap:select - { initiative, lane, start, end, status } on click
29
+ * @fires roadmap:reschedule - { initiative, lane, start, end } when bar dragged along time
30
+ * @fires roadmap:resize - { initiative, lane, start, end } when right edge dragged
31
+ * @fires roadmap:move - { initiative, fromLane, toLane, start, end } when bar dropped on another lane
32
+ */
33
+
34
+ import { registerComponent } from '../../lib/bundle-registry.js';
35
+ import { VBElement } from '../../lib/vb-element.js';
36
+
37
+ const MS_PER_DAY = 86_400_000;
38
+
39
+ class Roadmap extends VBElement {
40
+ setup() {
41
+ const startAttr = this.getAttribute('start');
42
+ const endAttr = this.getAttribute('end');
43
+ if (!startAttr || !endAttr) {
44
+ console.warn('product-roadmap: start and end attributes required');
45
+ return false;
46
+ }
47
+ const start = Roadmap.#parseDate(startAttr, 'start');
48
+ const end = Roadmap.#parseDate(endAttr, 'end');
49
+ if (!start || !end || end <= start) {
50
+ console.warn('product-roadmap: invalid start/end', startAttr, endAttr);
51
+ return false;
52
+ }
53
+ this.#start = start;
54
+ this.#end = end;
55
+ this.#totalMs = end.getTime() - start.getTime();
56
+ this.#view = (this.getAttribute('view') === 'month') ? 'month' : 'quarter';
57
+ this.#editable = this.hasAttribute('editable');
58
+
59
+ /* Snapshot original lane sections (auto-discovery) before we wipe
60
+ the host. We do not modify the original elements until/unless
61
+ drag interactions change their data-* attributes. */
62
+ const lanes = [...this.querySelectorAll(':scope > section[data-lane]')];
63
+ if (!lanes.length) {
64
+ console.warn('product-roadmap: no <section data-lane> children found');
65
+ return false;
66
+ }
67
+ this.#sourceLanes = lanes;
68
+ this.#render();
69
+ return true;
70
+ }
71
+
72
+ attributeChangedCallback() {
73
+ if (this.isConnected && this.#sourceLanes.length) this.#render();
74
+ }
75
+
76
+ #render() {
77
+ /* Wipe everything except the source lanes (which we re-read on
78
+ each render so author edits to data-start/data-end are picked up). */
79
+ [...this.children].forEach((c) => {
80
+ if (!this.#sourceLanes.includes(c)) c.remove();
81
+ });
82
+ /* Move source lanes out of the host so they don't display directly. */
83
+ this.#sourceLanes.forEach((lane) => { /** @type {HTMLElement} */ (lane).hidden = true; });
84
+
85
+ const wrapper = document.createElement('div');
86
+ wrapper.className = 'rm-wrapper';
87
+
88
+ /* --- time axis header --- */
89
+ const axis = this.#buildAxis();
90
+ wrapper.appendChild(axis);
91
+
92
+ /* --- lanes --- */
93
+ const lanesEl = document.createElement('div');
94
+ lanesEl.className = 'rm-lanes';
95
+
96
+ for (const sourceLane of this.#sourceLanes) {
97
+ const laneName = sourceLane.getAttribute('data-lane') || '';
98
+ const laneEl = document.createElement('section');
99
+ laneEl.className = 'rm-lane';
100
+ laneEl.dataset.lane = laneName;
101
+
102
+ const laneLabel = document.createElement('header');
103
+ laneLabel.className = 'rm-lane-label';
104
+ laneLabel.textContent = laneName;
105
+ laneEl.appendChild(laneLabel);
106
+
107
+ const track = document.createElement('div');
108
+ track.className = 'rm-track';
109
+ track.dataset.lane = laneName;
110
+
111
+ /* For each <article> in the source lane, build a bar. */
112
+ const initiatives = [...sourceLane.querySelectorAll(':scope > article[data-start][data-end]')];
113
+ for (const init of initiatives) {
114
+ const bar = this.#buildBar(init, laneName);
115
+ if (bar) track.appendChild(bar);
116
+ }
117
+
118
+ if (this.#editable) {
119
+ track.addEventListener('dragover', this.#onTrackDragOver);
120
+ track.addEventListener('drop', this.#onTrackDrop);
121
+ }
122
+
123
+ laneEl.appendChild(track);
124
+ lanesEl.appendChild(laneEl);
125
+ }
126
+
127
+ wrapper.appendChild(lanesEl);
128
+
129
+ if (this.hasAttribute('today-marker') && this.#start && this.#end) {
130
+ const today = new Date();
131
+ if (today >= this.#start && today <= this.#end) {
132
+ const marker = document.createElement('div');
133
+ marker.className = 'rm-today';
134
+ marker.style.setProperty('--rm-x', this.#fractionFor(today).toFixed(4));
135
+ marker.title = `Today: ${Roadmap.#toISO(today)}`;
136
+ lanesEl.appendChild(marker);
137
+ }
138
+ }
139
+
140
+ this.appendChild(wrapper);
141
+ this.#wrapper = wrapper;
142
+ }
143
+
144
+ #buildAxis() {
145
+ const axis = document.createElement('header');
146
+ axis.className = 'rm-axis';
147
+ const ticks = this.#enumerateTicks();
148
+ for (const tick of ticks) {
149
+ const cell = document.createElement('div');
150
+ cell.className = 'rm-tick';
151
+ cell.style.setProperty('--rm-x', tick.startFraction.toFixed(4));
152
+ cell.style.setProperty('--rm-w', (tick.endFraction - tick.startFraction).toFixed(4));
153
+ cell.textContent = tick.label;
154
+ axis.appendChild(cell);
155
+ }
156
+ return axis;
157
+ }
158
+
159
+ #buildBar(initiative, laneName) {
160
+ const start = Roadmap.#parseDate(initiative.getAttribute('data-start'), 'start');
161
+ const end = Roadmap.#parseDate(initiative.getAttribute('data-end'), 'end');
162
+ if (!start || !end || !this.#start || !this.#end) return null;
163
+
164
+ /* Clip to visible range. */
165
+ const clipStart = start < this.#start ? this.#start : start;
166
+ const clipEnd = end > this.#end ? this.#end : end;
167
+ if (clipStart >= clipEnd) return null;
168
+
169
+ const x = this.#fractionFor(clipStart);
170
+ const w = this.#fractionFor(clipEnd) - x;
171
+
172
+ const bar = document.createElement('article');
173
+ bar.className = 'rm-bar';
174
+ bar.style.setProperty('--rm-x', x.toFixed(4));
175
+ bar.style.setProperty('--rm-w', w.toFixed(4));
176
+ bar.dataset.lane = laneName;
177
+ bar.dataset.start = Roadmap.#toISO(start);
178
+ bar.dataset.end = Roadmap.#toISO(end);
179
+ if (initiative.dataset.status) bar.dataset.status = initiative.dataset.status;
180
+ if (initiative.id) bar.dataset.sourceId = initiative.id;
181
+ if (this.#editable) bar.setAttribute('draggable', 'true');
182
+
183
+ /* Inner content: clone the title and details. */
184
+ const title = initiative.querySelector('h1, h2, h3, h4, h5, h6');
185
+ const heading = document.createElement('span');
186
+ heading.className = 'rm-bar-title';
187
+ heading.textContent = title ? title.textContent.trim() : (initiative.textContent || '').trim().split('\n')[0];
188
+ bar.appendChild(heading);
189
+
190
+ bar.setAttribute('aria-label', `${heading.textContent} — ${bar.dataset.start} to ${bar.dataset.end} in ${laneName}`);
191
+
192
+ /* Click to select. */
193
+ bar.addEventListener('click', (e) => {
194
+ if (this.#dragInfo) return;
195
+ this.dispatchEvent(new CustomEvent('product-roadmap:select', {
196
+ bubbles: true,
197
+ detail: {
198
+ initiative: initiative,
199
+ lane: laneName,
200
+ start: bar.dataset.start,
201
+ end: bar.dataset.end,
202
+ status: bar.dataset.status || null,
203
+ },
204
+ }));
205
+ });
206
+
207
+ if (this.#editable) {
208
+ /* Resize handle. */
209
+ const handle = document.createElement('span');
210
+ handle.className = 'rm-bar-handle';
211
+ handle.setAttribute('aria-label', 'Resize end date');
212
+ bar.appendChild(handle);
213
+
214
+ handle.addEventListener('pointerdown', (e) => this.#startResize(e, bar, initiative));
215
+ bar.addEventListener('pointerdown', (e) => {
216
+ if (e.target === handle) return;
217
+ this.#startMove(e, bar, initiative);
218
+ });
219
+
220
+ /* HTML5 DnD for cross-lane drops. */
221
+ bar.addEventListener('dragstart', (e) => {
222
+ if (e.dataTransfer) {
223
+ e.dataTransfer.effectAllowed = 'move';
224
+ e.dataTransfer.setData('text/plain', initiative.id || bar.dataset.sourceId || '');
225
+ }
226
+ this.#draggingInitiative = initiative;
227
+ this.#draggingFromLane = laneName;
228
+ });
229
+ bar.addEventListener('dragend', () => {
230
+ this.#draggingInitiative = null;
231
+ this.#draggingFromLane = null;
232
+ });
233
+ }
234
+
235
+ return bar;
236
+ }
237
+
238
+ /* --- pointer drag (move along time axis) --- */
239
+ #startMove(e, bar, initiative) {
240
+ if (e.button !== 0) return;
241
+ e.preventDefault();
242
+ const trackRect = bar.parentElement.getBoundingClientRect();
243
+ const startX = e.clientX;
244
+ const startFraction = parseFloat(bar.style.getPropertyValue('--rm-x')) || 0;
245
+ const widthFraction = parseFloat(bar.style.getPropertyValue('--rm-w')) || 0;
246
+
247
+ this.#dragInfo = { kind: 'move', bar, initiative, trackRect, startX, startFraction, widthFraction };
248
+ bar.setPointerCapture(e.pointerId);
249
+ bar.addEventListener('pointermove', this.#onPointerMove);
250
+ bar.addEventListener('pointerup', this.#onPointerUp);
251
+ bar.addEventListener('pointercancel', this.#onPointerUp);
252
+ }
253
+
254
+ #startResize(e, bar, initiative) {
255
+ if (e.button !== 0) return;
256
+ e.preventDefault();
257
+ e.stopPropagation();
258
+ const trackRect = bar.parentElement.getBoundingClientRect();
259
+ const startX = e.clientX;
260
+ const startFraction = parseFloat(bar.style.getPropertyValue('--rm-x')) || 0;
261
+ const widthFraction = parseFloat(bar.style.getPropertyValue('--rm-w')) || 0;
262
+
263
+ this.#dragInfo = { kind: 'resize', bar, initiative, trackRect, startX, startFraction, widthFraction };
264
+ bar.setPointerCapture(e.pointerId);
265
+ bar.addEventListener('pointermove', this.#onPointerMove);
266
+ bar.addEventListener('pointerup', this.#onPointerUp);
267
+ bar.addEventListener('pointercancel', this.#onPointerUp);
268
+ }
269
+
270
+ #onPointerMove = (e) => {
271
+ if (!this.#dragInfo) return;
272
+ const { kind, bar, trackRect, startX, startFraction, widthFraction } = this.#dragInfo;
273
+ const dxFraction = (e.clientX - startX) / trackRect.width;
274
+
275
+ if (kind === 'move') {
276
+ const newStart = Math.max(0, Math.min(1 - widthFraction, startFraction + dxFraction));
277
+ bar.style.setProperty('--rm-x', newStart.toFixed(4));
278
+ } else if (kind === 'resize') {
279
+ const newWidth = Math.max(0.02, Math.min(1 - startFraction, widthFraction + dxFraction));
280
+ bar.style.setProperty('--rm-w', newWidth.toFixed(4));
281
+ }
282
+ };
283
+
284
+ #onPointerUp = (e) => {
285
+ if (!this.#dragInfo) return;
286
+ const { kind, bar, initiative } = this.#dragInfo;
287
+ bar.releasePointerCapture(e.pointerId);
288
+ bar.removeEventListener('pointermove', this.#onPointerMove);
289
+ bar.removeEventListener('pointerup', this.#onPointerUp);
290
+ bar.removeEventListener('pointercancel', this.#onPointerUp);
291
+
292
+ /* Snap to grid (quarter or month boundary) and update dates. */
293
+ const xFrac = parseFloat(bar.style.getPropertyValue('--rm-x')) || 0;
294
+ const wFrac = parseFloat(bar.style.getPropertyValue('--rm-w')) || 0;
295
+ const snappedStart = this.#snapDate(this.#dateForFraction(xFrac));
296
+ const snappedEnd = this.#snapDate(this.#dateForFraction(xFrac + wFrac));
297
+
298
+ bar.dataset.start = Roadmap.#toISO(snappedStart);
299
+ bar.dataset.end = Roadmap.#toISO(snappedEnd);
300
+ /* Re-snap visual to grid after drag. */
301
+ const snappedX = this.#fractionFor(snappedStart);
302
+ const snappedW = this.#fractionFor(snappedEnd) - snappedX;
303
+ bar.style.setProperty('--rm-x', snappedX.toFixed(4));
304
+ bar.style.setProperty('--rm-w', snappedW.toFixed(4));
305
+
306
+ /* Mirror back to the source <article> so the author's data is updated. */
307
+ if (initiative) {
308
+ initiative.setAttribute('data-start', bar.dataset.start ?? '');
309
+ initiative.setAttribute('data-end', bar.dataset.end ?? '');
310
+ }
311
+
312
+ this.dispatchEvent(new CustomEvent(kind === 'move' ? 'product-roadmap:reschedule' : 'product-roadmap:resize', {
313
+ bubbles: true,
314
+ detail: {
315
+ initiative,
316
+ lane: bar.dataset.lane,
317
+ start: bar.dataset.start,
318
+ end: bar.dataset.end,
319
+ },
320
+ }));
321
+
322
+ /* Clear dragInfo on next tick so any pending click event sees it
323
+ and skips select. */
324
+ setTimeout(() => { this.#dragInfo = null; }, 0);
325
+ };
326
+
327
+ #onTrackDragOver = (e) => {
328
+ if (!this.#draggingInitiative) return;
329
+ e.preventDefault();
330
+ e.dataTransfer.dropEffect = 'move';
331
+ };
332
+
333
+ #onTrackDrop = (e) => {
334
+ if (!this.#draggingInitiative) return;
335
+ e.preventDefault();
336
+ const targetLane = e.currentTarget.dataset.lane;
337
+ const initiative = this.#draggingInitiative;
338
+ const fromLane = this.#draggingFromLane;
339
+ if (targetLane === fromLane) return;
340
+
341
+ /* Move the source <article> to the new <section data-lane>. */
342
+ const newSourceLane = this.#sourceLanes.find((l) => l.getAttribute('data-lane') === targetLane);
343
+ if (newSourceLane) newSourceLane.appendChild(initiative);
344
+
345
+ this.dispatchEvent(new CustomEvent('product-roadmap:move', {
346
+ bubbles: true,
347
+ detail: {
348
+ initiative,
349
+ fromLane,
350
+ toLane: targetLane,
351
+ start: initiative.getAttribute('data-start'),
352
+ end: initiative.getAttribute('data-end'),
353
+ },
354
+ }));
355
+
356
+ this.#render();
357
+ };
358
+
359
+ /* --- date math --- */
360
+ #fractionFor(date) {
361
+ if (!this.#start) return 0;
362
+ const ms = date.getTime() - this.#start.getTime();
363
+ return Math.max(0, Math.min(1, ms / this.#totalMs));
364
+ }
365
+
366
+ #dateForFraction(frac) {
367
+ const start = this.#start ?? new Date();
368
+ return new Date(start.getTime() + frac * this.#totalMs);
369
+ }
370
+
371
+ #snapDate(date) {
372
+ if (this.#view === 'month') {
373
+ return new Date(date.getFullYear(), date.getMonth(), 1);
374
+ }
375
+ /* Quarter snap: round to nearest quarter boundary. */
376
+ const month = date.getMonth();
377
+ const qStartMonth = Math.floor(month / 3) * 3;
378
+ return new Date(date.getFullYear(), qStartMonth, 1);
379
+ }
380
+
381
+ #enumerateTicks() {
382
+ const ticks = [];
383
+ const start = this.#start;
384
+ const end = this.#end;
385
+ if (!start || !end) return ticks;
386
+ if (this.#view === 'quarter') {
387
+ let cursor = this.#snapDate(start);
388
+ while (cursor < end) {
389
+ const next = new Date(cursor.getFullYear(), cursor.getMonth() + 3, 1);
390
+ const startFraction = this.#fractionFor(cursor < start ? start : cursor);
391
+ const endFraction = this.#fractionFor(next > end ? end : next);
392
+ const q = Math.floor(cursor.getMonth() / 3) + 1;
393
+ ticks.push({ startFraction, endFraction, label: `Q${q} ${cursor.getFullYear()}` });
394
+ cursor = next;
395
+ }
396
+ } else {
397
+ let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
398
+ while (cursor < end) {
399
+ const next = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
400
+ const startFraction = this.#fractionFor(cursor < start ? start : cursor);
401
+ const endFraction = this.#fractionFor(next > end ? end : next);
402
+ const m = cursor.toLocaleString(undefined, { month: 'short' });
403
+ ticks.push({ startFraction, endFraction, label: `${m} ${cursor.getFullYear()}` });
404
+ cursor = next;
405
+ }
406
+ }
407
+ return ticks;
408
+ }
409
+
410
+ /** Parse "2026-Q3", "2026-Q3-end", or ISO date. `kind` decides how
411
+ "YYYY-Qn" without explicit position resolves: 'start' → first day
412
+ of the quarter; 'end' → last day. */
413
+ static #parseDate(value, kind) {
414
+ if (!value) return null;
415
+ const q = String(value).match(/^(\d{4})-Q([1-4])$/i);
416
+ if (q) {
417
+ const year = Number(q[1]);
418
+ const quarter = Number(q[2]);
419
+ const monthStart = (quarter - 1) * 3;
420
+ if (kind === 'end') {
421
+ /* Last day of last month of the quarter */
422
+ return new Date(year, monthStart + 3, 0);
423
+ }
424
+ return new Date(year, monthStart, 1);
425
+ }
426
+ const iso = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
427
+ if (iso) return new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]));
428
+ const d = new Date(value);
429
+ return isNaN(d.getTime()) ? null : d;
430
+ }
431
+
432
+ static #toISO(d) {
433
+ const m = String(d.getMonth() + 1).padStart(2, '0');
434
+ const day = String(d.getDate()).padStart(2, '0');
435
+ return `${d.getFullYear()}-${m}-${day}`;
436
+ }
437
+
438
+ /** @type {Date | null} */
439
+ #start = null;
440
+ /** @type {Date | null} */
441
+ #end = null;
442
+ #totalMs = 0;
443
+ /** @type {"quarter" | "month"} */
444
+ #view = 'quarter';
445
+ #editable = false;
446
+ /** @type {Element[]} */
447
+ #sourceLanes = [];
448
+ /** @type {HTMLElement | null} */
449
+ #wrapper = null;
450
+ /** @type {{ kind: string, bar: HTMLElement, initiative?: Element, trackRect: DOMRect, startX: number, startFraction: number, widthFraction: number } | null} */
451
+ #dragInfo = null;
452
+ /** @type {Element | null} */
453
+ #draggingInitiative = null;
454
+ /** @type {Element | null} */
455
+ #draggingFromLane = null;
456
+ }
457
+
458
+ registerComponent('product-roadmap', Roadmap);
459
+
460
+ export { Roadmap };
@@ -0,0 +1,44 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Roadmap — Static Fallback</title>
7
+ <style>
8
+ body { font-family: system-ui, sans-serif; padding: 2rem; }
9
+ section { margin-block: 1rem; }
10
+ section h2 { font-size: 0.9rem; text-transform: uppercase; }
11
+ article { padding: 0.5rem; background: #eee; border-radius: 0.25rem; margin-block: 0.25rem; }
12
+ </style>
13
+ </head>
14
+ <body>
15
+ <!--
16
+ Static fallback for <roadmap>.
17
+
18
+ Upgrade relationship:
19
+ - Without JS: lanes and initiatives render in source order. Each
20
+ <article data-start data-end> is just a card; the time axis +
21
+ bar positioning is purely additive once the component upgrades.
22
+ - With JS: the component reads start/end attributes, builds a
23
+ horizontal time axis (quarter or month), positions each bar by
24
+ its data-start/data-end fraction, and (with editable) wires
25
+ drag-to-reschedule, drag-to-resize, and drop-on-lane.
26
+ -->
27
+
28
+ <product-roadmap start="2026-Q3" end="2027-Q2">
29
+ <section data-lane="Platform">
30
+ <article data-start="2026-Q3" data-end="2026-Q4" data-status="in-progress">
31
+ <h3>Multi-tenant database</h3>
32
+ </article>
33
+ <article data-start="2027-Q1" data-end="2027-Q2" data-status="planned">
34
+ <h3>Edge cache layer</h3>
35
+ </article>
36
+ </section>
37
+ <section data-lane="Growth">
38
+ <article data-start="2026-Q4" data-end="2027-Q1" data-status="planned">
39
+ <h3>Onboarding rewrite</h3>
40
+ </article>
41
+ </section>
42
+ </product-roadmap>
43
+ </body>
44
+ </html>
@@ -0,0 +1,158 @@
1
+ /* roadmap: Interactive themes × quarters product roadmap */
2
+
3
+ product-roadmap:not(:defined) {
4
+ display: block;
5
+ }
6
+
7
+ product-roadmap {
8
+ display: block;
9
+ font-family: var(--font-sans, system-ui);
10
+ color: var(--color-text, #1a1a1a);
11
+ }
12
+
13
+ /* Hide author-supplied source lanes — the component reads them at
14
+ upgrade and renders its own visual structure. Without this rule
15
+ the raw <article> headings flash before upgrade. */
16
+ product-roadmap > section[data-lane] {
17
+ display: none;
18
+ }
19
+
20
+ @scope (product-roadmap) {
21
+ .rm-wrapper {
22
+ display: grid;
23
+ grid-template-columns: minmax(8rem, max-content) 1fr;
24
+ gap: var(--size-2xs, 0.25rem);
25
+ }
26
+
27
+ .rm-axis {
28
+ grid-column: 2;
29
+ position: relative;
30
+ block-size: var(--size-l, 1.5rem);
31
+ border-block-end: var(--border-width-thin, 1px) solid var(--color-border, oklch(85% 0 0));
32
+ margin-block-end: var(--size-2xs, 0.25rem);
33
+ }
34
+
35
+ .rm-tick {
36
+ position: absolute;
37
+ inset-block: 0;
38
+ inset-inline-start: calc(var(--rm-x) * 100%);
39
+ inline-size: calc(var(--rm-w) * 100%);
40
+ border-inline-end: var(--border-width-thin, 1px) dashed var(--color-border, oklch(85% 0 0));
41
+ padding-inline: var(--size-2xs, 0.25rem);
42
+ font-size: var(--font-size-xs, 0.75rem);
43
+ font-weight: 600;
44
+ text-transform: uppercase;
45
+ letter-spacing: 0.06em;
46
+ color: var(--color-text-muted, #666);
47
+ overflow: hidden;
48
+ white-space: nowrap;
49
+ text-overflow: ellipsis;
50
+ }
51
+
52
+ .rm-lanes {
53
+ grid-column: 1 / -1;
54
+ display: grid;
55
+ grid-template-columns: subgrid;
56
+ gap: var(--size-2xs, 0.25rem);
57
+ position: relative;
58
+ }
59
+
60
+ .rm-lane {
61
+ display: contents;
62
+ }
63
+
64
+ .rm-lane-label {
65
+ grid-column: 1;
66
+ font-size: var(--font-size-sm, 0.875rem);
67
+ font-weight: 600;
68
+ color: var(--color-text, currentColor);
69
+ padding: var(--size-xs, 0.5rem) var(--size-s, 0.75rem);
70
+ background: var(--color-surface-raised, oklch(96% 0 0));
71
+ border-radius: var(--radius-s, 0.25rem);
72
+ align-self: stretch;
73
+ display: flex;
74
+ align-items: center;
75
+ }
76
+
77
+ .rm-track {
78
+ grid-column: 2;
79
+ position: relative;
80
+ min-block-size: var(--size-2xl, 3rem);
81
+ background:
82
+ repeating-linear-gradient(
83
+ to right,
84
+ var(--color-surface, transparent),
85
+ var(--color-surface, transparent) calc(var(--rm-tick-w, 25%) - 1px),
86
+ var(--color-border, oklch(85% 0 0 / 0.4)) calc(var(--rm-tick-w, 25%) - 1px),
87
+ var(--color-border, oklch(85% 0 0 / 0.4)) var(--rm-tick-w, 25%)
88
+ );
89
+ border-radius: var(--radius-s, 0.25rem);
90
+ }
91
+
92
+ .rm-bar {
93
+ position: absolute;
94
+ inset-block: var(--size-2xs, 0.25rem);
95
+ inset-inline-start: calc(var(--rm-x) * 100%);
96
+ inline-size: calc(var(--rm-w) * 100%);
97
+ background: var(--color-accent, oklch(45% 0.18 260));
98
+ color: var(--color-text-on-primary, #fff);
99
+ border-radius: var(--radius-s, 0.25rem);
100
+ padding: var(--size-2xs, 0.25rem) var(--size-s, 0.75rem);
101
+ font-size: var(--font-size-sm, 0.875rem);
102
+ line-height: 1.2;
103
+ overflow: hidden;
104
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
105
+ cursor: grab;
106
+ user-select: none;
107
+ display: flex;
108
+ align-items: center;
109
+ gap: var(--size-2xs, 0.25rem);
110
+ }
111
+
112
+ .rm-bar:active { cursor: grabbing; }
113
+
114
+ .rm-bar-title {
115
+ flex: 1;
116
+ overflow: hidden;
117
+ white-space: nowrap;
118
+ text-overflow: ellipsis;
119
+ }
120
+
121
+ .rm-bar-handle {
122
+ position: absolute;
123
+ inset-block: 0;
124
+ inset-inline-end: 0;
125
+ inline-size: var(--size-xs, 0.5rem);
126
+ background: rgba(255, 255, 255, 0.25);
127
+ cursor: ew-resize;
128
+ border-start-end-radius: var(--radius-s, 0.25rem);
129
+ border-end-end-radius: var(--radius-s, 0.25rem);
130
+ }
131
+
132
+ .rm-bar[data-status="planned"] { background: var(--color-info, oklch(60% 0.12 240)); }
133
+ .rm-bar[data-status="in-progress"] { background: var(--color-accent, oklch(50% 0.16 260)); }
134
+ .rm-bar[data-status="at-risk"] { background: var(--color-warning, oklch(65% 0.16 60)); color: var(--color-text, #1a1a1a); }
135
+ .rm-bar[data-status="shipped"] { background: var(--color-success, oklch(55% 0.14 145)); }
136
+ .rm-bar[data-status="blocked"] { background: var(--color-danger, oklch(55% 0.18 25)); }
137
+
138
+ .rm-today {
139
+ position: absolute;
140
+ inset-block: 0;
141
+ inset-inline-start: calc(var(--rm-x) * 100%);
142
+ inline-size: 0;
143
+ border-inline-start: var(--border-width-medium, 2px) solid var(--color-warning, oklch(60% 0.18 60));
144
+ pointer-events: none;
145
+ z-index: 2;
146
+ /* Position the today line within the track column. The lanes use
147
+ subgrid, so we offset by the label column width. */
148
+ transform: translateX(0);
149
+ }
150
+ }
151
+
152
+ /* Read-only roadmap: bars don't show grab cursor or resize handle. */
153
+ product-roadmap:not([editable]) .rm-bar { cursor: default; }
154
+ product-roadmap:not([editable]) .rm-bar-handle { display: none; }
155
+
156
+ @media (prefers-reduced-motion: reduce) {
157
+ product-roadmap .rm-bar { transition: none; }
158
+ }