Verdict badge & callout
The reserved pass/warn/serious/fail palette rendered as an inline badge and a block-level callout.
npx shadcn@latest add @hextor/verdict-badgeSource
import type { ReactNode } from "react";
import { Check, ShieldAlert, TriangleAlert, X } from "lucide-react";
import { cn } from "@/registry/hextor/lib/utils";
/**
* The reserved status palette, rendered.
*
* Verdict colours are deliberately not drawn from the axis slots so a pass/fail
* can never be mistaken for a skill axis — which is why correctness is blue and
* "pass" is green rather than the other way round. They are also mode-invariant:
* defined once under `:root` in @hextor/viz-tokens and absent from `.dark`, so
* the two can never drift.
*
* This item exists because the idiom was being retyped by hand. Across the
* originating app the same tinted-callout treatment appeared in five files with
* border alpha at 35%, 40% and 45% and background alpha at 6%, 8% and 9% — the
* same intent, three slightly different results, none of them wrong enough to
* notice. One place to change it is the whole point.
*/
export type Verdict = "pass" | "warn" | "serious" | "fail";
/**
* The mapping is the contract. `color-mix` against `transparent` rather than a
* second colour on purpose: mixing toward `background` would break the moment a
* consumer renders this on a card, a muted panel, or anything else that is not
* the page background.
*/
const VERDICT_VAR: Record<Verdict, string> = {
pass: "var(--verdict-pass)",
warn: "var(--verdict-warn)",
serious: "var(--verdict-serious)",
fail: "var(--verdict-fail)",
};
/**
* An icon per verdict, always rendered. Colour alone does not survive a
* screenshot in greyscale, a colour-vision deficiency, or a 200px thumbnail,
* and three of these four hues sit under 3:1 against a light surface.
*/
const VERDICT_ICON: Record<Verdict, typeof Check> = {
pass: Check,
warn: TriangleAlert,
serious: ShieldAlert,
fail: X,
};
export function verdictColor(verdict: Verdict): string {
return VERDICT_VAR[verdict];
}
export function VerdictBadge({
verdict,
children,
showIcon = true,
className,
}: {
verdict: Verdict;
/** The label. Required — a bare coloured dot is not a readable verdict. */
children: ReactNode;
showIcon?: boolean;
className?: string;
}) {
const color = VERDICT_VAR[verdict];
const Icon = VERDICT_ICON[verdict];
return (
<span
data-slot="verdict-badge"
data-verdict={verdict}
className={cn(
"inline-flex w-fit items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium",
className,
)}
style={{
color,
background: `color-mix(in oklab, ${color} 10%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${color} 30%, transparent)`,
}}
>
{showIcon && <Icon aria-hidden className="size-3.5 shrink-0" />}
{children}
</span>
);
}
/**
* The block-level form: a tinted, outlined panel for a message that needs to
* stop the reader. `ring` rather than `border` follows the house convention —
* a border changes layout, a ring does not.
*/
export function VerdictCallout({
verdict,
title,
children,
showIcon = true,
className,
}: {
verdict: Verdict;
title?: ReactNode;
children?: ReactNode;
showIcon?: boolean;
className?: string;
}) {
const color = VERDICT_VAR[verdict];
const Icon = VERDICT_ICON[verdict];
return (
<div
data-slot="verdict-callout"
data-verdict={verdict}
className={cn("flex items-start gap-2.5 rounded-lg px-3 py-2.5 text-sm", className)}
style={{
background: `color-mix(in oklab, ${color} 8%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${color} 40%, transparent)`,
}}
>
{showIcon && <Icon aria-hidden className="mt-px size-4 shrink-0" style={{ color }} />}
<div className="min-w-0 flex-1">
{title && (
<p className="font-medium" style={{ color }}>
{title}
</p>
)}
{children && <div className="text-muted-foreground">{children}</div>}
</div>
</div>
);
}
Docs
Verdict colours are deliberately not drawn from the axis slots, so a pass/fail can never be mistaken for a skill axis — that is why correctness is blue and pass is green rather than the reverse. They are mode-invariant: defined once under :root by @hextor/viz-tokens and absent from .dark, so the two can never drift.
Every verdict renders an icon alongside its colour, and the label is required. Colour alone does not survive greyscale, a colour-vision deficiency, or a thumbnail, and three of the four hues sit under 3:1 against a light surface.
The tint is color-mix against transparent, never against background: mixing toward the page background breaks the moment you put one of these on a card or a muted panel.
Exists because the idiom was being retyped by hand. In the originating app the same treatment appeared in five files with border alpha at 35%, 40% and 45% and background alpha at 6%, 8% and 9% — same intent, three different results, none wrong enough to notice.