@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,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <iron-triangle> — Triangle-as-UI project-shape constraint surface.
|
|
3
|
+
*
|
|
4
|
+
* The triangle IS the interface. Three vertex hit-targets (Scope / Time
|
|
5
|
+
* / Cost) open native <dialog> editors on click; the center "Quality"
|
|
6
|
+
* target fires `iron-triangle:open-quality` and falls back to
|
|
7
|
+
* `data-quality-href` navigation when present. Hover surfaces the
|
|
8
|
+
* current value via native <title> tooltips.
|
|
9
|
+
*
|
|
10
|
+
* Authoring shape — minimal:
|
|
11
|
+
*
|
|
12
|
+
* <iron-triangle name="triangle" data-quality-href="/requirements">
|
|
13
|
+
* </iron-triangle>
|
|
14
|
+
*
|
|
15
|
+
* The component generates the SVG and the per-vertex dialogs. No form
|
|
16
|
+
* markup needed in the host page. The component is form-associated;
|
|
17
|
+
* the value serializes as JSON via setFormValue().
|
|
18
|
+
*
|
|
19
|
+
* Optional: pass a `data-quality-summary` attribute or set the
|
|
20
|
+
* `qualitySummary` property to surface the bound NFR compass's
|
|
21
|
+
* decision on hover (e.g. "3 critical: perf, sec, a11y").
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { VBElement } from '../../lib/vb-element.js';
|
|
25
|
+
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
26
|
+
import {
|
|
27
|
+
defaultFormula,
|
|
28
|
+
triangleHash,
|
|
29
|
+
} from './_capacity.js';
|
|
30
|
+
import {
|
|
31
|
+
triangleVertices,
|
|
32
|
+
buildTriangleSvg,
|
|
33
|
+
} from './_triangle-geometry.js';
|
|
34
|
+
|
|
35
|
+
const SECTIONS = ['time', 'cost', 'scope'];
|
|
36
|
+
|
|
37
|
+
const FIELD_DEFS = {
|
|
38
|
+
time: [
|
|
39
|
+
{ key: 'sprintWeeks', label: 'Sprint length (weeks)', type: 'number', min: 1, step: 1, value: 2, width: '6rem' },
|
|
40
|
+
{ key: 'sprintCount', label: 'Number of sprints', type: 'number', min: 1, step: 1, value: 3, width: '6rem' },
|
|
41
|
+
{ key: 'hoursPerWeek', label: 'Hours per week (per FTE)', type: 'number', min: 1, max: 168, step: 1, value: 40, width: '6rem' },
|
|
42
|
+
{ key: 'deadline', label: 'Deadline (optional)', type: 'date', width: '12rem' },
|
|
43
|
+
],
|
|
44
|
+
cost: [
|
|
45
|
+
{ key: 'teamFTE', label: 'Team size (FTE)', type: 'number', min: 0, step: 0.5, value: 1, width: '6rem' },
|
|
46
|
+
{ key: 'budgetTier', label: 'Budget tier', type: 'select', options: [
|
|
47
|
+
['solo', 'Solo'],
|
|
48
|
+
['small', 'Small (2-5)'],
|
|
49
|
+
['medium', 'Medium (6-15)'],
|
|
50
|
+
['large', 'Large (16+)'],
|
|
51
|
+
], value: 'solo', width: '14rem' },
|
|
52
|
+
{ key: 'contractorBudget', label: 'Contractor budget ($, optional)', type: 'number', min: 0, step: 100, width: '10rem' },
|
|
53
|
+
],
|
|
54
|
+
scope: [
|
|
55
|
+
{ key: 'mustHaveCount', label: 'Must-have feature count', type: 'number', min: 0, step: 1, width: '6rem' },
|
|
56
|
+
{ key: 'shouldHaveCount', label: 'Should-have feature count', type: 'number', min: 0, step: 1, width: '6rem' },
|
|
57
|
+
{ key: 'scopeNotes', label: 'Notes', type: 'textarea', rows: 3, maxlength: 2000 },
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const SECTION_TITLES = {
|
|
62
|
+
scope: 'Scope',
|
|
63
|
+
time: 'Time',
|
|
64
|
+
cost: 'Cost',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
class IronTriangle extends VBElement {
|
|
68
|
+
static formAssociated = true;
|
|
69
|
+
static get observedAttributes() {
|
|
70
|
+
return [
|
|
71
|
+
'data-focus-factor', 'data-min-capacity',
|
|
72
|
+
'data-quality-href', 'data-quality-summary',
|
|
73
|
+
'disabled', 'locked',
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @type {ElementInternals} */
|
|
78
|
+
#internals;
|
|
79
|
+
#value = {
|
|
80
|
+
time: {}, cost: {}, scope: {},
|
|
81
|
+
capacityPoints: 0,
|
|
82
|
+
capacitySource: 'formula',
|
|
83
|
+
hash: '',
|
|
84
|
+
};
|
|
85
|
+
/** @type {number | null} */
|
|
86
|
+
#manualPoints = null;
|
|
87
|
+
#revisionLog = [];
|
|
88
|
+
#qualitySummary = '';
|
|
89
|
+
/** @type {Record<string, HTMLDialogElement>} */
|
|
90
|
+
#dialogs = {};
|
|
91
|
+
|
|
92
|
+
constructor() {
|
|
93
|
+
super();
|
|
94
|
+
this.#internals = this.attachInternals();
|
|
95
|
+
this._adoptInternals(this.#internals);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setup() {
|
|
99
|
+
// Prefer an explicit attribute over an empty default.
|
|
100
|
+
this.#qualitySummary = this.getAttribute('data-quality-summary') || '';
|
|
101
|
+
// Seed initial value from FIELD_DEFS so the formula has something to chew on.
|
|
102
|
+
this.#seedDefaults();
|
|
103
|
+
this.#renderViz();
|
|
104
|
+
|
|
105
|
+
this.listen(this, 'click', (e) => this.#onClick(e));
|
|
106
|
+
this.listen(this, 'keydown', (e) => this.#onKeydown(e));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
attributeChangedCallback(name) {
|
|
110
|
+
if (!this.isConnected) return;
|
|
111
|
+
if (name === 'data-focus-factor' || name === 'data-min-capacity') {
|
|
112
|
+
this.#recompute({ source: 'attribute' });
|
|
113
|
+
} else if (name === 'data-quality-summary') {
|
|
114
|
+
this.#qualitySummary = this.getAttribute('data-quality-summary') || '';
|
|
115
|
+
this.#renderViz();
|
|
116
|
+
} else if (name === 'disabled' || name === 'locked') {
|
|
117
|
+
this.#syncDisabledLocked();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Public API ────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
get value() {
|
|
124
|
+
return JSON.parse(JSON.stringify({ ...this.#value, revisionLog: this.#revisionLog }));
|
|
125
|
+
}
|
|
126
|
+
set value(next) {
|
|
127
|
+
if (!next || typeof next !== 'object') return;
|
|
128
|
+
for (const section of SECTIONS) {
|
|
129
|
+
if (next[section] && typeof next[section] === 'object') {
|
|
130
|
+
this.#value[section] = { ...next[section] };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (next.capacitySource === 'manual' && Number.isFinite(next.capacityPoints)) {
|
|
134
|
+
this.#manualPoints = next.capacityPoints;
|
|
135
|
+
this.#value.capacitySource = 'manual';
|
|
136
|
+
} else {
|
|
137
|
+
this.#manualPoints = null;
|
|
138
|
+
this.#value.capacitySource = 'formula';
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(next.revisionLog)) this.#revisionLog = [...next.revisionLog];
|
|
141
|
+
this.#syncDialogInputs();
|
|
142
|
+
this.#recompute({ source: 'property' });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get capacityPoints() { return this.#value.capacityPoints; }
|
|
146
|
+
get capacitySource() { return this.#value.capacitySource; }
|
|
147
|
+
get hash() { return this.#value.hash; }
|
|
148
|
+
get revisionLog() { return JSON.parse(JSON.stringify(this.#revisionLog)); }
|
|
149
|
+
|
|
150
|
+
get qualitySummary() { return this.#qualitySummary; }
|
|
151
|
+
set qualitySummary(s) {
|
|
152
|
+
this.#qualitySummary = String(s ?? '');
|
|
153
|
+
this.#renderViz();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Imperatively open a vertex editor. */
|
|
157
|
+
openEditor(axis) {
|
|
158
|
+
if (!SECTIONS.includes(axis)) return;
|
|
159
|
+
const dialog = this.#ensureDialog(axis);
|
|
160
|
+
if (typeof dialog.showModal === 'function') dialog.showModal();
|
|
161
|
+
else dialog.setAttribute('open', '');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
revise(field, newValue, reason) {
|
|
165
|
+
if (!field || typeof reason !== 'string' || reason.length < 10) {
|
|
166
|
+
throw new Error('iron-triangle: revise() requires field and a reason of at least 10 characters');
|
|
167
|
+
}
|
|
168
|
+
const from = this.#getField(field);
|
|
169
|
+
this.#setField(field, newValue);
|
|
170
|
+
const change = { field, from, to: newValue, reason };
|
|
171
|
+
this.#revisionLog.push({ revisedAt: new Date().toISOString(), changes: [change] });
|
|
172
|
+
this.dispatchEvent(new CustomEvent('iron-triangle:revise', {
|
|
173
|
+
bubbles: true, composed: true, detail: change,
|
|
174
|
+
}));
|
|
175
|
+
this.#syncDialogInputs();
|
|
176
|
+
this.#recompute({ source: 'revise' });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
setManual(integer) {
|
|
180
|
+
const n = Math.max(1, Math.floor(Number(integer) || 0));
|
|
181
|
+
this.#manualPoints = n;
|
|
182
|
+
const previous = this.#value.capacitySource;
|
|
183
|
+
this.#value.capacitySource = 'manual';
|
|
184
|
+
if (previous !== 'manual') {
|
|
185
|
+
this.dispatchEvent(new CustomEvent('iron-triangle:mode', {
|
|
186
|
+
bubbles: true, composed: true, detail: { from: previous, to: 'manual' },
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
this.setState('manual', true);
|
|
190
|
+
this.setState('formula', false);
|
|
191
|
+
this.#recompute({ source: 'manual' });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setFormula() {
|
|
195
|
+
this.#manualPoints = null;
|
|
196
|
+
const previous = this.#value.capacitySource;
|
|
197
|
+
this.#value.capacitySource = 'formula';
|
|
198
|
+
if (previous !== 'formula') {
|
|
199
|
+
this.dispatchEvent(new CustomEvent('iron-triangle:mode', {
|
|
200
|
+
bubbles: true, composed: true, detail: { from: previous, to: 'formula' },
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
this.setState('manual', false);
|
|
204
|
+
this.setState('formula', true);
|
|
205
|
+
this.#recompute({ source: 'formula' });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
recalc() { this.#recompute({ source: 'recalc' }); }
|
|
209
|
+
|
|
210
|
+
// ── Internal: SVG hit-target click/keyboard ──────────────────────
|
|
211
|
+
|
|
212
|
+
#onClick(event) {
|
|
213
|
+
const vertex = event.target.closest?.('.vertex[data-axis]');
|
|
214
|
+
if (vertex && this.contains(vertex)) {
|
|
215
|
+
event.preventDefault();
|
|
216
|
+
this.openEditor(vertex.dataset.axis);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const center = event.target.closest?.('.center[data-target="quality"]');
|
|
220
|
+
if (center && this.contains(center)) {
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
this.#fireQualityOpen();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#onKeydown(event) {
|
|
227
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
228
|
+
const vertex = event.target.closest?.('.vertex[data-axis]');
|
|
229
|
+
if (vertex && this.contains(vertex)) {
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
this.openEditor(vertex.dataset.axis);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const center = event.target.closest?.('.center[data-target="quality"]');
|
|
235
|
+
if (center && this.contains(center)) {
|
|
236
|
+
event.preventDefault();
|
|
237
|
+
this.#fireQualityOpen();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#fireQualityOpen() {
|
|
242
|
+
const evt = new CustomEvent('iron-triangle:open-quality', {
|
|
243
|
+
bubbles: true, composed: true, cancelable: true,
|
|
244
|
+
detail: { qualitySummary: this.#qualitySummary, capacityPoints: this.#value.capacityPoints },
|
|
245
|
+
});
|
|
246
|
+
const allowDefault = this.dispatchEvent(evt);
|
|
247
|
+
if (allowDefault) {
|
|
248
|
+
const href = this.getAttribute('data-quality-href');
|
|
249
|
+
if (href) window.location.assign(href);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Internal: dialogs ─────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
#ensureDialog(axis) {
|
|
256
|
+
if (this.#dialogs[axis]) return this.#dialogs[axis];
|
|
257
|
+
const dialog = document.createElement('dialog');
|
|
258
|
+
dialog.className = `iron-triangle-dialog iron-triangle-dialog--${axis}`;
|
|
259
|
+
dialog.setAttribute('aria-label', `${SECTION_TITLES[axis]} — edit`);
|
|
260
|
+
|
|
261
|
+
const form = document.createElement('form');
|
|
262
|
+
form.method = 'dialog';
|
|
263
|
+
form.setAttribute('data-layout', 'stack');
|
|
264
|
+
form.setAttribute('data-layout-gap', 'm');
|
|
265
|
+
|
|
266
|
+
const heading = document.createElement('h3');
|
|
267
|
+
heading.textContent = SECTION_TITLES[axis];
|
|
268
|
+
form.append(heading);
|
|
269
|
+
|
|
270
|
+
for (const def of FIELD_DEFS[axis]) {
|
|
271
|
+
form.append(buildFormField(axis, def, this.#value[axis]?.[def.key]));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const actions = document.createElement('div');
|
|
275
|
+
actions.setAttribute('data-layout', 'cluster');
|
|
276
|
+
actions.setAttribute('data-layout-gap', 's');
|
|
277
|
+
actions.setAttribute('data-layout-justify', 'end');
|
|
278
|
+
const cancel = document.createElement('button');
|
|
279
|
+
cancel.type = 'button';
|
|
280
|
+
cancel.value = 'cancel';
|
|
281
|
+
cancel.textContent = 'Cancel';
|
|
282
|
+
cancel.addEventListener('click', () => dialog.close('cancel'));
|
|
283
|
+
const save = document.createElement('button');
|
|
284
|
+
save.type = 'submit';
|
|
285
|
+
save.value = 'save';
|
|
286
|
+
save.textContent = 'Save';
|
|
287
|
+
actions.append(cancel, save);
|
|
288
|
+
form.append(actions);
|
|
289
|
+
|
|
290
|
+
dialog.append(form);
|
|
291
|
+
this.append(dialog);
|
|
292
|
+
this.#dialogs[axis] = dialog;
|
|
293
|
+
|
|
294
|
+
form.addEventListener('submit', (e) => {
|
|
295
|
+
e.preventDefault();
|
|
296
|
+
this.#captureDialog(axis, form);
|
|
297
|
+
dialog.close('save');
|
|
298
|
+
this.#recompute({ source: 'dialog', field: axis });
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return dialog;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#captureDialog(axis, form) {
|
|
305
|
+
const next = {};
|
|
306
|
+
for (const def of FIELD_DEFS[axis]) {
|
|
307
|
+
const input = form.querySelector(`[name="${axis}.${def.key}"]`);
|
|
308
|
+
if (!input) continue;
|
|
309
|
+
const raw = input.type === 'checkbox' ? input.checked : input.value;
|
|
310
|
+
if (raw === '' || raw == null) continue;
|
|
311
|
+
if (def.type === 'number') {
|
|
312
|
+
const n = Number(raw);
|
|
313
|
+
if (Number.isFinite(n)) next[def.key] = n;
|
|
314
|
+
} else {
|
|
315
|
+
next[def.key] = raw;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
this.#value[axis] = next;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#syncDialogInputs() {
|
|
322
|
+
for (const axis of SECTIONS) {
|
|
323
|
+
const dialog = this.#dialogs[axis];
|
|
324
|
+
if (!dialog) continue;
|
|
325
|
+
for (const def of FIELD_DEFS[axis]) {
|
|
326
|
+
const input = /** @type {HTMLInputElement | null} */ (dialog.querySelector(`[name="${axis}.${def.key}"]`));
|
|
327
|
+
if (!input) continue;
|
|
328
|
+
const v = this.#value[axis]?.[def.key];
|
|
329
|
+
input.value = v == null ? '' : String(v);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Internal: capacity + viz ──────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
#seedDefaults() {
|
|
337
|
+
for (const axis of SECTIONS) {
|
|
338
|
+
for (const def of FIELD_DEFS[axis]) {
|
|
339
|
+
if (def.value !== undefined && this.#value[axis][def.key] === undefined) {
|
|
340
|
+
this.#value[axis][def.key] = def.value;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
this.#recompute({ source: 'init' });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** @param {{ source?: string, field?: string }} [options] */
|
|
348
|
+
#recompute({ source, field } = {}) {
|
|
349
|
+
const focusFactor = Number(this.dataset.focusFactor ?? 0.6);
|
|
350
|
+
const minCapacity = Number(this.dataset.minCapacity ?? 1);
|
|
351
|
+
|
|
352
|
+
let points;
|
|
353
|
+
if (this.#value.capacitySource === 'manual' && this.#manualPoints != null) {
|
|
354
|
+
points = this.#manualPoints;
|
|
355
|
+
} else {
|
|
356
|
+
points = defaultFormula(this.#value.time, this.#value.cost, focusFactor);
|
|
357
|
+
}
|
|
358
|
+
if (Number.isFinite(minCapacity) && points > 0 && points < minCapacity) {
|
|
359
|
+
points = minCapacity;
|
|
360
|
+
}
|
|
361
|
+
this.#value.capacityPoints = points;
|
|
362
|
+
this.#value.hash = triangleHash(this.#value);
|
|
363
|
+
|
|
364
|
+
this.#renderViz();
|
|
365
|
+
this.#syncStateFlags();
|
|
366
|
+
this.#publishFormValue();
|
|
367
|
+
|
|
368
|
+
this.dispatchEvent(new CustomEvent('iron-triangle:change', {
|
|
369
|
+
bubbles: true, composed: true,
|
|
370
|
+
detail: { ...this.value, source, field },
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#renderViz() {
|
|
375
|
+
const svg = buildTriangleSvg({
|
|
376
|
+
value: this.#value,
|
|
377
|
+
vertices: triangleVertices(this.#value),
|
|
378
|
+
capacityPoints: this.#value.capacityPoints,
|
|
379
|
+
capacitySource: this.#value.capacitySource,
|
|
380
|
+
qualitySummary: this.#qualitySummary,
|
|
381
|
+
});
|
|
382
|
+
const existing = this.querySelector(':scope > svg');
|
|
383
|
+
if (existing) existing.replaceWith(svg);
|
|
384
|
+
else this.prepend(svg);
|
|
385
|
+
this.dataset.capacityPoints = String(this.#value.capacityPoints);
|
|
386
|
+
this.dataset.capacitySource = this.#value.capacitySource;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#syncStateFlags() {
|
|
390
|
+
this.setState('formula', this.#value.capacitySource === 'formula');
|
|
391
|
+
this.setState('manual', this.#value.capacitySource === 'manual');
|
|
392
|
+
this.setState('unbudgeted', !(this.#value.capacityPoints > 0));
|
|
393
|
+
const deadline = this.#value.time?.deadline;
|
|
394
|
+
let overDeadline = false;
|
|
395
|
+
if (deadline) {
|
|
396
|
+
const target = new Date(deadline).getTime();
|
|
397
|
+
if (Number.isFinite(target)) overDeadline = target < Date.now();
|
|
398
|
+
}
|
|
399
|
+
this.setState('over-deadline', overDeadline);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#publishFormValue() {
|
|
403
|
+
try {
|
|
404
|
+
this.#internals.setFormValue(JSON.stringify(this.value));
|
|
405
|
+
} catch { /* older Safari */ }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#syncDisabledLocked() {
|
|
409
|
+
const off = this.hasAttribute('disabled') || this.hasAttribute('locked');
|
|
410
|
+
for (const dialog of Object.values(this.#dialogs)) {
|
|
411
|
+
for (const el of dialog.querySelectorAll('input, select, textarea, button')) /** @type {any} */ (el).disabled = off;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Internal: revise() field accessors ───────────────────────────
|
|
416
|
+
|
|
417
|
+
#getField(path) {
|
|
418
|
+
if (path === 'capacityPoints') return this.#value.capacityPoints;
|
|
419
|
+
const [section, key] = path.split('.');
|
|
420
|
+
return section && key ? this.#value[section]?.[key] : undefined;
|
|
421
|
+
}
|
|
422
|
+
#setField(path, value) {
|
|
423
|
+
if (path === 'capacityPoints') { this.setManual(value); return; }
|
|
424
|
+
const [section, key] = path.split('.');
|
|
425
|
+
if (!section || !key) return;
|
|
426
|
+
if (!this.#value[section]) this.#value[section] = {};
|
|
427
|
+
this.#value[section][key] = value;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── Module-level: build a <form-field> wrapper for one input def ──
|
|
432
|
+
|
|
433
|
+
function buildFormField(axis, def, currentValue) {
|
|
434
|
+
const ff = document.createElement('form-field');
|
|
435
|
+
const id = `${axis}-${def.key}`;
|
|
436
|
+
const label = document.createElement('label');
|
|
437
|
+
label.setAttribute('for', id);
|
|
438
|
+
label.textContent = def.label;
|
|
439
|
+
ff.append(label);
|
|
440
|
+
|
|
441
|
+
let control;
|
|
442
|
+
if (def.type === 'select') {
|
|
443
|
+
control = document.createElement('select');
|
|
444
|
+
for (const [val, text] of def.options) {
|
|
445
|
+
const opt = document.createElement('option');
|
|
446
|
+
opt.value = val;
|
|
447
|
+
opt.textContent = text;
|
|
448
|
+
if (currentValue === val || (currentValue == null && def.value === val)) opt.selected = true;
|
|
449
|
+
control.append(opt);
|
|
450
|
+
}
|
|
451
|
+
} else if (def.type === 'textarea') {
|
|
452
|
+
control = document.createElement('textarea');
|
|
453
|
+
if (def.rows) control.rows = def.rows;
|
|
454
|
+
if (def.maxlength) control.maxLength = def.maxlength;
|
|
455
|
+
if (currentValue != null) control.value = String(currentValue);
|
|
456
|
+
} else {
|
|
457
|
+
control = document.createElement('input');
|
|
458
|
+
control.type = def.type;
|
|
459
|
+
if (def.min != null) control.min = String(def.min);
|
|
460
|
+
if (def.max != null) control.max = String(def.max);
|
|
461
|
+
if (def.step != null) control.step = String(def.step);
|
|
462
|
+
if (currentValue != null) control.value = String(currentValue);
|
|
463
|
+
else if (def.value != null) control.value = String(def.value);
|
|
464
|
+
}
|
|
465
|
+
control.id = id;
|
|
466
|
+
control.name = `${axis}.${def.key}`;
|
|
467
|
+
if (def.width) control.style.inlineSize = def.width;
|
|
468
|
+
ff.append(control);
|
|
469
|
+
return ff;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
registerComponent('iron-triangle', IronTriangle);
|
|
473
|
+
|
|
474
|
+
export { IronTriangle };
|
|
@@ -0,0 +1,28 @@
|
|
|
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>Iron Triangle (static fallback)</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/main.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<!--
|
|
11
|
+
Triangle-as-UI: the component IS the surface. Three vertex
|
|
12
|
+
targets (Scope / Time / Cost) open native <dialog> editors on
|
|
13
|
+
click; the center "Quality" target fires
|
|
14
|
+
`iron-triangle:open-quality` and falls back to navigating
|
|
15
|
+
`data-quality-href`. Hover any target for a tooltip.
|
|
16
|
+
|
|
17
|
+
Pre-upgrade fallback: the component renders no UI without JS —
|
|
18
|
+
the meaningful interaction is dialog-driven and JS-only. For a
|
|
19
|
+
no-JS path, expose a server-rendered form on the same page and
|
|
20
|
+
flip with a feature flag. UUCD pre-fills `data-quality-summary`
|
|
21
|
+
so hover on the center previews the saved compass picks.
|
|
22
|
+
-->
|
|
23
|
+
<iron-triangle name="triangle"
|
|
24
|
+
data-focus-factor="0.6"
|
|
25
|
+
data-quality-href="/requirements"
|
|
26
|
+
data-quality-summary="3 critical: perf, sec, a11y"></iron-triangle>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/* iron-triangle — triangle-as-UI for project-shape constraints.
|
|
2
|
+
|
|
3
|
+
The component renders a single SVG with three clickable vertex
|
|
4
|
+
targets (Scope / Time / Cost) and a center "Quality" target. Each
|
|
5
|
+
target opens a native <dialog> editor (vertices) or fires the
|
|
6
|
+
open-quality event (center). Editor chrome inside the dialogs uses
|
|
7
|
+
VB primitives — <form-field>, <fieldset>, data-layout. */
|
|
8
|
+
|
|
9
|
+
iron-triangle {
|
|
10
|
+
display: block;
|
|
11
|
+
inline-size: 100%;
|
|
12
|
+
max-inline-size: var(--iron-triangle-max, 26rem);
|
|
13
|
+
margin-inline: auto;
|
|
14
|
+
container-type: inline-size;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
iron-triangle > svg {
|
|
18
|
+
inline-size: 100%;
|
|
19
|
+
block-size: auto;
|
|
20
|
+
display: block;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* ── Triangle outline ─────────────────────────────────────────── */
|
|
24
|
+
|
|
25
|
+
iron-triangle svg .triangle {
|
|
26
|
+
fill: var(--iron-triangle-fill, color-mix(in oklab, var(--color-interactive) 8%, transparent));
|
|
27
|
+
stroke: var(--iron-triangle-stroke, var(--color-interactive));
|
|
28
|
+
stroke-width: var(--iron-triangle-stroke-width, 1.5);
|
|
29
|
+
stroke-linejoin: round;
|
|
30
|
+
pointer-events: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* ── Vertex hit targets ───────────────────────────────────────── */
|
|
34
|
+
|
|
35
|
+
iron-triangle svg .vertex,
|
|
36
|
+
iron-triangle svg .center {
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
outline: none;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
iron-triangle svg .vertex .hit,
|
|
42
|
+
iron-triangle svg .center .hit {
|
|
43
|
+
fill: transparent;
|
|
44
|
+
stroke: transparent;
|
|
45
|
+
transition: fill 120ms ease, stroke 120ms ease;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
iron-triangle svg .vertex:hover .hit,
|
|
49
|
+
iron-triangle svg .vertex:focus-visible .hit,
|
|
50
|
+
iron-triangle svg .center:hover .hit,
|
|
51
|
+
iron-triangle svg .center:focus-visible .hit {
|
|
52
|
+
fill: color-mix(in oklab, var(--color-interactive) 8%, transparent);
|
|
53
|
+
stroke: var(--color-interactive);
|
|
54
|
+
stroke-width: 1.5;
|
|
55
|
+
stroke-dasharray: 3 3;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
iron-triangle svg .vertex-name {
|
|
59
|
+
font-size: 0.85rem;
|
|
60
|
+
font-weight: var(--font-weight-semibold, 600);
|
|
61
|
+
fill: var(--color-text);
|
|
62
|
+
pointer-events: none;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
iron-triangle svg .vertex:hover .vertex-name,
|
|
66
|
+
iron-triangle svg .vertex:focus-visible .vertex-name {
|
|
67
|
+
fill: var(--color-interactive);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
iron-triangle svg .vertex-summary {
|
|
71
|
+
font-size: 0.7rem;
|
|
72
|
+
fill: var(--color-text-muted);
|
|
73
|
+
font-variant-numeric: tabular-nums;
|
|
74
|
+
pointer-events: none;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ── Center "Quality" target ──────────────────────────────────── */
|
|
78
|
+
|
|
79
|
+
iron-triangle svg .capacity-backdrop {
|
|
80
|
+
fill: var(--color-surface);
|
|
81
|
+
stroke: var(--color-border-muted, var(--color-border, #e5e7eb));
|
|
82
|
+
stroke-width: 1;
|
|
83
|
+
pointer-events: none;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
iron-triangle svg .quality-label {
|
|
87
|
+
font-size: 0.65rem;
|
|
88
|
+
font-weight: var(--font-weight-semibold, 600);
|
|
89
|
+
fill: var(--color-interactive);
|
|
90
|
+
text-transform: uppercase;
|
|
91
|
+
letter-spacing: 0.06em;
|
|
92
|
+
pointer-events: none;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
iron-triangle svg .capacity {
|
|
96
|
+
font-size: var(--iron-triangle-capacity-size, 1.6rem);
|
|
97
|
+
font-weight: var(--font-weight-bold, 700);
|
|
98
|
+
fill: var(--color-text);
|
|
99
|
+
font-variant-numeric: tabular-nums;
|
|
100
|
+
pointer-events: none;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
iron-triangle svg .capacity-unit {
|
|
104
|
+
font-size: 0.6rem;
|
|
105
|
+
fill: var(--color-text-muted);
|
|
106
|
+
pointer-events: none;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* Whole center group lights up on hover/focus to show it's clickable. */
|
|
110
|
+
iron-triangle svg .center .hit {
|
|
111
|
+
fill: transparent;
|
|
112
|
+
stroke: transparent;
|
|
113
|
+
transition: fill 120ms ease, stroke 120ms ease;
|
|
114
|
+
}
|
|
115
|
+
iron-triangle svg .center:hover .hit,
|
|
116
|
+
iron-triangle svg .center:focus-visible .hit {
|
|
117
|
+
fill: color-mix(in oklab, var(--color-interactive) 8%, transparent);
|
|
118
|
+
stroke: var(--color-interactive);
|
|
119
|
+
stroke-width: 1.5;
|
|
120
|
+
stroke-dasharray: 3 3;
|
|
121
|
+
}
|
|
122
|
+
iron-triangle svg .center:hover .quality-label,
|
|
123
|
+
iron-triangle svg .center:focus-visible .quality-label {
|
|
124
|
+
fill: var(--color-interactive);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ── State hooks ──────────────────────────────────────────────── */
|
|
128
|
+
|
|
129
|
+
iron-triangle:state(over-deadline) svg .triangle { stroke: var(--color-warning); }
|
|
130
|
+
iron-triangle:state(unbudgeted) svg .capacity { fill: var(--color-warning); }
|
|
131
|
+
iron-triangle:state(manual) svg .quality-label::after { content: " (manual)"; }
|
|
132
|
+
|
|
133
|
+
iron-triangle[disabled],
|
|
134
|
+
iron-triangle[locked] {
|
|
135
|
+
opacity: 0.85;
|
|
136
|
+
pointer-events: none;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ── Dialog chrome ────────────────────────────────────────────── */
|
|
140
|
+
|
|
141
|
+
iron-triangle > dialog.iron-triangle-dialog {
|
|
142
|
+
inline-size: min(28rem, 92vw);
|
|
143
|
+
border: var(--border-width-thin, 1px) solid var(--color-border);
|
|
144
|
+
border-radius: var(--radius-l, 0.75rem);
|
|
145
|
+
padding: var(--size-l, 1.5rem);
|
|
146
|
+
background: var(--color-surface);
|
|
147
|
+
color: var(--color-text);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
iron-triangle > dialog.iron-triangle-dialog::backdrop {
|
|
151
|
+
background: oklch(from var(--color-surface) l c h / 0.5);
|
|
152
|
+
backdrop-filter: blur(2px);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
iron-triangle > dialog.iron-triangle-dialog h3 {
|
|
156
|
+
margin: 0 0 var(--size-m) 0;
|
|
157
|
+
font-size: var(--font-size-l, 1.125rem);
|
|
158
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schemas/api.schema.json",
|
|
3
|
+
"element": "kanban-board",
|
|
4
|
+
"type": "web-component",
|
|
5
|
+
"description": "Columnar drag-and-drop board with user-defined columns, count badges, and optional WIP limits",
|
|
6
|
+
"htmlvalidate": { "flow": true, "permittedContent": ["section"] },
|
|
7
|
+
"attributes": [
|
|
8
|
+
{ "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON data for columns and items" },
|
|
9
|
+
{ "name": "title", "kind": "host-api", "purpose": "config", "type": "string", "description": "Optional heading above the board" },
|
|
10
|
+
{ "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Reduced spacing variant" }
|
|
11
|
+
],
|
|
12
|
+
"childAttributes": [
|
|
13
|
+
{ "name": "data-column", "on": "section", "type": "string", "description": "Column identifier" },
|
|
14
|
+
{ "name": "data-column-label", "on": "section", "type": "string", "description": "Display label for the column header" },
|
|
15
|
+
{ "name": "data-wip", "on": "section", "type": "number", "description": "Optional WIP limit — visual warning when exceeded" },
|
|
16
|
+
{ "name": "data-column-color", "on": "section", "type": "string", "description": "Color token for column tint (success, warning, error, info)" }
|
|
17
|
+
],
|
|
18
|
+
"events": [
|
|
19
|
+
{ "name": "kanban-board:transfer", "detail": "{ itemId, fromColumn, toColumn, newIndex, item }", "description": "Item moved between columns" },
|
|
20
|
+
{ "name": "kanban-board:reorder", "detail": "{ itemId, column, oldIndex, newIndex }", "description": "Item reordered within a column" },
|
|
21
|
+
{ "name": "kanban-board:ready", "detail": "{ columnCount, itemCount }", "description": "Fired after component initializes" },
|
|
22
|
+
{ "name": "kanban-board:wip-exceeded", "detail": "{ column, limit, count }", "description": "Fired when a column exceeds its WIP limit" }
|
|
23
|
+
]
|
|
24
|
+
}
|