<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>.
<!-- One element. The component generates everything else. --><iron-triangle name="triangle" data-focus-factor="0.6" data-quality-href="/requirements" data-quality-summary="3 critical: perf, sec, a11y"></iron-triangle>
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.
| Vertex | Inputs | Drives capacity? |
|---|---|---|
| Scope | must-have count, should-have count, notes | no — informational |
| Time | sprint length, number of sprints, hours/week, deadline | yes (sprintWeeks × sprintCount) |
| Cost | team FTE, budget tier, contractor budget | yes (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:
capacityPoints = ceil(sprintWeeks × sprintCount × teamFTE × focusFactor) defaults: focusFactor = 0.6
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 and the integer becomes editable directly (for a board commitment or a carried-over OKR); the T/C/S inputs stay visible and saved but no longer drive the readout, and capacitySource records which path produced the number.
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.
const triangle = document.getElementById('project-shape');const compass = document.getElementById('priorities'); // Push the initial capacitycompass.capacityPoints = triangle.capacityPoints;compass.dataset.ironTriangleHash = triangle.hash; // And on every revisiontriangle.addEventListener('iron-triangle:change', (e) => { compass.capacityPoints = e.detail.capacityPoints; compass.dataset.ironTriangleHash = e.detail.hash;});
If you'd rather not wire it manually, point the compass at the triangle by id and the binding happens for you:
<iron-triangle id="shape"></iron-triangle><quality-target data-bind-to="shape"></quality-target>
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.
triangle.revise('cost.teamFTE', 2, 'Hired a backend contractor for sprints 3–5; doubles capacity.');// Updates the input + value, appends to revisionLog,// emits iron-triangle:revise. Reason must be = 10 chars./code-block>
Attributes
| Attribute | Values | Default | Description |
|---|---|---|---|
name | string | triangle | Form-association field name |
data-focus-factor | number | 0.6 | Multiplier in the default capacity formula |
data-min-capacity | number | 1 | Floor for capacityPoints |
data-quality-href | string | — | Navigation fallback when iron-triangle:open-quality isn't preventDefault-ed |
data-quality-summary | string | — | Short summary text shown in the center's native tooltip + aria-label (e.g. '3 critical: perf, sec, a11y') |
disabled | boolean | — | All inputs disabled |
locked | boolean | — | Read-only mode for shipped vectors |
Slots
| Slot | Purpose | Required |
|---|---|---|
title | Heading text shown above the form | no (defaults to "Iron Triangle") |
time-controls | Override the default Time inputs | no |
cost-controls | Override the default Cost inputs | no |
scope-controls | Override the default Scope inputs | no |
capacity-readout | Override the readout block (advanced) | no |
footer | Save / Submit button area | no |
Per VB convention, content lives in slots and state lives in attributes. The default-slot fieldsets named time, cost, scope, and capacity are the markup contract — you can hand-author them (as in the static fallback) or let the component inject sensible defaults if absent.
Events
| Event | Detail | When |
|---|---|---|
iron-triangle:change |
{ time, cost, scope, capacityPoints, capacitySource, hash, source } |
Any input or property change. source is 'pointer', 'keyboard', or 'api'. |
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().
| State | When |
|---|---|
:state(formula) | Capacity source is the formula (default). |
:state(manual) | Capacity source is a manual integer. |
:state(over-deadline) | The Time-fieldset deadline date is in the past (passive warning). |
:state(unbudgeted) | Computed capacityPoints < data-min-capacity. |
JavaScript API
| Property / Method | Type | Description |
|---|---|---|
.time | { sprintWeeks, sprintCount, hoursPerWeek, deadline } | Get / set the Time corner. |
.cost | { teamFTE, budgetTier, contractorBudget } | Get / set the Cost corner. |
.scope | { mustHaveCount, shouldHaveCount, scopeNotes } | Get / set the Scope corner. |
.capacityPoints | integer | Current budget. Read-only when capacitySource === 'formula'; writable in manual mode. |
.capacitySource | 'formula' \| 'manual' | Which mode produced the saved number. |
.hash | string (readonly) | FNV-1a of the T/C/S object — for drift detection. |
.revisionLog | array (readonly) | Append-only edit history. |
.value | object (readonly) | Full snapshot for serialization. |
.revise(field, newValue, reason) | method | Programmatic edit; throws if reason.length < 10. |
.setManual(integer) | method | Switch to manual mode and set capacityPoints. |
.setFormula(formulaString?) | method | Switch back to formula mode (default formula or custom). |
.recalc() | method | Force capacity recomputation (rarely needed). |
CSS Tokens
| Token | Default | Purpose |
|---|---|---|
--iron-triangle-padding | var(--size-l) | Outer padding |
--iron-triangle-section-gap | var(--size-l) | Vertical gap between Time / Cost / Scope |
--iron-triangle-input-min | 8rem | Min width per numeric input |
--iron-triangle-readout-bg | var(--color-surface-raised) | Capacity readout background |
--iron-triangle-readout-size | var(--font-size-3xl) | Capacity number font size |
--iron-triangle-formula-color | var(--color-text-muted) | Formula explanation text |
--iron-triangle-warning-color | var(--color-warning) | Over-deadline / unbudgeted warning |
Static fallback
Without JavaScript, the form still works — three <fieldset>s of native inputs that submit raw T/C/S values to the form's action. The server can persist the values and even compute capacity. The component just adds the live readout, formula visualization, and revision-tracking machinery on upgrade.
Accessibility
Native form semantics throughout: real <fieldset> / <legend> grouping, and the capacity readout is an <output> with aria-live="polite". The formula / manual switch is a real <button aria-pressed>, not a checkbox-and-label hack. Warnings are never color-only — the deadline-in-past warning includes the literal text "Deadline has passed" alongside any styling. Logical properties throughout keep RTL and writing-mode support clean. Without JavaScript the form still works: three <fieldset>s of native inputs submit raw T/C/S values to the form's action.
Related
<quality-target>— primary consumer; spendscapacityPointson 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.