@profpowell/vb-project-planning 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profpowell/vb-project-planning",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Project- and UX-planning Web Components for Vanilla Breeze: personas, stories, journeys, empathy maps, impact/effort and quadrant grids, risk and traceability matrices, burndown, roadmap, kanban, story map, gantt, review surface, work items, ADRs, iron triangle, capacity plans, quality targets, and requirement cards.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas A. Powell",
@@ -40,7 +40,7 @@ export function defaultFormulaText(time, cost, focusFactor) {
40
40
 
41
41
  /**
42
42
  * FNV-1a (32-bit) hash over a stable JSON serialization of the triangle.
43
- * Used for drift detection by downstream consumers (notably <nfr-compass>'s
43
+ * Used for drift detection by downstream consumers (notably <quality-target>'s
44
44
  * ironTriangleHash field).
45
45
  *
46
46
  * Matches the fnv1a in src/web-components/color-picker/_color-utils.js so
@@ -172,7 +172,7 @@ export function buildTriangleSvg({
172
172
  center.setAttribute('role', 'button');
173
173
  center.setAttribute(
174
174
  'aria-label',
175
- `Quality — ${formatQualitySummary(qualitySummary)}. Activate to open the NFR compass.`,
175
+ `Quality — ${formatQualitySummary(qualitySummary)}. Activate to open the quality decision.`,
176
176
  );
177
177
 
178
178
  // Backdrop circle — sits behind the text, masks the polygon stroke.
@@ -13,8 +13,8 @@
13
13
  { "name": "data-min-capacity", "kind": "data", "purpose": "config", "type": "number", "default": "1", "description": "Floor for capacityPoints" },
14
14
  { "name": "data-quality-href", "kind": "data", "purpose": "config", "type": "string", "description": "Navigation fallback when iron-triangle:open-quality isn't preventDefault-ed" },
15
15
  { "name": "data-quality-summary", "kind": "data", "purpose": "config", "type": "string", "description": "Short summary text shown in the center's native tooltip + aria-label (e.g. '3 critical: perf, sec, a11y')" },
16
- { "name": "disabled", "kind": "native", "purpose": "config", "type": "boolean", "description": "All inputs disabled" },
17
- { "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped vectors" }
16
+ { "name": "disabled", "kind": "native", "purpose": "config", "type": "boolean", "description": "Fully off: vertex and centre Quality targets leave the tab order (aria-disabled), openEditor() is a no-op, the mode switch and all dialog controls are disabled. setManual() / setFormula() / revise() still work for the host." },
17
+ { "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped shapes: the same editing lock as disabled, but the centre Quality target still opens the quality decision (iron-triangle:open-quality / data-quality-href)" }
18
18
  ],
19
19
  "slots": [],
20
20
  "events": [
@@ -25,7 +25,7 @@
25
25
  * is never colour-only.
26
26
  *
27
27
  * Optional: pass a `data-quality-summary` attribute or set the
28
- * `qualitySummary` property to surface the bound NFR compass's
28
+ * `qualitySummary` property to surface the bound quality-target's
29
29
  * decision on hover (e.g. "3 critical: perf, sec, a11y").
30
30
  */
31
31
 
@@ -171,7 +171,7 @@ class IronTriangle extends VBElement {
171
171
 
172
172
  /** Imperatively open a vertex editor. */
173
173
  openEditor(axis) {
174
- if (!SECTIONS.includes(axis)) return;
174
+ if (!SECTIONS.includes(axis) || this.#inert) return;
175
175
  const dialog = this.#ensureDialog(axis);
176
176
  if (typeof dialog.showModal === 'function') dialog.showModal();
177
177
  else dialog.setAttribute('open', '');
@@ -235,7 +235,7 @@ class IronTriangle extends VBElement {
235
235
  const center = event.target.closest?.('.center[data-target="quality"]');
236
236
  if (center && this.contains(center)) {
237
237
  event.preventDefault();
238
- this.#fireQualityOpen();
238
+ if (!this.hasAttribute('disabled')) this.#fireQualityOpen();
239
239
  }
240
240
  }
241
241
 
@@ -250,7 +250,7 @@ class IronTriangle extends VBElement {
250
250
  const center = event.target.closest?.('.center[data-target="quality"]');
251
251
  if (center && this.contains(center)) {
252
252
  event.preventDefault();
253
- this.#fireQualityOpen();
253
+ if (!this.hasAttribute('disabled')) this.#fireQualityOpen();
254
254
  }
255
255
  }
256
256
 
@@ -314,6 +314,7 @@ class IronTriangle extends VBElement {
314
314
  this.#recompute({ source: 'dialog', field: axis });
315
315
  });
316
316
 
317
+ this.#syncDisabledLocked();
317
318
  return dialog;
318
319
  }
319
320
 
@@ -399,6 +400,7 @@ class IronTriangle extends VBElement {
399
400
  const existing = this.querySelector(':scope > svg');
400
401
  if (existing) existing.replaceWith(svg);
401
402
  else this.prepend(svg);
403
+ this.#syncHitTargetsInert();
402
404
  this.dataset.capacityPoints = String(this.#value.capacityPoints);
403
405
  this.dataset.capacitySource = this.#value.capacitySource;
404
406
  }
@@ -422,12 +424,40 @@ class IronTriangle extends VBElement {
422
424
  } catch { /* older Safari */ }
423
425
  }
424
426
 
427
+ get #inert() { return this.hasAttribute('disabled') || this.hasAttribute('locked'); }
428
+
429
+ /**
430
+ * disabled / locked: no editing path stays open. styles.css already sets
431
+ * pointer-events: none; this closes the keyboard and programmatic paths
432
+ * (vertex targets leave the tab order, openEditor() is a no-op) and
433
+ * disables the mode switch and every dialog control, including dialogs
434
+ * built later.
435
+ *
436
+ * The centre Quality target is navigation, not editing, so under `locked`
437
+ * it stays focusable and clickable (styles.css re-enables its pointer).
438
+ * Under `disabled` it goes inert too: browsers suppress mouse events
439
+ * inside a disabled form-associated element, so keeping only its
440
+ * keyboard path alive would be inconsistent.
441
+ */
425
442
  #syncDisabledLocked() {
426
- const off = this.hasAttribute('disabled') || this.hasAttribute('locked');
443
+ const off = this.#inert;
427
444
  if (this.#modeButton) this.#modeButton.disabled = off;
428
445
  for (const dialog of Object.values(this.#dialogs)) {
429
446
  for (const el of dialog.querySelectorAll('input, select, textarea, button')) /** @type {any} */ (el).disabled = off;
430
447
  }
448
+ this.#syncHitTargetsInert();
449
+ }
450
+
451
+ /** Hit targets follow disabled / locked across re-renders. */
452
+ #syncHitTargetsInert() {
453
+ const editingOff = this.#inert;
454
+ const navOff = this.hasAttribute('disabled');
455
+ for (const target of this.querySelectorAll(':scope > svg [role="button"][tabindex]')) {
456
+ const off = target.matches('.center[data-target="quality"]') ? navOff : editingOff;
457
+ target.setAttribute('tabindex', off ? '-1' : '0');
458
+ if (off) target.setAttribute('aria-disabled', 'true');
459
+ else target.removeAttribute('aria-disabled');
460
+ }
431
461
  }
432
462
 
433
463
  // ── Internal: status strip (a11y hooks) ──────────────────────────
@@ -448,7 +478,9 @@ class IronTriangle extends VBElement {
448
478
 
449
479
  const button = document.createElement('button');
450
480
  button.type = 'button';
451
- button.className = 'iron-triangle-mode';
481
+ // VB's `ghost` variant keeps the switch secondary to the triangle;
482
+ // styles.css trims it further to sit inside the small status strip.
483
+ button.className = 'iron-triangle-mode ghost';
452
484
  button.setAttribute('aria-pressed', 'false');
453
485
  button.textContent = 'Manual capacity';
454
486
  button.addEventListener('click', () => this.#openCapacityDialog());
@@ -486,9 +518,14 @@ class IronTriangle extends VBElement {
486
518
 
487
519
  if (this.#modeButton) {
488
520
  this.#modeButton.setAttribute('aria-pressed', manual ? 'true' : 'false');
489
- this.#modeButton.setAttribute('aria-label',
490
- manual ? `Manual capacity: ${capacityPoints} points. Activate to edit or return to the formula.`
491
- : 'Manual capacity. Activate to override the formula with a fixed number.');
521
+ // Pressed state is carried by the visible text too, not only by the
522
+ // underline. The aria-label starts with that visible text so voice
523
+ // users can say what they see (WCAG 2.5.3 Label in Name).
524
+ const label = manual && capacityPoints > 0 ? `Manual capacity: ${capacityPoints} pts` : 'Manual capacity';
525
+ if (this.#modeButton.textContent !== label) this.#modeButton.textContent = label;
526
+ this.#modeButton.setAttribute('aria-label', manual
527
+ ? `${label}. Activate to edit or return to the formula.`
528
+ : `${label}. Activate to override the formula with a fixed number.`);
492
529
  }
493
530
  if (this.#deadlineWarning) {
494
531
  this.#deadlineWarning.textContent = overDeadline ? `Deadline has passed (${deadline})` : '';
@@ -566,6 +603,7 @@ class IronTriangle extends VBElement {
566
603
  if (Number.isFinite(n) && n > 0) this.setManual(n);
567
604
  });
568
605
 
606
+ this.#syncDisabledLocked();
569
607
  return dialog;
570
608
  }
571
609
 
@@ -150,13 +150,27 @@ iron-triangle > .iron-triangle-status .iron-triangle-readout {
150
150
  border: 0;
151
151
  }
152
152
 
153
+ /* Quiet, secondary switch: ghost button, compact, muted until hovered or
154
+ pressed. VB's touch-target minimum block size is kept. Pressed adds an underline (plus the "Manual capacity: N pts" label) so
155
+ the state never rests on colour alone. */
153
156
  iron-triangle > .iron-triangle-status .iron-triangle-mode {
154
157
  font-size: inherit;
158
+ font-weight: var(--font-weight-normal, 400);
159
+ padding-block: 0;
160
+ padding-inline: var(--size-xs, 0.5rem);
161
+ border-color: transparent;
162
+ color: var(--color-text-muted);
163
+ }
164
+
165
+ iron-triangle > .iron-triangle-status .iron-triangle-mode:hover {
166
+ color: var(--color-text);
155
167
  }
156
168
 
157
169
  iron-triangle > .iron-triangle-status .iron-triangle-mode[aria-pressed="true"] {
158
- border-color: var(--color-interactive);
159
170
  color: var(--color-interactive);
171
+ text-decoration: underline;
172
+ text-decoration-thickness: var(--border-width-medium, 2px);
173
+ text-underline-offset: 0.25em;
160
174
  }
161
175
 
162
176
  /* Visible, text-bearing warning — never colour-only. */
@@ -193,6 +207,13 @@ iron-triangle[locked] {
193
207
  pointer-events: none;
194
208
  }
195
209
 
210
+ /* The centre Quality target navigates to the quality decision rather than
211
+ editing the shape, so it keeps working on a locked (read-only) triangle.
212
+ A disabled triangle stays fully off, like any disabled control. */
213
+ iron-triangle[locked]:not([disabled]) svg .center[data-target="quality"] {
214
+ pointer-events: auto;
215
+ }
216
+
196
217
  /* ── Dialog chrome ────────────────────────────────────────────── */
197
218
 
198
219
  iron-triangle > dialog.iron-triangle-dialog {
@@ -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"]
@@ -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) {
@@ -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
  -->