feat(admin): extract KPIStat, EmptyState, LoadingState primitives; tighten AdminBadge palette

Phase 2 pattern extraction from the design/ui-revamp-2026-06 spec.

- KPIStat: extract the stat card pattern from DashboardClient (used four
  times inline). Supports tone (default/primary/accent/warning/danger)
  and an optional trend indicator. Reuses .admin-stat-card* CSS classes.
- EmptyState: generic centered icon + title + description + single
  primary CTA. Action renders as <Link> when href is provided, else a
  <button>; both use .ha-btn-primary.
- LoadingState: skeleton list driven by .ha-skeleton. Renders an
  optional ha-eyebrow label above N rows (icon tile + label + value
  placeholders). Each row uses the new admin design tokens.
- AdminBadge: tighten variants to the new design tokens. Legacy
  variant= prop kept for back-compat (default/success/warning/danger/
  info); new tone= prop adds neutral/primary/accent/success. All
  colors now reference --admin-* tokens; no hardcoded hex values.
  Added a 1px tone-aware border for stronger definition on the cream
  canvas. AdminStatusBadge + AdminCountBadge updated to use the new
  tone prop directly.
- design-system/index.tsx: re-export KPIStat, EmptyState, LoadingState
  from the design-system barrel alongside AdminBadge.

npx tsc --noEmit clean.
This commit is contained in:
Claude
2026-06-17 00:15:44 -06:00
parent 4ded68cec2
commit 8e937344ff
5 changed files with 438 additions and 26 deletions
+107
View File
@@ -0,0 +1,107 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { Inbox } from "lucide-react";
type EmptyStateAction = {
/** Visible label for the call-to-action. */
label: string;
/** If provided, renders a Next.js Link. Mutually exclusive with onClick. */
href?: string;
/** If provided, renders a button. */
onClick?: () => void;
};
type EmptyStateProps = {
/** Optional icon element (typically a lucide-react icon). */
icon?: ReactNode;
/** Required heading explaining the empty state. */
title: string;
/** Optional supporting copy shown below the title. */
description?: string;
/** Optional single primary action. */
action?: EmptyStateAction;
/** Extra class names appended to the root. */
className?: string;
};
/**
* EmptyState — generic empty state for admin pages.
*
* Centers an icon + title + description and (optionally) a single primary CTA
* styled with `.ha-btn-primary`. Uses the new admin design tokens for
* colors and Fraunces for the title.
*/
export default function EmptyState({
icon,
title,
description,
action,
className = "",
}: EmptyStateProps) {
return (
<div
className={`flex flex-col items-center justify-center text-center ${className}`.trim()}
style={{ padding: "3rem 1.5rem" }}
>
<div
className="mb-4"
style={{ color: "var(--admin-text-muted)" }}
aria-hidden="true"
>
{icon ?? <DefaultEmptyIcon />}
</div>
<h3
className="ha-display"
style={{
fontSize: "1.125rem",
color: "var(--admin-text-primary)",
}}
>
{title}
</h3>
{description && (
<p
className="mt-2 max-w-sm text-sm leading-relaxed"
style={{ color: "var(--admin-text-muted)" }}
>
{description}
</p>
)}
{action && (
<div className="mt-6">
<EmptyStateActionButton action={action} />
</div>
)}
</div>
);
}
function DefaultEmptyIcon() {
return <Inbox className="h-9 w-9" strokeWidth={1.25} />;
}
function EmptyStateActionButton({ action }: { action: EmptyStateAction }) {
// Pick Link vs button based on whether an href is provided. Both render
// with the same .ha-btn-primary class so the CTA is visually consistent.
if (action.href) {
return (
<Link href={action.href} className="ha-btn-primary">
{action.label}
</Link>
);
}
return (
<button
type="button"
onClick={action.onClick}
className="ha-btn-primary"
>
{action.label}
</button>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import type { ReactNode } from "react";
import { ArrowDown, ArrowRight, ArrowUp } from "lucide-react";
type KPITone = "default" | "primary" | "accent" | "warning" | "danger";
type KPIStatProps = {
/** Eyebrow label above the value (e.g. "Today's Orders") */
label: string;
/** The primary value to display. Accepts a node so callers can pass formatted strings, JSX, etc. */
value: ReactNode;
/** Optional icon element (typically a lucide-react icon) shown in the leading tile. */
icon?: ReactNode;
/** Controls the icon tile's background + icon color. Defaults to "default". */
tone?: KPITone;
/** Optional trend indicator rendered next to the value. */
trend?: { value: string; direction: "up" | "down" | "flat" };
/** Extra class names appended to the card. */
className?: string;
};
// Tone -> icon-tile background / icon color. Pulled from --admin-* tokens
// introduced in the Phase 1 palette unification. No hardcoded hex values.
const toneStyles: Record<KPITone, { bg: string; fg: string }> = {
default: { bg: "var(--admin-bg-subtle)", fg: "var(--admin-text-secondary)" },
primary: { bg: "var(--admin-primary-soft)", fg: "var(--admin-primary)" },
accent: { bg: "var(--admin-accent-soft)", fg: "var(--admin-accent)" },
warning: { bg: "var(--admin-warning-soft)", fg: "var(--admin-warning)" },
danger: { bg: "var(--admin-danger-soft)", fg: "var(--admin-danger)" },
};
// Trend direction -> icon + color. "up" reads as positive (primary),
// "down" as negative (danger), "flat" as neutral.
const trendStyles: Record<"up" | "down" | "flat", { Icon: typeof ArrowUp; color: string }> = {
up: { Icon: ArrowUp, color: "var(--admin-primary)" },
down: { Icon: ArrowDown, color: "var(--admin-danger)" },
flat: { Icon: ArrowRight, color: "var(--admin-text-muted)" },
};
/**
* KPIStat — compact metric card for admin surfaces.
*
* Extracted from the inline pattern previously duplicated four times in
* `DashboardClient.tsx`. Reuses the `.admin-stat-card*` classes from
* `admin-design-system.css` for the shell, then applies tone-aware
* background/foreground via inline `var(--admin-*)` styles.
*/
export default function KPIStat({
label,
value,
icon,
tone = "default",
trend,
className = "",
}: KPIStatProps) {
const { bg, fg } = toneStyles[tone];
return (
<div className={`admin-stat-card ${className}`.trim()}>
<div className="admin-stat-card-inner">
{icon && (
<div
className="admin-stat-icon"
style={{ backgroundColor: bg, color: fg }}
>
{icon}
</div>
)}
<div className="admin-stat-body">
<span className="admin-stat-label">{label}</span>
<div className="flex items-baseline gap-2">
<span className="admin-stat-value">{value}</span>
{trend && <TrendIndicator trend={trend} />}
</div>
</div>
</div>
</div>
);
}
function TrendIndicator({
trend,
}: {
trend: { value: string; direction: "up" | "down" | "flat" };
}) {
const { Icon, color } = trendStyles[trend.direction];
return (
<span
className="inline-flex items-center gap-0.5 text-[0.6875rem] font-semibold"
style={{ color }}
>
<Icon className="h-3 w-3" strokeWidth={2.25} aria-hidden="true" />
{trend.value}
</span>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
type LoadingStateProps = {
/** Optional eyebrow text rendered above the skeleton (e.g. "Loading orders..."). */
label?: string;
/** Number of skeleton rows to show. Defaults to 3. */
rows?: number;
/** Extra class names appended to the wrapper. */
className?: string;
};
/**
* LoadingState — generic skeleton for admin list/card surfaces.
*
* Renders an optional centered "Loading…" eyebrow above `rows` skeleton
* blocks, each composed of a label-sized bar and a value-sized bar. The
* shimmer comes from the shared `.ha-skeleton` class in
* `admin-design-system.css` so motion / reduced-motion stay consistent
* with the rest of the admin shell.
*/
export default function LoadingState({
label,
rows = 3,
className = "",
}: LoadingStateProps) {
const safeRows = Math.max(1, Math.floor(rows));
return (
<div
className={`flex flex-col items-stretch ${className}`.trim()}
style={{ padding: "1.5rem 1rem" }}
role="status"
aria-live="polite"
aria-busy="true"
>
{label && (
<span
className="ha-eyebrow mx-auto mb-4"
aria-label={label}
>
{label}
</span>
)}
<div className="space-y-3">
{Array.from({ length: safeRows }).map((_, i) => (
// Index is fine here — the list shape is stable per render.
// eslint-disable-next-line react/no-array-index-key
<SkeletonRow key={i} />
))}
</div>
</div>
);
}
function SkeletonRow() {
return (
<div
className="flex items-center gap-3 rounded-md border p-3"
style={{
backgroundColor: "var(--admin-card-bg)",
borderColor: "var(--admin-border)",
}}
>
{/* Icon-tile placeholder */}
<span
className="ha-skeleton block flex-shrink-0"
style={{ width: "2.25rem", height: "2.25rem", borderRadius: "0.5rem" }}
/>
{/* Label + value placeholders */}
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<span
className="ha-skeleton block"
style={{ width: "40%", height: "0.5rem", borderRadius: "0.25rem" }}
/>
<span
className="ha-skeleton block"
style={{ width: "65%", height: "0.75rem", borderRadius: "0.25rem" }}
/>
</div>
</div>
);
}
+145 -26
View File
@@ -1,33 +1,139 @@
"use client"; "use client";
import type { CSSProperties, ReactNode } from "react";
/**
* Visual variants for `AdminBadge`. Kept for backward compatibility —
* existing callers (status pills, add-on flags, etc.) continue to pass
* one of these values and are routed onto the new tone palette below.
*/
type BadgeVariant = "default" | "success" | "warning" | "danger" | "info"; type BadgeVariant = "default" | "success" | "warning" | "danger" | "info";
/**
* New tone palette. Matches the spec in
* `docs/superpowers/specs/2026-06-17-admin-redesign.md` §3. The previous
* `--admin-warning-light` / `--admin-danger-light` references have been
* replaced with the new `--admin-warning-soft` / `--admin-danger-soft`
* tokens. No hardcoded hex values remain in this file.
*/
type BadgeTone =
| "neutral"
| "primary"
| "accent"
| "warning"
| "danger"
| "success"
| "info";
type AdminBadgeProps = { type AdminBadgeProps = {
children: React.ReactNode; children: ReactNode;
/** Legacy variant name. Falls back to "default". Mapped onto `tone` internally. */
variant?: BadgeVariant; variant?: BadgeVariant;
/** New tone name. Takes precedence over `variant` when both are provided. */
tone?: BadgeTone;
/** Optional leading dot indicator. */
dot?: boolean; dot?: boolean;
/** Extra classes — typically size overrides like `"text-xs px-2 py-0.5"`. */
className?: string; className?: string;
}; };
const variantClasses: Record<BadgeVariant, { bg: string; text: string; dot: string }> = { type ToneStyle = {
default: { bg: "bg-[var(--admin-border-light)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-text-muted)]" }, bg: string;
success: { bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", dot: "bg-[var(--admin-accent)]" }, text: string;
warning: { bg: "bg-[var(--admin-warning-light)]", text: "text-[var(--admin-warning)]", dot: "bg-[var(--admin-warning)]" }, /** Optional border color; falls back to the background when unset. */
danger: { bg: "bg-[var(--admin-danger-light)]", text: "text-[var(--admin-danger)]", dot: "bg-[var(--admin-danger)]" }, border?: string;
info: { bg: "bg-[var(--admin-border)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-info)]" }, dot: string;
}; };
export default function AdminBadge({ // New tone palette. Every value references a `--admin-*` design token
children, // declared in `admin-design-system.css`.
variant = "default", const toneStyles: Record<BadgeTone, ToneStyle> = {
neutral: {
bg: "var(--admin-bg-subtle)",
text: "var(--admin-text-secondary)",
border: "var(--admin-border)",
dot: "var(--admin-text-muted)",
},
primary: {
bg: "var(--admin-primary-soft)",
text: "var(--admin-primary)",
border: "var(--admin-primary)",
dot: "var(--admin-primary)",
},
accent: {
bg: "var(--admin-accent-soft)",
text: "var(--admin-accent)",
border: "var(--admin-accent)",
dot: "var(--admin-accent)",
},
success: {
// "success" shares the primary botanical green per the unified palette.
bg: "var(--admin-primary-soft)",
text: "var(--admin-primary)",
border: "var(--admin-primary)",
dot: "var(--admin-primary)",
},
warning: {
bg: "var(--admin-warning-soft)",
text: "var(--admin-warning)",
border: "var(--admin-warning)",
dot: "var(--admin-warning)",
},
danger: {
bg: "var(--admin-danger-soft)",
text: "var(--admin-danger)",
border: "var(--admin-danger)",
dot: "var(--admin-danger)",
},
info: {
bg: "var(--admin-bg-subtle)",
text: "var(--admin-text-secondary)",
border: "var(--admin-border)",
dot: "var(--admin-text-secondary)",
},
};
// Map the legacy `variant` prop onto the new `tone` palette. The mapping
// preserves the visual intent of pre-refactor call sites.
const variantToTone: Record<BadgeVariant, BadgeTone> = {
default: "neutral",
success: "success",
warning: "warning",
danger: "danger",
info: "info",
};
export default function AdminBadge({
children,
variant = "default",
tone,
dot = false, dot = false,
className = "" className = "",
}: AdminBadgeProps) { }: AdminBadgeProps) {
const { bg, text, dot: dotColor } = variantClasses[variant]; // `tone` wins when provided; otherwise translate the legacy variant.
const resolvedTone: BadgeTone = tone ?? variantToTone[variant];
const { bg, text, border, dot: dotColor } = toneStyles[resolvedTone];
// Border color tracks tone when the tone is one of the chromatic ones;
// otherwise it falls back to the neutral hairline so the pill keeps a
// subtle outline against the cream canvas.
const style: CSSProperties = {
backgroundColor: bg,
color: text,
borderColor: border ?? "var(--admin-border)",
};
return ( return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${bg} ${text} ${className}`}> <span
{dot && <span className={`h-1.5 w-1.5 rounded-full ${dotColor}`} />} className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-bold ${className}`}
style={style}
>
{dot && (
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: dotColor }}
/>
)}
{children} {children}
</span> </span>
); );
@@ -39,19 +145,21 @@ type AdminStatusBadgeProps = {
className?: string; className?: string;
}; };
const statusConfig: Record<string, { variant: BadgeVariant; dot: boolean; label: string }> = { // Reuse the new `tone` prop directly so the status pill text style always
active: { variant: "success", dot: true, label: "Active" }, // matches the active AdminBadge palette.
inactive: { variant: "default", dot: true, label: "Inactive" }, const statusConfig: Record<string, { tone: BadgeTone; dot: boolean; label: string }> = {
pending: { variant: "warning", dot: true, label: "Pending" }, active: { tone: "success", dot: true, label: "Active" },
draft: { variant: "default", dot: true, label: "Draft" }, inactive: { tone: "neutral", dot: true, label: "Inactive" },
completed: { variant: "success", dot: true, label: "Completed" }, pending: { tone: "warning", dot: true, label: "Pending" },
cancelled: { variant: "danger", dot: true, label: "Cancelled" }, draft: { tone: "neutral", dot: true, label: "Draft" },
completed: { tone: "success", dot: true, label: "Completed" },
cancelled: { tone: "danger", dot: true, label: "Cancelled" },
}; };
export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgeProps) { export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgeProps) {
const config = statusConfig[status] || statusConfig.inactive; const config = statusConfig[status] || statusConfig.inactive;
return ( return (
<AdminBadge variant={config.variant} dot={config.dot} className={className}> <AdminBadge tone={config.tone} dot={config.dot} className={className}>
{config.label} {config.label}
</AdminBadge> </AdminBadge>
); );
@@ -60,15 +168,26 @@ export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgePro
// Count badge (circular) // Count badge (circular)
type AdminCountBadgeProps = { type AdminCountBadgeProps = {
count: number; count: number;
tone?: BadgeTone;
/** @deprecated Use `tone` instead. */
variant?: BadgeVariant; variant?: BadgeVariant;
className?: string; className?: string;
}; };
export function AdminCountBadge({ count, variant = "default", className = "" }: AdminCountBadgeProps) { export function AdminCountBadge({
const { bg, text } = variantClasses[variant]; count,
tone,
variant = "default",
className = "",
}: AdminCountBadgeProps) {
const resolvedTone: BadgeTone = tone ?? variantToTone[variant];
const { bg, text } = toneStyles[resolvedTone];
return ( return (
<span className={`inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5 text-xs font-bold ${bg} ${text} ${className}`}> <span
className={`inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5 text-xs font-bold ${className}`}
style={{ backgroundColor: bg, color: text }}
>
{count} {count}
</span> </span>
); );
} }
@@ -12,6 +12,12 @@ export { default as AdminActionMenu, AdminActionButton } from "./AdminActionMenu
export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination"; export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination";
export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge"; export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge";
// Phase 2 pattern primitives (admin-level, not in design-system/ folder)
// Re-exported here so callers can `import { KPIStat, EmptyState, LoadingState } from "@/components/admin/design-system"`.
export { default as KPIStat } from "../KPIStat";
export { default as EmptyState } from "../EmptyState";
export { default as LoadingState } from "../LoadingState";
// Design system components // Design system components
export { default as AdminButton, AdminIconButton } from "./AdminButton"; export { default as AdminButton, AdminIconButton } from "./AdminButton";
export { default as PageHeader } from "./PageHeader"; export { default as PageHeader } from "./PageHeader";