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, 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 target 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 + the full T/C/S snapshot serialize as JSON for a containing <form>.
Click any vertex to open its editor. Click the center to fire the open-quality event. Hover for tooltips.
<!-- 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>
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.
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 number is the headline; clicking it opens the Quality target. Two paths combine:
iron-triangle:open-quality event with { qualitySummary, capacityPoints }.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 data-quality-href and let the fallback navigate. Set data-quality-summary (or the qualitySummary property) to a short string and the center's native tooltip + aria-label surface it on hover.
const triangle = document.querySelector('iron-triangle'); triangle.addEventListener('iron-triangle:open-quality', (e) => { e.preventDefault(); // intercept the href fallback router.navigate('/requirements', { compassFocus: true });}); // Optional: surface the saved compass picks on hovertriangle.qualitySummary = '3 critical: perf, sec, a11y';
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><quality-target></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 quality-target" 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><quality-target data-bind-to="shape"></quality-target>
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-quality-href |
string (URL) | — | Fallback navigation target when the center "Quality" hit-target is activated and iron-triangle:open-quality is not preventDefault-ed. |
data-quality-summary |
string | "TBD — click to set" |
Short summary surfaced via the center target's native tooltip + aria-label (e.g. "3 critical: perf, sec, a11y"). Mirrors the qualitySummary property. |
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.<quality-target> — 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.