Incident Composer
The operator-side form for writing a status-page update — state picker, per-component impact, live email/SMS/webhook preview and a derived subscriber reach, with resolve behind a confirm.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/incident-composer.jsonPrompt
Build a React + TypeScript + Tailwind "IncidentComposer" block (lucide-react,
zod, a shadcn Button / Badge / DropdownMenu, and an AlertDialog-based confirm
dialog). It is the WRITE side of a status page: the form an on-call engineer
types the next update into — not the public page, and not a rendered history.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
incident: { id, title, startedAt, updates: { id?, state, message, at }[] } | null;
components: { id, name, currentImpact }[];
reach: { email, sms, webhooks };
asOf; errorMessage? }
state ∈ investigating | identified | monitoring | resolved.
currentImpact ∈ operational | degraded | partial_outage | major_outage.
- Props = z.infer of that schema plus onPublish(draft) (required),
onRetry?, onDeclareIncident?, locale = "en-US", timeZone = "UTC",
defaultChannels?, className, and the rest spread on the root <section>.
- onPublish receives the SAME object the preview rendered:
{ incidentId, state, message, components: {id, impact}[], impact,
channels, destinations, smsSegments, postedAt } — so a receipt or an
audit log can never disagree with what was on screen.
- asOf is an injected clock. Every duration, "ago" and preview timestamp
derives from it, so server and client render byte-identically.
Behavior
- Four first-class branches. "ready" with a null incident falls back to
empty: a composer pointed at no incident has nothing to compose.
- Thread: previous updates sorted newest first (unparseable timestamps sink
to the bottom in place), 3 shown with a disclosure for the rest, each with
its absolute time and an "ago" label — so the new update continues the
story instead of repeating it. Derived notes call out when the picked
state repeats the latest one, and when a non-resolved state would REOPEN
an already resolved incident.
- State picker: role=radiogroup with roving tabindex, arrow/Home/End moves
focus AND selection (APG). One hint line per state.
- Affected components: a checkbox per row, pre-ticked for every component
that is already non-operational (the incident's live footprint), plus a
DropdownMenu radio group for its impact. A row whose impact changes prints
"Changes from X to Y".
- Resolved is the one-way door: it FORCES every ticked component back to
operational (the chips, the preview and the payload all read the forced
value), warns inline that publishing closes the incident, and routes the
publish through a confirm dialog. A message is required for every state —
an update with no words tells subscribers nothing.
- Preview is a real tablist (roving tabindex, one panel per channel, hidden
panels get display:none from a class, not only the [hidden] attribute):
email renders subject + body + component list, SMS renders the composed
body, webhook renders the JSON payload verbatim. A channel that is off or
empty says so rather than pretending it will deliver.
- Reach: one checkbox per channel with its destination count. Counts are
DESTINATIONS, not people, and the component says so. A channel with zero
destinations cannot be armed (aria-disabled + guarded handler, so the
explaining sentence stays reachable) and says "No inboxes subscribed" on
the row itself — the disabled affordance is a dashed border, never alpha,
which would drop that sentence below the contrast floor. The armed total
and the per-channel percentages are derived; the percentages use LARGEST
REMAINDER so they add up to exactly 100.
- SMS cost is real arithmetic, per 3GPP TS 23.038 (GSM 03.38): GSM-7 basic
characters cost one septet, the nine extension characters (^ { } \ [ ~ ] |
€) cost two, 160 septets fit one message and concatenation drops that to
153 per segment. One character outside the alphabet forces UCS-2 for the
whole body (70 code units, 67 concatenated) — name the offending character
instead of silently doubling the bill. Both encodings are packed greedily,
never splitting a two-unit character across a segment boundary (an ESC +
character pair under GSM-7, a surrogate pair under UCS-2), so the count is
not ceil(units / limit). Print every figure in the unit the limits are in
— septets or UTF-16 units, beside the human character count, so the meter
and the limit sentence cannot contradict each other. Compose the SMS with
a plain hyphen: an em dash is outside GSM 03.38 and would push every
message to UCS-2.
- Publish: a ref guard swallows the double click that lands before the
pending state renders, a returned Promise drives pending, a rejection
keeps the typed message on screen and prints the reason, and success
clears only the message. Nothing is set after unmount. Picks re-seed
during render when the incident, its roster or its audience is replaced.
Rendering & styling
- Semantic tokens only: bg-card panels, muted-foreground supporting copy,
primary for the picked state / armed channel / operational, destructive
for outage impacts and the resolve warning. Colour never carries meaning
alone — the word is always printed beside it.
- Single column, readable from ~380px: every row is flex-wrap, user text is
wrap-anywhere, figures are tabular-nums, nothing is truncated away.
- focus-visible rings on every control, aria-labels on the icon-only
dropdowns, and pulses that stop under prefers-reduced-motion. The live
region carries a nonce so two identical publishes are both announced, and
every printed count agrees with its noun ("1 destination", never
"1 destinations"). cn() merges className.
Customization levers
- State ladder: STATE_ORDER / STATE_WORD / STATE_HINT drive the picker; add
a "scheduled" or "postmortem" state by extending the enum + those maps.
- Impact ladder: IMPACT_RANK and IMPACT_TO_INCIDENT are the only opinions
about severity — remap them to your own status vocabulary in one place.
- Channels: CHANNEL_ORDER / CHANNEL_UNIT / CHANNEL_ICON add Slack or RSS as
a fourth destination; the reach split and preview tabs follow automatically.
- Sub-blocks: the thread, preview and reach panels are independent
<Section>s — drop any of them for a compact composer, or move the preview
into a side column at lg: with a two-column grid.
- Density: THREAD_PREVIEW sets how much history shows before the disclosure;
the textarea rows and the p-3 / sm:p-4 section padding set the rest.
- Copy: every derived sentence is built in JS (reach, impact, resolve
warning), so localising means touching strings, not layout.Concepts
- One draft, many readouts — the preview, the reach figure, the SMS bill and the JSON payload are all rendered from the object
onPublishwill receive, so two numbers on this screen cannot disagree with each other or with the receipt. - The thread is context, not decoration — previous updates with their times are what stops the fifth update repeating the third; the composer marks a repeated state and a state change that would silently reopen a resolved incident.
- Resolve is a one-way door — picking it rewrites every ticked component to operational in front of you, says what publishing closes, and then asks once more; a state that ends an incident should never be one click away.
- Destinations, not people — reach counts inboxes, phones and endpoints; someone subscribed twice is counted twice, and the component says so instead of implying a headcount.
- Segment arithmetic is money — SMS is billed per 160-septet segment, and one curly quote flips the whole body to Unicode and 70 UTF-16 units; the composer counts in the billed unit, never splits a two-unit character across a segment, and names the character that did it rather than doubling the bill quietly.
- Empty channels cannot be armed — a channel with zero destinations stays unticked and explains itself, because a switch that promises a delivery nobody can receive is worse than no switch.