@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/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.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": "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": {
@@ -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 <nfr-compass>'s
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 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.
@@ -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": "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": [
@@ -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 NFR compass's
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.hasAttribute('disabled') || this.hasAttribute('locked');
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 (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() {
@@ -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": "All inputs disabled" },
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": [