import * as React from "react"; import { cn } from "../lib/utils"; /** * A run/resource state, told three ways at once. * * Status is the one place a console cannot afford to speak in colour alone: a * red dot and a green dot are the same dot to roughly one man in twelve, and * identical in a greyscale print or a screenshot pasted into a ticket. So every * pill carries all three channels — a GLYPH whose silhouette differs per tone, * the tone's COLOUR, and the state's own LABEL as text. * * The glyphs are chosen to survive at 8px and to differ in outline rather than * in fill: a ring reads as "still open", a solid disc as "settled", a slashed * disc as "stopped". Two states never share one silhouette. * * Each tone draws its fill, border and text from ONE matched token triple. * That pairing is the whole point: a status colour is solved against its own * background, and nothing guarantees it against an arbitrary one. A component * that always brings its own background cannot be placed onto a plane that * breaks it. */ export type StatusTone = | "success" | "warning" | "danger" | "info" | "neutral" | "running"; export interface StatusPillProps extends Omit, "children"> { tone: StatusTone; children: React.ReactNode; /** * Drops the fill and border, leaving a toned GLYPH beside a label in the * inherited body colour. * * The label deliberately does not keep the tone. Every status text token * clears the 4.5:1 body floor on the page canvas — a gate holds them there — * but in light the margin is thin (4.51:1 for warning, 4.54:1 for success), * and the canvas is only one of the planes a caller can put a pill on. The * glyph keeps the tone instead because a glyph is non-text content against a * 3:1 floor, so the tone still reads with room to spare on any plane. * * For a control that supplies its own surface (a chip, a selected row). */ bare?: boolean; size?: "sm" | "md"; } const TONE_SURFACE: Record = { success: "bg-[var(--surface-success-bg)] text-[var(--surface-success-text)] border-[var(--surface-success-border)]", warning: "bg-[var(--surface-warning-bg)] text-[var(--surface-warning-text)] border-[var(--surface-warning-border)]", danger: "bg-[var(--surface-danger-bg)] text-[var(--surface-danger-text)] border-[var(--surface-danger-border)]", info: "bg-[var(--surface-info-bg)] text-[var(--surface-info-text)] border-[var(--surface-info-border)]", // `running` shares the info triple on purpose. `--status-running` exists but // is the green dot on a sandbox — a machine's power state, not a run's // outcome — and it ships as one colour with no paired background or text // tier, which is the pairing this component is built to guarantee. Green also // already means SUCCEEDED here, so a green "running" pill would say a run had // finished well while it was still going. running: "bg-[var(--surface-info-bg)] text-[var(--surface-info-text)] border-[var(--surface-info-border)]", neutral: "bg-[var(--surface-neutral-bg)] text-[var(--surface-neutral-text)] border-[var(--surface-neutral-border)]", }; /** * The text tier alone, for `bare`. Declared rather than recovered from * `TONE_SURFACE` by string search: picking the `text-` class out of that string * silently yields `undefined` the moment the triple gains a second `text-` * utility, and an undefined class removes the tone from the one channel `bare` * has left — the glyph — with nothing to signal that it happened. */ const TONE_TEXT: Record = { success: "text-[var(--surface-success-text)]", warning: "text-[var(--surface-warning-text)]", danger: "text-[var(--surface-danger-text)]", info: "text-[var(--surface-info-text)]", running: "text-[var(--surface-info-text)]", neutral: "text-[var(--surface-neutral-text)]", }; /** * One glyph per silhouette. `currentColor` throughout so the mark inherits the * tone's text colour and can never drift from the label beside it. */ function ToneGlyph({ tone }: { tone: StatusTone }) { // `aria-hidden` is written on every `` rather than carried in this // spread: the label beside the glyph already names the state, so a title here // would announce it twice — and a lint rule that reads JSX statically cannot // see the attribute through a spread, so it reports each mark as an unlabelled // image. const common = { viewBox: "0 0 8 8", className: "size-2 shrink-0", }; switch (tone) { // Settled and good: a solid disc. case "success": return ( ); // Settled and bad: a disc with a bar through it, so it differs from // success in OUTLINE and not only in hue. case "danger": return ( ); // Needs a person: a triangle. case "warning": return ( ); // Still moving: an open ring, visibly hollow at 8px. case "running": return ( ); // Informational: a square, the only right-angled mark in the set. case "info": return ( ); // Nothing has happened: a dash. No enclosed area at all. default: return ( ); } } const StatusPill = React.forwardRef( ({ className, tone, children, bare = false, size = "sm", ...props }, ref) => ( {/* In `bare` mode the tone rides on the glyph alone, so it is scoped to the wrapper the glyph sits in rather than applied to the whole pill. */} {children} ), ); StatusPill.displayName = "StatusPill"; export { StatusPill };