+ Clear build order and state specifications.
- It invents missing project and design details.
# Design Handoff Notes — AI-First, Human-Readable ### A structured handoff document optimized for AI implementation agents (Claude Code, Cursor, Copil
| Category | Design › Design collaboration |
|---|---|
| Tags | DraftingReformattingDeveloperTemplate |
# Design Handoff Notes — AI-First, Human-Readable
### A structured handoff document optimized for AI implementation agents (Claude Code, Cursor, Copilot) while remaining clear for human developers
---
## About This Prompt
**Description:** Generates a design handoff document that serves as direct implementation instructions for AI coding agents. Unlike traditional handoff notes that describe how a design "should feel," this document provides machine-parseable specifications with zero ambiguity. Every value is explicit, every state is defined, every edge case has a rule. The document is structured so an AI agent can read it top-to-bottom and implement without asking clarifying questions — while a human developer can also read it naturally.
**The core philosophy:** If an AI reads this document and has to guess anything, the document has failed.
**When to use:** After design is finalized, before implementation begins. This replaces Figma handoff, design spec PDFs, and "just make it look like the mockup" conversations.
**Who reads this:**
- Primary: AI coding agents (Claude Code, Cursor, Copilot, etc.)
- Secondary: Human developers reviewing or debugging the AI's output
- Tertiary: You (the designer), when checking if implementation matches intent
**Relationship to CLAUDE.md:** This document assumes a CLAUDE.md design system file already exists in the project root. Handoff Notes reference tokens from CLAUDE.md but don't redefine them. If no CLAUDE.md exists, run the Design System Extraction prompts first.
---
## The Prompt
```
You are a design systems engineer writing implementation specifications.
Your output will be read primarily by AI coding agents (Claude Code, Cursor)
and secondarily by human developers.
Your writing must follow one absolute rule:
**If the reader has to guess, infer, or assume anything, you have failed.**
Every value must be explicit. Every state must be defined. Every edge case
must have a rule. No "as appropriate," no "roughly," no "similar to."
## Project Context
- **Project:** ${name}
- **Framework:** [Next.js 14+ / React / etc.]
- **Styling:** [Tailwind 3.x / CSS Modules / etc.]
- **Component library:** [shadcn/ui / custom / etc.]
- **CLAUDE.md location:** [path — or "not yet created"]
- **Design source:** [uploaded code / live URL / screenshots]
- **Pages to spec:** [all / specific pages]
## Output Format Rules
Before writing any specs, follow these formatting rules exactly:
1. **Values are always code-ready.**
WRONG: "medium spacing"
RIGHT: `p-6` (24px)
2. **Colors are always token references + fallback hex.**
WRONG: "brand blue"
RIGHT: `text-brand-500` (#2563EB) — from CLAUDE.md tokens
3. **Sizes are always in the project's unit system.**
If Tailwind: use Tailwind classes as primary, px as annotation
If CSS: use rem as primary, px as annotation
WRONG: "make it bigger on desktop"
RIGHT: `text-lg` (18px) at ≥768px, `text-base` (16px) below
4. **Conditionals use explicit if/else, never "as needed."**
WRONG: "show loading state as appropriate"
RIGHT: "if data fetch takes >300ms, show skeleton. If fetch fails, show error state. If data returns empty array, show empty state."
5. **File paths are explicit.**
WRONG: "create a button component"
RIGHT: "create `src/components/ui/Button.tsx`"
6. **Every visual property is stated, never inherited by assumption.**
Even if "obvious" — state it. AI agents don't have visual context.
---
## Document Structure
Generate the handoff document with these sections:
### SECTION 1: IMPLEMENTATION MAP
A priority-ordered table of everything to build.
AI agents should implement in this order to resolve dependencies correctly.
| Order | Component/Section | File Path | Dependencies | Complexity | Notes |
|-------|------------------|-----------|-------------|-----------|-------|
| 1 | Design tokens setup | `tailwind.config.ts` | None | Low | Must be first — all other components reference these |
| 2 | Typography components | `src/components/ui/Text.tsx` | Tokens | Low | Heading, Body, Caption, Label variants |
| 3 | Button | `src/components/ui/Button.tsx` | Tokens, Typography | Medium | 3 variants × 3 sizes × 6 states |
| ... | ... | ... | ... | ... | ... |
Rules:
- Nothing can reference a component that comes later in the table
- Complexity = how many variants × states the component has
- Notes = anything non-obvious about implementation
---
### SECTION 2: GLOBAL SPECIFICATIONS
These apply everywhere. AI agent should configure these BEFORE building any components.
#### 2.1 Breakpoints
Define exact behavior boundaries:
```
BREAKPOINTS {
mobile: 0px — 767px
tablet: 768px — 1023px
desktop: 1024px — 1279px
wide: 1280px — ∞
}
```
For each breakpoint, state:
- Container max-width and padding
- Base font size
- Global spacing multiplier (if it changes)
- Navigation mode (hamburger / horizontal / etc.)
#### 2.2 Transition Defaults
```
TRANSITIONS {
default: duration-200 ease-out
slow: duration-300 ease-in-out
spring: duration-500 cubic-bezier(0.34, 1.56, 0.64, 1)
none: duration-0
}
RULE: Every interactive element uses `default` unless
this document specifies otherwise.
RULE: Transitions apply to: background-color, color, border-color,
opacity, transform, box-shadow. Never to: width, height, padding,
margin (these cause layout recalculation).
```
#### 2.3 Z-Index Scale
```
Z-INDEX {
base: 0
dropdown: 10
sticky: 20
overlay: 30
modal: 40
toast: 50
tooltip: 60
}
RULE: No z-index value outside this scale. Ever.
```
#### 2.4 Focus Style
```
FOCUS {
style: ring-2 ring-offset-2 ring-brand-500
applies-to: every interactive element (buttons, links, inputs, selects, checkboxes)
visible: only on keyboard navigation (use focus-visible, not focus)
}
```
---
### SECTION 3: PAGE SPECIFICATIONS
For each page, provide a complete implementation spec.
#### Page: ${page_name}
**Route:** `/exact-route-path`
**Layout:** ${which_layout_wrapper_to_use}
**Data requirements:** [what data this page needs, from where]
##### Page Structure (top to bottom)
```
PAGE STRUCTURE: ${page_name}
├── Section: Hero
│ ├── Component: Heading (h1)
│ ├── Component: Subheading (p)
│ ├── Component: CTA Button (primary, lg)
│ └── Component: HeroImage
├── Section: Features
│ ├── Component: SectionHeading (h2)
│ └── Component: FeatureCard × 3 (grid)
├── Section: Testimonials
│ └── Component: TestimonialSlider
└── Section: CTA
├── Component: Heading (h2)
└── Component: CTA Button (primary, lg)
```
##### Section-by-Section Specs
For each section:
**${section_name}**
```
LAYOUT {
container: max-w-[1280px] mx-auto px-6 (mobile: px-4)
direction: flex-col (mobile) → flex-row (desktop)
gap: gap-8 (32px)
padding: py-16 (64px) (mobile: py-10)
background: bg-white
}
CONTENT {
heading {
text: "${exact_heading_text_or_content_source}"
element: h2
class: text-3xl font-bold text-gray-900 (mobile: text-2xl)
max-width: max-w-[640px]
}
body {
text: "${exact_body_text_or_content_source}"
class: text-lg text-gray-600 leading-relaxed (mobile: text-base)
max-width: max-w-[540px]
}
}
GRID (if applicable) {
columns: grid-cols-3 (tablet: grid-cols-2) (mobile: grid-cols-1)
gap: gap-6 (24px)
items: ${what_component_renders_in_each_cell}
alignment: items-start
}
ANIMATION (if applicable) {
type: fade-up on scroll
trigger: when section enters viewport (threshold: 0.2)
stagger: each child delays 100ms after previous
duration: duration-500
easing: ease-out
runs: once (do not re-trigger on scroll up)
}
```
---
### SECTION 4: COMPONENT SPECIFICATIONS
For each component, provide a complete implementation contract.
#### Component: ${componentname}
**File:** `src/components/${path}/${componentname}.tsx`
**Purpose:** [one sentence — what this component does]
##### Props Interface
```typescript
interface ${componentname}Props {
variant: 'primary' | 'secondary' | 'ghost' // visual style
size: 'sm' | 'md' | 'lg' // dimensions
disabled?: boolean // default: false
loading?: boolean // default: false
icon?: React.ReactNode // optional leading icon
children: React.ReactNode // label content
onClick?: () => void // click handler
}
```
##### Variant × Size Matrix
Define exact values for every combination:
```
VARIANT: primary
SIZE: sm
height: h-8 (32px)
padding: px-3 (12px)
font: text-sm font-medium (14px)
background: bg-brand-500 (#2563EB)
text: text-white (#FFFFFF)
border: none
border-radius: rounded-md (6px)
shadow: none
SIZE: md
height: h-10 (40px)
padding: px-4 (16px)
font: text-sm font-medium (14px)
background: bg-brand-500 (#2563EB)
text: text-white (#FFFFFF)
border: none
border-radius: rounded-lg (8px)
shadow: shadow-sm
SIZE: lg
height: h-12 (48px)
padding: px-6 (24px)
font: text-base font-semibold (16px)
background: bg-brand-500 (#2563EB)
text: text-white (#FFFFFF)
border: none
border-radius: rounded-lg (8px)
shadow: shadow-sm
VARIANT: secondary
[same structure, different values]
VARIANT: ghost
[same structure, different values]
```
##### State Specifications
Every state must be defined for every variant:
```
STATES (apply to ALL variants unless overridden):
hover {
background: ${token} — darken one step from default
transform: none (no scale/translate on hover)
shadow: ${token_or_none}
cursor: pointer
transition: default (duration-200 ease-out)
}
active {
background: ${token} — darken two steps from default
transform: scale-[0.98]
transition: duration-75
}
focus-visible {
ring: ring-2 ring-offset-2 ring-brand-500
all other: same as default state
}
disabled {
opacity: opacity-50
cursor: not-allowed
pointer-events: none
ALL hover/active/focus states: do not apply
}
loading {
content: replace children with spinner (16px, animate-spin)
width: maintain same width as non-loading state (prevent layout shift)
pointer-events: none
opacity: opacity-80
}
```
##### Icon Behavior
```
ICON RULES {
position: left of label text (always)
size: 16px (sm), 16px (md), 20px (lg)
gap: gap-1.5 (sm), gap-2 (md), gap-2 (lg)
color: inherits text color (currentColor)
when loading: icon is hidden, spinner takes its position
icon-only: if no children, component becomes square (width = height)
add aria-label prop requirement
}
```
---
### SECTION 5: INTERACTION FLOWS
For each user flow, provide step-by-step implementation:
#### Flow: [Flow Name, e.g., "User Signs Up"]
```
TRIGGER: user clicks "Sign Up" button in header
STEP 1: Modal opens
animation: fade-in (opacity 0→1, duration-200)
backdrop: bg-black/50, click-outside closes modal
focus: trap focus inside modal, auto-focus first input
body: scroll-lock (prevent background scroll)
STEP 2: User fills form
fields: ${list_exact_fields_with_validation_rules}
validation: on blur (not on change — reduces noise)
field: email {
type: email
required: true
validate: regex pattern + "must contain @ and domain"
error: "That doesn't look like an email — check for typos"
success: green checkmark icon appears (fade-in, duration-150)
}
field: password {
type: password (with show/hide toggle)
required: true
validate: min 8 chars, 1 uppercase, 1 number
error: show checklist of requirements, highlight unmet
strength: show strength bar (weak/medium/strong)
}
STEP 3: User submits
button: shows loading state (see Button component spec)
request: POST /api/auth/signup
duration: expect 1-3 seconds
STEP 4a: Success
modal: content transitions to success message (crossfade, duration-200)
message: "Account created! Check your email to verify."
action: "Got it" button closes modal
redirect: after close, redirect to /dashboard
toast: none (the modal IS the confirmation)
STEP 4b: Error — email exists
field: email input shows error state
message: "This email already has an account — want to log in instead?"
action: "Log in" link switches modal to login form
button: returns to default state (not loading)
STEP 4c: Error — network failure
display: error banner at top of modal (not a toast)
message: "Something went wrong on our end. Try again?"
action: "Try again" button re-submits
button: returns to default state
STEP 4d: Error — rate limited
display: error banner
message: "Too many attempts. Wait 60 seconds and try again."
button: disabled for 60 seconds with countdown visible
```
---
### SECTION 6: RESPONSIVE BEHAVIOR RULES
Don't describe what changes — specify the exact rules:
```
RESPONSIVE RULES:
Rule 1: Navigation
≥1024px: horizontal nav, all items visible
<1024px: hamburger icon, slide-in drawer from right
drawer-width: 80vw (max-w-[320px])
animation: translate-x (duration-300 ease-out)
backdrop: bg-black/50, click-outside closes
Rule 2: Grid Sections
≥1024px: grid-cols-3
768-1023px: grid-cols-2 (last item spans full if odd count)
<768px: grid-cols-1
Rule 3: Hero Section
≥1024px: two-column (text left, image right) — 55/45 split
<1024px: single column (text top, image bottom)
image max-height: 400px, object-cover
Rule 4: Typography Scaling
≥1024px: h1=text-5xl, h2=text-3xl, h3=text-xl, body=text-base
<1024px: h1=text-3xl, h2=text-2xl, h3=text-lg, body=text-base
Rule 5: Spacing Scaling
≥1024px: section-padding: py-16, container-padding: px-8
768-1023px: section-padding: py-12, container-padding: px-6
<768px: section-padding: py-10, container-padding: px-4
Rule 6: Touch Targets
<1024px: all interactive elements minimum 44×44px hit area
if visual size < 44px, use invisible padding to reach 44px
Rule 7: Images
all images: use next/image with responsive sizes prop
hero: sizes="(max-width: 1024px) 100vw, 50vw"
grid items: sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
```
---
### SECTION 7: EDGE CASES & BOUNDARY CONDITIONS
This section prevents the "but what happens when..." problems:
```
EDGE CASES:
Text Overflow {
headings: max 2 lines, then truncate with text-ellipsis (add title attr for full text)
body text: allow natural wrapping, no truncation
button labels: single line only, max 30 characters, no truncation (design constraint)
nav items: single line, truncate if >16 characters on mobile
table cells: truncate with tooltip on hover
}
Empty States {
lists/grids with 0 items: show ${emptystate} component
- illustration: ${describe_or_reference_asset}
- heading: "${exact_text}"
- body: "${exact_text}"
- CTA: "${exact_text}" → ${action}
user avatar missing: show initials on colored background
- background: generate from user name hash (deterministic)
- initials: first letter of first + last name, uppercase
- font: text-sm font-medium text-white
image fails to load: show gray placeholder with image icon
- background: bg-gray-100
- icon: ImageOff from lucide-react, text-gray-400, 24px
}
Loading States {
page load: full-page skeleton (not spinner)
component load: component-level skeleton matching final dimensions
button action: inline spinner in button (see Button spec)
infinite list: skeleton row × 3 at bottom while fetching next page
skeleton style: bg-gray-200 rounded animate-pulse
skeleton rule: skeleton shape must match final content shape
(rectangle for text, circle for avatars, rounded-lg for cards)
}
Error States {
API error (500): show inline error banner with retry button
Network error: show "You seem offline" banner at top (auto-dismiss when reconnected)
404 content: show custom 404 component (not Next.js default)
Permission denied: redirect to /login with return URL param
Form validation: inline per-field (see flow specs), never alert()
}
Data Extremes {
username 1 character: display normally
username 50 characters: truncate at 20 in nav, full in profile
price $0.00: show "Free"
price $999,999.99: ensure layout doesn't break (test with formatted number)
list with 1 item: same layout as multiple (no special case)
list with 500 items: paginate at 20, show "Load more" button
date today: show "Today" not the date
date this year: show "Mar 13" not "Mar 13, 2026"
date other year: show "Mar 13, 2025"
}
```
---
### SECTION 8: IMPLEMENTATION VERIFICATION CHECKLIST
After implementation, the AI agent (or human developer) should verify:
```
VERIFICATION:
□ Every component matches the variant × size matrix exactly
□ Every state (hover, active, focus, disabled, loading) works
□ Tab order follows visual order on all pages
□ Focus-visible ring appears on keyboard nav, not on mouse click
□ All transitions use specified duration and easing (not browser default)
□ No layout shift during page load (check CLS)
□ Skeleton states match final content dimensions
□ All edge cases from Section 7 are handled
□ Touch targets ≥ 44×44px on mobile breakpoints
□ No horizontal scroll at any breakpoint
□ All images use next/image with correct sizes prop
□ Z-index values only use the defined scale
□ Error states display correctly (test with network throttle)
□ Empty states display correctly (test with empty data)
□ Text truncation works at boundary lengths
□ Dark mode tokens (if applicable) are all mapped
```
---
## How the AI Agent Should Use This Document
Include this instruction at the top of the generated handoff document
so the implementing AI knows how to work with it:
```
INSTRUCTIONS FOR AI IMPLEMENTATION AGENT:
1. Read this document fully before writing any code.
2. Implement in the order specified in SECTION 1 (Implementation Map).
3. Reference CLAUDE.md for token values. If a token referenced here
is not in CLAUDE.md, flag it and use the fallback value provided.
4. Every value in this document is intentional. Do not substitute
with "close enough" values. `gap-6` means `gap-6`, not `gap-5`.
5. Every state must be implemented. If a state is not specified for
a component, that is a gap in the spec — flag it, do not guess.
6. After implementing each component, run through its state matrix
and verify all states work before moving to the next component.
7. When encountering ambiguity, prefer the more explicit interpretation.
If still ambiguous, add a TODO comment: "// HANDOFF-AMBIGUITY: [description]"
```
```
---
## Customization Notes
**If you're not using Tailwind:** Replace all Tailwind class references in the prompt with your system's equivalents. The structure stays the same — only the value format changes. Tell Claude: "Use CSS custom properties as primary, px values as annotations."
**If you're handing off to a specific AI tool:** Add tool-specific notes. For example, for Cursor: "Generate implementation as step-by-step edits to existing files, not full file rewrites." For Claude Code: "Create each component as a complete file, test it, then move to the next."
**If no CLAUDE.md exists yet:** Tell the prompt to generate a minimal token section at the top of the handoff document covering only the tokens needed for this specific handoff. It won't be a full design system, but it prevents hardcoded values.
**For multi-page projects:** Run the prompt once per page, but include Section 1 (Implementation Map) and Section 2 (Global Specs) only in the first run. Subsequent pages reference the same globals.This prompt generates handoff specs after design is finalized and before implementation. It requires every value, state, and edge case to be explicit, assuming CLAUDE.md tokens may be referenced.
ChatGPT and Gemini are well structured but invent a Moodystore spec from an unfilled template. ChatGPT is more complete, Gemini is truncated, and [C] is missing.
+ Clear build order and state specifications.
- It invents missing project and design details.
+ Responsive layouts and contracts are clear.
- It fabricates the spec and ends mid-sentence.
| Criterion | ChatGPT | Gemini | Leader |
|---|---|---|---|
| Instruction following | 3 | 3 | Tie |
| Accuracy | 2 | 2 | Tie |
| Specificity | 6 | 6 | Tie |
| Structure | 8 | 8 | Tie |
| Right length | 4 | 3 | ChatGPT +33% |
Scored 1–10 by gpt-5.6-sol with model names hidden (2026-09-25). This is an AI review, not a measurement.
We gave three models the same input and copied their answers unedited. Each ran in its CLI (an agent harness), and answers in the ChatGPT or Claude apps or on the web may differ. Outputs are in Korean.
[Pasted material]
Project: 무디스토어 (라이프스타일 편집숍 온라인 스토어)
Framework: Next.js 14 (App Router)
Styling: Tailwind CSS 3.4
Component library: shadcn/ui
CLAUDE.md location: /Users/park/project/moodystore/CLAUDE.md (이미 존재, 색상·타이포 토큰 정의됨)
Design source: Figma에서 export한 스크린샷 3장 업로드 (홈, 상품 상세, 장바구니)
Pages to spec: 상품 상세 페이지(Product Detail Page) 1개만 우선 작성 — Section 3의 ${page_name} 자리에는 '상품 상세 페이지'를 넣어서 작성해줘/Users/park/project/moodystore/CLAUDE.md의 토큰을 우선 사용한다.// HANDOFF-AMBIGUITY 주석을 남긴다.| 순서 | 컴포넌트/섹션 | 파일 경로 | 의존성 | 복잡도 | 구현 규칙 |
|---|---|---|---|---|---|
| 1 | 상품 타입 및 fixture | src/types/product.ts, src/data/product-fixture.ts |
없음 | Low | 상품, 옵션, 이미지, 리뷰 타입을 정의한다. |
| 2 | 상품 상세 데이터 로더 | src/lib/products/get-product.ts |
상품 타입 | Medium | /products/[slug]에서 상품을 조회한다. |
| 3 | 전역 상품 상세 레이아웃 | src/components/product/ProductDetailLayout.tsx |
상품 타입 | Medium | 데스크톱 2열, 모바일 1열 레이아웃을 담당한다. |
| 4 | 상품 이미지 갤러리 | src/components/product/ProductGallery.tsx |
Button, 상품 타입 | Medium | 썸네일 선택, 확대, 이미지 오류 상태를 처리한다. |
| 5 | 상품 정보 영역 | src/components/product/ProductInfo.tsx |
Text, Badge | Medium | 브랜드, 상품명, 가격, 리뷰 요약을 표시한다. |
| 6 | 옵션 선택기 | src/components/product/ProductOptionSelector.tsx |
Button | High | 옵션 선택, 품절, 선택 오류를 처리한다. |
| 7 | 수량 선택기 | src/components/product/QuantitySelector.tsx |
Button | Medium | 최소 1개, 최대 재고 수량을 적용한다. |
| 8 | 구매 액션 영역 | src/components/product/PurchaseActions.tsx |
Button, QuantitySelector | High | 장바구니, 바로 구매, 찜을 처리한다. |
| 9 | 상품 상세 탭 | src/components/product/ProductDetailTabs.tsx |
Tabs | Medium | 상세 정보, 리뷰, 배송/교환 탭을 제공한다. |
| 10 | 추천 상품 목록 | src/components/product/RelatedProducts.tsx |
ProductCard | Medium | 같은 카테고리 상품을 표시한다. |
| 11 | 상품 상세 페이지 | src/app/products/[slug]/page.tsx |
1–10 전체 | High | 데이터 조회와 페이지 조합을 담당한다. |
| 12 | 로딩/오류/빈 상태 | src/app/products/[slug]/loading.tsx, error.tsx, not-found.tsx |
Skeleton, ErrorBanner | Medium | Next.js App Router 상태 파일을 사용한다. |
BREAKPOINTS {
mobile: 0px — 767px
tablet: 768px — 1023px
desktop: 1024px — 1279px
wide: 1280px — ∞
}
| 구간 | 컨테이너 | 좌우 패딩 | 기본 폰트 | 헤더 |
|---|---|---|---|---|
| mobile | max-w-none |
px-4 (16px) |
text-base (16px) |
메뉴 버튼 |
| tablet | max-w-[960px] |
px-6 (24px) |
text-base (16px) |
축약 헤더 |
| desktop | max-w-[1200px] |
px-8 (32px) |
text-base (16px) |
전체 헤더 |
| wide | max-w-[1280px] |
px-8 (32px) |
text-base (16px) |
전체 헤더 |
상품 상세 콘텐츠의 최대 너비는 max-w-[1280px] (1280px)로 제한한다.
TRANSITIONS {
default: duration-200 ease-out
slow: duration-300 ease-in-out
spring: duration-500 cubic-bezier(0.34, 1.56, 0.64, 1)
none: duration-0
}
모든 인터랙티브 요소는 별도 지정이 없으면 default를 사용한다.
전환 대상:
background-colorcolorborder-coloropacitytransformbox-shadowwidth, height, padding, margin에는 transition을 적용하지 않는다.
Z-INDEX {
base: 0
dropdown: 10
sticky: 20
overlay: 30
modal: 40
toast: 50
tooltip: 60
}
이 외의 z-index 값은 사용하지 않는다.
FOCUS {
style: ring-2 ring-offset-2 ring-brand-500
applies-to: button, link, input, select, checkbox, tab
visible: focus-visible only
}
CLAUDE.md에 정의된 브랜드 포커스 토큰이 있으면 해당 토큰을 사용한다.
| 용도 | 토큰 | Fallback |
|---|---|---|
| 페이지 배경 | bg-background |
#FFFFFF |
| 기본 텍스트 | text-foreground |
#171717 |
| 보조 텍스트 | text-muted-foreground |
#737373 |
| 테두리 | border-border |
#E5E5E5 |
| 주요 브랜드 색상 | brand-500 |
#6B4F3A |
| 비활성 배경 | bg-muted |
#F5F5F5 |
| 오류 | text-destructive |
#DC2626 |
| 성공 | text-success |
#16A34A |
Route: /products/[slug]
예시 URL: /products/linen-cup
Layout: ProductDetailLayout
렌더링 방식: Server Component 기본. 옵션 선택, 갤러리, 수량, 장바구니 액션이 필요한 컴포넌트만 Client Component로 분리한다.
Data requirements:
interface Product {
id: string
slug: string
brandName: string
name: string
shortDescription: string
price: number
originalPrice?: number
discountRate?: number
currency: 'KRW'
images: ProductImage[]
options: ProductOption[]
stock: number
rating: number
reviewCount: number
descriptionHtml: string
shipping: ShippingInfo
category: string
relatedProductIds: string[]
}
상품 데이터 조회 실패 시 notFound()를 호출한다.
PAGE STRUCTURE: 상품 상세 페이지
├── SiteHeader
├── Breadcrumb
├── ProductDetailLayout
│ ├── ProductGallery
│ │ ├── MainProductImage
│ │ └── ThumbnailList
│ └── ProductInfo
│ ├── BrandName
│ ├── ProductName
│ ├── RatingSummary
│ ├── PriceBlock
│ ├── ShortDescription
│ ├── ProductOptionSelector
│ ├── QuantitySelector
│ └── PurchaseActions
├── ProductDetailTabs
│ ├── DescriptionTab
│ ├── ReviewTab
│ └── ShippingTab
├── RelatedProducts
└── SiteFooter
LAYOUT {
container: max-w-[1280px] mx-auto px-4 md:px-6 lg:px-8
height: h-12 (48px)
display: flex
align-items: items-center
gap: gap-2 (8px)
font: text-sm (14px)
color: text-muted-foreground (#737373)
}
표시 순서:
홈 / 카테고리명 / 상품명
마지막 상품명은 text-foreground로 표시하며 링크로 만들지 않는다.
상품명이 32자를 초과하면 한 줄에서 truncate하고 title 속성에 전체 상품명을 넣는다.
LAYOUT {
container: max-w-[1280px] mx-auto px-4 md:px-6 lg:px-8
display: grid
columns: grid-cols-1 lg:grid-cols-2
gap: gap-8 md:gap-10 lg:gap-16
padding: pb-16 md:pb-20 lg:pb-24
}
데스크톱에서 이미지 영역과 정보 영역은 각각 min-w-0를 적용한다.
lg:col-span-1lg:col-span-1max-w-[520px]MAIN IMAGE {
aspect-ratio: aspect-square
width: w-full
background: bg-muted (#F5F5F5)
border-radius: rounded-lg (8px)
object-fit: object-cover
}
첫 번째 이미지를 초기 선택 상태로 사용한다.
이미지에는 다음 sizes를 사용한다.
sizes="(max-width: 1023px) 100vw, 50vw"
THUMBNAILS {
display: flex
gap: gap-2 (8px)
margin-top: mt-3 (12px)
overflow: overflow-x-auto
}
각 썸네일:
width: w-20 (80px) mobile
width: w-24 (96px) desktop
height: 동일 width
border-radius: rounded-md (6px)
object-fit: object-cover
선택된 썸네일:
border: border-2 border-brand-500
opacity: opacity-100
선택되지 않은 썸네일:
border: border border-border
opacity: opacity-60
hover: opacity-100
이미지 개수가 1개이면 썸네일 목록을 렌더링하지 않는다.
이미지 로딩 실패 시:
background: bg-gray-100 (#F3F4F6)
icon: ImageOff, 24px, text-gray-400
aria-label: "상품 이미지를 불러올 수 없습니다"
element: p
class: text-sm font-medium text-muted-foreground
margin-bottom: mb-2
element: h1
desktop: text-3xl font-semibold leading-tight
mobile: text-2xl font-semibold leading-tight
color: text-foreground
max-lines: 2
overflow: line-clamp-2
전체 상품명은 title 속성에 저장한다.
display: flex
align-items: items-center
gap: gap-2
margin-top: mt-4
font: text-sm
구성:
별점: ★ 5개
별점 숫자: 4.8
리뷰 링크: 리뷰 24개
별점은 0.5 단위로 표시한다. 리뷰가 0개이면 별점 대신 다음 문구를 표시한다.
"첫 리뷰를 남겨보세요"
margin-top: mt-6
할인 가격이 있는 경우:
original price:
text-sm line-through text-muted-foreground
discount rate:
text-lg font-semibold text-destructive
current price:
text-2xl font-bold text-foreground
할인 가격이 없는 경우 현재 가격만 표시한다.
가격 포맷:
new Intl.NumberFormat('ko-KR').format(price) + '원'
가격이 0이면 0원 대신 무료를 표시한다.
element: p
margin-top: mt-4
font: text-base leading-7
color: text-muted-foreground
white-space: pre-line
옵션이 존재하면 가격 블록 아래 mt-8에 표시한다.
옵션이 없는 상품은 옵션 선택기를 렌더링하지 않는다.
OPTION SELECTOR {
label: text-sm font-medium text-foreground
label-margin: mb-2
trigger: h-11 w-full rounded-md border border-border px-3
}
옵션 상태:
| 상태 | 스타일 |
|---|---|
| 기본 | border-border bg-background |
| hover | border-foreground |
| focus-visible | ring-2 ring-offset-2 ring-brand-500 |
| 선택 완료 | border-foreground |
| 품절 | text-muted-foreground line-through, 선택 불가 |
| 오류 | border-destructive, 오류 문구 표시 |
옵션 선택 없이 구매 버튼을 클릭하면:
error message: "옵션을 선택해 주세요."
placement: 옵션 선택기 바로 아래
color: text-destructive
font: text-sm
display: flex
align-items: center
width: w-fit
height: h-11 (44px)
border: border border-border
border-radius: rounded-md
margin-top: mt-6
구성:
감소 버튼: 44×44px
수량 표시: min-width 48px, text-center
증가 버튼: 44×44px
규칙:
11stockPURCHASE ACTIONS {
display: grid
columns: grid-cols-[auto_1fr_1fr]
gap: gap-2
margin-top: mt-6
}
모바일에서는 다음과 같이 변경한다.
columns: grid-cols-1
버튼 순서:
찜하기 버튼:
size: h-12 w-12
variant: outline
icon: Heart, 20px
aria-label: "찜하기"
장바구니 버튼:
height: h-12 (48px)
variant: secondary
text: "장바구니"
바로 구매 버튼:
height: h-12 (48px)
variant: primary
text: "바로 구매"
장바구니 성공 시:
toast message: "장바구니에 상품을 담았습니다."
toast duration: 3000ms
바로 구매 성공 시:
redirect: "/checkout"
ProductGalleryFile: src/components/product/ProductGallery.tsx
Purpose: 상품 이미지 목록을 표시하고 선택된 이미지를 변경한다.
interface ProductGalleryProps {
images: ProductImage[]
productName: string
}
상태:
initial selectedIndex: 0
image loading: skeleton matching aspect-square
image error: bg-gray-100 + ImageOff icon
키보드 동작:
Tab: 다음 썸네일로 이동Enter 또는 Space: 썸네일 선택aria-current="true"ProductOptionSelectorFile: src/components/product/ProductOptionSelector.tsx
interface ProductOptionSelectorProps {
options: ProductOption[]
value?: string
onChange: (optionId: string) => void
error?: string
disabled?: boolean
}
규칙:
disabled=true이면 전체 옵션 선택 불가aria-disabled="true"를 사용한다.aria-describedby로 연결한다.QuantitySelectorFile: src/components/product/QuantitySelector.tsx
interface QuantitySelectorProps {
value: number
min?: number
max: number
onChange: (value: number) => void
disabled?: boolean
}
기본값:
min: 1
max: product.stock
수량 변경은 정수만 허용한다. value < min이면 min, value > max이면 max로 보정한다.
PurchaseActionsFile: src/components/product/PurchaseActions.tsx
interface PurchaseActionsProps {
productId: string
quantity: number
optionId?: string
stock: number
isWishlisted: boolean
onWishlist: () => void
onAddToCart: () => Promise<void>
onBuyNow: () => Promise<void>
disabled?: boolean
}
default:
버튼 활성화
loading:
클릭한 액션 버튼 내부에 16px spinner
버튼 텍스트 유지
해당 액션 pointer-events-none
중복 요청 방지
success:
장바구니: toast 표시
바로 구매: /checkout 이동
error:
버튼 loading 해제
inline error banner 표시
문구: "처리 중 문제가 발생했습니다. 다시 시도해 주세요."
ProductDetailTabsFile: src/components/product/ProductDetailTabs.tsx
interface ProductDetailTabsProps {
descriptionHtml: string
reviews: Review[]
shipping: ShippingInfo
}
탭:
"상품 설명"
"리뷰"
"배송 및 교환"
레이아웃:
container: max-w-[1280px] mx-auto px-4 md:px-6 lg:px-8
border-top: border-t border-border
tab-list: flex overflow-x-auto
tab-height: h-14 (56px)
활성 탭:
border-bottom: border-2 border-foreground
font: font-semibold
color: text-foreground
비활성 탭:
color: text-muted-foreground
탭 패널은 활성 탭 하나만 렌더링한다.
TRIGGER:
사용자가 "장바구니" 버튼 클릭
STEP 1:
옵션이 존재하고 선택되지 않았으면
옵션 선택기 아래에 "옵션을 선택해 주세요." 표시
포커스를 첫 번째 미선택 옵션으로 이동
요청하지 않음
STEP 2:
옵션이 선택되었으면
선택한 버튼을 loading 상태로 변경
중복 클릭 차단
POST /api/cart/items 요청
STEP 3a:
요청 성공
버튼 loading 해제
"장바구니에 상품을 담았습니다." toast 표시
toast duration: 3000ms
STEP 3b:
재고 부족
버튼 loading 해제
오류 문구: "현재 재고보다 많은 수량을 선택했습니다."
수량을 현재 재고로 보정
STEP 3c:
네트워크 오류
버튼 loading 해제
오류 문구: "네트워크 연결을 확인한 후 다시 시도해 주세요."
"다시 시도" 액션 표시
TRIGGER:
사용자가 "바로 구매" 버튼 클릭
IF:
필수 옵션 미선택
→ 옵션 오류 표시
→ 구매 요청 중단
IF:
재고가 0
→ "품절된 상품입니다." 표시
→ 버튼 disabled
ELSE:
버튼 loading
POST /api/checkout/prepare
성공 시 /checkout으로 이동
실패 시 오류 배너 표시
TRIGGER:
사용자가 썸네일 클릭
ACTION:
selectedIndex 변경
메인 이미지 opacity 0→1
transition: duration-200 ease-out
URL 변경 없음
페이지 스크롤 위치 유지
IF:
로그인 상태
→ POST /api/wishlist
→ Heart fill 상태로 변경
IF:
비로그인 상태
→ /login?returnUrl=/products/[slug]로 이동
요청 실패:
→ "찜하기를 완료하지 못했습니다." 오류 표시
→ 기존 상태 유지
≥1024px:
grid-cols-2
image/info gap: gap-16
이미지와 정보가 같은 행에 배치
768–1023px:
grid-cols-1
이미지가 위, 정보가 아래
gap: gap-10
<768px:
grid-cols-1
gap: gap-8
container padding: px-4
≥1024px:
메인 이미지 aspect-square
썸네일 96×96px
<1024px:
메인 이미지 aspect-square
썸네일 80×80px
썸네일 목록 가로 스크롤 허용
≥768px:
찜하기, 장바구니, 바로 구매를 한 줄에 표시
<768px:
세 버튼을 세로로 표시
각 버튼 최소 높이 48px
모든 구간:
탭 목록 가로 스크롤 가능
탭 라벨 줄바꿈 금지
white-space: nowrap
<1024px:
모든 버튼과 썸네일의 실제 hit area 최소 44×44px
시각적 아이콘이 20px이어도 버튼 영역은 44px 유지
상품 메인 이미지:
sizes="(max-width: 1023px) 100vw, 50vw"
추천 상품 이미지:
sizes="(max-width: 767px) 50vw, (max-width: 1023px) 33vw, 25vw"
상품명:
최대 2줄
line-clamp-2
전체 텍스트는 title 속성에 저장
브랜드명:
최대 1줄
truncate
옵션명:
최대 1줄
truncate
title 속성에 전체 텍스트 저장
가격:
999,999,999원까지 한 줄 유지
숫자 영역에 whitespace-nowrap 적용
상품 설명:
자연 줄바꿈 허용
truncate하지 않음
리뷰 0개:
heading: "아직 리뷰가 없습니다"
body: "이 상품을 처음 구매한 고객이 되어 리뷰를 남겨보세요."
CTA: 상품 구매 후 리뷰 작성
관련 상품 0개:
관련 상품 섹션을 렌더링하지 않음
상품 이미지 0개:
bg-gray-100
ImageOff 아이콘 24px
aria-label: "상품 이미지가 없습니다"
페이지 로딩:
ProductDetailSkeleton 표시
메인 이미지: aspect-square skeleton
상품명: h-8 w-3/4 skeleton
가격: h-8 w-1/3 skeleton
옵션: h-11 w-full skeleton
액션 버튼: h-12 w-full skeleton
상품 이미지:
bg-gray-200 rounded-lg animate-pulse
버튼 요청:
버튼 내부 16px animate-spin spinner
버튼 폭 유지
상품 없음:
not-found.tsx 렌더링
heading: "상품을 찾을 수 없습니다"
CTA: "상품 목록으로 돌아가기"
action: /products
API 오류:
inline error banner
message: "상품 정보를 불러오지 못했습니다."
action: "다시 시도"
네트워크 오류:
message: "네트워크 연결을 확인해 주세요."
auto-dismiss: 연결 복구 시
재고 0:
price 아래에 "품절" 표시
수량 선택기 disabled
장바구니/바로 구매 disabled
상품명 1자:
그대로 표시
상품명 100자:
h1 최대 2줄로 표시하고 나머지 truncate
가격 0:
"무료" 표시
가격 999,999,999:
줄바꿈 금지
컨테이너 overflow 허용하지 않음
옵션 1개:
선택기를 표시하고 자동 선택하지 않음
옵션 20개:
옵션 목록 최대 높이 320px
overflow-y-auto
리뷰 0개:
빈 리뷰 상태 표시
리뷰 500개:
초기 10개 표시
"리뷰 더보기" 버튼으로 10개씩 추가
재고 0:
구매 액션 전체 disabled
재고 999:
수량 증가 버튼은 재고 999에서 disabled
상품 이미지:
alt="${productName} 상품 이미지"
장식용 이미지:
alt=""
썸네일:
button 요소 사용
aria-label="${productName} 이미지 ${index} 보기"
탭:
role="tablist", role="tab", role="tabpanel"
aria-selected 사용
옵션 오류:
aria-invalid="true"
aria-describedby 연결
모든 아이콘 전용 버튼:
aria-label 필수
□ /products/[slug] 라우트가 정상 렌더링된다
□ 상품 데이터가 없으면 not-found.tsx가 표시된다
□ 모바일에서 상품 이미지가 정보 영역 위에 배치된다
□ 데스크톱에서 이미지와 정보가 2열로 배치된다
□ 메인 이미지와 썸네일 선택 상태가 일치한다
□ 이미지 로딩 실패 시 ImageOff placeholder가 표시된다
□ 상품명이 2줄을 초과하지 않는다
□ 가격이 한국 원화 형식으로 표시된다
□ 가격 0원은 "무료"로 표시된다
□ 할인 전 가격, 할인율, 현재 가격이 올바르게 표시된다
□ 옵션이 없을 때 옵션 선택기가 렌더링되지 않는다
□ 옵션 미선택 상태에서 구매할 수 없다
□ 품절 옵션을 선택할 수 없다
□ 수량 최소값은 1이다
□ 수량 최대값은 재고 수량이다
□ 재고 0이면 구매 관련 버튼이 disabled다
□ 장바구니 요청 중 버튼이 loading 상태다
□ 중복 장바구니 요청이 발생하지 않는다
□ 장바구니 성공 toast가 3초 표시된다
□ 바로 구매 성공 시 `/checkout`으로 이동한다
□ 비로그인 찜하기 시 return URL이 유지된다
□ 리뷰 0개 상태가 정상 표시된다
□ 관련 상품 0개일 때 섹션이 숨겨진다
□ 상세 탭 키보드 탐색이 가능하다
□ 모든 focus-visible 링이 표시된다
□ 터치 대상이 44×44px 이상이다
□ 767px, 768px, 1023px, 1024px, 1279px, 1280px에서 레이아웃을 확인했다
□ 가로 스크롤이 의도된 썸네일/탭 영역 외에는 발생하지 않는다
□ 모든 이미지를 `next/image`로 렌더링한다
□ 메인 이미지 `sizes`가 지정되어 있다
□ 추천 상품 이미지 `sizes`가 지정되어 있다
□ z-index가 정의된 scale 외의 값을 사용하지 않는다
□ 로딩 skeleton이 최종 콘텐츠 크기와 일치한다
□ 네트워크 오류와 API 오류가 구분되어 표시된다
□ 상품명, 옵션명, 가격의 경계값을 테스트했다
□ `prefers-reduced-motion`에서 애니메이션이 제거된다
실제 Figma 스크린샷에서 확인되지 않은 텍스트·상품 콘텐츠는 CMS/API 데이터로 주입한다. 시각적 토큰은 CLAUDE.md를 최우선으로 적용한다.
| Design Brief | |
| Work out what a change request actually costs | |
| Read the decisions behind a competitor's screen | |
| Decode vague design feedback | |
| Write a developer handoff spec |