- {/* Top tagline */}
-
- CVPILOT — AI JOB INTELLIGENCE
+
+ {/* Tagline */}
+
+ CVpilot — AI Job Intelligence
-
- {/* Heading */}
+
+ {/* Heading with gradient on "right" */}
Match your resume
@@ -35,53 +31,52 @@ export function Hero() {
{" "}
jobs.
-
+
{/* Subtext */}
-
- A precision tool for ambitious people. Use neural-matching to bypass
- the noise and land high-stakes opportunities.
+
+ A precision tool for ambitious people. Use neural-matching to bypass the noise and land high-stakes opportunities.
-
- {/* CTA */}
+
+ {/* CTA Button */}
-
- {/* Stats */}
-
-
-
+
+ {/* Stats Grid */}
+
+
+
2,400+
-
+
JOBS INDEXED
-
-
-
+
+
+
OPTIMIZED
-
+
ATS PROMPTS
-
-
-
+
{/* Dashboard Preview */}
-
+
);
}
diff --git a/frontend/src/components/Profile/ResumeUpdateSection.tsx b/frontend/src/components/Profile/ResumeUpdateSection.tsx
index 4e7aab5..2a8f15f 100644
--- a/frontend/src/components/Profile/ResumeUpdateSection.tsx
+++ b/frontend/src/components/Profile/ResumeUpdateSection.tsx
@@ -3,6 +3,7 @@ import { useState, useRef, type ChangeEvent, type DragEvent } from "react";
interface ResumeUpdateSectionProps {
currentFileName?: string;
currentFileUrl?: string | null;
+ hasExistingResume?: boolean;
onReplace?: (file: File) => void;
isUploading?: boolean;
}
@@ -10,6 +11,7 @@ interface ResumeUpdateSectionProps {
export function ResumeUpdateSection({
currentFileName,
currentFileUrl,
+ hasExistingResume = false,
onReplace,
isUploading = false,
}: ResumeUpdateSectionProps) {
@@ -38,6 +40,12 @@ export function ResumeUpdateSection({
if (inputRef.current) inputRef.current.value = "";
};
+ const buttonText = isUploading
+ ? "Uploading…"
+ : hasExistingResume
+ ? "Replace Resume"
+ : "Upload Resume";
+
return (
{/* Section header */}
@@ -54,19 +62,22 @@ export function ResumeUpdateSection({
- {/* Current file */}
+ {/* Current file - only show if we have a filename */}
{currentFileName && (
-
- {currentFileName}
-
+
+
Current Resume
+
+ {currentFileName}
+
+
{currentFileUrl && (
View ↗
@@ -125,7 +136,7 @@ export function ResumeUpdateSection({
Uploading…
>
) : (
- "Replace resume"
+ buttonText
)}
diff --git a/frontend/src/components/ui/Alert.tsx b/frontend/src/components/ui/Alert.tsx
new file mode 100644
index 0000000..d7af91e
--- /dev/null
+++ b/frontend/src/components/ui/Alert.tsx
@@ -0,0 +1,122 @@
+import React, { useEffect } from 'react';
+
+type AlertVariant = 'success' | 'error' | 'warning' | 'info';
+
+export interface AlertProps extends React.HTMLAttributes
{
+ variant?: AlertVariant;
+ title?: string;
+ dismissible?: boolean;
+ onDismiss?: () => void;
+ autoDismiss?: number;
+}
+
+const variantClasses: Record = {
+ success: {
+ bg: 'bg-status-success/10',
+ border: 'border-status-success/30',
+ text: 'text-status-success',
+ icon: 'text-status-success',
+ },
+ error: {
+ bg: 'bg-status-danger/10',
+ border: 'border-status-danger/30',
+ text: 'text-status-danger',
+ icon: 'text-status-danger',
+ },
+ warning: {
+ bg: 'bg-status-warning/10',
+ border: 'border-status-warning/30',
+ text: 'text-status-warning',
+ icon: 'text-status-warning',
+ },
+ info: {
+ bg: 'bg-status-info/10',
+ border: 'border-status-info/30',
+ text: 'text-status-info',
+ icon: 'text-status-info',
+ },
+};
+
+export const Alert = React.forwardRef(
+ (
+ {
+ variant = 'info',
+ title,
+ dismissible = false,
+ onDismiss,
+ autoDismiss = 4000,
+ className = '',
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const [isDismissed, setIsDismissed] = React.useState(false);
+
+ useEffect(() => {
+ if (isDismissed) return; // Don't set timer if already dismissed
+
+ const timer = setTimeout(() => {
+ setIsDismissed(true);
+ onDismiss?.();
+ }, autoDismiss);
+ return () => clearTimeout(timer);
+ }, [autoDismiss, onDismiss, isDismissed]);
+
+ if (isDismissed) return null;
+
+ const variantClass = variantClasses[variant] ?? variantClasses['info'];
+
+ return (
+
+
+ {variant === 'success' && (
+
+
+
+ )}
+ {variant === 'error' && (
+
+
+
+ )}
+ {variant === 'warning' && (
+
+
+
+ )}
+ {variant === 'info' && (
+
+
+
+ )}
+
+
+
+ {title &&
{title} }
+
{children}
+
+
+ {dismissible && (
+
{
+ setIsDismissed(true);
+ onDismiss?.();
+ }}
+ className="flex-shrink-0 text-gray-400 hover:text-gray-300 transition-colors"
+ >
+
+
+
+
+ )}
+
+ );
+ }
+);
+
+Alert.displayName = 'Alert';
diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..6ed9d60
--- /dev/null
+++ b/frontend/src/components/ui/Badge.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+
+type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info' | 'muted';
+
+export interface BadgeProps extends React.HTMLAttributes {
+ variant?: BadgeVariant;
+ size?: 'sm' | 'md';
+}
+
+const variantClasses: Record = {
+ default: 'bg-accent-primary/10 text-accent-primary border border-accent-primary/20',
+ success: 'bg-status-success/10 text-status-success border border-status-success/20',
+ warning: 'bg-status-warning/10 text-status-warning border border-status-warning/20',
+ danger: 'bg-status-danger/10 text-status-danger border border-status-danger/20',
+ info: 'bg-status-info/10 text-status-info border border-status-info/20',
+ muted: 'bg-text-tertiary/10 text-text-tertiary border border-text-tertiary/20',
+};
+
+const sizeClasses: Record<'sm' | 'md', string> = {
+ sm: 'px-2 py-0.5 text-xs rounded-md',
+ md: 'px-3 py-1 text-sm rounded-lg',
+};
+
+export const Badge = React.forwardRef(
+ (
+ { variant = 'default', size = 'md', className = '', children, ...props },
+ ref
+ ) => {
+ const variantClass = variantClasses[variant];
+ const sizeClass = sizeClasses[size];
+ const baseClasses = 'inline-flex items-center font-medium whitespace-nowrap';
+
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+Badge.displayName = 'Badge';
diff --git a/frontend/src/components/ui/BookmarksProvider.tsx b/frontend/src/components/ui/BookmarksProvider.tsx
new file mode 100644
index 0000000..a1d5688
--- /dev/null
+++ b/frontend/src/components/ui/BookmarksProvider.tsx
@@ -0,0 +1,75 @@
+import React, { createContext, useContext, useState, useEffect } from 'react';
+
+interface BookmarksContextType {
+ bookmarks: Set;
+ addBookmark: (jobId: string) => void;
+ removeBookmark: (jobId: string) => void;
+ isBookmarked: (jobId: string) => boolean;
+ getBookmarkCount: () => number;
+}
+
+const BookmarksContext = createContext(undefined);
+
+const STORAGE_KEY = 'cvpilot_bookmarks';
+
+export function BookmarksProvider({ children }: { children: React.ReactNode }) {
+ const [bookmarks, setBookmarks] = useState>(new Set());
+ const [isInitialized, setIsInitialized] = useState(false);
+
+ // Load bookmarks from localStorage on mount
+ useEffect(() => {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ setBookmarks(new Set(JSON.parse(stored)));
+ }
+ } catch (err) {
+ console.error('Failed to load bookmarks:', err);
+ } finally {
+ setIsInitialized(true);
+ }
+ }, []);
+
+ // Save bookmarks to localStorage whenever they change
+ useEffect(() => {
+ if (isInitialized) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(bookmarks)));
+ } catch (err) {
+ console.error('Failed to save bookmarks:', err);
+ }
+ }
+ }, [bookmarks, isInitialized]);
+
+ const addBookmark = (jobId: string) => {
+ setBookmarks((prev) => new Set(prev).add(jobId));
+ };
+
+ const removeBookmark = (jobId: string) => {
+ setBookmarks((prev) => {
+ const next = new Set(prev);
+ next.delete(jobId);
+ return next;
+ });
+ };
+
+ const isBookmarked = (jobId: string) => bookmarks.has(jobId);
+
+ const getBookmarkCount = () => bookmarks.size;
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useBookmarks() {
+ const context = useContext(BookmarksContext);
+ if (!context) {
+ throw new Error('useBookmarks must be used within BookmarksProvider');
+ }
+ return context;
+}
diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx
new file mode 100644
index 0000000..99b0877
--- /dev/null
+++ b/frontend/src/components/ui/Button.tsx
@@ -0,0 +1,110 @@
+import React from 'react';
+
+export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
+export type ButtonSize = 'sm' | 'md' | 'lg';
+
+export interface ButtonProps extends React.ButtonHTMLAttributes {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ fullWidth?: boolean;
+ isLoading?: boolean;
+ icon?: React.ReactNode;
+ iconPosition?: 'left' | 'right';
+}
+
+const variantClasses: Record = {
+ primary: `
+ bg-accent-primary hover:bg-indigo-700
+ text-white font-semibold
+ border border-accent-bright/40 hover:border-accent-bright/60
+ shadow-glow-sm hover:shadow-glow-md
+ disabled:opacity-50 disabled:cursor-not-allowed
+ transition-all duration-base
+ `,
+ secondary: `
+ bg-surface-hover hover:bg-surface-active
+ text-text-primary hover:text-text-inverse
+ border border-border-normal hover:border-border-hover
+ disabled:opacity-50 disabled:cursor-not-allowed
+ transition-all duration-base
+ `,
+ ghost: `
+ bg-transparent hover:bg-surface-hover
+ text-text-secondary hover:text-text-primary
+ border border-transparent hover:border-border-light
+ disabled:opacity-50 disabled:cursor-not-allowed
+ transition-all duration-base
+ `,
+ danger: `
+ bg-status-danger hover:bg-red-600
+ text-white font-semibold
+ border border-status-danger/40 hover:border-status-danger/60
+ shadow-sm hover:shadow-md
+ disabled:opacity-50 disabled:cursor-not-allowed
+ transition-all duration-base
+ `,
+ success: `
+ bg-status-success hover:bg-emerald-500
+ text-white font-semibold
+ border border-status-success/40 hover:border-status-success/60
+ shadow-sm hover:shadow-md
+ disabled:opacity-50 disabled:cursor-not-allowed
+ transition-all duration-base
+ `,
+};
+
+const sizeClasses: Record = {
+ sm: 'px-3 py-1.5 text-xs font-medium rounded-md gap-1.5',
+ md: 'px-4 py-2 text-sm font-medium rounded-lg gap-2',
+ lg: 'px-6 py-3 text-base font-semibold rounded-xl gap-2.5',
+};
+
+export const Button = React.forwardRef(
+ (
+ {
+ variant = 'primary',
+ size = 'md',
+ fullWidth = false,
+ isLoading = false,
+ icon,
+ iconPosition = 'right',
+ className = '',
+ disabled = false,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const baseClasses = 'inline-flex items-center justify-center font-medium active:scale-95 whitespace-nowrap';
+ const variantClass = variantClasses[variant];
+ const sizeClass = sizeClasses[size];
+ const widthClass = fullWidth ? 'w-full' : '';
+
+ return (
+
+ {isLoading ? (
+ <>
+
+
+
+
+ {children}
+ >
+ ) : (
+ <>
+ {icon && iconPosition === 'left' && {icon} }
+ {children}
+ {icon && iconPosition === 'right' && {icon} }
+ >
+ )}
+
+ );
+ }
+);
+
+Button.displayName = 'Button';
diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx
new file mode 100644
index 0000000..4c92b14
--- /dev/null
+++ b/frontend/src/components/ui/Card.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+
+type CardVariant = 'default' | 'elevated' | 'bordered';
+
+export interface CardProps extends React.HTMLAttributes {
+ variant?: CardVariant;
+ interactive?: boolean;
+}
+
+interface CardSubComponent {
+ Header: React.FC>;
+ Body: React.FC>;
+ Footer: React.FC>;
+}
+
+const variantClasses: Record = {
+ default: 'bg-bg-surface border border-border-light hover:border-border-normal',
+ elevated: 'bg-bg-surface border border-border-light shadow-md hover:shadow-lg',
+ bordered: 'bg-transparent border border-border-normal hover:border-border-hover',
+};
+
+const CardComponent = React.forwardRef(
+ (
+ { variant = 'default', interactive = false, className = '', children, ...props },
+ ref
+ ) => {
+ const variantClass = variantClasses[variant];
+ const interactiveClass = interactive ? 'hover:bg-bg-hover cursor-pointer transition-all duration-base' : '';
+ const baseClasses = 'rounded-xl overflow-hidden backdrop-blur-sm';
+
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+CardComponent.displayName = 'Card';
+
+const CardHeader: React.FC> = ({ className = '', children, ...props }) => (
+
+ {children}
+
+);
+
+CardHeader.displayName = 'CardHeader';
+
+const CardBody: React.FC> = ({ className = '', children, ...props }) => (
+
+ {children}
+
+);
+
+CardBody.displayName = 'CardBody';
+
+const CardFooter: React.FC> = ({ className = '', children, ...props }) => (
+
+ {children}
+
+);
+
+CardFooter.displayName = 'CardFooter';
+
+export const Card: React.FC & CardSubComponent = Object.assign(CardComponent, {
+ Header: CardHeader,
+ Body: CardBody,
+ Footer: CardFooter,
+});
diff --git a/frontend/src/components/ui/ErrorBoundary.tsx b/frontend/src/components/ui/ErrorBoundary.tsx
new file mode 100644
index 0000000..1efe7d9
--- /dev/null
+++ b/frontend/src/components/ui/ErrorBoundary.tsx
@@ -0,0 +1,57 @@
+import React, { type ReactNode } from 'react';
+import { Button } from './Button';
+
+interface ErrorBoundaryProps {
+ children: ReactNode;
+ fallback?: ReactNode;
+ onRetry?: () => void;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error?: Error;
+}
+
+export class ErrorBoundary extends React.Component {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error) {
+ console.error('Error caught by boundary:', error);
+ }
+
+ handleRetry = () => {
+ this.setState({ hasError: false });
+ this.props.onRetry?.();
+ };
+
+ render() {
+ if (this.state.hasError) {
+ return (
+ this.props.fallback || (
+
+
+
+ Something went wrong
+
+
+ {this.state.error?.message || 'An unexpected error occurred'}
+
+
+ Try Again
+
+
+
+ )
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx
new file mode 100644
index 0000000..3b90cb9
--- /dev/null
+++ b/frontend/src/components/ui/Input.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+
+export interface InputProps extends React.InputHTMLAttributes {
+ label?: string;
+ error?: string;
+ helperText?: string;
+ fullWidth?: boolean;
+}
+
+export const Input = React.forwardRef(
+ (
+ { label, error, helperText, fullWidth = false, className = '', ...props },
+ ref
+ ) => {
+ const widthClass = fullWidth ? 'w-full' : '';
+ const borderClass = error
+ ? 'border-status-danger focus:border-status-danger focus:ring-status-danger/20'
+ : 'border-border-normal focus:border-accent-primary focus:ring-accent-primary/20';
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+ {error && (
+
{error}
+ )}
+ {helperText && !error && (
+
{helperText}
+ )}
+
+ );
+ }
+);
+
+Input.displayName = 'Input';
diff --git a/frontend/src/components/ui/PageLayout.tsx b/frontend/src/components/ui/PageLayout.tsx
new file mode 100644
index 0000000..9e443eb
--- /dev/null
+++ b/frontend/src/components/ui/PageLayout.tsx
@@ -0,0 +1,118 @@
+import React from 'react';
+
+interface GridBackgroundProps {
+ variant?: 'default' | 'light';
+}
+
+export interface ContainerProps extends React.HTMLAttributes {
+ fluid?: boolean;
+ size?: 'sm' | 'md' | 'lg' | 'xl';
+ noPadding?: boolean;
+}
+
+export interface PageContainerProps extends React.HTMLAttributes {
+ background?: 'primary' | 'secondary' | 'tertiary';
+}
+
+export const GridBackground: React.FC = ({ variant = 'default' }) => {
+ const opacity = variant === 'light' ? 'rgba(99,102,241,0.02)' : 'rgba(99,102,241,0.04)';
+
+ return (
+
+ );
+};
+
+export const Container = React.forwardRef(
+ (
+ { fluid = false, size = 'lg', noPadding = false, className = '', children, ...props },
+ ref
+ ) => {
+ const sizeClasses: Record = {
+ sm: 'max-w-container-sm',
+ md: 'max-w-container-md',
+ lg: 'max-w-content',
+ xl: 'max-w-container-xl',
+ };
+
+ const paddingClass = noPadding ? '' : 'px-4 sm:px-6';
+ const sizeClass = fluid ? 'w-full' : sizeClasses[size];
+
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+Container.displayName = 'Container';
+
+export const PageContainer = React.forwardRef(
+ (
+ { background = 'secondary', className = '', children, ...props },
+ ref
+ ) => {
+ const bgClasses: Record = {
+ primary: 'bg-bg-primary',
+ secondary: 'bg-bg-secondary',
+ tertiary: 'bg-bg-tertiary',
+ };
+
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+PageContainer.displayName = 'PageContainer';
+
+export interface PageHeaderProps extends Omit, 'title'> {
+ tagline?: string;
+ title: React.ReactNode;
+ description?: string;
+}
+
+export const PageHeader = React.forwardRef(
+ (
+ { tagline, title, description, className = '', ...props },
+ ref
+ ) => {
+ return (
+
+ {tagline && (
+
+ {tagline}
+
+ )}
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ );
+ }
+);
+
+PageHeader.displayName = 'PageHeader';
diff --git a/frontend/src/components/ui/PageTransition.tsx b/frontend/src/components/ui/PageTransition.tsx
new file mode 100644
index 0000000..c186027
--- /dev/null
+++ b/frontend/src/components/ui/PageTransition.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+
+interface PageTransitionProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const PageTransition: React.FC = ({
+ children,
+ className = '',
+}) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx
new file mode 100644
index 0000000..805ab5f
--- /dev/null
+++ b/frontend/src/components/ui/Skeleton.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+
+export interface SkeletonProps extends React.HTMLAttributes {
+ width?: string | number;
+ height?: string | number;
+ variant?: 'text' | 'circular' | 'rectangular';
+ count?: number;
+}
+
+export const Skeleton = React.forwardRef(
+ (
+ {
+ width = '100%',
+ height = '1rem',
+ variant = 'rectangular',
+ count = 1,
+ className = '',
+ ...props
+ },
+ ref
+ ) => {
+ const baseClass = 'bg-bg-surface animate-pulse';
+
+ const variantClasses: Record = {
+ text: 'rounded-md',
+ circular: 'rounded-full',
+ rectangular: 'rounded-lg',
+ };
+
+ const skeletons = Array.from({ length: count }).map((_, i) => (
+
+ ));
+
+ return count === 1 ? skeletons[0] : {skeletons}
;
+ }
+);
+
+Skeleton.displayName = 'Skeleton';
diff --git a/frontend/src/components/ui/Toast.tsx b/frontend/src/components/ui/Toast.tsx
new file mode 100644
index 0000000..7b7f548
--- /dev/null
+++ b/frontend/src/components/ui/Toast.tsx
@@ -0,0 +1,120 @@
+import React, { useEffect } from 'react';
+
+type ToastVariant = 'success' | 'error' | 'info' | 'warning';
+
+export interface ToastProps {
+ id: string;
+ message: string;
+ variant?: ToastVariant;
+ duration?: number;
+ onDismiss?: (id: string) => void;
+}
+
+const variantClasses: Record = {
+ success: {
+ bg: 'bg-bg-surface border-status-success/30',
+ border: 'border',
+ text: 'text-text-primary',
+ icon: {
+ bg: 'bg-status-success/10',
+ text: 'text-status-success',
+ },
+ },
+ error: {
+ bg: 'bg-bg-surface border-status-danger/30',
+ border: 'border',
+ text: 'text-text-primary',
+ icon: {
+ bg: 'bg-status-danger/10',
+ text: 'text-status-danger',
+ },
+ },
+ info: {
+ bg: 'bg-bg-surface border-status-info/30',
+ border: 'border',
+ text: 'text-text-primary',
+ icon: {
+ bg: 'bg-status-info/10',
+ text: 'text-status-info',
+ },
+ },
+ warning: {
+ bg: 'bg-bg-surface border-status-warning/30',
+ border: 'border',
+ text: 'text-text-primary',
+ icon: {
+ bg: 'bg-status-warning/10',
+ text: 'text-status-warning',
+ },
+ },
+};
+
+const iconMap: Record = {
+ success: (
+
+
+
+ ),
+ error: (
+
+
+
+ ),
+ info: (
+
+
+
+ ),
+ warning: (
+
+
+
+ ),
+};
+
+export const Toast: React.FC = ({
+ id,
+ message,
+ variant = 'info',
+ duration = 4000,
+ onDismiss,
+}) => {
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ onDismiss?.(id);
+ }, duration);
+ return () => clearTimeout(timer);
+ }, [id, duration, onDismiss]);
+
+ const classes = variantClasses[variant];
+
+ return (
+
+ {/* Icon */}
+
+
+ {iconMap[variant]}
+
+
+
+ {/* Content */}
+
+
+ {/* Close Button */}
+
onDismiss?.(id)}
+ className="flex-shrink-0 text-text-tertiary hover:text-text-secondary transition-colors p-1"
+ >
+
+
+
+
+
+ );
+};
+
diff --git a/frontend/src/components/ui/ToastProvider.tsx b/frontend/src/components/ui/ToastProvider.tsx
new file mode 100644
index 0000000..5371215
--- /dev/null
+++ b/frontend/src/components/ui/ToastProvider.tsx
@@ -0,0 +1,55 @@
+import React, { createContext, useContext, useState, useCallback } from 'react';
+import { Toast, type ToastProps } from './Toast';
+
+interface ToastInput {
+ message: string;
+ variant?: 'success' | 'error' | 'info' | 'warning';
+ duration?: number;
+}
+
+interface ToastContextType {
+ addToast: (input: ToastInput) => void;
+}
+
+const ToastContext = createContext(undefined);
+
+export function ToastProvider({ children }: { children: React.ReactNode }) {
+ const [toasts, setToasts] = useState([]);
+
+ const addToast = useCallback((input: ToastInput) => {
+ const id = `${Date.now()}-${Math.random()}`;
+ const toast: ToastProps = {
+ id,
+ ...input,
+ };
+ setToasts((prev) => [...prev, toast]);
+ }, []);
+
+ const dismissToast = useCallback((id: string) => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, []);
+
+ return (
+
+ {children}
+
+ {toasts.map((toast) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+export function useToast() {
+ const context = useContext(ToastContext);
+ if (!context) {
+ throw new Error('useToast must be used within ToastProvider');
+ }
+ return context;
+}
diff --git a/frontend/src/components/ui/WelcomeTour.tsx b/frontend/src/components/ui/WelcomeTour.tsx
new file mode 100644
index 0000000..9da3fe8
--- /dev/null
+++ b/frontend/src/components/ui/WelcomeTour.tsx
@@ -0,0 +1,131 @@
+import { useState, useEffect } from 'react';
+import { Button } from './Button';
+
+interface TourStep {
+ target: string;
+ title: string;
+ description: string;
+ position?: 'top' | 'bottom' | 'left' | 'right';
+}
+
+interface WelcomeTourProps {
+ steps: TourStep[];
+ onComplete?: () => void;
+ storageKey?: string;
+}
+
+export function WelcomeTour({ steps, onComplete, storageKey = 'cvpilot_tour_completed' }: WelcomeTourProps) {
+ const [currentStep, setCurrentStep] = useState(0);
+ const [isVisible, setIsVisible] = useState(false);
+ const [targetPos, setTargetPos] = useState({ top: 0, left: 0 });
+
+ useEffect(() => {
+ let timerId: ReturnType | null = null;
+
+ try {
+ const completed = localStorage.getItem(storageKey);
+ if (!completed && steps.length > 0) {
+ // Delay showing tour until page is fully loaded
+ timerId = setTimeout(() => setIsVisible(true), 1000);
+ }
+ } catch (err) {
+ console.error('Failed to check tour completion:', err);
+ // Proceed with tour even if localStorage fails
+ if (steps.length > 0) {
+ timerId = setTimeout(() => setIsVisible(true), 1000);
+ }
+ }
+
+ // Cleanup: cancel pending timeout
+ return () => {
+ if (timerId !== null) {
+ clearTimeout(timerId);
+ }
+ };
+ }, [storageKey, steps]);
+
+ useEffect(() => {
+ if (!isVisible || currentStep >= steps.length) return;
+
+ const updatePosition = () => {
+ try {
+ const target = steps[currentStep]?.target;
+ if (!target || typeof target !== 'string' || target.trim() === '') {
+ console.warn('Invalid tour target:', target);
+ return;
+ }
+
+ const element = document.querySelector(target);
+ if (element) {
+ const rect = element.getBoundingClientRect();
+ setTargetPos({
+ top: rect.top + window.scrollY - 10,
+ left: rect.left + window.scrollX - 10,
+ });
+ }
+ } catch (err) {
+ console.error('Failed to find tour target element:', err);
+ }
+ };
+
+ updatePosition();
+ window.addEventListener('resize', updatePosition);
+ return () => window.removeEventListener('resize', updatePosition);
+ }, [currentStep, isVisible, steps]);
+
+ const handleNext = () => {
+ if (currentStep < steps.length - 1) {
+ setCurrentStep((prev) => prev + 1);
+ } else {
+ handleComplete();
+ }
+ };
+
+ const handleComplete = () => {
+ setIsVisible(false);
+ try {
+ localStorage.setItem(storageKey, 'true');
+ } catch (err) {
+ console.error('Failed to save tour completion:', err);
+ // Still proceed if localStorage fails
+ }
+ onComplete?.();
+ };
+
+ if (!isVisible || currentStep >= steps.length) return null;
+
+ const step = steps[currentStep];
+ const positionClass = step.position === 'top' ? 'bottom-full mb-4' : 'top-full mt-4';
+
+ return (
+ <>
+ {/* Overlay */}
+
+
+ {/* Tooltip */}
+
+
{step.title}
+
{step.description}
+
+
+
+ {currentStep + 1} of {steps.length}
+
+
+ {currentStep > 0 && (
+ setCurrentStep((prev) => prev - 1)}>
+ Back
+
+ )}
+
+ {currentStep === steps.length - 1 ? 'Done' : 'Next'}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
new file mode 100644
index 0000000..fdd5718
--- /dev/null
+++ b/frontend/src/components/ui/index.ts
@@ -0,0 +1,45 @@
+// Button
+export { Button } from './Button';
+export type { ButtonProps, ButtonVariant, ButtonSize } from './Button';
+
+// Card
+export { Card } from './Card';
+export type { CardProps } from './Card';
+
+// Badge
+export { Badge } from './Badge';
+export type { BadgeProps } from './Badge';
+
+// Alert
+export { Alert } from './Alert';
+export type { AlertProps } from './Alert';
+
+// Input
+export { Input } from './Input';
+export type { InputProps } from './Input';
+
+// Page Layout Components
+export { PageContainer, Container, GridBackground, PageHeader } from './PageLayout';
+export type { PageContainerProps, ContainerProps, PageHeaderProps } from './PageLayout';
+
+// Toast Notifications
+export { Toast } from './Toast';
+export { ToastProvider, useToast } from './ToastProvider';
+export type { ToastProps } from './Toast';
+
+// Skeleton Loading
+export { Skeleton } from './Skeleton';
+export type { SkeletonProps } from './Skeleton';
+
+// Page Transitions
+export { PageTransition } from './PageTransition';
+
+// Error Boundary
+export { ErrorBoundary } from './ErrorBoundary';
+
+// Bookmarks
+export { BookmarksProvider, useBookmarks } from './BookmarksProvider';
+
+// Welcome Tour
+export { WelcomeTour } from './WelcomeTour';
+
diff --git a/frontend/src/pages/JobPage.tsx b/frontend/src/pages/JobPage.tsx
index f496798..551822f 100644
--- a/frontend/src/pages/JobPage.tsx
+++ b/frontend/src/pages/JobPage.tsx
@@ -1,97 +1,160 @@
-import { useState } from "react";
+import { useState, useEffect, useRef } from "react";
+import { useNavigate } from "react-router-dom";
import { JobList } from "../components/Jobs/JobList";
import { MOCK_JOBS } from "../data/MockJobs";
-
+import { PageContainer, GridBackground, Container, Button, PageTransition, useToast } from "../components/ui";
+import { checkResumeExists } from "../services/resume";
+import { useApi } from "../lib/fetcher";
+
interface JobsPageProps {
onNavigateToJob: (jobId: string) => void;
}
-
+
export default function JobsPage({ onNavigateToJob }: JobsPageProps) {
- const [isLoading] = useState(false);
-
- const topScore = MOCK_JOBS[0]?.score ?? "0";
- const avgScore = Math.round(
- MOCK_JOBS.reduce((acc, j) => acc + parseInt(j.score, 10), 0) / MOCK_JOBS.length
- );
-
+ const [isRefetching, setIsRefetching] = useState(false);
+ const [hasResume, setHasResume] = useState(false);
+ const [isCheckingResume, setIsCheckingResume] = useState(true);
+ const navigate = useNavigate();
+ const { addToast } = useToast();
+ const { fetchWithAuth } = useApi();
+
+ // Stable ref to prevent effect rerun
+ const fetchRef = useRef(fetchWithAuth);
+ useEffect(() => {
+ fetchRef.current = fetchWithAuth;
+ }, [fetchWithAuth]);
+
+ // Check if user has uploaded resume
+ useEffect(() => {
+ const checkResume = async () => {
+ try {
+ const exists = await checkResumeExists(fetchRef.current);
+ setHasResume(exists);
+ } catch (err) {
+ console.error("Failed to check resume:", err);
+ setHasResume(false);
+ } finally {
+ setIsCheckingResume(false);
+ }
+ };
+ checkResume();
+ }, []);
+
+ // Only show real data: job count
+ // Note: topScore and avgScore removed - requires actual AI matching we don't have access to
+ const jobCount = MOCK_JOBS.length;
+
+ const handleRefetchJobs = async () => {
+ setIsRefetching(true);
+ // TODO: Implement API call to refetch jobs based on current resume
+ // This would call the backend to re-run the matching algorithm
+ setTimeout(() => {
+ setIsRefetching(false);
+ addToast({
+ message: 'Job list refreshed! New matches may be available.',
+ variant: 'success',
+ });
+ }, 1500);
+ };
+
return (
-
- {/* Grid background */}
-
-
-
- {/* Header */}
-
-
- CVPILOT — JOB MATCHES
-
-
- Your{" "}
-
- matches.
-
-
-
- Ranked by neural match score. Top 10 opportunities tailored to your resume and preferences.
-
-
-
- {/* Stats bar */}
-
-
-
- {MOCK_JOBS.length}
-
-
MATCHES
-
-
-
- {topScore}%
-
-
TOP SCORE
+
+
+
+
+
+ {/* Page Header - Only show when resume exists */}
+ {!isCheckingResume && hasResume && (
+
+
+
+ CVpilot — Job Matches
+
+
+ Your{" "}
+
+ matches.
+
+
+
+ Ranked by neural match score. Top 10 opportunities tailored to your resume and preferences.
+
+
+
+ {/* Action Buttons */}
+
+
+ ↻ Refresh Jobs
+
+ navigate("/profile")}
+ variant="ghost"
+ size="md"
+ >
+ ↺ Re-upload Resume
+
+
-
-
-
- {avgScore}%
-
-
AVG SCORE
+ )}
+
+ {/* Content based on resume status */}
+ {isCheckingResume ? (
+
-
-
- {/* Job list */}
- {isLoading ? (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
+ ) : !hasResume ? (
+ /* Show CTA to upload resume */
+
+
+
Upload Your Resume
+
We need your resume to match you with the perfect job opportunities. Let's get started!
+
navigate("/profile")}
+ variant="primary"
+ size="lg"
+ >
+ Add Resume →
+
) : (
-
+ /* Show job list */
+ <>
+ {/* Stats Card - Only showing realistic data (job count) */}
+
+
Available Positions
+
{jobCount}
+
+
+ {/* Job List */}
+
+ >
)}
-
-
+
+
+
);
}
\ No newline at end of file
diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx
index b580875..4c6a1a6 100644
--- a/frontend/src/pages/LandingPage.tsx
+++ b/frontend/src/pages/LandingPage.tsx
@@ -1,25 +1,27 @@
import { Hero } from "../components/Landing/Hero";
import { useUser } from "@clerk/react";
-import { useNavigate } from "react-router-dom";
-import { useEffect } from "react";
+import { PageContainer, GridBackground, PageTransition } from "../components/ui";
+
export default function LandingPage() {
- const { isSignedIn, isLoaded } = useUser();
- const navigate = useNavigate();
+ const { isLoaded } = useUser();
- useEffect(() => {
- if (isLoaded && isSignedIn) {
- navigate("/profile");
- }
- }, [isSignedIn, isLoaded, navigate]);
+ if (!isLoaded) {
+ return (
+
+
+
+
+ );
+ }
return (
-
-
-
+
+
+
+
+
+
);
}
\ No newline at end of file
diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx
index d045125..5379b94 100644
--- a/frontend/src/pages/ProfilePage.tsx
+++ b/frontend/src/pages/ProfilePage.tsx
@@ -4,13 +4,15 @@ import { PreferencesForm } from "../components/Profile/PreferencesForm";
import { PreferencesSummary } from "../components/Profile/PreferencesSummary";
import type { Seniority, LocationType } from "../components/Profile/PreferencesForm";
import { useApi } from "../lib/fetcher";
-import { uploadResume } from "../services/resume";
+import { uploadResume, getResume } from "../services/resume";
import { createPreferences, getPreferences, updatePreferences } from "../services/preferences";
import { useNavigate } from "react-router-dom";
+import { PageContainer, GridBackground, Container, Card, Button, PageHeader, useToast, PageTransition } from "../components/ui";
export default function ProfilePage() {
const navigate = useNavigate();
const { fetchWithAuth } = useApi();
+ const { addToast } = useToast();
// Keep a stable ref so the useEffect doesn't re-run on every render
const fetchRef = useRef(fetchWithAuth);
useEffect(() => { fetchRef.current = fetchWithAuth; }, [fetchWithAuth]);
@@ -20,6 +22,7 @@ export default function ProfilePage() {
// null = no file on server yet, string = filename known
const [resumeFileName, setResumeFileName] = useState
(null);
+ const [hasExistingResume, setHasExistingResume] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
@@ -27,13 +30,11 @@ export default function ProfilePage() {
// Track whether the user already has saved prefs so we know PATCH vs POST
const [hasPreferences, setHasPreferences] = useState(false);
+ const [showEditPrefs, setShowEditPrefs] = useState(false);
- // Separate feedback per section so they don't clobber each other
- const [resumeError, setResumeError] = useState(null);
- const [resumeSuccess, setResumeSuccess] = useState(null);
- const [prefsError, setPrefsError] = useState(null);
- const [prefsSuccess, setPrefsSuccess] = useState(null);
-
+ // Separate feedback per section
+ // Note: We now use toasts instead of alerts, no need to track individual state
+
useEffect(() => {
const fetchPrefs = async () => {
try {
@@ -53,39 +54,73 @@ export default function ProfilePage() {
};
fetchPrefs();
}, []); // stable fetchRef means no dep needed
-
+
+ // 📄 Load existing resume on mount
+ useEffect(() => {
+ const fetchResume = async () => {
+ try {
+ const data = await getResume(fetchRef.current);
+ if (data && data.fileUrl) {
+ // Use the original filename if available, otherwise use a friendly formatted name
+ const fileName = data.originalFileName || `Resume_${new Date(data.uploadedAt).toLocaleDateString().replace(/\//g, '-')}.pdf`;
+ setResumeFileName(fileName);
+ setHasExistingResume(true);
+ } else {
+ setResumeFileName(null);
+ setHasExistingResume(false);
+ }
+ } catch (err) {
+ console.error("Failed to load resume:", err);
+ setResumeFileName(null);
+ setHasExistingResume(false);
+ }
+ };
+ fetchResume();
+ }, []);
+
// 📄 Resume Upload
const handleResumeReplace = useCallback(async (file: File) => {
setIsUploading(true);
- setResumeError(null);
- setResumeSuccess(null);
-
+
try {
const data = await uploadResume(file, fetchRef.current);
if (data.changed) {
setResumeFileName(file.name);
- setResumeSuccess("Resume uploaded successfully ✅");
+ setHasExistingResume(true);
+ addToast({
+ message: 'Resume uploaded successfully! Ready to find jobs.',
+ variant: 'success',
+ });
} else {
- setResumeSuccess("Same resume already on file ⚠️");
+ // Duplicate file: Show as warning
+ addToast({
+ message: 'This resume was already uploaded.',
+ variant: 'warning',
+ });
}
} catch (err: any) {
- setResumeError(err.message || "Upload failed. Please try again.");
+ const errorMsg = err.message || "Upload failed. Please try again.";
+ addToast({
+ message: errorMsg,
+ variant: 'error',
+ });
} finally {
setIsUploading(false);
}
- }, []);
-
+ }, [addToast]);
+
+ // Auto-dismiss handled by toasts now, no need for extra effects
+ // Keeping these state vars minimal for potential future use
+
// ⚙️ Preferences Save — PATCH if exists, POST if new
const handlePreferencesSubmit = useCallback(async (
seniority: Seniority,
locations: LocationType[]
) => {
setIsSaving(true);
- setPrefsError(null);
- setPrefsSuccess(null);
-
+
const payload = { seniority, locationPreferences: locations };
-
+
try {
let data;
if (hasPreferences) {
@@ -96,112 +131,125 @@ export default function ProfilePage() {
}
setSavedSeniority(data.seniority);
setSavedLocations(data.locationPreferences);
- setPrefsSuccess(hasPreferences ? "Preferences updated ✅" : "Preferences saved ✅");
+ const successMsg = hasPreferences ? "Preferences updated ✅" : "Preferences saved ✅";
+ addToast({
+ message: successMsg,
+ variant: 'success',
+ });
} catch (err: any) {
- setPrefsError(err.message || "Failed to save preferences. Please try again.");
+ const errorMsg = err.message || "Failed to save preferences. Please try again.";
+ addToast({
+ message: errorMsg,
+ variant: 'error',
+ });
} finally {
setIsSaving(false);
}
- }, [hasPreferences]);
+ }, [hasPreferences, addToast]);
return (
-
- {/* Background grid */}
-
-
-
- {/* Header */}
-
-
- Your{" "}
-
- profile.
-
-
-
- Manage your resume and job preferences to get matched with the right opportunities.
-
-
-
-
- {/* Resume section with its own feedback */}
-
- {resumeError && (
-
- {resumeError}
-
- )}
- {resumeSuccess && (
-
- {resumeSuccess}
+
+
+
+
+
+ {/* Page Header */}
+ Your profile. >}
+ description="Manage your resume and job preferences to get matched with the right opportunities."
+ className="mb-10"
+ />
+
+
+ {/* Resume Section */}
+
+
+ Upload Your Resume
+ Required to get job matches
+
+
+
+
+
+
+ {/* Preferences Section */}
+
+
+
+
Job Preferences
+
Tell us what you're looking for
- )}
-
-
-
- {/* Preferences section with its own feedback */}
-
- {prefsError && (
-
- {prefsError}
-
- )}
- {prefsSuccess && (
-
- {prefsSuccess}
-
- )}
- {isLoadingPrefs ? (
-
-
-
-
- Loading preferences…
-
- ) : (
- <>
-
-
- >
- )}
+ {hasPreferences && (
+
setShowEditPrefs(!showEditPrefs)}
+ variant="secondary"
+ size="md"
+ >
+ {showEditPrefs ? "Hide" : "Edit"} Preferences
+
+ )}
+
+
+ {isLoadingPrefs ? (
+
+ ) : (
+ <>
+ {/* Show form only if no prefs saved OR edit mode is open */}
+ {(!hasPreferences || showEditPrefs) && (
+
+ )}
+
+ {/* Show summary if prefs exist */}
+ {hasPreferences && (
+
+ )}
+ >
+ )}
+
+
+
+ {/* Action Buttons */}
+
+ navigate("/jobs")}
+ variant="primary"
+ size="lg"
+ className="flex-1"
+ >
+ Find Jobs →
+
-
navigate("/jobs")}
- className="h-fit inline-flex items-center gap-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold px-5 py-2.5 transition-colors duration-150 shadow-lg shadow-indigo-500/10"
- >
- Find Jobs →
-
-
-
+
+
+
);
+
}
\ No newline at end of file
diff --git a/frontend/src/pages/SavedPage.tsx b/frontend/src/pages/SavedPage.tsx
new file mode 100644
index 0000000..a69bb53
--- /dev/null
+++ b/frontend/src/pages/SavedPage.tsx
@@ -0,0 +1,105 @@
+import { useNavigate } from "react-router-dom";
+import { JobList } from "../components/Jobs/JobList";
+import { PageContainer, GridBackground, Container, Button, PageTransition, useBookmarks } from "../components/ui";
+import { MOCK_JOBS } from "../data/MockJobs";
+
+interface SavedPageProps {
+ onNavigateToJob?: (jobId: string) => void;
+}
+
+export default function SavedPage({ onNavigateToJob }: SavedPageProps) {
+ const navigate = useNavigate();
+ const { bookmarks } = useBookmarks();
+
+ // Filter jobs to only show bookmarked ones
+ const savedJobs = MOCK_JOBS.filter(job => bookmarks.has(job.jobId));
+ const savedCount = savedJobs.length;
+
+ const handleNavigateToJob = (jobId: string) => {
+ onNavigateToJob?.(jobId);
+ };
+
+ return (
+
+
+
+
+
+ {/* Page Header */}
+
+
+ CVpilot — Saved Opportunities
+
+
+ Your
+ saved
+ {" "}
+ opportunities.
+
+
+ {savedCount === 0
+ ? "Start saving jobs to build your collection of opportunities."
+ : `${savedCount} ${savedCount === 1 ? 'opportunity' : 'opportunities'} saved and ready for review.`}
+
+
+
+ {/* Content */}
+ {savedCount === 0 ? (
+ /* Empty state */
+
+
+
No Saved Jobs Yet
+
+ Browse through opportunities and save the ones that interest you most. Your saved jobs will appear here.
+
+
navigate("/jobs")}
+ variant="primary"
+ size="lg"
+ >
+ Explore Jobs →
+
+
+ ) : (
+ /* Show saved jobs */
+ <>
+ {/* Stats Card */}
+
+
Saved Positions
+
{savedCount}
+
+
+ {/* Job List */}
+
+
+ {/* Back Button */}
+
+ navigate("/jobs")}
+ variant="secondary"
+ size="lg"
+ >
+ ← Back to All Jobs
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/services/resume.ts b/frontend/src/services/resume.ts
index eee7119..aafd541 100644
--- a/frontend/src/services/resume.ts
+++ b/frontend/src/services/resume.ts
@@ -10,5 +10,37 @@ export const uploadResume = async (
body: formData,
});
+ return res.json();
+};
+
+/**
+ * Check if user has uploaded a resume
+ * Returns true if resume exists and is ready (200) or parsing (202)
+ * Returns false if no resume (404) or any error occurs
+ */
+export const checkResumeExists = async (
+ fetchWithAuth: (url: string, options?: RequestInit) => Promise
+): Promise => {
+ try {
+ const res = await fetchWithAuth("/resume");
+ // 200 = resume ready, 202 = parsing in progress, both mean hasResume=true
+ return res.status === 200 || res.status === 202;
+ } catch (err) {
+ // If fetch fails or 404, assume no resume
+ console.error("Failed to check resume status:", err);
+ return false;
+ }
+};
+
+/**
+ * Fetch the current resume data for the user
+ * Returns resume metadata or null if not found
+ */
+export const getResume = async (
+ fetchWithAuth: (url: string, options?: RequestInit) => Promise
+) => {
+ const res = await fetchWithAuth("/resume");
+ if (res.status === 404) return null;
+ if (!res.ok) throw new Error("Failed to fetch resume");
return res.json();
};
\ No newline at end of file
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index d37737f..3d9e0b0 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -5,7 +5,153 @@ export default {
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
- extend: {},
+ fontFamily: {
+ 'sans': [
+ '-apple-system',
+ 'BlinkMacSystemFont',
+ '"Segoe UI"',
+ '"Helvetica Neue"',
+ 'Arial',
+ 'sans-serif',
+ '"Apple Color Emoji"',
+ '"Segoe UI Emoji"',
+ ],
+ 'display': [
+ '-apple-system',
+ 'BlinkMacSystemFont',
+ '"Segoe UI"',
+ '"Helvetica Neue"',
+ 'Arial',
+ 'sans-serif',
+ ],
+ },
+ extend: {
+ // Color system with semantic names
+ colors: {
+ // Background colors
+ 'bg-primary': '#07090f', // Landing page, hero backgrounds
+ 'bg-secondary': '#080b14', // Main content areas (Jobs, Profile)
+ 'bg-tertiary': '#0b0f19', // App layout background
+ 'bg-surface': '#0d111c', // Cards, elevated surfaces
+ 'bg-hover': '#0f1427', // Hover states on surfaces
+ 'bg-interactive': '#131829', // Interactive elements
+
+ // Text colors
+ 'text-primary': '#e8e8e8', // Headings, primary text
+ 'text-secondary': '#b3bac2', // Body text, descriptions
+ 'text-tertiary': '#7a8290', // Labels, muted text
+ 'text-disabled': '#4f5563', // Disabled text
+ 'text-inverse': '#ffffff', // White text on dark backgrounds
+
+ // Border colors with opacity
+ 'border-light': 'rgba(255, 255, 255, 0.08)',
+ 'border-normal': 'rgba(255, 255, 255, 0.12)',
+ 'border-hover': 'rgba(255, 255, 255, 0.16)',
+ 'border-focus': 'rgba(99, 102, 241, 0.3)',
+
+ // Surface hover/active states
+ 'surface-hover': 'rgba(255, 255, 255, 0.06)',
+ 'surface-active': 'rgba(255, 255, 255, 0.08)',
+
+ // Status colors
+ 'status-success': '#34d399', // Emerald - success, remote, positive
+ 'status-info': '#818cf8', // Indigo - info, primary, strong
+ 'status-warning': '#fb923c', // Orange - warning, moderate, intern
+ 'status-danger': '#f87171', // Red - danger, error, negative
+ 'status-muted': '#94a3b8', // Slate - muted, neutral, fair
+
+ // Accent colors
+ 'accent-primary': '#6366f1', // Indigo primary
+ 'accent-light': '#a5b4fc', // Indigo light
+ 'accent-bright': '#818cf8', // Indigo bright
+ },
+
+ // Semantic spacing
+ spacing: {
+ 'xs': '4px',
+ 'sm': '8px',
+ 'md': '16px',
+ 'lg': '24px',
+ 'xl': '32px',
+ 'xxl': '48px',
+ },
+
+ // Typography scale
+ fontSize: {
+ 'xs': ['12px', { lineHeight: '16px', letterSpacing: '0em' }],
+ 'sm': ['14px', { lineHeight: '20px', letterSpacing: '0em' }],
+ 'base': ['16px', { lineHeight: '24px', letterSpacing: '0em' }],
+ 'lg': ['18px', { lineHeight: '28px', letterSpacing: '0em' }],
+ 'xl': ['20px', { lineHeight: '28px', letterSpacing: '0em' }],
+ '2xl': ['24px', { lineHeight: '32px', letterSpacing: '-0.02em' }],
+ '3xl': ['30px', { lineHeight: '36px', letterSpacing: '-0.02em' }],
+ '4xl': ['36px', { lineHeight: '44px', letterSpacing: '-0.02em' }],
+ '5xl': ['48px', { lineHeight: '56px', letterSpacing: '-0.02em' }],
+ '6xl': ['60px', { lineHeight: '68px', letterSpacing: '-0.02em' }],
+ },
+
+ // Letter spacing
+ letterSpacing: {
+ 'tight': '-0.02em',
+ 'normal': '0em',
+ 'wide': '0.035em',
+ 'wider': '0.2em',
+ 'widest': '0.35em',
+ },
+
+ // Border radius
+ borderRadius: {
+ 'sm': '6px',
+ 'md': '8px',
+ 'lg': '12px',
+ 'xl': '16px',
+ '2xl': '24px',
+ },
+
+ // Box shadows with glow effects
+ boxShadow: {
+ 'xs': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
+ 'sm': '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
+ 'md': '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
+ 'lg': '0 8px 16px -2px rgba(0, 0, 0, 0.15)',
+ 'glow-sm': '0 0 12px rgba(99, 102, 241, 0.15)',
+ 'glow-md': '0 0 20px rgba(99, 102, 241, 0.25)',
+ 'glow-lg': '0 0 32px rgba(99, 102, 241, 0.35)',
+ },
+
+ // Max widths for containers
+ maxWidth: {
+ 'container-sm': '640px',
+ 'container-md': '768px',
+ 'container-lg': '1024px',
+ 'container-xl': '1280px',
+ 'content': '900px', // Main content width
+ 'form': '600px', // Form content width
+ },
+
+ // Animation additions
+ keyframes: {
+ 'fade-in': {
+ '0%': { opacity: '0' },
+ '100%': { opacity: '1' },
+ },
+ 'slide-down': {
+ '0%': { transform: 'translateY(-8px)', opacity: '0' },
+ '100%': { transform: 'translateY(0)', opacity: '1' },
+ },
+ },
+ animation: {
+ 'fade-in': 'fade-in 200ms ease-in-out',
+ 'slide-down': 'slide-down 200ms ease-out',
+ },
+
+ // Transitions
+ transitionDuration: {
+ 'fast': '150ms',
+ 'base': '200ms',
+ 'slow': '300ms',
+ },
+ },
},
plugins: [],
}