Component contract
Renders a status as a colored chip with text. Looks up presentation from a central registry rather than letting callers pass arbitrary colors.
kind: StatusKind— closed enum: see registry below.label?: string— overrides the registry's default label (rare).tone?: "default" | "subtle" | "outline"— visual variant; default fills the pill, subtle uses a light tint, outline is borderless background.icon?: ReactNode— optional leading icon. The registry supplies a default for some statuses (e.g. checkmark for "checked-in").aria-describedby?: string— id of an external longer description (used in jobs lists where "failed" expands to a reason).
Status registry
The closed set of statuses Voyage admin recognizes. Each has a semantic category that drives color, an icon, and a default label. Branches that need a status not in this list extend the registry — they don't pass freeform color or label inline.
| kind | category | default label | typical use |
|---|---|---|---|
draft | neutral | Draft | Unpublished events, designs, mailings |
scheduled | info | Scheduled | Events, mailings yet to send |
active | positive | Active | Events live, integrations connected |
archived | neutral | Archived | Past events |
invited | info | Invited | Guest list rows |
registered | positive | Registered | Guest list rows, accepted invites |
declined | neutral | Declined | Guest list rows |
waitlisted | warning | Waitlisted | Guest list, waitlist report |
checked-in | positive | Checked in | Day-of, check-in console |
cancelled | danger | Cancelled | Registrations, jobs, scheduled mailings |
queued | neutral | Queued | Report jobs, mailings |
running | info | Running | Report jobs in progress; pulses subtly |
ready | positive | Ready | Report job complete with downloadable artifact |
failed | danger | Failed | Jobs, mailings, sync runs |
expired | danger | Expired | Signed URLs, invitations, calendar links |
delivered | positive | Delivered | Email events |
opened | info | Opened | Email events |
clicked | info | Clicked | Email events |
bounced | danger | Bounced | Email events; usually paired with a reason |
suppressed | warning | Suppressed | Email events; recipient on suppression list |
connected | positive | Connected | Integrations (SF, Zoom) |
disconnected | danger | Disconnected | Integrations |
syncing | info | Syncing | Integrations mid-flight |
partial | warning | Partial | Imports/exports with some failures |
Failure modes
Color is the only differentiator
Trigger: registered, waitlisted, and declined render with different colors but the same icon (or no icon) and the user is colorblind.
Each status renders text always. Optional icon when registry supplies one. Text alone disambiguates; color reinforces. Harness: render each status with browser-level grayscale filter, screenshot, OCR-extract text, assert each status's text is the registry's default label.
Custom label silently dropped
Trigger: parent passes label="Re-invited" on a kind=invited pill but rendering shows the registry default "Invited".
When label prop is provided, it replaces the registry default. Harness: render kind=invited, label="Re-invited", assert visible text is "Re-invited".
Unknown status crashes the table
Trigger: API returns a status the registry doesn't know (e.g., new EFx status).
Component renders a fallback "Unknown" pill in neutral category and emits a dev-mode warning. It does NOT throw or render empty. Harness: render kind="not-a-real-status", assert visible text "Unknown" AND console warning logged.
Pulsing animation on "running" doesn't pause for prefers-reduced-motion
Trigger: subtle pulse animation runs even when the user has motion-reduction enabled.
Pulse animation respects @media (prefers-reduced-motion: reduce) and switches to a static visual indicator (e.g., a fixed dot) instead. Harness: emulate prefers-reduced-motion, render kind=running, assert no animation property in computed style.
Color contrast fails on subtle tone
Trigger: the "subtle" tone uses a 10% tint background with the same text color as default tone; contrast drops below 4.5:1.
Each tone × category combination must independently pass 4.5:1. Harness sweeps every status × every tone, asserts each passes contrast.
Long custom label breaks layout
Trigger: a parent passes a 50-character label and the pill expands across the entire row.
Pill caps at max-width: 16ch with text-overflow ellipsis. Full label is exposed via title attribute or a screen-reader-only span. Harness: render with 50-char label, assert pill computed-width ≤ 16ch + padding, assert title attr contains full label.
Pill is announced with verbose noise by screen readers
Trigger: each pill has aria-label="status pill registered icon checkmark" — screen reader reads the chrome.
Pill has role="status" only when it's a live update (e.g., job running → ready transition). Static pills are just text. Icon has aria-hidden="true". Harness: render static pill, assert SR text equals the visible label.
Accessibility
- Each pill renders the label as visible text. Color is not the only differentiator.
- Icons are decorative:
aria-hidden="true". - Pills used as live updates (running → ready transition) get
role="status"andaria-live="polite". - Color contrast: every status × every tone meets 4.5:1.
- Animation respects prefers-reduced-motion.
- axe-clean at severity ≥ serious.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-status-pill | Outer span | Component identity; data-status attr carries the kind |
ui-status-pill-icon | Leading icon | Present when registry supplies an icon |
ui-status-pill-label | Text content | Always present |
Agent test plan
Standalone probes against /admin-test/ui-status-pill-fixture with variants: every registered status × every tone, plus unknown, custom-label, long-custom-label, prefers-reduced-motion.
Probe list
- text-always-rendered: every status kind, label visible text
- registry-defaults: kind=registered, no label prop → text "Registered"
- custom-label-overrides: kind=invited, label="Re-invited" → text "Re-invited"
- unknown-fallback: kind="bogus" → text "Unknown" AND console warning
- data-status-attr: kind=ready → outer span has data-status="ready"
- icon-aria-hidden: pills with icons → icon has aria-hidden=true
- live-update-role-status: render kind=running, transition to kind=ready, assert role=status during transition
- color-contrast-default-every-status: ≥ 4.5:1 for all 24 statuses
- color-contrast-subtle-every-status: ≥ 4.5:1 for all 24 statuses in subtle tone
- color-contrast-outline-every-status: ≥ 4.5:1 for all 24 statuses in outline tone
- prefers-reduced-motion: kind=running, motion-reduce active, no animation in computed style
- long-label-truncates: 50-char label, computed width ≤ 16ch + padding
- long-label-title-attr: 50-char label, title attr contains full label
- text-disambiguates-without-color: render with grayscale filter, OCR-extract, assert text equals expected
- axe-clean-serious: no serious violations