Fix WorkflowCarousel layout and add placeholder image system

Restructure WorkflowCarousel into tab headers + detail panel to eliminate
blank space in inactive cards and jarring height jumps on step change.
Detail panel uses a grid overlay crossfade for smooth transitions.

Add PlaceholderImage component and integrate into Hero, BentoCard,
WorkflowCarousel detail panel, and Contact sections. Include AI generation
prompts (PROMPTS.md) for future image creation with Nano Banana 3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 00:40:07 -07:00
parent 3f8c807dac
commit 2a59beeac8
11 changed files with 244 additions and 89 deletions

View File

@ -1,5 +1,6 @@
import { Globe, Smartphone, Cloud, Plug, Shield, Lightbulb, type LucideIcon } from 'lucide-react'
import { cn } from '../../lib/utils'
import { PlaceholderImage } from '../ui/PlaceholderImage'
import type { BentoCardService } from '../../data/services'
const iconMap: Record<string, LucideIcon> = {
@ -57,6 +58,7 @@ export function BentoCard({
summary={service.summary}
details={service.details}
technologies={service.technologies}
image={service.image}
/>
)}
{size === 'md' && (
@ -65,6 +67,7 @@ export function BentoCard({
title={service.title}
summary={service.summary}
details={service.details}
image={service.image}
/>
)}
{size === 'sm' && (
@ -80,11 +83,24 @@ interface LargeLayoutProps {
summary: string
details: string
technologies: string[]
image?: string
}
function LargeLayout({ icon: Icon, title, summary, details, technologies }: LargeLayoutProps) {
function LargeLayout({ icon: Icon, title, summary, details, technologies, image }: LargeLayoutProps) {
return (
<div className="flex flex-col h-full">
{image && (
<div className="mb-4 -mx-8 -mt-8">
<PlaceholderImage
src={image}
alt={`${title} illustration`}
label={title}
aspectRatio="4:3"
className="w-full rounded-t-2xl"
/>
</div>
)}
<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"
@ -115,9 +131,10 @@ interface MediumLayoutProps {
title: string
summary: string
details: string
image?: string
}
function MediumLayout({ icon: Icon, title, summary, details }: MediumLayoutProps) {
function MediumLayout({ icon: Icon, title, summary, details, image }: MediumLayoutProps) {
return (
<div className="flex gap-4 items-start">
<div
@ -132,6 +149,18 @@ function MediumLayout({ icon: Icon, title, summary, details }: MediumLayoutProps
<p className="text-muted-foreground text-sm mb-2">{summary}</p>
<p className="text-foreground text-sm">{details}</p>
</div>
{image && (
<div className="hidden sm:block w-24 flex-shrink-0">
<PlaceholderImage
src={image}
alt={`${title} illustration`}
label={title}
aspectRatio="3:2"
className="w-full rounded-lg"
/>
</div>
)}
</div>
)
}

View File

@ -73,30 +73,6 @@ describe('WorkflowStep', () => {
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()
@ -123,16 +99,6 @@ describe('WorkflowStep', () => {
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} />)

View File

@ -1,4 +1,3 @@
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'
@ -37,7 +36,6 @@ export function WorkflowStep({ step, isActive, onClick, stepNumber }: WorkflowSt
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
@ -63,35 +61,7 @@ export function WorkflowStep({ step, isActive, onClick, stepNumber }: WorkflowSt
</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>
<p className="text-muted-foreground">{step.tagline}</p>
</div>
)
}

View File

@ -4,6 +4,7 @@ import { Container } from '../layout/Container'
import { Card } from '../ui/Card'
import { Button } from '../ui/Button'
import { Input } from '../ui/Input'
import { PlaceholderImage } from '../ui/PlaceholderImage'
import type { LucideIcon } from 'lucide-react'
interface ContactMethod {
@ -147,6 +148,16 @@ export function Contact({ onSubmit }: ContactProps) {
</div>
))}
</div>
<div className="hidden md:block">
<PlaceholderImage
src="/images/contact-collab.webp"
alt="Collaboration illustration"
label="Collaboration"
aspectRatio="4:3"
className="w-full rounded-xl"
/>
</div>
</div>
{/* Contact Form */}

View File

@ -2,6 +2,7 @@ import { motion } from 'framer-motion'
import { Zap, Shield, Layers, Cloud, TrendingUp, Sparkles } from 'lucide-react'
import { cn } from '../../lib/utils'
import { Button } from '../ui/Button'
import { PlaceholderImage } from '../ui/PlaceholderImage'
import { Container } from '../layout/Container'
interface HeroProps {
@ -89,29 +90,43 @@ export function Hero({ headline, subheadline }: HeroProps) {
See How We Work
</Button>
</motion.div>
{/* Capability badges — below text on all sizes */}
<motion.div
variants={itemVariants}
className="grid grid-cols-2 sm:grid-cols-3 gap-4"
>
{capabilities.map(({ label, icon: Icon }) => (
<div
key={label}
className={cn(
'flex items-center gap-3 p-4',
'bg-white rounded-xl shadow-sm',
'hover:-translate-y-1 hover:shadow-md',
'transition-all duration-300'
)}
>
<Icon className="w-5 h-5 text-primary flex-shrink-0" />
<span className="text-sm font-medium text-slate-700">
{label}
</span>
</div>
))}
</motion.div>
</div>
{/* Capability badges */}
{/* Hero illustration — visible on lg screens */}
<motion.div
variants={itemVariants}
className="grid grid-cols-2 sm:grid-cols-3 gap-4"
className="hidden lg:block"
>
{capabilities.map(({ label, icon: Icon }) => (
<div
key={label}
className={cn(
'flex items-center gap-3 p-4',
'bg-white rounded-xl shadow-sm',
'hover:-translate-y-1 hover:shadow-md',
'transition-all duration-300'
)}
>
<Icon className="w-5 h-5 text-primary flex-shrink-0" />
<span className="text-sm font-medium text-slate-700">
{label}
</span>
</div>
))}
<PlaceholderImage
src="/images/hero-dashboard.webp"
alt="Software dashboard illustration"
label="Dashboard"
aspectRatio="16:10"
className="w-full rounded-2xl shadow-lg"
/>
</motion.div>
</motion.div>
</Container>

View File

@ -14,7 +14,8 @@ describe('WorkflowCarousel', () => {
it('renders all 4 workflow steps', () => {
render(<WorkflowCarousel autoAdvance={false} />)
expect(screen.getByText('Discovery')).toBeInTheDocument()
// Discovery appears in both the tab and detail panel
expect(screen.getAllByText('Discovery').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('Design')).toBeInTheDocument()
expect(screen.getByText('Build')).toBeInTheDocument()
expect(screen.getByText('Deploy')).toBeInTheDocument()

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { motion, AnimatePresence } from 'framer-motion'
import { Container } from '../layout/Container'
import { WorkflowStep } from '../features/WorkflowStep'
import { PlaceholderImage } from '../ui/PlaceholderImage'
import { workflowSteps } from '../../data/workflow'
import { cn } from '../../lib/utils'
@ -63,8 +64,8 @@ export function WorkflowCarousel({
</p>
</div>
{/* Carousel */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
{/* Step Tabs */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{workflowSteps.map((step, index) => (
<WorkflowStep
key={step.id}
@ -76,6 +77,53 @@ export function WorkflowCarousel({
))}
</div>
{/* Detail Panel — grid overlay keeps both panels in the same cell during crossfade */}
<div className="grid mt-8 mb-12 [&>*]:col-start-1 [&>*]:row-start-1">
<AnimatePresence>
<motion.div
key={activeStep}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 1 }}
transition={{ duration: 1.0, ease: 'easeInOut' }}
className="bg-white rounded-2xl p-8 border border-slate-200"
>
<div className="flex flex-col md:flex-row gap-8">
<div className="flex-1">
<h3 className="text-2xl font-bold text-foreground mb-3">
{workflowSteps[activeStep].title}
</h3>
<p className="text-foreground mb-4">
{workflowSteps[activeStep].description}
</p>
<div>
<p className="font-semibold text-foreground mb-2">Deliverables:</p>
<ul className="space-y-1">
{workflowSteps[activeStep].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>
{workflowSteps[activeStep].image && (
<div className="md:w-64 lg:w-80 flex-shrink-0">
<PlaceholderImage
src={workflowSteps[activeStep].image}
alt={`${workflowSteps[activeStep].title} illustration`}
label={workflowSteps[activeStep].title}
className="w-full rounded-xl"
aspectRatio="1:1"
/>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
{/* Progress Dots */}
<div className="flex justify-center items-center gap-3">
{workflowSteps.map((_, index) => (

View File

@ -0,0 +1,69 @@
import { cn } from '../../lib/utils'
interface PlaceholderImageProps {
/** Image source path — renders <img> if provided and the file exists, otherwise a placeholder SVG */
src?: string
/** Alt text for the image */
alt?: string
/** Label displayed on the placeholder */
label?: string
/** Additional CSS classes */
className?: string
/** Aspect ratio for the placeholder (e.g. "16:10", "4:3", "1:1") */
aspectRatio?: string
}
const aspectRatioMap: Record<string, string> = {
'16:10': 'aspect-[16/10]',
'4:3': 'aspect-[4/3]',
'3:2': 'aspect-[3/2]',
'1:1': 'aspect-square',
}
export function PlaceholderImage({
src,
alt = '',
label = 'Image',
className,
aspectRatio = '4:3',
}: PlaceholderImageProps) {
if (src) {
return (
<img
src={src}
alt={alt}
className={cn('object-cover', aspectRatioMap[aspectRatio], className)}
loading="lazy"
/>
)
}
return (
<div
role="img"
aria-label={alt || label}
className={cn(
'bg-slate-100 border border-slate-200 rounded-xl flex items-center justify-center',
aspectRatioMap[aspectRatio],
className
)}
>
<div className="text-center px-4">
<svg
className="w-12 h-12 text-slate-300 mx-auto mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z"
/>
</svg>
<span className="text-sm font-medium text-slate-400">{label}</span>
</div>
</div>
)
}

View File

@ -7,6 +7,7 @@ export interface BentoCardService {
technologies: string[]
size?: 'lg' | 'md' | 'sm'
gridPosition?: string
image?: string
}
export const services: BentoCardService[] = [
@ -19,6 +20,7 @@ export const services: BentoCardService[] = [
technologies: ['React', 'Node.js', 'PostgreSQL', 'AWS'],
size: 'lg',
gridPosition: 'col-span-2 row-span-2',
image: '/images/service-web-apps.webp',
},
{
id: 'mobile',
@ -29,6 +31,7 @@ export const services: BentoCardService[] = [
technologies: ['React Native', 'Swift', 'Kotlin'],
size: 'md',
gridPosition: 'col-span-2 row-span-1',
image: '/images/service-mobile.webp',
},
{
id: 'devops',
@ -39,6 +42,7 @@ export const services: BentoCardService[] = [
technologies: ['Docker', 'AWS', 'Terraform', 'Kubernetes'],
size: 'lg',
gridPosition: 'col-span-2 row-span-2',
image: '/images/service-devops.webp',
},
{
id: 'integration',

View File

@ -5,6 +5,7 @@ export interface WorkflowStepData {
tagline: string
description: string
deliverables: string[]
image?: string
}
export const workflowSteps: WorkflowStepData[] = [
@ -15,6 +16,7 @@ export const workflowSteps: WorkflowStepData[] = [
tagline: 'Understand your goals',
description: 'We dive deep into your business requirements, user needs, and technical constraints.',
deliverables: ['Requirements document', 'Technical roadmap', 'Project timeline'],
image: '/images/workflow-discovery.webp',
},
{
id: 2,
@ -23,6 +25,7 @@ export const workflowSteps: WorkflowStepData[] = [
tagline: 'Blueprint for success',
description: 'Architecture planning and UI/UX design that prioritizes user experience.',
deliverables: ['System architecture', 'UI mockups', 'Database schema'],
image: '/images/workflow-design.webp',
},
{
id: 3,
@ -31,6 +34,7 @@ export const workflowSteps: WorkflowStepData[] = [
tagline: 'Code with precision',
description: 'Agile development with regular demos and continuous integration.',
deliverables: ['Working software', 'Test coverage', 'Documentation'],
image: '/images/workflow-build.webp',
},
{
id: 4,
@ -39,5 +43,6 @@ export const workflowSteps: WorkflowStepData[] = [
tagline: 'Ship with confidence',
description: 'Production deployment with monitoring, CI/CD, and ongoing support.',
deliverables: ['Production environment', 'Monitoring setup', 'Support plan'],
image: '/images/workflow-deploy.webp',
},
]