@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.
@@ -527,7 +527,7 @@ var UserPersona = class extends HTMLElement {
527
527
  /** @type {any} */
528
528
  root.querySelectorAll ? root : document
529
529
  );
530
- return Array.from(scope.querySelectorAll(`user-story[persona-id="${cssEscape(this.id)}"]`));
530
+ return Array.from(scope.querySelectorAll(`user-story[persona-id="${cssEscape(this.id)}"]:not([data-detail-clone])`));
531
531
  }
532
532
  #cacheSlotValues() {
533
533
  for (const child of [...this.children]) {
@@ -1362,7 +1362,7 @@ var UserStory = class _UserStory extends HTMLElement {
1362
1362
  }
1363
1363
  connectedCallback() {
1364
1364
  this.#cacheSlotValues();
1365
- if (this.storyId && !this.id) this.id = this.storyId;
1365
+ if (this.storyId && !this.id && !this.hasAttribute("data-detail-clone")) this.id = this.storyId;
1366
1366
  if (this.hasAttribute("src")) {
1367
1367
  this._loadSrc(this.getAttribute("src"));
1368
1368
  }
@@ -1545,6 +1545,7 @@ var UserStory = class _UserStory extends HTMLElement {
1545
1545
  const hasSlottedContent = [...this.children].some((c) => c.getAttribute("slot") && c.tagName !== "DIALOG");
1546
1546
  full.setAttribute("detail", hasSlottedContent ? "full" : "compact");
1547
1547
  full.removeAttribute("id");
1548
+ full.setAttribute("data-detail-clone", "");
1548
1549
  for (const child of [...this.children]) {
1549
1550
  if (child.tagName === "DIALOG") continue;
1550
1551
  full.appendChild(child.cloneNode(true));
@@ -4388,7 +4389,22 @@ registerComponent("burndown-chart", BurndownChart);
4388
4389
 
4389
4390
  // src/web-components/product-roadmap/logic.js
4390
4391
  var Roadmap = class _Roadmap extends VBElement {
4392
+ static get observedAttributes() {
4393
+ return ["start", "end", "view", "editable", "today-marker"];
4394
+ }
4391
4395
  setup() {
4396
+ if (!this.#readConfig()) return false;
4397
+ const lanes = [...this.querySelectorAll(":scope > section[data-lane]")];
4398
+ if (!lanes.length) {
4399
+ console.warn("product-roadmap: no <section data-lane> children found");
4400
+ return false;
4401
+ }
4402
+ this.#sourceLanes = lanes;
4403
+ this.#render();
4404
+ return true;
4405
+ }
4406
+ /** Parse start/end/view/editable from attributes. Returns false when invalid. */
4407
+ #readConfig() {
4392
4408
  const startAttr = this.getAttribute("start");
4393
4409
  const endAttr = this.getAttribute("end");
4394
4410
  if (!startAttr || !endAttr) {
@@ -4406,17 +4422,12 @@ var Roadmap = class _Roadmap extends VBElement {
4406
4422
  this.#totalMs = end.getTime() - start.getTime();
4407
4423
  this.#view = this.getAttribute("view") === "month" ? "month" : "quarter";
4408
4424
  this.#editable = this.hasAttribute("editable");
4409
- const lanes = [...this.querySelectorAll(":scope > section[data-lane]")];
4410
- if (!lanes.length) {
4411
- console.warn("product-roadmap: no <section data-lane> children found");
4412
- return false;
4413
- }
4414
- this.#sourceLanes = lanes;
4415
- this.#render();
4416
4425
  return true;
4417
4426
  }
4418
- attributeChangedCallback() {
4419
- if (this.isConnected && this.#sourceLanes.length) this.#render();
4427
+ attributeChangedCallback(_name, oldValue, newValue) {
4428
+ if (oldValue === newValue) return;
4429
+ if (!this.isConnected || !this.#sourceLanes.length) return;
4430
+ if (this.#readConfig()) this.#render();
4420
4431
  }
4421
4432
  #render() {
4422
4433
  [...this.children].forEach((c) => {
@@ -9339,7 +9350,7 @@ function buildTriangleSvg({
9339
9350
  center.setAttribute("role", "button");
9340
9351
  center.setAttribute(
9341
9352
  "aria-label",
9342
- `Quality \u2014 ${formatQualitySummary(qualitySummary)}. Activate to open the NFR compass.`
9353
+ `Quality \u2014 ${formatQualitySummary(qualitySummary)}. Activate to open the quality decision.`
9343
9354
  );
9344
9355
  const backdrop = document.createElementNS(SVG_NS, "circle");
9345
9356
  backdrop.setAttribute("class", "capacity-backdrop");
@@ -9502,6 +9513,12 @@ var IronTriangle = class extends VBElement {
9502
9513
  #qualitySummary = "";
9503
9514
  /** @type {Record<string, HTMLDialogElement>} */
9504
9515
  #dialogs = {};
9516
+ /** @type {HTMLOutputElement | null} */
9517
+ #readout = null;
9518
+ /** @type {HTMLButtonElement | null} */
9519
+ #modeButton = null;
9520
+ /** @type {HTMLElement | null} */
9521
+ #deadlineWarning = null;
9505
9522
  constructor() {
9506
9523
  super();
9507
9524
  this.#internals = this.attachInternals();
@@ -9510,7 +9527,9 @@ var IronTriangle = class extends VBElement {
9510
9527
  setup() {
9511
9528
  this.#qualitySummary = this.getAttribute("data-quality-summary") || "";
9512
9529
  this.#seedDefaults();
9530
+ this.#renderStatus();
9513
9531
  this.#renderViz();
9532
+ this.#syncStatus();
9514
9533
  this.listen(this, "click", (e) => this.#onClick(e));
9515
9534
  this.listen(this, "keydown", (e) => this.#onKeydown(e));
9516
9535
  }
@@ -9568,7 +9587,7 @@ var IronTriangle = class extends VBElement {
9568
9587
  }
9569
9588
  /** Imperatively open a vertex editor. */
9570
9589
  openEditor(axis) {
9571
- if (!SECTIONS.includes(axis)) return;
9590
+ if (!SECTIONS.includes(axis) || this.#inert) return;
9572
9591
  const dialog = this.#ensureDialog(axis);
9573
9592
  if (typeof dialog.showModal === "function") dialog.showModal();
9574
9593
  else dialog.setAttribute("open", "");
@@ -9634,7 +9653,7 @@ var IronTriangle = class extends VBElement {
9634
9653
  const center = event.target.closest?.('.center[data-target="quality"]');
9635
9654
  if (center && this.contains(center)) {
9636
9655
  event.preventDefault();
9637
- this.#fireQualityOpen();
9656
+ if (!this.hasAttribute("disabled")) this.#fireQualityOpen();
9638
9657
  }
9639
9658
  }
9640
9659
  #onKeydown(event) {
@@ -9648,7 +9667,7 @@ var IronTriangle = class extends VBElement {
9648
9667
  const center = event.target.closest?.('.center[data-target="quality"]');
9649
9668
  if (center && this.contains(center)) {
9650
9669
  event.preventDefault();
9651
- this.#fireQualityOpen();
9670
+ if (!this.hasAttribute("disabled")) this.#fireQualityOpen();
9652
9671
  }
9653
9672
  }
9654
9673
  #fireQualityOpen() {
@@ -9704,6 +9723,7 @@ var IronTriangle = class extends VBElement {
9704
9723
  dialog.close("save");
9705
9724
  this.#recompute({ source: "dialog", field: axis });
9706
9725
  });
9726
+ this.#syncDisabledLocked();
9707
9727
  return dialog;
9708
9728
  }
9709
9729
  #captureDialog(axis, form) {
@@ -9765,6 +9785,7 @@ var IronTriangle = class extends VBElement {
9765
9785
  this.#value.hash = triangleHash(this.#value);
9766
9786
  this.#renderViz();
9767
9787
  this.#syncStateFlags();
9788
+ this.#syncStatus();
9768
9789
  this.#publishFormValue();
9769
9790
  this.dispatchEvent(new CustomEvent("iron-triangle:change", {
9770
9791
  bubbles: true,
@@ -9783,6 +9804,7 @@ var IronTriangle = class extends VBElement {
9783
9804
  const existing = this.querySelector(":scope > svg");
9784
9805
  if (existing) existing.replaceWith(svg);
9785
9806
  else this.prepend(svg);
9807
+ this.#syncHitTargetsInert();
9786
9808
  this.dataset.capacityPoints = String(this.#value.capacityPoints);
9787
9809
  this.dataset.capacitySource = this.#value.capacitySource;
9788
9810
  }
@@ -9804,11 +9826,172 @@ var IronTriangle = class extends VBElement {
9804
9826
  } catch {
9805
9827
  }
9806
9828
  }
9829
+ get #inert() {
9830
+ return this.hasAttribute("disabled") || this.hasAttribute("locked");
9831
+ }
9832
+ /**
9833
+ * disabled / locked: no editing path stays open. styles.css already sets
9834
+ * pointer-events: none; this closes the keyboard and programmatic paths
9835
+ * (vertex targets leave the tab order, openEditor() is a no-op) and
9836
+ * disables the mode switch and every dialog control, including dialogs
9837
+ * built later.
9838
+ *
9839
+ * The centre Quality target is navigation, not editing, so under `locked`
9840
+ * it stays focusable and clickable (styles.css re-enables its pointer).
9841
+ * Under `disabled` it goes inert too: browsers suppress mouse events
9842
+ * inside a disabled form-associated element, so keeping only its
9843
+ * keyboard path alive would be inconsistent.
9844
+ */
9807
9845
  #syncDisabledLocked() {
9808
- const off = this.hasAttribute("disabled") || this.hasAttribute("locked");
9846
+ const off = this.#inert;
9847
+ if (this.#modeButton) this.#modeButton.disabled = off;
9809
9848
  for (const dialog of Object.values(this.#dialogs)) {
9810
9849
  for (const el of dialog.querySelectorAll("input, select, textarea, button")) el.disabled = off;
9811
9850
  }
9851
+ this.#syncHitTargetsInert();
9852
+ }
9853
+ /** Hit targets follow disabled / locked across re-renders. */
9854
+ #syncHitTargetsInert() {
9855
+ const editingOff = this.#inert;
9856
+ const navOff = this.hasAttribute("disabled");
9857
+ for (const target of this.querySelectorAll(':scope > svg [role="button"][tabindex]')) {
9858
+ const off = target.matches('.center[data-target="quality"]') ? navOff : editingOff;
9859
+ target.setAttribute("tabindex", off ? "-1" : "0");
9860
+ if (off) target.setAttribute("aria-disabled", "true");
9861
+ else target.removeAttribute("aria-disabled");
9862
+ }
9863
+ }
9864
+ // ── Internal: status strip (a11y hooks) ──────────────────────────
9865
+ /**
9866
+ * Builds the status strip once: live readout, mode toggle, deadline
9867
+ * warning. Content is refreshed by #syncStatus() on every recompute.
9868
+ */
9869
+ #renderStatus() {
9870
+ if (this.querySelector(":scope > .iron-triangle-status")) return;
9871
+ const strip = document.createElement("div");
9872
+ strip.className = "iron-triangle-status";
9873
+ const readout = document.createElement("output");
9874
+ readout.className = "iron-triangle-readout";
9875
+ readout.setAttribute("aria-live", "polite");
9876
+ readout.setAttribute("aria-atomic", "true");
9877
+ const button = document.createElement("button");
9878
+ button.type = "button";
9879
+ button.className = "iron-triangle-mode ghost";
9880
+ button.setAttribute("aria-pressed", "false");
9881
+ button.textContent = "Manual capacity";
9882
+ button.addEventListener("click", () => this.#openCapacityDialog());
9883
+ const warning = document.createElement("p");
9884
+ warning.className = "iron-triangle-warning";
9885
+ warning.setAttribute("data-warning", "over-deadline");
9886
+ warning.hidden = true;
9887
+ strip.append(readout, button, warning);
9888
+ const svg = this.querySelector(":scope > svg");
9889
+ if (svg) svg.after(strip);
9890
+ else this.prepend(strip);
9891
+ this.#readout = readout;
9892
+ this.#modeButton = button;
9893
+ this.#deadlineWarning = warning;
9894
+ this.#syncDisabledLocked();
9895
+ }
9896
+ #syncStatus() {
9897
+ if (!this.#readout) return;
9898
+ const { capacityPoints, capacitySource } = this.#value;
9899
+ const manual = capacitySource === "manual";
9900
+ const deadline = this.#value.time?.deadline;
9901
+ const overDeadline = this.matches(":state(over-deadline)");
9902
+ const parts = [];
9903
+ parts.push(capacityPoints > 0 ? `Capacity: ${capacityPoints} points (${manual ? "manual" : "formula"}).` : "Capacity: not yet budgeted.");
9904
+ if (overDeadline) parts.push(`Deadline has passed (${deadline}).`);
9905
+ const text = parts.join(" ");
9906
+ if (this.#readout.textContent !== text) this.#readout.textContent = text;
9907
+ if (this.#modeButton) {
9908
+ this.#modeButton.setAttribute("aria-pressed", manual ? "true" : "false");
9909
+ const label = manual && capacityPoints > 0 ? `Manual capacity: ${capacityPoints} pts` : "Manual capacity";
9910
+ if (this.#modeButton.textContent !== label) this.#modeButton.textContent = label;
9911
+ this.#modeButton.setAttribute("aria-label", manual ? `${label}. Activate to edit or return to the formula.` : `${label}. Activate to override the formula with a fixed number.`);
9912
+ }
9913
+ if (this.#deadlineWarning) {
9914
+ this.#deadlineWarning.textContent = overDeadline ? `Deadline has passed (${deadline})` : "";
9915
+ this.#deadlineWarning.hidden = !overDeadline;
9916
+ }
9917
+ }
9918
+ #openCapacityDialog() {
9919
+ const dialog = this.#ensureCapacityDialog();
9920
+ const input = (
9921
+ /** @type {HTMLInputElement} */
9922
+ dialog.querySelector('[name="capacity.points"]')
9923
+ );
9924
+ input.value = String(this.#value.capacityPoints > 0 ? this.#value.capacityPoints : 1);
9925
+ const useFormula = (
9926
+ /** @type {HTMLButtonElement} */
9927
+ dialog.querySelector('button[value="formula"]')
9928
+ );
9929
+ useFormula.hidden = this.#value.capacitySource !== "manual";
9930
+ if (typeof dialog.showModal === "function") dialog.showModal();
9931
+ else dialog.setAttribute("open", "");
9932
+ }
9933
+ #ensureCapacityDialog() {
9934
+ if (this.#dialogs.capacity) return this.#dialogs.capacity;
9935
+ const dialog = document.createElement("dialog");
9936
+ dialog.className = "iron-triangle-dialog iron-triangle-dialog--capacity";
9937
+ dialog.setAttribute("aria-label", "Capacity \u2014 manual override");
9938
+ const form = document.createElement("form");
9939
+ form.method = "dialog";
9940
+ form.setAttribute("data-layout", "stack");
9941
+ form.setAttribute("data-layout-gap", "m");
9942
+ const heading = document.createElement("h3");
9943
+ heading.textContent = "Manual capacity";
9944
+ form.append(heading);
9945
+ const hint = document.createElement("p");
9946
+ hint.className = "iron-triangle-dialog__hint";
9947
+ hint.textContent = "Fix the capacity to a number the team committed to. The Time / Cost / Scope inputs stay saved but stop driving the readout.";
9948
+ form.append(hint);
9949
+ form.append(buildFormField("capacity", {
9950
+ key: "points",
9951
+ label: "Capacity (points)",
9952
+ type: "number",
9953
+ min: 1,
9954
+ step: 1,
9955
+ width: "6rem"
9956
+ }, this.#value.capacityPoints));
9957
+ const actions = document.createElement("div");
9958
+ actions.setAttribute("data-layout", "cluster");
9959
+ actions.setAttribute("data-layout-gap", "s");
9960
+ actions.setAttribute("data-layout-justify", "end");
9961
+ const useFormula = document.createElement("button");
9962
+ useFormula.type = "button";
9963
+ useFormula.value = "formula";
9964
+ useFormula.textContent = "Use formula";
9965
+ useFormula.addEventListener("click", () => {
9966
+ dialog.close("formula");
9967
+ this.setFormula();
9968
+ });
9969
+ const cancel = document.createElement("button");
9970
+ cancel.type = "button";
9971
+ cancel.value = "cancel";
9972
+ cancel.textContent = "Cancel";
9973
+ cancel.addEventListener("click", () => dialog.close("cancel"));
9974
+ const save = document.createElement("button");
9975
+ save.type = "submit";
9976
+ save.value = "save";
9977
+ save.textContent = "Save";
9978
+ actions.append(useFormula, cancel, save);
9979
+ form.append(actions);
9980
+ dialog.append(form);
9981
+ this.append(dialog);
9982
+ this.#dialogs.capacity = dialog;
9983
+ form.addEventListener("submit", (e) => {
9984
+ e.preventDefault();
9985
+ const input = (
9986
+ /** @type {HTMLInputElement} */
9987
+ form.querySelector('[name="capacity.points"]')
9988
+ );
9989
+ const n = Number(input.value);
9990
+ dialog.close("save");
9991
+ if (Number.isFinite(n) && n > 0) this.setManual(n);
9992
+ });
9993
+ this.#syncDisabledLocked();
9994
+ return dialog;
9812
9995
  }
9813
9996
  // ── Internal: revise() field accessors ───────────────────────────
9814
9997
  #getField(path) {
@@ -10423,6 +10606,7 @@ function anchorFor(p) {
10423
10606
  }
10424
10607
 
10425
10608
  // src/web-components/quality-target/logic.js
10609
+ var instanceCount = 0;
10426
10610
  var QualityTarget = class extends VBElement {
10427
10611
  static formAssociated = true;
10428
10612
  static get observedAttributes() {
@@ -10431,7 +10615,6 @@ var QualityTarget = class extends VBElement {
10431
10615
  "data-capacity-points",
10432
10616
  "data-cost-weights",
10433
10617
  "data-radius",
10434
- "data-show-envelope",
10435
10618
  "data-min-rationale",
10436
10619
  "data-max-rationale",
10437
10620
  "data-min-overrun-rationale",
@@ -10462,6 +10645,9 @@ var QualityTarget = class extends VBElement {
10462
10645
  #dialogs = {};
10463
10646
  /** @type {HTMLElement | null} */
10464
10647
  #footer = null;
10648
+ /** Per-instance prefix so generated label/control ids never collide
10649
+ * when a page holds more than one target. */
10650
+ #uid = `quality-target-${++instanceCount}`;
10465
10651
  constructor() {
10466
10652
  super();
10467
10653
  this.#internals = this.attachInternals();
@@ -10472,6 +10658,7 @@ var QualityTarget = class extends VBElement {
10472
10658
  this.#bindToTriangle();
10473
10659
  this.#renderViz();
10474
10660
  this.#ensureFooter();
10661
+ this.#publishFormValue();
10475
10662
  this.listen(this, "click", (e) => this.#onClick(e));
10476
10663
  this.listen(this, "keydown", (e) => this.#onKeydown(e));
10477
10664
  this.listen(this, "input", (e) => this.#onFooterInput(e));
@@ -10490,8 +10677,11 @@ var QualityTarget = class extends VBElement {
10490
10677
  } else if (name === "data-cost-weights") {
10491
10678
  this.#refreshCostWeights();
10492
10679
  this.#renderViz();
10493
- } else if (name === "data-radius" || name === "data-show-envelope") {
10680
+ } else if (name === "data-radius") {
10494
10681
  this.#renderViz();
10682
+ } else if (name === "data-min-overrun-rationale" || name === "data-max-overrun-rationale") {
10683
+ this.#updateFooter();
10684
+ this.#publishFormValue();
10495
10685
  } else if (name === "disabled" || name === "locked") {
10496
10686
  this.#syncDisabledLocked();
10497
10687
  }
@@ -10563,7 +10753,7 @@ var QualityTarget = class extends VBElement {
10563
10753
  }
10564
10754
  /** Imperatively open one axis's editor. */
10565
10755
  openEditor(ility) {
10566
- if (!this.#ilities.includes(ility)) return;
10756
+ if (!this.#ilities.includes(ility) || this.#inert) return;
10567
10757
  const dialog = this.#ensureDialog(ility);
10568
10758
  if (typeof dialog.showModal === "function") dialog.showModal();
10569
10759
  else dialog.setAttribute("open", "");
@@ -10641,10 +10831,10 @@ var QualityTarget = class extends VBElement {
10641
10831
  form.append(fs);
10642
10832
  const ff = document.createElement("form-field");
10643
10833
  const rl = document.createElement("label");
10644
- rl.setAttribute("for", `quality-${ility}-rationale`);
10834
+ rl.setAttribute("for", `${this.#uid}-${ility}-rationale`);
10645
10835
  rl.textContent = "Rationale (required when Critical)";
10646
10836
  const ta = document.createElement("textarea");
10647
- ta.id = `quality-${ility}-rationale`;
10837
+ ta.id = `${this.#uid}-${ility}-rationale`;
10648
10838
  ta.name = "rationale";
10649
10839
  ta.rows = 3;
10650
10840
  ta.minLength = this.#minRationale();
@@ -10698,6 +10888,7 @@ var QualityTarget = class extends VBElement {
10698
10888
  this.#publish("dialog", ility);
10699
10889
  });
10700
10890
  this.#syncDialogFromState(ility);
10891
+ this.#syncDisabledLocked();
10701
10892
  return dialog;
10702
10893
  }
10703
10894
  #syncDialogFromState(ility) {
@@ -10740,8 +10931,8 @@ var QualityTarget = class extends VBElement {
10740
10931
  <strong>Over budget \u2014 explain why.</strong>
10741
10932
  <small data-overrun-prompt></small>
10742
10933
  <form-field>
10743
- <label for="quality-overrun">Overrun rationale (\u2265 ${this.#minOverrunRationale()} chars)</label>
10744
- <textarea id="quality-overrun" name="overrunRationale"
10934
+ <label for="${this.#uid}-overrun">Overrun rationale (\u2265 ${this.#minOverrunRationale()} chars)</label>
10935
+ <textarea id="${this.#uid}-overrun" name="overrunRationale"
10745
10936
  minlength="${this.#minOverrunRationale()}"
10746
10937
  maxlength="${this.#maxOverrunRationale()}"
10747
10938
  rows="3"></textarea>
@@ -10755,6 +10946,7 @@ var QualityTarget = class extends VBElement {
10755
10946
  footer.append(errors);
10756
10947
  this.append(footer);
10757
10948
  this.#footer = footer;
10949
+ this.#syncDisabledLocked();
10758
10950
  }
10759
10951
  #updateFooter() {
10760
10952
  if (!this.#footer) return;
@@ -10768,8 +10960,20 @@ var QualityTarget = class extends VBElement {
10768
10960
  if (overrun) {
10769
10961
  overrun.hidden = !isOver;
10770
10962
  const prompt = overrun.querySelector("[data-overrun-prompt]");
10771
- if (prompt && Number.isFinite(cap)) {
10772
- prompt.textContent = `You're over budget by ${sum - cap} points (${sum}/${cap}).`;
10963
+ if (prompt) {
10964
+ const over = sum - cap;
10965
+ prompt.textContent = isOver ? `You're over budget by ${over} ${over === 1 ? "point" : "points"} (${sum}/${cap}).` : "";
10966
+ }
10967
+ const min = this.#minOverrunRationale();
10968
+ const label = overrun.querySelector("label");
10969
+ if (label) label.textContent = `Overrun rationale (\u2265 ${min} chars)`;
10970
+ const ta = (
10971
+ /** @type {HTMLTextAreaElement | null} */
10972
+ overrun.querySelector("textarea")
10973
+ );
10974
+ if (ta) {
10975
+ ta.minLength = min;
10976
+ ta.maxLength = this.#maxOverrunRationale();
10773
10977
  }
10774
10978
  }
10775
10979
  }
@@ -10852,6 +11056,7 @@ var QualityTarget = class extends VBElement {
10852
11056
  const existing = this.querySelector(":scope > svg");
10853
11057
  if (existing) existing.replaceWith(svg);
10854
11058
  else this.prepend(svg);
11059
+ this.#syncAxesInert();
10855
11060
  }
10856
11061
  #publish(source, field) {
10857
11062
  this.#renderViz();
@@ -10878,14 +11083,67 @@ var QualityTarget = class extends VBElement {
10878
11083
  this.#internals.setFormValue(JSON.stringify(this.value));
10879
11084
  } catch {
10880
11085
  }
11086
+ this.#syncValidity();
10881
11087
  }
11088
+ /**
11089
+ * Mirror validateVector() into ElementInternals so a parent <form>'s
11090
+ * checkValidity() / submit is blocked too, not just this element's own
11091
+ * checkValidity() method. The overrun textarea anchors the message when
11092
+ * the overrun rationale is the problem.
11093
+ */
11094
+ #syncValidity() {
11095
+ if (typeof this.#internals.setValidity !== "function") return;
11096
+ const { valid, errors } = validateVector({
11097
+ vector: this.#vector,
11098
+ rationales: this.#rationales,
11099
+ costWeights: this.#costWeights,
11100
+ capacityPoints: this.#resolveCapacity(),
11101
+ overrunRationale: this.#overrunRationale,
11102
+ minRationale: this.#minRationale(),
11103
+ minOverrunRationale: this.#minOverrunRationale()
11104
+ });
11105
+ if (valid) {
11106
+ this.#internals.setValidity({});
11107
+ return;
11108
+ }
11109
+ const overrunTa = (
11110
+ /** @type {HTMLElement | null} */
11111
+ this.overBudget ? this.querySelector("[data-quality-overrun] textarea") : null
11112
+ );
11113
+ try {
11114
+ this.#internals.setValidity({ customError: true }, errors.join(" "), overrunTa ?? void 0);
11115
+ } catch {
11116
+ this.#internals.setValidity({ customError: true }, errors.join(" "));
11117
+ }
11118
+ }
11119
+ get #inert() {
11120
+ return this.hasAttribute("disabled") || this.hasAttribute("locked");
11121
+ }
11122
+ /**
11123
+ * disabled / locked: no editing path stays open. styles.css already sets
11124
+ * pointer-events: none; this closes the keyboard and programmatic paths
11125
+ * (axes leave the tab order, openEditor() is a no-op) and disables every
11126
+ * dialog and footer control, including dialogs built later.
11127
+ */
10882
11128
  #syncDisabledLocked() {
10883
- const off = this.hasAttribute("disabled") || this.hasAttribute("locked");
11129
+ const off = this.#inert;
10884
11130
  for (const dialog of Object.values(this.#dialogs)) {
10885
11131
  for (const el of dialog.querySelectorAll("input, textarea, select, button")) {
10886
11132
  el.disabled = off;
10887
11133
  }
10888
11134
  }
11135
+ for (const el of this.#footer?.querySelectorAll("textarea") ?? []) {
11136
+ el.disabled = off;
11137
+ }
11138
+ this.#syncAxesInert();
11139
+ }
11140
+ #syncAxesInert() {
11141
+ const off = this.#inert;
11142
+ for (const axis of this.querySelectorAll(":scope > svg .axis[data-ility]")) {
11143
+ axis.setAttribute("tabindex", off ? "-1" : "0");
11144
+ if (off) axis.setAttribute("aria-disabled", "true");
11145
+ else axis.removeAttribute("aria-disabled");
11146
+ }
10889
11147
  }
10890
11148
  // ── Internal: config readers ──────────────────────────────────────
10891
11149
  #minRationale() {
@@ -11040,6 +11298,8 @@ var TEMPLATE = `
11040
11298
  </article>
11041
11299
  `;
11042
11300
  var RequirementCard = class extends VBElement {
11301
+ /** True when this element added role="button" itself (so it may remove it). */
11302
+ #ownsRole = false;
11043
11303
  static get observedAttributes() {
11044
11304
  return ["data-priority", "data-conflict", "tabindex"];
11045
11305
  }
@@ -11062,6 +11322,15 @@ var RequirementCard = class extends VBElement {
11062
11322
  this.#syncDefaultPill();
11063
11323
  this.#syncInteractive();
11064
11324
  this.listen(this, "click", (e) => this.#onActivate(e));
11325
+ const wrapper = this.closest("a[href], button");
11326
+ if (wrapper) {
11327
+ this.listen(wrapper, "click", (e) => {
11328
+ if (!this.contains(
11329
+ /** @type {Node} */
11330
+ e.target
11331
+ )) this.#onActivate(e);
11332
+ });
11333
+ }
11065
11334
  this.listen(this, "keydown", (e) => {
11066
11335
  const ke = (
11067
11336
  /** @type {KeyboardEvent} */
@@ -11104,7 +11373,7 @@ var RequirementCard = class extends VBElement {
11104
11373
  return this.hasAttribute("tabindex") || this.closest("a[href], button");
11105
11374
  }
11106
11375
  #syncSlot(name, slot) {
11107
- const hasContent = !!slot && slot.assignedNodes({ flatten: true }).some(
11376
+ const hasContent = !!slot && slot.assignedNodes().some(
11108
11377
  (n) => n.nodeType === Node.ELEMENT_NODE || n.nodeType === Node.TEXT_NODE && n.textContent.trim().length > 0
11109
11378
  );
11110
11379
  this.setState(`has-${name}`, hasContent);
@@ -11121,6 +11390,14 @@ var RequirementCard = class extends VBElement {
11121
11390
  }
11122
11391
  #syncInteractive() {
11123
11392
  this.setState("interactive", !!this.#interactive);
11393
+ const needsRole = this.hasAttribute("tabindex") && !this.closest("a[href], button");
11394
+ if (needsRole && !this.hasAttribute("role")) {
11395
+ this.setAttribute("role", "button");
11396
+ this.#ownsRole = true;
11397
+ } else if (!needsRole && this.#ownsRole) {
11398
+ this.removeAttribute("role");
11399
+ this.#ownsRole = false;
11400
+ }
11124
11401
  }
11125
11402
  #onActivate(event) {
11126
11403
  if (!this.#interactive) return;