@profpowell/vb-project-planning 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +10 -5
  2. package/dist/vb-project-planning.css +69 -3
  3. package/dist/vb-project-planning.js +3191 -2544
  4. package/dist/vb-project-planning.min.css +1 -1
  5. package/dist/vb-project-planning.min.js +221 -197
  6. package/package.json +7 -4
  7. package/src/lib/board-surface.js +158 -0
  8. package/src/lib/bundle-registry.js +2 -75
  9. package/src/lib/dates.js +125 -0
  10. package/src/lib/escape.js +34 -0
  11. package/src/lib/hash.js +16 -0
  12. package/src/lib/portrait-url.js +3 -29
  13. package/src/lib/src-loader.js +78 -0
  14. package/src/lib/vb-view-transition.js +7 -1
  15. package/src/utils/copy-init.js +56 -17
  16. package/src/web-components/_ux-base.js +409 -24
  17. package/src/web-components/adr-wc/README.md +18 -16
  18. package/src/web-components/adr-wc/api.json +7 -5
  19. package/src/web-components/adr-wc/logic.js +50 -136
  20. package/src/web-components/burndown-chart/api.json +8 -6
  21. package/src/web-components/burndown-chart/logic.js +75 -39
  22. package/src/web-components/empathy-map/api.json +13 -13
  23. package/src/web-components/empathy-map/logic.js +161 -168
  24. package/src/web-components/empathy-map/static.html +3 -3
  25. package/src/web-components/empathy-map/styles.js +1 -0
  26. package/src/web-components/gantt-chart/README.md +12 -6
  27. package/src/web-components/gantt-chart/api.json +23 -7
  28. package/src/web-components/gantt-chart/logic.js +456 -431
  29. package/src/web-components/gantt-chart/styles.css +14 -1
  30. package/src/web-components/impact-effort/api.json +9 -7
  31. package/src/web-components/impact-effort/logic.js +238 -209
  32. package/src/web-components/impact-effort/static.html +12 -4
  33. package/src/web-components/iron-triangle/_capacity.js +0 -18
  34. package/src/web-components/kanban-board/api.json +8 -6
  35. package/src/web-components/kanban-board/logic.js +289 -333
  36. package/src/web-components/product-roadmap/api.json +14 -13
  37. package/src/web-components/product-roadmap/logic.js +393 -246
  38. package/src/web-components/product-roadmap/styles.css +40 -0
  39. package/src/web-components/quadrant-grid/api.json +9 -4
  40. package/src/web-components/quadrant-grid/logic.js +80 -12
  41. package/src/web-components/quadrant-grid/styles.css +7 -0
  42. package/src/web-components/quality-target/_quality-utils.js +2 -46
  43. package/src/web-components/review-surface/README.md +9 -5
  44. package/src/web-components/review-surface/api.json +10 -8
  45. package/src/web-components/review-surface/logic.js +349 -218
  46. package/src/web-components/risk-register/api.json +1 -6
  47. package/src/web-components/risk-register/logic.js +71 -24
  48. package/src/web-components/risk-register/styles.css +9 -1
  49. package/src/web-components/story-map/api.json +8 -6
  50. package/src/web-components/story-map/logic.js +280 -281
  51. package/src/web-components/story-map/static.html +15 -5
  52. package/src/web-components/traceability-matrix/api.json +10 -3
  53. package/src/web-components/traceability-matrix/logic.js +112 -63
  54. package/src/web-components/traceability-matrix/styles.css +22 -1
  55. package/src/web-components/user-journey/api.json +25 -24
  56. package/src/web-components/user-journey/logic.js +76 -127
  57. package/src/web-components/user-journey/static.html +2 -2
  58. package/src/web-components/user-persona/api.json +15 -14
  59. package/src/web-components/user-persona/logic.js +71 -117
  60. package/src/web-components/user-persona/styles.js +1 -0
  61. package/src/web-components/user-story/api.json +13 -11
  62. package/src/web-components/user-story/logic.js +164 -195
  63. package/src/web-components/user-story/styles.js +28 -1
  64. package/src/web-components/work-item/README.md +3 -3
  65. package/src/web-components/work-item/api.json +13 -11
  66. package/src/web-components/work-item/logic.js +58 -154
@@ -45,6 +45,7 @@
45
45
  import { styles } from './styles.js';
46
46
  import { registerComponent } from '../../lib/bundle-registry.js';
47
47
  import { esc, initials, hashColor, lucideSvg, UX_ICONS } from '../_ux-base.js';
48
+ import { SrcLoader } from '../../lib/src-loader.js';
48
49
  import { VBStore } from '../../lib/vb-store.js';
49
50
  import { copyText } from '../../utils/copy-init.js';
50
51
 
@@ -63,11 +64,19 @@ class MemoryAdapter {
63
64
  return pin;
64
65
  }
65
66
 
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;
67
+ /**
68
+ * Merge `changes` into the stored pin. Upserts: a pin this adapter has
69
+ * never seen (loaded via `src` or `.pins`) is stored from `pin` (or just
70
+ * its id) plus the changes, so resolving / replying always works.
71
+ * @param {string} id
72
+ * @param {object} changes
73
+ * @param {object} [pin] the component's full copy of the pin
74
+ */
75
+ async update(id, changes, pin) {
76
+ const stored = this.#pins.get(id) ?? { ...(pin ?? {}), id };
77
+ Object.assign(stored, changes);
78
+ this.#pins.set(id, stored);
79
+ return stored;
71
80
  }
72
81
 
73
82
  async remove(id) {
@@ -103,10 +112,16 @@ class LocalStorageAdapter {
103
112
  return pin;
104
113
  }
105
114
 
106
- async update(id, changes) {
115
+ /**
116
+ * Merge `changes` into the stored pin; upserts like MemoryAdapter.update.
117
+ * @param {string} id
118
+ * @param {object} changes
119
+ * @param {object} [pin]
120
+ */
121
+ async update(id, changes, pin) {
107
122
  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`);
123
+ let idx = pins.findIndex(p => p.id === id);
124
+ if (idx === -1) idx = pins.push({ ...(pin ?? {}), id }) - 1;
110
125
  Object.assign(pins[idx], changes);
111
126
  await this.#write(pins);
112
127
  return pins[idx];
@@ -163,16 +178,66 @@ class RestAdapter {
163
178
 
164
179
  /* ── Component ─────────────────────────────────────── */
165
180
 
181
+ /**
182
+ * Clamp a pin coordinate to a 0–100 percentage. Non-numeric input → 0.
183
+ * @param {unknown} v
184
+ * @returns {number}
185
+ */
186
+ const pct = (v) => {
187
+ const n = Number(v);
188
+ return Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : 0;
189
+ };
190
+
166
191
  class ReviewSurface extends HTMLElement {
167
192
  static get observedAttributes() {
168
193
  return ['src', 'editable', 'adapter', 'endpoint', 'storage-key', 'author', 'compact', 'show-resolved'];
169
194
  }
170
195
 
171
- #slotCache = new Map();
196
+ /** True after the first connect (adapter resolved, initial load started). */
197
+ #initialized = false;
198
+ /** True once the initial data (src / adapter / .pins) has settled. */
199
+ #loaded = false;
200
+ #readyFired = false;
201
+ /** Set when `.pins` is assigned; stops the first connect reloading from the adapter. */
202
+ #pinsAssigned = false;
203
+ /** Bumped by every load and every `.pins` / importPins() so stale adapter loads are dropped. */
204
+ #loadSeq = 0;
205
+ /** The src request; aborted by a newer src, src removal, `.pins`, importPins() or an adapter load. */
206
+ #loader = new SrcLoader();
207
+
208
+ /** @type {HTMLElement} */ #overlay;
209
+ /** @type {HTMLElement} */ #popoverHost;
210
+ /** @type {HTMLElement} */ #toolbarHost;
211
+ /** @type {HTMLElement} */ #status;
212
+ /** @type {HTMLElement} */ #live;
172
213
 
173
214
  constructor() {
174
215
  super();
175
- this.attachShadow({ mode: 'open' });
216
+ const root = this.attachShadow({ mode: 'open' });
217
+
218
+ /* The skeleton is built once. _render() only refills the overlay,
219
+ popover and toolbar hosts, so the live region persists and focus
220
+ can be restored to the equivalent control after a re-render. */
221
+ root.innerHTML = `<style>${styles}</style>
222
+ <div class="rs-status state-msg" hidden></div>
223
+ <div class="rs-container">
224
+ <slot></slot>
225
+ <div class="rs-overlay" role="group" aria-label="Review pins"></div>
226
+ <div class="rs-popover-host" style="display:contents"></div>
227
+ </div>
228
+ <div class="rs-toolbar-host" style="display:contents"></div>
229
+ <div class="rs-live" role="status" aria-live="polite" aria-atomic="true"></div>`;
230
+
231
+ const q = (/** @type {string} */ sel) => /** @type {HTMLElement} */ (root.querySelector(sel));
232
+ this.#status = q('.rs-status');
233
+ this.#overlay = q('.rs-overlay');
234
+ this.#popoverHost = q('.rs-popover-host');
235
+ this.#toolbarHost = q('.rs-toolbar-host');
236
+ this.#live = q('.rs-live');
237
+
238
+ /* Delegated once — nodes inside the hosts are replaced on render. */
239
+ root.addEventListener('click', (e) => this.#onClick(e));
240
+ root.addEventListener('keydown', (e) => this.#onKeydown(/** @type {KeyboardEvent} */ (e)));
176
241
 
177
242
  /** @type {import('./logic.js').Pin[]} */
178
243
  this.__pins = [];
@@ -191,16 +256,21 @@ class ReviewSurface extends HTMLElement {
191
256
 
192
257
  /**
193
258
  * 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.
259
+ * same array reference is a no-op. May be assigned before the element
260
+ * is connected: the first connect then keeps these pins instead of
261
+ * loading from the adapter. Emits review-surface:pins-changed with
262
+ * `source: 'property'`.
197
263
  * @param {Object[]} data
198
264
  */
199
265
  set pins(data) {
200
266
  const next = Array.isArray(data) ? data : [];
201
267
  if (this.__pins === next) return;
202
- this.__pins = next;
203
- if (this.isConnected) this._render();
268
+ this.__pins = /** @type {any} */ (next);
269
+ this.#pinsAssigned = true;
270
+ this.#loader.cancel();
271
+ this.#loadSeq++;
272
+ this.#loaded = true;
273
+ if (this.#initialized) this._render();
204
274
  this.dispatchEvent(new CustomEvent('review-surface:pins-changed', {
205
275
  detail: { pins: next, source: 'property' },
206
276
  bubbles: true, composed: true,
@@ -210,33 +280,36 @@ class ReviewSurface extends HTMLElement {
210
280
  /** @returns {MemoryAdapter|LocalStorageAdapter|RestAdapter|null} */
211
281
  get adapter() { return this.__adapter; }
212
282
 
213
- /** @param {Object} adapterInstance */
283
+ /**
284
+ * Set a persistence adapter. Before connect it is simply kept (the
285
+ * first connect loads from it unless `.pins` was assigned); once
286
+ * connected, pins are reloaded from it.
287
+ * @param {Object} adapterInstance
288
+ */
214
289
  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) || '';
290
+ this.__adapter = /** @type {any} */ (adapterInstance);
291
+ if (this.#initialized && this.isConnected) this._loadFromAdapter();
230
292
  }
231
293
 
232
294
  /* ── Lifecycle ─────────────────────────────────── */
233
295
 
234
296
  connectedCallback() {
235
- this.#cacheSlotValues();
236
297
  this.setAttribute('data-upgraded', '');
237
- this.#resolveAdapter();
238
298
 
239
- if (this.hasAttribute('src')) {
299
+ /* Reconnect (e.g. the element was moved): keep pins and adapter. */
300
+ if (this.#initialized) {
301
+ this._render();
302
+ return;
303
+ }
304
+ this.#initialized = true;
305
+ if (!this.__adapter) this.#resolveAdapter();
306
+
307
+ this.dispatchEvent(new CustomEvent('review-surface:upgraded', { bubbles: true, composed: true }));
308
+
309
+ if (this.#pinsAssigned) {
310
+ this.#loaded = true;
311
+ this._render();
312
+ } else if (this.hasAttribute('src')) {
240
313
  this._loadSrc(this.getAttribute('src'));
241
314
  } else {
242
315
  this._loadFromAdapter();
@@ -246,12 +319,15 @@ class ReviewSurface extends HTMLElement {
246
319
  disconnectedCallback() {
247
320
  this.removeAttribute('data-upgraded');
248
321
  this.removeAttribute('data-annotating');
322
+ this._annotating = false;
249
323
  }
250
324
 
251
325
  attributeChangedCallback(name) {
252
- if (!this.isConnected) return;
326
+ if (!this.#initialized || !this.isConnected) return;
253
327
  if (name === 'src') {
254
- this._loadSrc(this.getAttribute('src'));
328
+ const src = this.getAttribute('src');
329
+ if (src) this._loadSrc(src);
330
+ else if (this.#loader.cancel()) this._loadFromAdapter(); // src removed mid-load
255
331
  } else if (name === 'adapter' || name === 'endpoint' || name === 'storage-key') {
256
332
  this.#resolveAdapter();
257
333
  this._loadFromAdapter();
@@ -289,33 +365,57 @@ class ReviewSurface extends HTMLElement {
289
365
 
290
366
  /* ── Data loading ──────────────────────────────── */
291
367
 
368
+ /**
369
+ * Fetch pins from `src` (an array, or `{ pins }`). A newer src aborts the
370
+ * older request; `.pins`, importPins(), an adapter load or removing src
371
+ * abort it too.
372
+ * @param {string | null} src
373
+ */
292
374
  async _loadSrc(src) {
293
- if (!src || !this.shadowRoot) return;
294
- this.shadowRoot.innerHTML = `<style>${styles}</style>` +
295
- `<div class="state-msg">Loading\u2026</div>`;
375
+ if (!src) return;
376
+ this.#loadSeq++;
377
+ this.#showStatus('Loading…');
378
+ let result;
296
379
  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();
380
+ result = await this.#loader.fetchJson(src);
302
381
  } catch (err) {
303
382
  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>`;
383
+ this.#showStatus(`Could not load pins: ${msg}`, true);
384
+ return;
306
385
  }
386
+ if (!result) return;
387
+ const { data } = result;
388
+ this.__pins = Array.isArray(data) ? data : data?.pins || [];
389
+ this.#loaded = true;
390
+ this._render();
307
391
  }
308
392
 
309
393
  async _loadFromAdapter() {
310
394
  if (!this.__adapter) return;
395
+ this.#loader.cancel();
396
+ const seq = ++this.#loadSeq;
397
+ let pins;
311
398
  try {
312
- this.__pins = await this.__adapter.load();
399
+ pins = await this.__adapter.load();
313
400
  } catch {
314
- this.__pins = [];
401
+ pins = [];
315
402
  }
403
+ if (seq !== this.#loadSeq) return;
404
+ this.__pins = Array.isArray(pins) ? pins : [];
405
+ this.#loaded = true;
316
406
  this._render();
317
407
  }
318
408
 
409
+ /**
410
+ * @param {string} message
411
+ * @param {boolean} [error]
412
+ */
413
+ #showStatus(message, error = false) {
414
+ this.#status.textContent = message;
415
+ this.#status.classList.toggle('state-msg--error', error);
416
+ this.#status.hidden = false;
417
+ }
418
+
319
419
  /* ── Public API ────────────────────────────────── */
320
420
 
321
421
  /**
@@ -326,8 +426,8 @@ class ReviewSurface extends HTMLElement {
326
426
  async addPin(pinData) {
327
427
  const pin = {
328
428
  id: crypto.randomUUID(),
329
- x: Math.max(0, Math.min(100, pinData.x)),
330
- y: Math.max(0, Math.min(100, pinData.y)),
429
+ x: pct(pinData.x),
430
+ y: pct(pinData.y),
331
431
  text: pinData.text || '',
332
432
  author: pinData.author || this.getAttribute('author') || 'Anonymous',
333
433
  createdAt: new Date().toISOString(),
@@ -389,7 +489,7 @@ class ReviewSurface extends HTMLElement {
389
489
  };
390
490
 
391
491
  if (this.__adapter) {
392
- await this.__adapter.update(id, changes);
492
+ await this.__adapter.update(id, changes, pin);
393
493
  }
394
494
  Object.assign(pin, changes);
395
495
  this._render();
@@ -412,7 +512,7 @@ class ReviewSurface extends HTMLElement {
412
512
  const changes = { resolved: false, resolvedBy: null, resolvedAt: null };
413
513
 
414
514
  if (this.__adapter) {
415
- await this.__adapter.update(id, changes);
515
+ await this.__adapter.update(id, changes, pin);
416
516
  }
417
517
  Object.assign(pin, changes);
418
518
  this._render();
@@ -438,6 +538,9 @@ class ReviewSurface extends HTMLElement {
438
538
  */
439
539
  importPins(data) {
440
540
  this.__pins = Array.isArray(data) ? structuredClone(data) : [];
541
+ this.#loader.cancel();
542
+ this.#loadSeq++;
543
+ this.#loaded = true;
441
544
  this._activePin = null;
442
545
  this._render();
443
546
  }
@@ -452,41 +555,101 @@ class ReviewSurface extends HTMLElement {
452
555
  }
453
556
 
454
557
  _render() {
455
- if (!this.shadowRoot) return;
456
558
  const editable = this.hasAttribute('editable');
457
559
  const visible = this.#visiblePins();
458
560
  const activePin = this._activePin ? this.__pins.find(p => p.id === this._activePin) : null;
561
+ if (this._activePin && !activePin) this._activePin = null;
459
562
 
460
563
  this.setAttribute('pin-count', String(visible.length));
461
564
 
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>`;
565
+ const focus = this.#captureFocus();
566
+
567
+ this.#status.hidden = true;
568
+ this.#overlay.innerHTML = visible.map((pin, i) => this._renderPin(pin, i + 1)).join('');
569
+ this.#popoverHost.innerHTML = activePin ? this._renderPopover(activePin, editable) : '';
570
+ this.#toolbarHost.innerHTML = editable ? this._renderToolbar(visible.length) : '';
571
+
572
+ this.#restoreFocus(focus);
573
+
574
+ if (this.#loaded && !this.#readyFired) {
575
+ this.#readyFired = true;
576
+ this.dispatchEvent(new CustomEvent('review-surface:ready', {
577
+ bubbles: true, composed: true,
578
+ detail: { pinCount: visible.length },
579
+ }));
580
+ }
581
+ }
582
+
583
+ /* ── Focus preservation ────────────────────────── */
472
584
 
473
- this._bindListeners(editable);
585
+ /**
586
+ * Describe the focused shadow control so the equivalent node can be
587
+ * re-focused after its host is re-rendered.
588
+ * @returns {{ selector: string, value?: string, inPopover: boolean } | null}
589
+ */
590
+ #captureFocus() {
591
+ const el = /** @type {HTMLElement | null} */ (this.shadowRoot?.activeElement ?? null);
592
+ if (!el) return null;
593
+ const inPopover = !!el.closest('.rs-popover');
594
+ let selector = '';
595
+ if (el.classList.contains('rs-pin')) selector = `.rs-pin[data-pin-id="${CSS.escape(el.dataset.pinId ?? '')}"]`;
596
+ else if (el.dataset.action) selector = `[data-action="${CSS.escape(el.dataset.action)}"]`;
597
+ else if (el.classList.contains('rs-input__field')) selector = '.rs-input__field';
598
+ else if (el.classList.contains('rs-popover')) selector = '.rs-popover';
599
+ const value = el instanceof HTMLTextAreaElement ? el.value : undefined;
600
+ return { selector, value, inPopover };
601
+ }
602
+
603
+ /** @param {{ selector: string, value?: string, inPopover: boolean } | null} focus */
604
+ #restoreFocus(focus) {
605
+ if (!focus) return;
606
+ const root = /** @type {ShadowRoot} */ (this.shadowRoot);
607
+ let target = focus.selector ? /** @type {HTMLElement | null} */ (root.querySelector(focus.selector)) : null;
608
+ if (target instanceof HTMLTextAreaElement && focus.value !== undefined) target.value = focus.value;
609
+ /* The control is gone (e.g. Resolve swapped for Re-open): stay in the
610
+ popover if it is still open, else fall back to the active pin. */
611
+ if (!target && focus.inPopover) target = root.querySelector('.rs-popover');
612
+ if (!target) target = this.#pinButton(this._activePin) ?? root.querySelector('[data-action="toggle-mode"]');
613
+ target?.focus();
614
+ }
615
+
616
+ /** @param {string|null|undefined} id */
617
+ #pinButton(id) {
618
+ if (!id) return null;
619
+ return /** @type {HTMLElement | null} */ (this.shadowRoot?.querySelector(`.rs-pin[data-pin-id="${CSS.escape(id)}"]`) ?? null);
620
+ }
474
621
 
475
- this.dispatchEvent(new CustomEvent('review-surface:ready', {
622
+ /**
623
+ * Open a pin's popover and move focus into it.
624
+ * @param {string} id
625
+ */
626
+ #openPin(id) {
627
+ this._activePin = id;
628
+ this._render();
629
+ /** @type {HTMLElement | null} */ (this.shadowRoot?.querySelector('.rs-popover') ?? null)?.focus();
630
+ this.dispatchEvent(new CustomEvent('review-surface:select', {
476
631
  bubbles: true, composed: true,
477
- detail: { pinCount: visible.length },
632
+ detail: { pin: this.__pins.find(p => p.id === id) },
478
633
  }));
479
634
  }
480
635
 
636
+ /** Close the popover and return focus to its pin. */
637
+ #closePin() {
638
+ const id = this._activePin;
639
+ this._activePin = null;
640
+ this._render();
641
+ this.#pinButton(id)?.focus();
642
+ }
643
+
481
644
  /* ── Pin marker ────────────────────────────────── */
482
645
 
483
646
  _renderPin(pin, number) {
484
- const preview = pin.text ? pin.text.slice(0, 50) : 'Empty pin';
485
- return `<button class="rs-pin"
647
+ const preview = pin.text ? String(pin.text).slice(0, 50) : 'Empty pin';
648
+ return `<button type="button" class="rs-pin"
486
649
  data-pin-id="${esc(pin.id)}"
487
650
  ${pin.resolved ? 'data-resolved' : ''}
488
651
  ${this._activePin === pin.id ? 'data-active' : ''}
489
- style="left:${pin.x}%;top:${pin.y}%"
652
+ style="left:${pct(pin.x)}%;top:${pct(pin.y)}%"
490
653
  aria-label="Pin ${number}: ${esc(preview)}"
491
654
  aria-expanded="${this._activePin === pin.id}"
492
655
  aria-haspopup="dialog">
@@ -498,37 +661,38 @@ class ReviewSurface extends HTMLElement {
498
661
 
499
662
  _renderPopover(pin, editable) {
500
663
  const number = this.#visiblePins().findIndex(p => p.id === pin.id) + 1;
664
+ const label = number > 0 ? `Pin ${number}` : 'Pin';
501
665
 
502
- return `<div class="rs-popover" data-open
503
- style="left:${Math.min(pin.x, 70)}%;top:${pin.y}%"
666
+ return `<div class="rs-popover" data-open tabindex="-1"
667
+ style="left:${Math.min(pct(pin.x), 70)}%;top:${pct(pin.y)}%"
504
668
  role="dialog"
505
669
  aria-labelledby="rs-popover-title-${esc(pin.id)}">
506
670
 
507
671
  <div class="rs-popover__header">
508
672
  <span class="rs-popover__title" id="rs-popover-title-${esc(pin.id)}">
509
- Pin ${number}
673
+ ${label}
510
674
  ${pin.resolved ? `<span class="rs-resolved-badge">${lucideSvg(UX_ICONS.checkCircle)} Resolved</span>` : ''}
511
675
  </span>
512
676
  <div class="rs-popover__actions">
513
677
  ${editable && !pin.resolved ? `
514
- <button class="rs-popover__btn rs-popover__btn--resolve" data-action="resolve" data-pin-id="${esc(pin.id)}"
678
+ <button type="button" class="rs-popover__btn rs-popover__btn--resolve" data-action="resolve" data-pin-id="${esc(pin.id)}"
515
679
  aria-label="Resolve pin" title="Resolve">${lucideSvg(UX_ICONS.checkCircle)}</button>` : ''}
516
680
  ${editable && pin.resolved ? `
517
- <button class="rs-popover__btn" data-action="unresolve" data-pin-id="${esc(pin.id)}"
681
+ <button type="button" class="rs-popover__btn" data-action="unresolve" data-pin-id="${esc(pin.id)}"
518
682
  aria-label="Re-open pin" title="Re-open">${lucideSvg(UX_ICONS.messageCircle)}</button>` : ''}
519
683
  ${editable ? `
520
- <button class="rs-popover__btn rs-popover__btn--delete" data-action="delete" data-pin-id="${esc(pin.id)}"
684
+ <button type="button" class="rs-popover__btn rs-popover__btn--delete" data-action="delete" data-pin-id="${esc(pin.id)}"
521
685
  aria-label="Delete pin" title="Delete">${lucideSvg(UX_ICONS.x)}</button>` : ''}
522
- <button class="rs-popover__btn" data-action="close"
686
+ <button type="button" class="rs-popover__btn" data-action="close"
523
687
  aria-label="Close">${lucideSvg(UX_ICONS.x)}</button>
524
688
  </div>
525
689
  </div>
526
690
 
527
691
  <div class="rs-comment">
528
692
  <div class="rs-comment__meta">
529
- <span class="rs-comment__avatar" style="background:${hashColor(pin.author || 'Anonymous')}">${initials(pin.author || 'Anonymous')}</span>
693
+ <span class="rs-comment__avatar" style="background:${hashColor(pin.author || 'Anonymous')}">${esc(initials(pin.author || 'Anonymous'))}</span>
530
694
  <span class="rs-comment__author">${esc(pin.author || 'Anonymous')}</span>
531
- <span class="rs-comment__time">${this.#formatTime(pin.createdAt)}</span>
695
+ <span class="rs-comment__time">${esc(this.#formatTime(pin.createdAt))}</span>
532
696
  </div>
533
697
  <div class="rs-comment__text">${esc(pin.text)}</div>
534
698
  </div>
@@ -538,9 +702,9 @@ class ReviewSurface extends HTMLElement {
538
702
  ${pin.replies.map(reply => `
539
703
  <div class="rs-reply">
540
704
  <div class="rs-comment__meta">
541
- <span class="rs-comment__avatar" style="background:${hashColor(reply.author || 'Anonymous')}">${initials(reply.author || 'Anonymous')}</span>
705
+ <span class="rs-comment__avatar" style="background:${hashColor(reply.author || 'Anonymous')}">${esc(initials(reply.author || 'Anonymous'))}</span>
542
706
  <span class="rs-comment__author">${esc(reply.author || 'Anonymous')}</span>
543
- <span class="rs-comment__time">${this.#formatTime(reply.createdAt)}</span>
707
+ <span class="rs-comment__time">${esc(this.#formatTime(reply.createdAt))}</span>
544
708
  </div>
545
709
  <div class="rs-comment__text">${esc(reply.text)}</div>
546
710
  </div>
@@ -549,9 +713,9 @@ class ReviewSurface extends HTMLElement {
549
713
 
550
714
  ${editable ? `
551
715
  <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)}"
716
+ <textarea class="rs-input__field" placeholder="Reply…" rows="1"
717
+ aria-label="Reply to ${label.toLowerCase()}"></textarea>
718
+ <button type="button" class="rs-input__submit" data-action="reply" data-pin-id="${esc(pin.id)}"
555
719
  aria-label="Send reply">${lucideSvg(UX_ICONS.send)}</button>
556
720
  </div>` : ''}
557
721
 
@@ -562,12 +726,12 @@ class ReviewSurface extends HTMLElement {
562
726
 
563
727
  _renderToolbar(count) {
564
728
  return `<div class="rs-toolbar" role="toolbar" aria-label="Review tools">
565
- <button class="rs-toolbar__btn" data-action="toggle-mode"
729
+ <button type="button" class="rs-toolbar__btn" data-action="toggle-mode"
566
730
  aria-pressed="${this._annotating}"
567
731
  title="Toggle annotate mode">
568
732
  ${lucideSvg(UX_ICONS.mapPin)} Annotate
569
733
  </button>
570
- <button class="rs-toolbar__btn" data-action="export"
734
+ <button type="button" class="rs-toolbar__btn" data-action="export"
571
735
  title="Export pins as JSON">
572
736
  ${lucideSvg(UX_ICONS.download)} Export
573
737
  </button>
@@ -575,122 +739,97 @@ class ReviewSurface extends HTMLElement {
575
739
  </div>`;
576
740
  }
577
741
 
578
- /* ── Event binding ─────────────────────────────── */
742
+ /* ── Event handling (delegated on the shadow root) ── */
579
743
 
580
- _bindListeners(editable) {
581
- const root = this.shadowRoot;
582
- if (!root) return;
744
+ /** @param {Event} e */
745
+ #onClick(e) {
746
+ const target = e.target;
747
+ if (!(target instanceof Element)) return;
583
748
 
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
- });
749
+ /* Pin clicks — toggle popover */
750
+ const pinBtn = /** @type {HTMLElement | null} */ (target.closest('.rs-pin'));
751
+ if (pinBtn) {
752
+ e.stopPropagation();
753
+ const id = pinBtn.dataset.pinId ?? null;
754
+ if (this._activePin === id) this.#closePin();
755
+ else if (id) this.#openPin(id);
756
+ return;
757
+ }
758
+
759
+ /* Popover / toolbar actions */
760
+ const actionBtn = /** @type {HTMLElement | null} */ (target.closest('[data-action]'));
761
+ if (actionBtn) {
762
+ e.stopPropagation();
763
+ const pinId = actionBtn.dataset.pinId;
764
+ switch (actionBtn.dataset.action) {
765
+ case 'close':
766
+ this.#closePin();
767
+ break;
768
+ case 'resolve':
769
+ if (pinId) this.resolvePin(pinId);
770
+ break;
771
+ case 'unresolve':
772
+ if (pinId) this.unresolvePin(pinId);
773
+ break;
774
+ case 'delete':
775
+ if (pinId) this.removePin(pinId);
776
+ break;
777
+ case 'toggle-mode':
778
+ this.#setAnnotating(!this._annotating);
779
+ break;
780
+ case 'export':
781
+ this.#exportToClipboard();
782
+ break;
783
+ case 'reply':
784
+ if (pinId) this.#submitReply(pinId);
785
+ break;
786
+ }
787
+ return;
788
+ }
600
789
 
601
790
  /* 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
- });
791
+ if (target === this.#overlay && this._annotating && this.hasAttribute('editable')) {
792
+ const me = /** @type {MouseEvent} */ (e);
793
+ const rect = this.#overlay.getBoundingClientRect();
794
+ const x = ((me.clientX - rect.left) / rect.width) * 100;
795
+ const y = ((me.clientY - rect.top) / rect.height) * 100;
796
+ this.#promptNewPin(x, y);
616
797
  }
798
+ }
617
799
 
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
- });
800
+ /** @param {KeyboardEvent} e */
801
+ #onKeydown(e) {
802
+ const target = e.target;
661
803
 
662
804
  /* 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
- });
805
+ if (e.key === 'Enter' && !e.shiftKey && target instanceof Element && target.matches('.rs-input__field')) {
806
+ e.preventDefault();
807
+ const pinId = /** @type {HTMLElement | null} */ (this.shadowRoot?.querySelector('[data-action="reply"]') ?? null)?.dataset.pinId;
808
+ if (pinId) this.#submitReply(pinId);
809
+ return;
674
810
  }
675
811
 
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
- }
812
+ /* Escape — close the popover (focus back to its pin), else exit annotate mode */
813
+ if (e.key === 'Escape') {
814
+ if (this._activePin) {
815
+ e.stopPropagation();
816
+ this.#closePin();
817
+ } else if (this._annotating) {
818
+ e.stopPropagation();
819
+ this.#setAnnotating(false);
692
820
  }
693
- });
821
+ }
822
+ }
823
+
824
+ /** @param {boolean} on */
825
+ #setAnnotating(on) {
826
+ this._annotating = on;
827
+ this.toggleAttribute('data-annotating', on);
828
+ this._render();
829
+ this.dispatchEvent(new CustomEvent('review-surface:mode', {
830
+ bubbles: true, composed: true,
831
+ detail: { mode: on ? 'annotate' : 'view' },
832
+ }));
694
833
  }
695
834
 
696
835
  /* ── Internal helpers ──────────────────────────── */
@@ -699,15 +838,10 @@ class ReviewSurface extends HTMLElement {
699
838
  const author = this.getAttribute('author') || 'Anonymous';
700
839
  const pin = await this.addPin({ x, y, text: '', author });
701
840
 
702
- /* Open the new pin's popover immediately so user can type */
841
+ /* Open the new pin's popover and focus the reply field for text entry */
703
842
  this._activePin = pin.id;
704
843
  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
- });
844
+ /** @type {HTMLElement | null} */ (this.shadowRoot?.querySelector('.rs-input__field') ?? null)?.focus();
711
845
  }
712
846
 
713
847
  async #submitReply(pinId) {
@@ -727,33 +861,25 @@ class ReviewSurface extends HTMLElement {
727
861
  createdAt: new Date().toISOString(),
728
862
  };
729
863
 
730
- if (!pin.replies) pin.replies = [];
731
-
732
864
  /* 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
- }
865
+ const changes = pin.text
866
+ ? { replies: [...(pin.replies ?? []), reply] }
867
+ : { text };
868
+
869
+ if (this.__adapter) {
870
+ await this.__adapter.update(pinId, changes, pin);
743
871
  }
872
+ Object.assign(pin, changes);
873
+ if (!pin.replies) pin.replies = [];
744
874
 
875
+ field.value = '';
745
876
  this._render();
746
877
  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
- });
878
+ /** @type {HTMLElement | null} */ (this.shadowRoot?.querySelector('.rs-input__field') ?? null)?.focus();
753
879
 
754
880
  this.dispatchEvent(new CustomEvent('review-surface:update', {
755
881
  bubbles: true, composed: true,
756
- detail: { pin, changes: { replies: pin.replies } },
882
+ detail: { pin, changes },
757
883
  }));
758
884
  }
759
885
 
@@ -777,6 +903,7 @@ class ReviewSurface extends HTMLElement {
777
903
  if (!isoString) return '';
778
904
  try {
779
905
  const date = new Date(isoString);
906
+ if (Number.isNaN(date.getTime())) return '';
780
907
  const now = new Date();
781
908
  const diffMs = now.getTime() - date.getTime();
782
909
  const diffMins = Math.floor(diffMs / 60000);
@@ -796,9 +923,13 @@ class ReviewSurface extends HTMLElement {
796
923
  }
797
924
  }
798
925
 
926
+ /**
927
+ * Write to the persistent live region (never re-rendered, so the
928
+ * message survives the render that follows every change).
929
+ * @param {string} message
930
+ */
799
931
  #announce(message) {
800
- const live = this.shadowRoot?.querySelector('.rs-live');
801
- if (live) live.textContent = message;
932
+ this.#live.textContent = message;
802
933
  }
803
934
  }
804
935