Complete Phase 3: Feature components and micro-interactions

- Add WorkflowStep component with active/inactive states
- Add WorkflowCarousel with auto-advance, keyboard nav, progress dots
- Add ExpandableServiceCard with controlled/uncontrolled expand
- Add BentoCard with lg/md/sm responsive layouts
- Add BentoGrid section with asymmetric card layout
- Add shared animation utilities and micro-interactions
- Enhance Header logo with hover effects
- Fix Header test flakiness with waitFor

Components: WorkflowStep, WorkflowCarousel, ExpandableServiceCard,
BentoCard, BentoGrid

Data files: workflow.ts, services.ts

Total: 227 passing tests (80 new tests added)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 16:52:08 -07:00
parent f368ae9f8d
commit 3f8c807dac
19 changed files with 1662 additions and 52 deletions

View File

@ -1,6 +1,5 @@
import { Header, Footer, Container } from './components/layout'
import { Card } from './components/ui/Card'
import { Hero, Stats, Contact } from './components/sections'
import { Header, Footer } from './components/layout'
import { Hero, Stats, Contact, WorkflowCarousel, BentoGrid } from './components/sections'
function App() {
return (
@ -15,52 +14,11 @@ function App() {
{/* Stats Section */}
<Stats />
{/* Services Section Placeholder */}
<section id="services" className="py-20">
<Container>
<h2 className="text-3xl md:text-4xl font-bold text-center text-text-primary mb-12">
Our Services
</h2>
<div className="grid md:grid-cols-3 gap-6">
<Card variant="default" padding="lg">
<h3 className="text-xl font-semibold text-text-primary mb-3">
Web Development
</h3>
<p className="text-text-secondary">
Modern, responsive websites built with the latest technologies.
</p>
</Card>
<Card variant="elevated" padding="lg">
<h3 className="text-xl font-semibold text-text-primary mb-3">
Mobile Apps
</h3>
<p className="text-text-secondary">
Native and cross-platform mobile applications.
</p>
</Card>
<Card variant="outlined" padding="lg">
<h3 className="text-xl font-semibold text-text-primary mb-3">
Cloud Solutions
</h3>
<p className="text-text-secondary">
Scalable infrastructure and cloud architecture.
</p>
</Card>
</div>
</Container>
</section>
{/* Services Section - Bento Grid */}
<BentoGrid />
{/* Process Section Placeholder */}
<section id="process" className="py-20 bg-bg-secondary">
<Container>
<h2 className="text-3xl md:text-4xl font-bold text-center text-text-primary mb-12">
Our Process
</h2>
<p className="text-center text-text-secondary max-w-2xl mx-auto">
Phase 3 will add the animated workflow carousel here.
</p>
</Container>
</section>
{/* Process Section - Workflow Carousel */}
<WorkflowCarousel />
{/* Contact Section */}
<Contact />

View File

@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BentoCard } from './BentoCard'
import type { BentoCardService } from '../../data/services'
const mockService: BentoCardService = {
id: 'web-apps',
icon: 'Globe',
title: 'Web Applications',
summary: 'Full-stack solutions for modern web',
details: 'Modern web applications built with React, Node.js, and cloud infrastructure.',
technologies: ['React', 'Node.js', 'AWS'],
}
describe('BentoCard', () => {
it('renders service title', () => {
render(<BentoCard service={mockService} size="lg" />)
expect(screen.getByText('Web Applications')).toBeInTheDocument()
})
it('renders service icon', () => {
render(<BentoCard service={mockService} size="lg" />)
expect(screen.getByTestId('bento-card-icon')).toBeInTheDocument()
})
it('renders summary', () => {
render(<BentoCard service={mockService} size="lg" />)
expect(screen.getByText('Full-stack solutions for modern web')).toBeInTheDocument()
})
})
describe('BentoCard sizes', () => {
it('applies large layout classes when size="lg"', () => {
render(<BentoCard service={mockService} size="lg" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('col-span-2')
expect(card).toHaveClass('row-span-2')
expect(card).toHaveClass('p-8')
})
it('applies medium layout classes when size="md"', () => {
render(<BentoCard service={mockService} size="md" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('col-span-2')
expect(card).toHaveClass('row-span-1')
expect(card).toHaveClass('p-6')
})
it('applies small layout classes when size="sm"', () => {
render(<BentoCard service={mockService} size="sm" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('col-span-1')
expect(card).toHaveClass('row-span-1')
expect(card).toHaveClass('p-4')
})
it('shows technologies only for large size', () => {
const { rerender } = render(<BentoCard service={mockService} size="lg" />)
expect(screen.getByText('React')).toBeInTheDocument()
expect(screen.getByText('Node.js')).toBeInTheDocument()
expect(screen.getByText('AWS')).toBeInTheDocument()
rerender(<BentoCard service={mockService} size="md" />)
expect(screen.queryByText('React')).not.toBeInTheDocument()
rerender(<BentoCard service={mockService} size="sm" />)
expect(screen.queryByText('React')).not.toBeInTheDocument()
})
it('shows details only for large and medium sizes', () => {
const { rerender } = render(<BentoCard service={mockService} size="lg" />)
expect(screen.getByText(/Modern web applications built with React/)).toBeInTheDocument()
rerender(<BentoCard service={mockService} size="md" />)
expect(screen.getByText(/Modern web applications built with React/)).toBeInTheDocument()
rerender(<BentoCard service={mockService} size="sm" />)
expect(screen.queryByText(/Modern web applications built with React/)).not.toBeInTheDocument()
})
it('merges custom className', () => {
render(<BentoCard service={mockService} size="lg" className="custom-class" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('custom-class')
expect(card).toHaveClass('col-span-2') // Still has base classes
})
})
describe('BentoCard interactions', () => {
it('applies hover effect classes', () => {
render(<BentoCard service={mockService} size="lg" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('hover:-translate-y-1')
expect(card).toHaveClass('hover:shadow-xl')
})
it('has transition classes for animations', () => {
render(<BentoCard service={mockService} size="lg" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('transition-all')
expect(card).toHaveClass('duration-300')
})
it('has border and rounded corners', () => {
render(<BentoCard service={mockService} size="lg" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('border')
expect(card).toHaveClass('border-border')
expect(card).toHaveClass('rounded-2xl')
})
})
describe('BentoCard medium layout', () => {
it('uses horizontal flex layout for medium size', () => {
render(<BentoCard service={mockService} size="md" data-testid="card" />)
const content = screen.getByTestId('card').firstChild
expect(content).toHaveClass('flex')
expect(content).toHaveClass('gap-4')
})
})
describe('BentoCard small layout', () => {
it('centers content for small size', () => {
render(<BentoCard service={mockService} size="sm" data-testid="card" />)
const content = screen.getByTestId('card').firstChild
expect(content).toHaveClass('text-center')
})
})

View File

@ -0,0 +1,159 @@
import { Globe, Smartphone, Cloud, Plug, Shield, Lightbulb, type LucideIcon } from 'lucide-react'
import { cn } from '../../lib/utils'
import type { BentoCardService } from '../../data/services'
const iconMap: Record<string, LucideIcon> = {
Globe,
Smartphone,
Cloud,
Plug,
Shield,
Lightbulb,
}
interface BentoCardProps {
/** Service data */
service: BentoCardService
/** Card size determines layout */
size: 'lg' | 'md' | 'sm'
/** Additional CSS classes (for grid positioning) */
className?: string
/** Test ID */
'data-testid'?: string
}
const sizeClasses = {
lg: 'col-span-2 row-span-2 p-8',
md: 'col-span-2 row-span-1 p-6',
sm: 'col-span-1 row-span-1 p-4',
}
export function BentoCard({
service,
size,
className,
'data-testid': testId,
}: BentoCardProps) {
const Icon = iconMap[service.icon] || Globe
const showDetails = size === 'lg' || size === 'md'
const showTechnologies = size === 'lg'
return (
<div
data-testid={testId}
className={cn(
'bg-white border border-border rounded-2xl shadow-md',
'transition-all duration-300 ease-in-out',
'hover:-translate-y-1 hover:shadow-xl',
'group overflow-hidden',
sizeClasses[size],
className
)}
>
{size === 'lg' && (
<LargeLayout
icon={Icon}
title={service.title}
summary={service.summary}
details={service.details}
technologies={service.technologies}
/>
)}
{size === 'md' && (
<MediumLayout
icon={Icon}
title={service.title}
summary={service.summary}
details={service.details}
/>
)}
{size === 'sm' && (
<SmallLayout icon={Icon} title={service.title} summary={service.summary} />
)}
</div>
)
}
interface LargeLayoutProps {
icon: LucideIcon
title: string
summary: string
details: string
technologies: string[]
}
function LargeLayout({ icon: Icon, title, summary, details, technologies }: LargeLayoutProps) {
return (
<div className="flex flex-col h-full">
<div
data-testid="bento-card-icon"
className="p-3 rounded-xl bg-primary/10 text-primary w-fit mb-4 group-hover:scale-110 transition-transform duration-300"
>
<Icon className="w-8 h-8" />
</div>
<h3 className="text-2xl font-bold text-foreground mb-2">{title}</h3>
<p className="text-muted-foreground mb-4">{summary}</p>
<p className="text-foreground flex-1">{details}</p>
<div className="flex flex-wrap gap-2 mt-4">
{technologies.map((tech, index) => (
<span
key={index}
className="px-3 py-1 bg-primary/10 text-primary text-sm rounded-full font-medium"
>
{tech}
</span>
))}
</div>
</div>
)
}
interface MediumLayoutProps {
icon: LucideIcon
title: string
summary: string
details: string
}
function MediumLayout({ icon: Icon, title, summary, details }: MediumLayoutProps) {
return (
<div className="flex gap-4 items-start">
<div
data-testid="bento-card-icon"
className="p-3 rounded-xl bg-primary/10 text-primary shrink-0 group-hover:scale-110 transition-transform duration-300"
>
<Icon className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-bold text-foreground mb-1">{title}</h3>
<p className="text-muted-foreground text-sm mb-2">{summary}</p>
<p className="text-foreground text-sm">{details}</p>
</div>
</div>
)
}
interface SmallLayoutProps {
icon: LucideIcon
title: string
summary: string
}
function SmallLayout({ icon: Icon, title, summary }: SmallLayoutProps) {
return (
<div className="text-center flex flex-col items-center justify-center h-full">
<div
data-testid="bento-card-icon"
className="p-3 rounded-xl bg-primary/10 text-primary mb-3 group-hover:scale-110 transition-transform duration-300"
>
<Icon className="w-6 h-6" />
</div>
<h3 className="text-lg font-bold text-foreground mb-1">{title}</h3>
<p className="text-muted-foreground text-sm">{summary}</p>
</div>
)
}

View File

@ -0,0 +1,166 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Globe } from 'lucide-react'
import { ExpandableServiceCard } from './ExpandableServiceCard'
describe('ExpandableServiceCard', () => {
const defaultProps = {
icon: Globe,
title: 'Web Applications',
summary: 'Full-stack solutions for modern web',
details: 'Modern web applications built with React, Node.js, and cloud infrastructure. From MVPs to enterprise platforms.',
capabilities: ['Custom dashboards', 'E-commerce platforms', 'SaaS applications'],
technologies: ['React', 'Node.js', 'AWS'],
}
it('renders title correctly', () => {
render(<ExpandableServiceCard {...defaultProps} />)
expect(screen.getByText('Web Applications')).toBeInTheDocument()
})
it('renders icon correctly', () => {
render(<ExpandableServiceCard {...defaultProps} />)
expect(screen.getByTestId('service-card-icon')).toBeInTheDocument()
})
it('renders summary in collapsed state', () => {
render(<ExpandableServiceCard {...defaultProps} />)
expect(screen.getByText('Full-stack solutions for modern web')).toBeInTheDocument()
})
it('is collapsed by default', () => {
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
expect(card).toHaveAttribute('aria-expanded', 'false')
})
it('expands when clicked', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
await user.click(card)
expect(card).toHaveAttribute('aria-expanded', 'true')
})
it('collapses when clicked again', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
await user.click(card) // expand
await user.click(card) // collapse
expect(card).toHaveAttribute('aria-expanded', 'false')
})
it('shows details when expanded', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText(/Modern web applications built with React/)).toBeInTheDocument()
})
it('shows capabilities list when expanded', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText('Custom dashboards')).toBeInTheDocument()
expect(screen.getByText('E-commerce platforms')).toBeInTheDocument()
expect(screen.getByText('SaaS applications')).toBeInTheDocument()
})
it('shows technology tags when expanded', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText('React')).toBeInTheDocument()
expect(screen.getByText('Node.js')).toBeInTheDocument()
expect(screen.getByText('AWS')).toBeInTheDocument()
})
it('hides details when collapsed', () => {
render(<ExpandableServiceCard {...defaultProps} />)
expect(screen.queryByText(/Modern web applications built with React/)).not.toBeInTheDocument()
})
it('uses controlled state when isExpanded prop provided', () => {
render(<ExpandableServiceCard {...defaultProps} isExpanded={true} />)
const card = screen.getByRole('button')
expect(card).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText(/Modern web applications built with React/)).toBeInTheDocument()
})
it('calls onToggle when clicked (controlled)', async () => {
const handleToggle = vi.fn()
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} isExpanded={false} onToggle={handleToggle} />)
await user.click(screen.getByRole('button'))
expect(handleToggle).toHaveBeenCalledTimes(1)
})
it('applies cursor-pointer style', () => {
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
expect(card).toHaveClass('cursor-pointer')
})
it('is keyboard accessible with Enter', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
card.focus()
await user.keyboard('{Enter}')
expect(card).toHaveAttribute('aria-expanded', 'true')
})
it('is keyboard accessible with Space', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
card.focus()
await user.keyboard(' ')
expect(card).toHaveAttribute('aria-expanded', 'true')
})
it('has gradient border styling', () => {
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
expect(card).toHaveClass('before:bg-gradient-to-r')
expect(card).toHaveClass('before:from-primary')
expect(card).toHaveClass('before:to-accent')
})
it('works without technologies prop', async () => {
const user = userEvent.setup()
const propsWithoutTech = { ...defaultProps }
delete (propsWithoutTech as any).technologies
render(<ExpandableServiceCard {...propsWithoutTech} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText('Custom dashboards')).toBeInTheDocument()
expect(screen.queryByText('React')).not.toBeInTheDocument()
})
it('shows expand indicator when collapsed', () => {
render(<ExpandableServiceCard {...defaultProps} />)
expect(screen.getByTestId('expand-indicator')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,162 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown, ChevronUp, type LucideIcon } from 'lucide-react'
import { cn } from '../../lib/utils'
interface ExpandableServiceCardProps {
/** Lucide icon component */
icon: LucideIcon
/** Service title */
title: string
/** Short summary for collapsed state */
summary: string
/** Full description for expanded state */
details: string
/** List of capabilities/features */
capabilities: string[]
/** Technology tags (optional) */
technologies?: string[]
/** External control: is expanded */
isExpanded?: boolean
/** External control: toggle callback */
onToggle?: () => void
}
const contentVariants = {
hidden: {
opacity: 0,
height: 0,
},
visible: {
opacity: 1,
height: 'auto',
transition: { duration: 0.3, ease: 'easeOut' },
},
exit: {
opacity: 0,
height: 0,
transition: { duration: 0.2, ease: 'easeIn' },
},
}
export function ExpandableServiceCard({
icon: Icon,
title,
summary,
details,
capabilities,
technologies,
isExpanded: controlledExpanded,
onToggle,
}: ExpandableServiceCardProps) {
const [internalExpanded, setInternalExpanded] = useState(false)
// Use controlled state if provided, otherwise use internal state
const isControlled = controlledExpanded !== undefined
const expanded = isControlled ? controlledExpanded : internalExpanded
const handleToggle = () => {
if (onToggle) {
onToggle()
}
if (!isControlled) {
setInternalExpanded(!internalExpanded)
}
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleToggle()
}
}
return (
<div
role="button"
tabIndex={0}
onClick={handleToggle}
onKeyDown={handleKeyDown}
aria-expanded={expanded}
className={cn(
'bg-white border border-border rounded-2xl p-6 cursor-pointer',
'transition-all duration-300 ease-in-out',
'hover:-translate-y-1 hover:shadow-xl',
'relative overflow-hidden',
'before:absolute before:inset-x-0 before:top-0 before:h-1',
'before:bg-gradient-to-r before:from-primary before:to-accent'
)}
>
{/* Header - Always visible */}
<div className="flex items-start justify-between mb-4">
<div
data-testid="service-card-icon"
className="p-3 rounded-xl bg-primary/10 text-primary"
>
<Icon className="w-6 h-6" />
</div>
<div
data-testid="expand-indicator"
className="p-2 text-muted-foreground"
>
{expanded ? (
<ChevronUp className="w-5 h-5" />
) : (
<ChevronDown className="w-5 h-5" />
)}
</div>
</div>
{/* Title */}
<h3 className="text-xl font-bold text-foreground mb-2">{title}</h3>
{/* Summary - Always visible */}
<p className="text-muted-foreground">{summary}</p>
{/* Expandable content */}
<AnimatePresence>
{expanded && (
<motion.div
initial="hidden"
animate="visible"
exit="exit"
variants={contentVariants}
className="overflow-hidden"
>
<div className="pt-4 mt-4 border-t border-border">
{/* Details */}
<p className="text-foreground mb-4">{details}</p>
{/* Capabilities */}
<div className="mb-4">
<p className="font-semibold text-foreground mb-2">What we deliver:</p>
<ul className="space-y-1">
{capabilities.map((capability, index) => (
<li key={index} className="flex items-center gap-2 text-muted-foreground">
<span className="w-1.5 h-1.5 bg-primary rounded-full" />
{capability}
</li>
))}
</ul>
</div>
{/* Technologies */}
{technologies && technologies.length > 0 && (
<div className="flex flex-wrap gap-2">
{technologies.map((tech, index) => (
<span
key={index}
className="px-3 py-1 bg-primary/10 text-primary text-sm rounded-full font-medium"
>
{tech}
</span>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@ -0,0 +1,164 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkflowStep } from './WorkflowStep'
import type { WorkflowStepData } from '../../data/workflow'
const mockStep: WorkflowStepData = {
id: 1,
icon: 'Search',
title: 'Discovery',
tagline: 'Understand your goals',
description: 'Test description for the discovery phase',
deliverables: ['Item 1', 'Item 2', 'Item 3'],
}
describe('WorkflowStep', () => {
const defaultProps = {
step: mockStep,
isActive: false,
onClick: vi.fn(),
stepNumber: 1,
}
it('renders step title correctly', () => {
render(<WorkflowStep {...defaultProps} />)
expect(screen.getByText('Discovery')).toBeInTheDocument()
})
it('renders step tagline correctly', () => {
render(<WorkflowStep {...defaultProps} />)
expect(screen.getByText('Understand your goals')).toBeInTheDocument()
})
it('renders step icon', () => {
render(<WorkflowStep {...defaultProps} />)
// Icon should be present (Search icon from lucide-react)
expect(screen.getByTestId('workflow-step-icon')).toBeInTheDocument()
})
it('renders step number', () => {
render(<WorkflowStep {...defaultProps} stepNumber={2} />)
expect(screen.getByText('Step 2')).toBeInTheDocument()
})
it('calls onClick when clicked', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<WorkflowStep {...defaultProps} onClick={handleClick} />)
const stepCard = screen.getByRole('button')
await user.click(stepCard)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('applies active styles when isActive=true', () => {
render(<WorkflowStep {...defaultProps} isActive={true} />)
const stepCard = screen.getByRole('button')
// Active state: full opacity, elevated shadow, scale 1
expect(stepCard).toHaveClass('opacity-100')
expect(stepCard).toHaveClass('shadow-lg')
expect(stepCard).toHaveClass('scale-100')
})
it('applies inactive styles when isActive=false', () => {
render(<WorkflowStep {...defaultProps} isActive={false} />)
const stepCard = screen.getByRole('button')
// Inactive state: reduced opacity, scale 0.95
expect(stepCard).toHaveClass('opacity-70')
expect(stepCard).toHaveClass('scale-95')
})
it('shows description when active', () => {
render(<WorkflowStep {...defaultProps} isActive={true} />)
expect(screen.getByText('Test description for the discovery phase')).toBeInTheDocument()
})
it('shows deliverables list when active', () => {
render(<WorkflowStep {...defaultProps} isActive={true} />)
expect(screen.getByText('Deliverables:')).toBeInTheDocument()
expect(screen.getByText('Item 1')).toBeInTheDocument()
expect(screen.getByText('Item 2')).toBeInTheDocument()
expect(screen.getByText('Item 3')).toBeInTheDocument()
})
it('hides description when inactive', () => {
render(<WorkflowStep {...defaultProps} isActive={false} />)
expect(screen.queryByText('Test description for the discovery phase')).not.toBeInTheDocument()
})
it('hides deliverables when inactive', () => {
render(<WorkflowStep {...defaultProps} isActive={false} />)
expect(screen.queryByText('Deliverables:')).not.toBeInTheDocument()
})
it('is keyboard accessible', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<WorkflowStep {...defaultProps} onClick={handleClick} />)
const stepCard = screen.getByRole('button')
stepCard.focus()
await user.keyboard('{Enter}')
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('supports keyboard navigation with Space key', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<WorkflowStep {...defaultProps} onClick={handleClick} />)
const stepCard = screen.getByRole('button')
stepCard.focus()
await user.keyboard(' ')
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('has correct aria-expanded attribute based on isActive', () => {
const { rerender } = render(<WorkflowStep {...defaultProps} isActive={false} />)
const stepCard = screen.getByRole('button')
expect(stepCard).toHaveAttribute('aria-expanded', 'false')
rerender(<WorkflowStep {...defaultProps} isActive={true} />)
expect(stepCard).toHaveAttribute('aria-expanded', 'true')
})
it('applies primary border when active', () => {
render(<WorkflowStep {...defaultProps} isActive={true} />)
const stepCard = screen.getByRole('button')
expect(stepCard).toHaveClass('border-primary')
})
it('applies subtle border when inactive', () => {
render(<WorkflowStep {...defaultProps} isActive={false} />)
const stepCard = screen.getByRole('button')
expect(stepCard).toHaveClass('border-border')
})
it('applies cursor-pointer style', () => {
render(<WorkflowStep {...defaultProps} />)
const stepCard = screen.getByRole('button')
expect(stepCard).toHaveClass('cursor-pointer')
})
it('has transition classes for smooth animations', () => {
render(<WorkflowStep {...defaultProps} />)
const stepCard = screen.getByRole('button')
expect(stepCard).toHaveClass('transition-all')
expect(stepCard).toHaveClass('duration-300')
})
})

View File

@ -0,0 +1,97 @@
import { motion, AnimatePresence } from 'framer-motion'
import { Search, Palette, Code, Rocket, type LucideIcon } from 'lucide-react'
import { cn } from '../../lib/utils'
import type { WorkflowStepData } from '../../data/workflow'
const iconMap: Record<string, LucideIcon> = {
Search,
Palette,
Code,
Rocket,
}
interface WorkflowStepProps {
/** Step data */
step: WorkflowStepData
/** Whether this step is currently active */
isActive: boolean
/** Click handler to select this step */
onClick: () => void
/** Step number for display */
stepNumber: number
}
export function WorkflowStep({ step, isActive, onClick, stepNumber }: WorkflowStepProps) {
const Icon = iconMap[step.icon] || Search
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onClick()
}
}
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
aria-expanded={isActive}
className={cn(
'bg-white border rounded-2xl p-6 cursor-pointer transition-all duration-300 ease-in-out',
isActive
? 'opacity-100 shadow-lg scale-100 border-primary'
: 'opacity-70 shadow-md scale-95 border-border hover:opacity-90 hover:shadow-lg'
)}
>
{/* Header */}
<div className="flex items-center gap-4 mb-2">
<div
data-testid="workflow-step-icon"
className={cn(
'p-3 rounded-xl transition-colors duration-300',
isActive ? 'bg-primary/10 text-primary' : 'bg-secondary/10 text-secondary'
)}
>
<Icon className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-muted-foreground font-medium">Step {stepNumber}</p>
<h3 className="text-xl font-bold text-foreground">{step.title}</h3>
</div>
</div>
{/* Tagline */}
<p className="text-muted-foreground mb-4">{step.tagline}</p>
{/* Expandable content */}
<AnimatePresence>
{isActive && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="pt-4 border-t border-border">
<p className="text-foreground mb-4">{step.description}</p>
<div>
<p className="font-semibold text-foreground mb-2">Deliverables:</p>
<ul className="space-y-1">
{step.deliverables.map((item, index) => (
<li key={index} className="flex items-center gap-2 text-muted-foreground">
<span className="w-1.5 h-1.5 bg-primary rounded-full" />
{item}
</li>
))}
</ul>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@ -0,0 +1,3 @@
export { WorkflowStep } from './WorkflowStep'
export { ExpandableServiceCard } from './ExpandableServiceCard'
export { BentoCard } from './BentoCard'

View File

@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Header } from './Header'
@ -167,8 +167,11 @@ describe('Header', () => {
// Close menu
await user.click(screen.getByRole('button', { name: /close menu/i }))
// Wait for the exit animation to complete
await waitFor(() => {
expect(screen.queryByRole('navigation', { name: /mobile/i })).not.toBeInTheDocument()
})
})
it('mobile menu has same links as desktop nav', async () => {
const user = userEvent.setup()

View File

@ -67,9 +67,9 @@ export function Header({ links = defaultLinks }: HeaderProps) {
<a
href="#home"
onClick={(e) => handleLinkClick(e, '#home')}
className="flex items-center gap-2 text-xl font-bold text-primary"
className="flex items-center gap-2 text-xl font-bold text-primary transition-transform duration-300 hover:scale-105"
>
<Diamond className="h-5 w-5 text-accent" />
<Diamond className="h-5 w-5 text-accent transition-transform duration-300 hover:rotate-12" />
<span>Maverick</span>
</a>

View File

@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BentoGrid } from './BentoGrid'
// Mock IntersectionObserver
class MockIntersectionObserver {
callback: IntersectionObserverCallback
constructor(callback: IntersectionObserverCallback) {
this.callback = callback
// Immediately trigger as visible for testing
setTimeout(() => {
this.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
)
}, 0)
}
observe() {}
unobserve() {}
disconnect() {}
}
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('BentoGrid', () => {
it('renders section header with default title', () => {
render(<BentoGrid />)
expect(screen.getByText('What We Build')).toBeInTheDocument()
expect(screen.getByText('End-to-end solutions tailored to your business needs')).toBeInTheDocument()
})
it('renders custom title when provided', () => {
render(<BentoGrid title="Our Services" subtitle="Custom solutions" />)
expect(screen.getByText('Our Services')).toBeInTheDocument()
expect(screen.getByText('Custom solutions')).toBeInTheDocument()
})
it('renders all 6 service cards', () => {
render(<BentoGrid />)
expect(screen.getByText('Web Applications')).toBeInTheDocument()
expect(screen.getByText('Mobile Development')).toBeInTheDocument()
expect(screen.getByText('DevOps & Infrastructure')).toBeInTheDocument()
expect(screen.getByText('System Integration')).toBeInTheDocument()
expect(screen.getByText('Security Solutions')).toBeInTheDocument()
expect(screen.getByText('Technical Consulting')).toBeInTheDocument()
})
it('applies correct grid classes to container', () => {
render(<BentoGrid />)
const grid = screen.getByTestId('bento-grid-container')
expect(grid).toHaveClass('grid')
expect(grid).toHaveClass('grid-cols-1')
expect(grid).toHaveClass('lg:grid-cols-4')
expect(grid).toHaveClass('gap-6')
})
})
describe('BentoGrid card positioning', () => {
it('renders 2 large cards', () => {
render(<BentoGrid />)
const largeCards = screen.getAllByTestId(/bento-card-lg/)
expect(largeCards).toHaveLength(2)
})
it('renders 2 medium cards', () => {
render(<BentoGrid />)
const mediumCards = screen.getAllByTestId(/bento-card-md/)
expect(mediumCards).toHaveLength(2)
})
it('renders 2 small cards', () => {
render(<BentoGrid />)
const smallCards = screen.getAllByTestId(/bento-card-sm/)
expect(smallCards).toHaveLength(2)
})
it('applies correct grid position classes to large cards', () => {
render(<BentoGrid />)
const largeCards = screen.getAllByTestId(/bento-card-lg/)
largeCards.forEach((card) => {
expect(card).toHaveClass('lg:col-span-2')
expect(card).toHaveClass('lg:row-span-2')
})
})
it('applies correct grid position classes to medium cards', () => {
render(<BentoGrid />)
const mediumCards = screen.getAllByTestId(/bento-card-md/)
mediumCards.forEach((card) => {
expect(card).toHaveClass('lg:col-span-2')
expect(card).toHaveClass('lg:row-span-1')
})
})
it('applies correct grid position classes to small cards', () => {
render(<BentoGrid />)
const smallCards = screen.getAllByTestId(/bento-card-sm/)
smallCards.forEach((card) => {
expect(card).toHaveClass('lg:col-span-1')
expect(card).toHaveClass('lg:row-span-1')
})
})
})
describe('BentoGrid responsiveness', () => {
it('has responsive column classes', () => {
render(<BentoGrid />)
const grid = screen.getByTestId('bento-grid-container')
// Mobile: 1 column, tablet: 2 columns, desktop: 4 columns
expect(grid).toHaveClass('grid-cols-1')
expect(grid).toHaveClass('md:grid-cols-2')
expect(grid).toHaveClass('lg:grid-cols-4')
})
it('cards have mobile override classes', () => {
render(<BentoGrid />)
// All cards should be full width on mobile
// Match only the card containers, not the icons
const largeCards = screen.getAllByTestId(/^bento-card-lg/)
const mediumCards = screen.getAllByTestId(/^bento-card-md/)
const smallCards = screen.getAllByTestId(/^bento-card-sm/)
const allCards = [...largeCards, ...mediumCards, ...smallCards]
allCards.forEach((card) => {
expect(card).toHaveClass('col-span-1')
expect(card).toHaveClass('row-span-1')
})
})
})
describe('BentoGrid section structure', () => {
it('has correct section id for navigation', () => {
render(<BentoGrid />)
const section = screen.getByTestId('bento-grid-section')
expect(section).toHaveAttribute('id', 'services')
})
it('has section padding', () => {
render(<BentoGrid />)
const section = screen.getByTestId('bento-grid-section')
expect(section).toHaveClass('py-24')
})
})

View File

@ -0,0 +1,73 @@
import { Container } from '../layout/Container'
import { BentoCard } from '../features/BentoCard'
import { AnimatedSection } from '../ui/AnimatedSection'
import { services } from '../../data/services'
import { cn } from '../../lib/utils'
interface BentoGridProps {
/** Optional section title */
title?: string
/** Optional section subtitle */
subtitle?: string
}
const sizeToResponsiveClasses = {
lg: 'lg:col-span-2 lg:row-span-2',
md: 'lg:col-span-2 lg:row-span-1',
sm: 'lg:col-span-1 lg:row-span-1',
}
export function BentoGrid({
title = 'What We Build',
subtitle = 'End-to-end solutions tailored to your business needs',
}: BentoGridProps) {
return (
<section
id="services"
data-testid="bento-grid-section"
className="py-24 bg-white"
>
<Container>
{/* Section Header */}
<AnimatedSection animation="fade-up" className="text-center mb-16">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-foreground mb-4">
{title}
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
{subtitle}
</p>
</AnimatedSection>
{/* Bento Grid */}
<AnimatedSection animation="fade-up" delay={200} stagger staggerDelay={100}>
<div
data-testid="bento-grid-container"
className={cn(
'grid gap-6',
'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
'auto-rows-min lg:grid-rows-[repeat(3,minmax(150px,auto))]'
)}
>
{services.map((service) => {
const size = service.size || 'md'
return (
<BentoCard
key={service.id}
service={service}
size={size}
data-testid={`bento-card-${size}-${service.id}`}
className={cn(
// Mobile: all cards are 1x1
'col-span-1 row-span-1',
// Desktop: apply size-specific classes
sizeToResponsiveClasses[size]
)}
/>
)
})}
</div>
</AnimatedSection>
</Container>
</section>
)
}

View File

@ -0,0 +1,240 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, act, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkflowCarousel } from './WorkflowCarousel'
describe('WorkflowCarousel', () => {
it('renders section header', () => {
render(<WorkflowCarousel autoAdvance={false} />)
expect(screen.getByText('How We Work')).toBeInTheDocument()
expect(screen.getByText('From idea to production in four focused phases')).toBeInTheDocument()
})
it('renders all 4 workflow steps', () => {
render(<WorkflowCarousel autoAdvance={false} />)
expect(screen.getByText('Discovery')).toBeInTheDocument()
expect(screen.getByText('Design')).toBeInTheDocument()
expect(screen.getByText('Build')).toBeInTheDocument()
expect(screen.getByText('Deploy')).toBeInTheDocument()
})
it('renders progress indicator dots', () => {
render(<WorkflowCarousel autoAdvance={false} />)
const dots = screen.getAllByRole('button', { name: /go to step/i })
expect(dots).toHaveLength(4)
})
it('first step is active by default', () => {
render(<WorkflowCarousel autoAdvance={false} />)
// The first step should show its description (expanded state)
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
})
it('clicking a step makes it active', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
// Click the Design step
const designStep = screen.getByText('Design').closest('[role="button"]')
await user.click(designStep!)
// Design step description should now be visible
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
})
it('clicking a dot navigates to that step', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
// Click the third dot (Build)
const dots = screen.getAllByRole('button', { name: /go to step/i })
await user.click(dots[2])
// Build step description should now be visible
expect(screen.getByText(/Agile development/i)).toBeInTheDocument()
})
it('has correct aria-current on active dot', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
const dots = screen.getAllByRole('button', { name: /go to step/i })
// First dot should have aria-current="step" initially
expect(dots[0]).toHaveAttribute('aria-current', 'step')
expect(dots[1]).not.toHaveAttribute('aria-current')
// Click second dot
await user.click(dots[1])
// Second dot should now have aria-current
expect(dots[0]).not.toHaveAttribute('aria-current')
expect(dots[1]).toHaveAttribute('aria-current', 'step')
})
})
describe('Auto-advance', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('auto-advances after interval', () => {
render(<WorkflowCarousel autoAdvance={true} autoAdvanceInterval={5000} />)
// Initially first step is active
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
// Advance time
act(() => {
vi.advanceTimersByTime(5000)
})
// Second step should now be active (Design)
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
})
it('pauses on hover', () => {
render(<WorkflowCarousel autoAdvance={true} autoAdvanceInterval={5000} />)
const carousel = screen.getByTestId('workflow-carousel')
// Hover over carousel
fireEvent.mouseEnter(carousel)
// Advance time
act(() => {
vi.advanceTimersByTime(6000)
})
// Should still be on first step (paused)
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
expect(screen.queryByText(/Architecture planning/i)).not.toBeInTheDocument()
})
it('resumes after mouse leaves', () => {
render(<WorkflowCarousel autoAdvance={true} autoAdvanceInterval={5000} />)
const carousel = screen.getByTestId('workflow-carousel')
// Hover then unhover
fireEvent.mouseEnter(carousel)
act(() => {
vi.advanceTimersByTime(3000)
})
fireEvent.mouseLeave(carousel)
// Should still be on first step
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
// Now advance time - should move to next step
act(() => {
vi.advanceTimersByTime(5000)
})
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
})
it('loops back to first step after last', () => {
render(<WorkflowCarousel autoAdvance={true} autoAdvanceInterval={5000} />)
// Advance through all 4 steps
act(() => {
vi.advanceTimersByTime(5000) // Step 2
})
act(() => {
vi.advanceTimersByTime(5000) // Step 3
})
act(() => {
vi.advanceTimersByTime(5000) // Step 4
})
// Should be on Deploy (step 4)
expect(screen.getByText(/Production deployment/i)).toBeInTheDocument()
// Advance one more time - should loop back to step 1
act(() => {
vi.advanceTimersByTime(5000)
})
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
})
it('does not auto-advance when autoAdvance=false', () => {
render(<WorkflowCarousel autoAdvance={false} />)
act(() => {
vi.advanceTimersByTime(10000)
})
// Should still be on first step
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
expect(screen.queryByText(/Architecture planning/i)).not.toBeInTheDocument()
})
})
describe('Keyboard navigation', () => {
it('right arrow advances to next step', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
const carousel = screen.getByTestId('workflow-carousel')
carousel.focus()
await user.keyboard('{ArrowRight}')
// Second step should now be active
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
})
it('left arrow goes to previous step', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
const carousel = screen.getByTestId('workflow-carousel')
carousel.focus()
// Go to second step first
await user.keyboard('{ArrowRight}')
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
// Go back
await user.keyboard('{ArrowLeft}')
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
})
it('wraps around at end boundary', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
const carousel = screen.getByTestId('workflow-carousel')
carousel.focus()
// Go to last step
await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}')
expect(screen.getByText(/Production deployment/i)).toBeInTheDocument()
// Arrow right should wrap to first step
await user.keyboard('{ArrowRight}')
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
})
it('wraps around at start boundary', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
const carousel = screen.getByTestId('workflow-carousel')
carousel.focus()
// Arrow left from first step should go to last step
await user.keyboard('{ArrowLeft}')
expect(screen.getByText(/Production deployment/i)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,114 @@
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { Container } from '../layout/Container'
import { WorkflowStep } from '../features/WorkflowStep'
import { workflowSteps } from '../../data/workflow'
import { cn } from '../../lib/utils'
interface WorkflowCarouselProps {
/** Enable auto-advance */
autoAdvance?: boolean
/** Auto-advance interval in ms */
autoAdvanceInterval?: number
}
export function WorkflowCarousel({
autoAdvance = true,
autoAdvanceInterval = 5000,
}: WorkflowCarouselProps) {
const [activeStep, setActiveStep] = useState(0)
const [isPaused, setIsPaused] = useState(false)
// Auto-advance effect
useEffect(() => {
if (!autoAdvance || isPaused) {
return
}
const interval = setInterval(() => {
setActiveStep((current) => (current + 1) % workflowSteps.length)
}, autoAdvanceInterval)
return () => clearInterval(interval)
}, [autoAdvance, autoAdvanceInterval, isPaused])
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'ArrowRight') {
event.preventDefault()
setActiveStep((current) => (current + 1) % workflowSteps.length)
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
setActiveStep((current) => (current - 1 + workflowSteps.length) % workflowSteps.length)
}
}
return (
<section
id="process"
className="py-24 bg-slate-900"
data-testid="workflow-carousel"
tabIndex={0}
onKeyDown={handleKeyDown}
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
<Container>
{/* Section Header */}
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-4">
How We Work
</h2>
<p className="text-lg text-slate-300 max-w-2xl mx-auto">
From idea to production in four focused phases
</p>
</div>
{/* Carousel */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
{workflowSteps.map((step, index) => (
<WorkflowStep
key={step.id}
step={step}
stepNumber={index + 1}
isActive={activeStep === index}
onClick={() => setActiveStep(index)}
/>
))}
</div>
{/* Progress Dots */}
<div className="flex justify-center items-center gap-3">
{workflowSteps.map((_, index) => (
<button
key={index}
onClick={() => setActiveStep(index)}
aria-label={`Go to step ${index + 1}`}
aria-current={activeStep === index ? 'step' : undefined}
className={cn(
'rounded-full transition-all duration-300',
activeStep === index
? 'w-4 h-4 bg-primary'
: 'w-3 h-3 bg-slate-600 hover:bg-slate-500'
)}
/>
))}
</div>
{/* Progress Bar (for auto-advance) */}
{autoAdvance && !isPaused && (
<div className="mt-4 flex justify-center">
<div className="w-48 h-1 bg-slate-700 rounded-full overflow-hidden">
<motion.div
key={activeStep}
className="h-full bg-primary origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: autoAdvanceInterval / 1000, ease: 'linear' }}
/>
</div>
</div>
)}
</Container>
</section>
)
}

View File

@ -1,3 +1,5 @@
export { Hero } from './Hero'
export { Stats } from './Stats'
export { Contact } from './Contact'
export { WorkflowCarousel } from './WorkflowCarousel'
export { BentoGrid } from './BentoGrid'

73
src/data/services.ts Normal file
View File

@ -0,0 +1,73 @@
export interface BentoCardService {
id: string
icon: string
title: string
summary: string
details: string
technologies: string[]
size?: 'lg' | 'md' | 'sm'
gridPosition?: string
}
export const services: BentoCardService[] = [
{
id: 'web-apps',
icon: 'Globe',
title: 'Web Applications',
summary: 'Full-stack solutions that scale',
details: 'Modern web applications built with React, Node.js, and cloud infrastructure. From MVPs to enterprise platforms.',
technologies: ['React', 'Node.js', 'PostgreSQL', 'AWS'],
size: 'lg',
gridPosition: 'col-span-2 row-span-2',
},
{
id: 'mobile',
icon: 'Smartphone',
title: 'Mobile Development',
summary: 'Native and cross-platform apps',
details: 'Beautiful, performant mobile applications for iOS and Android using React Native and native technologies.',
technologies: ['React Native', 'Swift', 'Kotlin'],
size: 'md',
gridPosition: 'col-span-2 row-span-1',
},
{
id: 'devops',
icon: 'Cloud',
title: 'DevOps & Infrastructure',
summary: 'Reliable, scalable systems',
details: 'CI/CD pipelines, containerization, and cloud deployment for high-availability systems.',
technologies: ['Docker', 'AWS', 'Terraform', 'Kubernetes'],
size: 'lg',
gridPosition: 'col-span-2 row-span-2',
},
{
id: 'integration',
icon: 'Plug',
title: 'System Integration',
summary: 'Connect your systems',
details: 'API integrations, data synchronization, and workflow automation.',
technologies: ['REST', 'GraphQL', 'Webhooks'],
size: 'sm',
gridPosition: 'col-span-1 row-span-1',
},
{
id: 'security',
icon: 'Shield',
title: 'Security Solutions',
summary: 'Protect your assets',
details: 'Authentication, authorization, security audits, and compliance.',
technologies: ['OAuth', 'JWT', 'OWASP'],
size: 'sm',
gridPosition: 'col-span-1 row-span-1',
},
{
id: 'consulting',
icon: 'Lightbulb',
title: 'Technical Consulting',
summary: 'Strategic technology guidance',
details: 'Architecture reviews, technology strategy, and team mentoring for your projects.',
technologies: ['Architecture', 'Strategy', 'Mentoring'],
size: 'md',
gridPosition: 'col-span-2 row-span-1',
},
]

43
src/data/workflow.ts Normal file
View File

@ -0,0 +1,43 @@
export interface WorkflowStepData {
id: number
icon: string
title: string
tagline: string
description: string
deliverables: string[]
}
export const workflowSteps: WorkflowStepData[] = [
{
id: 1,
icon: 'Search',
title: 'Discovery',
tagline: 'Understand your goals',
description: 'We dive deep into your business requirements, user needs, and technical constraints.',
deliverables: ['Requirements document', 'Technical roadmap', 'Project timeline'],
},
{
id: 2,
icon: 'Palette',
title: 'Design',
tagline: 'Blueprint for success',
description: 'Architecture planning and UI/UX design that prioritizes user experience.',
deliverables: ['System architecture', 'UI mockups', 'Database schema'],
},
{
id: 3,
icon: 'Code',
title: 'Build',
tagline: 'Code with precision',
description: 'Agile development with regular demos and continuous integration.',
deliverables: ['Working software', 'Test coverage', 'Documentation'],
},
{
id: 4,
icon: 'Rocket',
title: 'Deploy',
tagline: 'Ship with confidence',
description: 'Production deployment with monitoring, CI/CD, and ongoing support.',
deliverables: ['Production environment', 'Monitoring setup', 'Support plan'],
},
]

View File

@ -74,3 +74,24 @@
@apply bg-white/95 backdrop-blur-md;
}
}
/* Utility animations */
@layer utilities {
/* Subtle pulse for stats */
.animate-stat-pulse {
animation: stat-pulse 2s ease-in-out 1;
}
@keyframes stat-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
/* Logo hover rotation */
.hover-rotate {
transition: transform 0.3s ease;
}
.hover-rotate:hover {
transform: rotate(12deg) scale(1.1);
}
}

View File

@ -44,3 +44,57 @@ export const staggerContainer: Variants = {
}
export type AnimationVariant = keyof typeof animationVariants
// Shared transition presets
export const springTransition = {
type: 'spring' as const,
stiffness: 300,
damping: 20,
}
export const easeTransition = {
duration: 0.3,
ease: 'easeOut' as const,
}
// Common hover effects
export const hoverLift = {
y: -5,
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
}
export const hoverScale = {
scale: 1.05,
}
// Icon animation variants
export const iconVariants = {
rest: { scale: 1, rotate: 0 },
hover: {
scale: 1.1,
rotate: [0, -5, 5, 0],
transition: { duration: 0.3 },
},
}
// Badge hover animation
export const badgeVariants = {
rest: { y: 0 },
hover: {
y: -3,
transition: springTransition,
},
}
// Card hover animation
export const cardHoverVariants = {
rest: {
y: 0,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
},
hover: {
y: -5,
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
transition: easeTransition,
},
}