@profpowell/vb-project-planning 0.2.0 → 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 (73) hide show
  1. package/README.md +5 -2
  2. package/dist/vb-project-planning.css +70 -3
  3. package/dist/vb-project-planning.js +3333 -2565
  4. package/dist/vb-project-planning.min.css +1 -1
  5. package/dist/vb-project-planning.min.js +224 -200
  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 +1 -19
  33. package/src/web-components/iron-triangle/_triangle-geometry.js +1 -1
  34. package/src/web-components/iron-triangle/api.json +2 -2
  35. package/src/web-components/iron-triangle/logic.js +47 -9
  36. package/src/web-components/iron-triangle/styles.css +22 -1
  37. package/src/web-components/kanban-board/api.json +8 -6
  38. package/src/web-components/kanban-board/logic.js +289 -333
  39. package/src/web-components/product-roadmap/api.json +14 -13
  40. package/src/web-components/product-roadmap/logic.js +393 -246
  41. package/src/web-components/product-roadmap/styles.css +40 -0
  42. package/src/web-components/quadrant-grid/api.json +9 -4
  43. package/src/web-components/quadrant-grid/logic.js +80 -12
  44. package/src/web-components/quadrant-grid/styles.css +7 -0
  45. package/src/web-components/quality-target/_quality-utils.js +2 -46
  46. package/src/web-components/quality-target/api.json +2 -3
  47. package/src/web-components/quality-target/logic.js +85 -13
  48. package/src/web-components/requirement-card/api.json +1 -1
  49. package/src/web-components/requirement-card/logic.js +26 -2
  50. package/src/web-components/requirement-card/static.html +1 -1
  51. package/src/web-components/review-surface/README.md +9 -5
  52. package/src/web-components/review-surface/api.json +10 -8
  53. package/src/web-components/review-surface/logic.js +349 -218
  54. package/src/web-components/risk-register/api.json +1 -6
  55. package/src/web-components/risk-register/logic.js +71 -24
  56. package/src/web-components/story-map/api.json +8 -6
  57. package/src/web-components/story-map/logic.js +280 -281
  58. package/src/web-components/story-map/static.html +15 -5
  59. package/src/web-components/traceability-matrix/api.json +10 -3
  60. package/src/web-components/traceability-matrix/logic.js +112 -63
  61. package/src/web-components/traceability-matrix/styles.css +14 -1
  62. package/src/web-components/user-journey/api.json +25 -24
  63. package/src/web-components/user-journey/logic.js +76 -127
  64. package/src/web-components/user-journey/static.html +2 -2
  65. package/src/web-components/user-persona/api.json +15 -14
  66. package/src/web-components/user-persona/logic.js +71 -117
  67. package/src/web-components/user-persona/styles.js +1 -0
  68. package/src/web-components/user-story/api.json +13 -11
  69. package/src/web-components/user-story/logic.js +164 -195
  70. package/src/web-components/user-story/styles.js +28 -1
  71. package/src/web-components/work-item/README.md +3 -3
  72. package/src/web-components/work-item/api.json +13 -11
  73. 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);
@@ -13,13 +13,12 @@
13
13
  { "name": "data-capacity-points", "kind": "data", "purpose": "config", "type": "number", "description": "Literal capacity fallback when no triangle is bound" },
14
14
  { "name": "data-cost-weights", "kind": "data", "purpose": "config", "type": "string", "description": "JSON object overriding default per-ility cost weights" },
15
15
  { "name": "data-radius", "kind": "data", "purpose": "config", "type": "number", "default": "100", "description": "Base radius in SVG units" },
16
- { "name": "data-show-envelope", "kind": "data", "purpose": "visual-variant", "type": "boolean", "description": "Show the capacity envelope polygon (default true; set to \"false\" to hide)" },
17
16
  { "name": "data-min-rationale", "kind": "data", "purpose": "config", "type": "number", "default": "10", "description": "Min characters for a Critical row's rationale" },
18
17
  { "name": "data-max-rationale", "kind": "data", "purpose": "config", "type": "number", "default": "200", "description": "Max characters for the rationale textarea" },
19
18
  { "name": "data-min-overrun-rationale", "kind": "data", "purpose": "config", "type": "number", "default": "10", "description": "Min characters for overrunRationale" },
20
19
  { "name": "data-max-overrun-rationale", "kind": "data", "purpose": "config", "type": "number", "default": "400", "description": "Max characters for overrunRationale" },
21
- { "name": "disabled", "kind": "native", "purpose": "config", "type": "boolean", "description": "All inputs disabled" },
22
- { "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped vectors" }
20
+ { "name": "disabled", "kind": "native", "purpose": "config", "type": "boolean", "description": "No editing: axes leave the tab order, openEditor() is a no-op, dialog and footer controls are disabled; also bars the element from form validation" },
21
+ { "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped vectors; same editing lock as disabled, but the value still submits and validates" }
23
22
  ],
24
23
  "slots": [],
25
24
  "events": [
@@ -4,9 +4,10 @@
4
4
  * One component, two roles. The radar polygon IS the picker: each
5
5
  * axis is a clickable hit-target that opens a per-ility <dialog>
6
6
  * editor (level radio group + rationale textarea). The same SVG
7
- * also visualizes the chosen vector against the project's capacity
8
- * envelope (read from a paired <iron-triangle> via data-bind-to or
9
- * supplied via data-capacity-points).
7
+ * also shows the Critical spend against the project's capacity in a
8
+ * centre readout ("5 / of 4"), with capacity read from a paired
9
+ * <iron-triangle> via data-bind-to or supplied via data-capacity-points.
10
+ * There is no envelope polygon: three level rings are the only backdrop.
10
11
  *
11
12
  * Replaces both <nfr-compass> and <nfr-radar> from earlier rounds.
12
13
  *
@@ -37,12 +38,14 @@ import {
37
38
  buildTargetSvg,
38
39
  } from './_quality-utils.js';
39
40
 
41
+ let instanceCount = 0;
42
+
40
43
  class QualityTarget extends VBElement {
41
44
  static formAssociated = true;
42
45
  static get observedAttributes() {
43
46
  return [
44
47
  'data-bind-to', 'data-capacity-points', 'data-cost-weights',
45
- 'data-radius', 'data-show-envelope',
48
+ 'data-radius',
46
49
  'data-min-rationale', 'data-max-rationale',
47
50
  'data-min-overrun-rationale', 'data-max-overrun-rationale',
48
51
  'disabled', 'locked',
@@ -71,6 +74,9 @@ class QualityTarget extends VBElement {
71
74
  #dialogs = {};
72
75
  /** @type {HTMLElement | null} */
73
76
  #footer = null;
77
+ /** Per-instance prefix so generated label/control ids never collide
78
+ * when a page holds more than one target. */
79
+ #uid = `quality-target-${++instanceCount}`;
74
80
 
75
81
  constructor() {
76
82
  super();
@@ -83,6 +89,7 @@ class QualityTarget extends VBElement {
83
89
  this.#bindToTriangle();
84
90
  this.#renderViz();
85
91
  this.#ensureFooter();
92
+ this.#publishFormValue();
86
93
 
87
94
  this.listen(this, 'click', (e) => this.#onClick(e));
88
95
  this.listen(this, 'keydown', (e) => this.#onKeydown(e));
@@ -104,8 +111,11 @@ class QualityTarget extends VBElement {
104
111
  } else if (name === 'data-cost-weights') {
105
112
  this.#refreshCostWeights();
106
113
  this.#renderViz();
107
- } else if (name === 'data-radius' || name === 'data-show-envelope') {
114
+ } else if (name === 'data-radius') {
108
115
  this.#renderViz();
116
+ } else if (name === 'data-min-overrun-rationale' || name === 'data-max-overrun-rationale') {
117
+ this.#updateFooter();
118
+ this.#publishFormValue();
109
119
  } else if (name === 'disabled' || name === 'locked') {
110
120
  this.#syncDisabledLocked();
111
121
  }
@@ -153,7 +163,7 @@ class QualityTarget extends VBElement {
153
163
 
154
164
  /** Imperatively open one axis's editor. */
155
165
  openEditor(ility) {
156
- if (!this.#ilities.includes(ility)) return;
166
+ if (!this.#ilities.includes(ility) || this.#inert) return;
157
167
  const dialog = this.#ensureDialog(ility);
158
168
  if (typeof dialog.showModal === 'function') dialog.showModal();
159
169
  else dialog.setAttribute('open', '');
@@ -244,10 +254,10 @@ class QualityTarget extends VBElement {
244
254
  // Rationale form-field (required when Critical).
245
255
  const ff = document.createElement('form-field');
246
256
  const rl = document.createElement('label');
247
- rl.setAttribute('for', `quality-${ility}-rationale`);
257
+ rl.setAttribute('for', `${this.#uid}-${ility}-rationale`);
248
258
  rl.textContent = 'Rationale (required when Critical)';
249
259
  const ta = document.createElement('textarea');
250
- ta.id = `quality-${ility}-rationale`;
260
+ ta.id = `${this.#uid}-${ility}-rationale`;
251
261
  ta.name = 'rationale';
252
262
  ta.rows = 3;
253
263
  ta.minLength = this.#minRationale();
@@ -304,6 +314,7 @@ class QualityTarget extends VBElement {
304
314
  });
305
315
 
306
316
  this.#syncDialogFromState(ility);
317
+ this.#syncDisabledLocked();
307
318
  return dialog;
308
319
  }
309
320
 
@@ -338,8 +349,8 @@ class QualityTarget extends VBElement {
338
349
  <strong>Over budget — explain why.</strong>
339
350
  <small data-overrun-prompt></small>
340
351
  <form-field>
341
- <label for="quality-overrun">Overrun rationale (≥ ${this.#minOverrunRationale()} chars)</label>
342
- <textarea id="quality-overrun" name="overrunRationale"
352
+ <label for="${this.#uid}-overrun">Overrun rationale (≥ ${this.#minOverrunRationale()} chars)</label>
353
+ <textarea id="${this.#uid}-overrun" name="overrunRationale"
343
354
  minlength="${this.#minOverrunRationale()}"
344
355
  maxlength="${this.#maxOverrunRationale()}"
345
356
  rows="3"></textarea>
@@ -355,6 +366,7 @@ class QualityTarget extends VBElement {
355
366
 
356
367
  this.append(footer);
357
368
  this.#footer = footer;
369
+ this.#syncDisabledLocked();
358
370
  }
359
371
 
360
372
  #updateFooter() {
@@ -366,9 +378,19 @@ class QualityTarget extends VBElement {
366
378
  if (overrun) {
367
379
  overrun.hidden = !isOver;
368
380
  const prompt = overrun.querySelector('[data-overrun-prompt]');
369
- if (prompt && Number.isFinite(cap)) {
370
- prompt.textContent = `You're over budget by ${sum - cap} points (${sum}/${cap}).`;
381
+ if (prompt) {
382
+ const over = sum - cap;
383
+ prompt.textContent = isOver
384
+ ? `You're over budget by ${over} ${over === 1 ? 'point' : 'points'} (${sum}/${cap}).`
385
+ : '';
371
386
  }
387
+ // Keep the label and limits in step with the data-*-overrun-rationale
388
+ // attributes, which may change after the footer was built.
389
+ const min = this.#minOverrunRationale();
390
+ const label = overrun.querySelector('label');
391
+ if (label) label.textContent = `Overrun rationale (≥ ${min} chars)`;
392
+ const ta = /** @type {HTMLTextAreaElement | null} */ (overrun.querySelector('textarea'));
393
+ if (ta) { ta.minLength = min; ta.maxLength = this.#maxOverrunRationale(); }
372
394
  }
373
395
  }
374
396
 
@@ -461,6 +483,7 @@ class QualityTarget extends VBElement {
461
483
  const existing = this.querySelector(':scope > svg');
462
484
  if (existing) existing.replaceWith(svg);
463
485
  else this.prepend(svg);
486
+ this.#syncAxesInert();
464
487
  }
465
488
 
466
489
  #publish(source, field) {
@@ -490,15 +513,64 @@ class QualityTarget extends VBElement {
490
513
  try {
491
514
  this.#internals.setFormValue(JSON.stringify(this.value));
492
515
  } catch { /* older Safari */ }
516
+ this.#syncValidity();
493
517
  }
494
518
 
519
+ /**
520
+ * Mirror validateVector() into ElementInternals so a parent <form>'s
521
+ * checkValidity() / submit is blocked too, not just this element's own
522
+ * checkValidity() method. The overrun textarea anchors the message when
523
+ * the overrun rationale is the problem.
524
+ */
525
+ #syncValidity() {
526
+ if (typeof this.#internals.setValidity !== 'function') return;
527
+ const { valid, errors } = validateVector({
528
+ vector: this.#vector,
529
+ rationales: this.#rationales,
530
+ costWeights: this.#costWeights,
531
+ capacityPoints: this.#resolveCapacity(),
532
+ overrunRationale: this.#overrunRationale,
533
+ minRationale: this.#minRationale(),
534
+ minOverrunRationale: this.#minOverrunRationale(),
535
+ });
536
+ if (valid) { this.#internals.setValidity({}); return; }
537
+ const overrunTa = /** @type {HTMLElement | null} */ (
538
+ this.overBudget ? this.querySelector('[data-quality-overrun] textarea') : null);
539
+ try {
540
+ this.#internals.setValidity({ customError: true }, errors.join(' '), overrunTa ?? undefined);
541
+ } catch { /* anchor not yet connected */
542
+ this.#internals.setValidity({ customError: true }, errors.join(' '));
543
+ }
544
+ }
545
+
546
+ get #inert() { return this.hasAttribute('disabled') || this.hasAttribute('locked'); }
547
+
548
+ /**
549
+ * disabled / locked: no editing path stays open. styles.css already sets
550
+ * pointer-events: none; this closes the keyboard and programmatic paths
551
+ * (axes leave the tab order, openEditor() is a no-op) and disables every
552
+ * dialog and footer control, including dialogs built later.
553
+ */
495
554
  #syncDisabledLocked() {
496
- const off = this.hasAttribute('disabled') || this.hasAttribute('locked');
555
+ const off = this.#inert;
497
556
  for (const dialog of Object.values(this.#dialogs)) {
498
557
  for (const el of dialog.querySelectorAll('input, textarea, select, button')) {
499
558
  /** @type {HTMLInputElement} */ (el).disabled = off;
500
559
  }
501
560
  }
561
+ for (const el of this.#footer?.querySelectorAll('textarea') ?? []) {
562
+ /** @type {HTMLTextAreaElement} */ (el).disabled = off;
563
+ }
564
+ this.#syncAxesInert();
565
+ }
566
+
567
+ #syncAxesInert() {
568
+ const off = this.#inert;
569
+ for (const axis of this.querySelectorAll(':scope > svg .axis[data-ility]')) {
570
+ axis.setAttribute('tabindex', off ? '-1' : '0');
571
+ if (off) axis.setAttribute('aria-disabled', 'true');
572
+ else axis.removeAttribute('aria-disabled');
573
+ }
502
574
  }
503
575
 
504
576
  // ── Internal: config readers ──────────────────────────────────────
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../schemas/api.schema.json",
3
3
  "element": "requirement-card",
4
4
  "type": "web-component",
5
- "description": "Display-only card for ONE NFR ility row. Renders priority + rationale + optional conflict flag for status dashboards and ADR appendices. The prioritization decision itself lives in <nfr-compass>.",
5
+ "description": "Display-only card for ONE NFR ility row. Renders priority + rationale + optional conflict flag for status dashboards and ADR appendices. The prioritization decision itself lives in <quality-target>.",
6
6
  "htmlvalidate": {
7
7
  "flow": true,
8
8
  "permittedContent": ["@flow"]