@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,807 @@
1
+ /**
2
+ * <review-surface> — Pin-based annotation overlay for design review
3
+ *
4
+ * Wraps any content and adds a transparent overlay for placing
5
+ * review comment pins. Supports pluggable persistence adapters.
6
+ *
7
+ * Data sources:
8
+ * 1. `src` attribute — URL to a JSON file
9
+ * 2. `.pins` property — set programmatically
10
+ * 3. Adapter — loaded via `adapter` attribute or `.adapter` property
11
+ *
12
+ * JSON schema:
13
+ * {
14
+ * "pins": [
15
+ * {
16
+ * "id": "...",
17
+ * "x": 42.5,
18
+ * "y": 67.3,
19
+ * "text": "Fix alignment",
20
+ * "author": "Sarah",
21
+ * "createdAt": "2026-04-06T14:30:00Z",
22
+ * "resolved": false,
23
+ * "replies": [{ "id": "...", "text": "...", "author": "...", "createdAt": "..." }]
24
+ * }
25
+ * ]
26
+ * }
27
+ *
28
+ * @typedef {object} PinReply
29
+ * @property {string} id
30
+ * @property {string} text
31
+ * @property {string} [author]
32
+ * @property {string} [createdAt]
33
+ *
34
+ * @typedef {object} Pin
35
+ * @property {string} id
36
+ * @property {number} x
37
+ * @property {number} y
38
+ * @property {string} text
39
+ * @property {string} [author]
40
+ * @property {string} [createdAt]
41
+ * @property {boolean} [resolved]
42
+ * @property {PinReply[]} [replies]
43
+ */
44
+
45
+ import { styles } from './styles.js';
46
+ import { registerComponent } from '../../lib/bundle-registry.js';
47
+ import { esc, initials, hashColor, lucideSvg, UX_ICONS } from '../_ux-base.js';
48
+ import { VBStore } from '../../lib/vb-store.js';
49
+ import { copyText } from '../../utils/copy-init.js';
50
+
51
+ /* ── Adapters ──────────────────────────────────────── */
52
+
53
+ class MemoryAdapter {
54
+ #pins = new Map();
55
+
56
+ async load() {
57
+ return [...this.#pins.values()];
58
+ }
59
+
60
+ async save(pin) {
61
+ if (!pin.id) pin.id = crypto.randomUUID();
62
+ this.#pins.set(pin.id, pin);
63
+ return pin;
64
+ }
65
+
66
+ async update(id, changes) {
67
+ const pin = this.#pins.get(id);
68
+ if (!pin) throw new Error(`Pin ${id} not found`);
69
+ Object.assign(pin, changes);
70
+ return pin;
71
+ }
72
+
73
+ async remove(id) {
74
+ this.#pins.delete(id);
75
+ }
76
+ }
77
+
78
+ class LocalStorageAdapter {
79
+ #key;
80
+
81
+ constructor(key = 'default') {
82
+ this.#key = key;
83
+ }
84
+
85
+ async #read() {
86
+ const pins = await VBStore.get('reviews', this.#key);
87
+ return Array.isArray(pins) ? pins : [];
88
+ }
89
+
90
+ async #write(pins) {
91
+ await VBStore.set('reviews', this.#key, pins);
92
+ }
93
+
94
+ async load() {
95
+ return this.#read();
96
+ }
97
+
98
+ async save(pin) {
99
+ if (!pin.id) pin.id = crypto.randomUUID();
100
+ const pins = await this.#read();
101
+ pins.push(pin);
102
+ await this.#write(pins);
103
+ return pin;
104
+ }
105
+
106
+ async update(id, changes) {
107
+ const pins = await this.#read();
108
+ const idx = pins.findIndex(p => p.id === id);
109
+ if (idx === -1) throw new Error(`Pin ${id} not found`);
110
+ Object.assign(pins[idx], changes);
111
+ await this.#write(pins);
112
+ return pins[idx];
113
+ }
114
+
115
+ async remove(id) {
116
+ const pins = (await this.#read()).filter(p => p.id !== id);
117
+ await this.#write(pins);
118
+ }
119
+ }
120
+
121
+ class RestAdapter {
122
+ #endpoint;
123
+
124
+ constructor(endpoint) {
125
+ if (!endpoint) throw new Error('RestAdapter requires an endpoint URL');
126
+ this.#endpoint = endpoint.replace(/\/$/, '');
127
+ }
128
+
129
+ async load() {
130
+ const res = await fetch(this.#endpoint);
131
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
132
+ const data = await res.json();
133
+ return Array.isArray(data) ? data : data.pins || [];
134
+ }
135
+
136
+ async save(pin) {
137
+ const res = await fetch(this.#endpoint, {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify(pin),
141
+ });
142
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
143
+ return res.json();
144
+ }
145
+
146
+ async update(id, changes) {
147
+ const res = await fetch(`${this.#endpoint}/${encodeURIComponent(id)}`, {
148
+ method: 'PATCH',
149
+ headers: { 'Content-Type': 'application/json' },
150
+ body: JSON.stringify(changes),
151
+ });
152
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
153
+ return res.json();
154
+ }
155
+
156
+ async remove(id) {
157
+ const res = await fetch(`${this.#endpoint}/${encodeURIComponent(id)}`, {
158
+ method: 'DELETE',
159
+ });
160
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
161
+ }
162
+ }
163
+
164
+ /* ── Component ─────────────────────────────────────── */
165
+
166
+ class ReviewSurface extends HTMLElement {
167
+ static get observedAttributes() {
168
+ return ['src', 'editable', 'adapter', 'endpoint', 'storage-key', 'author', 'compact', 'show-resolved'];
169
+ }
170
+
171
+ #slotCache = new Map();
172
+
173
+ constructor() {
174
+ super();
175
+ this.attachShadow({ mode: 'open' });
176
+
177
+ /** @type {import('./logic.js').Pin[]} */
178
+ this.__pins = [];
179
+ /** @type {MemoryAdapter|LocalStorageAdapter|RestAdapter|null} */
180
+ this.__adapter = null;
181
+ /** @type {string|null} */
182
+ this._activePin = null;
183
+ /** @type {boolean} */
184
+ this._annotating = false;
185
+ }
186
+
187
+ /* ── Properties ─────────────────────────────────── */
188
+
189
+ /** @returns {Object[]} */
190
+ get pins() { return this.__pins; }
191
+
192
+ /**
193
+ * Replace the pin list and re-render. Idempotent — assigning the
194
+ * same array reference is a no-op. Emits review-surface:pins-changed
195
+ * with `source: 'property'` so reactive consumers can filter their
196
+ * own assignments out of the event stream.
197
+ * @param {Object[]} data
198
+ */
199
+ set pins(data) {
200
+ const next = Array.isArray(data) ? data : [];
201
+ if (this.__pins === next) return;
202
+ this.__pins = next;
203
+ if (this.isConnected) this._render();
204
+ this.dispatchEvent(new CustomEvent('review-surface:pins-changed', {
205
+ detail: { pins: next, source: 'property' },
206
+ bubbles: true, composed: true,
207
+ }));
208
+ }
209
+
210
+ /** @returns {MemoryAdapter|LocalStorageAdapter|RestAdapter|null} */
211
+ get adapter() { return this.__adapter; }
212
+
213
+ /** @param {Object} adapterInstance */
214
+ set adapter(adapterInstance) {
215
+ this.__adapter = adapterInstance;
216
+ if (this.isConnected) this._loadFromAdapter();
217
+ }
218
+
219
+ /* ── Slot caching ───────────────────────────────── */
220
+
221
+ #cacheSlotValues() {
222
+ for (const child of this.children) {
223
+ const slot = child.getAttribute('slot');
224
+ if (slot) this.#slotCache.set(slot, child.textContent.trim());
225
+ }
226
+ }
227
+
228
+ _resolve(attr) {
229
+ return this.getAttribute(attr) || this.#slotCache.get(attr) || '';
230
+ }
231
+
232
+ /* ── Lifecycle ─────────────────────────────────── */
233
+
234
+ connectedCallback() {
235
+ this.#cacheSlotValues();
236
+ this.setAttribute('data-upgraded', '');
237
+ this.#resolveAdapter();
238
+
239
+ if (this.hasAttribute('src')) {
240
+ this._loadSrc(this.getAttribute('src'));
241
+ } else {
242
+ this._loadFromAdapter();
243
+ }
244
+ }
245
+
246
+ disconnectedCallback() {
247
+ this.removeAttribute('data-upgraded');
248
+ this.removeAttribute('data-annotating');
249
+ }
250
+
251
+ attributeChangedCallback(name) {
252
+ if (!this.isConnected) return;
253
+ if (name === 'src') {
254
+ this._loadSrc(this.getAttribute('src'));
255
+ } else if (name === 'adapter' || name === 'endpoint' || name === 'storage-key') {
256
+ this.#resolveAdapter();
257
+ this._loadFromAdapter();
258
+ } else {
259
+ this._render();
260
+ }
261
+ }
262
+
263
+ /* ── Adapter resolution ────────────────────────── */
264
+
265
+ #resolveAdapter() {
266
+ /* If a custom adapter was set via property, keep it */
267
+ if (this.__adapter && !(this.__adapter instanceof MemoryAdapter) &&
268
+ !(this.__adapter instanceof LocalStorageAdapter) &&
269
+ !(this.__adapter instanceof RestAdapter)) {
270
+ return;
271
+ }
272
+
273
+ const type = this.getAttribute('adapter') || 'memory';
274
+ switch (type) {
275
+ case 'local':
276
+ this.__adapter = new LocalStorageAdapter(this.getAttribute('storage-key') || 'default');
277
+ break;
278
+ case 'rest':
279
+ try {
280
+ this.__adapter = new RestAdapter(this.getAttribute('endpoint') ?? '');
281
+ } catch {
282
+ this.__adapter = new MemoryAdapter();
283
+ }
284
+ break;
285
+ default:
286
+ this.__adapter = new MemoryAdapter();
287
+ }
288
+ }
289
+
290
+ /* ── Data loading ──────────────────────────────── */
291
+
292
+ async _loadSrc(src) {
293
+ if (!src || !this.shadowRoot) return;
294
+ this.shadowRoot.innerHTML = `<style>${styles}</style>` +
295
+ `<div class="state-msg">Loading\u2026</div>`;
296
+ try {
297
+ const res = await fetch(src);
298
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
299
+ const data = await res.json();
300
+ this.__pins = Array.isArray(data) ? data : data.pins || [];
301
+ this._render();
302
+ } catch (err) {
303
+ const msg = err instanceof Error ? err.message : String(err);
304
+ this.shadowRoot.innerHTML = `<style>${styles}</style>` +
305
+ `<div class="state-msg state-msg--error">Could not load pins: ${esc(msg)}</div>`;
306
+ }
307
+ }
308
+
309
+ async _loadFromAdapter() {
310
+ if (!this.__adapter) return;
311
+ try {
312
+ this.__pins = await this.__adapter.load();
313
+ } catch {
314
+ this.__pins = [];
315
+ }
316
+ this._render();
317
+ }
318
+
319
+ /* ── Public API ────────────────────────────────── */
320
+
321
+ /**
322
+ * Add a pin programmatically.
323
+ * @param {{ x: number, y: number, text: string, author?: string }} pinData
324
+ * @returns {Promise<Object>}
325
+ */
326
+ async addPin(pinData) {
327
+ const pin = {
328
+ id: crypto.randomUUID(),
329
+ x: Math.max(0, Math.min(100, pinData.x)),
330
+ y: Math.max(0, Math.min(100, pinData.y)),
331
+ text: pinData.text || '',
332
+ author: pinData.author || this.getAttribute('author') || 'Anonymous',
333
+ createdAt: new Date().toISOString(),
334
+ resolved: false,
335
+ resolvedBy: null,
336
+ resolvedAt: null,
337
+ replies: [],
338
+ };
339
+
340
+ if (this.__adapter) {
341
+ await this.__adapter.save(pin);
342
+ }
343
+ this.__pins.push(pin);
344
+ this._render();
345
+ this.#announce(`Pin ${this.#visiblePins().length} added`);
346
+
347
+ this.dispatchEvent(new CustomEvent('review-surface:add', {
348
+ bubbles: true, composed: true,
349
+ detail: { pin },
350
+ }));
351
+
352
+ return pin;
353
+ }
354
+
355
+ /**
356
+ * Remove a pin by ID.
357
+ * @param {string} id
358
+ */
359
+ async removePin(id) {
360
+ const pin = this.__pins.find(p => p.id === id);
361
+ if (!pin) return;
362
+
363
+ if (this.__adapter) {
364
+ await this.__adapter.remove(id);
365
+ }
366
+ this.__pins = this.__pins.filter(p => p.id !== id);
367
+ if (this._activePin === id) this._activePin = null;
368
+ this._render();
369
+ this.#announce('Pin removed');
370
+
371
+ this.dispatchEvent(new CustomEvent('review-surface:remove', {
372
+ bubbles: true, composed: true,
373
+ detail: { pin },
374
+ }));
375
+ }
376
+
377
+ /**
378
+ * Mark a pin as resolved.
379
+ * @param {string} id
380
+ */
381
+ async resolvePin(id) {
382
+ const pin = this.__pins.find(p => p.id === id);
383
+ if (!pin) return;
384
+
385
+ const changes = {
386
+ resolved: true,
387
+ resolvedBy: this.getAttribute('author') || 'Anonymous',
388
+ resolvedAt: new Date().toISOString(),
389
+ };
390
+
391
+ if (this.__adapter) {
392
+ await this.__adapter.update(id, changes);
393
+ }
394
+ Object.assign(pin, changes);
395
+ this._render();
396
+ this.#announce('Pin resolved');
397
+
398
+ this.dispatchEvent(new CustomEvent('review-surface:resolve', {
399
+ bubbles: true, composed: true,
400
+ detail: { pin },
401
+ }));
402
+ }
403
+
404
+ /**
405
+ * Re-open a resolved pin.
406
+ * @param {string} id
407
+ */
408
+ async unresolvePin(id) {
409
+ const pin = this.__pins.find(p => p.id === id);
410
+ if (!pin) return;
411
+
412
+ const changes = { resolved: false, resolvedBy: null, resolvedAt: null };
413
+
414
+ if (this.__adapter) {
415
+ await this.__adapter.update(id, changes);
416
+ }
417
+ Object.assign(pin, changes);
418
+ this._render();
419
+ this.#announce('Pin re-opened');
420
+
421
+ this.dispatchEvent(new CustomEvent('review-surface:update', {
422
+ bubbles: true, composed: true,
423
+ detail: { pin, changes },
424
+ }));
425
+ }
426
+
427
+ /**
428
+ * Export all pins as a plain array.
429
+ * @returns {Object[]}
430
+ */
431
+ exportPins() {
432
+ return structuredClone(this.__pins);
433
+ }
434
+
435
+ /**
436
+ * Import pins from an array (replaces current).
437
+ * @param {Object[]} data
438
+ */
439
+ importPins(data) {
440
+ this.__pins = Array.isArray(data) ? structuredClone(data) : [];
441
+ this._activePin = null;
442
+ this._render();
443
+ }
444
+
445
+ /* ── Render ────────────────────────────────────── */
446
+
447
+ #visiblePins() {
448
+ const showResolved = this.hasAttribute('show-resolved');
449
+ return this.__pins
450
+ .filter(p => showResolved || !p.resolved)
451
+ .sort((a, b) => new Date(a.createdAt ?? 0).getTime() - new Date(b.createdAt ?? 0).getTime());
452
+ }
453
+
454
+ _render() {
455
+ if (!this.shadowRoot) return;
456
+ const editable = this.hasAttribute('editable');
457
+ const visible = this.#visiblePins();
458
+ const activePin = this._activePin ? this.__pins.find(p => p.id === this._activePin) : null;
459
+
460
+ this.setAttribute('pin-count', String(visible.length));
461
+
462
+ this.shadowRoot.innerHTML = `<style>${styles}</style>
463
+ <div class="rs-container">
464
+ <slot></slot>
465
+ <div class="rs-overlay" role="img" aria-label="Review annotation surface">
466
+ ${visible.map((pin, i) => this._renderPin(pin, i + 1)).join('')}
467
+ </div>
468
+ ${activePin ? this._renderPopover(activePin, editable) : ''}
469
+ </div>
470
+ ${editable ? this._renderToolbar(visible.length) : ''}
471
+ <div class="rs-live" aria-live="polite" aria-atomic="true"></div>`;
472
+
473
+ this._bindListeners(editable);
474
+
475
+ this.dispatchEvent(new CustomEvent('review-surface:ready', {
476
+ bubbles: true, composed: true,
477
+ detail: { pinCount: visible.length },
478
+ }));
479
+ }
480
+
481
+ /* ── Pin marker ────────────────────────────────── */
482
+
483
+ _renderPin(pin, number) {
484
+ const preview = pin.text ? pin.text.slice(0, 50) : 'Empty pin';
485
+ return `<button class="rs-pin"
486
+ data-pin-id="${esc(pin.id)}"
487
+ ${pin.resolved ? 'data-resolved' : ''}
488
+ ${this._activePin === pin.id ? 'data-active' : ''}
489
+ style="left:${pin.x}%;top:${pin.y}%"
490
+ aria-label="Pin ${number}: ${esc(preview)}"
491
+ aria-expanded="${this._activePin === pin.id}"
492
+ aria-haspopup="dialog">
493
+ <span class="rs-pin__number">${number}</span>
494
+ </button>`;
495
+ }
496
+
497
+ /* ── Popover ───────────────────────────────────── */
498
+
499
+ _renderPopover(pin, editable) {
500
+ const number = this.#visiblePins().findIndex(p => p.id === pin.id) + 1;
501
+
502
+ return `<div class="rs-popover" data-open
503
+ style="left:${Math.min(pin.x, 70)}%;top:${pin.y}%"
504
+ role="dialog"
505
+ aria-labelledby="rs-popover-title-${esc(pin.id)}">
506
+
507
+ <div class="rs-popover__header">
508
+ <span class="rs-popover__title" id="rs-popover-title-${esc(pin.id)}">
509
+ Pin ${number}
510
+ ${pin.resolved ? `<span class="rs-resolved-badge">${lucideSvg(UX_ICONS.checkCircle)} Resolved</span>` : ''}
511
+ </span>
512
+ <div class="rs-popover__actions">
513
+ ${editable && !pin.resolved ? `
514
+ <button class="rs-popover__btn rs-popover__btn--resolve" data-action="resolve" data-pin-id="${esc(pin.id)}"
515
+ aria-label="Resolve pin" title="Resolve">${lucideSvg(UX_ICONS.checkCircle)}</button>` : ''}
516
+ ${editable && pin.resolved ? `
517
+ <button class="rs-popover__btn" data-action="unresolve" data-pin-id="${esc(pin.id)}"
518
+ aria-label="Re-open pin" title="Re-open">${lucideSvg(UX_ICONS.messageCircle)}</button>` : ''}
519
+ ${editable ? `
520
+ <button class="rs-popover__btn rs-popover__btn--delete" data-action="delete" data-pin-id="${esc(pin.id)}"
521
+ aria-label="Delete pin" title="Delete">${lucideSvg(UX_ICONS.x)}</button>` : ''}
522
+ <button class="rs-popover__btn" data-action="close"
523
+ aria-label="Close">${lucideSvg(UX_ICONS.x)}</button>
524
+ </div>
525
+ </div>
526
+
527
+ <div class="rs-comment">
528
+ <div class="rs-comment__meta">
529
+ <span class="rs-comment__avatar" style="background:${hashColor(pin.author || 'Anonymous')}">${initials(pin.author || 'Anonymous')}</span>
530
+ <span class="rs-comment__author">${esc(pin.author || 'Anonymous')}</span>
531
+ <span class="rs-comment__time">${this.#formatTime(pin.createdAt)}</span>
532
+ </div>
533
+ <div class="rs-comment__text">${esc(pin.text)}</div>
534
+ </div>
535
+
536
+ ${pin.replies?.length ? `
537
+ <div class="rs-replies">
538
+ ${pin.replies.map(reply => `
539
+ <div class="rs-reply">
540
+ <div class="rs-comment__meta">
541
+ <span class="rs-comment__avatar" style="background:${hashColor(reply.author || 'Anonymous')}">${initials(reply.author || 'Anonymous')}</span>
542
+ <span class="rs-comment__author">${esc(reply.author || 'Anonymous')}</span>
543
+ <span class="rs-comment__time">${this.#formatTime(reply.createdAt)}</span>
544
+ </div>
545
+ <div class="rs-comment__text">${esc(reply.text)}</div>
546
+ </div>
547
+ `).join('')}
548
+ </div>` : ''}
549
+
550
+ ${editable ? `
551
+ <div class="rs-input">
552
+ <textarea class="rs-input__field" placeholder="Reply\u2026" rows="1"
553
+ aria-label="Reply to pin ${number}"></textarea>
554
+ <button class="rs-input__submit" data-action="reply" data-pin-id="${esc(pin.id)}"
555
+ aria-label="Send reply">${lucideSvg(UX_ICONS.send)}</button>
556
+ </div>` : ''}
557
+
558
+ </div>`;
559
+ }
560
+
561
+ /* ── Toolbar ───────────────────────────────────── */
562
+
563
+ _renderToolbar(count) {
564
+ return `<div class="rs-toolbar" role="toolbar" aria-label="Review tools">
565
+ <button class="rs-toolbar__btn" data-action="toggle-mode"
566
+ aria-pressed="${this._annotating}"
567
+ title="Toggle annotate mode">
568
+ ${lucideSvg(UX_ICONS.mapPin)} Annotate
569
+ </button>
570
+ <button class="rs-toolbar__btn" data-action="export"
571
+ title="Export pins as JSON">
572
+ ${lucideSvg(UX_ICONS.download)} Export
573
+ </button>
574
+ <output class="rs-toolbar__count">${count} pin${count !== 1 ? 's' : ''}</output>
575
+ </div>`;
576
+ }
577
+
578
+ /* ── Event binding ─────────────────────────────── */
579
+
580
+ _bindListeners(editable) {
581
+ const root = this.shadowRoot;
582
+ if (!root) return;
583
+
584
+ /* Pin clicks — open popover */
585
+ root.querySelectorAll('.rs-pin').forEach((/** @type {Element} */ btn) => {
586
+ btn.addEventListener('click', (e) => {
587
+ e.stopPropagation();
588
+ const id = /** @type {HTMLElement} */ (btn).dataset.pinId ?? null;
589
+ this._activePin = this._activePin === id ? null : id;
590
+ this._render();
591
+
592
+ if (this._activePin) {
593
+ this.dispatchEvent(new CustomEvent('review-surface:select', {
594
+ bubbles: true, composed: true,
595
+ detail: { pin: this.__pins.find(p => p.id === id) },
596
+ }));
597
+ }
598
+ });
599
+ });
600
+
601
+ /* Overlay click — place new pin in annotate mode */
602
+ const overlay = root.querySelector('.rs-overlay');
603
+ if (overlay && editable) {
604
+ overlay.addEventListener('click', (/** @type {Event} */ e) => {
605
+ if (!this._annotating) return;
606
+ const me = /** @type {MouseEvent} */ (e);
607
+ const target = /** @type {Element | null} */ (me.target);
608
+ if (target?.closest('.rs-pin')) return;
609
+
610
+ const rect = overlay.getBoundingClientRect();
611
+ const x = ((me.clientX - rect.left) / rect.width) * 100;
612
+ const y = ((me.clientY - rect.top) / rect.height) * 100;
613
+
614
+ this.#promptNewPin(x, y);
615
+ });
616
+ }
617
+
618
+ /* Popover actions */
619
+ root.querySelectorAll('[data-action]').forEach((/** @type {Element} */ btn) => {
620
+ btn.addEventListener('click', (e) => {
621
+ e.stopPropagation();
622
+ const action = /** @type {HTMLElement} */ (btn).dataset.action;
623
+ const pinId = /** @type {HTMLElement} */ (btn).dataset.pinId;
624
+
625
+ switch (action) {
626
+ case 'close':
627
+ this._activePin = null;
628
+ this._render();
629
+ break;
630
+ case 'resolve':
631
+ if (pinId) this.resolvePin(pinId);
632
+ break;
633
+ case 'unresolve':
634
+ if (pinId) this.unresolvePin(pinId);
635
+ break;
636
+ case 'delete':
637
+ if (pinId) this.removePin(pinId);
638
+ break;
639
+ case 'toggle-mode':
640
+ this._annotating = !this._annotating;
641
+ if (this._annotating) {
642
+ this.setAttribute('data-annotating', '');
643
+ } else {
644
+ this.removeAttribute('data-annotating');
645
+ }
646
+ this._render();
647
+ this.dispatchEvent(new CustomEvent('review-surface:mode', {
648
+ bubbles: true, composed: true,
649
+ detail: { mode: this._annotating ? 'annotate' : 'view' },
650
+ }));
651
+ break;
652
+ case 'export':
653
+ this.#exportToClipboard();
654
+ break;
655
+ case 'reply':
656
+ this.#submitReply(pinId);
657
+ break;
658
+ }
659
+ });
660
+ });
661
+
662
+ /* Reply input — Enter to submit (Shift+Enter for newline) */
663
+ const replyField = root.querySelector('.rs-input__field');
664
+ if (replyField) {
665
+ replyField.addEventListener('keydown', (/** @type {Event} */ e) => {
666
+ const ke = /** @type {KeyboardEvent} */ (e);
667
+ if (ke.key === 'Enter' && !ke.shiftKey) {
668
+ ke.preventDefault();
669
+ const replyBtn = /** @type {HTMLElement | null} */ (root.querySelector('[data-action="reply"]'));
670
+ const pinId = replyBtn?.dataset.pinId;
671
+ if (pinId) this.#submitReply(pinId);
672
+ }
673
+ });
674
+ }
675
+
676
+ /* Keyboard: Escape to close popover or exit annotate mode */
677
+ root.addEventListener('keydown', (/** @type {Event} */ e) => {
678
+ const ke = /** @type {KeyboardEvent} */ (e);
679
+ if (ke.key === 'Escape') {
680
+ if (this._activePin) {
681
+ this._activePin = null;
682
+ this._render();
683
+ } else if (this._annotating) {
684
+ this._annotating = false;
685
+ this.removeAttribute('data-annotating');
686
+ this._render();
687
+ this.dispatchEvent(new CustomEvent('review-surface:mode', {
688
+ bubbles: true, composed: true,
689
+ detail: { mode: 'view' },
690
+ }));
691
+ }
692
+ }
693
+ });
694
+ }
695
+
696
+ /* ── Internal helpers ──────────────────────────── */
697
+
698
+ async #promptNewPin(x, y) {
699
+ const author = this.getAttribute('author') || 'Anonymous';
700
+ const pin = await this.addPin({ x, y, text: '', author });
701
+
702
+ /* Open the new pin's popover immediately so user can type */
703
+ this._activePin = pin.id;
704
+ this._render();
705
+
706
+ /* Focus the reply field for immediate text entry */
707
+ requestAnimationFrame(() => {
708
+ const field = /** @type {HTMLTextAreaElement | null} */ (this.shadowRoot?.querySelector('.rs-input__field') ?? null);
709
+ field?.focus();
710
+ });
711
+ }
712
+
713
+ async #submitReply(pinId) {
714
+ const field = /** @type {HTMLTextAreaElement | null} */ (this.shadowRoot?.querySelector('.rs-input__field') ?? null);
715
+ if (!field) return;
716
+
717
+ const text = field.value.trim();
718
+ if (!text) return;
719
+
720
+ const pin = this.__pins.find(p => p.id === pinId);
721
+ if (!pin) return;
722
+
723
+ const reply = {
724
+ id: crypto.randomUUID(),
725
+ text,
726
+ author: this.getAttribute('author') || 'Anonymous',
727
+ createdAt: new Date().toISOString(),
728
+ };
729
+
730
+ if (!pin.replies) pin.replies = [];
731
+
732
+ /* If pin has no text yet (just placed), promote first reply to pin text */
733
+ if (!pin.text) {
734
+ pin.text = text;
735
+ if (this.__adapter) {
736
+ await this.__adapter.update(pinId, { text });
737
+ }
738
+ } else {
739
+ pin.replies.push(reply);
740
+ if (this.__adapter) {
741
+ await this.__adapter.update(pinId, { replies: pin.replies });
742
+ }
743
+ }
744
+
745
+ this._render();
746
+ this.#announce('Reply added');
747
+
748
+ /* Re-focus input */
749
+ requestAnimationFrame(() => {
750
+ const newField = /** @type {HTMLTextAreaElement | null} */ (this.shadowRoot?.querySelector('.rs-input__field') ?? null);
751
+ newField?.focus();
752
+ });
753
+
754
+ this.dispatchEvent(new CustomEvent('review-surface:update', {
755
+ bubbles: true, composed: true,
756
+ detail: { pin, changes: { replies: pin.replies } },
757
+ }));
758
+ }
759
+
760
+ async #exportToClipboard() {
761
+ const data = JSON.stringify(this.exportPins(), null, 2);
762
+ const ok = await copyText(data, { announceMessage: 'Pins copied to clipboard' });
763
+ if (ok) return;
764
+
765
+ /* Fallback: download as file */
766
+ const blob = new Blob([data], { type: 'application/json' });
767
+ const url = URL.createObjectURL(blob);
768
+ const a = document.createElement('a');
769
+ a.href = url;
770
+ a.download = 'review-pins.json';
771
+ a.click();
772
+ URL.revokeObjectURL(url);
773
+ this.#announce('Pins exported as file');
774
+ }
775
+
776
+ #formatTime(isoString) {
777
+ if (!isoString) return '';
778
+ try {
779
+ const date = new Date(isoString);
780
+ const now = new Date();
781
+ const diffMs = now.getTime() - date.getTime();
782
+ const diffMins = Math.floor(diffMs / 60000);
783
+
784
+ if (diffMins < 1) return 'just now';
785
+ if (diffMins < 60) return `${diffMins}m ago`;
786
+
787
+ const diffHours = Math.floor(diffMins / 60);
788
+ if (diffHours < 24) return `${diffHours}h ago`;
789
+
790
+ const diffDays = Math.floor(diffHours / 24);
791
+ if (diffDays < 7) return `${diffDays}d ago`;
792
+
793
+ return date.toLocaleDateString();
794
+ } catch {
795
+ return '';
796
+ }
797
+ }
798
+
799
+ #announce(message) {
800
+ const live = this.shadowRoot?.querySelector('.rs-live');
801
+ if (live) live.textContent = message;
802
+ }
803
+ }
804
+
805
+ registerComponent('review-surface', ReviewSurface);
806
+
807
+ export { ReviewSurface, MemoryAdapter, LocalStorageAdapter, RestAdapter };