diff --git a/src/entities/project/index.ts b/src/entities/project/index.ts
new file mode 100644
index 0000000..07d5dce
--- /dev/null
+++ b/src/entities/project/index.ts
@@ -0,0 +1,3 @@
+export { ProjectMetadata } from './ui/ProjectMetadata'
+export { ProjectCard } from './ui/ProjectCard'
+export { DetailedProjectCard } from './ui/DetailedProjectCard'
diff --git a/src/entities/project/ui/DetailedProjectCard.test.tsx b/src/entities/project/ui/DetailedProjectCard.test.tsx
new file mode 100644
index 0000000..bc3cbae
--- /dev/null
+++ b/src/entities/project/ui/DetailedProjectCard.test.tsx
@@ -0,0 +1,91 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { DetailedProjectCard } from './DetailedProjectCard'
+
+const DEFAULT_PROPS = {
+ title: 'Big Project',
+ year: '2023',
+ role: 'Lead Dev',
+ stack: ['Vue', 'Go'],
+ description: 'A detailed project description',
+ details: ['First detail point', 'Second detail point'],
+}
+
+describe('DetailedProjectCard', () => {
+ describe('rendering', () => {
+ it('renders the project title', () => {
+ render()
+ expect(screen.getByText('Big Project')).toBeInTheDocument()
+ })
+
+ it('renders the description', () => {
+ render()
+ expect(screen.getByText('A detailed project description')).toBeInTheDocument()
+ })
+
+ it('renders each detail item', () => {
+ render()
+ expect(screen.getByText('First detail point')).toBeInTheDocument()
+ expect(screen.getByText('Second detail point')).toBeInTheDocument()
+ })
+
+ it('renders ProjectMetadata with year, role, and stack', () => {
+ render()
+ expect(screen.getByText('2023')).toBeInTheDocument()
+ expect(screen.getByText('Lead Dev')).toBeInTheDocument()
+ expect(screen.getByText('Vue')).toBeInTheDocument()
+ expect(screen.getByText('Go')).toBeInTheDocument()
+ })
+ })
+
+ describe('structure', () => {
+ it('outer grid has grid-cols-1 and lg:grid-cols-12', () => {
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12')
+ })
+
+ it('title is rendered as an h3', () => {
+ render()
+ expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project')
+ })
+
+ it('detail items are rendered as
tags with text-base', () => {
+ render()
+ const detail = screen.getByText('First detail point')
+ expect(detail.tagName).toBe('P')
+ expect(detail).toHaveClass('text-base')
+ })
+
+ it('details list has brutal-border-top and pt-6', () => {
+ render()
+ const detail = screen.getByText('First detail point')
+ const detailList = detail.parentElement
+ expect(detailList).toHaveClass('brutal-border-top', 'pt-6')
+ })
+
+ it('description has text-lg and mb-6', () => {
+ render()
+ const desc = screen.getByText('A detailed project description')
+ expect(desc).toHaveClass('text-lg', 'mb-6')
+ })
+ })
+
+ describe('conditional image rendering', () => {
+ it('does not render image when imageUrl is absent', () => {
+ const { container } = render()
+ expect(container.querySelector('img')).toBeNull()
+ })
+
+ it('renders image when imageUrl is provided', () => {
+ render()
+ const img = screen.getByRole('img')
+ expect(img).toHaveAttribute('src', '/detail.jpg')
+ })
+
+ it('image wrapper has aspect-video and brutal-border when imageUrl is provided', () => {
+ const { container } = render()
+ const imgWrapper = container.querySelector('img')!.parentElement
+ expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border')
+ })
+ })
+})
diff --git a/src/entities/project/ui/DetailedProjectCard.tsx b/src/entities/project/ui/DetailedProjectCard.tsx
new file mode 100644
index 0000000..53a2663
--- /dev/null
+++ b/src/entities/project/ui/DetailedProjectCard.tsx
@@ -0,0 +1,78 @@
+import { Card } from '$shared/ui'
+import { ProjectMetadata } from './ProjectMetadata'
+
+type Props = {
+ /**
+ * Project name
+ */
+ title: string
+ /**
+ * Year the project was completed
+ */
+ year: string
+ /**
+ * Developer role on the project
+ */
+ role: string
+ /**
+ * Technology stack list
+ */
+ stack: string[]
+ /**
+ * Project description paragraph
+ */
+ description: string
+ /**
+ * Bullet-style detail points listed below the description
+ */
+ details: string[]
+ /**
+ * Optional hero image URL
+ */
+ imageUrl?: string
+ /**
+ * Reverse layout (reserved for future use)
+ * @default false
+ */
+ reverse?: boolean
+}
+
+/**
+ * Full-width detailed project card with metadata sidebar.
+ */
+export function DetailedProjectCard({
+ title,
+ year,
+ role,
+ stack,
+ description,
+ details,
+ imageUrl,
+}: Props) {
+ return (
+
+
+
+
+
+ {title}
+ {description}
+
+ {imageUrl && (
+
+

+
+ )}
+
+
+ {details.map((detail, index) => (
+
{detail}
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/entities/project/ui/ProjectCard.test.tsx b/src/entities/project/ui/ProjectCard.test.tsx
new file mode 100644
index 0000000..42d579a
--- /dev/null
+++ b/src/entities/project/ui/ProjectCard.test.tsx
@@ -0,0 +1,79 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { ProjectCard } from './ProjectCard'
+
+const DEFAULT_PROPS = {
+ title: 'My Project',
+ year: '2024',
+ description: 'A cool project description',
+ tags: ['React', 'Node'],
+}
+
+describe('ProjectCard', () => {
+ describe('rendering', () => {
+ it('renders the project title', () => {
+ render()
+ expect(screen.getByText('My Project')).toBeInTheDocument()
+ })
+
+ it('renders the year badge', () => {
+ render()
+ expect(screen.getByText('2024')).toBeInTheDocument()
+ })
+
+ it('renders the description', () => {
+ render()
+ expect(screen.getByText('A cool project description')).toBeInTheDocument()
+ })
+
+ it('renders each tag', () => {
+ render()
+ expect(screen.getByText('React')).toBeInTheDocument()
+ expect(screen.getByText('Node')).toBeInTheDocument()
+ })
+
+ it('renders the View Project button', () => {
+ render()
+ expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument()
+ })
+ })
+
+ describe('structure', () => {
+ it('card has hover transition classes', () => {
+ const { container } = render()
+ const card = container.firstChild as HTMLElement
+ expect(card).toHaveClass('group', 'transition-all', 'duration-300')
+ })
+
+ it('year badge has correct classes', () => {
+ render()
+ const yearBadge = screen.getByText('2024')
+ expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm')
+ })
+
+ it('tags have correct classes', () => {
+ render()
+ const tag = screen.getByText('React')
+ expect(tag).toHaveClass('brutal-border', 'bg-white', 'text-carbon-black', 'text-sm', 'uppercase', 'tracking-wide')
+ })
+ })
+
+ describe('conditional image rendering', () => {
+ it('does not render image when imageUrl is absent', () => {
+ const { container } = render()
+ expect(container.querySelector('img')).toBeNull()
+ })
+
+ it('renders image when imageUrl is provided', () => {
+ render()
+ const img = screen.getByRole('img')
+ expect(img).toHaveAttribute('src', '/project.jpg')
+ })
+
+ it('image wrapper has aspect-video and overflow-hidden when imageUrl is provided', () => {
+ const { container } = render()
+ const imgWrapper = container.querySelector('img')!.parentElement
+ expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border')
+ })
+ })
+})
diff --git a/src/entities/project/ui/ProjectCard.tsx b/src/entities/project/ui/ProjectCard.tsx
new file mode 100644
index 0000000..c718b0f
--- /dev/null
+++ b/src/entities/project/ui/ProjectCard.tsx
@@ -0,0 +1,68 @@
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui'
+import { cn } from '$shared/lib'
+
+type Props = {
+ /**
+ * Project name
+ */
+ title: string
+ /**
+ * Year the project was completed
+ */
+ year: string
+ /**
+ * Short project description
+ */
+ description: string
+ /**
+ * Technology or category tags
+ */
+ tags: string[]
+ /**
+ * Optional preview image URL
+ */
+ imageUrl?: string
+}
+
+/**
+ * Compact project card for grid/list display.
+ */
+export function ProjectCard({ title, year, description, tags, imageUrl }: Props) {
+ return (
+
+
+
+ {title}
+ {year}
+
+ {description}
+
+
+ {imageUrl && (
+
+

+
+ )}
+
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/src/entities/project/ui/ProjectMetadata.test.tsx b/src/entities/project/ui/ProjectMetadata.test.tsx
new file mode 100644
index 0000000..2efc055
--- /dev/null
+++ b/src/entities/project/ui/ProjectMetadata.test.tsx
@@ -0,0 +1,96 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { ProjectMetadata } from './ProjectMetadata'
+
+const DEFAULT_PROPS = {
+ year: '2024',
+ role: 'Frontend Engineer',
+ stack: ['React', 'TypeScript', 'Tailwind'],
+}
+
+describe('ProjectMetadata', () => {
+ describe('rendering', () => {
+ it('renders the year value', () => {
+ render()
+ expect(screen.getByText('2024')).toBeInTheDocument()
+ })
+
+ it('renders the YEAR label', () => {
+ render()
+ expect(screen.getByText('YEAR')).toBeInTheDocument()
+ })
+
+ it('renders the role value', () => {
+ render()
+ expect(screen.getByText('Frontend Engineer')).toBeInTheDocument()
+ })
+
+ it('renders the ROLE label', () => {
+ render()
+ expect(screen.getByText('ROLE')).toBeInTheDocument()
+ })
+
+ it('renders the STACK label', () => {
+ render()
+ expect(screen.getByText('STACK')).toBeInTheDocument()
+ })
+
+ it('renders each stack technology', () => {
+ render()
+ expect(screen.getByText('React')).toBeInTheDocument()
+ expect(screen.getByText('TypeScript')).toBeInTheDocument()
+ expect(screen.getByText('Tailwind')).toBeInTheDocument()
+ })
+ })
+
+ describe('structure', () => {
+ it('outer div has space-y-6', () => {
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('space-y-6')
+ })
+
+ it('year section has no brutal-border-top (first section)', () => {
+ const { container } = render()
+ const sections = container.firstChild!.childNodes
+ expect(sections[0]).not.toHaveClass('brutal-border-top')
+ })
+
+ it('role section has brutal-border-top and pt-6', () => {
+ const { container } = render()
+ const sections = container.firstChild!.childNodes
+ expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6')
+ })
+
+ it('stack section has brutal-border-top and pt-6', () => {
+ const { container } = render()
+ const sections = container.firstChild!.childNodes
+ expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6')
+ })
+
+ it('label has text-xs uppercase tracking-wider opacity-60', () => {
+ render()
+ const yearLabel = screen.getByText('YEAR')
+ expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60')
+ })
+
+ it('year value has text-base font-bold', () => {
+ render()
+ const yearValue = screen.getByText('2024')
+ expect(yearValue).toHaveClass('text-base', 'font-bold')
+ })
+
+ it('each stack tech is rendered as a with text-sm', () => {
+ render()
+ const techEl = screen.getByText('React')
+ expect(techEl.tagName).toBe('P')
+ expect(techEl).toHaveClass('text-sm')
+ })
+ })
+
+ describe('className passthrough', () => {
+ it('merges custom className onto outer div', () => {
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('my-custom')
+ })
+ })
+})
diff --git a/src/entities/project/ui/ProjectMetadata.tsx b/src/entities/project/ui/ProjectMetadata.tsx
new file mode 100644
index 0000000..f1b73f5
--- /dev/null
+++ b/src/entities/project/ui/ProjectMetadata.tsx
@@ -0,0 +1,44 @@
+import { cn } from '$shared/lib'
+
+type Props = {
+ /**
+ * Project year
+ */
+ year: string
+ /**
+ * Developer role on the project
+ */
+ role: string
+ /**
+ * Technology stack list
+ */
+ stack: string[]
+ /**
+ * Additional CSS classes
+ */
+ className?: string
+}
+
+/**
+ * Sidebar metadata display for a project: year, role, and stack.
+ */
+export function ProjectMetadata({ year, role, stack, className }: Props) {
+ return (
+
+
+
+
+
STACK
+ {stack.map((tech) => (
+
{tech}
+ ))}
+
+
+ )
+}