From deafc5ef3844bb97354023686132f135192e07f6 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Tue, 28 Apr 2026 02:25:08 +0200 Subject: [PATCH] feat(ui): visual polish primitives + token additions (Phase A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/shared/page-header.tsx | 50 +++- src/components/ui/empty-state.tsx | 33 +++ src/components/ui/kpi-tile.tsx | 70 ++++++ src/components/ui/status-pill.tsx | 55 +++++ tailwind.config.ts | 336 +++++++++++++------------- 5 files changed, 368 insertions(+), 176 deletions(-) create mode 100644 src/components/ui/empty-state.tsx create mode 100644 src/components/ui/kpi-tile.tsx create mode 100644 src/components/ui/status-pill.tsx 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;