HextorUI
ui

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-badge

Source

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.

Dependencies

lucide-react@hextor/viz-tokens