WIP Limits
A four-state work-in-progress card: every board column read against its own limit, with a breach as an explicit state and a column that was never capped kept out of the verdict entirely.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-wip-limits.jsonPrompt
Build a React + TypeScript + Tailwind "ChartWipLimits" card (plain HTML and
CSS, no chart library) with zod. It reads one kanban board column by column
against the limit that column agreed to, and the whole product is in three
distinctions most implementations flatten: a breach is not "high", a column
sitting exactly on its line is not a breach, and a column that was never
capped is neither.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; unit?: string;
columns: { id: string; label: string; count: number;
limit: number | null }[] }.
- columns are in left-to-right BOARD order. count is what the column holds
right now; limit is what it is allowed to hold.
- limit carries three meanings and they are never interchangeable:
a positive number is the cap; 0 means the column is CLOSED, so a single
item in it is already a breach and an empty closed column is the only
compliant one; null means NO LIMIT WAS CONFIGURED — the column can neither
comply nor breach. Make limit required (not optional) so a feed that forgot
to join the policy table fails loudly instead of silently un-policing a
column.
- unit is a plural noun (default "cards") used by every printed sentence; a
count of exactly 1 drops a trailing "s" so nothing reads "1 cards".
- Component props = z.infer of the schema plus order: "board" | "pressure"
(default "board"), showRails (default true), formatCount, onRetry,
className and the native div props through forwardRef.
- Ship a pure module beside the schema: inspectWipLimitsData() for the
structural pass, apportionPercents() for the shares, and
buildWipLimitsLayout() returning one reading per column (state, headroom,
over, rail geometry, share) plus the board totals (totalCount, totalLimit,
policedCount, unpolicedCount, overCount, atLimitCount, closedCount,
headroom, overflow, worst, tightest).
Behavior — the verdicts, which are the product
- FIVE column states, resolved once in the layout so no renderer has to
remember the rules: "unpoliced" (limit null), "over" (count > limit),
"closed" (limit 0 and count 0), "at-limit" (count === limit, limit > 0),
"under". Note the order: a closed column holding anything is "over", not
"closed".
- AT THE LINE IS NOT A BREACH. count === limit is full and legal — it means
"pull nothing new", not "you broke the policy". Counting it as a breach is
the fastest way to make a team stop trusting the card.
- UNPOLICED IS NOT COMPLIANT. A column with limit null contributes to no
headroom, no overflow, no breach count, and to no board verdict of any
kind; it only contributes to the board's own total. When NO column has a
limit, the sentence under the big number must say so outright instead of
letting "0 over limit" read as a clean bill of health — a board with no
policy is not a board that is passing.
- Headroom = sum of max(0, limit − count) over policed columns; overflow =
sum of max(0, count − limit). Both skip unpoliced columns.
- SHARE OF THE BOARD by LARGEST REMAINDER, never per-row rounding: floor
every exact share, then hand the leftover points to the largest fractional
parts first, ties to the earlier column. Per-row rounding is what prints a
board totalling 99% or 101%. When the board holds nothing at all, drop the
share instead of printing a 0% that came from dividing by zero.
- Guard every denominator: an empty board, a board where every count is 0,
and a column with limit 0 and count 0 all have to render without producing
NaN, Infinity or a 0/0. Nothing non-finite may be formatted: print an em
dash instead.
- The four states are first-class branches of one bg-card panel: a pulsing
skeleton (aria-hidden, plus an sr-only role="status"), an empty state, an
error state carrying either the transport message or the specific contract
issue plus a "Try again" button only when onRetry exists, and ready.
status="ready" with no columns falls through to the empty copy.
- The structural pass refuses duplicate ids (readings are keyed by id, so a
duplicate lets one column's verdict overwrite another's), negative counts
or limits, and non-finite values — including Infinity as a limit, which is
a cap that reports the column permanently compliant. Render the reason in
the error branch instead of drawing a plausible board.
Rendering & styling
- One focal number: the count of columns over their limit, set in the
largest type on the card, in text-destructive only when it is above zero,
under a small label and above ONE muted sentence naming the worst column
("In review holds 9 against a limit of 4") or, when nothing is over, the
headroom and the tightest column. Everything else — board total, capped
columns, headroom, overflow — is a small stat pair in a <dl> beside it.
Hierarchy comes from size and weight, not from boxing each stat in a card.
- One row per column, unboxed, generous vertical rhythm: label on the left,
the count in large tabular-nums on the right with a small "/ limit" or
"no limit" suffix, a capacity rail under them, and one muted caption line
carrying the verdict and the share.
- RAIL SCALE IS PER COLUMN: max(count, limit). The track therefore ENDS at
that column's limit while there is headroom left, and when the column is
over, the line falls at limit/count with the excess painted past it. A full
rail always means "at this column's line" whatever the numbers are — say
out loud in the footer that widths compare fullness, never counts across
rows. A 2px tick marks the line; nudge it fully inside the track at 0% and
100%, or the two most important positions are the two that half disappear.
- THE THREE LIMIT MEANINGS MUST SURVIVE WITHOUT COLOUR. A policed column gets
a real rail; an unpoliced column gets a dashed rule and the words "no limit
set"; a closed column gets a rail whose line sits at the very start. Each
distinction is carried by the suffix, the caption AND the rail, so turning
showRails off loses none of them.
- Colour: bg-muted track, var(--chart-1) for the part within the limit,
bg-destructive for the part past it, bg-foreground for the limit tick.
Destructive appears only when something is actually over, which is what
makes it the single loud thing on an otherwise neutral card. No gradients,
no glow, no shadowed tiles.
- Accessibility: the whole card is real text read exactly once — there is no
aria-hidden picture with a transcript beside it, because the rails are
decoration over numbers that are already written out. Rails and the empty
state's glyph are aria-hidden; the retry button is the only interactive
element and carries a focus-visible ring.
- Column labels WRAP, they are not truncated: the name is the row's identity
and an ellipsis with nowhere to reveal the rest hides the thing being
judged. Use overflow-wrap:anywhere plus min-width:0 so an unbroken name
cannot set the row's minimum width and push the count off the card.
- Motion: the only animation is the skeleton pulse, with motion-reduce
disabling it. Nothing about reading the card depends on motion, and there
are no timers, observers or listeners to clean up.
Customization levers
- order="pressure" sorts breaches first (by how far past the line, then by
size), then the columns on their line, then the ones with least room, with
unpoliced last — use it on a wall display where nobody will scroll; keep
"board" when the card sits next to the board itself.
- showRails={false} is the density dial for a dashboard tile: every verdict
survives as text.
- formatCount and unit re-point every printed number — story points instead
of cards, "GB" instead of a noun.
- The focal number is a swap point: exchange the breach count for headroom or
for the board total if your team pulls rather than polices, keeping the
label/number/sentence shape.
- Palette: --chart-1 for the within-limit fill is the only accent; re-point it
to any --chart-n, and keep --destructive for the excess so the loud colour
stays reserved for a real breach.
- Density: the row gap and the numeral sizes are the two dials. Drop the
share from the caption before shrinking the count.
- Interaction: the card is deliberately static. Wire a row onClick to filter
the board by that column, or lift `worst` into a parent to drive a banner.Concepts
- Breach as an explicit state — a column is over only when
count > limit, and that verdict is computed once and carried as a named state rather than re-derived at every render site. Sitting exactly on the line is a separate state: it is full, it means "pull nothing new", and calling it a breach is the fastest way to teach a team to ignore the card. - Unpoliced is not compliant — a column with
limit: nullhas no verdict to give. It is excluded from headroom, from overflow and from the breach count, and when no column on the board has a limit the card says that out loud instead of reporting a comfortable "0 over limit". A board with no policy is not a board that is passing, and that is the single most common lie this kind of chart tells. - A closed column —
limit: 0is a real policy, not a missing one: the column is closed, so the first item to land in it is already a breach and an empty one is the only compliant state. It is drawn with a real rail whose line sits at the very start, which is what keeps it visually distinct from the dashed rule of a column nobody capped. - Per-column rail scale — each rail is scaled to
max(count, limit), so the track ends at that column's own line and a full rail always means "at the limit" whatever the numbers behind it are. The price is that widths compare fullness and never counts across rows, which is why the footer says so rather than leaving a reader to assume the bars share a scale. - Headroom as the pull signal — the sum of
max(0, limit − count)over policed columns answers the only question a standup actually asks of a limit: how much more may we pull before something breaks. When it reaches zero with nothing over, the board is full rather than broken, and the sentence under the big number changes to say exactly that. - Largest-remainder shares — the per-column share of the board's work in progress is apportioned by flooring every exact share and handing the leftover points to the largest fractional parts. Rounding each share on its own is what makes a board print 99% or 101%; when the board holds nothing at all the share is dropped entirely rather than printed as a 0% that came from dividing by zero.
Aging WIP
A four-state aging chart for a team board — every item still in progress plotted by column against how many days it has been in flight, read against the percentile bands this board's own finished work actually hit.
Critical Path
A four-state task dependency board that derives the schedule from durations and links alone — a forward and backward pass, total and free float per task, the zero-float route drawn in the one accent colour, and a cyclic plan named rather than walked forever.