@profpowell/vb-project-planning 0.1.0
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/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/vb-project-planning.css +1603 -0
- package/dist/vb-project-planning.js +11138 -0
- package/dist/vb-project-planning.min.css +1 -0
- package/dist/vb-project-planning.min.js +3312 -0
- package/package.json +80 -0
- package/src/index.css +14 -0
- package/src/index.js +26 -0
- package/src/lib/bundle-registry.js +156 -0
- package/src/lib/diff-by-key.js +86 -0
- package/src/lib/portrait-url.js +87 -0
- package/src/lib/vb-collection.js +122 -0
- package/src/lib/vb-element.js +96 -0
- package/src/lib/vb-store.js +200 -0
- package/src/lib/vb-view-transition.js +44 -0
- package/src/utils/copy-init.js +287 -0
- package/src/web-components/_ux-base.js +106 -0
- package/src/web-components/adr-wc/README.md +77 -0
- package/src/web-components/adr-wc/api.json +26 -0
- package/src/web-components/adr-wc/logic.js +376 -0
- package/src/web-components/adr-wc/static.html +17 -0
- package/src/web-components/adr-wc/styles.js +338 -0
- package/src/web-components/burndown-chart/api.json +74 -0
- package/src/web-components/burndown-chart/logic.js +198 -0
- package/src/web-components/burndown-chart/static.html +31 -0
- package/src/web-components/burndown-chart/styles.css +59 -0
- package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
- package/src/web-components/capacity-plan/api.json +20 -0
- package/src/web-components/capacity-plan/logic.js +222 -0
- package/src/web-components/capacity-plan/static.html +24 -0
- package/src/web-components/capacity-plan/styles.css +84 -0
- package/src/web-components/empathy-map/api.json +31 -0
- package/src/web-components/empathy-map/logic.js +502 -0
- package/src/web-components/empathy-map/static.html +32 -0
- package/src/web-components/empathy-map/styles.js +534 -0
- package/src/web-components/gantt-chart/README.md +91 -0
- package/src/web-components/gantt-chart/api.json +20 -0
- package/src/web-components/gantt-chart/logic.js +812 -0
- package/src/web-components/gantt-chart/static.html +60 -0
- package/src/web-components/gantt-chart/styles.css +391 -0
- package/src/web-components/impact-effort/api.json +21 -0
- package/src/web-components/impact-effort/logic.js +392 -0
- package/src/web-components/impact-effort/static.html +23 -0
- package/src/web-components/impact-effort/styles.css +193 -0
- package/src/web-components/iron-triangle/_capacity.js +83 -0
- package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
- package/src/web-components/iron-triangle/api.json +26 -0
- package/src/web-components/iron-triangle/logic.js +474 -0
- package/src/web-components/iron-triangle/static.html +28 -0
- package/src/web-components/iron-triangle/styles.css +158 -0
- package/src/web-components/kanban-board/api.json +24 -0
- package/src/web-components/kanban-board/logic.js +698 -0
- package/src/web-components/kanban-board/static.html +20 -0
- package/src/web-components/kanban-board/styles.css +242 -0
- package/src/web-components/product-roadmap/api.json +118 -0
- package/src/web-components/product-roadmap/logic.js +460 -0
- package/src/web-components/product-roadmap/static.html +44 -0
- package/src/web-components/product-roadmap/styles.css +158 -0
- package/src/web-components/quadrant-grid/api.json +125 -0
- package/src/web-components/quadrant-grid/logic.js +397 -0
- package/src/web-components/quadrant-grid/static.html +49 -0
- package/src/web-components/quadrant-grid/styles.css +184 -0
- package/src/web-components/quality-target/_quality-utils.js +460 -0
- package/src/web-components/quality-target/api.json +30 -0
- package/src/web-components/quality-target/logic.js +516 -0
- package/src/web-components/quality-target/static.html +22 -0
- package/src/web-components/quality-target/styles.css +203 -0
- package/src/web-components/requirement-card/api.json +25 -0
- package/src/web-components/requirement-card/logic.js +166 -0
- package/src/web-components/requirement-card/static.html +36 -0
- package/src/web-components/requirement-card/styles.js +114 -0
- package/src/web-components/review-surface/README.md +178 -0
- package/src/web-components/review-surface/api.json +30 -0
- package/src/web-components/review-surface/logic.js +807 -0
- package/src/web-components/review-surface/static.html +33 -0
- package/src/web-components/review-surface/styles.js +459 -0
- package/src/web-components/risk-register/api.json +47 -0
- package/src/web-components/risk-register/logic.js +180 -0
- package/src/web-components/risk-register/static.html +46 -0
- package/src/web-components/risk-register/styles.css +37 -0
- package/src/web-components/story-map/api.json +22 -0
- package/src/web-components/story-map/logic.js +445 -0
- package/src/web-components/story-map/static.html +35 -0
- package/src/web-components/story-map/styles.css +169 -0
- package/src/web-components/traceability-matrix/api.json +84 -0
- package/src/web-components/traceability-matrix/logic.js +243 -0
- package/src/web-components/traceability-matrix/static.html +41 -0
- package/src/web-components/traceability-matrix/styles.css +94 -0
- package/src/web-components/user-journey/api.json +84 -0
- package/src/web-components/user-journey/logic.js +368 -0
- package/src/web-components/user-journey/static.html +14 -0
- package/src/web-components/user-journey/styles.js +317 -0
- package/src/web-components/user-persona/api.json +35 -0
- package/src/web-components/user-persona/logic.js +502 -0
- package/src/web-components/user-persona/static.html +20 -0
- package/src/web-components/user-persona/styles.js +299 -0
- package/src/web-components/user-story/api.json +35 -0
- package/src/web-components/user-story/logic.js +567 -0
- package/src/web-components/user-story/static.html +27 -0
- package/src/web-components/user-story/styles.js +332 -0
- package/src/web-components/work-item/README.md +85 -0
- package/src/web-components/work-item/api.json +30 -0
- package/src/web-components/work-item/logic.js +414 -0
- package/src/web-components/work-item/static.html +24 -0
- package/src/web-components/work-item/styles.js +376 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <quality-target> — Polygon-as-UI quality prioritization surface.
|
|
3
|
+
*
|
|
4
|
+
* One component, two roles. The radar polygon IS the picker: each
|
|
5
|
+
* axis is a clickable hit-target that opens a per-ility <dialog>
|
|
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).
|
|
10
|
+
*
|
|
11
|
+
* Replaces both <nfr-compass> and <nfr-radar> from earlier rounds.
|
|
12
|
+
*
|
|
13
|
+
* Authoring shape — minimal:
|
|
14
|
+
*
|
|
15
|
+
* <iron-triangle id="shape" data-quality-href="#quality"></iron-triangle>
|
|
16
|
+
* <quality-target id="quality" name="quality"
|
|
17
|
+
* data-bind-to="shape"></quality-target>
|
|
18
|
+
*
|
|
19
|
+
* Form-associated; serializes the same vector schema (validated by
|
|
20
|
+
* uucd-core's validateQualityVector). Over-budget reveals an inline
|
|
21
|
+
* rationale field in the component footer; checkValidity() returns
|
|
22
|
+
* false until that rationale meets the min-length.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { VBElement } from '../../lib/vb-element.js';
|
|
26
|
+
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_ILITIES,
|
|
29
|
+
LEVELS,
|
|
30
|
+
LEVEL_LABELS,
|
|
31
|
+
ilityLabel,
|
|
32
|
+
mergeCostWeights,
|
|
33
|
+
criticalSum,
|
|
34
|
+
parseJsonAttr,
|
|
35
|
+
validateVector,
|
|
36
|
+
canSaveAxis,
|
|
37
|
+
buildTargetSvg,
|
|
38
|
+
} from './_quality-utils.js';
|
|
39
|
+
|
|
40
|
+
class QualityTarget extends VBElement {
|
|
41
|
+
static formAssociated = true;
|
|
42
|
+
static get observedAttributes() {
|
|
43
|
+
return [
|
|
44
|
+
'data-bind-to', 'data-capacity-points', 'data-cost-weights',
|
|
45
|
+
'data-radius', 'data-show-envelope',
|
|
46
|
+
'data-min-rationale', 'data-max-rationale',
|
|
47
|
+
'data-min-overrun-rationale', 'data-max-overrun-rationale',
|
|
48
|
+
'disabled', 'locked',
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @type {ElementInternals} */
|
|
53
|
+
#internals;
|
|
54
|
+
/** @type {string[]} */
|
|
55
|
+
#ilities = [...DEFAULT_ILITIES];
|
|
56
|
+
/** @type {Record<string, string>} */
|
|
57
|
+
#vector = {};
|
|
58
|
+
/** @type {Record<string, string>} */
|
|
59
|
+
#rationales = {};
|
|
60
|
+
/** @type {Readonly<Record<string, number>>} */
|
|
61
|
+
#costWeights = mergeCostWeights(DEFAULT_ILITIES);
|
|
62
|
+
/** @type {string} */
|
|
63
|
+
#overrunRationale = '';
|
|
64
|
+
/** @type {boolean} */
|
|
65
|
+
#wasOver = false;
|
|
66
|
+
/** @type {(() => void) | null} */
|
|
67
|
+
#unbindTriangle = null;
|
|
68
|
+
/** @type {number | null} */
|
|
69
|
+
#capacityPropOverride = null;
|
|
70
|
+
/** @type {Record<string, HTMLDialogElement>} */
|
|
71
|
+
#dialogs = {};
|
|
72
|
+
/** @type {HTMLElement | null} */
|
|
73
|
+
#footer = null;
|
|
74
|
+
|
|
75
|
+
constructor() {
|
|
76
|
+
super();
|
|
77
|
+
this.#internals = this.attachInternals();
|
|
78
|
+
this._adoptInternals(this.#internals);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setup() {
|
|
82
|
+
this.#refreshCostWeights();
|
|
83
|
+
this.#bindToTriangle();
|
|
84
|
+
this.#renderViz();
|
|
85
|
+
this.#ensureFooter();
|
|
86
|
+
|
|
87
|
+
this.listen(this, 'click', (e) => this.#onClick(e));
|
|
88
|
+
this.listen(this, 'keydown', (e) => this.#onKeydown(e));
|
|
89
|
+
this.listen(this, 'input', (e) => this.#onFooterInput(e));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
teardown() {
|
|
93
|
+
this.#unbindTriangle?.();
|
|
94
|
+
this.#unbindTriangle = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
attributeChangedCallback(name) {
|
|
98
|
+
if (!this.isConnected) return;
|
|
99
|
+
if (name === 'data-bind-to') {
|
|
100
|
+
this.#bindToTriangle();
|
|
101
|
+
this.#renderViz();
|
|
102
|
+
} else if (name === 'data-capacity-points') {
|
|
103
|
+
this.#renderViz();
|
|
104
|
+
} else if (name === 'data-cost-weights') {
|
|
105
|
+
this.#refreshCostWeights();
|
|
106
|
+
this.#renderViz();
|
|
107
|
+
} else if (name === 'data-radius' || name === 'data-show-envelope') {
|
|
108
|
+
this.#renderViz();
|
|
109
|
+
} else if (name === 'disabled' || name === 'locked') {
|
|
110
|
+
this.#syncDisabledLocked();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Public API ────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
get vector() { return { ...this.#vector }; }
|
|
117
|
+
set vector(next) { this.#applyVector(next); this.#publish('property'); }
|
|
118
|
+
get rationales() { return { ...this.#rationales }; }
|
|
119
|
+
set rationales(next) { this.#applyRationales(next); this.#publish('property'); }
|
|
120
|
+
get costWeights() { return { ...this.#costWeights }; }
|
|
121
|
+
get capacityPoints() { return this.#resolveCapacity(); }
|
|
122
|
+
set capacityPoints(n) { this.#capacityPropOverride = Number.isFinite(+n) ? +n : null; this.#publish('property'); }
|
|
123
|
+
get criticalSum() { return criticalSum(this.#vector, this.#costWeights); }
|
|
124
|
+
get overBudget() { const cap = this.#resolveCapacity(); return Number.isFinite(cap) && this.criticalSum > cap; }
|
|
125
|
+
get overrunRationale() { return this.#overrunRationale; }
|
|
126
|
+
set overrunRationale(s) {
|
|
127
|
+
this.#overrunRationale = String(s ?? '');
|
|
128
|
+
const ta = /** @type {HTMLTextAreaElement | null} */ (this.querySelector('[data-quality-overrun] textarea'));
|
|
129
|
+
if (ta) ta.value = this.#overrunRationale;
|
|
130
|
+
this.#publishFormValue();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get ilities() { return [...this.#ilities]; }
|
|
134
|
+
set ilities(next) {
|
|
135
|
+
if (!Array.isArray(next) || next.length === 0) return;
|
|
136
|
+
this.#ilities = [...next];
|
|
137
|
+
this.#refreshCostWeights();
|
|
138
|
+
this.#publish('property');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
get value() {
|
|
142
|
+
return {
|
|
143
|
+
vector: { ...this.#vector },
|
|
144
|
+
rationales: { ...this.#rationales },
|
|
145
|
+
costWeights: { ...this.#costWeights },
|
|
146
|
+
capacityPoints: this.#resolveCapacityForSerialize(),
|
|
147
|
+
capacitySource: this.#capacitySourceLabel(),
|
|
148
|
+
criticalSum: this.criticalSum,
|
|
149
|
+
overrunRationale: this.#overrunRationale || undefined,
|
|
150
|
+
ironTriangleHash: this.#readTriangleHash(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Imperatively open one axis's editor. */
|
|
155
|
+
openEditor(ility) {
|
|
156
|
+
if (!this.#ilities.includes(ility)) return;
|
|
157
|
+
const dialog = this.#ensureDialog(ility);
|
|
158
|
+
if (typeof dialog.showModal === 'function') dialog.showModal();
|
|
159
|
+
else dialog.setAttribute('open', '');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
checkValidity() {
|
|
163
|
+
const { valid, errors } = validateVector({
|
|
164
|
+
vector: this.#vector,
|
|
165
|
+
rationales: this.#rationales,
|
|
166
|
+
costWeights: this.#costWeights,
|
|
167
|
+
capacityPoints: this.#resolveCapacity(),
|
|
168
|
+
overrunRationale: this.#overrunRationale,
|
|
169
|
+
minRationale: this.#minRationale(),
|
|
170
|
+
minOverrunRationale: this.#minOverrunRationale(),
|
|
171
|
+
});
|
|
172
|
+
this.setState('missing-rationale', !valid);
|
|
173
|
+
const errBox = this.querySelector('[data-quality-errors]');
|
|
174
|
+
if (errBox) errBox.textContent = errors.join(' ');
|
|
175
|
+
return valid;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Internal: SVG hit-target click + keyboard ────────────────────
|
|
179
|
+
|
|
180
|
+
#onClick(event) {
|
|
181
|
+
const axis = event.target.closest?.('.axis[data-ility]');
|
|
182
|
+
if (axis && this.contains(axis)) {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
this.openEditor(axis.dataset.ility);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#onKeydown(event) {
|
|
189
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
190
|
+
const axis = event.target.closest?.('.axis[data-ility]');
|
|
191
|
+
if (axis && this.contains(axis)) {
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
this.openEditor(axis.dataset.ility);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#onFooterInput(event) {
|
|
198
|
+
const target = event.target;
|
|
199
|
+
if (target?.matches?.('[data-quality-overrun] textarea')) {
|
|
200
|
+
this.#overrunRationale = target.value;
|
|
201
|
+
this.checkValidity();
|
|
202
|
+
this.#publishFormValue();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Internal: per-axis dialog ────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
#ensureDialog(ility) {
|
|
209
|
+
if (this.#dialogs[ility]) {
|
|
210
|
+
this.#syncDialogFromState(ility);
|
|
211
|
+
return this.#dialogs[ility];
|
|
212
|
+
}
|
|
213
|
+
const dialog = document.createElement('dialog');
|
|
214
|
+
dialog.className = `quality-dialog quality-dialog--${ility}`;
|
|
215
|
+
dialog.setAttribute('aria-label', `${ilityLabel(ility)} — edit priority`);
|
|
216
|
+
|
|
217
|
+
const form = document.createElement('form');
|
|
218
|
+
form.method = 'dialog';
|
|
219
|
+
form.setAttribute('data-layout', 'stack');
|
|
220
|
+
form.setAttribute('data-layout-gap', 'm');
|
|
221
|
+
|
|
222
|
+
const heading = document.createElement('h3');
|
|
223
|
+
heading.textContent = ilityLabel(ility);
|
|
224
|
+
form.append(heading);
|
|
225
|
+
|
|
226
|
+
// Level radio group (one fieldset).
|
|
227
|
+
const fs = document.createElement('fieldset');
|
|
228
|
+
fs.setAttribute('data-layout', 'stack');
|
|
229
|
+
fs.setAttribute('data-layout-gap', '2xs');
|
|
230
|
+
const lg = document.createElement('legend');
|
|
231
|
+
lg.textContent = 'Priority';
|
|
232
|
+
fs.append(lg);
|
|
233
|
+
for (const level of LEVELS) {
|
|
234
|
+
const lbl = document.createElement('label');
|
|
235
|
+
const input = document.createElement('input');
|
|
236
|
+
input.type = 'radio';
|
|
237
|
+
input.name = 'level';
|
|
238
|
+
input.value = level;
|
|
239
|
+
lbl.append(input, document.createTextNode(' ' + LEVEL_LABELS[level]));
|
|
240
|
+
fs.append(lbl);
|
|
241
|
+
}
|
|
242
|
+
form.append(fs);
|
|
243
|
+
|
|
244
|
+
// Rationale form-field (required when Critical).
|
|
245
|
+
const ff = document.createElement('form-field');
|
|
246
|
+
const rl = document.createElement('label');
|
|
247
|
+
rl.setAttribute('for', `quality-${ility}-rationale`);
|
|
248
|
+
rl.textContent = 'Rationale (required when Critical)';
|
|
249
|
+
const ta = document.createElement('textarea');
|
|
250
|
+
ta.id = `quality-${ility}-rationale`;
|
|
251
|
+
ta.name = 'rationale';
|
|
252
|
+
ta.rows = 3;
|
|
253
|
+
ta.minLength = this.#minRationale();
|
|
254
|
+
ta.maxLength = this.#maxRationale();
|
|
255
|
+
ta.placeholder = `Why ≥ ${this.#minRationale()} chars`;
|
|
256
|
+
ff.append(rl, ta);
|
|
257
|
+
form.append(ff);
|
|
258
|
+
|
|
259
|
+
// Inline error spot.
|
|
260
|
+
const err = document.createElement('p');
|
|
261
|
+
err.className = 'message message-error';
|
|
262
|
+
err.dataset.dialogError = '';
|
|
263
|
+
err.hidden = true;
|
|
264
|
+
form.append(err);
|
|
265
|
+
|
|
266
|
+
// Actions.
|
|
267
|
+
const actions = document.createElement('div');
|
|
268
|
+
actions.setAttribute('data-layout', 'cluster');
|
|
269
|
+
actions.setAttribute('data-layout-gap', 's');
|
|
270
|
+
actions.setAttribute('data-layout-justify', 'end');
|
|
271
|
+
const cancel = document.createElement('button');
|
|
272
|
+
cancel.type = 'button';
|
|
273
|
+
cancel.textContent = 'Cancel';
|
|
274
|
+
cancel.addEventListener('click', () => dialog.close('cancel'));
|
|
275
|
+
const save = document.createElement('button');
|
|
276
|
+
save.type = 'submit';
|
|
277
|
+
save.textContent = 'Save';
|
|
278
|
+
actions.append(cancel, save);
|
|
279
|
+
form.append(actions);
|
|
280
|
+
|
|
281
|
+
dialog.append(form);
|
|
282
|
+
this.append(dialog);
|
|
283
|
+
this.#dialogs[ility] = dialog;
|
|
284
|
+
|
|
285
|
+
form.addEventListener('submit', (e) => {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
const levelInput = /** @type {HTMLInputElement | null} */ (form.querySelector('input[name="level"]:checked'));
|
|
288
|
+
const level = levelInput?.value;
|
|
289
|
+
const rationaleEl = /** @type {HTMLTextAreaElement | null} */ (form.querySelector('textarea[name="rationale"]'));
|
|
290
|
+
const rationale = rationaleEl?.value ?? '';
|
|
291
|
+
const result = canSaveAxis({ level, rationale, minRationale: this.#minRationale() });
|
|
292
|
+
if (!result.ok) {
|
|
293
|
+
err.hidden = false;
|
|
294
|
+
err.textContent = result.reason === 'pick-level'
|
|
295
|
+
? 'Pick a priority level.'
|
|
296
|
+
: `Rationale must be at least ${this.#minRationale()} characters.`;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
this.#vector[ility] = level ?? '';
|
|
300
|
+
if (level === 'critical') this.#rationales[ility] = rationale.trim();
|
|
301
|
+
else delete this.#rationales[ility];
|
|
302
|
+
dialog.close('save');
|
|
303
|
+
this.#publish('dialog', ility);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
this.#syncDialogFromState(ility);
|
|
307
|
+
return dialog;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#syncDialogFromState(ility) {
|
|
311
|
+
const dialog = this.#dialogs[ility];
|
|
312
|
+
if (!dialog) return;
|
|
313
|
+
const level = this.#vector[ility];
|
|
314
|
+
for (const rawRadio of dialog.querySelectorAll('input[name="level"]')) {
|
|
315
|
+
const radio = /** @type {HTMLInputElement} */ (rawRadio);
|
|
316
|
+
radio.checked = (radio.value === level);
|
|
317
|
+
}
|
|
318
|
+
const ta = /** @type {HTMLTextAreaElement | null} */ (dialog.querySelector('textarea[name="rationale"]'));
|
|
319
|
+
if (ta) ta.value = this.#rationales[ility] || '';
|
|
320
|
+
const err = /** @type {HTMLElement | null} */ (dialog.querySelector('[data-dialog-error]'));
|
|
321
|
+
if (err) { err.hidden = true; err.textContent = ''; }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Internal: footer (capacity + overrun rationale) ──────────────
|
|
325
|
+
|
|
326
|
+
#ensureFooter() {
|
|
327
|
+
if (this.#footer) return;
|
|
328
|
+
const footer = document.createElement('section');
|
|
329
|
+
footer.className = 'quality-footer';
|
|
330
|
+
footer.dataset.qualityFooter = '';
|
|
331
|
+
footer.setAttribute('data-layout', 'stack');
|
|
332
|
+
footer.setAttribute('data-layout-gap', 's');
|
|
333
|
+
|
|
334
|
+
const overrun = document.createElement('section');
|
|
335
|
+
overrun.dataset.qualityOverrun = '';
|
|
336
|
+
overrun.hidden = true;
|
|
337
|
+
overrun.innerHTML = `
|
|
338
|
+
<strong>Over budget — explain why.</strong>
|
|
339
|
+
<small data-overrun-prompt></small>
|
|
340
|
+
<form-field>
|
|
341
|
+
<label for="quality-overrun">Overrun rationale (≥ ${this.#minOverrunRationale()} chars)</label>
|
|
342
|
+
<textarea id="quality-overrun" name="overrunRationale"
|
|
343
|
+
minlength="${this.#minOverrunRationale()}"
|
|
344
|
+
maxlength="${this.#maxOverrunRationale()}"
|
|
345
|
+
rows="3"></textarea>
|
|
346
|
+
</form-field>
|
|
347
|
+
`;
|
|
348
|
+
footer.append(overrun);
|
|
349
|
+
|
|
350
|
+
const errors = document.createElement('p');
|
|
351
|
+
errors.dataset.qualityErrors = '';
|
|
352
|
+
errors.className = 'message message-error';
|
|
353
|
+
errors.setAttribute('role', 'alert');
|
|
354
|
+
footer.append(errors);
|
|
355
|
+
|
|
356
|
+
this.append(footer);
|
|
357
|
+
this.#footer = footer;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#updateFooter() {
|
|
361
|
+
if (!this.#footer) return;
|
|
362
|
+
const cap = this.#resolveCapacity();
|
|
363
|
+
const sum = this.criticalSum;
|
|
364
|
+
const isOver = Number.isFinite(cap) && sum > cap;
|
|
365
|
+
const overrun = /** @type {HTMLElement | null} */ (this.#footer.querySelector('[data-quality-overrun]'));
|
|
366
|
+
if (overrun) {
|
|
367
|
+
overrun.hidden = !isOver;
|
|
368
|
+
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}).`;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── Internal: ility / weight wiring ──────────────────────────────
|
|
376
|
+
|
|
377
|
+
#refreshCostWeights() {
|
|
378
|
+
const overrides = parseJsonAttr(this.getAttribute('data-cost-weights') || '');
|
|
379
|
+
this.#costWeights = mergeCostWeights(this.#ilities, overrides);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── Internal: vector / rationale setters ─────────────────────────
|
|
383
|
+
|
|
384
|
+
#applyVector(next) {
|
|
385
|
+
if (!next || typeof next !== 'object') return;
|
|
386
|
+
for (const [ility, level] of Object.entries(next)) {
|
|
387
|
+
if (LEVELS.includes(level)) this.#vector[ility] = level;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
#applyRationales(next) {
|
|
392
|
+
if (!next || typeof next !== 'object') return;
|
|
393
|
+
for (const [ility, text] of Object.entries(next)) {
|
|
394
|
+
this.#rationales[ility] = String(text ?? '');
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── Internal: capacity resolution ────────────────────────────────
|
|
399
|
+
|
|
400
|
+
#resolveCapacity() {
|
|
401
|
+
const triangle = this.#findTriangle();
|
|
402
|
+
if (triangle && Number.isFinite(triangle.capacityPoints) && triangle.capacityPoints > 0) {
|
|
403
|
+
return triangle.capacityPoints;
|
|
404
|
+
}
|
|
405
|
+
const attr = Number(this.getAttribute('data-capacity-points'));
|
|
406
|
+
if (Number.isFinite(attr) && attr > 0) return attr;
|
|
407
|
+
const override = this.#capacityPropOverride;
|
|
408
|
+
if (override !== null && Number.isFinite(override) && override > 0) {
|
|
409
|
+
return override;
|
|
410
|
+
}
|
|
411
|
+
return Infinity;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
#resolveCapacityForSerialize() {
|
|
415
|
+
const cap = this.#resolveCapacity();
|
|
416
|
+
return Number.isFinite(cap) ? cap : null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#capacitySourceLabel() {
|
|
420
|
+
const triangle = this.#findTriangle();
|
|
421
|
+
if (triangle?.capacitySource === 'manual') return 'manual';
|
|
422
|
+
if (triangle) return 'formula';
|
|
423
|
+
if (this.getAttribute('data-capacity-points')) return 'manual';
|
|
424
|
+
return 'manual';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
#findTriangle() {
|
|
428
|
+
const id = this.getAttribute('data-bind-to');
|
|
429
|
+
if (!id) return null;
|
|
430
|
+
const root = /** @type {any} */ (this.getRootNode());
|
|
431
|
+
const found = root.getElementById ? root.getElementById(id) : document.getElementById(id);
|
|
432
|
+
return found && found.localName === 'iron-triangle' ? found : null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#readTriangleHash() {
|
|
436
|
+
return this.#findTriangle()?.hash || null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
#bindToTriangle() {
|
|
440
|
+
this.#unbindTriangle?.();
|
|
441
|
+
this.#unbindTriangle = null;
|
|
442
|
+
const triangle = this.#findTriangle();
|
|
443
|
+
if (!triangle) return;
|
|
444
|
+
const handler = () => this.#publish('iron-triangle');
|
|
445
|
+
triangle.addEventListener('iron-triangle:change', handler);
|
|
446
|
+
this.#unbindTriangle = () => triangle.removeEventListener('iron-triangle:change', handler);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── Internal: render + publish cycle ─────────────────────────────
|
|
450
|
+
|
|
451
|
+
#renderViz() {
|
|
452
|
+
const radius = clamp(Number(this.dataset.radius ?? 100), 30, 400);
|
|
453
|
+
const cap = this.#resolveCapacity();
|
|
454
|
+
const svg = buildTargetSvg({
|
|
455
|
+
ilities: this.#ilities,
|
|
456
|
+
vector: this.#vector,
|
|
457
|
+
costWeights: this.#costWeights,
|
|
458
|
+
capacityPoints: cap,
|
|
459
|
+
radius,
|
|
460
|
+
});
|
|
461
|
+
const existing = this.querySelector(':scope > svg');
|
|
462
|
+
if (existing) existing.replaceWith(svg);
|
|
463
|
+
else this.prepend(svg);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#publish(source, field) {
|
|
467
|
+
this.#renderViz();
|
|
468
|
+
this.#updateFooter();
|
|
469
|
+
const cap = this.#resolveCapacity();
|
|
470
|
+
const sum = this.criticalSum;
|
|
471
|
+
const isOver = Number.isFinite(cap) && sum > cap;
|
|
472
|
+
this.setState('over-budget', isOver);
|
|
473
|
+
this.#publishFormValue();
|
|
474
|
+
|
|
475
|
+
if (isOver !== this.#wasOver) {
|
|
476
|
+
const evt = isOver ? 'quality-target:over-budget' : 'quality-target:under-budget';
|
|
477
|
+
const detail = isOver
|
|
478
|
+
? { delta: sum - cap, criticalSum: sum, capacityPoints: cap }
|
|
479
|
+
: { slack: cap - sum, criticalSum: sum, capacityPoints: cap };
|
|
480
|
+
this.dispatchEvent(new CustomEvent(evt, { bubbles: true, composed: true, detail }));
|
|
481
|
+
this.#wasOver = isOver;
|
|
482
|
+
}
|
|
483
|
+
this.dispatchEvent(new CustomEvent('quality-target:change', {
|
|
484
|
+
bubbles: true, composed: true,
|
|
485
|
+
detail: { ...this.value, source, field },
|
|
486
|
+
}));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
#publishFormValue() {
|
|
490
|
+
try {
|
|
491
|
+
this.#internals.setFormValue(JSON.stringify(this.value));
|
|
492
|
+
} catch { /* older Safari */ }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
#syncDisabledLocked() {
|
|
496
|
+
const off = this.hasAttribute('disabled') || this.hasAttribute('locked');
|
|
497
|
+
for (const dialog of Object.values(this.#dialogs)) {
|
|
498
|
+
for (const el of dialog.querySelectorAll('input, textarea, select, button')) {
|
|
499
|
+
/** @type {HTMLInputElement} */ (el).disabled = off;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── Internal: config readers ──────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
#minRationale() { return Math.max(0, parseInt(this.dataset.minRationale ?? '10', 10) || 10); }
|
|
507
|
+
#maxRationale() { return Math.max(10, parseInt(this.dataset.maxRationale ?? '200', 10) || 200); }
|
|
508
|
+
#minOverrunRationale() { return Math.max(0, parseInt(this.dataset.minOverrunRationale ?? '10', 10) || 10); }
|
|
509
|
+
#maxOverrunRationale() { return Math.max(10, parseInt(this.dataset.maxOverrunRationale ?? '400', 10) || 400); }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
|
|
513
|
+
|
|
514
|
+
registerComponent('quality-target', QualityTarget);
|
|
515
|
+
|
|
516
|
+
export { QualityTarget };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Quality Target (static fallback)</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/main.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<!--
|
|
11
|
+
Polygon-as-UI quality picker. The radar IS the picker — click an
|
|
12
|
+
axis to open its per-ility editor. Pre-upgrade fallback: empty
|
|
13
|
+
element (the meaningful interaction is dialog-driven and JS-only).
|
|
14
|
+
|
|
15
|
+
Pair with <iron-triangle> via data-bind-to so capacity reads from
|
|
16
|
+
the project shape; or set data-capacity-points directly.
|
|
17
|
+
-->
|
|
18
|
+
<iron-triangle id="shape" data-quality-href="#quality"></iron-triangle>
|
|
19
|
+
|
|
20
|
+
<quality-target id="quality" name="quality" data-bind-to="shape"></quality-target>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|