feat(ui): visual polish primitives + token additions (Phase A)

Adds the design tokens the polish PRs (10a-e) will draw from:
shadow-xs/sm/md/lg/glow, radius scale tuned to spec, gradient utilities,
spring/smooth eases, and fast/base/slow durations. Introduces
StatusPill, KPITile, and EmptyState primitives plus a polished
PageHeader variant ('gradient') with optional eyebrow + KPI sub-line —
existing PageHeader callers stay on the plain variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 02:25:08 +02:00
parent 9b87b14c99
commit deafc5ef38
5 changed files with 368 additions and 176 deletions

View File

@@ -6,22 +6,54 @@ interface PageHeaderProps {
description?: string;
actions?: ReactNode;
className?: string;
/** Optional small uppercase label above the title. */
eyebrow?: string;
/** Optional one-line stats / KPI summary under the description. */
kpiLine?: ReactNode;
/** Render with the polished gradient-brand-soft background strip. */
variant?: 'plain' | 'gradient';
}
/**
* Consistent page-level header: title, optional description, and an action
* slot (typically buttons — e.g. "New Client", "Export").
* Consistent page-level header: title, optional description, KPI sub-line,
* eyebrow, and an action slot. Use `variant="gradient"` for hero strips on
* landing pages and detail headers; the plain variant remains the default so
* existing call-sites stay unchanged.
*/
export function PageHeader({ title, description, actions, className }: PageHeaderProps) {
export function PageHeader({
title,
description,
actions,
className,
eyebrow,
kpiLine,
variant = 'plain',
}: PageHeaderProps) {
const isGradient = variant === 'gradient';
return (
<div className={cn('flex items-start justify-between gap-4 mb-6', className)}>
<div
className={cn(
'mb-6 flex items-start justify-between gap-4',
isGradient &&
'rounded-xl border border-slate-200 bg-gradient-brand-soft px-5 py-4 shadow-xs',
className,
)}
>
<div className="min-w-0">
<h1 className="text-2xl font-bold text-foreground tracking-tight truncate">{title}</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
{eyebrow ? (
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-brand">
{eyebrow}
</div>
) : null}
<h1 className="truncate text-2xl font-bold tracking-tight text-foreground">{title}</h1>
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
{kpiLine ? (
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
{kpiLine}
</div>
) : null}
</div>
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
body?: React.ReactNode;
actions?: React.ReactNode;
className?: string;
}
export function EmptyState({ icon, title, body, actions, className }: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-200 bg-white px-6 py-12 text-center',
className,
)}
>
{icon ? (
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-gradient-brand-soft text-brand">
{icon}
</div>
) : null}
<div className="text-base font-semibold text-foreground">{title}</div>
{body ? <div className="max-w-md text-sm text-muted-foreground">{body}</div> : null}
{actions ? (
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">{actions}</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface KPITileProps extends React.HTMLAttributes<HTMLDivElement> {
title: string;
value: React.ReactNode;
/** Signed delta vs. prior period; positive = green, negative = red, undefined = no chip. */
delta?: number;
/** Pre-rendered sparkline (recharts) — caller decides shape. */
sparkline?: React.ReactNode;
/** Optional accent stripe colour token; defaults to brand. */
accent?: 'brand' | 'success' | 'warning' | 'mint' | 'teal' | 'purple';
}
const ACCENT_STRIPES: Record<NonNullable<KPITileProps['accent']>, string> = {
brand: 'bg-gradient-brand',
success: 'bg-success',
warning: 'bg-warning',
mint: 'bg-mint',
teal: 'bg-teal',
purple: 'bg-purple',
};
export function KPITile({
title,
value,
delta,
sparkline,
accent = 'brand',
className,
...props
}: KPITileProps) {
const deltaClass =
typeof delta === 'number'
? delta > 0
? 'text-success'
: delta < 0
? 'text-error'
: 'text-muted-foreground'
: '';
const deltaPrefix = typeof delta === 'number' ? (delta > 0 ? '+' : '') : '';
return (
<div
className={cn(
'group relative overflow-hidden rounded-xl border border-slate-200 bg-gradient-brand-soft p-5 shadow-sm transition-all duration-base ease-smooth hover:shadow-md',
className,
)}
{...props}
>
<div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden />
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{title}
</div>
<div className="mt-2 text-2xl font-semibold tabular-nums text-foreground">{value}</div>
{typeof delta === 'number' ? (
<div className={cn('mt-1 text-xs font-medium', deltaClass)}>
{deltaPrefix}
{delta}
</div>
) : null}
</div>
{sparkline ? <div className="h-12 w-24 shrink-0 opacity-80">{sparkline}</div> : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Status pill — a single visual primitive for "this thing is in state X" across
* documents, signers, reservations, interests. Replaces ad-hoc Badge variants
* sprinkled through detail pages so the colour mapping stays consistent.
*/
const statusPillVariants = cva(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors',
{
variants: {
status: {
// Document/signer lifecycle
pending: 'border-slate-200 bg-slate-100 text-slate-700',
sent: 'border-brand-100 bg-brand-50 text-brand-700',
partial: 'border-teal-light bg-teal-light/40 text-teal-dark',
signed: 'border-success-border bg-success-bg text-success',
completed: 'border-success-border bg-success-bg text-success',
expired: 'border-warning-border bg-warning-bg text-warning',
rejected: 'border-error-border bg-error-bg text-error',
cancelled: 'border-slate-300 bg-slate-200 text-slate-600',
declined: 'border-error-border bg-error-bg text-error',
// Reservation / interest lifecycle
active: 'border-success-border bg-success-bg text-success',
archived: 'border-slate-200 bg-slate-100 text-slate-500',
// Delivered (non-signature docs in hub)
delivered: 'border-purple-light bg-purple-light/40 text-purple-dark',
draft: 'border-slate-200 bg-white text-slate-600',
},
},
defaultVariants: {
status: 'pending',
},
},
);
export type StatusPillStatus = NonNullable<VariantProps<typeof statusPillVariants>['status']>;
interface StatusPillProps
extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof statusPillVariants> {
/** Optional leading dot — useful for "in-progress" style indicators. */
withDot?: boolean;
}
export function StatusPill({ status, withDot, className, children, ...props }: StatusPillProps) {
return (
<span className={cn(statusPillVariants({ status }), className)} {...props}>
{withDot ? <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden /> : null}
{children}
</span>
);
}