Vanilla Breeze

iron-triangle

Project-shape constraint surface — captures Time × Cost × Scope on one form and computes a single integer capacityPoints budget that downstream Planning Pack components (notably ) can spend on quality decisions.

Overview

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>.

Live demo

Click any vertex to open its editor. Click the center to fire the open-quality event. Hover for tooltips.

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 center "Quality" target

The capacity number is the headline; clicking it opens the Quality target. Two paths combine:

  • 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 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.

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.

ProjectMathcapacityPoints
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

Manual override

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.

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

AttributeTypeDefaultDescription
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.

Slots

SlotPurposeRequired
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

EventDetailWhen
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().

StateWhen
: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 / MethodTypeDescription
.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

TokenDefaultPurpose
--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; the capacity readout is an <output> with aria-live="polite".
  • Mode toggle is a real button. The formula / manual switch uses <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 for clean RTL and writing-mode support.

Related

  • <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.
  • UX Planning Pack — loads iron-triangle, quality-target, and the rest of the planning surface together.