@profpowell/vb-project-planning 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -5
- package/dist/vb-project-planning.css +61 -5
- package/dist/vb-project-planning.js +304 -27
- package/dist/vb-project-planning.min.css +1 -1
- package/dist/vb-project-planning.min.js +101 -101
- package/package.json +5 -4
- package/src/web-components/iron-triangle/_capacity.js +1 -1
- package/src/web-components/iron-triangle/_triangle-geometry.js +1 -1
- package/src/web-components/iron-triangle/api.json +3 -3
- package/src/web-components/iron-triangle/logic.js +200 -5
- package/src/web-components/iron-triangle/styles.css +81 -3
- package/src/web-components/product-roadmap/logic.js +25 -13
- package/src/web-components/quality-target/api.json +2 -3
- package/src/web-components/quality-target/logic.js +85 -13
- package/src/web-components/requirement-card/api.json +1 -1
- package/src/web-components/requirement-card/logic.js +27 -3
- package/src/web-components/requirement-card/static.html +1 -1
- package/src/web-components/user-persona/logic.js +1 -1
- package/src/web-components/user-story/logic.js +2 -1
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profpowell/vb-project-planning",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Project- and UX-planning Web Components for Vanilla Breeze: personas, stories, journeys, empathy maps, impact/effort and quadrant grids, risk and traceability matrices, burndown, roadmap, kanban, story map, gantt, review surface, work items, ADRs, iron triangle, capacity plans, quality targets, and requirement cards.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"author": "
|
|
6
|
+
"author": "Thomas A. Powell",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"homepage": "https://github.com/ProfPowell/vb-project-planning#readme",
|
|
9
9
|
"repository": {
|
|
@@ -63,10 +63,11 @@
|
|
|
63
63
|
"build:js": "esbuild src/index.js --bundle --format=esm --outfile=dist/vb-project-planning.js && esbuild src/index.js --bundle --format=esm --minify --outfile=dist/vb-project-planning.min.js",
|
|
64
64
|
"build:css": "esbuild src/index.css --bundle --outfile=dist/vb-project-planning.css && esbuild src/index.css --bundle --minify --outfile=dist/vb-project-planning.min.css",
|
|
65
65
|
"prepublishOnly": "npm run build",
|
|
66
|
-
"test:components": "playwright test"
|
|
66
|
+
"test:components": "playwright test",
|
|
67
|
+
"test": "playwright test"
|
|
67
68
|
},
|
|
68
69
|
"peerDependencies": {
|
|
69
|
-
"vanilla-breeze": ">=0.
|
|
70
|
+
"vanilla-breeze": ">=0.3.0"
|
|
70
71
|
},
|
|
71
72
|
"peerDependenciesMeta": {
|
|
72
73
|
"vanilla-breeze": {
|
|
@@ -40,7 +40,7 @@ export function defaultFormulaText(time, cost, focusFactor) {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* FNV-1a (32-bit) hash over a stable JSON serialization of the triangle.
|
|
43
|
-
* Used for drift detection by downstream consumers (notably <
|
|
43
|
+
* Used for drift detection by downstream consumers (notably <quality-target>'s
|
|
44
44
|
* ironTriangleHash field).
|
|
45
45
|
*
|
|
46
46
|
* Matches the fnv1a in src/web-components/color-picker/_color-utils.js so
|
|
@@ -172,7 +172,7 @@ export function buildTriangleSvg({
|
|
|
172
172
|
center.setAttribute('role', 'button');
|
|
173
173
|
center.setAttribute(
|
|
174
174
|
'aria-label',
|
|
175
|
-
`Quality — ${formatQualitySummary(qualitySummary)}. Activate to open the
|
|
175
|
+
`Quality — ${formatQualitySummary(qualitySummary)}. Activate to open the quality decision.`,
|
|
176
176
|
);
|
|
177
177
|
|
|
178
178
|
// Backdrop circle — sits behind the text, masks the polygon stroke.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "../../../schemas/api.schema.json",
|
|
3
3
|
"element": "iron-triangle",
|
|
4
4
|
"type": "web-component",
|
|
5
|
-
"description": "Triangle-as-UI project-shape constraint surface. Three vertex hit-targets (Scope/Time/Cost) open native <dialog> editors; the center 'Quality' target fires iron-triangle:open-quality with a data-quality-href fallback. Computes capacityPoints from sprintWeeks × sprintCount × teamFTE × focusFactor. Form-associated.",
|
|
5
|
+
"description": "Triangle-as-UI project-shape constraint surface. Three vertex hit-targets (Scope/Time/Cost) open native <dialog> editors; the center 'Quality' target fires iron-triangle:open-quality with a data-quality-href fallback. Computes capacityPoints from sprintWeeks × sprintCount × teamFTE × focusFactor. Form-associated. Renders a status strip below the SVG: a visually-hidden <output aria-live=polite> capacity readout, a <button aria-pressed> formula/manual switch that opens a manual-capacity dialog, and a visible 'Deadline has passed' warning.",
|
|
6
6
|
"htmlvalidate": {
|
|
7
7
|
"flow": true,
|
|
8
8
|
"permittedContent": ["@flow"]
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
{ "name": "data-min-capacity", "kind": "data", "purpose": "config", "type": "number", "default": "1", "description": "Floor for capacityPoints" },
|
|
14
14
|
{ "name": "data-quality-href", "kind": "data", "purpose": "config", "type": "string", "description": "Navigation fallback when iron-triangle:open-quality isn't preventDefault-ed" },
|
|
15
15
|
{ "name": "data-quality-summary", "kind": "data", "purpose": "config", "type": "string", "description": "Short summary text shown in the center's native tooltip + aria-label (e.g. '3 critical: perf, sec, a11y')" },
|
|
16
|
-
{ "name": "disabled", "kind": "native", "purpose": "config", "type": "boolean", "description": "
|
|
17
|
-
{ "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped
|
|
16
|
+
{ "name": "disabled", "kind": "native", "purpose": "config", "type": "boolean", "description": "Fully off: vertex and centre Quality targets leave the tab order (aria-disabled), openEditor() is a no-op, the mode switch and all dialog controls are disabled. setManual() / setFormula() / revise() still work for the host." },
|
|
17
|
+
{ "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped shapes: the same editing lock as disabled, but the centre Quality target still opens the quality decision (iron-triangle:open-quality / data-quality-href)" }
|
|
18
18
|
],
|
|
19
19
|
"slots": [],
|
|
20
20
|
"events": [
|
|
@@ -16,8 +16,16 @@
|
|
|
16
16
|
* markup needed in the host page. The component is form-associated;
|
|
17
17
|
* the value serializes as JSON via setFormValue().
|
|
18
18
|
*
|
|
19
|
+
* Accessibility hooks (rendered below the SVG in `.iron-triangle-status`):
|
|
20
|
+
* - an `<output aria-live="polite">` capacity readout (visually hidden)
|
|
21
|
+
* that announces capacity, mode, and any deadline warning;
|
|
22
|
+
* - the formula / manual switch as a real `<button aria-pressed>` that
|
|
23
|
+
* opens a manual-capacity dialog;
|
|
24
|
+
* - a visible "Deadline has passed" warning so the over-deadline state
|
|
25
|
+
* is never colour-only.
|
|
26
|
+
*
|
|
19
27
|
* Optional: pass a `data-quality-summary` attribute or set the
|
|
20
|
-
* `qualitySummary` property to surface the bound
|
|
28
|
+
* `qualitySummary` property to surface the bound quality-target's
|
|
21
29
|
* decision on hover (e.g. "3 critical: perf, sec, a11y").
|
|
22
30
|
*/
|
|
23
31
|
|
|
@@ -88,6 +96,12 @@ class IronTriangle extends VBElement {
|
|
|
88
96
|
#qualitySummary = '';
|
|
89
97
|
/** @type {Record<string, HTMLDialogElement>} */
|
|
90
98
|
#dialogs = {};
|
|
99
|
+
/** @type {HTMLOutputElement | null} */
|
|
100
|
+
#readout = null;
|
|
101
|
+
/** @type {HTMLButtonElement | null} */
|
|
102
|
+
#modeButton = null;
|
|
103
|
+
/** @type {HTMLElement | null} */
|
|
104
|
+
#deadlineWarning = null;
|
|
91
105
|
|
|
92
106
|
constructor() {
|
|
93
107
|
super();
|
|
@@ -100,7 +114,9 @@ class IronTriangle extends VBElement {
|
|
|
100
114
|
this.#qualitySummary = this.getAttribute('data-quality-summary') || '';
|
|
101
115
|
// Seed initial value from FIELD_DEFS so the formula has something to chew on.
|
|
102
116
|
this.#seedDefaults();
|
|
117
|
+
this.#renderStatus();
|
|
103
118
|
this.#renderViz();
|
|
119
|
+
this.#syncStatus();
|
|
104
120
|
|
|
105
121
|
this.listen(this, 'click', (e) => this.#onClick(e));
|
|
106
122
|
this.listen(this, 'keydown', (e) => this.#onKeydown(e));
|
|
@@ -155,7 +171,7 @@ class IronTriangle extends VBElement {
|
|
|
155
171
|
|
|
156
172
|
/** Imperatively open a vertex editor. */
|
|
157
173
|
openEditor(axis) {
|
|
158
|
-
if (!SECTIONS.includes(axis)) return;
|
|
174
|
+
if (!SECTIONS.includes(axis) || this.#inert) return;
|
|
159
175
|
const dialog = this.#ensureDialog(axis);
|
|
160
176
|
if (typeof dialog.showModal === 'function') dialog.showModal();
|
|
161
177
|
else dialog.setAttribute('open', '');
|
|
@@ -219,7 +235,7 @@ class IronTriangle extends VBElement {
|
|
|
219
235
|
const center = event.target.closest?.('.center[data-target="quality"]');
|
|
220
236
|
if (center && this.contains(center)) {
|
|
221
237
|
event.preventDefault();
|
|
222
|
-
this.#fireQualityOpen();
|
|
238
|
+
if (!this.hasAttribute('disabled')) this.#fireQualityOpen();
|
|
223
239
|
}
|
|
224
240
|
}
|
|
225
241
|
|
|
@@ -234,7 +250,7 @@ class IronTriangle extends VBElement {
|
|
|
234
250
|
const center = event.target.closest?.('.center[data-target="quality"]');
|
|
235
251
|
if (center && this.contains(center)) {
|
|
236
252
|
event.preventDefault();
|
|
237
|
-
this.#fireQualityOpen();
|
|
253
|
+
if (!this.hasAttribute('disabled')) this.#fireQualityOpen();
|
|
238
254
|
}
|
|
239
255
|
}
|
|
240
256
|
|
|
@@ -298,6 +314,7 @@ class IronTriangle extends VBElement {
|
|
|
298
314
|
this.#recompute({ source: 'dialog', field: axis });
|
|
299
315
|
});
|
|
300
316
|
|
|
317
|
+
this.#syncDisabledLocked();
|
|
301
318
|
return dialog;
|
|
302
319
|
}
|
|
303
320
|
|
|
@@ -363,6 +380,7 @@ class IronTriangle extends VBElement {
|
|
|
363
380
|
|
|
364
381
|
this.#renderViz();
|
|
365
382
|
this.#syncStateFlags();
|
|
383
|
+
this.#syncStatus();
|
|
366
384
|
this.#publishFormValue();
|
|
367
385
|
|
|
368
386
|
this.dispatchEvent(new CustomEvent('iron-triangle:change', {
|
|
@@ -382,6 +400,7 @@ class IronTriangle extends VBElement {
|
|
|
382
400
|
const existing = this.querySelector(':scope > svg');
|
|
383
401
|
if (existing) existing.replaceWith(svg);
|
|
384
402
|
else this.prepend(svg);
|
|
403
|
+
this.#syncHitTargetsInert();
|
|
385
404
|
this.dataset.capacityPoints = String(this.#value.capacityPoints);
|
|
386
405
|
this.dataset.capacitySource = this.#value.capacitySource;
|
|
387
406
|
}
|
|
@@ -405,11 +424,187 @@ class IronTriangle extends VBElement {
|
|
|
405
424
|
} catch { /* older Safari */ }
|
|
406
425
|
}
|
|
407
426
|
|
|
427
|
+
get #inert() { return this.hasAttribute('disabled') || this.hasAttribute('locked'); }
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* disabled / locked: no editing path stays open. styles.css already sets
|
|
431
|
+
* pointer-events: none; this closes the keyboard and programmatic paths
|
|
432
|
+
* (vertex targets leave the tab order, openEditor() is a no-op) and
|
|
433
|
+
* disables the mode switch and every dialog control, including dialogs
|
|
434
|
+
* built later.
|
|
435
|
+
*
|
|
436
|
+
* The centre Quality target is navigation, not editing, so under `locked`
|
|
437
|
+
* it stays focusable and clickable (styles.css re-enables its pointer).
|
|
438
|
+
* Under `disabled` it goes inert too: browsers suppress mouse events
|
|
439
|
+
* inside a disabled form-associated element, so keeping only its
|
|
440
|
+
* keyboard path alive would be inconsistent.
|
|
441
|
+
*/
|
|
408
442
|
#syncDisabledLocked() {
|
|
409
|
-
const off = this
|
|
443
|
+
const off = this.#inert;
|
|
444
|
+
if (this.#modeButton) this.#modeButton.disabled = off;
|
|
410
445
|
for (const dialog of Object.values(this.#dialogs)) {
|
|
411
446
|
for (const el of dialog.querySelectorAll('input, select, textarea, button')) /** @type {any} */ (el).disabled = off;
|
|
412
447
|
}
|
|
448
|
+
this.#syncHitTargetsInert();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Hit targets follow disabled / locked across re-renders. */
|
|
452
|
+
#syncHitTargetsInert() {
|
|
453
|
+
const editingOff = this.#inert;
|
|
454
|
+
const navOff = this.hasAttribute('disabled');
|
|
455
|
+
for (const target of this.querySelectorAll(':scope > svg [role="button"][tabindex]')) {
|
|
456
|
+
const off = target.matches('.center[data-target="quality"]') ? navOff : editingOff;
|
|
457
|
+
target.setAttribute('tabindex', off ? '-1' : '0');
|
|
458
|
+
if (off) target.setAttribute('aria-disabled', 'true');
|
|
459
|
+
else target.removeAttribute('aria-disabled');
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ── Internal: status strip (a11y hooks) ──────────────────────────
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Builds the status strip once: live readout, mode toggle, deadline
|
|
467
|
+
* warning. Content is refreshed by #syncStatus() on every recompute.
|
|
468
|
+
*/
|
|
469
|
+
#renderStatus() {
|
|
470
|
+
if (this.querySelector(':scope > .iron-triangle-status')) return;
|
|
471
|
+
const strip = document.createElement('div');
|
|
472
|
+
strip.className = 'iron-triangle-status';
|
|
473
|
+
|
|
474
|
+
const readout = document.createElement('output');
|
|
475
|
+
readout.className = 'iron-triangle-readout';
|
|
476
|
+
readout.setAttribute('aria-live', 'polite');
|
|
477
|
+
readout.setAttribute('aria-atomic', 'true');
|
|
478
|
+
|
|
479
|
+
const button = document.createElement('button');
|
|
480
|
+
button.type = 'button';
|
|
481
|
+
// VB's `ghost` variant keeps the switch secondary to the triangle;
|
|
482
|
+
// styles.css trims it further to sit inside the small status strip.
|
|
483
|
+
button.className = 'iron-triangle-mode ghost';
|
|
484
|
+
button.setAttribute('aria-pressed', 'false');
|
|
485
|
+
button.textContent = 'Manual capacity';
|
|
486
|
+
button.addEventListener('click', () => this.#openCapacityDialog());
|
|
487
|
+
|
|
488
|
+
const warning = document.createElement('p');
|
|
489
|
+
warning.className = 'iron-triangle-warning';
|
|
490
|
+
warning.setAttribute('data-warning', 'over-deadline');
|
|
491
|
+
warning.hidden = true;
|
|
492
|
+
|
|
493
|
+
strip.append(readout, button, warning);
|
|
494
|
+
const svg = this.querySelector(':scope > svg');
|
|
495
|
+
if (svg) svg.after(strip);
|
|
496
|
+
else this.prepend(strip);
|
|
497
|
+
|
|
498
|
+
this.#readout = readout;
|
|
499
|
+
this.#modeButton = button;
|
|
500
|
+
this.#deadlineWarning = warning;
|
|
501
|
+
this.#syncDisabledLocked();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#syncStatus() {
|
|
505
|
+
if (!this.#readout) return;
|
|
506
|
+
const { capacityPoints, capacitySource } = this.#value;
|
|
507
|
+
const manual = capacitySource === 'manual';
|
|
508
|
+
const deadline = this.#value.time?.deadline;
|
|
509
|
+
const overDeadline = this.matches(':state(over-deadline)');
|
|
510
|
+
|
|
511
|
+
const parts = [];
|
|
512
|
+
parts.push(capacityPoints > 0
|
|
513
|
+
? `Capacity: ${capacityPoints} points (${manual ? 'manual' : 'formula'}).`
|
|
514
|
+
: 'Capacity: not yet budgeted.');
|
|
515
|
+
if (overDeadline) parts.push(`Deadline has passed (${deadline}).`);
|
|
516
|
+
const text = parts.join(' ');
|
|
517
|
+
if (this.#readout.textContent !== text) this.#readout.textContent = text;
|
|
518
|
+
|
|
519
|
+
if (this.#modeButton) {
|
|
520
|
+
this.#modeButton.setAttribute('aria-pressed', manual ? 'true' : 'false');
|
|
521
|
+
// Pressed state is carried by the visible text too, not only by the
|
|
522
|
+
// underline. The aria-label starts with that visible text so voice
|
|
523
|
+
// users can say what they see (WCAG 2.5.3 Label in Name).
|
|
524
|
+
const label = manual && capacityPoints > 0 ? `Manual capacity: ${capacityPoints} pts` : 'Manual capacity';
|
|
525
|
+
if (this.#modeButton.textContent !== label) this.#modeButton.textContent = label;
|
|
526
|
+
this.#modeButton.setAttribute('aria-label', manual
|
|
527
|
+
? `${label}. Activate to edit or return to the formula.`
|
|
528
|
+
: `${label}. Activate to override the formula with a fixed number.`);
|
|
529
|
+
}
|
|
530
|
+
if (this.#deadlineWarning) {
|
|
531
|
+
this.#deadlineWarning.textContent = overDeadline ? `Deadline has passed (${deadline})` : '';
|
|
532
|
+
this.#deadlineWarning.hidden = !overDeadline;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
#openCapacityDialog() {
|
|
537
|
+
const dialog = this.#ensureCapacityDialog();
|
|
538
|
+
const input = /** @type {HTMLInputElement} */ (dialog.querySelector('[name="capacity.points"]'));
|
|
539
|
+
input.value = String(this.#value.capacityPoints > 0 ? this.#value.capacityPoints : 1);
|
|
540
|
+
const useFormula = /** @type {HTMLButtonElement} */ (dialog.querySelector('button[value="formula"]'));
|
|
541
|
+
useFormula.hidden = this.#value.capacitySource !== 'manual';
|
|
542
|
+
if (typeof dialog.showModal === 'function') dialog.showModal();
|
|
543
|
+
else dialog.setAttribute('open', '');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
#ensureCapacityDialog() {
|
|
547
|
+
if (this.#dialogs.capacity) return this.#dialogs.capacity;
|
|
548
|
+
const dialog = document.createElement('dialog');
|
|
549
|
+
dialog.className = 'iron-triangle-dialog iron-triangle-dialog--capacity';
|
|
550
|
+
dialog.setAttribute('aria-label', 'Capacity — manual override');
|
|
551
|
+
|
|
552
|
+
const form = document.createElement('form');
|
|
553
|
+
form.method = 'dialog';
|
|
554
|
+
form.setAttribute('data-layout', 'stack');
|
|
555
|
+
form.setAttribute('data-layout-gap', 'm');
|
|
556
|
+
|
|
557
|
+
const heading = document.createElement('h3');
|
|
558
|
+
heading.textContent = 'Manual capacity';
|
|
559
|
+
form.append(heading);
|
|
560
|
+
|
|
561
|
+
const hint = document.createElement('p');
|
|
562
|
+
hint.className = 'iron-triangle-dialog__hint';
|
|
563
|
+
hint.textContent = 'Fix the capacity to a number the team committed to. The Time / Cost / Scope inputs stay saved but stop driving the readout.';
|
|
564
|
+
form.append(hint);
|
|
565
|
+
|
|
566
|
+
form.append(buildFormField('capacity', {
|
|
567
|
+
key: 'points', label: 'Capacity (points)', type: 'number', min: 1, step: 1, width: '6rem',
|
|
568
|
+
}, this.#value.capacityPoints));
|
|
569
|
+
|
|
570
|
+
const actions = document.createElement('div');
|
|
571
|
+
actions.setAttribute('data-layout', 'cluster');
|
|
572
|
+
actions.setAttribute('data-layout-gap', 's');
|
|
573
|
+
actions.setAttribute('data-layout-justify', 'end');
|
|
574
|
+
const useFormula = document.createElement('button');
|
|
575
|
+
useFormula.type = 'button';
|
|
576
|
+
useFormula.value = 'formula';
|
|
577
|
+
useFormula.textContent = 'Use formula';
|
|
578
|
+
useFormula.addEventListener('click', () => {
|
|
579
|
+
dialog.close('formula');
|
|
580
|
+
this.setFormula();
|
|
581
|
+
});
|
|
582
|
+
const cancel = document.createElement('button');
|
|
583
|
+
cancel.type = 'button';
|
|
584
|
+
cancel.value = 'cancel';
|
|
585
|
+
cancel.textContent = 'Cancel';
|
|
586
|
+
cancel.addEventListener('click', () => dialog.close('cancel'));
|
|
587
|
+
const save = document.createElement('button');
|
|
588
|
+
save.type = 'submit';
|
|
589
|
+
save.value = 'save';
|
|
590
|
+
save.textContent = 'Save';
|
|
591
|
+
actions.append(useFormula, cancel, save);
|
|
592
|
+
form.append(actions);
|
|
593
|
+
|
|
594
|
+
dialog.append(form);
|
|
595
|
+
this.append(dialog);
|
|
596
|
+
this.#dialogs.capacity = dialog;
|
|
597
|
+
|
|
598
|
+
form.addEventListener('submit', (e) => {
|
|
599
|
+
e.preventDefault();
|
|
600
|
+
const input = /** @type {HTMLInputElement} */ (form.querySelector('[name="capacity.points"]'));
|
|
601
|
+
const n = Number(input.value);
|
|
602
|
+
dialog.close('save');
|
|
603
|
+
if (Number.isFinite(n) && n > 0) this.setManual(n);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
this.#syncDisabledLocked();
|
|
607
|
+
return dialog;
|
|
413
608
|
}
|
|
414
609
|
|
|
415
610
|
// ── Internal: revise() field accessors ───────────────────────────
|
|
@@ -124,11 +124,82 @@ iron-triangle svg .center:focus-visible .quality-label {
|
|
|
124
124
|
fill: var(--color-interactive);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/* ── Status strip: live readout, mode toggle, deadline warning ─── */
|
|
128
|
+
|
|
129
|
+
iron-triangle > .iron-triangle-status {
|
|
130
|
+
display: flex;
|
|
131
|
+
flex-wrap: wrap;
|
|
132
|
+
align-items: center;
|
|
133
|
+
justify-content: center;
|
|
134
|
+
gap: var(--size-s, 0.75rem);
|
|
135
|
+
margin-block-start: var(--size-xs, 0.5rem);
|
|
136
|
+
font-size: var(--font-size-sm, 0.875rem);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* The SVG already shows the number; the <output> exists for assistive
|
|
140
|
+
tech (aria-live) and stays visually hidden. */
|
|
141
|
+
iron-triangle > .iron-triangle-status .iron-triangle-readout {
|
|
142
|
+
position: absolute;
|
|
143
|
+
inline-size: 1px;
|
|
144
|
+
block-size: 1px;
|
|
145
|
+
margin: -1px;
|
|
146
|
+
padding: 0;
|
|
147
|
+
overflow: hidden;
|
|
148
|
+
clip-path: inset(50%);
|
|
149
|
+
white-space: nowrap;
|
|
150
|
+
border: 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* Quiet, secondary switch: ghost button, compact, muted until hovered or
|
|
154
|
+
pressed. VB's touch-target minimum block size is kept. Pressed adds an underline (plus the "Manual capacity: N pts" label) so
|
|
155
|
+
the state never rests on colour alone. */
|
|
156
|
+
iron-triangle > .iron-triangle-status .iron-triangle-mode {
|
|
157
|
+
font-size: inherit;
|
|
158
|
+
font-weight: var(--font-weight-normal, 400);
|
|
159
|
+
padding-block: 0;
|
|
160
|
+
padding-inline: var(--size-xs, 0.5rem);
|
|
161
|
+
border-color: transparent;
|
|
162
|
+
color: var(--color-text-muted);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
iron-triangle > .iron-triangle-status .iron-triangle-mode:hover {
|
|
166
|
+
color: var(--color-text);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
iron-triangle > .iron-triangle-status .iron-triangle-mode[aria-pressed="true"] {
|
|
170
|
+
color: var(--color-interactive);
|
|
171
|
+
text-decoration: underline;
|
|
172
|
+
text-decoration-thickness: var(--border-width-medium, 2px);
|
|
173
|
+
text-underline-offset: 0.25em;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* Visible, text-bearing warning — never colour-only. */
|
|
177
|
+
iron-triangle > .iron-triangle-status .iron-triangle-warning {
|
|
178
|
+
margin: 0;
|
|
179
|
+
padding: var(--size-2xs, 0.25rem) var(--size-xs, 0.5rem);
|
|
180
|
+
border-radius: var(--radius-s, 0.25rem);
|
|
181
|
+
border: var(--border-width-thin, 1px) solid var(--iron-triangle-warning-color, var(--color-warning));
|
|
182
|
+
color: var(--iron-triangle-warning-color, var(--color-warning));
|
|
183
|
+
font-weight: var(--font-weight-semibold, 600);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
iron-triangle > .iron-triangle-status .iron-triangle-warning::before {
|
|
187
|
+
content: "\26A0\FE0E "; /* ⚠ text presentation */
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* VB's button rules set display; keep the hidden attribute authoritative. */
|
|
191
|
+
iron-triangle > dialog.iron-triangle-dialog [hidden] { display: none; }
|
|
192
|
+
|
|
193
|
+
iron-triangle > dialog.iron-triangle-dialog .iron-triangle-dialog__hint {
|
|
194
|
+
margin: 0;
|
|
195
|
+
color: var(--color-text-muted);
|
|
196
|
+
font-size: var(--font-size-sm, 0.875rem);
|
|
197
|
+
}
|
|
198
|
+
|
|
127
199
|
/* ── State hooks ──────────────────────────────────────────────── */
|
|
128
200
|
|
|
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)"; }
|
|
201
|
+
iron-triangle:state(over-deadline) svg .triangle { stroke: var(--iron-triangle-warning-color, var(--color-warning)); }
|
|
202
|
+
iron-triangle:state(unbudgeted) svg .capacity { fill: var(--iron-triangle-warning-color, var(--color-warning)); }
|
|
132
203
|
|
|
133
204
|
iron-triangle[disabled],
|
|
134
205
|
iron-triangle[locked] {
|
|
@@ -136,6 +207,13 @@ iron-triangle[locked] {
|
|
|
136
207
|
pointer-events: none;
|
|
137
208
|
}
|
|
138
209
|
|
|
210
|
+
/* The centre Quality target navigates to the quality decision rather than
|
|
211
|
+
editing the shape, so it keeps working on a locked (read-only) triangle.
|
|
212
|
+
A disabled triangle stays fully off, like any disabled control. */
|
|
213
|
+
iron-triangle[locked]:not([disabled]) svg .center[data-target="quality"] {
|
|
214
|
+
pointer-events: auto;
|
|
215
|
+
}
|
|
216
|
+
|
|
139
217
|
/* ── Dialog chrome ────────────────────────────────────────────── */
|
|
140
218
|
|
|
141
219
|
iron-triangle > dialog.iron-triangle-dialog {
|
|
@@ -37,7 +37,28 @@ import { VBElement } from '../../lib/vb-element.js';
|
|
|
37
37
|
const MS_PER_DAY = 86_400_000;
|
|
38
38
|
|
|
39
39
|
class Roadmap extends VBElement {
|
|
40
|
+
static get observedAttributes() {
|
|
41
|
+
return ['start', 'end', 'view', 'editable', 'today-marker'];
|
|
42
|
+
}
|
|
43
|
+
|
|
40
44
|
setup() {
|
|
45
|
+
if (!this.#readConfig()) return false;
|
|
46
|
+
|
|
47
|
+
/* Snapshot original lane sections (auto-discovery) before we wipe
|
|
48
|
+
the host. We do not modify the original elements until/unless
|
|
49
|
+
drag interactions change their data-* attributes. */
|
|
50
|
+
const lanes = [...this.querySelectorAll(':scope > section[data-lane]')];
|
|
51
|
+
if (!lanes.length) {
|
|
52
|
+
console.warn('product-roadmap: no <section data-lane> children found');
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
this.#sourceLanes = lanes;
|
|
56
|
+
this.#render();
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Parse start/end/view/editable from attributes. Returns false when invalid. */
|
|
61
|
+
#readConfig() {
|
|
41
62
|
const startAttr = this.getAttribute('start');
|
|
42
63
|
const endAttr = this.getAttribute('end');
|
|
43
64
|
if (!startAttr || !endAttr) {
|
|
@@ -55,22 +76,13 @@ class Roadmap extends VBElement {
|
|
|
55
76
|
this.#totalMs = end.getTime() - start.getTime();
|
|
56
77
|
this.#view = (this.getAttribute('view') === 'month') ? 'month' : 'quarter';
|
|
57
78
|
this.#editable = this.hasAttribute('editable');
|
|
58
|
-
|
|
59
|
-
/* Snapshot original lane sections (auto-discovery) before we wipe
|
|
60
|
-
the host. We do not modify the original elements until/unless
|
|
61
|
-
drag interactions change their data-* attributes. */
|
|
62
|
-
const lanes = [...this.querySelectorAll(':scope > section[data-lane]')];
|
|
63
|
-
if (!lanes.length) {
|
|
64
|
-
console.warn('product-roadmap: no <section data-lane> children found');
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
this.#sourceLanes = lanes;
|
|
68
|
-
this.#render();
|
|
69
79
|
return true;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
attributeChangedCallback() {
|
|
73
|
-
if (
|
|
82
|
+
attributeChangedCallback(_name, oldValue, newValue) {
|
|
83
|
+
if (oldValue === newValue) return;
|
|
84
|
+
if (!this.isConnected || !this.#sourceLanes.length) return;
|
|
85
|
+
if (this.#readConfig()) this.#render();
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
#render() {
|
|
@@ -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": [
|