@profpowell/vb-project-planning 0.2.1 → 0.2.2

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 (64) hide show
  1. package/README.md +5 -2
  2. package/dist/vb-project-planning.css +56 -2
  3. package/dist/vb-project-planning.js +3188 -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/utils/copy-init.js +56 -17
  15. package/src/web-components/_ux-base.js +409 -24
  16. package/src/web-components/adr-wc/README.md +18 -16
  17. package/src/web-components/adr-wc/api.json +7 -5
  18. package/src/web-components/adr-wc/logic.js +50 -136
  19. package/src/web-components/burndown-chart/api.json +8 -6
  20. package/src/web-components/burndown-chart/logic.js +75 -39
  21. package/src/web-components/empathy-map/api.json +13 -13
  22. package/src/web-components/empathy-map/logic.js +161 -168
  23. package/src/web-components/empathy-map/static.html +3 -3
  24. package/src/web-components/empathy-map/styles.js +1 -0
  25. package/src/web-components/gantt-chart/README.md +12 -6
  26. package/src/web-components/gantt-chart/api.json +23 -7
  27. package/src/web-components/gantt-chart/logic.js +456 -431
  28. package/src/web-components/gantt-chart/styles.css +9 -1
  29. package/src/web-components/impact-effort/api.json +9 -7
  30. package/src/web-components/impact-effort/logic.js +238 -209
  31. package/src/web-components/impact-effort/static.html +12 -4
  32. package/src/web-components/iron-triangle/_capacity.js +0 -18
  33. package/src/web-components/kanban-board/api.json +8 -6
  34. package/src/web-components/kanban-board/logic.js +289 -333
  35. package/src/web-components/product-roadmap/api.json +14 -13
  36. package/src/web-components/product-roadmap/logic.js +393 -246
  37. package/src/web-components/product-roadmap/styles.css +40 -0
  38. package/src/web-components/quadrant-grid/api.json +9 -4
  39. package/src/web-components/quadrant-grid/logic.js +80 -12
  40. package/src/web-components/quadrant-grid/styles.css +7 -0
  41. package/src/web-components/quality-target/_quality-utils.js +2 -46
  42. package/src/web-components/review-surface/README.md +9 -5
  43. package/src/web-components/review-surface/api.json +10 -8
  44. package/src/web-components/review-surface/logic.js +349 -218
  45. package/src/web-components/risk-register/api.json +1 -6
  46. package/src/web-components/risk-register/logic.js +71 -24
  47. package/src/web-components/story-map/api.json +8 -6
  48. package/src/web-components/story-map/logic.js +280 -281
  49. package/src/web-components/story-map/static.html +15 -5
  50. package/src/web-components/traceability-matrix/api.json +10 -3
  51. package/src/web-components/traceability-matrix/logic.js +112 -63
  52. package/src/web-components/traceability-matrix/styles.css +14 -1
  53. package/src/web-components/user-journey/api.json +25 -24
  54. package/src/web-components/user-journey/logic.js +76 -127
  55. package/src/web-components/user-journey/static.html +2 -2
  56. package/src/web-components/user-persona/api.json +15 -14
  57. package/src/web-components/user-persona/logic.js +71 -117
  58. package/src/web-components/user-persona/styles.js +1 -0
  59. package/src/web-components/user-story/api.json +13 -11
  60. package/src/web-components/user-story/logic.js +164 -195
  61. package/src/web-components/user-story/styles.js +28 -1
  62. package/src/web-components/work-item/README.md +3 -3
  63. package/src/web-components/work-item/api.json +13 -11
  64. package/src/web-components/work-item/logic.js +58 -154
@@ -129,6 +129,44 @@ product-roadmap > section[data-lane] {
129
129
  border-end-end-radius: var(--radius-s, 0.25rem);
130
130
  }
131
131
 
132
+ .rm-bar:focus-visible {
133
+ outline: var(--border-width-medium, 2px) solid var(--color-focus, var(--color-interactive, #0066cc));
134
+ outline-offset: 2px;
135
+ }
136
+
137
+ .rm-bar[data-dragging] {
138
+ cursor: grabbing;
139
+ z-index: 3;
140
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
141
+ }
142
+
143
+ /* Status as text too, so it is not conveyed by colour alone. */
144
+ .rm-bar-status {
145
+ flex: none;
146
+ font-size: var(--font-size-xs, 0.75rem);
147
+ font-weight: 600;
148
+ opacity: 0.9;
149
+ white-space: nowrap;
150
+ padding-inline-end: var(--size-xs, 0.5rem);
151
+ }
152
+
153
+ .rm-track[data-drop-target] {
154
+ outline: var(--border-width-medium, 2px) dashed var(--color-interactive, #0066cc);
155
+ outline-offset: -2px;
156
+ }
157
+
158
+ .rm-sr-only {
159
+ position: absolute;
160
+ inline-size: 1px;
161
+ block-size: 1px;
162
+ margin: -1px;
163
+ padding: 0;
164
+ overflow: hidden;
165
+ clip-path: inset(50%);
166
+ white-space: nowrap;
167
+ border: 0;
168
+ }
169
+
132
170
  .rm-bar[data-status="planned"] { background: var(--color-info, oklch(60% 0.12 240)); }
133
171
  .rm-bar[data-status="in-progress"] { background: var(--color-accent, oklch(50% 0.16 260)); }
134
172
  .rm-bar[data-status="at-risk"] { background: var(--color-warning, oklch(65% 0.16 60)); color: var(--color-text, #1a1a1a); }
@@ -152,6 +190,8 @@ product-roadmap > section[data-lane] {
152
190
  /* Read-only roadmap: bars don't show grab cursor or resize handle. */
153
191
  product-roadmap:not([editable]) .rm-bar { cursor: default; }
154
192
  product-roadmap:not([editable]) .rm-bar-handle { display: none; }
193
+ /* Editable bars handle their own pointer gestures (no touch scroll/pan). */
194
+ product-roadmap[editable] .rm-bar { touch-action: none; }
155
195
 
156
196
  @media (prefers-reduced-motion: reduce) {
157
197
  product-roadmap .rm-bar { transition: none; }
@@ -79,7 +79,7 @@
79
79
  "kind": "host-api",
80
80
  "purpose": "config",
81
81
  "type": "boolean",
82
- "description": "Enable drag-and-drop between quadrants (composes <drag-surface>)"
82
+ "description": "Enable drag-and-drop between quadrants (composes one <drag-surface> per quadrant)"
83
83
  }
84
84
  ],
85
85
  "childAttributes": [
@@ -93,13 +93,13 @@
93
93
  "name": "data-x",
94
94
  "on": "*",
95
95
  "type": "number",
96
- "description": "Normalized x coordinate 0..1; quadrant is computed from coords"
96
+ "description": "Normalized x coordinate 0..1; quadrant is computed from coords; removed when the child is dragged to another quadrant"
97
97
  },
98
98
  {
99
99
  "name": "data-y",
100
100
  "on": "*",
101
101
  "type": "number",
102
- "description": "Normalized y coordinate 0..1; quadrant is computed from coords"
102
+ "description": "Normalized y coordinate 0..1; quadrant is computed from coords; removed when the child is dragged to another quadrant"
103
103
  },
104
104
  {
105
105
  "name": "data-id",
@@ -112,7 +112,12 @@
112
112
  {
113
113
  "name": "quadrant-grid:move",
114
114
  "detail": "{ item, itemId, from, to }",
115
- "description": "Fired when a draggable child moves between quadrants"
115
+ "description": "Fired when a draggable child moves between quadrants (from / to are indices 0..3). A coordinate-pinned child dropped in another quadrant loses its data-x / data-y and joins that quadrant's stack"
116
+ },
117
+ {
118
+ "name": "quadrant-grid:upgraded",
119
+ "detail": "",
120
+ "description": "Fired (in a microtask) each time the grid connects and upgrades"
116
121
  }
117
122
  ],
118
123
  "structure": [],
@@ -45,7 +45,9 @@
45
45
  * @attr {number} data-y - 0..1 normalized y coordinate
46
46
  * @attr {string} data-id - Stable identifier for moves
47
47
  *
48
- * @fires quadrant-grid:move - { item, itemId, from, to, x?, y? }
48
+ * @fires quadrant-grid:move - { item, itemId, from, to }. A coordinate-pinned
49
+ * child that is dragged to another quadrant drops its data-x/data-y (a drop
50
+ * has no position) and joins that quadrant's stack as a regular card.
49
51
  */
50
52
 
51
53
  import { registerComponent } from '../../lib/bundle-registry.js';
@@ -54,8 +56,19 @@ import { VBElement } from '../../lib/vb-element.js';
54
56
  class QuadrantGrid extends VBElement {
55
57
  static #instanceCount = 0;
56
58
 
59
+ /** Quadrant indices in visual reading order: TL (Q2), TR (Q1), BL (Q3), BR (Q4). */
60
+ static VISUAL_ORDER = [1, 0, 2, 3];
61
+
57
62
  setup() {
58
- this.#groupId = `qg-${++QuadrantGrid.#instanceCount}`;
63
+ if (!this.#groupId) this.#groupId = `qg-${++QuadrantGrid.#instanceCount}`;
64
+
65
+ // Reconnect (or a repeated setup() call): the grid is already built and
66
+ // still holds every child, so only re-attach listeners and observers.
67
+ if (this.#built) {
68
+ this.#wire();
69
+ return;
70
+ }
71
+ this.#built = true;
59
72
 
60
73
  const xLabel = this.getAttribute('x-label') || '';
61
74
  const yLabel = this.getAttribute('y-label') || '';
@@ -86,7 +99,7 @@ class QuadrantGrid extends VBElement {
86
99
  const yScale = document.createElement('aside');
87
100
  yScale.className = 'qg-y-scale';
88
101
  yScale.setAttribute('aria-hidden', 'true');
89
- yScale.innerHTML = `<span>${yHigh}</span><span>${yLow}</span>`;
102
+ yScale.append(QuadrantGrid.#span(yHigh), QuadrantGrid.#span(yLow));
90
103
 
91
104
  const grid = document.createElement('div');
92
105
  grid.className = 'qg-grid';
@@ -94,16 +107,18 @@ class QuadrantGrid extends VBElement {
94
107
  const xScale = document.createElement('aside');
95
108
  xScale.className = 'qg-x-scale';
96
109
  xScale.setAttribute('aria-hidden', 'true');
97
- xScale.innerHTML = `<span>${xLow}</span><span>${xHigh}</span>`;
110
+ xScale.append(QuadrantGrid.#span(xLow), QuadrantGrid.#span(xHigh));
98
111
 
99
112
  const xLabelEl = document.createElement('footer');
100
113
  xLabelEl.className = 'qg-x-label';
101
114
  xLabelEl.setAttribute('aria-hidden', 'true');
102
115
  xLabelEl.textContent = `${xLabel} →`;
103
116
 
104
- /* Cartesian DOM order: place sections in the visual grid by index.
105
- Indices 0..3 correspond to Q1..Q4. CSS grid-area positions them. */
106
- for (let q = 0; q < 4; q++) {
117
+ /* Indices 0..3 are Cartesian Q1..Q4 (Q1 top-right, Q2 top-left).
118
+ Append in visual reading order (top-left, top-right, bottom-left,
119
+ bottom-right) so DOM, reading and tab order match what is seen;
120
+ CSS still places each zone by data-quadrant-zone. */
121
+ for (const q of QuadrantGrid.VISUAL_ORDER) {
107
122
  const section = document.createElement('section');
108
123
  section.className = 'qg-quadrant';
109
124
  section.dataset.quadrantZone = String(q);
@@ -167,11 +182,14 @@ class QuadrantGrid extends VBElement {
167
182
  }
168
183
  }
169
184
 
170
- // Wrap non-drag children in <li> so list semantics hold.
185
+ // Wrap non-drag children in <li> so list semantics hold. A pinned
186
+ // child is absolutely positioned out of its <li>, so that <li> must
187
+ // not draw a chip of its own.
171
188
  if (draggable) {
172
189
  target.appendChild(child);
173
190
  } else {
174
191
  const li = document.createElement('li');
192
+ if (child.classList.contains('qg-pinned')) li.className = 'qg-pin-item';
175
193
  li.appendChild(child);
176
194
  target.appendChild(li);
177
195
  }
@@ -192,11 +210,18 @@ class QuadrantGrid extends VBElement {
192
210
  this.appendChild(wrapper);
193
211
  this.appendChild(liveRegion);
194
212
 
195
- this.#grid = grid;
196
213
  this.#liveRegion = liveRegion;
197
214
  this.#qLabels = qLabels;
215
+ this.#draggable = draggable;
198
216
 
199
- if (draggable) {
217
+ this.#wire();
218
+ }
219
+
220
+ /** Attach listeners and pin observers once per connection. */
221
+ #wire() {
222
+ if (this.#wired) return;
223
+ this.#wired = true;
224
+ if (this.#draggable) {
200
225
  this.listen(this, 'drag-surface:transfer', this.#onTransfer);
201
226
  }
202
227
 
@@ -208,6 +233,13 @@ class QuadrantGrid extends VBElement {
208
233
  this.#observePins();
209
234
  }
210
235
 
236
+ /** @param {string} text */
237
+ static #span(text) {
238
+ const span = document.createElement('span');
239
+ span.textContent = text;
240
+ return span;
241
+ }
242
+
211
243
  #observePins() {
212
244
  if (typeof ResizeObserver === 'undefined') return;
213
245
  const measure = (el) => {
@@ -250,6 +282,10 @@ class QuadrantGrid extends VBElement {
250
282
  pin.removeAttribute('tabindex');
251
283
  delete pin.dataset.qgTabbed;
252
284
  }
285
+ if (pin.dataset.qgRole !== undefined) {
286
+ pin.removeAttribute('role');
287
+ delete pin.dataset.qgRole;
288
+ }
253
289
  }
254
290
 
255
291
  let clusterSeq = 0;
@@ -284,9 +320,16 @@ class QuadrantGrid extends VBElement {
284
320
  pin.dataset.cluster = id;
285
321
  pin.style.setProperty('--qg-cluster-i', String(i));
286
322
  pin.style.setProperty('--qg-cluster-n', String(members.length));
323
+ // Clustered pins become focusable so keyboard users can fan the
324
+ // cluster out (:focus-within); a focusable generic element needs a
325
+ // role, so expose it as a group.
287
326
  if (!pin.hasAttribute('tabindex') && !pin.matches('a, button, [contenteditable]')) {
288
327
  pin.setAttribute('tabindex', '0');
289
328
  pin.dataset.qgTabbed = '';
329
+ if (!pin.hasAttribute('role')) {
330
+ pin.setAttribute('role', 'group');
331
+ pin.dataset.qgRole = '';
332
+ }
290
333
  }
291
334
  });
292
335
  }
@@ -294,6 +337,9 @@ class QuadrantGrid extends VBElement {
294
337
  }
295
338
 
296
339
  teardown() {
340
+ // Listeners are removed by VBElement; the built grid and its children
341
+ // stay in place so a moved grid keeps its content.
342
+ this.#wired = false;
297
343
  this.#pinObserver?.disconnect();
298
344
  this.#pinObserver = null;
299
345
  if (this.#clusterRaf) cancelAnimationFrame(this.#clusterRaf);
@@ -320,6 +366,8 @@ class QuadrantGrid extends VBElement {
320
366
  if (from == null || to == null) return;
321
367
 
322
368
  item.setAttribute('data-quadrant', String(to));
369
+ if (item.classList.contains('qg-pinned')) this.#unpin(item);
370
+ this.#scheduleClusterPass();
323
371
  this.#announce(`Moved ${this.#itemLabel(item)} to ${this.#qLabels[to]}`);
324
372
 
325
373
  this.dispatchEvent(new CustomEvent('quadrant-grid:move', {
@@ -333,6 +381,25 @@ class QuadrantGrid extends VBElement {
333
381
  }));
334
382
  };
335
383
 
384
+ /**
385
+ * A dropped pin has no coordinates in its new quadrant: drop data-x /
386
+ * data-y and the pin styling so it joins the quadrant's stack.
387
+ * @param {HTMLElement} item
388
+ */
389
+ #unpin(item) {
390
+ this.#pinObserver?.unobserve(item);
391
+ item.removeAttribute('data-x');
392
+ item.removeAttribute('data-y');
393
+ item.classList.remove('qg-pinned', 'qg-clustered');
394
+ for (const prop of ['--qg-local-x', '--qg-local-y', '--qg-pin-half-w', '--qg-pin-half-h',
395
+ '--qg-cluster-i', '--qg-cluster-n']) {
396
+ item.style.removeProperty(prop);
397
+ }
398
+ delete item.dataset.cluster;
399
+ if (item.dataset.qgTabbed !== undefined) { item.removeAttribute('tabindex'); delete item.dataset.qgTabbed; }
400
+ if (item.dataset.qgRole !== undefined) { item.removeAttribute('role'); delete item.dataset.qgRole; }
401
+ }
402
+
336
403
  #resolveQuadrant(child) {
337
404
  const q = parseInt(child.getAttribute('data-quadrant'), 10);
338
405
  if (q >= 0 && q <= 3) return q;
@@ -374,6 +441,9 @@ class QuadrantGrid extends VBElement {
374
441
  }
375
442
 
376
443
  #groupId = '';
444
+ #built = false;
445
+ #wired = false;
446
+ #draggable = false;
377
447
  /** @type {Record<number, HTMLElement>} */
378
448
  #surfaces = {};
379
449
  /** @type {Record<number, HTMLElement>} */
@@ -381,8 +451,6 @@ class QuadrantGrid extends VBElement {
381
451
  /** @type {Record<number, HTMLElement>} */
382
452
  #sections = {};
383
453
  /** @type {HTMLElement | null} */
384
- #grid = null;
385
- /** @type {HTMLElement | null} */
386
454
  #liveRegion = null;
387
455
  /** @type {string[]} */
388
456
  #qLabels = [];
@@ -122,6 +122,13 @@ quadrant-grid {
122
122
  font-size: var(--font-size-sm, 0.875rem);
123
123
  }
124
124
 
125
+ /* The <li> around a coordinate-pinned child: the pin is absolutely
126
+ positioned against the quadrant, so the <li> draws no chip and takes
127
+ no space in the list. */
128
+ .qg-items > li.qg-pin-item {
129
+ display: contents;
130
+ }
131
+
125
132
  /* Coordinate-pinned children get absolute placement.
126
133
  Section-local coords (0..1) are computed by JS and stored as
127
134
  --qg-local-x / --qg-local-y. The component also measures each pin
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Combines the prior _nfr-utils.js (default ilities, default cost
5
5
  * weights, validation invariants) with _radar-geometry.js (axis
6
- * angles, vector/envelope points, SVG builder), with the addition
6
+ * angles, vector points, SVG builder), with the addition
7
7
  * of click-to-edit dialog state helpers.
8
8
  *
9
9
  * Domain-neutral schema property names are unchanged: vector,
@@ -37,7 +37,7 @@ export const LEVEL_LABELS = Object.freeze({
37
37
  });
38
38
 
39
39
  /** Default cost weights when picking an ility as Critical. Sum = 40. */
40
- export const DEFAULT_COST_WEIGHTS = Object.freeze({
40
+ const DEFAULT_COST_WEIGHTS = Object.freeze({
41
41
  accessibility: 3,
42
42
  performance: 5,
43
43
  security: 5,
@@ -65,20 +65,6 @@ const ILITY_LABELS = Object.freeze({
65
65
  privacy: 'Privacy',
66
66
  });
67
67
 
68
- const ILITY_ABBR = Object.freeze({
69
- accessibility: 'a11y',
70
- internationalization: 'i18n',
71
- maintainability: 'maint',
72
- observability: 'obs',
73
- compatibility: 'compat',
74
- portability: 'porta',
75
- performance: 'perf',
76
- reliability: 'rely',
77
- scalability: 'scale',
78
- security: 'sec',
79
- privacy: 'priv',
80
- });
81
-
82
68
  const LEVEL_RATIO = Object.freeze({
83
69
  critical: 1,
84
70
  important: 0.6,
@@ -95,10 +81,6 @@ export function ilityLabel(ility) {
95
81
  .replace(/\b\w/g, c => c.toUpperCase());
96
82
  }
97
83
 
98
- export function ilityAbbr(ility) {
99
- return ILITY_ABBR[ility] || ility;
100
- }
101
-
102
84
  // ── Cost weights ──────────────────────────────────────────────────
103
85
 
104
86
  export function mergeCostWeights(ilities, overrides = {}) {
@@ -213,20 +195,6 @@ export function axisAngles(n) {
213
195
  return out;
214
196
  }
215
197
 
216
- /**
217
- * Fraction of `radius` the capacity envelope reaches per axis.
218
- * Returns null when capacity is unbounded.
219
- */
220
- export function envelopeRatio({ costWeights = {}, capacityPoints, ilities }) {
221
- if (!Number.isFinite(capacityPoints)) return null;
222
- const total = ilities.reduce((s, k) => {
223
- const w = Number(costWeights[k]);
224
- return s + (Number.isFinite(w) && w > 0 ? w : 0);
225
- }, 0);
226
- if (total <= 0) return 0;
227
- return Math.min(1, Math.max(0, capacityPoints / total));
228
- }
229
-
230
198
  /** Per-ility {x, y, ility, level} points for the chosen vector. */
231
199
  export function vectorPoints({ ilities, vector = {}, radius }) {
232
200
  const angles = axisAngles(ilities.length);
@@ -242,18 +210,6 @@ export function vectorPoints({ ilities, vector = {}, radius }) {
242
210
  });
243
211
  }
244
212
 
245
- /** Per-ility {x, y, ility} points for the capacity envelope. */
246
- export function envelopePoints({ ilities, costWeights, capacityPoints, radius }) {
247
- const ratio = envelopeRatio({ costWeights, capacityPoints, ilities });
248
- if (ratio == null) return null;
249
- const angles = axisAngles(ilities.length);
250
- return ilities.map((k, i) => ({
251
- ility: k,
252
- x: round(Math.cos(angles[i]) * radius * ratio),
253
- y: round(Math.sin(angles[i]) * radius * ratio),
254
- }));
255
- }
256
-
257
213
  /** Outer axis endpoints — used for the spoke + label positions. */
258
214
  export function axisOuterPoints({ ilities, radius }) {
259
215
  const angles = axisAngles(ilities.length);
@@ -92,13 +92,15 @@ With JavaScript:
92
92
 
93
93
  | Event | Detail | Description |
94
94
  |-------|--------|-------------|
95
- | `review-surface:ready` | `{ pinCount }` | After render |
95
+ | `review-surface:upgraded` | — | Once, on first connect |
96
+ | `review-surface:ready` | `{ pinCount }` | Once, after the initial pins render |
96
97
  | `review-surface:add` | `{ pin }` | Pin created |
97
98
  | `review-surface:update` | `{ pin, changes }` | Pin modified |
98
99
  | `review-surface:remove` | `{ pin }` | Pin deleted |
99
100
  | `review-surface:resolve` | `{ pin }` | Pin resolved |
100
101
  | `review-surface:select` | `{ pin }` | Popover opened |
101
102
  | `review-surface:mode` | `{ mode }` | Mode toggled |
103
+ | `review-surface:pins-changed` | `{ pins, source }` | After `.pins` assignment |
102
104
 
103
105
  ## Adapters
104
106
 
@@ -121,22 +123,24 @@ Set via property:
121
123
  ```js
122
124
  document.querySelector('review-surface').adapter = myCustomAdapter;
123
125
  ```
124
- Must implement: `load()`, `save(pin)`, `update(id, changes)`, `remove(id)`.
126
+ Must implement: `load()`, `save(pin)`, `update(id, changes, pin)`, `remove(id)`.
127
+ `update` receives the component's full pin as a third argument so it can
128
+ upsert pins it did not create (loaded via `src` or `.pins`).
125
129
 
126
130
  ## Failure Modes
127
131
 
128
132
  - **No JavaScript:** Child content renders normally; pins are not shown.
129
133
  - **No CSS:** Shadow DOM styles are self-contained; layout degrades gracefully.
130
- - **Keyboard Only:** Tab navigates pins; Enter opens popover; Escape closes.
134
+ - **Keyboard Only:** Tab navigates pins; Enter opens popover (focus moves into it); Escape closes and returns focus to the pin.
131
135
  - **Screen Reader:** Pin markers announced as "Pin N: [text preview]";
132
136
  live region announces create/delete/resolve actions.
133
137
 
134
138
  ## Accessibility
135
139
 
136
- - Pin markers are `<button>` with `aria-label`, `aria-expanded`, `aria-haspopup="dialog"`
140
+ - Pin markers are `<button>` with `aria-label`, `aria-expanded`, `aria-haspopup="dialog"`, inside a `role="group"` overlay labelled "Review pins"
137
141
  - Popover uses `role="dialog"` with `aria-labelledby`
138
142
  - Toolbar uses `role="toolbar"` with `aria-label`
139
- - `aria-live="polite"` region announces pin actions
143
+ - One persistent `aria-live="polite"` region announces pin actions
140
144
  - All transitions respect `prefers-reduced-motion: reduce`
141
145
 
142
146
  ## CSS Tokens
@@ -5,7 +5,7 @@
5
5
  "description": "Pin-based annotation overlay for design review — wraps any content and adds interactive comment pins with pluggable persistence",
6
6
  "htmlvalidate": { "flow": true, "permittedContent": ["@flow"] },
7
7
  "attributes": [
8
- { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON file containing pin data" },
8
+ { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON file containing pin data (an array, or { pins }). A newer value aborts an older in-flight request; removing it, or assigning .pins / importPins(), aborts a pending request (removing it mid-load falls back to the adapter)" },
9
9
  { "name": "editable", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Enables annotate mode toolbar and pin creation" },
10
10
  { "name": "adapter", "kind": "host-api", "purpose": "config", "type": "string", "description": "Persistence backend: memory | local | rest" },
11
11
  { "name": "endpoint", "kind": "host-api", "purpose": "config", "type": "string", "description": "REST endpoint URL (when adapter=rest)" },
@@ -19,12 +19,14 @@
19
19
  { "name": "(default)", "description": "The content to annotate (any HTML)" }
20
20
  ],
21
21
  "events": [
22
- { "name": "review-surface:ready", "detail": "{ pinCount }", "description": "Fired after initial render" },
23
- { "name": "review-surface:add", "detail": "{ pin }", "description": "Fired when a pin is created" },
24
- { "name": "review-surface:update", "detail": "{ pin, changes }", "description": "Fired when a pin is modified" },
25
- { "name": "review-surface:remove", "detail": "{ pin }", "description": "Fired when a pin is deleted" },
26
- { "name": "review-surface:resolve", "detail": "{ pin }", "description": "Fired when a pin is marked resolved" },
27
- { "name": "review-surface:select", "detail": "{ pin }", "description": "Fired when a pin popover is opened" },
28
- { "name": "review-surface:mode", "detail": "{ mode }", "description": "Fired when view/annotate mode toggles" }
22
+ { "name": "review-surface:upgraded", "detail": "—", "description": "Fired once, on first connect, after the adapter is resolved and before the initial pin load. Not re-fired when the element is moved." },
23
+ { "name": "review-surface:ready", "detail": "{ pinCount }", "description": "Fired once, after the initial pins (from src, the adapter, or a pre-connect .pins assignment) have loaded and rendered. Not re-fired on later renders." },
24
+ { "name": "review-surface:add", "detail": "{ pin }", "description": "Fired when a pin is created (annotate-mode click or addPin())" },
25
+ { "name": "review-surface:update", "detail": "{ pin, changes }", "description": "Fired when a pin is modified (reply added, text set, re-opened)" },
26
+ { "name": "review-surface:remove", "detail": "{ pin }", "description": "Fired when a pin is deleted" },
27
+ { "name": "review-surface:resolve", "detail": "{ pin }", "description": "Fired when a pin is marked resolved" },
28
+ { "name": "review-surface:select", "detail": "{ pin }", "description": "Fired when a pin popover is opened" },
29
+ { "name": "review-surface:mode", "detail": "{ mode: 'view' | 'annotate' }", "description": "Fired when view/annotate mode toggles" },
30
+ { "name": "review-surface:pins-changed", "detail": "{ pins, source: 'property' }", "description": "Fired after a .pins assignment (not for adapter loads or addPin())" }
29
31
  ]
30
32
  }