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

37
public/images/PROMPTS.md Normal file
View File

@ -0,0 +1,37 @@
# AI Image Generation Prompts (Nano Banana 3)
## 1. Hero Illustration (`hero-dashboard.webp`)
Isometric 3D illustration of a modern software dashboard floating above a desk workspace. Clean minimal style with soft blue (#2563eb) and amber (#f59e0b) accent colors on a light grey background. Shows a laptop screen with abstract UI components — charts, cards, and navigation elements. Subtle floating code snippets and cloud icons surround it. Professional tech company hero image style. No text. No people. Sharp vector-like rendering with soft shadows. 16:10 aspect ratio.
## 2. Web Applications Service (`service-web-apps.webp`)
Clean flat illustration of a modern web application interface. Shows a browser window with a dashboard containing analytics charts, a sidebar navigation, and data cards. Colour palette: deep blue (#2563eb) primary with white and light grey. Minimal geometric style, no text, no people. Soft drop shadows. 4:3 aspect ratio.
## 3. Mobile Development Service (`service-mobile.webp`)
Flat illustration of two smartphones side by side (iOS and Android) displaying a modern mobile app interface. Screens show a clean onboarding flow with abstract UI elements. Blue (#2563eb) accent colour, white backgrounds, rounded corners. Minimal style, no text, no people. 3:2 aspect ratio.
## 4. DevOps & Infrastructure Service (`service-devops.webp`)
Isometric illustration of cloud infrastructure: server racks, Docker containers as stacked blue cubes, CI/CD pipeline shown as connected conveyor belts with checkmarks. Kubernetes wheel logo subtly integrated. Blue (#2563eb) and slate grey colour scheme on white background. Technical but clean, no text, no people. 4:3 aspect ratio.
## 5. Contact Illustration (`contact-collab.webp`)
Warm minimal illustration of two abstract figures collaborating over a shared digital workspace. One figure gestures toward a floating screen with wireframes. Soft blue (#2563eb) and amber (#f59e0b) highlights on a light background. Friendly, approachable, professional. No realistic faces — abstract/geometric people. 4:3 aspect ratio.
## 6. Workflow: Discovery (`workflow-discovery.webp`)
Minimal illustration of a magnifying glass examining a blueprint/roadmap document. Abstract sticky notes and a checklist float nearby. Blue (#2563eb) tones with amber (#f59e0b) accent on dark slate (#1e293b) background. Clean flat style, no text, no people. Square 1:1 aspect ratio.
## 7. Workflow: Design (`workflow-design.webp`)
Minimal illustration of a design workspace: a colour palette, wireframe sketches, and a pen tool cursor. Geometric UI component shapes float in an organised arrangement. Blue (#2563eb) tones with amber accent on dark slate (#1e293b) background. Flat vector style, no text, no people. Square 1:1 aspect ratio.
## 8. Workflow: Build (`workflow-build.webp`)
Minimal illustration of code editor windows stacked at angles, showing abstract coloured syntax lines. A gear/cog icon and test-tube icon float nearby representing testing and automation. Blue (#2563eb) on dark slate (#1e293b). Clean flat style, no text, no people. Square 1:1 aspect ratio.
## 9. Workflow: Deploy (`workflow-deploy.webp`)
Minimal illustration of a rocket launching from a laptop screen toward abstract cloud shapes. Uptime chart and checkmark shield float alongside. Blue (#2563eb) with amber (#f59e0b) rocket flame on dark slate (#1e293b). Flat style, no text, no people. Square 1:1 aspect ratio.

View File

@ -1,5 +1,6 @@
import { Globe, Smartphone, Cloud, Plug, Shield, Lightbulb, type LucideIcon } from 'lucide-react' import { Globe, Smartphone, Cloud, Plug, Shield, Lightbulb, type LucideIcon } from 'lucide-react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { PlaceholderImage } from '../ui/PlaceholderImage'
import type { BentoCardService } from '../../data/services' import type { BentoCardService } from '../../data/services'
const iconMap: Record<string, LucideIcon> = { const iconMap: Record<string, LucideIcon> = {
@ -57,6 +58,7 @@ export function BentoCard({
summary={service.summary} summary={service.summary}
details={service.details} details={service.details}
technologies={service.technologies} technologies={service.technologies}
image={service.image}
/> />
)} )}
{size === 'md' && ( {size === 'md' && (
@ -65,6 +67,7 @@ export function BentoCard({
title={service.title} title={service.title}
summary={service.summary} summary={service.summary}
details={service.details} details={service.details}
image={service.image}
/> />
)} )}
{size === 'sm' && ( {size === 'sm' && (
@ -80,11 +83,24 @@ interface LargeLayoutProps {
summary: string summary: string
details: string details: string
technologies: 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 ( return (
<div className="flex flex-col h-full"> <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 <div
data-testid="bento-card-icon" 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" 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 title: string
summary: string summary: string
details: string details: string
image?: string
} }
function MediumLayout({ icon: Icon, title, summary, details }: MediumLayoutProps) { function MediumLayout({ icon: Icon, title, summary, details, image }: MediumLayoutProps) {
return ( return (
<div className="flex gap-4 items-start"> <div className="flex gap-4 items-start">
<div <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-muted-foreground text-sm mb-2">{summary}</p>
<p className="text-foreground text-sm">{details}</p> <p className="text-foreground text-sm">{details}</p>
</div> </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> </div>
) )
} }

View File

@ -73,30 +73,6 @@ describe('WorkflowStep', () => {
expect(stepCard).toHaveClass('scale-95') 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 () => { it('is keyboard accessible', async () => {
const handleClick = vi.fn() const handleClick = vi.fn()
const user = userEvent.setup() const user = userEvent.setup()
@ -123,16 +99,6 @@ describe('WorkflowStep', () => {
expect(handleClick).toHaveBeenCalledTimes(1) 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', () => { it('applies primary border when active', () => {
render(<WorkflowStep {...defaultProps} isActive={true} />) 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 { Search, Palette, Code, Rocket, type LucideIcon } from 'lucide-react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import type { WorkflowStepData } from '../../data/workflow' import type { WorkflowStepData } from '../../data/workflow'
@ -37,7 +36,6 @@ export function WorkflowStep({ step, isActive, onClick, stepNumber }: WorkflowSt
tabIndex={0} tabIndex={0}
onClick={onClick} onClick={onClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-expanded={isActive}
className={cn( className={cn(
'bg-white border rounded-2xl p-6 cursor-pointer transition-all duration-300 ease-in-out', 'bg-white border rounded-2xl p-6 cursor-pointer transition-all duration-300 ease-in-out',
isActive isActive
@ -63,35 +61,7 @@ export function WorkflowStep({ step, isActive, onClick, stepNumber }: WorkflowSt
</div> </div>
{/* Tagline */} {/* Tagline */}
<p className="text-muted-foreground mb-4">{step.tagline}</p> <p className="text-muted-foreground">{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> </div>
) )
} }

View File

@ -4,6 +4,7 @@ import { Container } from '../layout/Container'
import { Card } from '../ui/Card' import { Card } from '../ui/Card'
import { Button } from '../ui/Button' import { Button } from '../ui/Button'
import { Input } from '../ui/Input' import { Input } from '../ui/Input'
import { PlaceholderImage } from '../ui/PlaceholderImage'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
interface ContactMethod { interface ContactMethod {
@ -147,6 +148,16 @@ export function Contact({ onSubmit }: ContactProps) {
</div> </div>
))} ))}
</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> </div>
{/* Contact Form */} {/* Contact Form */}

View File

@ -2,6 +2,7 @@ import { motion } from 'framer-motion'
import { Zap, Shield, Layers, Cloud, TrendingUp, Sparkles } from 'lucide-react' import { Zap, Shield, Layers, Cloud, TrendingUp, Sparkles } from 'lucide-react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { Button } from '../ui/Button' import { Button } from '../ui/Button'
import { PlaceholderImage } from '../ui/PlaceholderImage'
import { Container } from '../layout/Container' import { Container } from '../layout/Container'
interface HeroProps { interface HeroProps {
@ -89,29 +90,43 @@ export function Hero({ headline, subheadline }: HeroProps) {
See How We Work See How We Work
</Button> </Button>
</motion.div> </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> </div>
{/* Capability badges */} {/* Hero illustration — visible on lg screens */}
<motion.div <motion.div
variants={itemVariants} variants={itemVariants}
className="grid grid-cols-2 sm:grid-cols-3 gap-4" className="hidden lg:block"
> >
{capabilities.map(({ label, icon: Icon }) => ( <PlaceholderImage
<div src="/images/hero-dashboard.webp"
key={label} alt="Software dashboard illustration"
className={cn( label="Dashboard"
'flex items-center gap-3 p-4', aspectRatio="16:10"
'bg-white rounded-xl shadow-sm', className="w-full rounded-2xl shadow-lg"
'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> </motion.div>
</motion.div> </motion.div>
</Container> </Container>

View File

@ -14,7 +14,8 @@ describe('WorkflowCarousel', () => {
it('renders all 4 workflow steps', () => { it('renders all 4 workflow steps', () => {
render(<WorkflowCarousel autoAdvance={false} />) 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('Design')).toBeInTheDocument()
expect(screen.getByText('Build')).toBeInTheDocument() expect(screen.getByText('Build')).toBeInTheDocument()
expect(screen.getByText('Deploy')).toBeInTheDocument() expect(screen.getByText('Deploy')).toBeInTheDocument()

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { motion } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { Container } from '../layout/Container' import { Container } from '../layout/Container'
import { WorkflowStep } from '../features/WorkflowStep' import { WorkflowStep } from '../features/WorkflowStep'
import { PlaceholderImage } from '../ui/PlaceholderImage'
import { workflowSteps } from '../../data/workflow' import { workflowSteps } from '../../data/workflow'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
@ -63,8 +64,8 @@ export function WorkflowCarousel({
</p> </p>
</div> </div>
{/* Carousel */} {/* Step Tabs */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12"> <div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{workflowSteps.map((step, index) => ( {workflowSteps.map((step, index) => (
<WorkflowStep <WorkflowStep
key={step.id} key={step.id}
@ -76,6 +77,53 @@ export function WorkflowCarousel({
))} ))}
</div> </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 */} {/* Progress Dots */}
<div className="flex justify-center items-center gap-3"> <div className="flex justify-center items-center gap-3">
{workflowSteps.map((_, index) => ( {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[] technologies: string[]
size?: 'lg' | 'md' | 'sm' size?: 'lg' | 'md' | 'sm'
gridPosition?: string gridPosition?: string
image?: string
} }
export const services: BentoCardService[] = [ export const services: BentoCardService[] = [
@ -19,6 +20,7 @@ export const services: BentoCardService[] = [
technologies: ['React', 'Node.js', 'PostgreSQL', 'AWS'], technologies: ['React', 'Node.js', 'PostgreSQL', 'AWS'],
size: 'lg', size: 'lg',
gridPosition: 'col-span-2 row-span-2', gridPosition: 'col-span-2 row-span-2',
image: '/images/service-web-apps.webp',
}, },
{ {
id: 'mobile', id: 'mobile',
@ -29,6 +31,7 @@ export const services: BentoCardService[] = [
technologies: ['React Native', 'Swift', 'Kotlin'], technologies: ['React Native', 'Swift', 'Kotlin'],
size: 'md', size: 'md',
gridPosition: 'col-span-2 row-span-1', gridPosition: 'col-span-2 row-span-1',
image: '/images/service-mobile.webp',
}, },
{ {
id: 'devops', id: 'devops',
@ -39,6 +42,7 @@ export const services: BentoCardService[] = [
technologies: ['Docker', 'AWS', 'Terraform', 'Kubernetes'], technologies: ['Docker', 'AWS', 'Terraform', 'Kubernetes'],
size: 'lg', size: 'lg',
gridPosition: 'col-span-2 row-span-2', gridPosition: 'col-span-2 row-span-2',
image: '/images/service-devops.webp',
}, },
{ {
id: 'integration', id: 'integration',

View File

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