Welcome to Vanilla Breeze
This bell pulls live notifications from /go/notify/messages — the same contract documented at /docs/concepts/service-contracts/. Static articles like this one are the no-JS / no-backend fallback.
This bell pulls live notifications from /go/notify/messages — the same contract documented at /docs/concepts/service-contracts/. Static articles like this one are the no-JS / no-backend fallback.
Project-shape constraint surface — captures Time × Cost × Scope on one form and computes a single integer capacityPoints budget that downstream Planning Pack components (notably
The <iron-triangle> component is the Planning Pack's constraint surface. It captures Time, Cost, and Scope as one decision and computes a single integer capacityPoints budget that downstream tools — first and foremost <nfr-compass> — spend against quality choices. Reach for it whenever a project's shape is currently scattered across targetLaunch fields, MoSCoW spreadsheets, and informal "team-size" guesswork; one surface, one decision moment, one number the rest of the planning artifacts can read.
Pre-upgrade the markup is a plain <form> with three <fieldset>s, so a server can persist values and even compute capacity without the bundle. After upgrade the component computes capacity live, exposes a stable hash for drift detection, and records every revision with a required reason.
Edit any input — the capacity readout and formula text below the inputs update in real time.
<!-- Layout, fieldset chrome, and input sizing all come from VB primitives. The only iron-triangle-specific markup is the <figure data-iron-triangle-viz> placeholder where the SVG is swapped in on every input change. --><iron-triangle name="triangle" data-focus-factor="0.6"> <form data-layout="split" data-layout-ratio="2:1" data-layout-gap="l"> <section data-layout="stack" data-layout-gap="m"> <fieldset data-layout="stack" data-layout-gap="m"> <legend>Time</legend> <form-field> <label for="sprint-weeks">Sprint length (weeks)</label> <input id="sprint-weeks" name="time.sprintWeeks" type="number" min="1" value="2" style="inline-size: 6rem"> </form-field> <form-field> <label for="sprint-count">Number of sprints</label> <input id="sprint-count" name="time.sprintCount" type="number" min="1" value="3" style="inline-size: 6rem"> </form-field> <!-- … hours/week, deadline … --> </fieldset> <fieldset data-layout="stack" data-layout-gap="m"> <legend>Cost</legend> <form-field> <label for="fte">Team size (FTE)</label> <input id="fte" name="cost.teamFTE" type="number" min="0" step="0.5" value="1" style="inline-size: 6rem"> </form-field> </fieldset> <fieldset data-layout="stack" data-layout-gap="m"> <legend>Scope</legend> <form-field> <label for="must-have">Must-have count</label> <input id="must-have" name="scope.mustHaveCount" type="number" min="0" style="inline-size: 6rem"> </form-field> </fieldset> </section> <!-- The component injects/replaces the inline SVG inside this figure on every input change. The <output>/<small> below stay as a no-JS fallback and SR-only after upgrade. --> <figure data-iron-triangle-viz aria-live="polite"> <output name="capacityPoints">—</output> <small name="capacityFormula">Set inputs to compute capacity.</small> </figure> </form> <fieldset data-layout="cluster" data-layout-gap="s" data-layout-align="center"> <legend class="visually-hidden">Capacity mode</legend> <button type="button" data-iron-triangle-mode-toggle aria-pressed="false">Use manual capacity</button> <form-field hidden data-iron-triangle-manual-input-field> <label for="manual-points">Manual points</label> <input id="manual-points" type="number" min="1" step="1" data-iron-triangle-manual-input> </form-field> </fieldset></iron-triangle>
The component renders an inline SVG triangle into the [data-iron-triangle-viz] figure. Each vertex sits on an equilateral baseline ray and stretches outward by a factor in [0.55, 1.45] based on its constraint's relative magnitude (time = total weeks, cost = FTE × hours/week, scope = weighted feature counts). A balanced project stays close to equilateral; a long-deadline project pushes the TIME vertex up; a tiny team pulls the COST vertex toward center.
The capacity number anchors at the centroid (origin), so the integer never moves when the shape changes.
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.
| Project | Math | capacityPoints |
|---|---|---|
| Solo, 6-week project | 6 wk × 1 FTE × 0.6 | 4 |
| Small team, quarter | 12 wk × 3 FTE × 0.6 | 22 |
| Full team, half-year | 26 wk × 5 FTE × 0.6 | 78 |
Sometimes the budget already exists — a board commitment, an OKR, or a number carried over from a prior project. Flip to manual mode and the integer becomes editable directly; the T/C/S inputs stay visible and saved (they document why the project looks the way it does) but no longer drive the readout. capacitySource records which path produced the saved number.
const triangle = document.getElementById('project-shape');triangle.setManual(15); // capacitySource flips to "manual"triangle.setFormula(); // back to the default formula/code-block</section> <section> <h2>Drift detection</h2> <p>Every change recomputes a stable FNV-1a hash of the T/C/S object — exposed via <code>.hash</code> and emitted in <code>iron-triangle:change</code>. Downstream consumers (notably <code><nfr-compass></code>) 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.</p> <code-block language="javascript" label="Wire iron-triangle to nfr-compass" data-escape>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><nfr-compass data-bind-to="shape"></nfr-compass>
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>
| Attribute | Type | Default | Description |
|---|---|---|---|
name |
string | triangle |
Form-association field name. |
data-focus-factor |
number (0–1) | 0.6 |
Multiplier in the default capacity formula — share of FTE available for engineering quality work. |
data-min-capacity |
integer | 1 |
Floor for capacityPoints. Below this, :state(unbudgeted) is set. |
data-capacity-formula |
string | — | Custom formula expression (extension point; out of scope for v1). |
disabled |
boolean | absent | All inputs disabled. |
locked |
boolean | absent | Read-only mode for shipped vectors. |
| 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.
| 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'. |
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. |
| 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). |
| 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 |
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.
<fieldset> / <legend> grouping; the capacity readout is an <output> with aria-live="polite".<button aria-pressed>, not a checkbox-and-label hack.<nfr-compass> — primary consumer; spends capacityPoints on quality picks and stamps the triangle's hash for drift detection.<adr-wc> — record the Iron Triangle decision itself as an ADR.