@profpowell/vb-project-planning 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +5 -2
  2. package/dist/vb-project-planning.css +70 -3
  3. package/dist/vb-project-planning.js +3333 -2565
  4. package/dist/vb-project-planning.min.css +1 -1
  5. package/dist/vb-project-planning.min.js +224 -200
  6. package/package.json +7 -4
  7. package/src/lib/board-surface.js +158 -0
  8. package/src/lib/bundle-registry.js +2 -75
  9. package/src/lib/dates.js +125 -0
  10. package/src/lib/escape.js +34 -0
  11. package/src/lib/hash.js +16 -0
  12. package/src/lib/portrait-url.js +3 -29
  13. package/src/lib/src-loader.js +78 -0
  14. package/src/utils/copy-init.js +56 -17
  15. package/src/web-components/_ux-base.js +409 -24
  16. package/src/web-components/adr-wc/README.md +18 -16
  17. package/src/web-components/adr-wc/api.json +7 -5
  18. package/src/web-components/adr-wc/logic.js +50 -136
  19. package/src/web-components/burndown-chart/api.json +8 -6
  20. package/src/web-components/burndown-chart/logic.js +75 -39
  21. package/src/web-components/empathy-map/api.json +13 -13
  22. package/src/web-components/empathy-map/logic.js +161 -168
  23. package/src/web-components/empathy-map/static.html +3 -3
  24. package/src/web-components/empathy-map/styles.js +1 -0
  25. package/src/web-components/gantt-chart/README.md +12 -6
  26. package/src/web-components/gantt-chart/api.json +23 -7
  27. package/src/web-components/gantt-chart/logic.js +456 -431
  28. package/src/web-components/gantt-chart/styles.css +9 -1
  29. package/src/web-components/impact-effort/api.json +9 -7
  30. package/src/web-components/impact-effort/logic.js +238 -209
  31. package/src/web-components/impact-effort/static.html +12 -4
  32. package/src/web-components/iron-triangle/_capacity.js +1 -19
  33. package/src/web-components/iron-triangle/_triangle-geometry.js +1 -1
  34. package/src/web-components/iron-triangle/api.json +2 -2
  35. package/src/web-components/iron-triangle/logic.js +47 -9
  36. package/src/web-components/iron-triangle/styles.css +22 -1
  37. package/src/web-components/kanban-board/api.json +8 -6
  38. package/src/web-components/kanban-board/logic.js +289 -333
  39. package/src/web-components/product-roadmap/api.json +14 -13
  40. package/src/web-components/product-roadmap/logic.js +393 -246
  41. package/src/web-components/product-roadmap/styles.css +40 -0
  42. package/src/web-components/quadrant-grid/api.json +9 -4
  43. package/src/web-components/quadrant-grid/logic.js +80 -12
  44. package/src/web-components/quadrant-grid/styles.css +7 -0
  45. package/src/web-components/quality-target/_quality-utils.js +2 -46
  46. package/src/web-components/quality-target/api.json +2 -3
  47. package/src/web-components/quality-target/logic.js +85 -13
  48. package/src/web-components/requirement-card/api.json +1 -1
  49. package/src/web-components/requirement-card/logic.js +26 -2
  50. package/src/web-components/requirement-card/static.html +1 -1
  51. package/src/web-components/review-surface/README.md +9 -5
  52. package/src/web-components/review-surface/api.json +10 -8
  53. package/src/web-components/review-surface/logic.js +349 -218
  54. package/src/web-components/risk-register/api.json +1 -6
  55. package/src/web-components/risk-register/logic.js +71 -24
  56. package/src/web-components/story-map/api.json +8 -6
  57. package/src/web-components/story-map/logic.js +280 -281
  58. package/src/web-components/story-map/static.html +15 -5
  59. package/src/web-components/traceability-matrix/api.json +10 -3
  60. package/src/web-components/traceability-matrix/logic.js +112 -63
  61. package/src/web-components/traceability-matrix/styles.css +14 -1
  62. package/src/web-components/user-journey/api.json +25 -24
  63. package/src/web-components/user-journey/logic.js +76 -127
  64. package/src/web-components/user-journey/static.html +2 -2
  65. package/src/web-components/user-persona/api.json +15 -14
  66. package/src/web-components/user-persona/logic.js +71 -117
  67. package/src/web-components/user-persona/styles.js +1 -0
  68. package/src/web-components/user-story/api.json +13 -11
  69. package/src/web-components/user-story/logic.js +164 -195
  70. package/src/web-components/user-story/styles.js +28 -1
  71. package/src/web-components/work-item/README.md +3 -3
  72. package/src/web-components/work-item/api.json +13 -11
  73. package/src/web-components/work-item/logic.js +58 -154
@@ -19,28 +19,10 @@ export function defaultFormula(time, cost, focusFactor) {
19
19
  return Math.ceil(weeks * fte * f);
20
20
  }
21
21
 
22
- /**
23
- * Format the formula string used as the human-readable explanation
24
- * shown beneath the readout. Matches the formula above; if either
25
- * factor is missing the string degrades gracefully.
26
- */
27
- export function defaultFormulaText(time, cost, focusFactor) {
28
- const sprintCount = num(time?.sprintCount);
29
- const sprintWeeks = num(time?.sprintWeeks) || 1;
30
- const totalWeeks = sprintCount > 0 ? sprintCount * sprintWeeks : sprintWeeks;
31
- const fte = num(cost?.teamFTE);
32
- const f = clamp(num(focusFactor), 0, 1);
33
- const points = defaultFormula(time, cost, focusFactor);
34
- if (!points) return 'Set sprint length, team size, and focus factor to compute capacity.';
35
- const weeksLabel = sprintCount > 0
36
- ? `${sprintCount} × ${sprintWeeks}wk = ${totalWeeks}wk`
37
- : `${sprintWeeks}wk`;
38
- return `${weeksLabel} × ${fte} FTE × ${f} = ${(totalWeeks * fte * f).toFixed(1)} → ${points} points`;
39
- }
40
22
 
41
23
  /**
42
24
  * FNV-1a (32-bit) hash over a stable JSON serialization of the triangle.
43
- * Used for drift detection by downstream consumers (notably <nfr-compass>'s
25
+ * Used for drift detection by downstream consumers (notably <quality-target>'s
44
26
  * ironTriangleHash field).
45
27
  *
46
28
  * 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 NFR compass.`,
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.
@@ -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": "All inputs disabled" },
17
- { "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped vectors" }
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": [
@@ -25,7 +25,7 @@
25
25
  * is never colour-only.
26
26
  *
27
27
  * Optional: pass a `data-quality-summary` attribute or set the
28
- * `qualitySummary` property to surface the bound NFR compass's
28
+ * `qualitySummary` property to surface the bound quality-target's
29
29
  * decision on hover (e.g. "3 critical: perf, sec, a11y").
30
30
  */
31
31
 
@@ -171,7 +171,7 @@ class IronTriangle extends VBElement {
171
171
 
172
172
  /** Imperatively open a vertex editor. */
173
173
  openEditor(axis) {
174
- if (!SECTIONS.includes(axis)) return;
174
+ if (!SECTIONS.includes(axis) || this.#inert) return;
175
175
  const dialog = this.#ensureDialog(axis);
176
176
  if (typeof dialog.showModal === 'function') dialog.showModal();
177
177
  else dialog.setAttribute('open', '');
@@ -235,7 +235,7 @@ class IronTriangle extends VBElement {
235
235
  const center = event.target.closest?.('.center[data-target="quality"]');
236
236
  if (center && this.contains(center)) {
237
237
  event.preventDefault();
238
- this.#fireQualityOpen();
238
+ if (!this.hasAttribute('disabled')) this.#fireQualityOpen();
239
239
  }
240
240
  }
241
241
 
@@ -250,7 +250,7 @@ class IronTriangle extends VBElement {
250
250
  const center = event.target.closest?.('.center[data-target="quality"]');
251
251
  if (center && this.contains(center)) {
252
252
  event.preventDefault();
253
- this.#fireQualityOpen();
253
+ if (!this.hasAttribute('disabled')) this.#fireQualityOpen();
254
254
  }
255
255
  }
256
256
 
@@ -314,6 +314,7 @@ class IronTriangle extends VBElement {
314
314
  this.#recompute({ source: 'dialog', field: axis });
315
315
  });
316
316
 
317
+ this.#syncDisabledLocked();
317
318
  return dialog;
318
319
  }
319
320
 
@@ -399,6 +400,7 @@ class IronTriangle extends VBElement {
399
400
  const existing = this.querySelector(':scope > svg');
400
401
  if (existing) existing.replaceWith(svg);
401
402
  else this.prepend(svg);
403
+ this.#syncHitTargetsInert();
402
404
  this.dataset.capacityPoints = String(this.#value.capacityPoints);
403
405
  this.dataset.capacitySource = this.#value.capacitySource;
404
406
  }
@@ -422,12 +424,40 @@ class IronTriangle extends VBElement {
422
424
  } catch { /* older Safari */ }
423
425
  }
424
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
+ */
425
442
  #syncDisabledLocked() {
426
- const off = this.hasAttribute('disabled') || this.hasAttribute('locked');
443
+ const off = this.#inert;
427
444
  if (this.#modeButton) this.#modeButton.disabled = off;
428
445
  for (const dialog of Object.values(this.#dialogs)) {
429
446
  for (const el of dialog.querySelectorAll('input, select, textarea, button')) /** @type {any} */ (el).disabled = off;
430
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
+ }
431
461
  }
432
462
 
433
463
  // ── Internal: status strip (a11y hooks) ──────────────────────────
@@ -448,7 +478,9 @@ class IronTriangle extends VBElement {
448
478
 
449
479
  const button = document.createElement('button');
450
480
  button.type = 'button';
451
- button.className = 'iron-triangle-mode';
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';
452
484
  button.setAttribute('aria-pressed', 'false');
453
485
  button.textContent = 'Manual capacity';
454
486
  button.addEventListener('click', () => this.#openCapacityDialog());
@@ -486,9 +518,14 @@ class IronTriangle extends VBElement {
486
518
 
487
519
  if (this.#modeButton) {
488
520
  this.#modeButton.setAttribute('aria-pressed', manual ? 'true' : 'false');
489
- this.#modeButton.setAttribute('aria-label',
490
- manual ? `Manual capacity: ${capacityPoints} points. Activate to edit or return to the formula.`
491
- : 'Manual capacity. Activate to override the formula with a fixed number.');
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.`);
492
529
  }
493
530
  if (this.#deadlineWarning) {
494
531
  this.#deadlineWarning.textContent = overDeadline ? `Deadline has passed (${deadline})` : '';
@@ -566,6 +603,7 @@ class IronTriangle extends VBElement {
566
603
  if (Number.isFinite(n) && n > 0) this.setManual(n);
567
604
  });
568
605
 
606
+ this.#syncDisabledLocked();
569
607
  return dialog;
570
608
  }
571
609
 
@@ -150,13 +150,27 @@ iron-triangle > .iron-triangle-status .iron-triangle-readout {
150
150
  border: 0;
151
151
  }
152
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. */
153
156
  iron-triangle > .iron-triangle-status .iron-triangle-mode {
154
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);
155
167
  }
156
168
 
157
169
  iron-triangle > .iron-triangle-status .iron-triangle-mode[aria-pressed="true"] {
158
- border-color: var(--color-interactive);
159
170
  color: var(--color-interactive);
171
+ text-decoration: underline;
172
+ text-decoration-thickness: var(--border-width-medium, 2px);
173
+ text-underline-offset: 0.25em;
160
174
  }
161
175
 
162
176
  /* Visible, text-bearing warning — never colour-only. */
@@ -193,6 +207,13 @@ iron-triangle[locked] {
193
207
  pointer-events: none;
194
208
  }
195
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
+
196
217
  /* ── Dialog chrome ────────────────────────────────────────────── */
197
218
 
198
219
  iron-triangle > dialog.iron-triangle-dialog {
@@ -5,8 +5,8 @@
5
5
  "description": "Columnar drag-and-drop board with user-defined columns, count badges, and optional WIP limits",
6
6
  "htmlvalidate": { "flow": true, "permittedContent": ["section"] },
7
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" },
8
+ { "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON data for columns and items. Fetched once per value; a new value replaces the board and aborts an older in-flight request" },
9
+ { "name": "title", "kind": "host-api", "purpose": "config", "type": "string", "description": "Optional heading above the board; also names the board region" },
10
10
  { "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Reduced spacing variant" }
11
11
  ],
12
12
  "childAttributes": [
@@ -16,9 +16,11 @@
16
16
  { "name": "data-column-color", "on": "section", "type": "string", "description": "Color token for column tint (success, warning, error, info)" }
17
17
  ],
18
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" }
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 once after the first render. With src, fired once the JSON has loaded and rendered. Once per element: not re-fired by a later src change (watch items-changed with source 'src') or when the board is moved in the DOM" },
22
+ { "name": "kanban-board:wip-exceeded", "detail": "{ column, limit, count }", "description": "Fired when a drop takes a column over its WIP limit" },
23
+ { "name": "kanban-board:items-changed", "detail": "{ items, source: 'api' | 'drag' | 'src' }", "description": "Fired after the items array changes: an .items assignment, or a .columns assignment that drops cards (source 'api'), a drag (source 'drag'), or each src load once its data has rendered (source 'src'). Filter on source to avoid feedback loops" },
24
+ { "name": "kanban-board:upgraded", "detail": "", "description": "Fired (in a microtask) each time the board connects and upgrades; a safe signal for assigning .columns / .items from a framework" }
23
25
  ]
24
26
  }