@profpowell/vb-project-planning 0.1.0 → 0.2.1

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.
@@ -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"]
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * <requirement-card> — Display atom for ONE NFR ility row.
3
3
  *
4
- * Display-only. The prioritization decision lives in <nfr-compass>;
4
+ * Display-only. The prioritization decision lives in <quality-target>;
5
5
  * this component just renders one row's worth of state — name +
6
6
  * category badge + priority pill + rationale + (optional) conflict flag —
7
7
  * for use on status dashboards, ADR appendices, or anywhere a project
8
- * wants to surface a single ility outside the compass.
8
+ * wants to surface a single ility outside the target.
9
9
  *
10
10
  * Slots:
11
11
  * name — ility label (e.g. "Performance")
@@ -24,6 +24,8 @@
24
24
  * requirement-card:click — bubbles a structured detail when the card
25
25
  * is activated (mouse, keyboard, or touch);
26
26
  * parent components can wire navigation.
27
+ * Inside an <a>/<button> wrapper, the
28
+ * wrapper's own keyboard activation counts.
27
29
  *
28
30
  * Pattern reference: matches the shadow-DOM + slot conventions from
29
31
  * <score-card> (also a single-row dashboard atom).
@@ -58,6 +60,9 @@ const TEMPLATE = `
58
60
  `;
59
61
 
60
62
  class RequirementCard extends VBElement {
63
+ /** True when this element added role="button" itself (so it may remove it). */
64
+ #ownsRole = false;
65
+
61
66
  static get observedAttributes() {
62
67
  return ['data-priority', 'data-conflict', 'tabindex'];
63
68
  }
@@ -82,6 +87,15 @@ class RequirementCard extends VBElement {
82
87
  this.#syncInteractive();
83
88
 
84
89
  this.listen(this, 'click', (e) => this.#onActivate(e));
90
+ // Keyboard activation of a wrapping <a>/<button> fires `click` on the
91
+ // wrapper itself, which never reaches the card's own listener. Clicks
92
+ // that started inside the card are already handled above.
93
+ const wrapper = this.closest('a[href], button');
94
+ if (wrapper) {
95
+ this.listen(wrapper, 'click', (e) => {
96
+ if (!this.contains(/** @type {Node} */ (e.target))) this.#onActivate(e);
97
+ });
98
+ }
85
99
  this.listen(this, 'keydown', (e) => {
86
100
  const ke = /** @type {KeyboardEvent} */ (e);
87
101
  if ((ke.key === 'Enter' || ke.key === ' ') && this.#interactive) {
@@ -125,7 +139,7 @@ class RequirementCard extends VBElement {
125
139
  }
126
140
 
127
141
  #syncSlot(name, slot) {
128
- const hasContent = !!slot && slot.assignedNodes({ flatten: true }).some(n =>
142
+ const hasContent = !!slot && slot.assignedNodes().some(n =>
129
143
  n.nodeType === Node.ELEMENT_NODE ||
130
144
  (n.nodeType === Node.TEXT_NODE && n.textContent.trim().length > 0)
131
145
  );
@@ -146,6 +160,16 @@ class RequirementCard extends VBElement {
146
160
 
147
161
  #syncInteractive() {
148
162
  this.setState('interactive', !!this.#interactive);
163
+ // A focusable card that is not inside a native control needs a role
164
+ // so assistive tech announces it as activatable. Author roles win.
165
+ const needsRole = this.hasAttribute('tabindex') && !this.closest('a[href], button');
166
+ if (needsRole && !this.hasAttribute('role')) {
167
+ this.setAttribute('role', 'button');
168
+ this.#ownsRole = true;
169
+ } else if (!needsRole && this.#ownsRole) {
170
+ this.removeAttribute('role');
171
+ this.#ownsRole = false;
172
+ }
149
173
  }
150
174
 
151
175
  #onActivate(event) {
@@ -8,7 +8,7 @@
8
8
  <body>
9
9
  <!--
10
10
  Display-only card for ONE NFR ility row. The prioritization
11
- decision lives in <nfr-compass>; this is the visual atom.
11
+ decision lives in <quality-target>; this is the visual atom.
12
12
  Pre-upgrade, the card renders as a plain article — no JS needed
13
13
  for it to be readable.
14
14
  -->
@@ -105,7 +105,7 @@ class UserPersona extends HTMLElement {
105
105
  if (!this.id) return [];
106
106
  const root = /** @type {ParentNode} */ (this.getRootNode());
107
107
  const scope = /** @type {ParentNode} */ (/** @type {any} */ (root).querySelectorAll ? root : document);
108
- return Array.from(scope.querySelectorAll(`user-story[persona-id="${cssEscape(this.id)}"]`));
108
+ return Array.from(scope.querySelectorAll(`user-story[persona-id="${cssEscape(this.id)}"]:not([data-detail-clone])`));
109
109
  }
110
110
 
111
111
  #cacheSlotValues() {
@@ -210,7 +210,7 @@ class UserStory extends HTMLElement {
210
210
 
211
211
  connectedCallback() {
212
212
  this.#cacheSlotValues();
213
- if (this.storyId && !this.id) this.id = this.storyId;
213
+ if (this.storyId && !this.id && !this.hasAttribute('data-detail-clone')) this.id = this.storyId;
214
214
  if (this.hasAttribute('src')) {
215
215
  this._loadSrc(this.getAttribute('src'));
216
216
  }
@@ -410,6 +410,7 @@ class UserStory extends HTMLElement {
410
410
  const hasSlottedContent = [...this.children].some(c => c.getAttribute('slot') && c.tagName !== 'DIALOG');
411
411
  full.setAttribute('detail', hasSlottedContent ? 'full' : 'compact');
412
412
  full.removeAttribute('id');
413
+ full.setAttribute('data-detail-clone', ''); // keep the clone out of id/persona lookups
413
414
  for (const child of [...this.children]) {
414
415
  if (child.tagName === 'DIALOG') continue;
415
416
  full.appendChild(child.cloneNode(true));