Compare commits

..

24 Commits

Author SHA1 Message Date
gahusb
65f0a6bb41 Merge PR #2: 라이트 고craft 재설계 (홈·외주·제품 3면)
Deep Field 다크 → 라이트 단일 시스템 재설계. 검증 통과(test 20/20, build 86/86).
2026-06-30 16:15:35 +09:00
7e1105f574 fix(redesign): ScrollReveal reduced-motion 시 transition까지 생략(정적 표시)
기존엔 스크롤 스태거만 건너뛰고 700ms 전환은 남았음 → instant 분기로 완전 정지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:53:23 +09:00
f4fd0f60c9 chore(redesign): 재설계가 죽인 다크/스크롤큐 CSS 제거 + 연결선 gradient 제거
globals.css: --jsm-dark-* 토큰, --jsm-accent-bright, .jsm-dark-form,
.df-scroll-dot/@keyframes df-scroll-cue 제거 (전부 소비처 0).
홈 PROCESS 연결선 linear-gradient → solid 인셋 라인.

유지: --kx-*/.kx-*(/, packages·work·music 사용), .gradient-text(/portfolio/[token] 사용)
— 숨김·레거시 라우트라 이번 범위 밖.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:52:22 +09:00
37465701af feat(redesign): 제품 페이지 craft 정렬(공통 언어)
max-w-5xl→6xl, 타입 스케일·여백 리듬·카드 스펙(rounded-2xl/hover)을
홈·외주와 통일. surface↔surface-alt 교차 4섹션.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:49:10 +09:00
c3be57ea1f feat(redesign): 외주 페이지 + 의뢰폼 라이트 전환
페이지: 다크 캔버스/HeroField/스크림 제거, surface↔surface-alt 교차 8섹션.
HERO 비대칭 2단(우 FeedMock 목업). 앵커(#showcase/#portfolio/#process/#contact) 유지.
폼: --jsm-dark-* 전량 라이트 치환, jsm-dark-form 제거. 흰 카드 위 surface-alt 입력으로 가독성 확보.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:48:00 +09:00
897e37f14e feat(redesign): 홈 라이트 재구성 + 2축 복원 + 히어로 제품 목업
다크 캔버스/HeroField/스크림 폐기. surface↔surface-alt 교차 7섹션.
히어로 비대칭 2단(좌 텍스트 / 우 MockWindow=DashboardMock).
누락됐던 "2축 소개"(외주/완성SW) 섹션 복원. CTA 평면 navy(radial 제거).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:44:15 +09:00
7c6238508b feat(redesign): TopNav 다크 라우트 분기 제거 → 단일 라이트 네비
DARK_ROUTES/isDark 및 다크 팔레트 삼항 전량 제거.
전 라우트 동일 라이트 셸 (스크롤 시 surface+line+shadow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:41:40 +09:00
989cc25465 feat(redesign): 쇼케이스 그래디언트 타일 → 라이트 MockWindow 카드
lib/showcase.ts를 mock 키 기반으로 교체(보라 4슬롯 제거, 목업 6종 다양화).
ShowcaseCard 캔버스/시드/그래디언트 제거 → surface-alt 스테이지 + 흰 MockWindow.
키 목록을 JSX-free keys.ts로 분리해 vitest 가드레일 테스트 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:40:56 +09:00
c1afb58bcd feat(redesign): MockWindow 라이트 목업 시스템(프레임+6스크린+레지스트리)
파티클 대체 craft 핵심. 실데이터 0, --jsm-* 라이트 토큰만.
dashboard/feed/match/commerce/site/booking 6종 + 레지스트리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:38:07 +09:00
b2bd7b1b31 docs(redesign): 라이트 재설계 구현 계획 (7 Task)
MockWindow 목업 시스템 → 쇼케이스 전환 → TopNav 단일화 →
홈/외주/제품 3면 라이트 재작성 → 죽은 CSS 제거·검증.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:35:40 +09:00
e5b907dc38 docs(redesign): 라이트 고craft 재설계 설계 문서 확정
홈·외주·제품 3면을 라이트 --jsm-* 단일 시스템으로 통일.
Deep Field 다크/파티클 폐기, 히어로에 코드 UI 목업(MockWindow) 도입,
가짜 그래디언트 쇼케이스 → 실화면 느낌 목업 그리드, 죽은 CSS 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:32:18 +09:00
d10fe981f0 fix(deepfield): 히어로 텍스트 대비 복구 — 좌측 앵커 스크림 + 파티클 블룸 완화
세로 중앙이 투명한 스크림 위에 헤드라인이 놓여(items-center) 글자 뒤
받침이 없었고, AdditiveBlending 파티클 3000개가 텍스트 뒤를 밝게 씻어내
흰 글씨가 안 보이던 문제 수정.

- page.tsx: 스크림을 좌측 앵커 다크(좌→우 0.94→0) + 상하 비네트 2겹으로 교체
- HeroField: StaticField radial 광원 밝기 완화(0.45→0.30, 0.16→0.10) + 우측 이동
- HeroField: 파티클 수 3000→1600(lite 800→500), 셰이더 알파 0.45+0.25→0.28+0.18

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQNcycdLJVtoSKN1tHZU6Q
2026-06-26 18:21:39 +09:00
b705f35c2d feat(outsourcing): Deep Field 재스킨 + 쇼케이스 풀 그리드 + 운영 실증 카피
- 라이트 → 다크 캔버스 전환 (메인과 동일 비주얼 언어: 다크 루트 div + -mt-16 hero + border-t 섹션 리듬 + 모노 라벨 헤더)
- Hero 축약 ~60vh + HeroField 배경
- #showcase 섹션 ShowcaseGrid variant="full"(8슬롯), #portfolio 하위호환 앵커 유지
- 구 SAMPLES(/work/website/samples) 노출 링크 제거 — 쇼케이스가 대체
- 운영 실사례/제공분야/프로세스/FAQ 다크 카드 + ScrollReveal 스태거
- OutsourcingRequestForm 다크 스킨(스타일 값만, 로직 diff 0) + placeholder dark-soft
- "7년차"·"대기업" 잔존 카피 전부 운영 실증 톤으로 교체 (metadata 포함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 01:10:54 +09:00
4cd4a50869 feat(home): Deep Field 다크 캔버스 재조립 + 운영 실증 카피
- HERO/SHOWCASE/PROCESS/PROOF/SOFTWARE+CTA 5섹션 다크(--jsm-dark-bg) 재구성
- HeroField WebGL 배경 + -mt-16/pt-16로 상단 라이트 띠 제거 (PublicShell 무수정)
- "생각을 동작하는 소프트웨어로." 거대 타이포(clamp, -0.04em)
- 경력·소속 표현 전면 제거 → "24시간 돌아가는 실서비스 직접 설계·운영" 신뢰 축
- CountUp 카운트업 스탯 + 스크롤 큐 keyframes(motion-safe 가드)
- layout metadata·jsonLd 카피 동일 톤 교체 (jobTitle "소프트웨어 엔지니어")

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:57:38 +09:00
01c31e3e5d feat(nav): 다크 라우트 인지형 네비게이션 2026-06-13 00:40:20 +09:00
e22622d36d fix(deepfield): home 그리드 지그재그 3-wide 배치(빈 칸 제거) + 데드 CSS 정리
- ShowcaseGrid: index 0·3·4 → feature/col-span-2, 1·2·5 → standard
  wide 3장+standard 3장 = 9셀(3×3) 완전 충전, Row 2 col3 빈 칸 제거
- ShowcaseCard: ring-1(인라인 boxShadow에 덮이는 데드 클래스) 제거
  transition-[...]에서 미사용 border-color 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 00:36:37 +09:00
186ae546f2 feat(deepfield): 쇼케이스 카드·그리드 (시드 제너러티브 타일 + 호버 시차)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:29:12 +09:00
eb1ecf0021 feat(deepfield): HeroField WebGL 파티클 필드 (full/lite/static + 커서 자기장) 2026-06-13 00:19:16 +09:00
4b85c52cfe refactor(deepfield): ScrollReveal variant별 복원 클래스 명시 2026-06-13 00:14:27 +09:00
4223004c24 feat(deepfield): ScrollReveal 스크롤 연출 컴포넌트 2026-06-13 00:06:09 +09:00
bd13641f5e feat(deepfield): 렌더 모드 판정(TDD) + useFieldMode 훅 2026-06-13 00:05:17 +09:00
5cfa124d38 feat(deepfield): three.js + 다크 토큰 + 쇼케이스 8슬롯 데이터 2026-06-13 00:03:43 +09:00
64259a85b5 docs(plan): Deep Field 랜딩 구현 계획 — WebGL 히어로·쇼케이스·다크 재조립 9태스크
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:47:58 +09:00
70068ff3d7 docs(spec): Deep Field 랜딩 경험 — 다크 캔버스 + WebGL 쇼케이스 설계
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:42:55 +09:00
27 changed files with 2783 additions and 989 deletions

View File

@@ -16,7 +16,7 @@ const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
const INPUT_STYLE = {
background: 'var(--jsm-surface)',
background: 'var(--jsm-surface-alt)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
} as const;
@@ -218,7 +218,7 @@ export default function OutsourcingRequestForm() {
</Link>
<p
className="mt-3 text-xs leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
.
</p>
@@ -244,7 +244,7 @@ export default function OutsourcingRequestForm() {
className="flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold shrink-0 transition-colors"
style={
state === 'upcoming'
? { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-faint)' }
? { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)', boxShadow: 'inset 0 0 0 1px var(--jsm-line)' }
: { background: 'var(--jsm-accent)', color: '#ffffff' }
}
aria-current={state === 'current' ? 'step' : undefined}
@@ -255,7 +255,7 @@ export default function OutsourcingRequestForm() {
className="text-xs font-semibold truncate hidden sm:inline"
style={{
color:
state === 'upcoming' ? 'var(--jsm-ink-faint)' : 'var(--jsm-ink)',
state === 'upcoming' ? 'var(--jsm-ink-soft)' : 'var(--jsm-ink)',
...KOR_BODY,
}}
>
@@ -307,7 +307,7 @@ export default function OutsourcingRequestForm() {
: '1px solid var(--jsm-line)',
background: selected
? 'var(--jsm-accent-soft)'
: 'var(--jsm-surface)',
: 'var(--jsm-surface-alt)',
color: selected ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
...KOR_BODY,
}}
@@ -413,7 +413,7 @@ export default function OutsourcingRequestForm() {
/>
<p
className="mt-1.5 text-xs"
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{trimmedMessage.length}/10
</p>
@@ -548,7 +548,7 @@ export default function OutsourcingRequestForm() {
className="flex-1 py-3 rounded-lg text-sm font-semibold text-white transition-colors"
style={{
background: !canAdvance || submitting
? 'var(--jsm-ink-faint)'
? 'var(--jsm-line)'
: 'var(--jsm-accent)',
cursor: !canAdvance || submitting ? 'not-allowed' : 'pointer',
...KOR_BODY,
@@ -563,7 +563,7 @@ export default function OutsourcingRequestForm() {
disabled={!canAdvance}
className="flex-1 inline-flex items-center justify-center gap-2 py-3 rounded-lg text-sm font-semibold text-white transition-colors"
style={{
background: canAdvance ? 'var(--jsm-accent)' : 'var(--jsm-ink-faint)',
background: canAdvance ? 'var(--jsm-accent)' : 'var(--jsm-line)',
cursor: canAdvance ? 'pointer' : 'not-allowed',
...KOR_BODY,
}}
@@ -596,7 +596,7 @@ function Chip({
className="px-4 py-2.5 rounded-lg text-sm font-semibold break-keep transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{
border: selected ? '1px solid var(--jsm-accent)' : '1px solid var(--jsm-line)',
background: selected ? 'var(--jsm-accent-soft)' : 'var(--jsm-surface)',
background: selected ? 'var(--jsm-accent-soft)' : 'var(--jsm-surface-alt)',
color: selected ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
...KOR_BODY,
}}

View File

@@ -65,6 +65,14 @@ export default function TopNav() {
return () => window.removeEventListener('keydown', onKey);
}, [open]);
// 단일 라이트 팔레트 (전 라우트 동일 — 라우트 분기 제거)
const ink = 'var(--jsm-ink)';
const inkSoft = 'var(--jsm-ink-soft)';
const surface = 'var(--jsm-surface)';
const line = 'var(--jsm-line)';
const accent = 'var(--jsm-accent)';
const accentBg = 'var(--jsm-accent-soft)';
const isActive = (href: string) => {
if (href === '/') return pathname === '/';
return pathname === href || pathname.startsWith(href + '/');
@@ -76,7 +84,9 @@ export default function TopNav() {
className="fixed top-0 left-0 right-0 z-50 w-full transition-all duration-300"
style={{
background: scrolled ? 'var(--jsm-surface)' : 'transparent',
borderBottom: scrolled ? '1px solid var(--jsm-line)' : '1px solid transparent',
borderBottom: scrolled
? `1px solid ${line}`
: '1px solid transparent',
boxShadow: scrolled ? '0 1px 8px rgba(15,23,42,0.06)' : 'none',
}}
>
@@ -89,13 +99,13 @@ export default function TopNav() {
>
<span
className="text-xl font-black tracking-tight"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
style={{ color: ink, letterSpacing: '-0.02em' }}
>
JSM
</span>
<span
className="hidden sm:inline text-sm font-medium"
style={{ color: 'var(--jsm-ink-soft)', letterSpacing: '-0.01em' }}
style={{ color: inkSoft, letterSpacing: '-0.01em' }}
>
</span>
@@ -109,8 +119,8 @@ export default function TopNav() {
href={l.href}
className="text-sm font-medium px-4 py-2 rounded-md transition-colors duration-150"
style={{
color: isActive(l.href) ? 'var(--jsm-accent)' : 'var(--jsm-ink-soft)',
background: isActive(l.href) ? 'var(--jsm-accent-soft)' : 'transparent',
color: isActive(l.href) ? accent : inkSoft,
background: isActive(l.href) ? accentBg : 'transparent',
textDecoration: 'none',
letterSpacing: '-0.01em',
}}
@@ -127,14 +137,14 @@ export default function TopNav() {
<Link
href="/mypage"
className="hidden sm:inline-block text-sm font-medium px-3 py-2 rounded-md transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)', textDecoration: 'none', letterSpacing: '-0.01em' }}
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
>
</Link>
<button
onClick={handleLogout}
className="hidden sm:inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)', background: 'transparent', letterSpacing: '-0.01em' }}
style={{ color: inkSoft, background: 'transparent', letterSpacing: '-0.01em' }}
>
</button>
@@ -143,7 +153,7 @@ export default function TopNav() {
<Link
href="/login"
className="hidden sm:inline-block text-sm font-medium px-3 py-2 rounded-md transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)', textDecoration: 'none', letterSpacing: '-0.01em' }}
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
>
</Link>
@@ -167,7 +177,7 @@ export default function TopNav() {
aria-label="메뉴 열기"
aria-expanded={open}
className="md:hidden p-2 rounded-lg transition-colors duration-150"
style={{ color: 'var(--jsm-ink)' }}
style={{ color: ink }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
@@ -186,7 +196,7 @@ export default function TopNav() {
>
<div
className="absolute top-0 right-0 h-full w-72 flex flex-col shadow-xl"
style={{ background: 'var(--jsm-surface)' }}
style={{ background: surface }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
@@ -195,18 +205,18 @@ export default function TopNav() {
{/* 드로어 헤더 */}
<div
className="flex items-center justify-between px-6 h-16 border-b"
style={{ borderColor: 'var(--jsm-line)' }}
style={{ borderColor: line }}
>
<div className="flex items-baseline gap-2">
<span
className="text-lg font-black tracking-tight"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
style={{ color: ink, letterSpacing: '-0.02em' }}
>
JSM
</span>
<span
className="text-xs font-medium"
style={{ color: 'var(--jsm-ink-soft)' }}
style={{ color: inkSoft }}
>
</span>
@@ -215,7 +225,7 @@ export default function TopNav() {
onClick={() => setOpen(false)}
aria-label="메뉴 닫기"
className="p-2 rounded-lg transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)' }}
style={{ color: inkSoft }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -231,8 +241,8 @@ export default function TopNav() {
href={l.href}
className="text-base font-semibold px-3 py-3 rounded-lg transition-colors duration-150"
style={{
color: isActive(l.href) ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
background: isActive(l.href) ? 'var(--jsm-accent-soft)' : 'transparent',
color: isActive(l.href) ? accent : ink,
background: isActive(l.href) ? accentBg : 'transparent',
textDecoration: 'none',
letterSpacing: '-0.01em',
}}
@@ -243,7 +253,7 @@ export default function TopNav() {
<div
className="my-4 border-t"
style={{ borderColor: 'var(--jsm-line)' }}
style={{ borderColor: line }}
/>
{user ? (
@@ -251,14 +261,14 @@ export default function TopNav() {
<Link
href="/mypage"
className="text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)', textDecoration: 'none', letterSpacing: '-0.01em' }}
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
>
</Link>
<button
onClick={handleLogout}
className="text-left text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)', background: 'transparent', letterSpacing: '-0.01em' }}
style={{ color: inkSoft, background: 'transparent', letterSpacing: '-0.01em' }}
>
</button>
@@ -267,7 +277,7 @@ export default function TopNav() {
<Link
href="/login"
className="text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)', textDecoration: 'none', letterSpacing: '-0.01em' }}
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
>
</Link>

View File

@@ -0,0 +1,76 @@
'use client';
import { useEffect, useRef, useState } from 'react';
interface Props {
/** 카운트업 목표 숫자 */
to: number;
/** 숫자 앞에 붙는 고정 텍스트 (예: 없음) */
prefix?: string;
/** 숫자 뒤에 붙는 고정 텍스트 (예: '+') */
suffix?: string;
/** 애니메이션 길이(ms) — 기본 600 */
duration?: number;
className?: string;
}
/**
* IntersectionObserver 진입 시 0 → to 로 카운트업.
* prefers-reduced-motion이면 즉시 최종값 표시(연출 생략).
* transform/opacity가 아닌 textContent 변경이라 레이아웃 안정 위해 tabular-nums 권장.
*/
export default function CountUp({ to, prefix = '', suffix = '', duration = 600, className }: Props) {
const ref = useRef<HTMLSpanElement>(null);
const [value, setValue] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
let rafId = 0;
let started = false;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const run = () => {
// reduced-motion: 즉시 최종값 (연출 생략)
if (reduced) {
setValue(to);
return;
}
const start = performance.now();
const tick = (now: number) => {
const t = Math.min((now - start) / duration, 1);
// easeOutCubic — 끝에서 부드럽게 안착
const eased = 1 - Math.pow(1 - t, 3);
setValue(Math.round(eased * to));
if (t < 1) rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !started) {
started = true;
run();
io.disconnect();
}
},
{ threshold: 0.4 },
);
io.observe(el);
return () => {
io.disconnect();
if (rafId) cancelAnimationFrame(rafId);
};
}, [to, duration]);
return (
<span ref={ref} className={className} style={{ fontVariantNumeric: 'tabular-nums' }}>
{prefix}
{value.toLocaleString('ko-KR')}
{suffix}
</span>
);
}

View File

@@ -0,0 +1,330 @@
'use client';
import { useEffect, useRef, useState } from 'react';
// 타입만 정적 import — 번들에 코드가 들어가지 않음 (import type)
import type * as THREE from 'three';
import { useFieldMode } from './useFieldMode';
interface Props {
className?: string;
}
/**
* 정적 2광원 radial 그래디언트.
* static 모드 단독 비주얼이자, full/lite에서 캔버스 아래에 항상 깔리는 베이스.
* (WebGL 로딩 전/실패 시에도 비주얼 공백 없음)
*/
function StaticField() {
return (
<div
aria-hidden="true"
className="absolute inset-0"
style={{
backgroundColor: 'var(--jsm-dark-bg, #070d1a)',
backgroundImage: [
// 광원1: 우상단 — accent blue-700 (텍스트 컬럼에서 떨어진 우측에 배치, 밝기 완화)
'radial-gradient(58% 52% at 78% 22%, rgba(29,78,216,0.30) 0%, transparent 46%)',
// 광원2: 우하단 — bright blue (sky-400), 좌측 텍스트와 겹치지 않게 우측 이동 + 밝기 완화
'radial-gradient(52% 48% at 82% 88%, rgba(56,189,248,0.10) 0%, transparent 42%)',
].join(','),
}}
/>
);
}
// ───────────────────────── 셰이더 ─────────────────────────
const VERTEX_SHADER = /* glsl */ `
uniform float uTime;
uniform vec2 uMouse; // NDC (-1..1), lite/static에선 사실상 미사용
uniform float uMouseAmp; // 커서 자기장 세기 (lite=0)
uniform float uScroll; // 0..1, 진행될수록 흩어짐
uniform float uPixelRatio;
attribute float aScale; // 파티클별 기본 크기 (1.5~3px)
attribute float aSeed; // 드리프트 위상 분산
varying float vAlpha;
varying vec3 vColor;
// 색: #60a5fa(밝은) ↔ #1d4ed8(딥) 보간
const vec3 C_BRIGHT = vec3(0.376, 0.647, 0.980); // #60a5fa
const vec3 C_DEEP = vec3(0.114, 0.306, 0.847); // #1d4ed8
void main() {
vec3 pos = position;
// 미세 유영 — 사인 노이즈 (드리프트)
float t = uTime * 0.18 + aSeed * 6.2831853;
pos.x += sin(t) * 0.06;
pos.y += cos(t * 0.9) * 0.06;
pos.z += sin(t * 0.7) * 0.04;
// 스크롤 — 진행될수록 바깥으로 밀려 흩어짐
pos.xy += normalize(pos.xy + 0.0001) * uScroll * 0.9;
// 커서 자기장 — 화면 평면 기준 거리로 부드럽게 밀어냄
if (uMouseAmp > 0.0) {
vec2 toP = pos.xy - uMouse * 1.6;
float d = length(toP);
float radius = 0.7;
float push = smoothstep(radius, 0.0, d); // 가까울수록 1
pos.xy += normalize(toP + 0.0001) * push * 0.35 * uMouseAmp;
pos.z += push * 0.2 * uMouseAmp;
}
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
gl_Position = projectionMatrix * mvPosition;
// 크기: 원근(-mvPosition.z) 반영 + DPR
gl_PointSize = aScale * uPixelRatio * (300.0 / -mvPosition.z);
// 색: seed로 두 파랑 사이 보간
vColor = mix(C_DEEP, C_BRIGHT, aSeed);
// 불투명도: 스크롤로 소멸, 깊이로 약간 페이드 (전체 톤 다운 — 텍스트 가독성 우선)
float depthFade = smoothstep(-3.0, 0.5, mvPosition.z);
vAlpha = (1.0 - uScroll) * (0.28 + depthFade * 0.18);
}
`;
const FRAGMENT_SHADER = /* glsl */ `
precision mediump float;
varying float vAlpha;
varying vec3 vColor;
void main() {
// 원형 소프트 포인트 — 가장자리 부드럽게
vec2 c = gl_PointCoord - vec2(0.5);
float dist = length(c);
if (dist > 0.5) discard;
float soft = smoothstep(0.5, 0.05, dist);
gl_FragColor = vec4(vColor, soft * vAlpha);
}
`;
// ───────────────────────── 컴포넌트 ─────────────────────────
export default function HeroField({ className }: Props) {
const mode = useFieldMode();
// WebGL 초기화 실패 시 static으로 강등
const [failed, setFailed] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const effectiveMode = failed ? 'static' : mode;
const animated = effectiveMode === 'full' || effectiveMode === 'lite';
useEffect(() => {
if (!animated) return;
const canvas = canvasRef.current;
if (!canvas) return;
const isFull = effectiveMode === 'full';
// 밀도 완화 — 가산 혼합 파티클이 겹쳐 텍스트 뒤를 밝게 씻어내는(화이트 블룸) 현상 억제
const COUNT = isFull ? 1600 : 500;
let disposed = false;
let rafId = 0;
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
let geometry: THREE.BufferGeometry | null = null;
let material: THREE.ShaderMaterial | null = null;
let points: THREE.Points | null = null;
let io: IntersectionObserver | null = null;
// 가시성/뷰포트 상태 — 둘 다 OK일 때만 rAF 돌림
let pageVisible = document.visibilityState !== 'hidden';
let inView = true;
// 마우스 스무딩 (NDC)
const mouse = { x: 0, y: 0 };
const mouseTarget = { x: 0, y: 0 };
// 핸들러 참조 (cleanup용)
let onMouseMove: ((e: MouseEvent) => void) | null = null;
let onResize: (() => void) | null = null;
let onVisibility: (() => void) | null = null;
const start = () => {
if (rafId || disposed) return;
if (!pageVisible || !inView) return;
rafId = requestAnimationFrame(loop);
};
const stop = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = 0;
};
let loop: (now: number) => void = () => {};
(async () => {
let THREE_NS: typeof THREE;
try {
// three는 dynamic import만 — 메인 번들 분리
THREE_NS = await import('three');
} catch {
if (!disposed) setFailed(true);
return;
}
if (disposed || !canvasRef.current) return;
try {
const width = canvas.clientWidth || window.innerWidth;
const height = canvas.clientHeight || window.innerHeight;
renderer = new THREE_NS.WebGLRenderer({
canvas,
alpha: true, // 섹션 bg가 비치도록 투명
antialias: false,
powerPreference: 'low-power',
});
// lite는 DPR 1 고정, full은 최대 2로 제한
const dpr = isFull ? Math.min(window.devicePixelRatio || 1, 2) : 1;
renderer.setPixelRatio(dpr);
renderer.setSize(width, height, false);
renderer.setClearColor(0x000000, 0); // 완전 투명
scene = new THREE_NS.Scene();
camera = new THREE_NS.PerspectiveCamera(60, width / height, 0.1, 100);
camera.position.z = 3;
// 얕은 3D 슬랩: 화면을 덮는 균일 분포 + 약간의 노이즈, z 약간 분산
const positions = new Float32Array(COUNT * 3);
const scales = new Float32Array(COUNT);
const seeds = new Float32Array(COUNT);
const SPREAD_X = 5.0;
const SPREAD_Y = 3.2;
for (let i = 0; i < COUNT; i++) {
positions[i * 3 + 0] = (Math.random() - 0.5) * SPREAD_X;
positions[i * 3 + 1] = (Math.random() - 0.5) * SPREAD_Y;
positions[i * 3 + 2] = (Math.random() - 0.5) * 1.2; // 얕은 z 분산
scales[i] = 1.5 + Math.random() * 1.5; // 1.5~3px
seeds[i] = Math.random();
}
geometry = new THREE_NS.BufferGeometry();
geometry.setAttribute('position', new THREE_NS.BufferAttribute(positions, 3));
geometry.setAttribute('aScale', new THREE_NS.BufferAttribute(scales, 1));
geometry.setAttribute('aSeed', new THREE_NS.BufferAttribute(seeds, 1));
material = new THREE_NS.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uMouse: { value: new THREE_NS.Vector2(0, 0) },
uMouseAmp: { value: isFull ? 1 : 0 }, // lite는 커서 반응 off
uScroll: { value: 0 },
uPixelRatio: { value: dpr },
},
vertexShader: VERTEX_SHADER,
fragmentShader: FRAGMENT_SHADER,
transparent: true,
depthWrite: false,
depthTest: false,
blending: THREE_NS.AdditiveBlending, // 미세한 글로우
});
points = new THREE_NS.Points(geometry, material);
scene.add(points);
// ── 핸들러 ──
if (isFull) {
onMouseMove = (e: MouseEvent) => {
mouseTarget.x = (e.clientX / window.innerWidth) * 2 - 1;
mouseTarget.y = -((e.clientY / window.innerHeight) * 2 - 1);
};
window.addEventListener('mousemove', onMouseMove, { passive: true });
}
onResize = () => {
if (!renderer || !camera || !canvasRef.current) return;
const w = canvasRef.current.clientWidth || window.innerWidth;
const h = canvasRef.current.clientHeight || window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
};
window.addEventListener('resize', onResize, { passive: true });
onVisibility = () => {
pageVisible = document.visibilityState !== 'hidden';
if (pageVisible) start();
else stop();
};
document.addEventListener('visibilitychange', onVisibility);
// 뷰포트 밖이면 rAF 정지
io = new IntersectionObserver(
(entries) => {
inView = entries[0]?.isIntersecting ?? false;
if (inView) start();
else stop();
},
{ threshold: 0 },
);
io.observe(canvas);
// ── 렌더 루프 ──
const clock = new THREE_NS.Clock();
loop = () => {
rafId = 0;
if (disposed || !renderer || !scene || !camera || !material) return;
const elapsed = clock.getElapsedTime();
const u = material.uniforms;
u.uTime.value = elapsed;
// 커서 스무딩 (lerp 0.08)
mouse.x += (mouseTarget.x - mouse.x) * 0.08;
mouse.y += (mouseTarget.y - mouse.y) * 0.08;
(u.uMouse.value as THREE.Vector2).set(mouse.x, mouse.y);
// 스크롤 진행도 0~1 clamp
const scrollT = Math.min(
Math.max(window.scrollY / (window.innerHeight || 1), 0),
1,
);
u.uScroll.value = scrollT;
renderer.render(scene, camera);
start();
};
start();
} catch {
if (!disposed) setFailed(true);
}
})();
// ── cleanup: rAF cancel + 리스너 제거 + dispose ──
return () => {
disposed = true;
stop();
if (onMouseMove) window.removeEventListener('mousemove', onMouseMove);
if (onResize) window.removeEventListener('resize', onResize);
if (onVisibility) document.removeEventListener('visibilitychange', onVisibility);
io?.disconnect();
geometry?.dispose();
material?.dispose();
renderer?.dispose();
scene = null;
camera = null;
points = null;
};
}, [animated, effectiveMode]);
return (
<div className={`pointer-events-none absolute inset-0 overflow-hidden ${className ?? ''}`}>
{/* 정적 그래디언트 — 항상 캔버스 아래에 깔림 */}
<StaticField />
{animated && (
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import { useEffect, useRef, useState } from 'react';
interface Props {
children: React.ReactNode;
/** 등장 지연(ms) — 연속 항목 스태거용 */
delay?: number;
/** 'fade-up'(기본) | 'fade' | 'draw'(선 그리기용 — width 확장) */
variant?: 'fade-up' | 'fade' | 'draw';
className?: string;
}
export default function ScrollReveal({ children, delay = 0, variant = 'fade-up', className }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [shown, setShown] = useState(false);
// reduced-motion: transition까지 생략하고 정적으로 표시
const [instant, setInstant] = useState(false);
useEffect(() => {
// reduced-motion: 즉시 표시 (연출·전환 생략)
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setInstant(true);
setShown(true);
return;
}
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setShown(true);
io.disconnect();
}
},
{ threshold: 0.2 },
);
io.observe(el);
return () => io.disconnect();
}, []);
const hidden =
variant === 'fade' ? 'opacity-0' :
variant === 'draw' ? 'opacity-0 [transform:scaleX(0)] origin-left' :
'opacity-0 translate-y-6';
const visible =
variant === 'draw' ? 'opacity-100 [transform:scaleX(1)]' :
variant === 'fade' ? 'opacity-100' :
'opacity-100 translate-y-0';
// reduced-motion이면 transition/transform 없이 정적 표시
if (instant) {
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
return (
<div
ref={ref}
className={`${className ?? ''} transition-all duration-700 ease-out ${shown ? visible : hidden}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import Link from 'next/link';
import type { ShowcaseSlot } from '@/lib/showcase';
import MockWindow from '@/app/components/mock/MockWindow';
import { MOCK_REGISTRY } from '@/app/components/mock/registry';
interface Props {
slot: ShowcaseSlot;
size?: 'feature' | 'standard';
index: number;
}
// 라이트 쇼케이스 카드 — surface-alt 스테이지 위에 흰 MockWindow가 떠 있는 "framed screen".
// 서버 컴포넌트 (캔버스/시드/그래디언트 전량 제거).
export default function ShowcaseCard({ slot, size = 'standard' }: Props) {
const Mock = MOCK_REGISTRY[slot.mock];
const isFeature = size === 'feature';
const isLink = Boolean(slot.href);
const body = (
<div
className={[
'group/card flex h-full flex-col rounded-2xl border p-5 lg:p-6',
'transition-[transform,box-shadow,border-color] duration-300',
'[transition-timing-function:cubic-bezier(0.16,1,0.3,1)]',
'motion-safe:hover:-translate-y-1 hover:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.4)]',
isLink ? 'cursor-pointer' : '',
].join(' ')}
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<MockWindow title={`${slot.slug}.app`} className="group-hover/card:border-[var(--jsm-accent-soft)]">
<Mock />
</MockWindow>
<div className="mt-5">
<span
className="font-mono text-[11px] uppercase tracking-[0.18em]"
style={{ color: 'var(--jsm-accent)' }}
>
{slot.label}
</span>
<h3
className={[
'mt-1.5 font-bold [word-break:keep-all]',
isFeature ? 'text-xl' : 'text-lg',
].join(' ')}
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
>
{slot.title}
</h3>
<p
className="mt-1.5 text-sm leading-relaxed [word-break:keep-all]"
style={{ color: 'var(--jsm-ink-soft)', letterSpacing: '-0.01em' }}
>
{slot.desc}
</p>
{isLink && (
<span
className="mt-3 inline-flex items-center gap-1.5 text-[13px] font-semibold transition-transform duration-300 group-hover/card:translate-x-1"
style={{ color: 'var(--jsm-accent)' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M5 12h14M13 6l6 6-6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</div>
</div>
);
if (isLink) {
return (
<Link href={slot.href!} aria-label={slot.title} className="block h-full">
{body}
</Link>
);
}
return body;
}

View File

@@ -0,0 +1,61 @@
import type { ShowcaseSlot } from '@/lib/showcase';
import ScrollReveal from './ScrollReveal';
import ShowcaseCard from './ShowcaseCard';
interface Props {
slots: ShowcaseSlot[];
variant: 'home' | 'full';
}
/**
* home: 6슬롯 지그재그 — wide(col-span-2) 3장 + standard 3장 = 9셀(3×3 완전 충전)
* row1: [0 feature span2][1 std]
* row2: [2 std][3 feature span2]
* row3: [4 feature span2][5 std]
* 모바일은 1col 전부 standard.
* full: 8슬롯 데스크톱 2col 균등(standard), 모바일 1col.
*/
export default function ShowcaseGrid({ slots, variant }: Props) {
if (variant === 'full') {
return (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 md:gap-6">
{slots.slice(0, 8).map((slot, i) => (
<ScrollReveal key={slot.slug} delay={i * 80}>
<ShowcaseCard slot={slot} size="standard" index={i} />
</ScrollReveal>
))}
</div>
);
}
// home — 6슬롯 (3col 그리드)
const items = slots.slice(0, 6);
// 데스크톱 흐름 (3col) — wide(span-2) 3장 + standard 3장 = 9셀, 빈 칸 없음
// row1: [0 feature span2 좌][1 std 우] → 2+1 = 3
// row2: [2 std 좌][3 feature span2 우] → 1+2 = 3
// row3: [4 feature span2 좌][5 std 우] → 2+1 = 3
// 자동 흐름(auto-placement)이 위 순서를 보장하므로 col-start 불필요.
const layout: Array<{ span: string; size: 'feature' | 'standard' }> = [
{ span: 'md:col-span-2', size: 'feature' }, // 0 — row1 좌 와이드
{ span: 'md:col-span-1', size: 'standard' }, // 1 — row1 우 1칸
{ span: 'md:col-span-1', size: 'standard' }, // 2 — row2 좌 1칸
{ span: 'md:col-span-2', size: 'feature' }, // 3 — row2 우 와이드
{ span: 'md:col-span-2', size: 'feature' }, // 4 — row3 좌 와이드
{ span: 'md:col-span-1', size: 'standard' }, // 5 — row3 우 1칸
];
return (
<div className="grid grid-cols-1 gap-5 md:grid-cols-3 md:gap-6">
{items.map((slot, i) => {
const cfg = layout[i] ?? { span: 'md:col-span-1', size: 'standard' as const };
return (
<ScrollReveal key={slot.slug} delay={i * 80} className={cfg.span}>
<ShowcaseCard slot={slot} size={cfg.size} index={i} />
</ScrollReveal>
);
})}
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { useEffect, useState } from 'react';
import { decideFieldMode, type FieldMode } from '@/lib/deepfield-mode';
function detectWebGL(): boolean {
try {
const canvas = document.createElement('canvas');
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
} catch {
return false;
}
}
/** SSR/첫 페인트는 'static'으로 시작 — 클라이언트에서 승격 (hydration 불일치 방지) */
export function useFieldMode(): FieldMode {
const [mode, setMode] = useState<FieldMode>('static');
useEffect(() => {
setMode(
decideFieldMode({
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
webglSupported: detectWebGL(),
hardwareConcurrency: navigator.hardwareConcurrency ?? 0,
viewportWidth: window.innerWidth,
}),
);
}, []);
return mode;
}

View File

@@ -0,0 +1,51 @@
// 라이트 UI 목업의 공용 크롬 프레임 (서버 컴포넌트).
// 실데이터 없이 "운영 중인 화면" 인상을 주는 craft 요소. --jsm-* 토큰만 사용.
import type { ReactNode } from 'react';
interface MockWindowProps {
/** 타이틀바 텍스트 — 파일/서비스명 느낌 (예: 'stock-report', 'realestate-match') */
title: string;
children: ReactNode;
className?: string;
}
export default function MockWindow({ title, children, className }: MockWindowProps) {
return (
<div
className={`overflow-hidden rounded-xl border shadow-[0_24px_60px_-30px_rgba(15,23,42,0.35)] ${className ?? ''}`}
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
{/* 타이틀바 — 신호등 + 모노 파일명 + 라이브 점 */}
<div
className="flex items-center gap-2 border-b px-3.5 py-2.5"
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<span className="flex gap-1.5" aria-hidden>
<span className="h-2.5 w-2.5 rounded-full" style={{ background: '#cbd5e1' }} />
<span className="h-2.5 w-2.5 rounded-full" style={{ background: '#d8e0ea' }} />
<span className="h-2.5 w-2.5 rounded-full" style={{ background: '#e2e8f0' }} />
</span>
<span
className="ml-1 font-mono text-[11px]"
style={{ color: 'var(--jsm-ink-faint)', letterSpacing: '-0.01em' }}
>
{title}
</span>
<span className="ml-auto flex items-center gap-1.5" aria-hidden>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ background: 'var(--jsm-accent)' }}
/>
<span
className="font-mono text-[10px] uppercase tracking-[0.14em]"
style={{ color: 'var(--jsm-ink-faint)' }}
>
live
</span>
</span>
</div>
{/* 본문 슬롯 */}
<div className="p-4">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
// 목업 키 — JSX를 끌어오지 않는 순수 모듈 (vitest/showcase가 안전하게 참조).
export type MockKey =
| 'dashboard'
| 'feed'
| 'match'
| 'commerce'
| 'site'
| 'booking';
export const MOCK_KEYS: MockKey[] = [
'dashboard',
'feed',
'match',
'commerce',
'site',
'booking',
];

View File

@@ -0,0 +1,24 @@
// 목업 스크린 레지스트리 — showcase 슬롯의 mock 키를 컴포넌트로 해석.
import type { ComponentType } from 'react';
import type { MockKey } from './keys';
import {
DashboardMock,
FeedMock,
MatchMock,
CommerceMock,
SiteMock,
BookingMock,
} from './screens';
export type { MockKey } from './keys';
export { MOCK_KEYS } from './keys';
export const MOCK_REGISTRY: Record<MockKey, ComponentType> = {
dashboard: DashboardMock,
feed: FeedMock,
match: MatchMock,
commerce: CommerceMock,
site: SiteMock,
booking: BookingMock,
};

View File

@@ -0,0 +1,250 @@
// 라이트 UI 목업 스크린 6종 (서버 컴포넌트, props 없음, 정적 마크업).
// MockWindow 본문에 들어가 "운영 중인 화면" 인상을 만든다. 실데이터 0, --jsm-* 만.
const ACCENT = 'var(--jsm-accent)';
const INK = 'var(--jsm-ink)';
const SOFT = 'var(--jsm-ink-soft)';
const FAINT = 'var(--jsm-ink-faint)';
const LINE = 'var(--jsm-line)';
const ALT = 'var(--jsm-surface-alt)';
const SOFTBG = 'var(--jsm-accent-soft)';
/** 1. 대시보드 — 주식 리포트 톤: 스탯 3 + 막대 차트 */
export function DashboardMock() {
const bars = [38, 54, 30, 62, 46, 72, 58];
return (
<div className="space-y-3.5">
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg p-2.5" style={{ background: ALT }}>
<p className="font-mono text-[10px]" style={{ color: FAINT }}>
</p>
<p className="mt-1 text-sm font-bold" style={{ color: ACCENT, letterSpacing: '-0.02em' }}>
+2.4%
</p>
</div>
<div className="rounded-lg p-2.5" style={{ background: ALT }}>
<p className="font-mono text-[10px]" style={{ color: FAINT }}>
</p>
<p className="mt-1 text-sm font-bold" style={{ color: INK, letterSpacing: '-0.02em' }}>
12
</p>
</div>
<div className="rounded-lg p-2.5" style={{ background: ALT }}>
<p className="font-mono text-[10px]" style={{ color: FAINT }}>
</p>
<p className="mt-1 text-sm font-bold" style={{ color: INK, letterSpacing: '-0.02em' }}>
68%
</p>
</div>
</div>
<div
className="flex h-20 items-end gap-1.5 rounded-lg border p-2.5"
style={{ borderColor: LINE }}
>
{bars.map((h, i) => (
<span
key={i}
className="flex-1 rounded-sm"
style={{
height: `${h}%`,
background: i === 5 ? ACCENT : '#dbe3ee',
}}
/>
))}
</div>
</div>
);
}
/** 2. 피드 — 텔레그램 봇 톤: 메시지 버블 3 */
export function FeedMock() {
const rows = [
{ t: '09:01', m: '매수 체결 · 삼성전자 12,400', tag: '체결', on: true },
{ t: '11:24', m: '목표가 도달 — 익절 알림', tag: '알림', on: false },
{ t: '15:30', m: '일일 손익 리포트 전송 완료', tag: '리포트', on: false },
];
return (
<div className="space-y-2">
{rows.map((r) => (
<div
key={r.t}
className="flex items-start gap-2.5 rounded-lg p-2.5"
style={{ background: ALT }}
>
<span className="mt-0.5 font-mono text-[10px]" style={{ color: FAINT }}>
{r.t}
</span>
<p className="flex-1 text-[12px] leading-snug" style={{ color: INK, letterSpacing: '-0.01em' }}>
{r.m}
</p>
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold"
style={
r.on
? { color: ACCENT, background: SOFTBG }
: { color: SOFT, background: 'var(--jsm-surface)' }
}
>
{r.tag}
</span>
</div>
))}
</div>
);
}
/** 3. 매칭 — 부동산 청약 톤: 필터칩 + 매칭률 리스트 3 */
export function MatchMock() {
const chips = ['강남구', '85㎡↑', '신축'];
const rows = [
{ n: '래미안 원베일리', s: '92%' },
{ n: '디에이치 퍼스티어', s: '88%' },
{ n: '아크로 포레스트', s: '81%' },
];
return (
<div className="space-y-3">
<div className="flex gap-1.5">
{chips.map((c, i) => (
<span
key={c}
className="rounded-full px-2.5 py-1 text-[11px] font-medium"
style={
i === 0
? { color: ACCENT, background: SOFTBG }
: { color: SOFT, background: ALT }
}
>
{c}
</span>
))}
</div>
<div className="space-y-2">
{rows.map((r) => (
<div
key={r.n}
className="flex items-center justify-between rounded-lg border px-3 py-2.5"
style={{ borderColor: LINE }}
>
<span className="text-[12px] font-medium" style={{ color: INK, letterSpacing: '-0.01em' }}>
{r.n}
</span>
<span
className="rounded px-1.5 py-0.5 font-mono text-[11px] font-bold"
style={{ color: ACCENT, background: SOFTBG }}
>
{r.s}
</span>
</div>
))}
</div>
</div>
);
}
/** 4. 커머스 — 상품 그리드 4 + 장바구니 바 */
export function CommerceMock() {
const items = [
{ p: '₩28,000' },
{ p: '₩45,000' },
{ p: '₩19,000' },
{ p: '₩36,000' },
];
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
{items.map((it, i) => (
<div key={i} className="rounded-lg border p-2" style={{ borderColor: LINE }}>
<div className="h-9 rounded-md" style={{ background: ALT }} />
<p className="mt-1.5 text-[11px] font-bold" style={{ color: INK, letterSpacing: '-0.02em' }}>
{it.p}
</p>
</div>
))}
</div>
<div
className="flex items-center justify-between rounded-lg px-3 py-2.5"
style={{ background: INK }}
>
<span className="text-[11px] font-medium text-white/80"> 3 · 128,000</span>
<span
className="rounded px-2 py-1 text-[11px] font-semibold"
style={{ background: ACCENT, color: '#fff' }}
>
</span>
</div>
</div>
);
}
/** 5. 사이트 — 기업/포트폴리오 와이어: 네비 + 헤드라인 + 카드 3 */
export function SiteMock() {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="h-2.5 w-2.5 rounded-full" style={{ background: ACCENT }} />
<div className="flex gap-3">
<span className="h-1.5 w-6 rounded-full" style={{ background: LINE }} />
<span className="h-1.5 w-6 rounded-full" style={{ background: LINE }} />
<span className="h-1.5 w-6 rounded-full" style={{ background: LINE }} />
</div>
<span className="h-4 w-10 rounded" style={{ background: ALT }} />
</div>
<div className="space-y-1.5 py-1">
<span className="block h-3 w-3/4 rounded" style={{ background: '#cbd5e1' }} />
<span className="block h-3 w-1/2 rounded" style={{ background: ACCENT }} />
</div>
<div className="grid grid-cols-3 gap-2">
{[0, 1, 2].map((i) => (
<div key={i} className="rounded-lg border p-2" style={{ borderColor: LINE }}>
<div className="h-6 rounded" style={{ background: ALT }} />
<span className="mt-1.5 block h-1.5 w-full rounded-full" style={{ background: LINE }} />
</div>
))}
</div>
</div>
);
}
/** 6. 예약 — 로컬 매장 톤: 주간 캘린더 + 슬롯 그리드 */
export function BookingMock() {
const days = ['월', '화', '수', '목', '금', '토', '일'];
// 0=빈 1=예약됨(accent) 2=불가(alt)
const slots = [
1, 0, 0, 1, 0, 2, 2,
0, 1, 0, 0, 1, 1, 2,
0, 0, 1, 0, 0, 1, 0,
];
return (
<div className="space-y-2.5">
<div className="grid grid-cols-7 gap-1.5">
{days.map((d) => (
<span key={d} className="text-center font-mono text-[10px]" style={{ color: FAINT }}>
{d}
</span>
))}
</div>
<div className="grid grid-cols-7 gap-1.5">
{slots.map((s, i) => (
<span
key={i}
className="aspect-square rounded"
style={{
background: s === 1 ? ACCENT : s === 2 ? ALT : 'var(--jsm-surface)',
boxShadow: s === 0 ? `inset 0 0 0 1px ${LINE}` : undefined,
}}
/>
))}
</div>
<div
className="rounded-lg py-2 text-center text-[11px] font-semibold"
style={{ background: SOFTBG, color: ACCENT }}
>
· 19:00
</div>
</div>
);
}

View File

@@ -49,7 +49,7 @@
--jsm-accent-hover: #1e40af; /* blue-800 */
--jsm-accent-soft: #dbeafe; /* blue-100 뱃지 배경 */
/* 기존 kx 변수 재매핑 (잔여 참조 호환용) */
/* 기존 kx 변수 재매핑 (레거시·숨김 라우트 /packages·/work·/music 호환용) */
--kx-surface: var(--jsm-bg);
--kx-surface-low: var(--jsm-surface-alt);
--kx-surface-mid: var(--jsm-surface);

View File

@@ -10,7 +10,7 @@ export const metadata: Metadata = {
template: "%s | 쟁승메이드",
},
description:
"7년차 대기업 백엔드 개발자가 직접 설계하고 만듭니다. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 제공하는 쟁승메이드.",
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
keywords: [
"외주 개발",
"소프트웨어 개발",
@@ -28,7 +28,7 @@ export const metadata: Metadata = {
siteName: "쟁승메이드",
title: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
description:
"7년차 대기업 백엔드 개발자가 직접 설계·개발·운영합니다. 맞춤 외주 개발과 검증된 완성 소프트웨어를 제공하는 쟁승메이드.",
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
images: [
{
url: "https://jaengseung-made.com/og-image.png",
@@ -42,7 +42,7 @@ export const metadata: Metadata = {
card: "summary_large_image",
title: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
description:
"7년차 대기업 백엔드 개발자가 직접 만듭니다. 맞춤 외주 개발과 검증된 완성 소프트웨어를 제공합니다.",
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
},
robots: {
index: true,
@@ -59,19 +59,18 @@ const jsonLd = {
'@id': 'https://jaengseung-made.com/#person',
name: '박재오',
url: 'https://jaengseung-made.com',
jobTitle: '백엔드 개발자 · 외주 개발 전문가',
worksFor: { '@type': 'Organization', name: '대기업 재직 중' },
jobTitle: '소프트웨어 엔지니어',
email: 'bgg8988@gmail.com',
telephone: '010-3907-1392',
knowsAbout: ['Python', 'Java', 'Spring Boot', 'Next.js', '외주 개발', '웹사이트 제작', '업무 자동화', 'API 설계'],
description: '7년차 대기업 백엔드 개발자. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 직접 설계·개발·운영합니다.',
description: '24시간 돌아가는 실서비스를 직접 설계·운영합니다. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 제공합니다.',
},
{
'@type': 'LocalBusiness',
'@id': 'https://jaengseung-made.com/#business',
name: '쟁승메이드',
url: 'https://jaengseung-made.com',
description: '7년차 대기업 백엔드 개발자가 직접 설계·개발·운영하는 외주 개발 · 완성 소프트웨어 스토어.',
description: '24시간 돌아가는 실서비스를 직접 설계·운영하는 외주 개발 · 완성 소프트웨어 스토어.',
email: 'bgg8988@gmail.com',
telephone: '010-3907-1392',
priceRange: '₩',
@@ -88,7 +87,7 @@ const jsonLd = {
'@type': 'Service',
name: '외주 개발',
url: 'https://jaengseung-made.com/outsourcing',
description: '7년차 백엔드 개발자의 1:1 맞춤 소프트웨어 개발 외주. 자동화·API·웹/모바일 등 사이트 한정가로 제공.',
description: '1:1 맞춤 소프트웨어 개발 외주. 자동화·API·웹/모바일 등 사이트 한정가로 제공.',
serviceType: 'Custom Software Development',
provider: { '@id': 'https://jaengseung-made.com/#business' },
areaServed: '대한민국',

View File

@@ -2,44 +2,32 @@ import Link from 'next/link';
import type { Metadata } from 'next';
import OutsourcingRequestForm from '@/app/components/OutsourcingRequestForm';
// 외주 개발 의뢰 페이지 (서버 컴포넌트)
// PublicShell이 TopNav(h-16)·푸터·main 배경을 제공하므로 여기서는 콘텐츠 섹션만 렌더한다.
// 메인(/)의 토큰·타이포 패턴(KOR_TIGHT/KOR_BODY)·섹션 리듬과 일관되게 구성한다.
import ShowcaseGrid from '@/app/components/deepfield/ShowcaseGrid';
import ScrollReveal from '@/app/components/deepfield/ScrollReveal';
import MockWindow from '@/app/components/mock/MockWindow';
import { FeedMock } from '@/app/components/mock/screens';
import { SHOWCASE_SLOTS } from '@/lib/showcase';
// 외주 개발 의뢰 페이지 (서버 컴포넌트) — 라이트 고craft.
// PublicShell의 단일 라이트 셸을 따르며, 메인(/)과 동일한 비주얼 언어
// (surface↔surface-alt 교차 + accent 모노 라벨 헤더 + 카드 스펙)를 공유한다.
export const metadata: Metadata = {
title: '외주 개발',
description:
'7년차 대기업 백엔드 개발자가 직접 진행하는 맞춤 소프트웨어 외주 개발. 웹 서비스, 업무 자동화, API·백엔드, 봇, AI 연동까지 기획부터 납품·하자보수까지 단독으로 책임집니다.',
'24시간 돌아가는 실서비스를 직접 설계·운영하는 손으로, 맞춤 소프트웨어를 만들어 드립니다. 웹 서비스·업무 자동화·API·백엔드·봇·AI 연동까지 기획부터 납품·하자보수까지 단독으로 책임집니다.',
};
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
const FIELDS = [
{
t: '웹 서비스 개발',
d: '회원·결제·관리자까지, 실제로 굴러가는 서비스를 기획부터 배포까지 만들어 드립니다.',
},
{
t: '웹사이트 제작',
d: '기업 소개·포트폴리오·랜딩 페이지를 반응형·SEO까지 갖춰 제작합니다.',
},
{
t: '업무 자동화',
d: 'RPA·엑셀 집계·웹 크롤링으로 반복 업무를 사람 손에서 떼어냅니다.',
},
{
t: 'API·백엔드',
d: '데이터 모델 설계부터 인증·외부 연동까지 안정적인 서버를 구축합니다.',
},
{
t: '텔레그램·디스코드 봇',
d: '알림·명령·자동 응답 봇으로 운영과 커뮤니티 관리를 자동화합니다.',
},
{
t: 'AI 연동 개발',
d: 'LLM·생성형 AI를 업무 흐름에 붙여 초안 작성·분류·요약을 자동화합니다.',
},
{ t: '웹 서비스 개발', d: '회원·결제·관리자까지, 실제로 굴러가는 서비스를 기획부터 배포까지 만들어 드립니다.' },
{ t: '웹사이트 제작', d: '기업 소개·포트폴리오·랜딩 페이지를 반응형·SEO까지 갖춰 제작합니다.' },
{ t: '업무 자동화', d: 'RPA·엑셀 집계·웹 크롤링으로 반복 업무를 사람 손에서 떼어냅니다.' },
{ t: 'API·백엔드', d: '데이터 모델 설계부터 인증·외부 연동까지 안정적인 서버를 구축합니다.' },
{ t: '텔레그램·디스코드 봇', d: '알림·명령·자동 응답 봇으로 운영과 커뮤니티 관리를 자동화합니다.' },
{ t: 'AI 연동 개발', d: 'LLM·생성형 AI를 업무 흐름에 붙여 초안 작성·분류·요약을 자동화합니다.' },
];
const PROCESS = [
@@ -51,485 +39,274 @@ const PROCESS = [
{ n: '06', t: '무상 하자보수 30일', d: '납품 후 30일간 결함·수정을 무상으로 대응해 안정화까지 책임집니다.' },
];
// 기존 work/freelance(lib/freelance-portfolio) 실사례를 새 토큰 기준으로 재구성.
const CASES = [
{
t: '주식 자동매매 시스템',
cat: '실시간 트레이딩 · 직접 운영 중',
live: true,
d: '텔레그램과 연동해 실시간으로 주문을 집행하고 체결·손익 리포트를 자동 전송합니다.',
tags: ['Python', 'Telegram Bot', '실시간 주문'],
},
{
t: '부동산 청약 자동 수집·매칭',
cat: '크롤링 · 직접 운영 중',
live: true,
d: '공고를 주기적으로 크롤링해 조건에 맞는 매물만 골라내고, 신규 매칭을 즉시 푸시합니다.',
tags: ['Python', '크롤링', '조건 매칭'],
},
{
t: 'AI 콘텐츠 자동화 파이프라인',
cat: 'AI 연동 · 직접 운영 중',
live: true,
d: '생성부터 검수, 발행까지 사람이 개입할 지점만 남기고 전 과정을 자동으로 연결합니다.',
tags: ['AI 연동', '검수 워크플로우', '자동 발행'],
},
{
t: 'Gmail 자동화 RPA',
cat: 'RPA · 납품 완료',
live: false,
d: '거래처 이메일 수신 시 자동 분류, 답장 초안 작성, 담당자 알림을 전송합니다.',
tags: ['Python', 'Gmail API'],
},
{
t: '쇼핑몰 가격 모니터링 봇',
cat: '웹 스크래핑 · 납품 완료',
live: false,
d: '경쟁사 상품 가격을 매일 모니터링해 변동 시 텔레그램으로 즉시 알립니다.',
tags: ['Python', 'Selenium', 'Telegram Bot'],
},
{
t: '영업 일보 자동화 시스템',
cat: '엑셀 자동화 · 납품 완료',
live: false,
d: '엑셀 데이터를 자동 집계해 일·주·월별 보고서 PDF를 생성하고 매일 09시 발송합니다.',
tags: ['Python', 'OpenPyXL', 'ReportLab'],
},
];
// /work/website/samples/* 중 대표 샘플 — 이 라우트는 숨김이 아니라 포트폴리오용으로 잔존.
const SAMPLES = [
{ slug: 'corporate', t: '기업 홈페이지', sub: '테크솔루션㈜', tag: 'B2B · 신뢰' },
{ slug: 'shopping', t: '개인 쇼핑몰', sub: 'MELLOW STUDIO', tag: '쇼핑몰 · 브랜드' },
{ slug: 'dashboard', t: '관리자 대시보드', sub: 'DataFlow SaaS', tag: 'SaaS · 자동화' },
{ slug: 'portfolio', t: '개인 포트폴리오', sub: 'Kim Jisu', tag: '크리에이터 · 수주' },
{ t: '주식 자동매매 시스템', cat: '실시간 트레이딩 · 직접 운영 중', live: true, d: '텔레그램과 연동해 실시간으로 주문을 집행하고 체결·손익 리포트를 자동 전송합니다.', tags: ['Python', 'Telegram Bot', '실시간 주문'] },
{ t: '부동산 청약 자동 수집·매칭', cat: '크롤링 · 직접 운영 중', live: true, d: '공고를 주기적으로 크롤링해 조건에 맞는 매물만 골라내고, 신규 매칭을 즉시 푸시합니다.', tags: ['Python', '크롤링', '조건 매칭'] },
{ t: 'AI 콘텐츠 자동화 파이프라인', cat: 'AI 연동 · 직접 운영 중', live: true, d: '생성부터 검수, 발행까지 사람이 개입할 지점만 남기고 전 과정을 자동으로 연결합니다.', tags: ['AI 연동', '검수 워크플로우', '자동 발행'] },
{ t: 'Gmail 자동화 RPA', cat: 'RPA · 납품 완료', live: false, d: '거래처 이메일 수신 시 자동 분류, 답장 초안 작성, 담당자 알림을 전송합니다.', tags: ['Python', 'Gmail API'] },
{ t: '쇼핑몰 가격 모니터링 봇', cat: '웹 스크래핑 · 납품 완료', live: false, d: '경쟁사 상품 가격을 매일 모니터링해 변동 시 텔레그램으로 즉시 알립니다.', tags: ['Python', 'Selenium', 'Telegram Bot'] },
{ t: '영업 일보 자동화 시스템', cat: '엑셀 자동화 · 납품 완료', live: false, d: '엑셀 데이터를 자동 집계해 일·주·월별 보고서 PDF를 생성하고 매일 09시 발송합니다.', tags: ['Python', 'OpenPyXL', 'ReportLab'] },
];
const FAQ = [
{
q: '견적은 어떻게 산정되나요?',
a: '기능 범위와 구현 난이도를 기준으로 산정합니다. 상담에서 필요한 기능을 함께 정리한 뒤, 영업일 2일 내에 범위·일정·금액을 명시한 견적으로 회신드립니다. 추측으로 부풀리지 않고 실제 작업량 기준으로 잡습니다.',
},
{
q: '수정 요청은 몇 번까지 가능한가요?',
a: '합의한 범위 안에서는 2회까지 무상으로 수정해 드립니다. 범위를 벗어나는 기능 추가나 방향 전환은 별도로 협의해 진행합니다. 무엇이 범위 안/밖인지는 착수 전 견적에 미리 명시합니다.',
},
{
q: '소스코드도 제공되나요?',
a: '제공됩니다. 잔금 완납 시 전체 소스코드와 배포·실행 문서를 함께 전달합니다. 직접 운영하시거나 다른 개발자에게 이어 맡기셔도 문제없도록 인도합니다.',
},
{
q: '납품 후 유지보수는요?',
a: '납품일로부터 30일간 결함·오류를 무상으로 하자보수합니다. 이후 기능 추가나 지속 운영이 필요하면 월 단위 유지보수 계약으로 이어갈 수 있습니다.',
},
{ q: '견적은 어떻게 산정되나요?', a: '기능 범위와 구현 난이도를 기준으로 산정합니다. 상담에서 필요한 기능을 함께 정리한 뒤, 영업일 2일 내에 범위·일정·금액을 명시한 견적으로 회신드립니다. 추측으로 부풀리지 않고 실제 작업량 기준으로 잡습니다.' },
{ q: '수정 요청은 몇 번까지 가능한가요?', a: '합의한 범위 안에서는 2회까지 무상으로 수정해 드립니다. 범위를 벗어나는 기능 추가나 방향 전환은 별도로 협의해 진행합니다. 무엇이 범위 안/밖인지는 착수 전 견적에 미리 명시합니다.' },
{ q: '소스코드도 제공되나요?', a: '제공됩니다. 잔금 완납 시 전체 소스코드와 배포·실행 문서를 함께 전달합니다. 직접 운영하시거나 다른 개발자에게 이어 맡기셔도 문제없도록 인도합니다.' },
{ q: '납품 후 유지보수는요?', a: '납품일로부터 30일간 결함·오류를 무상으로 하자보수합니다. 이후 기능 추가나 지속 운영이 필요하면 월 단위 유지보수 계약으로 이어갈 수 있습니다.' },
];
function ArrowRight() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M5 12h14" />
<path d="m13 5 7 7-7 7" />
</svg>
);
}
function Eyebrow({ children }: { children: React.ReactNode }) {
return (
<p className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
{children}
</p>
);
}
export default function OutsourcingPage() {
return (
<>
{/* ─── 1. Hero ─── */}
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-24 lg:py-32">
<div className="max-w-3xl">
<span
className="inline-block text-xs font-semibold mb-6 px-2.5 py-1 rounded"
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)', ...KOR_BODY }}
>
{/* ─────────────────── 1. HERO ─────────────────── */}
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto grid max-w-6xl items-center gap-12 px-6 pt-20 pb-16 lg:grid-cols-2 lg:gap-16 lg:px-8 lg:pt-28 lg:pb-24">
<div>
<span className="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
<span className="inline-block h-1 w-1 rounded-full" style={{ background: 'var(--jsm-accent)' }} />
outsourcing
</span>
<h1
className="text-4xl sm:text-5xl lg:text-[3.5rem] font-bold leading-[1.2] break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
className="mt-6 font-extrabold break-keep"
style={{ color: 'var(--jsm-ink)', fontSize: 'clamp(2.3rem, 6vw, 3.6rem)', lineHeight: 1.1, letterSpacing: '-0.035em' }}
>
{' '}
<span style={{ color: 'var(--jsm-accent)' }}> </span>
<br />
<span style={{ color: 'var(--jsm-accent)' }}>.</span>
</h1>
<p
className="mt-7 text-lg lg:text-xl leading-relaxed break-keep max-w-2xl"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. . 7
· .
<p className="mt-7 max-w-xl break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
. .
</p>
<div className="mt-10 flex flex-col sm:flex-row gap-3">
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
<Link
href="#contact"
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold text-white transition-colors duration-150 hover:bg-[var(--jsm-accent-hover)]"
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 font-semibold text-white transition-colors duration-200 hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
<Link
href="#portfolio"
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold border transition-colors duration-150 hover:bg-[var(--jsm-surface-alt)]"
style={{
color: 'var(--jsm-ink)',
borderColor: 'var(--jsm-line)',
background: 'var(--jsm-surface)',
...KOR_BODY,
}}
href="#showcase"
className="inline-flex items-center justify-center gap-2 rounded-lg border px-6 py-3.5 font-semibold transition-colors duration-200 hover:bg-[var(--jsm-surface-alt)]"
style={{ color: 'var(--jsm-ink)', borderColor: 'var(--jsm-line)', ...KOR_BODY }}
>
</Link>
</div>
</div>
<div className="lg:pl-4">
<MockWindow title="telegram-bot.log">
<FeedMock />
</MockWindow>
</div>
</div>
</section>
{/* ─── 2. 제공 분야 ─── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="max-w-2xl">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
Scope
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{/* ─────────────────── 2. SHOWCASE (풀 그리드) ─────────────────── */}
<section id="showcase" className="scroll-mt-20" style={{ background: 'var(--jsm-surface-alt)' }}>
{/* 하위 호환: 기존 /outsourcing#portfolio 링크 앵커 유지 */}
<div id="portfolio" className="scroll-mt-20" />
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>showcase</Eyebrow>
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
</h2>
</div>
<div className="mt-12 grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{FIELDS.map((f) => (
<div
key={f.t}
className="rounded-2xl p-7 border"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<h3
className="text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{f.t}
</h3>
<p
className="mt-2.5 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{f.d}
</p>
</div>
))}
</ScrollReveal>
<div className="mt-12">
<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="full" />
</div>
</div>
</section>
{/* ─── 3. 진행 프로세스 ─── */}
<section id="process" className="scroll-mt-20" style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="max-w-2xl">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
Process
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
,
</h2>
</div>
<div
className="mt-12 grid sm:grid-cols-2 lg:grid-cols-3 gap-px rounded-2xl overflow-hidden border"
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-line)' }}
>
{PROCESS.map((s) => (
<div key={s.n} className="p-7 lg:p-8" style={{ background: 'var(--jsm-surface)' }}>
<span
className="text-sm font-bold"
style={{ color: 'var(--jsm-accent)', fontFamily: 'monospace' }}
>
{s.n}
</span>
<h3
className="mt-4 text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{s.t}
</h3>
<p
className="mt-2 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{s.d}
</p>
</div>
))}
</div>
</div>
</section>
{/* ─── 4. 포트폴리오 ─── */}
<section id="portfolio" className="scroll-mt-20" style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="max-w-2xl">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
Portfolio
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{/* ─────────────────── 3. 운영 실사례 ─────────────────── */}
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>in production</Eyebrow>
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
,
</h2>
<p
className="mt-4 leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
<p className="mt-4 max-w-xl break-keep leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
. .
</p>
</div>
</ScrollReveal>
{/* 실사례 카드 */}
<div className="mt-12 grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{CASES.map((c) => (
<div
key={c.t}
className="flex flex-col rounded-2xl p-7 border"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="self-start inline-flex items-center gap-1.5 text-[11px] font-semibold px-2.5 py-1 rounded-full mb-5"
style={
c.live
? { color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' }
: { color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)' }
}
>
{c.live && (
<span
className="w-1.5 h-1.5 rounded-full"
style={{ background: 'var(--jsm-accent)' }}
/>
)}
{c.cat}
</span>
<h3
className="text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{c.t}
</h3>
<p
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{c.d}
</p>
<div className="mt-5 flex flex-wrap gap-1.5">
{c.tags.map((tag) => (
<span
key={tag}
className="text-xs px-2.5 py-1 rounded"
style={{
color: 'var(--jsm-ink-soft)',
background: 'var(--jsm-surface-alt)',
...KOR_BODY,
}}
>
{tag}
</span>
))}
</div>
</div>
))}
</div>
{/* 웹사이트 샘플 링크 */}
<div className="mt-14">
<h3
className="text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h3>
<p
className="mt-2 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. .
</p>
<div className="mt-6 grid sm:grid-cols-2 lg:grid-cols-4 gap-5">
{SAMPLES.map((s) => (
<Link
key={s.slug}
href={`/work/website/samples/${s.slug}`}
className="group flex flex-col rounded-2xl p-6 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{CASES.map((c, i) => (
<ScrollReveal key={c.t} delay={i * 80}>
<div className="flex h-full flex-col rounded-2xl border p-7" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<span
className="text-[11px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--jsm-accent)' }}
className="mb-5 inline-flex items-center gap-1.5 self-start rounded-full px-2.5 py-1 text-[11px] font-semibold"
style={c.live ? { color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' } : { color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)' }}
>
{s.tag}
{c.live && <span className="h-1.5 w-1.5 rounded-full" style={{ background: 'var(--jsm-accent)' }} />}
{c.cat}
</span>
<h4
className="mt-3 text-base font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{s.t}
</h4>
<p
className="mt-1 text-sm break-keep"
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
>
{s.sub}
<h3 className="break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{c.t}
</h3>
<p className="mt-2.5 flex-1 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{c.d}
</p>
<span
className="mt-5 inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</span>
</Link>
))}
</div>
<div className="mt-5 flex flex-wrap gap-1.5">
{c.tags.map((tag) => (
<span key={tag} className="rounded px-2.5 py-1 text-xs" style={{ color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)', ...KOR_BODY }}>
{tag}
</span>
))}
</div>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ─── 5. FAQ ─── */}
<section style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-3xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="mb-12">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
FAQ
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{/* ─────────────────── 4a. 제공 분야 ─────────────────── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>scope</Eyebrow>
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
</h2>
</ScrollReveal>
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{FIELDS.map((f, i) => (
<ScrollReveal key={f.t} delay={i * 80}>
<div className="h-full rounded-2xl border p-7" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<h3 className="break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{f.t}
</h3>
<p className="mt-2.5 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{f.d}
</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ─────────────────── 4b. 진행 프로세스 ─────────────────── */}
<section id="process" className="scroll-mt-20" style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>process</Eyebrow>
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
,
</h2>
</ScrollReveal>
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{PROCESS.map((s, i) => (
<ScrollReveal key={s.n} delay={i * 80}>
<div className="relative h-full rounded-2xl border p-7 lg:p-8" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<span
className="relative z-10 inline-flex h-12 w-12 items-center justify-center rounded-full font-mono text-sm font-bold"
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-surface)', boxShadow: 'inset 0 0 0 1px var(--jsm-line)' }}
>
{s.n}
</span>
<h3 className="mt-5 break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{s.t}
</h3>
<p className="mt-2 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{s.d}
</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ─────────────────── 5. FAQ ─────────────────── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-3xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>faq</Eyebrow>
<h2 className="break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
</h2>
</div>
<div className="space-y-3">
{FAQ.map((item) => (
<details
key={item.q}
className="group rounded-2xl border overflow-hidden"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<summary
className="flex items-center justify-between gap-4 cursor-pointer list-none px-6 py-5 font-semibold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{item.q}
<svg
className="shrink-0 transition-transform duration-200 group-open:rotate-45"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden
style={{ color: 'var(--jsm-ink-faint)' }}
>
<path d="M12 5v14M5 12h14" />
</svg>
</summary>
<p
className="px-6 pb-5 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{item.a}
</p>
</details>
</ScrollReveal>
<div className="mt-12 space-y-3">
{FAQ.map((item, i) => (
<ScrollReveal key={item.q} delay={i * 80}>
<details className="group overflow-hidden rounded-2xl border" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 break-keep px-6 py-5 font-semibold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{item.q}
<svg className="shrink-0 transition-transform duration-200 group-open:rotate-45" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden style={{ color: 'var(--jsm-ink-soft)' }}>
<path d="M12 5v14M5 12h14" />
</svg>
</summary>
<p className="break-keep px-6 pb-5 text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{item.a}
</p>
</details>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ─── 6. 의뢰 폼 ─── */}
<section id="contact" className="scroll-mt-20" style={{ background: 'var(--jsm-navy)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="grid lg:grid-cols-5 gap-10 lg:gap-12">
{/* ─────────────────── 6. 의뢰 폼 ─────────────────── */}
<section id="contact" className="scroll-mt-20" style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<div className="grid gap-10 lg:grid-cols-5 lg:gap-12">
{/* 안내 */}
<div className="lg:col-span-2">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: '#7aa7ff' }}
>
Contact
</p>
<h2
className="text-3xl lg:text-[2.5rem] font-bold leading-tight text-white break-keep"
style={KOR_TIGHT}
>
</h2>
<p
className="mt-5 text-lg leading-relaxed text-white/70 break-keep"
style={KOR_BODY}
>
2 .
.
</p>
<div
className="mt-8 pt-8 border-t space-y-3"
style={{ borderColor: 'rgba(255,255,255,0.12)' }}
>
<a
href="mailto:bgg8988@gmail.com"
className="flex items-center gap-3 text-sm text-white/80 hover:text-white transition-colors"
style={KOR_BODY}
>
<span className="text-white/40 text-xs uppercase tracking-wider w-12">Mail</span>
bgg8988@gmail.com
</a>
<a
href="tel:010-3907-1392"
className="flex items-center gap-3 text-sm text-white/80 hover:text-white transition-colors"
style={KOR_BODY}
>
<span className="text-white/40 text-xs uppercase tracking-wider w-12">Tel</span>
010-3907-1392
</a>
</div>
<ScrollReveal>
<Eyebrow>contact</Eyebrow>
<h2 className="break-keep text-3xl font-bold leading-tight lg:text-[2.4rem]" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
</h2>
<p className="mt-5 break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
2 . .
</p>
<div className="mt-8 space-y-3 border-t pt-8" style={{ borderColor: 'var(--jsm-line)' }}>
<a href="mailto:bgg8988@gmail.com" className="flex items-center gap-3 text-sm transition-colors hover:text-[var(--jsm-ink)]" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
<span className="w-12 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--jsm-accent)' }}>Mail</span>
bgg8988@gmail.com
</a>
<a href="tel:010-3907-1392" className="flex items-center gap-3 text-sm transition-colors hover:text-[var(--jsm-ink)]" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
<span className="w-12 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--jsm-accent)' }}>Tel</span>
010-3907-1392
</a>
</div>
</ScrollReveal>
</div>
{/* 폼 */}
<div className="lg:col-span-3">
<div
className="rounded-2xl p-6 lg:p-8"
style={{ background: 'var(--jsm-surface)' }}
>
<OutsourcingRequestForm />
</div>
<ScrollReveal delay={100}>
<div className="rounded-2xl border p-6 shadow-[0_24px_60px_-32px_rgba(15,23,42,0.3)] lg:p-8" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<OutsourcingRequestForm />
</div>
</ScrollReveal>
</div>
</div>
</div>

View File

@@ -2,15 +2,28 @@ import Link from 'next/link';
import { createAdminClient } from '@/lib/supabase/admin';
import { getListedProducts, type ProductRow } from '@/lib/supabase/product-files';
// 쟁승메이드 메인 — 외주 개발 + 완성 소프트웨어 2축 랜딩 (서버 컴포넌트)
// PublicShell이 TopNav(h-16)·푸터·main 배경을 제공하므로 여기서는 콘텐츠 섹션만 렌더한다.
import ShowcaseGrid from './components/deepfield/ShowcaseGrid';
import ScrollReveal from './components/deepfield/ScrollReveal';
import CountUp from './components/deepfield/CountUp';
import MockWindow from './components/mock/MockWindow';
import { DashboardMock } from './components/mock/screens';
import { SHOWCASE_SLOTS } from '@/lib/showcase';
// 쟁승메이드 메인 — 라이트 고craft (서버 컴포넌트).
// PublicShell이 단일 라이트 TopNav(h-16)·navy 푸터·main(라이트 --jsm-bg, pt-16)을 제공한다.
// 섹션은 surface(#fff) ↔ surface-alt(#f1f5f9) 교차로 구분하고, 히어로의 제품 목업이 유일한 강조면.
// 소프트웨어 진열 섹션이 DB 조회를 포함하므로 항상 최신 목록을 보여준다.
export const dynamic = 'force-dynamic';
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
const TRUST = [
{ v: '15+', t: '직접 운영 중인 실서비스' },
{ v: '24/7', t: '무중단 운영' },
{ v: '원스톱', t: '기획 → 배포 단독 진행' },
];
const PROCESS = [
{ n: '01', t: '무료 상담', d: '요구사항을 함께 정리하고 실현 가능성을 점검합니다.' },
{ n: '02', t: '견적·범위 확정', d: '영업일 2일 내 범위와 견적을 정리해 회신드립니다.' },
@@ -18,15 +31,7 @@ const PROCESS = [
{ n: '04', t: '납품·배포 지원', d: '검수 후 30일 무상 하자보수로 안정화까지 책임집니다.' },
];
const STATS = [
{ v: '7년차', l: '대기업 백엔드 개발 경력' },
{ v: '15+', l: '직접 운영 중인 서비스' },
{ v: '기획→배포', l: '원스톱 단독 진행' },
];
const STACK = ['Python', 'Java', 'Spring', 'Next.js', 'AI 연동'];
const PORTFOLIO = [
const PROOF = [
{
t: '주식 자동매매 시스템',
d: '텔레그램과 연동해 실시간으로 주문을 집행하고 체결·손익 리포트를 자동 전송합니다.',
@@ -63,6 +68,17 @@ function ArrowRight() {
);
}
function Eyebrow({ children }: { children: React.ReactNode }) {
return (
<p
className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]"
style={{ color: 'var(--jsm-accent)' }}
>
{children}
</p>
);
}
async function loadFeaturedProducts(): Promise<ProductRow[]> {
try {
const all = await getListedProducts(createAdminClient());
@@ -79,51 +95,57 @@ export default async function Home() {
return (
<>
{/* ─── 1. Hero ─── */}
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-24 lg:py-32">
<div className="max-w-3xl">
{/* ─────────────────── 1. HERO ─────────────────── */}
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto grid max-w-6xl items-center gap-12 px-6 pt-20 pb-16 lg:grid-cols-2 lg:gap-16 lg:px-8 lg:pt-28 lg:pb-24">
{/* 좌 — 텍스트 */}
<div>
<span
className="inline-block text-xs font-semibold mb-6 px-2.5 py-1 rounded"
style={{
color: 'var(--jsm-accent)',
background: 'var(--jsm-accent-soft)',
...KOR_BODY,
}}
className="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em]"
style={{ color: 'var(--jsm-accent)' }}
>
·
<span
className="inline-block h-1 w-1 rounded-full"
style={{ background: 'var(--jsm-accent)' }}
/>
outsourcing · software
</span>
<h1
className="text-4xl sm:text-5xl lg:text-[3.5rem] font-bold leading-[1.2] break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
className="mt-6 font-extrabold break-keep"
style={{
color: 'var(--jsm-ink)',
fontSize: 'clamp(2.4rem, 7vw, 4rem)',
lineHeight: 1.08,
letterSpacing: '-0.035em',
}}
>
,
<br className="hidden sm:block" /> {' '}
<span style={{ color: 'var(--jsm-accent)' }}> .</span>
<br />
<span style={{ color: 'var(--jsm-accent)' }}>.</span>
</h1>
<p
className="mt-7 text-lg lg:text-xl leading-relaxed break-keep max-w-2xl"
className="mt-7 max-w-xl break-keep text-lg leading-relaxed"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
7 ··.
.
24 . ,
.
</p>
<div className="mt-10 flex flex-col sm:flex-row gap-3">
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
<Link
href="/outsourcing#contact"
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold text-white transition-colors duration-150"
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 font-semibold text-white transition-colors duration-200 hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
<Link
href="/products"
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold border transition-colors duration-150 hover:bg-[var(--jsm-surface-alt)]"
className="inline-flex items-center justify-center gap-2 rounded-lg border px-6 py-3.5 font-semibold transition-colors duration-200 hover:bg-[var(--jsm-surface-alt)]"
style={{
color: 'var(--jsm-ink)',
borderColor: 'var(--jsm-line)',
background: 'var(--jsm-surface)',
...KOR_BODY,
}}
>
@@ -131,321 +153,364 @@ export default async function Home() {
</Link>
</div>
</div>
</div>
</section>
{/* ─── 2. 2축 서비스 ─── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="grid md:grid-cols-2 gap-6">
{/* 외주 개발 */}
<Link
href="/outsourcing"
className="group block rounded-2xl p-9 lg:p-11 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: 'var(--jsm-accent)' }}
>
Custom
</span>
<h2
className="mt-3 text-2xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
<p
className="mt-3 leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
· . , API, ,
.
</p>
<span
className="mt-6 inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</span>
</Link>
{/* 완성 소프트웨어 */}
<Link
href="/products"
className="group block rounded-2xl p-9 lg:p-11 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: 'var(--jsm-accent)' }}
>
Ready-made
</span>
<h2
className="mt-3 text-2xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
<p
className="mt-3 leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
.
.
</p>
<span
className="mt-6 inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</span>
</Link>
{/* 우 — 제품 목업 (유일한 강조면) */}
<div className="lg:pl-4">
<MockWindow title="stock-report.app">
<DashboardMock />
</MockWindow>
</div>
</div>
</section>
{/* ─── 3. 개발 프로세스 ─── */}
<section id="process" style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="max-w-2xl">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
Process
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
,
</h2>
</div>
<div className="mt-12 grid sm:grid-cols-2 lg:grid-cols-4 gap-px rounded-2xl overflow-hidden border" style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-line)' }}>
{PROCESS.map((s) => (
<div key={s.n} className="p-7 lg:p-8" style={{ background: 'var(--jsm-surface)' }}>
{/* 신뢰 스트립 */}
<div className="mx-auto max-w-6xl px-6 pb-16 lg:px-8 lg:pb-20">
<div
className="grid grid-cols-1 gap-px overflow-hidden rounded-2xl border sm:grid-cols-3"
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-line)' }}
>
{TRUST.map((s) => (
<div
key={s.v}
className="flex items-baseline gap-3 px-6 py-5"
style={{ background: 'var(--jsm-surface)' }}
>
<span
className="text-sm font-bold"
style={{ color: 'var(--jsm-accent)', fontFamily: 'monospace' }}
>
{s.n}
</span>
<h3
className="mt-4 text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{s.t}
</h3>
<p
className="mt-2 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{s.d}
</p>
</div>
))}
</div>
</div>
</section>
{/* ─── 4. 신뢰 요소 ─── */}
<section style={{ background: 'var(--jsm-navy)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-24">
<div className="grid sm:grid-cols-3 gap-10 sm:gap-8">
{STATS.map((s) => (
<div key={s.l}>
<p
className="text-3xl lg:text-4xl font-bold text-white"
style={KOR_TIGHT}
className="text-2xl font-bold"
style={{ color: 'var(--jsm-accent)', letterSpacing: '-0.03em' }}
>
{s.v}
</p>
<p
className="mt-2 text-sm leading-relaxed break-keep text-white/60"
style={KOR_BODY}
>
{s.l}
</p>
</span>
<span className="break-keep text-sm" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{s.t}
</span>
</div>
))}
</div>
<div
className="mt-12 pt-8 border-t flex flex-wrap items-center gap-x-3 gap-y-2"
style={{ borderColor: 'rgba(255,255,255,0.1)' }}
>
<span className="text-xs uppercase tracking-wider text-white/40 mr-1">Stack</span>
{STACK.map((s) => (
<span
key={s}
className="text-sm text-white/80 px-3 py-1 rounded-full"
style={{ background: 'rgba(255,255,255,0.06)', ...KOR_BODY }}
>
{s}
</span>
))}
</div>
</div>
</section>
{/* ─── 5. 포트폴리오 하이라이트 ─── */}
<section id="portfolio" style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="max-w-2xl">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
Portfolio
</p>
{/* ─────────────────── 2. 2축 소개 ─────────────────── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>what we do</Eyebrow>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}
>
</h2>
<p
className="mt-4 leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. .
</p>
</div>
<div className="mt-12 grid md:grid-cols-3 gap-6">
{PORTFOLIO.map((p) => (
<div
key={p.t}
className="flex flex-col rounded-2xl p-7 border"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="self-start inline-flex items-center gap-1.5 text-[11px] font-semibold px-2.5 py-1 rounded-full mb-5"
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' }}
</ScrollReveal>
<div className="mt-12 grid gap-6 md:grid-cols-2">
{[
{
n: '01',
k: 'outsourcing',
t: '맞춤 외주 개발',
d: '웹 서비스·업무 자동화·API·봇·AI 연동까지. 기획부터 납품과 30일 하자보수까지 단독으로 책임집니다.',
href: '/outsourcing',
cta: '의뢰 시작',
},
{
n: '02',
k: 'software',
t: '완성 소프트웨어 구매',
d: '직접 운영하며 검증한 도구를 계좌이체로 가져가세요. 입금 확인 즉시 마이페이지에서 다운로드합니다.',
href: '/products',
cta: '제품 보기',
},
].map((a, i) => (
<ScrollReveal key={a.k} delay={i * 100}>
<Link
href={a.href}
className="group flex h-full flex-col rounded-2xl border p-8 transition-[transform,box-shadow,border-color] duration-300 hover:-translate-y-1 hover:border-[var(--jsm-accent)] hover:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.4)] lg:p-10"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ background: 'var(--jsm-accent)' }}
/>
·
</span>
<h3
className="text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{p.t}
</h3>
<p
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{p.d}
</p>
<div className="mt-5 flex flex-wrap gap-1.5">
{p.tags.map((tag) => (
<span
key={tag}
className="text-xs px-2.5 py-1 rounded"
style={{
color: 'var(--jsm-ink-soft)',
background: 'var(--jsm-surface-alt)',
...KOR_BODY,
}}
>
{tag}
</span>
))}
</div>
</div>
className="font-mono text-[11px] uppercase tracking-[0.18em]"
style={{ color: 'var(--jsm-accent)' }}
>
{a.n} · {a.k}
</span>
<h3
className="mt-4 break-keep text-xl font-bold lg:text-2xl"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{a.t}
</h3>
<p
className="mt-3 flex-1 break-keep leading-relaxed"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{a.d}
</p>
<span
className="mt-6 inline-flex items-center gap-1.5 font-semibold transition-transform duration-300 group-hover:translate-x-1"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
{a.cta}
<ArrowRight />
</span>
</Link>
</ScrollReveal>
))}
</div>
<div className="mt-10">
</div>
</section>
{/* ─────────────────── 3. SHOWCASE ─────────────────── */}
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>showcase</Eyebrow>
<h2
className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}
>
</h2>
</ScrollReveal>
<div className="mt-12">
<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="home" />
</div>
<div className="mt-10 flex justify-end">
<Link
href="/outsourcing#portfolio"
href="/outsourcing#showcase"
className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</div>
</div>
</section>
{/* ─── 6. 소프트웨어 진열 ─── */}
{/* Phase 2: products 테이블 기반 동적 진열. 0개이면 출시 준비 중 폴백. */}
<section style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
{hasProducts ? (
<>
<div className="flex items-end justify-between mb-10">
<div>
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
{/* ─────────────────── 4. 운영 실증 ─────────────────── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>in production</Eyebrow>
<h2
className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}
>
</h2>
<p
className="mt-4 max-w-xl break-keep leading-relaxed"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. .
</p>
</ScrollReveal>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{PROOF.map((p, i) => (
<ScrollReveal key={p.t} delay={i * 100}>
<div
className="flex h-full flex-col rounded-2xl border p-7"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="mb-5 inline-flex items-center gap-1.5 self-start rounded-full px-2.5 py-1 text-[11px] font-semibold"
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' }}
>
Software
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
<span className="h-1.5 w-1.5 rounded-full" style={{ background: 'var(--jsm-accent)' }} />
·
</span>
<h3
className="break-keep text-lg font-bold"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
{p.t}
</h3>
<p
className="mt-2.5 flex-1 break-keep text-sm leading-relaxed"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{p.d}
</p>
<div className="mt-5 flex flex-wrap gap-1.5">
{p.tags.map((tag) => (
<span
key={tag}
className="rounded px-2.5 py-1 text-xs"
style={{ color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)', ...KOR_BODY }}
>
{tag}
</span>
))}
</div>
</div>
<Link
href="/products"
className="hidden sm:inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 hover:text-[var(--jsm-accent-hover)] shrink-0"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</ScrollReveal>
))}
</div>
{/* 스탯 3종 — 카운트업 */}
<ScrollReveal className="mt-12">
<div
className="grid grid-cols-1 gap-px overflow-hidden rounded-2xl border sm:grid-cols-3"
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-line)' }}
>
<div className="px-8 py-10" style={{ background: 'var(--jsm-surface)' }}>
<p className="text-4xl font-bold lg:text-5xl" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
<CountUp to={15} suffix="+" />
</p>
<p className="mt-2 break-keep text-sm" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
</p>
</div>
<div className="grid md:grid-cols-3 gap-6">
{featuredProducts.map((p) => (
<Link
key={p.id}
href={`/products/${p.id}`}
className="group flex flex-col rounded-2xl p-7 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
<div className="px-8 py-10" style={{ background: 'var(--jsm-surface)' }}>
<p className="text-4xl font-bold lg:text-5xl" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
24/7
</p>
<p className="mt-2 break-keep text-sm" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
</p>
</div>
<div className="px-8 py-10" style={{ background: 'var(--jsm-surface)' }}>
<p className="text-4xl font-bold lg:text-5xl" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
</p>
<p className="mt-2 break-keep text-sm" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
</p>
</div>
</div>
</ScrollReveal>
</div>
</section>
{/* ─────────────────── 5. PROCESS ─────────────────── */}
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>process</Eyebrow>
<h2
className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}
>
,
</h2>
</ScrollReveal>
<div className="relative mt-12">
{/* 단계 연결선 (데스크톱) */}
<span
aria-hidden
className="absolute left-[12%] right-[12%] top-7 hidden h-px lg:block"
style={{ background: 'var(--jsm-line)' }}
/>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{PROCESS.map((s, i) => (
<ScrollReveal key={s.n} delay={i * 100}>
<div
className="relative h-full rounded-2xl border p-7 lg:p-8"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="relative z-10 inline-flex h-14 w-14 items-center justify-center rounded-full font-mono text-sm font-bold"
style={{
color: 'var(--jsm-accent)',
background: 'var(--jsm-surface)',
boxShadow: 'inset 0 0 0 1px var(--jsm-line)',
}}
>
{s.n}
</span>
<h3
className="text-lg font-bold break-keep"
className="mt-5 break-keep text-lg font-bold"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{p.name}
{s.t}
</h3>
{p.description && (
<p
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{p.description}
</p>
)}
<div
className="mt-6 pt-5 flex items-center justify-between border-t"
style={{ borderColor: 'var(--jsm-line)' }}
<p
className="mt-2 break-keep text-sm leading-relaxed"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
<span
className="text-lg font-bold"
{s.d}
</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</div>
</section>
{/* ─────────────────── 6. 완성 SW + CTA ─────────────────── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
{hasProducts ? (
<>
<ScrollReveal>
<div className="flex items-end justify-between">
<div>
<Eyebrow>software</Eyebrow>
<h2
className="break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}
>
</h2>
</div>
<Link
href="/products"
className="hidden shrink-0 items-center gap-1.5 text-sm font-semibold transition-colors duration-150 hover:text-[var(--jsm-accent-hover)] sm:inline-flex"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</div>
</ScrollReveal>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{featuredProducts.map((p, i) => (
<ScrollReveal key={p.id} delay={i * 100}>
<Link
href={`/products/${p.id}`}
className="group flex h-full flex-col rounded-2xl border p-7 transition-[transform,box-shadow,border-color] duration-300 hover:-translate-y-1 hover:border-[var(--jsm-accent)] hover:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.4)]"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<h3
className="break-keep text-lg font-bold"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
&#8361;{p.price.toLocaleString('ko-KR')}
</span>
<span
className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
{p.name}
</h3>
{p.description && (
<p
className="mt-2.5 flex-1 break-keep text-sm leading-relaxed"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{p.description}
</p>
)}
<div
className="mt-6 flex items-center justify-between border-t pt-5"
style={{ borderColor: 'var(--jsm-line)' }}
>
<ArrowRight />
</span>
</div>
</Link>
<span
className="text-lg font-bold"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
&#8361;{p.price.toLocaleString('ko-KR')}
</span>
<span
className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</span>
</div>
</Link>
</ScrollReveal>
))}
</div>
<div className="mt-8 sm:hidden">
@@ -460,71 +525,67 @@ export default async function Home() {
</div>
</>
) : (
<div
className="rounded-2xl border px-8 py-14 lg:px-14 lg:py-16 text-center"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
<ScrollReveal>
<div
className="rounded-2xl border px-8 py-14 text-center lg:px-14 lg:py-16"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
Coming soon
</p>
<h2
className="text-2xl lg:text-3xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
<p
className="mt-4 max-w-xl mx-auto leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
.
.
</p>
<Link
href="/outsourcing#contact"
className="mt-8 inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold border transition-colors duration-150 hover:bg-[var(--jsm-surface-alt)]"
style={{
color: 'var(--jsm-ink)',
borderColor: 'var(--jsm-line)',
...KOR_BODY,
}}
>
<ArrowRight />
</Link>
</div>
<Eyebrow>coming soon</Eyebrow>
<h2
className="break-keep text-2xl font-bold lg:text-3xl"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
<p
className="mx-auto mt-4 max-w-xl break-keep leading-relaxed"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
.
.
</p>
<Link
href="/outsourcing#contact"
className="mt-8 inline-flex items-center justify-center gap-2 rounded-lg border px-6 py-3.5 font-semibold transition-colors duration-200 hover:bg-[var(--jsm-surface-alt)]"
style={{ color: 'var(--jsm-ink)', borderColor: 'var(--jsm-line)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</div>
</ScrollReveal>
)}
</div>
</section>
{/* ─── 7. 최종 CTA ─── */}
<section style={{ background: 'var(--jsm-navy)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-24 lg:py-28">
<div className="max-w-3xl">
<h2
className="text-3xl lg:text-[2.5rem] font-bold leading-tight text-white break-keep"
style={KOR_TIGHT}
{/* 최종 CTA 밴드 — 평면 navy (사이트 유일 다크면) */}
<ScrollReveal className="mt-20 lg:mt-28">
<div
className="rounded-3xl px-8 py-16 lg:px-16 lg:py-20"
style={{ background: 'var(--jsm-navy)' }}
>
,
</h2>
<p
className="mt-5 text-lg leading-relaxed text-white/70 break-keep max-w-2xl"
style={KOR_BODY}
>
. .
</p>
<Link
href="/outsourcing#contact"
className="mt-9 inline-flex items-center justify-center gap-2 px-7 py-4 rounded-lg font-semibold text-white transition-colors duration-150"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</div>
<div className="max-w-3xl">
<h2
className="break-keep text-3xl font-bold leading-tight text-white lg:text-[2.5rem]"
style={KOR_TIGHT}
>
,
</h2>
<p
className="mt-5 max-w-2xl break-keep text-lg leading-relaxed text-white/70"
style={KOR_BODY}
>
. .
</p>
<Link
href="/outsourcing#contact"
className="mt-9 inline-flex items-center justify-center gap-2 rounded-lg bg-white px-7 py-4 font-semibold transition-transform duration-200 hover:translate-y-[-1px]"
style={{ color: 'var(--jsm-navy)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</div>
</div>
</ScrollReveal>
</div>
</section>
</>

View File

@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
import { createAdminClient } from '@/lib/supabase/admin';
import { getListedProducts, type ProductRow } from '@/lib/supabase/product-files';
// 완성 소프트웨어 동적 카탈로그 (서버 컴포넌트).
// 완성 소프트웨어 동적 카탈로그 (서버 컴포넌트). 라이트 고craft — 홈·외주와 동일 언어.
// DB 장애·마이그레이션 미적용 시 빈 배열로 폴백해 페이지가 항상 200으로 생존한다.
export const metadata: Metadata = {
@@ -12,7 +12,6 @@ export const metadata: Metadata = {
'쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어 목록. 계좌이체 결제 후 입금 확인 즉시 마이페이지에서 다운로드할 수 있습니다.',
};
// 카탈로그는 항상 최신 상품을 보여주도록 동적 렌더링.
export const dynamic = 'force-dynamic';
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
@@ -26,17 +25,7 @@ const HOW = [
function ArrowRight() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M5 12h14" />
<path d="m13 5 7 7-7 7" />
</svg>
@@ -45,18 +34,7 @@ function ArrowRight() {
function CheckMark() {
return (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 mt-0.5"
aria-hidden
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="mt-0.5 shrink-0" aria-hidden>
<path d="M20 6 9 17l-5-5" />
</svg>
);
@@ -66,7 +44,6 @@ async function loadProducts(): Promise<ProductRow[]> {
try {
return await getListedProducts(createAdminClient());
} catch (err) {
// DB 장애·컬럼 미존재(마이그레이션 미적용) 등 — 페이지는 준비 중 폴백으로 생존
console.error('[Products] getListedProducts failed, falling back to empty:', err);
return [];
}
@@ -79,31 +56,23 @@ export default async function ProductsPage() {
return (
<>
{/* ─── Hero ─── */}
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 pt-20 pb-16 lg:px-8 lg:pt-28 lg:pb-20">
<div className="max-w-2xl">
<span
className="inline-block text-xs font-semibold mb-6 px-2.5 py-1 rounded"
style={{
color: 'var(--jsm-accent)',
background: 'var(--jsm-accent-soft)',
...KOR_BODY,
}}
>
<span className="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
<span className="inline-block h-1 w-1 rounded-full" style={{ background: 'var(--jsm-accent)' }} />
software
</span>
<h1
className="text-3xl sm:text-4xl lg:text-5xl font-bold leading-[1.2] break-keep mb-5"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
className="mt-6 font-extrabold break-keep"
style={{ color: 'var(--jsm-ink)', fontSize: 'clamp(2.3rem, 6vw, 3.6rem)', lineHeight: 1.1, letterSpacing: '-0.035em' }}
>
<br />
.
<span style={{ color: 'var(--jsm-accent)' }}>.</span>
</h1>
<p
className="text-base sm:text-lg leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
<p className="mt-7 break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
.
</p>
</div>
@@ -111,30 +80,24 @@ export default async function ProductsPage() {
</section>
{/* ─── 카탈로그 / 준비 중 ─── */}
{hasProducts ? (
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-24">
{hasProducts ? (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{products.map((p) => {
const features = (p.features ?? []).slice(0, 3);
return (
<Link
key={p.id}
href={`/products/${p.id}`}
className="group flex flex-col rounded-2xl p-7 lg:p-8 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
className="group flex flex-col rounded-2xl border p-7 transition-[transform,box-shadow,border-color] duration-300 hover:-translate-y-1 hover:border-[var(--jsm-accent)] hover:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.4)] lg:p-8"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<h2
className="text-xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
<h2 className="break-keep text-xl font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{p.name}
</h2>
{p.description && (
<p
className="mt-2.5 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
<p className="mt-2.5 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{p.description}
</p>
)}
@@ -142,11 +105,7 @@ export default async function ProductsPage() {
{features.length > 0 && (
<ul className="mt-5 space-y-2">
{features.map((f) => (
<li
key={f}
className="flex items-start gap-2 text-sm break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
<li key={f} className="flex items-start gap-2 break-keep text-sm" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
<span style={{ color: 'var(--jsm-accent)' }}>
<CheckMark />
</span>
@@ -156,17 +115,11 @@ export default async function ProductsPage() {
</ul>
)}
<div className="mt-6 pt-5 flex items-center justify-between border-t" style={{ borderColor: 'var(--jsm-line)' }}>
<span
className="text-lg font-bold"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{p.price.toLocaleString('ko-KR')}
<div className="mt-6 flex items-center justify-between border-t pt-5" style={{ borderColor: 'var(--jsm-line)' }}>
<span className="text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
&#8361;{p.price.toLocaleString('ko-KR')}
</span>
<span
className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<span className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]" style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}>
<ArrowRight />
</span>
@@ -175,75 +128,46 @@ export default async function ProductsPage() {
);
})}
</div>
</div>
</section>
) : (
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
<div
className="rounded-lg border p-8 text-center"
style={{
background: 'var(--jsm-surface-alt)',
borderColor: 'var(--jsm-line)',
}}
>
<p
className="text-sm font-semibold mb-3"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
) : (
<div className="rounded-2xl border px-8 py-14 text-center lg:py-16" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<p className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
coming soon
</p>
<p
className="text-xl sm:text-2xl font-bold mb-4 break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
.
</p>
<p
className="text-sm sm:text-base leading-relaxed break-keep max-w-md mx-auto"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
,
.
.
<h2 className="break-keep text-2xl font-bold lg:text-3xl" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
</h2>
<p className="mx-auto mt-4 max-w-md break-keep leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
,
.
.
</p>
</div>
</div>
</section>
)}
)}
</div>
</section>
{/* ─── 구매 방식 안내 ─── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
<h2
className="text-xl sm:text-2xl font-bold mb-10 break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-24">
<p className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
how to buy
</p>
<h2 className="break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
<div className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-3">
{HOW.map((step) => (
<div
key={step.n}
className="rounded-lg border p-6"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<div key={step.n} className="rounded-2xl border p-7" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<span
className="text-xs font-semibold mb-3 block"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
className="inline-flex h-12 w-12 items-center justify-center rounded-full font-mono text-sm font-bold"
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-surface)', boxShadow: 'inset 0 0 0 1px var(--jsm-line)' }}
>
{step.n}
</span>
<p
className="font-bold mb-2 break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
<p className="mt-5 break-keep font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{step.t}
</p>
<p
className="text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
<p className="mt-2 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{step.d}
</p>
</div>
@@ -253,29 +177,21 @@ export default async function ProductsPage() {
</section>
{/* ─── CTA ─── */}
<section className="border-t" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
<div className="flex flex-col sm:flex-row gap-4">
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-20">
<div className="flex flex-col gap-4 sm:flex-row">
<Link
href="/outsourcing#contact"
className="inline-flex items-center justify-center gap-2 px-6 py-3 rounded-lg text-sm font-semibold transition-colors"
style={{
background: 'var(--jsm-accent)',
color: '#ffffff',
...KOR_BODY,
}}
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
{hasProducts ? '맞춤 개발 문의' : '출시 소식 받기'}
<ArrowRight />
</Link>
<Link
href="/outsourcing"
className="inline-flex items-center justify-center gap-2 px-6 py-3 rounded-lg border text-sm font-semibold transition-colors"
style={{
borderColor: 'var(--jsm-line)',
color: 'var(--jsm-ink-soft)',
background: 'var(--jsm-surface)',
...KOR_BODY,
}}
className="inline-flex items-center justify-center gap-2 rounded-lg border px-6 py-3.5 text-sm font-semibold transition-colors hover:bg-[var(--jsm-surface)]"
style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink)', background: 'var(--jsm-surface)', ...KOR_BODY }}
>
</Link>

View File

@@ -0,0 +1,384 @@
# Deep Field 랜딩 경험 구현 계획
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **비주얼 태스크(4·5·7·8)는 구현 시 `designer` + `soft-skill` 스킬 로드 필수.**
**Goal:** 메인(/)·/outsourcing을 "Deep Field" 다크 캔버스로 재구성 — WebGL 커서 반응 히어로 + 몰입형 쇼케이스(주인공) + 스크롤 연출, 3단계 성능 폴백 내장.
**Architecture:** 다크 토큰 6종을 기존 jsm 체계에 추가(라이트 토큰 무수정). WebGL은 `app/components/deepfield/`에 격리된 클라이언트 경계 — 페이지는 서버 컴포넌트 유지, three.js는 dynamic import. 모드 판정(`full|lite|static`)은 순수 함수(`lib/deepfield-mode.ts`)로 TDD. 쇼케이스 데이터는 `lib/showcase.ts` 단일 소스(8슬롯, href 있는 슬롯만 클릭 가능).
**Tech Stack:** Next.js 16, three.js(코어만, dynamic import), Tailwind v4, vitest
**Spec:** `docs/superpowers/specs/2026-06-12-deep-field-landing-design.md`
**Branch:** `feature/deepfield-landing`
---
## 카피 절대 규칙 (전 태스크 공통)
"7년차", "대기업" 등 경력·소속 표현 **금지** — 신규 카피·metadata·jsonLd 전부. 신뢰 축은 "24시간 돌아가는 실서비스 15+를 직접 설계·운영" ([[feedback-copy-no-career-emphasis]]).
## 무수정 금지선 (전 태스크 공통)
OutsourcingRequestForm 로직·검증·API / products 동적 연동 로직(`loadFeaturedProducts`) / 라우팅·redirect / 거래·계정·admin 페이지 / TopNav auth 로직.
---
### Task 1: 기반 — three 설치 + 다크 토큰 + 쇼케이스 데이터
**Files:**
- Modify: `package.json` (`npm install three @types/three`)
- Modify: `app/globals.css` (다크 토큰 6종 추가 — 기존 토큰 무수정)
- Create: `lib/showcase.ts`
- [ ] **Step 1:** `npm install three` + `npm install -D @types/three`
- [ ] **Step 2:** `app/globals.css``:root`에 추가 (기존 jsm 라이트 토큰 아래):
```css
/* === Deep Field dark tokens (2026-06 랜딩 경험) — 라이트 토큰과 공존 === */
--jsm-dark-bg: #070d1a;
--jsm-dark-surface: rgba(255, 255, 255, 0.03);
--jsm-dark-line: rgba(148, 163, 184, 0.14);
--jsm-dark-ink: #f8fafc;
--jsm-dark-soft: #94a3b8;
--jsm-accent-bright: #60a5fa;
```
- [ ] **Step 3:** `lib/showcase.ts`:
```typescript
/** Deep Field 쇼케이스 8슬롯 — 단일 소스.
* href가 있는 슬롯만 클릭 가능 (샘플 리뉴얼 완료 시 href 추가). */
export interface ShowcaseSlot {
slug: string;
label: string; // 모노스페이스 컨셉 라벨 (영문)
title: string; // 카드 타이틀 (한글)
desc: string; // 한 줄 설명
palette: [string, string]; // 카드 고유 그래디언트 월드 [from, to]
accent: string; // 카드 포인트 컬러
href?: string; // 리뉴얼 완료된 샘플의 데모 링크
}
export const SHOWCASE_SLOTS: ShowcaseSlot[] = [
{ slug: 'corporate', label: 'corporate', title: '기업 브랜드 사이트', desc: '신뢰를 첫인상으로 — 브랜드 스토리와 IR까지', palette: ['#13203a', '#0d2c54'], accent: '#60a5fa' },
{ slug: 'shopping', label: 'commerce', title: '커머스 스토어', desc: '탐색부터 결제까지 끊김 없는 구매 동선', palette: ['#1a1430', '#341a4f'], accent: '#c4b5fd' },
{ slug: 'dashboard', label: 'dashboard', title: '데이터 대시보드', desc: '실시간 지표를 한눈에 — 의사결정용 화면', palette: ['#0f2922', '#14503c'], accent: '#6ee7b7' },
{ slug: 'bakery', label: 'local shop', title: '로컬 매장 사이트', desc: '예약·주문이 자연스러운 동네 가게의 얼굴', palette: ['#2b1a10', '#4f2d14'], accent: '#fdba74' },
{ slug: 'portfolio', label: 'portfolio', title: '포트폴리오', desc: '작업물이 주인공이 되는 미니멀 갤러리', palette: ['#101418', '#23272d'], accent: '#e2e8f0' },
{ slug: 'game', label: 'game', title: '게임 프로모션', desc: '세계관에 빠져들게 하는 런칭 페이지', palette: ['#250f23', '#4a1342'], accent: '#f0abfc' },
{ slug: 'interior', label: 'interior', title: '인테리어 스튜디오', desc: '공간의 톤을 그대로 옮긴 쇼룸', palette: ['#1f2218', '#3a4028'], accent: '#d9f99d' },
{ slug: 'reading', label: 'editorial', title: '에디토리얼·매거진', desc: '읽는 경험을 설계한 콘텐츠 사이트', palette: ['#101b2b', '#1f3a5f'], accent: '#93c5fd' },
];
```
(컨셉·팔레트는 기존 샘플 8종의 주제를 승계 — 각 샘플 page.tsx를 열어 주제가 맞는지 확인하고 어긋나면 title/desc만 조정)
- [ ] **Step 4:** `npm test`(10) + `npm run build` 통과
- [ ] **Step 5:** Commit — `feat(deepfield): three.js + 다크 토큰 + 쇼케이스 8슬롯 데이터`
---
### Task 2: 모드 판정 (TDD) + WebGL 지원 훅
**Files:**
- Create: `lib/deepfield-mode.ts`
- Test: `lib/__tests__/deepfield-mode.test.ts`
- Create: `app/components/deepfield/useFieldMode.ts`
- [ ] **Step 1: 실패 테스트**`lib/__tests__/deepfield-mode.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { decideFieldMode } from '@/lib/deepfield-mode';
const base = { reducedMotion: false, webglSupported: true, hardwareConcurrency: 8, viewportWidth: 1440 };
describe('decideFieldMode', () => {
it('데스크톱 + WebGL = full', () => {
expect(decideFieldMode(base)).toBe('full');
});
it('reduced-motion이면 무조건 static', () => {
expect(decideFieldMode({ ...base, reducedMotion: true })).toBe('static');
expect(decideFieldMode({ ...base, reducedMotion: true, viewportWidth: 375 })).toBe('static');
});
it('WebGL 미지원이면 static', () => {
expect(decideFieldMode({ ...base, webglSupported: false })).toBe('static');
});
it('모바일 뷰포트(<768)는 lite', () => {
expect(decideFieldMode({ ...base, viewportWidth: 767 })).toBe('lite');
});
it('저성능 코어(<4)는 lite', () => {
expect(decideFieldMode({ ...base, hardwareConcurrency: 2 })).toBe('lite');
});
it('hardwareConcurrency 미보고(0/undefined)는 lite로 보수적 판정', () => {
expect(decideFieldMode({ ...base, hardwareConcurrency: 0 })).toBe('lite');
});
});
```
- [ ] **Step 2:** `npm test` → FAIL 확인
- [ ] **Step 3: 구현**`lib/deepfield-mode.ts`:
```typescript
export type FieldMode = 'full' | 'lite' | 'static';
export interface FieldEnv {
reducedMotion: boolean;
webglSupported: boolean;
hardwareConcurrency: number; // 미보고 시 0
viewportWidth: number;
}
/** Deep Field 렌더 모드 판정 — 우선순위: 접근성 > 지원 여부 > 성능 */
export function decideFieldMode(env: FieldEnv): FieldMode {
if (env.reducedMotion) return 'static';
if (!env.webglSupported) return 'static';
if (env.viewportWidth < 768) return 'lite';
if (!env.hardwareConcurrency || env.hardwareConcurrency < 4) return 'lite';
return 'full';
}
```
- [ ] **Step 4:** `npm test` → 16 passed (기존 10 + 신규 6)
- [ ] **Step 5: 훅**`app/components/deepfield/useFieldMode.ts` ('use client'):
```typescript
'use client';
import { useEffect, useState } from 'react';
import { decideFieldMode, type FieldMode } from '@/lib/deepfield-mode';
function detectWebGL(): boolean {
try {
const canvas = document.createElement('canvas');
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
} catch {
return false;
}
}
/** SSR/첫 페인트는 'static'으로 시작 — 클라이언트에서 승격 (hydration 불일치 방지) */
export function useFieldMode(): FieldMode {
const [mode, setMode] = useState<FieldMode>('static');
useEffect(() => {
setMode(
decideFieldMode({
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
webglSupported: detectWebGL(),
hardwareConcurrency: navigator.hardwareConcurrency ?? 0,
viewportWidth: window.innerWidth,
}),
);
}, []);
return mode;
}
```
- [ ] **Step 6:** `npm run build` 통과 → Commit — `feat(deepfield): 렌더 모드 판정(TDD) + useFieldMode 훅`
---
### Task 3: `ScrollReveal` 공용 연출 컴포넌트
**Files:**
- Create: `app/components/deepfield/ScrollReveal.tsx`
- [ ] **Step 1:** 'use client' 컴포넌트 — IntersectionObserver 기반:
```tsx
'use client';
import { useEffect, useRef, useState } from 'react';
interface Props {
children: React.ReactNode;
/** 등장 지연(ms) — 연속 항목 스태거용 */
delay?: number;
/** 'fade-up'(기본) | 'fade' | 'draw'(선 그리기용 — width 확장) */
variant?: 'fade-up' | 'fade' | 'draw';
className?: string;
}
export default function ScrollReveal({ children, delay = 0, variant = 'fade-up', className }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [shown, setShown] = useState(false);
useEffect(() => {
// reduced-motion: 즉시 표시 (연출 생략)
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setShown(true);
return;
}
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setShown(true);
io.disconnect();
}
},
{ threshold: 0.2 },
);
io.observe(el);
return () => io.disconnect();
}, []);
const hidden =
variant === 'fade' ? 'opacity-0' :
variant === 'draw' ? 'opacity-0 [transform:scaleX(0)] origin-left' :
'opacity-0 translate-y-6';
return (
<div
ref={ref}
className={`${className ?? ''} transition-all duration-700 ease-out ${shown ? 'opacity-100 translate-y-0 [transform:none]' : hidden}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
```
- [ ] **Step 2:** `npm run build` 통과 → Commit — `feat(deepfield): ScrollReveal 스크롤 연출 컴포넌트`
---
### Task 4: `HeroField` — WebGL 커서 반응 파티클 필드
> **designer + soft-skill 로드 필수.** 가장 중요한 비주얼 태스크.
**Files:**
- Create: `app/components/deepfield/HeroField.tsx`
**요구 동작:**
- props: `{ className?: string }` — 히어로 섹션의 절대배치 배경 캔버스
- `useFieldMode()`로 모드 결정:
- **static**: 캔버스 미기동 — `--jsm-dark-bg` 위 정적 radial 그래디언트(accent 30~40% 불투명 2개 광원) div 렌더. 이것만으로도 완성된 비주얼이어야 함
- **lite**: 파티클 수 full의 1/4, 커서 반응 비활성(자동 드리프트만), DPR 1 고정
- **full**: 파티클 포인트 필드(2,000~4,000pt) — 커서 위치를 향해 자기장처럼 휘는 변위(셰이더 uniform으로 마우스 전달), 미세 드리프트, 스크롤 진행도(uniform)에 따라 필드가 흩어짐
- **three.js는 `await import('three')`로 dynamic import** — 모듈 상단 정적 import 금지
- 색: 파티클은 `#60a5fa`~`#1d4ed8` 범위, 배경은 투명(섹션 bg가 비침)
- 정리: 언마운트 시 renderer.dispose()+geometry/material dispose, `document.visibilityState` hidden 시 rAF 정지, IntersectionObserver로 화면 밖이면 정지
- 마우스 추적은 window 리스너(passive), rAF 내에서 lerp로 부드럽게
- 캔버스에 `aria-hidden="true"`, pointer-events 없음
- [ ] **Step 1:** 컴포넌트 구현 (위 3모드)
- [ ] **Step 2:** `npm run build` 통과 + 임시 검증: dev 서버에서 컴포넌트를 임시로 메인에 올리지 말고, Task 6에서 통합 검증 (이 태스크는 build·타입 통과까지)
- [ ] **Step 3:** Commit — `feat(deepfield): HeroField WebGL 파티클 필드 (full/lite/static)`
---
### Task 5: `ShowcaseGrid` + `ShowcaseCard`
> **designer + soft-skill 로드 필수.**
**Files:**
- Create: `app/components/deepfield/ShowcaseCard.tsx`
- Create: `app/components/deepfield/ShowcaseGrid.tsx`
**ShowcaseCard** — props `{ slot: ShowcaseSlot, size?: 'feature' | 'standard', index: number }`:
- 카드 비주얼 = 슬롯 palette 그래디언트 월드 + 절제된 제너러티브 패턴(슬롯별로 달라 보이게 — slug를 시드로 한 캔버스 2D 패턴: 격자/등고선/도트 등 2~3종 변형). WebGL 필수 아님 — **카드 타일은 Canvas2D로 충분** (성능·단순성). hover 시:
- full 모드: 타일이 미세 굴절(translate+scale 1.03)되고 패턴이 커서 방향으로 시차 이동 (CSS transform + mousemove 기반 — 카드당 WebGL 인스턴스 금지)
- lite/static: CSS 전환만 (border accent 점등 + 살짝 lift)
- 텍스트: 모노스페이스 label(accent 컬러) + 한글 title(굵게) + desc 1줄
- `slot.href` 있으면 `<Link>` 래핑 + "데모 보기 →" 표시 / 없으면 비클릭(커서 default, hover는 동일하게 동작 — "준비 중" 라벨 금지)
- `aria-label` = title
**ShowcaseGrid** — props `{ slots: ShowcaseSlot[], variant: 'home' | 'full' }`:
- `home`: 상위 6슬롯, 비대칭 그리드 — 1번 feature(2col), 2·3 standard, 4 feature, 5·6 standard (데스크톱 3col 기준 / 모바일 1col 스택). 각 카드는 `ScrollReveal`로 스태거 등장(delay = index*80)
- `full`: 8슬롯 전체, 2col 균등(모바일 1col)
- 서버에서 import 가능하도록 그리드 자체는 서버 컴포넌트, 카드만 'use client'
- [ ] **Step 1:** ShowcaseCard 구현 (Canvas2D 패턴 + hover)
- [ ] **Step 2:** ShowcaseGrid 구현
- [ ] **Step 3:** `npm run build` 통과 → Commit — `feat(deepfield): 쇼케이스 카드·그리드 (제너러티브 타일 + 호버 시차)`
---
### Task 6: TopNav route-aware 다크 모드
**Files:**
- Modify: `app/components/TopNav.tsx`
- [ ] **Step 1:** `usePathname()`으로 다크 페이지 판정:
```typescript
const DARK_ROUTES = ['/', '/outsourcing'];
const isDark = DARK_ROUTES.includes(pathname) || pathname.startsWith('/outsourcing/');
```
- 다크 페이지: 기본 투명 배경 + `--jsm-dark-ink` 텍스트, 스크롤 시 `rgba(7,13,26,0.85)` 배경 + `--jsm-dark-line` 하단 보더. 로고·링크·CTA 색상도 다크 팔레트(accent-bright 활성)
- 라이트 페이지: **기존 동작 그대로** (흰 배경 전환)
- 모바일 드로어: 다크 페이지에서는 다크 패널(`--jsm-dark-bg`), 라이트에서는 기존 흰 패널
- **auth 로직(getSession/onAuthStateChange/handleLogout)·접근성 속성(aria-expanded/Esc/dialog) 무수정**
- [ ] **Step 2:** `npm run build` + dev에서 `/products`(라이트)·`/`(다크 예정 — 아직 페이지는 라이트지만 네비만 다크 톤이 되는 과도기 OK, Task 7과 같은 PR이므로 순서상 문제 없음) 컴파일 확인
- [ ] **Step 3:** Commit — `feat(nav): 다크 라우트 인지형 네비게이션`
---
### Task 7: 메인(/) Deep Field 재조립 + 카피·메타 교체
> **designer + soft-skill 로드 필수.** 스펙 §2의 5섹션 구조.
**Files:**
- Modify: `app/page.tsx` (전면 재구성 — products 동적 로직 `loadFeaturedProducts`는 그대로 이식)
- Modify: `app/layout.tsx` (metadata description·jsonLd에서 경력 표현 제거 — 구조 무수정)
- Modify: `app/components/PublicShell.tsx` (main 배경이 페이지별로 다크/라이트 — main의 고정 `--jsm-bg` 인라인 배경을 제거하고 페이지가 자기 배경을 그리도록, 또는 route-aware. 푸터·KakaoFloatButton 무수정)
**섹션 구성 (승인된 목업 기준):**
1. **HERO** — min-h-[100svh] 풀스크린. `HeroField` 배경 + 거대 타이포: "생각을\n동작하는 소프트웨어로." (디자인 스킬로 다듬기 허용 — 단 경력 표현 금지). 서브 1줄: "24시간 돌아가는 실서비스를 직접 설계하고 운영합니다. 외주 개발도, 완성 소프트웨어도 — 같은 손으로." CTA 2개([프로젝트 문의 → /outsourcing#contact] accent 솔리드 / [소프트웨어 보기 → /products] 다크 고스트). 하단 스크롤 큐(미세 바운스 화살표)
2. **SHOWCASE** — "이런 걸 만들어 드립니다" + `<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="home" />` + [전체 레퍼런스 → /outsourcing#showcase]
3. **PROCESS** — 4단계(기존 카피 유지: 상담→견적 2일→주1회 공유→납품+30일 하자보수), ScrollReveal `draw`로 연결선 + 스태거 점등
4. **PROOF** — 운영 시스템 3종 카드(주식 자동매매/청약 자동 매칭/AI 콘텐츠 파이프라인 — 기존 카피 재사용 가능) + 스탯: "실서비스 15+" "24/7 무중단 운영" "기획→배포 원스톱" (스크롤 진입 시 카운트업은 ScrollReveal + 간단한 useEffect 카운터, reduced-motion 시 즉시 최종값)
5. **SOFTWARE + CTA**`loadFeaturedProducts` 동적 연동 그대로(라이트 카드가 다크 위에 뜨는 대비), 빈 상태 폴백 유지. 최종 CTA 밴드(accent)
- metadata: title 유지, description → "24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어." / jsonLd Person·LocalBusiness description에서 "7년차" 제거, jobTitle "소프트웨어 엔지니어"로
- 전체 페이지 배경 `--jsm-dark-bg`, 텍스트 다크 토큰. 가드레일: gradient는 **Deep Field 광원 표현에 한해 radial 그래디언트 허용**(다크 캔버스의 일부 — 기존 "그래디언트 금지"의 의도는 generic AI 보라 그라데이션 차단이었음), 보라 금지 유지(쇼케이스 palette의 컨셉 컬러는 예외 — 카드 월드 한정), blur 금지, 이모지 금지
- [ ] **Step 1:** 페이지 재조립 + 카피 교체
- [ ] **Step 2:** `npm run build` + dev: `/` 200, "7년차"·"대기업" grep 0건(app/page.tsx·app/layout.tsx), products 폴백 동작
- [ ] **Step 3:** Commit — `feat(home): Deep Field 다크 캔버스 재조립 + 운영 실증 카피`
---
### Task 8: /outsourcing Deep Field 재스킨
> **designer + soft-skill 로드 필수.**
**Files:**
- Modify: `app/outsourcing/page.tsx`
- Modify(스타일만): `app/components/OutsourcingRequestForm.tsx`
- [ ] **Step 1:** 페이지를 다크 토큰으로 재스킨:
- Hero 축약(타이포+간단 필드 배경 — HeroField 재사용 가능, 높이 60vh)
- `#showcase` 섹션 신설: `<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="full" />` — 기존 #portfolio 위치에 배치하고 `id="showcase"``id="portfolio"` 모두 도달 가능하게(섹션에 showcase, 내부 앵커 div에 portfolio)
- 기존 실사례 6건(운영 시스템)은 PROOF 스타일 카드로 유지
- 제공 분야·프로세스·FAQ를 다크 카드로 재스킨 (카피 무수정)
- `#contact` 의뢰 폼: OutsourcingRequestForm을 다크 스킨으로 — **INPUT_STYLE 상수·카드 배경 등 스타일 값만 변경, 로직·검증·API·단계 구조 무수정** (goNext 스테일 클로저 경고 주석 보존)
- [ ] **Step 2:** `npm run build` + dev: `/outsourcing` 200, 앵커 3+1종(process/portfolio/showcase/contact) 존재, 폼 1단계 카드 렌더
- [ ] **Step 3:** Commit — `feat(outsourcing): Deep Field 재스킨 + 쇼케이스 풀 그리드`
---
### Task 9: E2E + 성능 검증
- [ ] **Step 1: 자동**`npm test`(16) + `npm run build` + prod 서버 curl:
- `/` 200 + 새 히어로 카피 존재 + "7년차|대기업" 0건 / `/outsourcing` 200 + id="showcase" / 폼 마크업 존재
- 회귀: `/products` 200(라이트 유지), `/work/saju` 404, `/music/packs` 308, POST `/api/contact` 빈 body 400, `/api/orders` 401, `/track/x` 404
- 번들 확인: `.next` 빌드 출력에서 `/` 페이지 First Load JS — three.js가 별도 청크인지(메인 First Load에 포함 안 됨), 합계가 과도하지 않은지 보고
- [ ] **Step 2: 수동 체크리스트 (CEO + 컨트롤러)**
- 데스크톱: 히어로 커서 반응·쇼케이스 hover 시차·스크롤 연출·카운터
- 모바일 375px: lite 모드(드리프트만), 레이아웃
- DevTools에서 prefers-reduced-motion 에뮬레이션 → 정적 폴백이 그 자체로 완성돼 보이는지
- 탭 비활성 시 CPU 사용 0 근접 확인
- 의뢰 폼 4단계 제출 회귀 1회
- [ ] **Step 3:** 최종 보고
---
## 후속 (별도 스펙·플랜)
샘플 8종 Deep Field 컨셉 리뉴얼 — 2개씩 4회차, 완료 슬롯마다 `lib/showcase.ts`에 href 추가로 활성화.

View File

@@ -0,0 +1,235 @@
# 쟁승메이드 라이트 고craft 재설계 — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 홈·외주·제품 3면을 라이트 `--jsm-*` 단일 시스템으로 통일하고, 히어로·쇼케이스를 코드 UI 목업(MockWindow)으로 재구성한다.
**Architecture:** 파티클(HeroField)·다크 토큰을 폐기하고, 재사용 가능한 라이트 `MockWindow` 목업 시스템을 craft의 핵심 비주얼로 삼는다. 3면이 동일한 컨테이너·타입 스케일·여백 리듬·카드 스펙을 공유한다. TopNav의 다크 라우트 분기를 제거해 전 페이지 단일 라이트 셸로 통일한다.
**Tech Stack:** Next.js 16 (App Router, 서버 컴포넌트 우선), TypeScript, Tailwind v4, Pretendard, vitest.
설계 문서: `docs/superpowers/specs/2026-06-30-jsm-light-redesign-design.md`
## Global Constraints
- 색: `--jsm-*` 라이트 토큰만. **금지**`--jsm-dark-*`, `--kx-*`, 보라/violet, gradient, blur, 이모지.
- navy(`--jsm-navy`)는 푸터 + 홈 CTA 밴드 **2곳에서만** (평면, radial 없음).
- 컨테이너: `max-w-6xl mx-auto px-6 lg:px-8` (3면 동일).
- 한글: 헤딩·본문 `break-keep`. `KOR_TIGHT = letterSpacing -0.02em`, `KOR_BODY = -0.01em`.
- 타이포: h1 `clamp(2.4rem,7vw,4rem)` w800 -0.03em / h2 `clamp(1.7rem,4vw,2.4rem)` w700 -0.02em / eyebrow 11px UPPER 0.2em accent / 본문 1618px ink-soft.
- 카피: 경력 어필("대기업 7년차" 류) 금지 → 운영 실증 표현 유지.
- 모션: `ScrollReveal`·`.reveal` CSS 유지, `prefers-reduced-motion` 가드.
- 각 Task 종료 시 `npm run build` 통과 + 커밋. 브랜치 `redesign/jsm-light-craft` (생성됨).
- 빌드 명령(Windows): `npm run build`. 테스트: `npm test`.
> **계획 altitude 주석:** 본 계획은 *재사용 빌딩블록*(MockWindow API·showcase 타입·테스트)은 완전한 코드로, *페이지 재작성*은 섹션 구조 + 정확한 토큰/클래스 규약 + 검증 게이트로 명세한다. 페이지 JSX 전문을 계획에 박지 않는 것은 의도된 결정이다(시각 레이아웃은 토큰·구조 제약으로 충분히 결정되며, 전문 박제는 중복·열화를 유발).
---
### Task 1: MockWindow 목업 시스템
**Files:**
- Create: `app/components/mock/MockWindow.tsx`
- Create: `app/components/mock/screens.tsx` (6 스크린 목업 한 파일 — 함께 변경되므로 동거)
- Create: `app/components/mock/registry.ts` (mock key → 컴포넌트 + 메타)
**Interfaces:**
- Produces:
- `MockWindow({ title, accent?, children, className? }): JSX` — 브라우저 크롬 프레임(● ● ● 신호등 + 타이틀바 + 본문 슬롯). 서버 컴포넌트. 라이트(surface) + navy 타이틀바 옵션.
- 스크린 컴포넌트(전부 서버, props 없음, 정적 마크업): `DashboardMock`, `FeedMock`, `MatchMock`, `CommerceMock`, `SiteMock`, `BookingMock`.
- `MOCK_REGISTRY: Record<MockKey, React.ComponentType>``type MockKey = 'dashboard'|'feed'|'match'|'commerce'|'site'|'booking'`.
**MockWindow 규약 (완전 코드):**
```tsx
// app/components/mock/MockWindow.tsx
interface MockWindowProps {
title: string; // 타이틀바 텍스트 (예: 'stock-report', 'realestate-match')
children: React.ReactNode;
className?: string;
}
export default function MockWindow({ title, children, className }: MockWindowProps) {
return (
<div
className={`overflow-hidden rounded-xl border shadow-[0_24px_60px_-30px_rgba(15,23,42,0.35)] ${className ?? ''}`}
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
{/* 타이틀바 */}
<div
className="flex items-center gap-2 px-3.5 py-2.5 border-b"
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<span className="flex gap-1.5" aria-hidden>
<span className="h-2.5 w-2.5 rounded-full" style={{ background: '#e2e8f0' }} />
<span className="h-2.5 w-2.5 rounded-full" style={{ background: '#e2e8f0' }} />
<span className="h-2.5 w-2.5 rounded-full" style={{ background: '#e2e8f0' }} />
</span>
<span
className="ml-1 font-mono text-[11px]"
style={{ color: 'var(--jsm-ink-faint)', letterSpacing: '-0.01em' }}
>
{title}
</span>
</div>
{/* 본문 */}
<div className="p-4">{children}</div>
</div>
);
}
```
**스크린 목업 시각 명세** (`screens.tsx` — 각 컴포넌트가 그릴 요소; 전부 `--jsm-*`, SVG/div, 실데이터 0):
- `DashboardMock` — 상단 스탯 3칸(라벨+숫자, 1칸 accent 강조) + 막대 차트(div 높이 배열) 1개. "주식 리포트" 톤.
- `FeedMock` — 메시지 버블 3~4개(좌측 정렬, 시각·텍스트·체결/알림 배지). "텔레그램 봇" 톤.
- `MatchMock` — 리스트 행 3개(항목명 + 매칭률 배지 `92%` accent-soft) + 상단 필터칩. "부동산 청약" 톤.
- `CommerceMock` — 상품 카드 그리드 4(썸네일 박스 + 가격) + 장바구니 바.
- `SiteMock` — 기업 사이트 와이어(네비 바 + 큰 헤드라인 라인 2 + CTA 버튼 + 카드 3). "corporate/portfolio".
- `BookingMock` — 주간 캘린더 헤더(요일 7) + 슬롯 그리드(일부 accent 채움) + 예약 버튼.
**Steps:**
- [ ] **Step 1:** `MockWindow.tsx` 작성 (위 완전 코드).
- [ ] **Step 2:** `screens.tsx`에 6개 스크린 컴포넌트 작성 (위 시각 명세 따름, 각 `<div className="space-y-3">...` 라이트 마크업).
- [ ] **Step 3:** `registry.ts` 작성 — `MockKey` 타입 + `MOCK_REGISTRY` 매핑 export.
- [ ] **Step 4:** 빌드 검증. Run: `npm run build` — Expected: 성공(타입 에러 0).
- [ ] **Step 5:** 커밋. `git add app/components/mock && git commit -m "feat(redesign): MockWindow 라이트 목업 시스템(프레임+6스크린+레지스트리)"`
---
### Task 2: 쇼케이스 라이트 전환
**Files:**
- Modify: `lib/showcase.ts` (슬롯 타입을 mock 기반으로 교체)
- Modify: `app/components/deepfield/ShowcaseCard.tsx` (그래디언트/캔버스 → MockWindow 라이트 카드 재작성)
- Keep: `app/components/deepfield/ShowcaseGrid.tsx` (레이아웃 로직 유지, 카드만 교체)
- Test: `lib/__tests__/showcase.test.ts` (신규 — 가드레일 데이터 테스트)
**Interfaces:**
- Consumes: Task 1의 `MockKey`, `MOCK_REGISTRY`.
- Produces: `ShowcaseSlot { slug; label; title; desc; mock: MockKey; href? }` (palette/accent 제거). `SHOWCASE_SLOTS: ShowcaseSlot[]` (8슬롯, 보라 0).
**신규 슬롯 매핑** (보라 제거, mock 배정):
```
corporate → site | commerce → commerce | dashboard → dashboard | bakery → booking
portfolio → site | game → site | interior → site | reading → site
```
> 메모: site 목업이 다수 → 시각 단조 방지 위해 `SiteMock`에 variant prop(헤드라인 색/레이아웃 미세 차이) 추가 가능(선택). 1차는 단일 SiteMock로 진행, Task 7 검증 시 단조하면 variant 보강.
**Steps:**
- [ ] **Step 1 (테스트 먼저):** `lib/__tests__/showcase.test.ts` 작성 — 각 슬롯이 (a) `mock`이 유효한 MockKey, (b) `slug/title/desc` 비어있지 않음, (c) 어떤 필드에도 보라 hex(`#c4b5fd`,`#f0abfc`,`#341a4f`,`#4a1342`) 부재. (palette 필드 자체가 사라지므로 타입+값 검증.)
- [ ] **Step 2:** Run `npm test` — Expected: FAIL (showcase 타입에 mock 없음 / palette 잔존).
- [ ] **Step 3:** `lib/showcase.ts` 인터페이스·데이터를 mock 기반으로 교체.
- [ ] **Step 4:** `ShowcaseCard.tsx` 재작성 — 카드 = `MockWindow`(상단) + 하단 텍스트(eyebrow label·title·desc, href면 "데모 보기"). 캔버스/시드/그래디언트/보라 전량 제거. 라이트 카드. `'use client'` 불필요면 서버 컴포넌트로.
- [ ] **Step 5:** Run `npm test` — Expected: PASS. 이어서 `npm run build` — Expected: 성공.
- [ ] **Step 6:** 커밋. `git commit -am "feat(redesign): 쇼케이스 그래디언트 타일 → 라이트 MockWindow 카드 + 가드레일 테스트"`
---
### Task 3: TopNav 라이트 단일화
**Files:**
- Modify: `app/components/TopNav.tsx`
**Interfaces:**
- Consumes: 없음. Produces: 단일 라이트 네비(전 라우트 동일).
**변경:**
- `DARK_ROUTES`/`isDark` 분기 + 다크 팔레트 헬퍼(`ink/inkSoft/surface/line/accent/accentBg`의 isDark 삼항) 전량 제거 → 라이트 고정값.
- 최상단(미스크롤): 배경 transparent 유지(라이트 히어로 위 dark ink 텍스트로 가독) / 스크롤 시: `--jsm-surface` + `--jsm-line` border + 미세 shadow.
- 모바일 드로어 `surface` = `--jsm-surface` 고정.
**Steps:**
- [ ] **Step 1:** `isDark` 및 다크 분기 제거, 팔레트를 라이트 토큰 고정으로 치환.
- [ ] **Step 2:** Run `npm run build` — Expected: 성공.
- [ ] **Step 3:** 커밋. `git commit -am "feat(redesign): TopNav 다크 라우트 분기 제거 → 단일 라이트 네비"`
---
### Task 4: 홈 라이트 재구성 (`app/page.tsx`)
**Files:**
- Modify: `app/page.tsx` (전면 재작성)
**Interfaces:**
- Consumes: Task 1 `MockWindow`+스크린, Task 2 `ShowcaseGrid`/`SHOWCASE_SLOTS`, 기존 `getListedProducts`·`CountUp`·`ScrollReveal`.
**섹션 구조(배경 교차):**
1. HERO (surface) — 비대칭 2단: 좌(eyebrow `OUTSOURCING · SOFTWARE` / h1 "생각을 / 동작하는 소프트웨어로." / sub / CTA 2개: filled accent `프로젝트 문의``/outsourcing#contact`, ghost `소프트웨어 보기``/products`) · 우(`MockWindow title="stock-report"` 안에 `DashboardMock`). `-mt-16`/스크림/HeroField 전량 제거. 하단 신뢰 스트립(15+ 실서비스 · 24/7 · 원스톱) border-y row.
2. 2축 소개 (surface-alt) — `01 OUTSOURCING`/`02 SOFTWARE` 2카드(라벨·제목·요약·링크).
3. SHOWCASE (surface) — `ShowcaseGrid slots variant="home"` (6).
4. 운영 실증 (surface-alt) — PROOF 3카드 + 스탯(CountUp 15+/24·7/원스톱). 라이트 카드.
5. PROCESS (surface) — 4단계 + 가로 연결선.
6. 완성 SW (surface-alt) — featured 3(DB) / 0개 coming-soon 폴백, 라이트 카드.
7. CTA 밴드 (navy 평면) — "프로젝트, 이야기부터 시작하세요" + 흰 버튼.
**Steps:**
- [ ] **Step 1:** 다크 래퍼/HeroField/스크림 제거, 위 7섹션을 라이트 토큰으로 재작성. 모든 `--jsm-dark-*`/`accent-bright` → 라이트 대응(`--jsm-ink`/`ink-soft`/`accent`).
- [ ] **Step 2:** Run `npm run build` — Expected: 성공. (DB 0개 폴백 경로도 타입 통과 확인.)
- [ ] **Step 3:** 커밋. `git commit -am "feat(redesign): 홈 라이트 재구성 + 2축 복원 + 히어로 목업"`
---
### Task 5: 외주 라이트 전환 (`app/outsourcing/page.tsx` + 폼)
**Files:**
- Modify: `app/outsourcing/page.tsx`
- Modify: `app/components/OutsourcingRequestForm.tsx`
**Interfaces:**
- Consumes: Task 1·2 컴포넌트, 기존 `ScrollReveal`.
**변경:**
- 페이지: 다크 래퍼/HeroField/스크림 제거. 섹션 구조 유지(HERO·SHOWCASE 8·운영 실사례 6·제공분야 6·PROCESS 6·FAQ·CONTACT)를 라이트 토큰으로. 앵커(`#showcase`/`#portfolio`/`#process`/`#contact`) 유지. HERO 우측에 소형 `MockWindow`(`FeedMock` 등) 1개 추가(선택, 2단 비대칭).
- 폼: `INPUT_STYLE`·각 `--jsm-dark-*`/`accent-bright`/`rgba(96,165,250,..)` → 라이트(`--jsm-surface`/`--jsm-line`/`--jsm-ink`/`--jsm-accent`/`--jsm-accent-soft`). 래퍼 `className="jsm-dark-form"` 제거. 에러 박스(이미 라이트 `#fef2f2`)는 유지.
**Steps:**
- [ ] **Step 1:** `OutsourcingRequestForm.tsx`의 다크 토큰 전량 라이트 치환 + `jsm-dark-form` 제거.
- [ ] **Step 2:** `outsourcing/page.tsx` 라이트 재작성(구조 유지).
- [ ] **Step 3:** Run `npm run build` — Expected: 성공.
- [ ] **Step 4:** 커밋. `git commit -am "feat(redesign): 외주 페이지 + 의뢰폼 라이트 전환"`
---
### Task 6: 제품 craft 정렬 (`app/products/page.tsx`)
**Files:**
- Modify: `app/products/page.tsx`
**변경:** 이미 라이트 → `max-w-5xl``max-w-6xl`, 타입 스케일(h1 clamp·eyebrow·h2)·여백 리듬·카드(rounded-2xl·shadow-sm·hover) 를 홈과 동일 언어로 정렬. 교차 배경(surface↔surface-alt) 적용. 구조·카피 유지.
**Steps:**
- [ ] **Step 1:** 컨테이너·타입·카드 스펙을 공통 언어로 정렬.
- [ ] **Step 2:** Run `npm run build` — Expected: 성공.
- [ ] **Step 3:** 커밋. `git commit -am "feat(redesign): 제품 페이지 craft 정렬(공통 언어)"`
---
### Task 7: 죽은 CSS 제거 + 전체 검증 + 문서 정리
**Files:**
- Modify: `app/globals.css`
- Modify: `CLAUDE.md` (다크 토큰 언급 정리 — 가드레일 본문 변경 없음)
**변경 (globals.css 제거 대상):** `--jsm-dark-*` 토큰, `--kx-*` 매핑, `.kx-section/.kx-display/.kx-label/.kx-folder/.kx-glass/.kx-glow/.kx-btn-*/.kx-gradient-text/.kx-orb`, `.gradient-text`(보라), `.jsm-dark-form`, `.df-scroll-dot` + `@keyframes df-scroll-cue`. **유지:** `--jsm-*` 라이트, `@font-face`, `.reveal*`, `.marquee*`(사용처 grep 후 미사용이면 제거), 스크롤바, `.scrollbar-hide`, `.service-card`.
**Steps:**
- [ ] **Step 1:** `HeroField`/`useFieldMode` import 잔존 grep — Run: `grep -rn "HeroField\|useFieldMode\|jsm-dark\|--kx-\|gradient-text" app lib` — Expected: 코드(컴포넌트 파일 제외)에서 0건. 잔존 시 해당 파일 수정.
- [ ] **Step 2:** `globals.css`에서 위 제거 대상 삭제.
- [ ] **Step 3:** 가드레일 grep — Run: `grep -rn "jsm-dark\|--kx-\|#7c3aed\|#c4b5fd\|#f0abfc\|backdrop-filter\|blur(" app lib` — Expected: 0건(`globals.css` `.kx`/dark 제거 후).
- [ ] **Step 4:** Run `npm test` — Expected: PASS. 이어서 `npm run build` — Expected: 성공.
- [ ] **Step 5:** `CLAUDE.md` 디자인 시스템 섹션에서 다크 토큰 잔재 언급 정리(있다면).
- [ ] **Step 6:** 커밋. `git commit -am "chore(redesign): 죽은 다크/kx/보라 CSS 제거 + 가드레일 검증 통과"`
---
## Self-Review
**Spec coverage:**
- §3 시스템 기반 → Global Constraints + 각 Task. ✓
- §4 MockWindow → Task 1. ✓
- §5.1 홈 → Task 4. ✓ / §5.2 외주 → Task 5. ✓ / §5.3 제품 → Task 6. ✓
- §6 셸(TopNav/Footer) → Task 3 (Footer는 이미 navy 유지, 변경 없음 명시). ✓
- §7 정리 → Task 7. ✓
- §9 검증 기준 → Task 2(테스트)·Task 7(grep/build/test). ✓
**Placeholder scan:** 페이지 JSX 전문 미기재는 의도(계획 altitude 주석). 스크린 목업은 시각 명세로 구체화. 빌딩블록(MockWindow)·테스트는 완전 코드. TBD 없음.
**Type consistency:** `MockKey`/`MOCK_REGISTRY`(Task1) → `ShowcaseSlot.mock`(Task2)에서 동일 사용. `ShowcaseGrid``variant`/`size`(home|full / feature|standard) 기존 시그니처 유지. ✓

View File

@@ -0,0 +1,85 @@
# "Deep Field" 랜딩 경험 — 메인·외주 다크 캔버스 + WebGL 쇼케이스
- **작성일**: 2026-06-12
- **상태**: CEO 승인 완료 (Visual Companion 세션으로 방향·구조 확정)
- **목표**: 고객이 "AI가 만든 디자인"으로 느끼지 않는 새로운 경험의 랜딩. phantom.land 류의 커서 반응형 WebGL·몰입형 쇼케이스·볼드 타이포·스크롤 연출을 메인(/)과 /outsourcing에 적용하고, 외주 레퍼런스 쇼케이스를 페이지의 주인공으로.
---
## 0. 확정된 결정 (CEO)
| 항목 | 결정 |
|------|------|
| 레퍼런스 포인트 | phantom.land의 4요소 전부: 커서 반응 비주얼 + 몰입형 쇼케이스 + 볼드 타이포 + 스크롤 연출 |
| 기술 수준 | **three.js WebGL 풀장착** (성능·폴백 조건은 §6) |
| 범위 | **메인(/) + /outsourcing** 다크 캔버스 통일. 거래 페이지는 라이트 유지 + 브릿지 |
| 쇼케이스 콘텐츠 | 기존 샘플 8종을 각 컨셉에 맞게 리뉴얼해 연결 — **샘플 리뉴얼은 별도 후속 스펙**, 이번엔 쇼케이스 시스템 + 아트 타일 |
| 톤 | 히어로·본문·쇼케이스 동일 톤 (다크 하이브리드 아님 — 페이지 전체 통일) |
| **카피** | **"대기업 7년차 개발자" 류 경력 강조 금지.** 신뢰는 운영 실증으로: "24시간 돌아가는 실서비스를 직접 설계·운영" 축 ([[feedback-copy-no-career-emphasis]]) |
## 1. 컨셉 — "Deep Field"
깊은 네이비 우주(필드) 위에 작업물이 떠오른다. 다크 베이스는 순수 검정이 아닌 **브랜드 네이비 혈통**(`#070d1a` 계열)으로, 기존 `--jsm-navy` 푸터와 한 핏줄. 포인트는 기존 `--jsm-accent`(#1d4ed8)에 다크 위 가독용 밝은 변형(#60a5fa)을 추가.
### 다크 토큰 확장 (`globals.css`)
```
--jsm-dark-bg: #070d1a /* 페이지 베이스 */
--jsm-dark-surface: rgba(255,255,255,.03) /* 카드 */
--jsm-dark-line: rgba(148,163,184,.14) /* 보더 */
--jsm-dark-ink: #f8fafc /* 헤드라인 */
--jsm-dark-soft: #94a3b8 /* 보조 텍스트 */
--jsm-accent-bright:#60a5fa /* 다크 위 포인트 */
```
기존 라이트 토큰은 무수정 (거래 페이지가 사용).
## 2. 메인(/) 스크롤 구조 (승인된 목업 기준)
1. **HERO** — WebGL 파티클/포인트 필드가 커서를 자기장처럼 따라 굴절. 거대 타이포(2줄, letter-spacing 타이트): 카피는 "생각을 동작하는 소프트웨어로." 방향 (구현 시 디자인 스킬로 다듬되 경력 표현 금지). 서브: 운영 실증 한 줄. CTA 2개(프로젝트 문의 솔리드 / 소프트웨어 보기 고스트). 스크롤 시작 시 필드가 흩어지며 다음 섹션으로.
2. **SHOWCASE (주인공)** — "이런 걸 만들어 드립니다". 비대칭 그리드(대형 1 + 보조 2 페턴, 데스크톱) / 세로 스택(모바일). 각 카드는 컨셉별 고유 컬러 월드를 가진 WebGL 평면 — hover 시 굴절·미세 확대, 클릭 시 풀스크린 몰입 전환 후 데모로 이동. 8슬롯 체계: 리뉴얼 완료된 샘플부터 클릭 활성, 미완료 슬롯은 아트 타일(비활성, "coming" 라벨 없이 자연스럽게 비클릭). [전체 보기 → /outsourcing#showcase]
3. **PROCESS** — 4단계. 스크롤 진입 시 연결선이 그려지며 단계가 순차 점등.
4. **PROOF** — 운영 중 시스템 3종(주식 자동매매/청약 매칭/AI 파이프라인) + 카운터 스탯("운영 중인 실서비스가 곧 포트폴리오"). 숫자는 스크롤 진입 시 카운트업.
5. **SOFTWARE + CTA** — 제품 카드(라이트 카드가 다크 위에 떠 있는 대비), 기존 동적 products 연동 유지. 최종 CTA 밴드.
## 3. /outsourcing 구조
동일 다크 톤. Hero(축약) → **#showcase 풀 그리드(8슬롯 전체)** → 제공 분야 → 프로세스(상세 6단계) → FAQ → **의뢰 폼**(4단계 폼 — 다크 스타일로 재스킨, **로직·검증·API 무수정**). 기존 앵커(#process/#portfolio/#contact) 유지 — #portfolio는 #showcase로 통합하되 구 앵커도 동작(중복 id 불가하니 #portfolio 위치에 showcase 배치).
## 4. 쇼케이스 카드 시스템 (8슬롯)
기존 샘플 8종(`/work/website/samples/*`)의 컨셉을 슬롯으로 승계: corporate / commerce(shopping) / dashboard / bakery / portfolio / 기타 3종(구현 시 실제 샘플 목록 확인). 각 슬롯: 컨셉명(모노스페이스 라벨) + 고유 컬러 그래디언트 월드 + 한 줄 설명. 카드 비주얼은 **이번 스펙에서 신규 제작하는 아트 타일**(WebGL 텍스처/캔버스), 스크린샷 의존 없음 → 샘플 리뉴얼 전에도 완성도 유지. 샘플이 리뉴얼되면 해당 슬롯에 라이브 링크 활성화(데이터는 `lib/showcase.ts` 단일 배열로 관리 — `{ slug, label, title, desc, palette, href?: string }`, href 있으면 클릭 가능).
## 5. 네비·브릿지 전략
- **TopNav route-aware**: 다크 페이지(`/`, `/outsourcing`)에서는 투명→스크롤 시 다크 배경, 라이트 페이지에서는 기존 흰색 동작 유지. `usePathname` 기반 분기 (auth 로직 무수정).
- 푸터는 전 페이지 동일 네이비(기존) — 자연 브릿지.
- 거래·계정 페이지(/products, /mypage, /track, /quote, /login, /legal)는 **라이트 유지, 무수정** (이번 스펙 범위 밖).
## 6. 기술·성능·접근성 (WebGL 풀장착의 조건)
- **three.js는 dynamic import** — 클라이언트 전용 청크, 첫 페인트는 서버 렌더 정적 콘텐츠(텍스트·레이아웃)가 담당. SEO 텍스트는 전부 SSR 유지.
- **폴백 3단계**: ① `prefers-reduced-motion` → WebGL 미기동, 정적 그래디언트 ② 모바일/저성능(`navigator.hardwareConcurrency<4` 또는 뷰포트<768) → 경량 모드(파티클 수 1/4, 쇼케이스 굴절 비활성·CSS 전환) ③ WebGL 컨텍스트 실패 → 정적 폴백. 폴백 상태에서도 페이지는 완전한 경험이어야 함 (그래디언트·타이포·CSS 모션).
- 단일 `<canvas>` 재사용(히어로) + 쇼케이스는 카드별 경량 셰이더 또는 IntersectionObserver로 화면 내만 렌더. `requestAnimationFrame`은 탭 비활성 시 정지.
- 번들: three.js 코어만(없는 기능 import 금지), 목표 추가 JS ≤ 200KB gzip.
- 컴포넌트 구조: `app/components/deepfield/``HeroField.tsx`(WebGL 히어로) / `ShowcaseGrid.tsx` + `ShowcaseCard.tsx` / `ScrollReveal.tsx`(공용 스크롤 연출) / `useWebGLSupport.ts`(폴백 판정 훅). 페이지는 서버 컴포넌트 유지, WebGL 부분만 클라이언트 경계.
## 7. 카피 원칙
- 경력·소속 자격("7년차", "대기업") 표현 전면 제거 — **metadata description·jsonLd 포함**.
- 신뢰 축: "24시간 돌아가는 실서비스 15+를 직접 설계·운영합니다", "납품으로 끝나지 않습니다 — 직접 쓰는 사람이 만듭니다" 류.
- 한글 헤드라인 우선, 영문은 라벨·모노스페이스 디테일에만.
## 8. 무수정 보존 (회귀 금지선)
- 의뢰 폼(OutsourcingRequestForm) 로직·검증·API 호출 / products 동적 연동 로직 / 모든 라우팅·redirect / GA·jsonLd 구조(내용 카피만 갱신) / 거래·계정·admin 페이지 전부.
## 9. 검증
- `npm test` + `npm run build` + Phase 1~3 E2E 매트릭스 회귀 (숨김 404·redirect·API 401)
- 수동: 데스크톱(커서 반응·쇼케이스 hover·스크롤 연출), 모바일 375px(경량 모드), `prefers-reduced-motion` 에뮬레이션(정적 폴백), 탭 전환 CPU, Lighthouse 성능 확인(LCP가 WebGL에 막히지 않는지)
- 의뢰 폼 4단계 제출 회귀 1회
## 10. 의도적 제외 (후속 스펙)
- **샘플 8종 리뉴얼** (별도 스펙 — 2개씩 점진, 완료 시 쇼케이스 슬롯 활성화)
- 거래·계정 페이지 다크 전환
- 쇼케이스 클릭 몰입 전환의 풀스크린 WebGL 트랜지션 고도화 (1차는 절제된 전환으로 시작)

View File

@@ -0,0 +1,158 @@
# 쟁승메이드 라이트 고craft 재설계 — 설계 문서
> 작성 2026-06-30 · brainstorming 산출물 (승인 완료)
> 대상: `app/page.tsx`(홈) · `app/outsourcing/page.tsx` · `app/products/page.tsx` + 공통 시스템
---
## 1. 배경 / 문제 정의
최근 "Deep Field" 다크 캔버스 재스킨이 검증 없이 얹히면서 다음 문제가 발생했다.
1. **문서 ↔ 코드 충돌**`CLAUDE.md` 가드레일(라이트·gradient/blur/보라 금지·`--jsm-*`)을 실제 메인/외주 코드가 정면으로 위반(다크 배경 + WebGL 파티클 + radial gradient + 보라 팔레트).
2. **반복된 사후 패치** — 최근 커밋 2개가 전부 "히어로 텍스트 대비 복구" 류 → 다크 파티클 히어로가 픽셀 단위 튜닝에 실패.
3. **톤 단절** — 홈·외주는 다크, `/products`는 라이트. 첫 클릭에서 톤이 깨진다.
4. **가짜 포트폴리오** — 쇼케이스 8슬롯이 실작업 이미지가 아닌 그래디언트 타일(보라 포함). "AI가 뽑은 가짜" 인상.
5. **사이트 정체성 누락** — CLAUDE.md가 규정한 "외주+완성SW 2축" 소개가 홈에 없고 바로 쇼케이스로 점프.
6. **죽은 CSS**`kx-*`(blur), `gradient-text`(보라), `kx-orb/glow`, `--jsm-dark-*`, `--kx-*` 잔존.
### 타깃·포지셔닝 (의사결정 근거)
- 고객: 크몽·숨고·위시캣 트래픽 = 다수가 비개발자 소상공인/실무자.
- 무기: "실서비스 15+ 직접 운영"이라는 **운영 실증** (경력 어필 금지 — `feedback_copy_no_career`).
- 결론: 다크 스펙터클이 아니라 **라이트·명료 + 진짜 목업**이 신뢰·전환에 유리.
---
## 2. 확정된 방향 (승인됨)
| 결정 | 값 |
|------|-----|
| 비주얼 방향 | 라이트 기반 고(高)craft + 강조면 1곳 |
| 강조면 위치 | **히어로의 코드 제품 목업** (운영 실증을 이미지로) |
| 소재 확보 | **코드로 디자인한 UI 목업** (실데이터 0, `--jsm-*` 라이트/navy) |
| 범위 | 홈 + 외주 + 제품 3면 통일 + 공통 시스템 정리 |
| 가드레일 | 라이트 복귀 = **CLAUDE.md 컴플라이언스 회복** (개정 불필요, 다크 토큰 언급만 정리) |
---
## 3. 디자인 시스템 기반 (3면 공통)
### 색 (─ `--jsm-*` 만)
```
bg #f8fafc · surface #fff · surface-alt #f1f5f9
ink #0f172a · ink-soft #475569 · ink-faint #94a3b8 · line #e2e8f0
navy #0b1f3a (푸터 + CTA 1곳, 사이트 유일 다크면)
accent #1d4ed8 (유일 포인트) · accent-hover #1e40af · accent-soft #dbeafe
금지: 보라/violet · gradient · blur (navy CTA 밴드도 평면 navy로 — radial 광원 제거)
```
### 타이포 (Pretendard)
| 역할 | 스펙 |
|------|------|
| 디스플레이 h1 | `clamp(2.4rem, 7vw, 4rem)` · w800 · `-0.03em` · `break-keep` · lh 1.08 |
| 섹션 h2 | `clamp(1.7rem, 4vw, 2.4rem)` · w700 · `-0.02em` |
| 모노 라벨(eyebrow) | 11px · UPPER · `0.2em` · accent — 편집 디자인 시그니처 |
| 본문 | 1618px · ink-soft · `-0.01em` · leading-relaxed |
### 레이아웃·여백·리듬
- 컨테이너 `max-w-6xl`(1152) · 패딩 `px-6 lg:px-8`. **3면 동일** (현재 제품은 max-w-5xl로 어긋남 → 통일).
- **여백 변주**: 현재 전부 `py-24/32` 단조 → 히어로 큰 호흡, 이후 섹션 `py-20 / py-24 / py-28`로 리듬.
- **교차 배경**: `surface`(#fff) ↔ `surface-alt`(#f1f5f9) 교차로 섹션 구분. `border-t` 단독 의존 탈피.
- 카드: `rounded-2xl` · `border line` · `shadow-sm` · hover `translateY(-2px)` + border accent.
### 모션
- `ScrollReveal`(fade+rise) 유지. `prefers-reduced-motion` 가드(기존 `.reveal` CSS 활용). 절제.
- `CountUp` 유지 (운영 실증 스탯).
---
## 4. 핵심 신규 컴포넌트 — `MockWindow` 목업 시스템
파티클(HeroField)을 대체하는 craft의 핵심. **재사용 가능한 라이트 UI 목업.**
```
app/components/mock/
MockWindow.tsx 브라우저/앱 크롬 프레임 (● ● ● 신호등 + 타이틀바 + 본문 슬롯)
screens/
DashboardMock 스탯 카드 3 + 막대/라인 차트 (주식 리포트 톤)
FeedMock 텔레그램풍 메시지 피드 (봇 알림)
MatchMock 매물/항목 카드 + 매칭률 배지 (부동산 청약)
CommerceMock 상품 그리드 + 장바구니/가격
SiteMock 기업 사이트 히어로 와이어 (corporate/portfolio/editorial)
BookingMock 예약 캘린더/슬롯 (로컬 매장)
```
- 전부 SVG/CSS, `--jsm-*` 라이트 + navy 헤더, accent 포인트. **실데이터 없음.**
- 결정적 렌더(난수 시드 불필요 — 정적 마크업). SSR-safe(클라이언트 캔버스 의존 제거 → 서버 컴포넌트로 렌더 가능).
- 용도: **히어로 1개**(대표 = DashboardMock/FeedMock) + **쇼케이스 N개**.
---
## 5. 페이지별 설계
### 5.1 홈 `/`
섹션 순서 (배경 교차 표기):
1. **HERO** (surface) — 비대칭 2단: 좌 텍스트(eyebrow·h1·sub·CTA 2) / 우 `MockWindow`(대표 목업). 하단 **신뢰 스트립**(15+ 실서비스 · 24/7 · 원스톱).
2. **2축 소개** (surface-alt) — 신규. `01 OUTSOURCING` / `02 SOFTWARE` 2카드. 사이트 정체성 복원.
3. **SHOWCASE** (surface) — `ShowcaseGrid` 재작성: 그래디언트 타일 → `MockWindow` 그리드. 홈 6장.
4. **운영 실증** (surface-alt) — 3종 카드 + 스탯(CountUp 15+/24·7/원스톱). 라이트 카드 통일.
5. **PROCESS** (surface) — 4단계 + 가로 연결선.
6. **완성 SW** (surface-alt) — featured 3종(DB, `getListedProducts`). 0개면 coming-soon 폴백(라이트).
7. **CTA 밴드** (navy) — 사이트 유일 다크면. 평면 navy(radial gradient 제거). "프로젝트, 이야기부터".
삭제: `HeroField` 사용, 좌측 스크림/비네트, `-mt-16` 다크 풀블리드 트릭(라이트라 불필요).
### 5.2 외주 `/outsourcing` — 다크→라이트 전환 (구조 유지)
```
HERO(라이트, 소형 MockWindow 1개) → 제공 분야 6 → 운영 실사례 6(라이트 카드)
→ SHOWCASE 풀그리드 8(MockWindow) → PROCESS 6단계 → FAQ(아코디언) → 의뢰 폼
```
- 의뢰 폼: 라이트 스킨. `.jsm-dark-form` placeholder 규칙 제거/라이트화. `OutsourcingRequestForm` 입력 가독성 복구.
- 앵커(`#showcase`/`#portfolio`/`#process`/`#contact`) 유지.
### 5.3 제품 `/products` — 이미 라이트, craft 격상
```
HERO → 카탈로그(2열 카드) → 구매방식 3단계 → CTA
```
- `max-w-5xl``max-w-6xl`, 타입 스케일·여백을 홈과 동일 언어로 정렬.
- 카드 hover·라운드·그림자를 공통 카드 스펙에 맞춤.
---
## 6. 공통 셸
- **TopNav** — "다크 인지형" 라우트 분기 제거 → 단일 라이트 네비(흰 배경 + 하단 line, 스크롤 시 미세 shadow) + 우측 `프로젝트 문의` CTA. (구현 시 현 코드 확인 후 최소 수정.)
- **Footer** — navy 유지. 사이트 유일 다크면(CTA 밴드와 함께).
---
## 7. 정리·마이그레이션
- `app/globals.css`:
- 제거: `--jsm-dark-*` 토큰, `--kx-*` 매핑, `.kx-*`(glass/orb/glow/folder/...), `.gradient-text`(보라), `.kx-gradient-text`, `.jsm-dark-form`, `.df-scroll-dot`(파티클 전용).
- 유지: `--jsm-*` 라이트, `.reveal*`, `.marquee*`(사용처 확인 후), 스크롤바, `.scrollbar-hide`.
- `lib/showcase.ts`: `palette/accent` 그래디언트 스펙 → **목업 타입 스펙**(`mock: 'dashboard'|'commerce'|...`)으로 교체. 보라 4슬롯 제거/치환.
- `app/components/deepfield/`:
- `ShowcaseCard.tsx``MockWindow` 기반으로 재작성(또는 `app/components/mock/`로 이전).
- `ShowcaseGrid.tsx` 유지(레이아웃 로직) — 카드만 교체.
- `HeroField.tsx`·`useFieldMode.ts` — 홈/외주에서 import 제거. 파일은 보존만(미사용). three 의존 트리셰이킹 확인.
- `CLAUDE.md`: 디자인 시스템 섹션에서 다크 토큰 언급 정리(가드레일 본문은 이미 라이트 → 변경 불필요).
---
## 8. 비목표 (YAGNI)
- 다크 모드 토글/테마 시스템 (불필요).
- 실제 스크린샷 수집·마스킹 파이프라인 (코드 목업으로 대체).
- admin/mypage/legal 등 비공개·내부 페이지 재설계 (이번 범위 밖 — 이미 라이트).
- 카피 전면 재작성 (기존 카피 유지, 구조·톤만 변경. 단 경력 어필 카피는 금지 유지).
---
## 9. 검증 기준
- [ ] 3면 모두 라이트 `--jsm-*`만 사용, 다크 토큰/보라/blur/임의 색 0건 (grep).
- [ ] 홈→외주→제품 클릭 시 톤 단절 없음.
- [ ] 쇼케이스가 코드 목업(실화면 느낌)으로 렌더, 그래디언트 타일 0건.
- [ ] 홈에 "2축 소개" 섹션 존재.
- [ ] 의뢰 폼 입력 텍스트·placeholder 가독성 정상(라이트).
- [ ] `npm run build` 성공 + `npm test`(lib 단위) 통과.
- [ ] 죽은 CSS(`kx-*`/`gradient-text` 등) 제거 확인.
- [ ] `prefers-reduced-motion` 시 모션 정지.

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { decideFieldMode } from '@/lib/deepfield-mode';
const base = { reducedMotion: false, webglSupported: true, hardwareConcurrency: 8, viewportWidth: 1440 };
describe('decideFieldMode', () => {
it('데스크톱 + WebGL = full', () => {
expect(decideFieldMode(base)).toBe('full');
});
it('reduced-motion이면 무조건 static', () => {
expect(decideFieldMode({ ...base, reducedMotion: true })).toBe('static');
expect(decideFieldMode({ ...base, reducedMotion: true, viewportWidth: 375 })).toBe('static');
});
it('WebGL 미지원이면 static', () => {
expect(decideFieldMode({ ...base, webglSupported: false })).toBe('static');
});
it('모바일 뷰포트(<768)는 lite', () => {
expect(decideFieldMode({ ...base, viewportWidth: 767 })).toBe('lite');
});
it('저성능 코어(<4)는 lite', () => {
expect(decideFieldMode({ ...base, hardwareConcurrency: 2 })).toBe('lite');
});
it('hardwareConcurrency 미보고(0/undefined)는 lite로 보수적 판정', () => {
expect(decideFieldMode({ ...base, hardwareConcurrency: 0 })).toBe('lite');
});
});

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { SHOWCASE_SLOTS } from '@/lib/showcase';
import { MOCK_KEYS } from '@/app/components/mock/keys';
// 가드레일: 쇼케이스 슬롯이 라이트 목업 기반이고 보라/그래디언트 잔재가 없어야 한다.
const VIOLET_HEXES = ['#c4b5fd', '#f0abfc', '#341a4f', '#4a1342', '#7c3aed', '#9c48ea'];
describe('SHOWCASE_SLOTS 가드레일', () => {
it('8슬롯이고 slug가 고유하다', () => {
expect(SHOWCASE_SLOTS.length).toBe(8);
const slugs = SHOWCASE_SLOTS.map((s) => s.slug);
expect(new Set(slugs).size).toBe(slugs.length);
});
it('각 슬롯의 mock이 유효한 MockKey이고 핵심 필드가 비어있지 않다', () => {
for (const s of SHOWCASE_SLOTS) {
expect(MOCK_KEYS).toContain(s.mock);
expect(s.slug.length).toBeGreaterThan(0);
expect(s.label.length).toBeGreaterThan(0);
expect(s.title.length).toBeGreaterThan(0);
expect(s.desc.length).toBeGreaterThan(0);
}
});
it('어떤 슬롯에도 보라/그래디언트 hex가 남아있지 않다', () => {
const serialized = JSON.stringify(SHOWCASE_SLOTS).toLowerCase();
for (const hex of VIOLET_HEXES) {
expect(serialized).not.toContain(hex.toLowerCase());
}
// 더 이상 palette 필드를 갖지 않는다 (라이트 목업 전환).
for (const s of SHOWCASE_SLOTS) {
expect('palette' in s).toBe(false);
}
});
it('목업 종류가 최소 4가지 이상으로 다양하다 (단조 방지)', () => {
const uniqueMocks = new Set(SHOWCASE_SLOTS.map((s) => s.mock));
expect(uniqueMocks.size).toBeGreaterThanOrEqual(4);
});
});

17
lib/deepfield-mode.ts Normal file
View File

@@ -0,0 +1,17 @@
export type FieldMode = 'full' | 'lite' | 'static';
export interface FieldEnv {
reducedMotion: boolean;
webglSupported: boolean;
hardwareConcurrency: number; // 미보고 시 0
viewportWidth: number;
}
/** Deep Field 렌더 모드 판정 — 우선순위: 접근성 > 지원 여부 > 성능 */
export function decideFieldMode(env: FieldEnv): FieldMode {
if (env.reducedMotion) return 'static';
if (!env.webglSupported) return 'static';
if (env.viewportWidth < 768) return 'lite';
if (!env.hardwareConcurrency || env.hardwareConcurrency < 4) return 'lite';
return 'full';
}

23
lib/showcase.ts Normal file
View File

@@ -0,0 +1,23 @@
/** Deep Field 쇼케이스 8슬롯 — 단일 소스 (라이트 MockWindow 목업 기반).
* href가 있는 슬롯만 클릭 가능 (샘플 데모 완료 시 href 추가). */
import type { MockKey } from '@/app/components/mock/keys';
export interface ShowcaseSlot {
slug: string;
label: string; // 모노스페이스 컨셉 라벨 (영문)
title: string; // 카드 타이틀 (한글)
desc: string; // 한 줄 설명
mock: MockKey; // 카드에 렌더할 라이트 목업 화면
href?: string; // 데모 링크 (있으면 클릭 가능)
}
export const SHOWCASE_SLOTS: ShowcaseSlot[] = [
{ slug: 'corporate', label: 'corporate', title: '기업 브랜드 사이트', desc: '신뢰를 첫인상으로 — 브랜드 스토리와 회사 소개', mock: 'site' },
{ slug: 'commerce', label: 'commerce', title: '커머스 스토어', desc: '탐색부터 결제까지 끊김 없는 구매 동선', mock: 'commerce' },
{ slug: 'dashboard', label: 'dashboard', title: '데이터 대시보드', desc: '실시간 지표를 한눈에 — 의사결정용 화면', mock: 'dashboard' },
{ slug: 'automation', label: 'automation', title: '봇·자동화 알림', desc: '체결·알림·리포트를 사람 손 없이 자동 전송', mock: 'feed' },
{ slug: 'matching', label: 'matching', title: '조건 매칭 시스템', desc: '수집·필터·매칭으로 원하는 것만 골라내는 화면', mock: 'match' },
{ slug: 'booking', label: 'local shop', title: '예약·매장 사이트', desc: '예약·주문이 자연스러운 동네 가게의 얼굴', mock: 'booking' },
{ slug: 'portfolio', label: 'portfolio', title: '포트폴리오', desc: '작업물이 주인공이 되는 미니멀 갤러리', mock: 'site' },
{ slug: 'editorial', label: 'editorial', title: '에디토리얼·매거진', desc: '읽는 경험을 설계한 콘텐츠 사이트', mock: 'site' },
];

67
package-lock.json generated
View File

@@ -28,13 +28,15 @@
"remark-gfm": "^4.0.0",
"resend": "^6.9.1",
"solarlunar": "^2.0.7",
"tailwind-merge": "^3.5.0"
"tailwind-merge": "^3.5.0",
"three": "^0.184.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.184.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
@@ -324,6 +326,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -2149,6 +2158,13 @@
"tailwindcss": "4.1.18"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2280,12 +2296,41 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.184.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
"integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -4963,6 +5008,13 @@
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -7192,6 +7244,13 @@
"node": ">= 8"
}
},
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"dev": true,
"license": "MIT"
},
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -9744,6 +9803,12 @@
"node": ">=18"
}
},
"node_modules/three": {
"version": "0.184.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

View File

@@ -30,13 +30,15 @@
"remark-gfm": "^4.0.0",
"resend": "^6.9.1",
"solarlunar": "^2.0.7",
"tailwind-merge": "^3.5.0"
"tailwind-merge": "^3.5.0",
"three": "^0.184.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.184.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",