Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
"PostToolUse": [
{
"matcher": "Write|Edit",
"command": "pnpm exec prettier --write \"$FILE_PATH\" --ignore-unknown",
"description": "파일 저장 후 자동 Prettier 포맷"
"hooks": [
{
"type": "command",
"command": "pnpm exec prettier --write \"$FILE_PATH\" --ignore-unknown",
"description": "파일 저장 후 자동 Prettier 포맷"
}
]
}
]
}
Expand Down
61 changes: 61 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -1 +1,62 @@
@import 'tailwindcss';

@layer base {
body {
font-family: var(--font-pretendard), sans-serif;
}

:root {
/* ── 텍스트 계층 ── */
--text-1: oklch(97% 0.005 240); /* 최고 밝기, 헤드라인 */
--text-2: oklch(82% 0.01 240); /* 본문 */
--text-3: oklch(62% 0.012 240); /* 보조 설명 */
--text-4: oklch(45% 0.01 240); /* 비활성, 힌트 */

/* ── 배경 ── */
--bg-base: oklch(12% 0.015 255); /* 전체 배경 */
--bg-card: oklch(16% 0.018 255); /* 카드 배경 */
--bg-subtle: oklch(19% 0.016 255); /* 호버, 입력 배경 */

/* ── 테두리 ── */
--border-subtle: oklch(28% 0.02 255); /* 구분선 */
--border-default: oklch(35% 0.022 255); /* 카드 테두리, 인풋 */

/* ── 그린 팔레트 (브랜드 컬러) ── */
--green-100: oklch(92% 0.08 155); /* 배경 틴트 */
--green-200: oklch(78% 0.14 155); /* 아이콘, 배지 */
--green-300: oklch(68% 0.18 155); /* 기본 강조 */
--green-400: oklch(58% 0.21 155); /* 호버, 활성 */

/* ── 그라디언트 ── */
--grad-text: linear-gradient(
105deg,
var(--green-200) 0%,
var(--green-100) 55%,
oklch(88% 0.07 180) 100%
);
--grad-glow: radial-gradient(
ellipse 60% 40% at 50% 0%,
oklch(35% 0.1 155 / 0.25) 0%,
transparent 70%
);
--grad-ai: linear-gradient(
135deg,
oklch(60% 0.18 280) 0%,
var(--green-300) 50%,
oklch(72% 0.15 170) 100%
);

/* ── 상태 색상 ── */
--warn: oklch(75% 0.18 60); /* 주의/경고 (앰버 계열) */

/* ── 공통 유틸 ── */
--radius-card: 1rem; /* 16px */
--radius-btn: 0.625rem; /* 10px */
--shadow-card:
0 4px 32px oklch(0% 0 0 / 0.35), 0 1px 4px oklch(0% 0 0 / 0.2);
}
}

.app-bg {
background: var(--grad-glow);
}
10 changes: 9 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import type {Metadata} from 'next';
import localFont from 'next/font/local';

import './globals.css';

const pretendard = localFont({
src: '../shared/assets/fonts/PretendardVariable.woff2',
variable: '--font-pretendard',
weight: '45 920',
display: 'swap',
});

export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
Expand All @@ -13,7 +21,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang='ko'>
<html lang='ko' className={pretendard.variable}>
<body className='flex min-h-full flex-col'>{children}</body>
</html>
);
Expand Down
47 changes: 47 additions & 0 deletions app/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {render, screen} from '@testing-library/react';
import {describe, it, expect} from 'vitest';

import LandingPage from './page';

describe('LandingPage', () => {
it('헤드라인 텍스트가 렌더링된다', () => {
// Arrange & Act
render(<LandingPage />);

// Assert
expect(screen.getByText('경험을 기록하면')).toBeInTheDocument();
});

it('그라디언트 텍스트 스팬이 렌더링된다', () => {
// Arrange & Act
render(<LandingPage />);

// Assert
expect(screen.getByText('자기소개서가 자라요')).toBeInTheDocument();
});

it('Google 로그인 링크가 /dashboard로 연결된다', () => {
// Arrange & Act
render(<LandingPage />);

// Assert
const link = screen.getByRole('link', {name: /google/i});
expect(link).toHaveAttribute('href', '/dashboard');
});

it('앱 이름 eyebrow가 표시된다', () => {
// Arrange & Act
render(<LandingPage />);

// Assert
expect(screen.getByText('Sprout · Career Studio')).toBeInTheDocument();
});

it('무료 이용 안내 문구가 표시된다', () => {
// Arrange & Act
render(<LandingPage />);

// Assert
expect(screen.getByText(/전체 무료/)).toBeInTheDocument();
});
});
93 changes: 91 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,92 @@
export default function Home() {
return <></>;
import Link from 'next/link';

import {LogoGlyph} from '@/components/ui';

export default function LandingPage() {
return (
<main className='relative min-h-screen w-full overflow-hidden bg-[var(--bg-base)]'>
{/* 배경 글로우 */}
<div className='app-bg pointer-events-none absolute inset-0' />

{/* 중앙 정렬 컨테이너 */}
<div className='relative z-10 flex min-h-screen items-center justify-center px-4 py-16'>
{/* 로그인 카드 */}
<div className='flex w-full max-w-sm flex-col gap-4 rounded-[var(--radius-card)] border border-[var(--border-default)] bg-[var(--bg-card)] p-8 shadow-[var(--shadow-card)]'>
{/* 로고 아이콘 */}
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--bg-subtle)] text-[var(--green-300)]'>
<LogoGlyph size={26} />
</div>

{/* eyebrow */}
<p className='text-xs font-semibold tracking-widest text-[var(--text-4)] uppercase'>
Sprout · Career Studio
</p>

{/* 헤드라인 */}
<h1 className='text-2xl leading-snug font-bold text-[var(--text-1)]'>
경험을 기록하면
<br />
<span className='bg-[var(--grad-text)] bg-clip-text text-transparent'>
자기소개서가 자라요
</span>
</h1>

{/* 서브 설명 */}
<p className='text-sm leading-relaxed text-[var(--text-3)]'>
프로젝트·인턴·활동을 STAR 형식으로 정리하고,
<br />
채용공고에 맞는 자기소개서를 AI와 함께 완성하세요.
</p>

{/* Google 로그인 버튼 */}
<Link
href='/dashboard'
className='flex w-full items-center justify-center gap-3 rounded-[var(--radius-btn)] bg-white px-4 py-3 text-sm font-medium text-gray-800 shadow-sm transition-opacity hover:opacity-90'>
<svg
width='18'
height='18'
viewBox='0 0 18 18'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'>
<path
d='M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z'
fill='#4285F4'
/>
<path
d='M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z'
fill='#34A853'
/>
<path
d='M3.964 10.707A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.707V4.961H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.039l3.007-2.332z'
fill='#FBBC05'
/>
<path
d='M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.961L3.964 7.293C4.672 5.166 6.656 3.58 9 3.58z'
fill='#EA4335'
/>
</svg>
Google 계정으로 계속하기
</Link>

{/* 약관 동의 안내 */}
<p className='text-center text-xs text-[var(--text-4)]'>
계속하면{' '}
<span className='underline underline-offset-2'>이용약관</span> 및{' '}
<span className='underline underline-offset-2'>
개인정보처리방침
</span>
에 동의하는 것으로 간주합니다.
</p>

{/* 구분선 */}
<hr className='border-t border-[var(--border-subtle)]' />

{/* 무료 이용 안내 */}
<p className='text-center text-xs text-[var(--text-4)]'>
경험 데이터는 비공개 · 전체 무료
</p>
</div>
</div>
</main>
);
}
32 changes: 32 additions & 0 deletions components/ui/LogoGlyph.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type {Meta, StoryObj} from '@storybook/react';

import {LogoGlyph} from './LogoGlyph';

const meta: Meta<typeof LogoGlyph> = {
title: 'UI/LogoGlyph',
component: LogoGlyph,
parameters: {layout: 'centered'},
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof LogoGlyph>;

export const Default: Story = {
args: {
size: 26,
},
};

export const Large: Story = {
args: {
size: 48,
},
};

export const Colored: Story = {
args: {
size: 26,
className: 'text-green-400',
},
};
45 changes: 45 additions & 0 deletions components/ui/LogoGlyph.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {render} from '@testing-library/react';
import {describe, it, expect} from 'vitest';

import {LogoGlyph} from './LogoGlyph';

describe('LogoGlyph', () => {
it('기본 size로 SVG를 렌더링한다', () => {
// Arrange & Act
render(<LogoGlyph />);

// Assert
const svg = document.querySelector('svg');
expect(svg).not.toBeNull();
expect(svg?.getAttribute('width')).toBe('26');
expect(svg?.getAttribute('height')).toBe('26');
});

it('size prop이 반영된다', () => {
// Arrange & Act
render(<LogoGlyph size={40} />);

// Assert
const svg = document.querySelector('svg');
expect(svg?.getAttribute('width')).toBe('40');
expect(svg?.getAttribute('height')).toBe('40');
});

it('className prop이 svg에 적용된다', () => {
// Arrange & Act
render(<LogoGlyph className='text-green-400' />);

// Assert
const svg = document.querySelector('svg');
expect(svg?.classList.contains('text-green-400')).toBe(true);
});

it('aria-hidden이 설정된다', () => {
// Arrange & Act
render(<LogoGlyph />);

// Assert
const svg = document.querySelector('svg');
expect(svg?.getAttribute('aria-hidden')).toBe('true');
});
});
40 changes: 40 additions & 0 deletions components/ui/LogoGlyph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
interface LogoGlyphProps {
size?: number;
className?: string;
}

export const LogoGlyph = ({size = 26, className}: LogoGlyphProps) => {
return (
<svg
width={size}
height={size}
viewBox='0 0 26 26'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
className={className}>
{/* 씨앗 — 아래쪽 원형 바닥 */}
<circle cx='13' cy='20' r='3.5' fill='currentColor' opacity='0.7' />
{/* 줄기 — 위로 뻗는 곡선 */}
<path
d='M13 20 C13 16 13 12 13 8'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
/>
{/* 잎사귀 — 왼쪽으로 난 타원형 */}
<path
d='M13 13 C10 11 6.5 11.5 6 14 C5.5 16.5 8.5 17 10.5 15.5 C12 14.5 13 13 13 13Z'
fill='currentColor'
/>
{/* 새 잎 — 오른쪽 위로 난 작은 잎 */}
<path
d='M13 9 C15 7.5 18 7.5 18.5 10 C19 12 16.5 12.5 15 11 C14 10 13 9 13 9Z'
fill='currentColor'
opacity='0.85'
/>
</svg>
);
};

export default LogoGlyph;
1 change: 1 addition & 0 deletions components/ui/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {LogoGlyph} from './LogoGlyph';
Loading
Loading