Release Readiness
A four-state go/no-go board that rolls per-workstream checks, sign-offs and open blockers into one derived verdict, leads with the single number that reaches zero exactly when the release is clear, and refuses to call an unread board.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-release-readiness.jsonPrompt
Build a React + TypeScript + Tailwind "ChartReleaseReadiness" card (plain
HTML/CSS layout, no chart library) with zod. It answers one question — can we
ship? — and the whole design rests on a single rule: THE VERDICT IS DERIVED.
There is no verdict field on the payload and no prop that sets it. A card that
can be told "Go" independently of the rows printed under it will eventually
disagree with them, and the row is what a reader believes.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; message?: string;
workstreams: {
id: string; label: string;
checks: { passed: number; failed: number; waiting: number };
signOff: { state: "signed" | "pending" | "declined" | "unassigned";
owner?: string; role?: string; note?: string };
blockers: { id: string; title: string;
severity: "critical" | "major" | "minor" }[];
}[];
cutoff?: string; asOf?: string; timeZone?: string }.
- `waiting` is not "skipped" and not "passed": it is a check that has not
reported yet. Say so in the schema doc comment. Folding it into either
neighbour is how a board talks a team into shipping on evidence it never
received.
- `declined` is not `pending`: a refusal is a known bad answer, a missing
signature is only an absence. `unassigned` is an outstanding signature with
nobody named, which is worse to discover late.
- Only OPEN blockers belong in the array. A closed blocker is history, not a
fact about readiness.
- `cutoff` and `asOf` are ISO instants and `timeZone` is the IANA zone they are
read in (default "UTC"). Never read the clock inside the component: a card
that calls Date.now() renders one number on the server and another in the
browser, and stops being reproducible the moment anyone screenshots it.
- Component props = z.infer of the schema plus heroMetric ("open" | "cleared" |
"pass-rate", default "open"), order ("worst-first" | "given", default
"worst-first"), showMix (default true), onSelectWorkstream, onRetry,
className and the native div props through forwardRef.
- Ship a pure module beside the schema: inspectReleaseReadinessData() for the
structural pass, apportionPercent(), readReleaseCutoff() and
buildReleaseReadinessRollup() returning per-row readings, totals, the mix,
the deciding row and the verdict.
Behavior — the rule, which is the product
- Split every outstanding thing into two buckets. KNOWN BAD: failed checks,
critical blockers, refused signatures. NOT IN YET: waiting checks, non-
critical open blockers, missing or unassigned signatures.
- Verdict: no-go the moment the known-bad bucket is non-empty; hold when it is
empty but the not-in-yet bucket is not; go only when both are empty. Apply
the same split per row to give each workstream a state (blocked / waiting /
clear) so the verdict is visibly the roll-up of the rows, not a parallel
opinion.
- AN EMPTY BOARD IS NOT A GO. status="ready" with zero workstreams falls
through to the zero state. Nothing assessed is not the same as nothing
wrong, and a green light on an empty board is the worst thing this card
could print.
- A BOARD THAT COULD NOT BE READ GETS NO VERDICT. Duplicate workstream ids,
duplicate blocker ids inside one workstream, negative or non-finite counts:
refuse in the contract and render the reason in the error branch. Every
number here is a sum over ids, so a repeat is counted twice.
- Lead with ONE number, and pick the one that reaches zero exactly when the
verdict turns green: the count of open items (known bad + not in yet). The
big number and the verdict then say the same thing twice, which is what
makes the card readable in a second.
- The deciding row is the worst row under one total order (state, then failing
count, then criticals, then open items, then payload position). That order is
also the default display order, so the row the verdict rests on is the top
row, and the readout starts on it before anyone touches the card.
- Guard every denominator. A workstream with no automated gate at all is legal
(0 of 0 checks) and must print "no checks configured" rather than NaN;
heroMetric="pass-rate" on a board with no checks anywhere prints an em dash,
never 0% or 100%.
- The four states are first-class branches of one bg-card panel: a pulsing
silhouette of the ready layout (aria-hidden, plus an sr-only role="status"),
the zero state, an error state carrying either the transport message or the
contract issue plus a "Try again" button only when onRetry exists, and ready.
Rendering & styling
- Hierarchy comes from size and weight, not from boxing: one card, generous
whitespace, a text-6xl tabular-nums numeral, a text-sm label under it, a
text-xs muted caption under that, and the verdict as the single saturated
focal element on the row.
- Three tones, one meaning each, used identically by the mix bar, the row bars,
the row dots and the verdict pill: passed var(--chart-2), waiting
var(--chart-3), failed var(--destructive). Colour is never the only
encoding — every slice carries its count, every row carries its state as a
word, and the verdict is a word before it is a colour.
- The mix bar's segments are flex items growing in proportion to their count,
so they always fill the track exactly; per cent widths would inherit the
rounding of the printed labels and leave a sliver of rail showing. A
non-empty slice carries a minimum width so a single failure among hundreds
survives on a narrow card.
- PER CENTS THAT PARTITION A WHOLE ARE APPORTIONED BY LARGEST REMAINDER: floor
every exact share, hand the leftover points to the largest fractional parts,
ties to the bigger count then the earlier key. Rounding each slice on its own
prints 99 or 101. One deliberate departure: a slice holding at least one item
never prints 0% — it is floored to 1% and the point comes off the largest
slice, because "0% failed" beside a red segment is the one number a reader
must not be able to dismiss. Say so in the footnote.
- DATES: format the cutoff from the same epoch value the countdown is measured
on, with an explicit timeZone. Never slice the ISO string — an instant late
in the UTC day is the next day in Tokyo and the previous day in Los Angeles,
and the board would name a date the signers do not recognise. Measure the
countdown as a difference of CIVIL DAYS: read the calendar fields in the zone
and re-pack them through Date.UTC, whose differences are exact multiples of a
day. Dividing raw millisecond gaps by 86400000 loses a day across a clock
change and rounds "tomorrow morning" down to zero.
- Two row signals, two meanings, both visible: a muted wash says "the readout
is reading this row" (pointer, focus or the deciding row), a left bar says
"you pinned it" — the visible twin of aria-pressed. Draw the bar transparent
in every other state so pinning cannot nudge the row sideways.
- Accessibility: rows are real buttons with aria-pressed for the pin. Their
aria-label is built by concatenating the row's own visible strings and then
adding detail, so the accessible name always CONTAINS the visible label
(WCAG 2.5.3 Label in Name). Tab reaches every row; with one focused, ArrowUp
/ ArrowDown walk the list and Home / End jump to the ends, and only those
keys are preventDefault-ed. The readout box is aria-hidden — it is the
pointer's visible echo and every sentence in it is already a row's accessible
name. Below everything, an sr-only WRAPPER DIV holds a real table (one row
per workstream, with what stands in the way spelled out) plus a summary
paragraph naming the verdict, the split and the deciding row. Put sr-only on
the wrapper, never on the table: CSS width is only a lower bound for a table
box, so width:1px does not hold one back.
- Motion: the only animation is the skeleton pulse and a colour transition on
hover, both with motion-reduce variants. Nothing about reading the card
depends on motion, and there are no timers, observers or rAF loops to leak.
- The readout reserves three lines whether or not the third is filled, so the
footnote under it does not move as the pointer crosses the list.
Customization levers
- heroMetric picks the one big number: "open" (default, reaches 0 exactly when
the verdict turns green), "cleared" (workstreams cleared, for a board whose
unit is teams rather than items), "pass-rate" (the passed slice of the mix —
the same whole per cent the legend prints, so the two cannot disagree).
- order="given" keeps the payload's sequence for boards where the order is
itself meaningful, such as the stages of a release train; the default
"worst-first" is the order a go/no-go meeting reads in.
- showMix={false} drops the release-wide bar and its legend for a dense tile;
the per-row bars and every count stay.
- The bucket rule is the one thing worth re-pointing per organisation: move
major blockers into the known-bad bucket for a stricter gate, or let an
unassigned signature merely hold rather than block. Change it in the rollup,
never per render, so the rows and the verdict cannot drift apart.
- Tone tokens: re-point passed / waiting / failed to any three semantic tokens;
keep one meaning per tone, since the same three colour the bar, the rows and
the verdict.
- Density: drop the mix legend first, the footnote second, the readout last —
the readout is what makes the list explorable rather than merely printed.
- onSelectWorkstream fires alongside the card's own pin/unpin, for drilling
into that workstream's checks in a panel beside the card. The pin is local
and real on its own, so nothing breaks when the callback is omitted.Concepts
- Derived verdict — go / hold / no-go is recomputed from the rows on every render and cannot be supplied. The moment a verdict is a field, some code path sets it without touching the evidence, and the card starts printing a decision its own rows contradict. The corollary is that removing a workstream can change the answer, which is exactly what a reader expects a readiness board to do.
- Blocking versus outstanding — the split that makes a hold different from a no-go. A failed check, a critical blocker and a refused signature are known bad answers; a waiting check, a lesser blocker and a missing signature are absences. Collapsing the two loses the only distinction a release manager acts on: whether to fix something or to chase someone.
- Empty is not clear — a board with no workstreams renders its zero state instead of a green light. Both look like "nothing wrong" from the maths, and only one of them is; the zero state is the card refusing to let an unpopulated feed read as an approval.
- Deciding row — one total order (state, then failures, then criticals, then open items, then payload position) picks the single row the verdict rests on. It is also the default sort, so the top row is the answer's cause, and the readout opens on it before anyone points at anything — the card explains itself untouched.
- Pin, hover, focus — the readout listens to three sources with a fixed precedence: a pinned row wins, then the pointer, then keyboard focus, then the deciding row. A pin survives the pointer leaving, so a row can be held while the eye walks the rest of the list.
- Civil-day countdown — "days left" is a difference of calendar days read in the release team's zone, not a millisecond gap divided by 86 400 000. The second form loses a day whenever the window crosses a clock change and rounds "tomorrow morning" down to zero, and the printed date comes from the same instant so the two can never disagree.
Blocker Impact
A four-state blocker ranking whose bar is a transitive blast radius counted once per task, split into what only that blocker holds versus what is also stuck behind someone else, with the board's held-up share apportioned to exactly 100 percent.
Team Availability
A four-state week-by-week coverage card — one oversized coverage number for the week you picked, a rail of week bars against a target line, and a per-person day grid where partial days stay fractions instead of being rounded to whole days off.