@profpowell/vb-project-planning 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@profpowell/vb-project-planning",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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": "Patrick Powell",
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.1.0"
70
+ "vanilla-breeze": ">=0.3.0"
70
71
  },
71
72
  "peerDependenciesMeta": {
72
73
  "vanilla-breeze": {
@@ -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"]
@@ -16,6 +16,14 @@
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
28
  * `qualitySummary` property to surface the bound NFR compass's
21
29
  * decision on hover (e.g. "3 critical: perf, sec, a11y").
@@ -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));
@@ -363,6 +379,7 @@ class IronTriangle extends VBElement {
363
379
 
364
380
  this.#renderViz();
365
381
  this.#syncStateFlags();
382
+ this.#syncStatus();
366
383
  this.#publishFormValue();
367
384
 
368
385
  this.dispatchEvent(new CustomEvent('iron-triangle:change', {
@@ -407,11 +424,151 @@ class IronTriangle extends VBElement {
407
424
 
408
425
  #syncDisabledLocked() {
409
426
  const off = this.hasAttribute('disabled') || this.hasAttribute('locked');
427
+ if (this.#modeButton) this.#modeButton.disabled = off;
410
428
  for (const dialog of Object.values(this.#dialogs)) {
411
429
  for (const el of dialog.querySelectorAll('input, select, textarea, button')) /** @type {any} */ (el).disabled = off;
412
430
  }
413
431
  }
414
432
 
433
+ // ── Internal: status strip (a11y hooks) ──────────────────────────
434
+
435
+ /**
436
+ * Builds the status strip once: live readout, mode toggle, deadline
437
+ * warning. Content is refreshed by #syncStatus() on every recompute.
438
+ */
439
+ #renderStatus() {
440
+ if (this.querySelector(':scope > .iron-triangle-status')) return;
441
+ const strip = document.createElement('div');
442
+ strip.className = 'iron-triangle-status';
443
+
444
+ const readout = document.createElement('output');
445
+ readout.className = 'iron-triangle-readout';
446
+ readout.setAttribute('aria-live', 'polite');
447
+ readout.setAttribute('aria-atomic', 'true');
448
+
449
+ const button = document.createElement('button');
450
+ button.type = 'button';
451
+ button.className = 'iron-triangle-mode';
452
+ button.setAttribute('aria-pressed', 'false');
453
+ button.textContent = 'Manual capacity';
454
+ button.addEventListener('click', () => this.#openCapacityDialog());
455
+
456
+ const warning = document.createElement('p');
457
+ warning.className = 'iron-triangle-warning';
458
+ warning.setAttribute('data-warning', 'over-deadline');
459
+ warning.hidden = true;
460
+
461
+ strip.append(readout, button, warning);
462
+ const svg = this.querySelector(':scope > svg');
463
+ if (svg) svg.after(strip);
464
+ else this.prepend(strip);
465
+
466
+ this.#readout = readout;
467
+ this.#modeButton = button;
468
+ this.#deadlineWarning = warning;
469
+ this.#syncDisabledLocked();
470
+ }
471
+
472
+ #syncStatus() {
473
+ if (!this.#readout) return;
474
+ const { capacityPoints, capacitySource } = this.#value;
475
+ const manual = capacitySource === 'manual';
476
+ const deadline = this.#value.time?.deadline;
477
+ const overDeadline = this.matches(':state(over-deadline)');
478
+
479
+ const parts = [];
480
+ parts.push(capacityPoints > 0
481
+ ? `Capacity: ${capacityPoints} points (${manual ? 'manual' : 'formula'}).`
482
+ : 'Capacity: not yet budgeted.');
483
+ if (overDeadline) parts.push(`Deadline has passed (${deadline}).`);
484
+ const text = parts.join(' ');
485
+ if (this.#readout.textContent !== text) this.#readout.textContent = text;
486
+
487
+ if (this.#modeButton) {
488
+ 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.');
492
+ }
493
+ if (this.#deadlineWarning) {
494
+ this.#deadlineWarning.textContent = overDeadline ? `Deadline has passed (${deadline})` : '';
495
+ this.#deadlineWarning.hidden = !overDeadline;
496
+ }
497
+ }
498
+
499
+ #openCapacityDialog() {
500
+ const dialog = this.#ensureCapacityDialog();
501
+ const input = /** @type {HTMLInputElement} */ (dialog.querySelector('[name="capacity.points"]'));
502
+ input.value = String(this.#value.capacityPoints > 0 ? this.#value.capacityPoints : 1);
503
+ const useFormula = /** @type {HTMLButtonElement} */ (dialog.querySelector('button[value="formula"]'));
504
+ useFormula.hidden = this.#value.capacitySource !== 'manual';
505
+ if (typeof dialog.showModal === 'function') dialog.showModal();
506
+ else dialog.setAttribute('open', '');
507
+ }
508
+
509
+ #ensureCapacityDialog() {
510
+ if (this.#dialogs.capacity) return this.#dialogs.capacity;
511
+ const dialog = document.createElement('dialog');
512
+ dialog.className = 'iron-triangle-dialog iron-triangle-dialog--capacity';
513
+ dialog.setAttribute('aria-label', 'Capacity — manual override');
514
+
515
+ const form = document.createElement('form');
516
+ form.method = 'dialog';
517
+ form.setAttribute('data-layout', 'stack');
518
+ form.setAttribute('data-layout-gap', 'm');
519
+
520
+ const heading = document.createElement('h3');
521
+ heading.textContent = 'Manual capacity';
522
+ form.append(heading);
523
+
524
+ const hint = document.createElement('p');
525
+ hint.className = 'iron-triangle-dialog__hint';
526
+ hint.textContent = 'Fix the capacity to a number the team committed to. The Time / Cost / Scope inputs stay saved but stop driving the readout.';
527
+ form.append(hint);
528
+
529
+ form.append(buildFormField('capacity', {
530
+ key: 'points', label: 'Capacity (points)', type: 'number', min: 1, step: 1, width: '6rem',
531
+ }, this.#value.capacityPoints));
532
+
533
+ const actions = document.createElement('div');
534
+ actions.setAttribute('data-layout', 'cluster');
535
+ actions.setAttribute('data-layout-gap', 's');
536
+ actions.setAttribute('data-layout-justify', 'end');
537
+ const useFormula = document.createElement('button');
538
+ useFormula.type = 'button';
539
+ useFormula.value = 'formula';
540
+ useFormula.textContent = 'Use formula';
541
+ useFormula.addEventListener('click', () => {
542
+ dialog.close('formula');
543
+ this.setFormula();
544
+ });
545
+ const cancel = document.createElement('button');
546
+ cancel.type = 'button';
547
+ cancel.value = 'cancel';
548
+ cancel.textContent = 'Cancel';
549
+ cancel.addEventListener('click', () => dialog.close('cancel'));
550
+ const save = document.createElement('button');
551
+ save.type = 'submit';
552
+ save.value = 'save';
553
+ save.textContent = 'Save';
554
+ actions.append(useFormula, cancel, save);
555
+ form.append(actions);
556
+
557
+ dialog.append(form);
558
+ this.append(dialog);
559
+ this.#dialogs.capacity = dialog;
560
+
561
+ form.addEventListener('submit', (e) => {
562
+ e.preventDefault();
563
+ const input = /** @type {HTMLInputElement} */ (form.querySelector('[name="capacity.points"]'));
564
+ const n = Number(input.value);
565
+ dialog.close('save');
566
+ if (Number.isFinite(n) && n > 0) this.setManual(n);
567
+ });
568
+
569
+ return dialog;
570
+ }
571
+
415
572
  // ── Internal: revise() field accessors ───────────────────────────
416
573
 
417
574
  #getField(path) {
@@ -124,11 +124,68 @@ 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
+ iron-triangle > .iron-triangle-status .iron-triangle-mode {
154
+ font-size: inherit;
155
+ }
156
+
157
+ iron-triangle > .iron-triangle-status .iron-triangle-mode[aria-pressed="true"] {
158
+ border-color: var(--color-interactive);
159
+ color: var(--color-interactive);
160
+ }
161
+
162
+ /* Visible, text-bearing warning — never colour-only. */
163
+ iron-triangle > .iron-triangle-status .iron-triangle-warning {
164
+ margin: 0;
165
+ padding: var(--size-2xs, 0.25rem) var(--size-xs, 0.5rem);
166
+ border-radius: var(--radius-s, 0.25rem);
167
+ border: var(--border-width-thin, 1px) solid var(--iron-triangle-warning-color, var(--color-warning));
168
+ color: var(--iron-triangle-warning-color, var(--color-warning));
169
+ font-weight: var(--font-weight-semibold, 600);
170
+ }
171
+
172
+ iron-triangle > .iron-triangle-status .iron-triangle-warning::before {
173
+ content: "\26A0\FE0E "; /* ⚠ text presentation */
174
+ }
175
+
176
+ /* VB's button rules set display; keep the hidden attribute authoritative. */
177
+ iron-triangle > dialog.iron-triangle-dialog [hidden] { display: none; }
178
+
179
+ iron-triangle > dialog.iron-triangle-dialog .iron-triangle-dialog__hint {
180
+ margin: 0;
181
+ color: var(--color-text-muted);
182
+ font-size: var(--font-size-sm, 0.875rem);
183
+ }
184
+
127
185
  /* ── State hooks ──────────────────────────────────────────────── */
128
186
 
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)"; }
187
+ iron-triangle:state(over-deadline) svg .triangle { stroke: var(--iron-triangle-warning-color, var(--color-warning)); }
188
+ iron-triangle:state(unbudgeted) svg .capacity { fill: var(--iron-triangle-warning-color, var(--color-warning)); }
132
189
 
133
190
  iron-triangle[disabled],
134
191
  iron-triangle[locked] {
@@ -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 (this.isConnected && this.#sourceLanes.length) this.#render();
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() {
@@ -125,7 +125,7 @@ class RequirementCard extends VBElement {
125
125
  }
126
126
 
127
127
  #syncSlot(name, slot) {
128
- const hasContent = !!slot && slot.assignedNodes({ flatten: true }).some(n =>
128
+ const hasContent = !!slot && slot.assignedNodes().some(n =>
129
129
  n.nodeType === Node.ELEMENT_NODE ||
130
130
  (n.nodeType === Node.TEXT_NODE && n.textContent.trim().length > 0)
131
131
  );
@@ -105,7 +105,7 @@ class UserPersona extends HTMLElement {
105
105
  if (!this.id) return [];
106
106
  const root = /** @type {ParentNode} */ (this.getRootNode());
107
107
  const scope = /** @type {ParentNode} */ (/** @type {any} */ (root).querySelectorAll ? root : document);
108
- return Array.from(scope.querySelectorAll(`user-story[persona-id="${cssEscape(this.id)}"]`));
108
+ return Array.from(scope.querySelectorAll(`user-story[persona-id="${cssEscape(this.id)}"]:not([data-detail-clone])`));
109
109
  }
110
110
 
111
111
  #cacheSlotValues() {
@@ -210,7 +210,7 @@ class UserStory extends HTMLElement {
210
210
 
211
211
  connectedCallback() {
212
212
  this.#cacheSlotValues();
213
- if (this.storyId && !this.id) this.id = this.storyId;
213
+ if (this.storyId && !this.id && !this.hasAttribute('data-detail-clone')) this.id = this.storyId;
214
214
  if (this.hasAttribute('src')) {
215
215
  this._loadSrc(this.getAttribute('src'));
216
216
  }
@@ -410,6 +410,7 @@ class UserStory extends HTMLElement {
410
410
  const hasSlottedContent = [...this.children].some(c => c.getAttribute('slot') && c.tagName !== 'DIALOG');
411
411
  full.setAttribute('detail', hasSlottedContent ? 'full' : 'compact');
412
412
  full.removeAttribute('id');
413
+ full.setAttribute('data-detail-clone', ''); // keep the clone out of id/persona lookups
413
414
  for (const child of [...this.children]) {
414
415
  if (child.tagName === 'DIALOG') continue;
415
416
  full.appendChild(child.cloneNode(true));