@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.
- package/README.md +5 -2
- package/dist/vb-project-planning.css +70 -3
- package/dist/vb-project-planning.js +3333 -2565
- package/dist/vb-project-planning.min.css +1 -1
- package/dist/vb-project-planning.min.js +224 -200
- package/package.json +7 -4
- package/src/lib/board-surface.js +158 -0
- package/src/lib/bundle-registry.js +2 -75
- package/src/lib/dates.js +125 -0
- package/src/lib/escape.js +34 -0
- package/src/lib/hash.js +16 -0
- package/src/lib/portrait-url.js +3 -29
- package/src/lib/src-loader.js +78 -0
- package/src/utils/copy-init.js +56 -17
- package/src/web-components/_ux-base.js +409 -24
- package/src/web-components/adr-wc/README.md +18 -16
- package/src/web-components/adr-wc/api.json +7 -5
- package/src/web-components/adr-wc/logic.js +50 -136
- package/src/web-components/burndown-chart/api.json +8 -6
- package/src/web-components/burndown-chart/logic.js +75 -39
- package/src/web-components/empathy-map/api.json +13 -13
- package/src/web-components/empathy-map/logic.js +161 -168
- package/src/web-components/empathy-map/static.html +3 -3
- package/src/web-components/empathy-map/styles.js +1 -0
- package/src/web-components/gantt-chart/README.md +12 -6
- package/src/web-components/gantt-chart/api.json +23 -7
- package/src/web-components/gantt-chart/logic.js +456 -431
- package/src/web-components/gantt-chart/styles.css +9 -1
- package/src/web-components/impact-effort/api.json +9 -7
- package/src/web-components/impact-effort/logic.js +238 -209
- package/src/web-components/impact-effort/static.html +12 -4
- package/src/web-components/iron-triangle/_capacity.js +1 -19
- package/src/web-components/iron-triangle/_triangle-geometry.js +1 -1
- package/src/web-components/iron-triangle/api.json +2 -2
- package/src/web-components/iron-triangle/logic.js +47 -9
- package/src/web-components/iron-triangle/styles.css +22 -1
- package/src/web-components/kanban-board/api.json +8 -6
- package/src/web-components/kanban-board/logic.js +289 -333
- package/src/web-components/product-roadmap/api.json +14 -13
- package/src/web-components/product-roadmap/logic.js +393 -246
- package/src/web-components/product-roadmap/styles.css +40 -0
- package/src/web-components/quadrant-grid/api.json +9 -4
- package/src/web-components/quadrant-grid/logic.js +80 -12
- package/src/web-components/quadrant-grid/styles.css +7 -0
- package/src/web-components/quality-target/_quality-utils.js +2 -46
- 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 +26 -2
- package/src/web-components/requirement-card/static.html +1 -1
- package/src/web-components/review-surface/README.md +9 -5
- package/src/web-components/review-surface/api.json +10 -8
- package/src/web-components/review-surface/logic.js +349 -218
- package/src/web-components/risk-register/api.json +1 -6
- package/src/web-components/risk-register/logic.js +71 -24
- package/src/web-components/story-map/api.json +8 -6
- package/src/web-components/story-map/logic.js +280 -281
- package/src/web-components/story-map/static.html +15 -5
- package/src/web-components/traceability-matrix/api.json +10 -3
- package/src/web-components/traceability-matrix/logic.js +112 -63
- package/src/web-components/traceability-matrix/styles.css +14 -1
- package/src/web-components/user-journey/api.json +25 -24
- package/src/web-components/user-journey/logic.js +76 -127
- package/src/web-components/user-journey/static.html +2 -2
- package/src/web-components/user-persona/api.json +15 -14
- package/src/web-components/user-persona/logic.js +71 -117
- package/src/web-components/user-persona/styles.js +1 -0
- package/src/web-components/user-story/api.json +13 -11
- package/src/web-components/user-story/logic.js +164 -195
- package/src/web-components/user-story/styles.js +28 -1
- package/src/web-components/work-item/README.md +3 -3
- package/src/web-components/work-item/api.json +13 -11
- 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
|
|
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.
|
|
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.
|
|
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
|
-
/*
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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": "
|
|
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
|
|
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"]
|