<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 — 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.
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 |
Generated markup
There are no slots. The author writes one empty element and the component renders everything under it, in this order:
| Child | Purpose |
|---|---|
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
| Event | Detail | When |
|---|---|---|
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().
| State | When |
|---|---|
: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 / Method | Type | Description |
|---|---|---|
.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
| Token | Default | Purpose |
|---|---|---|
--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 SVGrole="group"; Enter and Space activate exactly like a click. Each target'saria-labelcarries 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 anaria-labelnaming the vertex.disabled/lockeddisable the mode switch and every dialog control. - Logical properties throughout keep RTL and vertical writing modes clean.
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.