VB Project Planning

<iron-triangle>

Project-shape constraint surface — captures Time × Cost × Scope and computes a single capacityPoints budget that downstream planning components can spend on quality decisions.

Overview

The <iron-triangle> component is the planning surface's constraint view, and it is the UI. Three vertex hit-targets — Scope (top), Time (bottom-left), Cost (bottom-right) — open native <dialog> editors on click. The center "Quality" target shows the live capacityPoints integer and routes to the quality decision when activated. Hover any target for a native tooltip preview of its current value.

The author writes a single empty element. The component generates the SVG and the per-vertex dialogs from a fixed schema; inputs inside the dialogs use VB primitives (<form-field>, <fieldset>, data-layout). The component is form-associated; capacityPoints plus the full T/C/S snapshot serialize as JSON for a containing <form>.

The triangle deforms with project shape

Each vertex sits on an equilateral baseline ray and stretches outward by a factor in [0.55, 1.45] derived from that constraint's magnitude relative to the other two (time = total weeks, cost = FTE × hours/week, scope = weighted feature counts). A balanced project stays close to equilateral; a long-deadline project pushes Scope up; a tiny team pulls Cost toward the centroid. The capacity number anchors at the centroid, so the integer never moves when the shape changes.

Per-vertex editors

Click a vertex to open its dialog. Each editor is built from VB <form-field> rows — labels above controls, native validation, no inline width hacks. The dialog's method="dialog" means submitting closes it automatically.

VertexInputsDrives capacity?
Scopemust-have count, should-have count, notesno — informational
Timesprint length, number of sprints, hours/week, deadlineyes (sprintWeeks × sprintCount)
Costteam FTE, budget tier, contractor budgetyes (teamFTE drives the formula)

The cost dialog mixes inputs that drive the formula (team FTE) with inputs that record the shape but don't (budget tier, contractor budget). The latter are metadata — they answer "why does the team look this way?" without polluting the math.

The capacity formula

Capacity is one integer the team picked together. The default formula is intentionally tiny:

The focus factor represents the share of FTE actually available for engineering quality work — the remaining 40% goes to feature delivery, meetings, ops, and the unknowns. 0.6 is the middle of the 50–70% range most teams settle on; tune per project with data-focus-factor. Flip to manual mode — via the Manual capacity toggle under the triangle, or setManual(n) — and the integer becomes a fixed commitment (a board number or a carried-over OKR); the T/C/S inputs stay saved but no longer drive the readout, and capacitySource records which path produced the number. The toggle opens a small dialog: enter the number and Save, or choose Use formula to hand control back to the formula.

The center "Quality" target

The capacity number is the headline; clicking it opens the quality decision. The component dispatches a cancelable iron-triangle:open-quality event with { qualitySummary, capacityPoints }. If the event isn't preventDefault()'d and data-quality-href is set, the page navigates to that URL — SPAs intercept the event and route in-app, static pages just set the href and let the fallback navigate. Set data-quality-summary to surface the saved picks on hover. Every change also recomputes a stable FNV-1a hash of the T/C/S object (exposed via .hash), which downstream consumers stamp into their saved vector so a CI guard can detect drift against a since-changed project shape.

Drift detection

Every change recomputes a stable FNV-1a hash of the T/C/S object — exposed via .hash and emitted in iron-triangle:change. Downstream consumers (notably <quality-target>) stamp this hash into their saved vector so a CI guard can detect when a quality vector was decided against a since-changed project shape.

If you'd rather not wire it manually, point the compass at the triangle by id and the binding happens for you:

Revisions with reasons

Edits made via the revise() method append to revisionLog and emit iron-triangle:revise. The reason is required (≥ 10 characters) so the constraint surface accumulates institutional memory rather than vanishing into Slack threads.

Attributes

AttributeValuesDefaultDescription
namestringtriangleForm-association field name
data-focus-factornumber0.6Multiplier in the default capacity formula
data-min-capacitynumber1Floor for capacityPoints
data-quality-hrefstring—Navigation fallback when iron-triangle:open-quality isn't preventDefault-ed
data-quality-summarystring—Short summary text shown in the center's native tooltip + aria-label (e.g. '3 critical: perf, sec, a11y')
disabledboolean—All inputs disabled
lockedboolean—Read-only mode for shipped vectors

Generated markup

There are no slots. The author writes one empty element and the component renders everything under it, in this order:

ChildPurpose
svg[role="group"] The triangle. Each vertex is a g.vertex[role="button"][tabindex="0"]; the center is g.center[role="button"]. Click / Enter / Space activate.
.iron-triangle-status Status strip: the <output aria-live="polite"> capacity readout (visually hidden), the button.iron-triangle-mode[aria-pressed] formula / manual switch, and the [data-warning="over-deadline"] text warning.
dialog.iron-triangle-dialog--{time,cost,scope,capacity} Per-vertex editors and the manual-capacity editor, created lazily on first open. Inputs are <form-field> rows named time.sprintWeeks, cost.teamFTE, capacity.points, and so on.

Per VB convention, state lives in attributes and custom states: data-capacity-points and data-capacity-source are reflected on the host after every recompute, and the :state() hooks below drive styling.

Events

EventDetailWhen
iron-triangle:change { time, cost, scope, capacityPoints, capacitySource, hash, source } Any input or property change. source is one of 'init', 'dialog' (with field = the vertex), 'property', 'attribute', 'manual', 'formula', 'revise', or 'recalc'.
iron-triangle:revise { field, from, to, reason } A revision is committed via revise().
iron-triangle:mode { from, to } Capacity source flips between 'formula' and 'manual'.

Internal state hooks

Iron-triangle exposes CustomStateSet entries for CSS targeting via :state().

StateWhen
:state(formula) Capacity source is the formula (default).
:state(manual) Capacity source is a manual integer.
:state(over-deadline) The Time editor's deadline date is in the past. The triangle stroke turns to the warning colour and the status strip shows "Deadline has passed (date)".
:state(unbudgeted) capacityPoints is 0 (nothing to spend yet).

JavaScript API

Property / MethodTypeDescription
.value object Get a deep copy of { time, cost, scope, capacityPoints, capacitySource, hash, revisionLog }. Set to replace any of time / cost / scope (and optionally capacitySource: 'manual' + capacityPoints, or a revisionLog).
.capacityPoints integer (readonly) Current budget. Use setManual() to write it.
.capacitySource 'formula' \| 'manual' (readonly) Which mode produced the number.
.hash string (readonly) FNV-1a of the T/C/S object — for drift detection.
.revisionLog array (readonly) Append-only edit history from revise().
.qualitySummary string Mirror of data-quality-summary; surfaces in the center tooltip and aria-label.
.openEditor(axis) method Open the 'time' / 'cost' / 'scope' dialog programmatically.
.revise(field, newValue, reason) method Programmatic edit of 'time.sprintWeeks'-style paths (or 'capacityPoints'); throws if reason.length < 10.
.setManual(integer) method Switch to manual mode and set capacityPoints (floored at 1).
.setFormula() method Switch back to the formula.
.recalc() method Force capacity recomputation (rarely needed).

CSS Tokens

TokenDefaultPurpose
--iron-triangle-max 26rem Max inline size of the host (the SVG scales to fit)
--iron-triangle-fill color-mix(in oklab, var(--color-interactive) 8%, transparent) Triangle fill
--iron-triangle-stroke var(--color-interactive) Triangle outline colour
--iron-triangle-stroke-width 1.5 Triangle outline width (SVG units)
--iron-triangle-capacity-size 1.6rem Capacity number font size
--iron-triangle-warning-color var(--color-warning) Over-deadline stroke, unbudgeted number, and the deadline warning text

Static fallback

The triangle is inherently interactive, so without JavaScript the element renders nothing. For a no-JS path, server-render a plain <form> with three <fieldset>s of native Time / Cost / Scope inputs on the same page and hide it once the component upgrades (iron-triangle[data-upgraded] ~ form { display: none }). The component's form value is the same JSON snapshot returned by .value, so a server can accept either.

Accessibility

  • Keyboard. Every hit target is a focusable role="button" (tabindex="0") inside an SVG role="group"; Enter and Space activate exactly like a click. Each target's aria-label carries its name and current summary ("Time — 6 weeks (3 × 2wk). Activate to edit.").
  • Live readout. The capacity number lives in an <output aria-live="polite" aria-atomic="true"> below the SVG. It is visually hidden (the big number already shows on screen) and announces "Capacity: 18 points (formula)." after every recompute, including mode flips and any deadline warning.
  • Mode switch. The formula / manual switch is a real <button aria-pressed>, not a checkbox-and-label hack. Its accessible name states the current manual number when pressed. Activating it opens a native <dialog> (focus is trapped and returned by the browser) with a single numeric <form-field> plus Use formula, Cancel, and Save.
  • Never colour-only. A deadline in the past changes the triangle stroke and shows the literal text "Deadline has passed (date)" in the status strip; the same text is announced through the live region. An unbudgeted capacity shows "—" rather than relying on colour alone.
  • Editors. Dialog inputs are labelled <form-field> rows with native validation (min, step, maxlength); each dialog has an aria-label naming the vertex. disabled / locked disable the mode switch and every dialog control.
  • Logical properties throughout keep RTL and vertical writing modes clean.

Related

  • <quality-target> — primary consumer; spends capacityPoints on quality picks and stamps the triangle's hash for drift detection.
  • <capacity-plan> — reconciles this capacity against quality spend and slotted feature costs in a stacked-bar ledger.
  • <requirement-card> — renders a single ility's priority row downstream of the quality decision.
  • <adr-wc> — record the iron-triangle decision itself as an ADR.
↑