@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.
- package/README.md +27 -5
- package/dist/vb-project-planning.css +61 -5
- package/dist/vb-project-planning.js +304 -27
- package/dist/vb-project-planning.min.css +1 -1
- package/dist/vb-project-planning.min.js +101 -101
- package/package.json +5 -4
- package/src/web-components/iron-triangle/_capacity.js +1 -1
- package/src/web-components/iron-triangle/_triangle-geometry.js +1 -1
- package/src/web-components/iron-triangle/api.json +3 -3
- package/src/web-components/iron-triangle/logic.js +200 -5
- package/src/web-components/iron-triangle/styles.css +81 -3
- package/src/web-components/product-roadmap/logic.js +25 -13
- package/src/web-components/quality-target/api.json +2 -3
- package/src/web-components/quality-target/logic.js +85 -13
- package/src/web-components/requirement-card/api.json +1 -1
- package/src/web-components/requirement-card/logic.js +27 -3
- package/src/web-components/requirement-card/static.html +1 -1
- package/src/web-components/user-persona/logic.js +1 -1
- package/src/web-components/user-story/logic.js +2 -1
|
@@ -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
|
|
8
|
-
*
|
|
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',
|
|
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'
|
|
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',
|
|
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 =
|
|
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="
|
|
342
|
-
<textarea id="
|
|
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
|
|
370
|
-
|
|
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
|
|
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 <
|
|
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 <
|
|
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
|
|
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(
|
|
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 <
|
|
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));
|