diff --git a/src/components/shared/page-header.tsx b/src/components/shared/page-header.tsx
index c99c345..75e7d16 100644
--- a/src/components/shared/page-header.tsx
+++ b/src/components/shared/page-header.tsx
@@ -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 (
-
+
-
{title}
- {description && (
-
{description}
- )}
+ {eyebrow ? (
+
+ {eyebrow}
+
+ ) : null}
+
{title}
+ {description &&
{description}
}
+ {kpiLine ? (
+
+ {kpiLine}
+
+ ) : null}
- {actions &&
{actions}
}
+ {actions &&
{actions}
}
);
}
diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx
new file mode 100644
index 0000000..f3d4982
--- /dev/null
+++ b/src/components/ui/empty-state.tsx
@@ -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 (
+
+ {icon ? (
+
+ {icon}
+
+ ) : null}
+
{title}
+ {body ?
{body}
: null}
+ {actions ? (
+
{actions}
+ ) : null}
+
+ );
+}
diff --git a/src/components/ui/kpi-tile.tsx b/src/components/ui/kpi-tile.tsx
new file mode 100644
index 0000000..f54a038
--- /dev/null
+++ b/src/components/ui/kpi-tile.tsx
@@ -0,0 +1,70 @@
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+interface KPITileProps extends React.HTMLAttributes
{
+ 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, 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 (
+
+
+
+
+
+ {title}
+
+
{value}
+ {typeof delta === 'number' ? (
+
+ {deltaPrefix}
+ {delta}
+
+ ) : null}
+
+ {sparkline ?
{sparkline}
: null}
+
+
+ );
+}
diff --git a/src/components/ui/status-pill.tsx b/src/components/ui/status-pill.tsx
new file mode 100644
index 0000000..b740d11
--- /dev/null
+++ b/src/components/ui/status-pill.tsx
@@ -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['status']>;
+
+interface StatusPillProps
+ extends React.HTMLAttributes, VariantProps {
+ /** Optional leading dot — useful for "in-progress" style indicators. */
+ withDot?: boolean;
+}
+
+export function StatusPill({ status, withDot, className, children, ...props }: StatusPillProps) {
+ return (
+
+ {withDot ? : null}
+ {children}
+
+ );
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index c1049d3..6f1a89e 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,172 +1,174 @@
-import type { Config } from "tailwindcss";
+import type { Config } from 'tailwindcss';
+import tailwindcssAnimate from 'tailwindcss-animate';
export default {
- darkMode: ["class", "class"],
- content: ["./src/**/*.{ts,tsx}"],
+ darkMode: ['class', 'class'],
+ content: ['./src/**/*.{ts,tsx}'],
theme: {
- extend: {
- colors: {
- border: "hsl(var(--border))",
- input: "hsl(var(--input))",
- ring: "hsl(var(--ring))",
- background: "hsl(var(--background))",
- foreground: "hsl(var(--foreground))",
- primary: {
- DEFAULT: "hsl(var(--primary))",
- foreground: "hsl(var(--primary-foreground))",
- },
- secondary: {
- DEFAULT: "hsl(var(--secondary))",
- foreground: "hsl(var(--secondary-foreground))",
- },
- destructive: {
- DEFAULT: "hsl(var(--destructive))",
- foreground: "hsl(var(--destructive-foreground))",
- },
- muted: {
- DEFAULT: "hsl(var(--muted))",
- foreground: "hsl(var(--muted-foreground))",
- },
- accent: {
- DEFAULT: "hsl(var(--accent))",
- foreground: "hsl(var(--accent-foreground))",
- },
- popover: {
- DEFAULT: "hsl(var(--popover))",
- foreground: "hsl(var(--popover-foreground))",
- },
- card: {
- DEFAULT: "hsl(var(--card))",
- foreground: "hsl(var(--card-foreground))",
- },
- brand: {
- '50': '#d8e5f4',
- '100': '#b1cbe9',
- '200': '#89b0de',
- '300': '#6196d3',
- '400': '#3a7bc8',
- '500': '#2f6ab5',
- '600': '#255a9e',
- '700': '#1c4a87',
- DEFAULT: '#3a7bc8',
- dark: '#1e2844'
- },
- navy: {
- '50': '#cdcfd6',
- '100': '#9ea1af',
- '200': '#71768a',
- '300': '#474e66',
- '400': '#1e2844',
- '500': '#171f35',
- '600': '#101625',
- DEFAULT: '#1e2844'
- },
- sage: {
- DEFAULT: '#dae3c1',
- light: '#edf1e2',
- dark: '#b8c49e'
- },
- mint: {
- DEFAULT: '#add5b3',
- light: '#d6ead9',
- dark: '#7dba85'
- },
- teal: {
- DEFAULT: '#83aab1',
- light: '#b1cdd2',
- dark: '#5a8a92'
- },
- purple: {
- DEFAULT: '#685aa3',
- light: '#a49ac6',
- dark: '#4d4280'
- },
- success: {
- DEFAULT: '#2d8a4e',
- bg: '#e8f5e9',
- border: '#a5d6a7'
- },
- warning: {
- DEFAULT: '#e6a817',
- bg: '#fff8e1',
- border: '#ffe082'
- },
- error: {
- DEFAULT: '#d32f2f',
- bg: '#ffebee',
- border: '#ef9a9a'
- },
- sidebar: {
- DEFAULT: '#1e2844',
- text: '#cdcfd6',
- hover: '#171f35',
- active: '#3a7bc8',
- divider: '#474e66'
- }
- },
- fontFamily: {
- sans: [
- 'Inter',
- 'system-ui',
- '-apple-system',
- 'Arial',
- 'sans-serif'
- ],
- mono: [
- 'JetBrains Mono',
- 'ui-monospace',
- 'monospace'
- ],
- serif: [
- 'Georgia',
- 'Times New Roman',
- 'serif'
- ]
- },
- boxShadow: {
- sm: '0 1px 2px rgba(30, 40, 68, 0.06)',
- DEFAULT: '0 1px 3px rgba(30, 40, 68, 0.10), 0 1px 2px rgba(30, 40, 68, 0.06)',
- md: '0 4px 6px rgba(30, 40, 68, 0.10), 0 2px 4px rgba(30, 40, 68, 0.06)',
- lg: '0 10px 15px rgba(30, 40, 68, 0.10), 0 4px 6px rgba(30, 40, 68, 0.05)',
- xl: '0 20px 25px rgba(30, 40, 68, 0.10), 0 8px 10px rgba(30, 40, 68, 0.04)'
- },
- borderRadius: {
- sm: '0.25rem',
- DEFAULT: '0.375rem',
- md: '0.5rem',
- lg: '0.75rem',
- xl: '1rem'
- },
- width: {
- sidebar: '256px',
- 'sidebar-collapsed': '64px'
- },
- transitionDuration: {
- sidebar: '200ms'
- },
- keyframes: {
- 'accordion-down': {
- from: {
- height: '0'
- },
- to: {
- height: 'var(--radix-accordion-content-height)'
- }
- },
- 'accordion-up': {
- from: {
- height: 'var(--radix-accordion-content-height)'
- },
- to: {
- height: '0'
- }
- }
- },
- animation: {
- 'accordion-down': 'accordion-down 0.2s ease-out',
- 'accordion-up': 'accordion-up 0.2s ease-out'
- }
- }
+ extend: {
+ colors: {
+ border: 'hsl(var(--border))',
+ input: 'hsl(var(--input))',
+ ring: 'hsl(var(--ring))',
+ background: 'hsl(var(--background))',
+ foreground: 'hsl(var(--foreground))',
+ primary: {
+ DEFAULT: 'hsl(var(--primary))',
+ foreground: 'hsl(var(--primary-foreground))',
+ },
+ secondary: {
+ DEFAULT: 'hsl(var(--secondary))',
+ foreground: 'hsl(var(--secondary-foreground))',
+ },
+ destructive: {
+ DEFAULT: 'hsl(var(--destructive))',
+ foreground: 'hsl(var(--destructive-foreground))',
+ },
+ muted: {
+ DEFAULT: 'hsl(var(--muted))',
+ foreground: 'hsl(var(--muted-foreground))',
+ },
+ accent: {
+ DEFAULT: 'hsl(var(--accent))',
+ foreground: 'hsl(var(--accent-foreground))',
+ },
+ popover: {
+ DEFAULT: 'hsl(var(--popover))',
+ foreground: 'hsl(var(--popover-foreground))',
+ },
+ card: {
+ DEFAULT: 'hsl(var(--card))',
+ foreground: 'hsl(var(--card-foreground))',
+ },
+ brand: {
+ '50': '#d8e5f4',
+ '100': '#b1cbe9',
+ '200': '#89b0de',
+ '300': '#6196d3',
+ '400': '#3a7bc8',
+ '500': '#2f6ab5',
+ '600': '#255a9e',
+ '700': '#1c4a87',
+ DEFAULT: '#3a7bc8',
+ dark: '#1e2844',
+ },
+ navy: {
+ '50': '#cdcfd6',
+ '100': '#9ea1af',
+ '200': '#71768a',
+ '300': '#474e66',
+ '400': '#1e2844',
+ '500': '#171f35',
+ '600': '#101625',
+ DEFAULT: '#1e2844',
+ },
+ sage: {
+ DEFAULT: '#dae3c1',
+ light: '#edf1e2',
+ dark: '#b8c49e',
+ },
+ mint: {
+ DEFAULT: '#add5b3',
+ light: '#d6ead9',
+ dark: '#7dba85',
+ },
+ teal: {
+ DEFAULT: '#83aab1',
+ light: '#b1cdd2',
+ dark: '#5a8a92',
+ },
+ purple: {
+ DEFAULT: '#685aa3',
+ light: '#a49ac6',
+ dark: '#4d4280',
+ },
+ success: {
+ DEFAULT: '#2d8a4e',
+ bg: '#e8f5e9',
+ border: '#a5d6a7',
+ },
+ warning: {
+ DEFAULT: '#e6a817',
+ bg: '#fff8e1',
+ border: '#ffe082',
+ },
+ error: {
+ DEFAULT: '#d32f2f',
+ bg: '#ffebee',
+ border: '#ef9a9a',
+ },
+ sidebar: {
+ DEFAULT: '#1e2844',
+ text: '#cdcfd6',
+ hover: '#171f35',
+ active: '#3a7bc8',
+ divider: '#474e66',
+ },
+ },
+ fontFamily: {
+ sans: ['Inter', 'system-ui', '-apple-system', 'Arial', 'sans-serif'],
+ mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
+ serif: ['Georgia', 'Times New Roman', 'serif'],
+ },
+ boxShadow: {
+ xs: '0 1px 2px 0 rgb(15 23 42 / 0.04)',
+ sm: '0 2px 4px -1px rgb(15 23 42 / 0.06)',
+ DEFAULT: '0 1px 3px rgba(30, 40, 68, 0.10), 0 1px 2px rgba(30, 40, 68, 0.06)',
+ md: '0 4px 12px -2px rgb(15 23 42 / 0.08)',
+ lg: '0 12px 32px -8px rgb(15 23 42 / 0.12)',
+ xl: '0 20px 25px rgba(30, 40, 68, 0.10), 0 8px 10px rgba(30, 40, 68, 0.04)',
+ glow: '0 0 0 4px rgb(58 123 200 / 0.12)',
+ },
+ borderRadius: {
+ sm: '0.375rem',
+ DEFAULT: '0.375rem',
+ md: '0.5rem',
+ lg: '0.625rem',
+ xl: '0.875rem',
+ },
+ backgroundImage: {
+ 'gradient-brand': 'linear-gradient(135deg, #3a7bc8 0%, #2f6ab5 100%)',
+ 'gradient-brand-soft': 'linear-gradient(135deg, #d8e5f4 0%, #ffffff 100%)',
+ 'gradient-success': 'linear-gradient(135deg, #e8f5e9 0%, #ffffff 100%)',
+ 'gradient-warning': 'linear-gradient(135deg, #fef3c7 0%, #ffffff 100%)',
+ },
+ transitionTimingFunction: {
+ spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
+ smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ },
+ width: {
+ sidebar: '256px',
+ 'sidebar-collapsed': '64px',
+ },
+ transitionDuration: {
+ sidebar: '200ms',
+ fast: '150ms',
+ base: '200ms',
+ slow: '300ms',
+ },
+ keyframes: {
+ 'accordion-down': {
+ from: {
+ height: '0',
+ },
+ to: {
+ height: 'var(--radix-accordion-content-height)',
+ },
+ },
+ 'accordion-up': {
+ from: {
+ height: 'var(--radix-accordion-content-height)',
+ },
+ to: {
+ height: '0',
+ },
+ },
+ },
+ animation: {
+ 'accordion-down': 'accordion-down 0.2s ease-out',
+ 'accordion-up': 'accordion-up 0.2s ease-out',
+ },
+ },
},
- plugins: [require("tailwindcss-animate")],
+ plugins: [tailwindcssAnimate],
} satisfies Config;