Compare commits
12 Commits
c58d698df6
...
feature/20
| Author | SHA1 | Date | |
|---|---|---|---|
| 754ce5de5a | |||
| 8876998acd | |||
| 607ef1a435 | |||
|
|
420b29ff43 | ||
|
|
1c8149a343 | ||
|
|
08be2c3e5a | ||
|
|
b012567628 | ||
|
|
a8d7cbbb9c | ||
|
|
1ab599f4ff | ||
|
|
d6e750e2e9 | ||
|
|
64c383ce32 | ||
|
|
fb31c8d7e5 |
59
.claude/project/dreaming-context.md
Normal file
59
.claude/project/dreaming-context.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 🧠 프로젝트 자율 인지 메모리 (Dreaming Context)
|
||||
|
||||
이 파일은 `dreaming.js` 스크립트에 의해 프로젝트 코드베이스를 분석하여 자동 생성되었습니다.
|
||||
Claude Code가 프로젝트의 실시간 코드 구조, 사용 중인 스토어, 컴포넌트 레이아웃, 그리고 최신 개발 흐름을 완벽히 인지하도록 돕습니다.
|
||||
|
||||
* **최종 동기화 시간:** 2026. 5. 21. 오후 9:20:21 (Asia/Seoul)
|
||||
|
||||
---
|
||||
|
||||
## 🏗 프로젝트 정보
|
||||
* **프로젝트명:** `Unknown Project` (v1.0.0)
|
||||
* **핵심 프레임워크:** `Unknown`
|
||||
* **기술 스택 라이브러리:**
|
||||
- (감지된 주요 라이브러리 없음)
|
||||
|
||||
---
|
||||
|
||||
## 📁 디렉토리 구조 및 컴포넌트 현황
|
||||
현재 활성화되어 있는 프로젝트 레이아웃 정보입니다.
|
||||
|
||||
| 디렉토리 | 활성 여부 | 파일 개수 | 주요 샘플 파일 (최대 5개) |
|
||||
|---|---|---|---|
|
||||
| `components/` | ❌ | 0개 | - |
|
||||
| `composables/` | ❌ | 0개 | - |
|
||||
| `stores/` | ❌ | 0개 | - |
|
||||
| `pages/` | ❌ | 0개 | - |
|
||||
| `server/` | ❌ | 0개 | - |
|
||||
| `layouts/` | ❌ | 0개 | - |
|
||||
| `middleware/` | ❌ | 0개 | - |
|
||||
| `plugins/` | ❌ | 0개 | - |
|
||||
| `types/` | ❌ | 0개 | - |
|
||||
| `assets/` | ❌ | 0개 | - |
|
||||
|
||||
---
|
||||
|
||||
## 🍍 액티브 Pinia 스토어 목록
|
||||
현재 코드베이스에 존재하는 글로벌 상태 저장소들의 템플릿 정보입니다. 새 기능을 개발할 때 아래 스토어를 재사용하거나 참고하세요.
|
||||
|
||||
*감지된 Pinia 스토어가 없습니다. (stores/ 디렉토리 없음 혹은 비어있음)*
|
||||
|
||||
---
|
||||
|
||||
## 🎣 커스텀 Composable (useXxx) 목록
|
||||
다양한 비즈니스 로직과 부수효과를 격리해 둔 커스텀 훅 목록입니다. 컴포넌트 내부에서 비즈니스 로직을 직접 짜기 전, 아래 훅들의 재사용 가능성을 먼저 타진하세요.
|
||||
|
||||
*감지된 커스텀 Composable이 없습니다. (composables/ 디렉토리 없음 혹은 비어있음)*
|
||||
|
||||
---
|
||||
|
||||
## 🧪 유닛 테스트 통계
|
||||
현재까지 구축된 테스트 커버리지 현황입니다.
|
||||
* **감지된 테스트 파일 수:** `0개`
|
||||
*(새 기능을 추가할 때 반드시 Vitest 규격의 유닛 테스트를 함께 작성해야 함)*
|
||||
|
||||
---
|
||||
|
||||
## 🛠 실행 가능한 스크립트 (package.json)
|
||||
프로젝트 구동 및 테스트 검증을 위해 사용 가능한 명령어 리스트입니다.
|
||||
- 스크립트 없음
|
||||
107
.claude/rules/automation-guide.md
Normal file
107
.claude/rules/automation-guide.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 🤖 프론트엔드 에이전트 자동화 시스템 가이드
|
||||
|
||||
이 문서는 `gameservice-fe-agent` 패키지에 탑재된 두 가지 핵심 자동화 프로세스—**프로젝트 자율 인지(Dreaming) 시스템** 및 **AI 개발 스쿼드(Squad) 오케스트레이션**의 개념과 실행 방법을 상세히 가이드합니다.
|
||||
|
||||
이 도구들은 일회성(Stateless) AI 코드 작성의 한계를 넘어, **지속성 있는 컨벤션 수호자(Convention Guardian)**로 동작하고 복잡한 컴포넌트를 **병렬 전문 역할 분담 스쿼드**를 통해 해결하는 것을 목적으로 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 1. "AI 코더"에서 "상태 저장형 컨벤션 가디언"으로 (Dreaming)
|
||||
|
||||
### 💡 도입 배경 및 개념
|
||||
일반적인 LLM 코딩 에이전트는 프롬프트를 보낼 때마다 컨텍스트가 초기화되는 **단발성(Stateless)** 모델로 동작합니다. 이 때문에 기존 프로젝트의 폴더 구조, 이미 만들어진 커스텀 Composable(useXxx), 활성화된 Pinia 스토어 목록, 패키지 버전 등을 매번 인지하지 못해 불필요한 코드를 중복 구현하거나 기존 컨벤션을 깨는 실수를 저지릅니다.
|
||||
|
||||
**Dreaming 자동화 시스템**은 에이전트가 "코드베이스 전체를 정기적으로 자율 성찰(Self-reflection)"하도록 만드는 로컬 구현체입니다. `dreaming.js`를 구동하면 프로젝트의 상태를 휴리스틱하게 분석하여 `.claude/project/dreaming-context.md` 파일에 기록하고, `CLAUDE.md` 수입 선언을 통해 에이전트가 시작부터 이 최신 상태를 인지하게 만듭니다.
|
||||
|
||||
### 🛠 실행 및 연동 방법
|
||||
|
||||
#### ① 실행 명령어
|
||||
프로젝트 루트 경로에서 아래 스크립트를 구동합니다:
|
||||
```bash
|
||||
node .claude/skills/dreaming/scripts/dreaming.js
|
||||
```
|
||||
|
||||
#### ② 자동 수행되는 태스크
|
||||
1. `package.json` 파싱: 프레임워크 버전, 핵심 기술 스택(Nuxt, Pinia, Tailwind, Vitest 등), 실행 가능한 스크립트 명령어 수집.
|
||||
2. 디렉토리 구조 스캔: 활성화된 디렉토리와 소속 파일 개수, 구조적 예시 목록 도출.
|
||||
3. **Pinia 스토어 자율 추출:** `stores/` 내부의 파일들을 분석하여 각 스토어의 ID, 반응형 상태(state/computed), 비즈니스 함수(actions) 목록을 정밀 인지.
|
||||
4. **커스텀 Composable 자율 추출:** `composables/` 내부의 `useXxx` 스타일 컴포저블을 검출해 노출 함수 리스트 확보.
|
||||
5. **유닛 테스트 통계 파싱:** 구축된 테스트 파일 목록 및 통계 추출.
|
||||
6. **마크다운 출력 및 자동 임포트:** 수집된 정보를 마크다운 리포트로 자동 빌드해 `.claude/project/dreaming-context.md` 파일에 덮어쓰고, 루트의 `CLAUDE.md`에 `@.claude/project/dreaming-context.md` 임포트 구문이 없으면 이를 자동으로 연결합니다.
|
||||
|
||||
### 📌 시니어 FE 관점의 기대 효과
|
||||
* **중복 코드 생성 전면 억제:** 에이전트가 이미 존재하는 Pinia 스토어나 커스텀 훅을 즉시 찾아내기 때문에, 동일한 API 바인딩이나 헬퍼 함수를 이중으로 작성하지 않습니다.
|
||||
* **프로젝트 맞춤형 가상 주니어화:** 며칠 쉬고 오거나 세션이 만료되더라도, 에이전트가 단 1초 만에 프로젝트의 최신 스냅샷을 뇌리에 새긴 상태(Stateful)로 지능적인 보조를 시작합니다.
|
||||
|
||||
---
|
||||
|
||||
## 👥 2. "AI 한 명과 대화"가 아닌 "AI 개발 스쿼드(Squad) 오케스트레이션"
|
||||
|
||||
### 💡 도입 배경 및 개념
|
||||
아무리 성능이 뛰어난 모델이라도 템플릿 마크업 작성, 복잡한 Tailwind 스타일링, WCAG 2.1 AA 접근성 마크업, 그리고 Vitest 단위 테스트 작성을 한 번에 지시하면 문맥 누락이나 결함(Bug)이 발생하기 마련입니다.
|
||||
|
||||
**스쿼드 오케스트레이션**은 하나의 피처 요청을 **3인의 가상 전문 개발 에이전트**로 쪼개어 단계별/병렬 협업 파이프라인으로 수행하는 시스템입니다. 시니어 프론트엔드 엔지니어(Gil)는 코드 작성에 시간을 소모하는 대신, 이 전문 에이전트들을 조율하고 합산 결과물의 비즈니스 사양과 렌더링을 최종 승인하는 **오케스트레이터(Orchestrator)**의 최고 존엄 지위를 갖게 됩니다.
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ Gil (Orchestrator) │
|
||||
└──────────┬───────────┘
|
||||
│ (스쿼드 생성 지시)
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ squad-orchestrator.js │
|
||||
└────────────┬───────────────┬───────────────┬───────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐┌──────────────┐┌──────────────┐
|
||||
│ Role 1 ││ Role 2 ││ Role 3 │
|
||||
│ UI/마크업 ││ 웹 접근성 ││ QA/단위테스트│
|
||||
│ 스페셜리스트 ││ 스페셜리스트 ││ 스페셜리스트 │
|
||||
└───────┬──────┘└───────┬──────┘└───────┬──────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
[구조 & 뼈대 빌드] [ARIA & 키보드 주입] [Vitest 케이스 PASS]
|
||||
```
|
||||
|
||||
### 🛠 실행 및 연동 방법
|
||||
|
||||
#### ① 스쿼드 조직하기 (인터랙션/CLI 겸용)
|
||||
새로운 피처나 컴포넌트를 설계할 때 아래 스크립트를 구동합니다:
|
||||
|
||||
* **대화형 모드로 조직하기:**
|
||||
```bash
|
||||
node .claude/skills/squad-orchestration/scripts/squad-orchestrator.js
|
||||
```
|
||||
명령어 실행 후 터미널의 질문에 따라 컴포넌트 이름과 요구 스펙을 입력하면 스쿼드가 즉시 조직됩니다.
|
||||
|
||||
* **CLI 인자로 한 번에 조직하기:**
|
||||
```bash
|
||||
node .claude/skills/squad-orchestration/scripts/squad-orchestrator.js --name "UserScoreCard" --spec "유저 프로필과 전적, 랭킹을 보여주고 점수에 따라 테두리 색상이 바뀌는 컴포넌트 구현"
|
||||
```
|
||||
|
||||
#### ② 스쿼드 파일 구성 및 역할
|
||||
명령이 끝나면 루트에 `squad/<ComponentName>/` 폴더가 자동 생성됩니다.
|
||||
* **`tasks/01_markup_agent.md` (Role 1):** Vue 3 템플릿, 데이터 상태 설계, Tailwind 반응형 레이아웃 구성 집중 지시서.
|
||||
* **`tasks/02_a11y_agent.md` (Role 2):** WCAG 2.1 AA 기준 준수, ARIA 역할(role), 스크린 리더용 라벨링, 키보드 인터랙션 집중 보완 및 Surgical 정밀 수정 지시서.
|
||||
* **`tasks/03_test_agent.md` (Role 3):** 생성된 컴포넌트 사양 검증을 위한 `*.spec.ts` 단위 테스트 코드 및 Mock 데이터 구축 지시서.
|
||||
* **`run-squad.js`:** Claude Code CLI를 연속 구동하여 세 단계의 코딩 에이전트를 차례로 자동 실행하고, 마지막에 Vitest 테스트 엔진을 가동하여 검증을 완수해 내는 마스터 러너 스크립트.
|
||||
|
||||
#### ③ 스쿼드 자동 파이프라인 가동
|
||||
스쿼드가 세팅되면, 해당 컴포넌트 폴더 내의 마스터 스크립트를 즉시 가동하여 오토파일럿 개발을 시작할 수 있습니다:
|
||||
```bash
|
||||
node squad/<ComponentName>/run-squad.js
|
||||
```
|
||||
|
||||
### 📌 시니어 FE 관점의 기대 효과
|
||||
* **결점 제로(Defect-Free) 컴포넌트 완성:** 각 역할군이 하나의 관점(UI 구조 -> 접근성 -> 테스트 품질)에 완벽히 몰입하여 단계별로 코드를 가꾸고 다듬기 때문에, 품질적으로 완벽무결한 컴포넌트가 조립됩니다.
|
||||
* **테스트 주도 개발(TDD)의 정수 체득:** 최종 테스트가 완료될 때까지 에이전트 루프가 가동되므로, 코드를 올리기도 전에 모든 단위 동작과 가시적인 비즈니스 엣지 케이스들의 통과를 보장받은 채 개발이 마무리됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 실무 도입 시 베스트 프랙티스
|
||||
|
||||
1. **지속성 확보를 위한 git ignore 추가 권장:**
|
||||
`squad/` 폴더 내의 작업 지시서나 가이드 파일들은 개발 도중의 중간 생성물(Task sheets)에 해당하므로, 프로젝트의 메인 git 히스토리를 깔끔하게 유지하기 위해 `.gitignore`에 `squad/` 경로를 추가하는 것을 추천합니다.
|
||||
|
||||
2. **개발 전 dreaming 구동 루틴화:**
|
||||
새로운 브랜치를 따거나 대규모 PR을 머지받았을 때는 에이전트에게 일을 시키기 전 `node .claude/skills/dreaming/scripts/dreaming.js`를 한 번 실행해 주는 것이 좋습니다. 에이전트의 뇌 스냅샷을 1초 만에 최신화해 줍니다.
|
||||
62
.claude/rules/claude-workflow.md
Normal file
62
.claude/rules/claude-workflow.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Claude 작업 방식 지침
|
||||
|
||||
이 문서는 Claude가 팀 프로젝트에서 작업할 때 따라야 할 일반적인 원칙을 정의합니다.
|
||||
|
||||
## 기본 원칙
|
||||
|
||||
1. **기존 코드 존중**: 수정 전에 관련 파일과 주변 컨벤션을 먼저 파악합니다.
|
||||
2. **최소 변경**: 요구사항을 충족하는 최소한의 변경만 수행합니다. 관련 없는 리팩토링은 별도 작업으로 분리합니다.
|
||||
3. **가정 대신 질문**: 요구사항이 모호하면 추측하지 말고 사용자에게 확인합니다.
|
||||
4. **근거 있는 수정**: 코드 변경의 이유를 설명할 수 있어야 합니다.
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. **탐색 (Explore)**
|
||||
- 관련 파일을 먼저 읽고 프로젝트 구조를 파악합니다.
|
||||
- 유사한 패턴이 이미 존재하는지 확인합니다.
|
||||
|
||||
2. **계획 (Plan)**
|
||||
- 여러 파일을 수정하거나 복잡한 작업이면 할 일 목록을 만들어 공유합니다.
|
||||
- 아키텍처에 영향을 주는 변경은 착수 전에 사용자 승인을 받습니다.
|
||||
|
||||
3. **구현 (Implement)**
|
||||
- 한 번에 하나의 논리적 변경에 집중합니다.
|
||||
- 공통 지침과 프로젝트 지침을 모두 준수합니다.
|
||||
|
||||
4. **검증 (Verify)**
|
||||
- 린트 / 타입체크 / 빌드가 깨지지 않는지 확인합니다.
|
||||
- 테스트가 있는 프로젝트라면 관련 테스트를 실행합니다.
|
||||
- 수동 검증이 필요한 경우 확인 방법을 사용자에게 안내합니다.
|
||||
|
||||
## 해서는 안 되는 것
|
||||
|
||||
- **임의 기능 추가 금지**: 사용자가 요청하지 않은 기능을 추가하지 않습니다.
|
||||
- **기존 코드 대량 리팩토링 금지**: 요청 범위를 벗어나는 변경은 하지 않습니다.
|
||||
- **주석 / 문서 임의 삭제 금지**: 불필요해 보여도 삭제 전 사용자에게 확인합니다.
|
||||
- **비밀정보 출력 금지**: 환경변수, 키, 토큰 등은 코드에 하드코딩하지 않습니다.
|
||||
- **의존성 버전 임의 변경 금지**: 요청이 없다면 `package.json`의 버전을 변경하지 않습니다.
|
||||
- **강제 푸시 / 히스토리 재작성 금지**: `push --force`, `reset --hard` 등은 사용자의 명시적 요청 없이 실행하지 않습니다.
|
||||
|
||||
## 커뮤니케이션
|
||||
|
||||
- 답변은 간결하게, 결론을 먼저 말합니다.
|
||||
- 코드를 수정했다면 **어떤 파일을 어떻게 바꿨는지** 요약합니다.
|
||||
- 불확실한 부분은 솔직하게 밝히고 대안을 제시합니다.
|
||||
- 긴 설명보다 실제 결과물(코드/파일)을 우선합니다.
|
||||
|
||||
## 파일 작업 원칙
|
||||
|
||||
- 새 파일 생성보다 **기존 파일 수정**을 우선합니다.
|
||||
- README, 문서는 사용자가 명시적으로 요청했을 때만 생성합니다.
|
||||
- 파일을 읽지 않고 수정하지 않습니다.
|
||||
- 대량 변경 시에는 diff를 확인할 수 있도록 단계별로 진행합니다.
|
||||
|
||||
## 질문이 필요한 상황
|
||||
|
||||
다음의 경우 반드시 사용자에게 확인을 요청합니다.
|
||||
|
||||
- 요구사항의 일부가 불명확할 때
|
||||
- 여러 구현 방식이 있고 각각 장단점이 뚜렷할 때
|
||||
- 공통 지침과 프로젝트 지침이 충돌할 때
|
||||
- 파괴적 작업(파일 삭제, 데이터 마이그레이션, 스키마 변경 등)이 필요할 때
|
||||
- 외부 서비스 호출이나 결제 관련 작업일 때
|
||||
50
.claude/rules/coding-conventions.md
Normal file
50
.claude/rules/coding-conventions.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 코딩 컨벤션
|
||||
|
||||
## 기본 원칙
|
||||
|
||||
- **가독성 우선**: 영리한 코드보다 읽기 쉬운 코드를 선호합니다.
|
||||
- **일관성 유지**: 기존 코드의 스타일을 먼저 관찰하고 그에 맞춥니다.
|
||||
- **작은 단위**: 함수와 파일은 한 가지 책임만 지도록 작게 유지합니다.
|
||||
|
||||
## 포맷팅
|
||||
|
||||
- 들여쓰기: 스페이스 2칸 (탭 사용 금지)
|
||||
- 문자열: 싱글 쿼터(`'`) 사용, JSX/템플릿 속성값은 더블 쿼터(`"`)
|
||||
- 세미콜론: 생략하지 않고 항상 작성
|
||||
- 줄 끝 공백 제거, 파일 끝 개행 1줄 유지
|
||||
- 한 줄 최대 100자 (초과 시 줄바꿈)
|
||||
- Prettier 설정 파일(`.prettierrc`)이 있는 경우 해당 설정을 우선합니다.
|
||||
|
||||
## 네이밍
|
||||
|
||||
- **변수/함수**: `camelCase` (예: `userProfile`, `fetchUserData`)
|
||||
- **상수**: `UPPER_SNAKE_CASE` (예: `MAX_RETRY_COUNT`)
|
||||
- **컴포넌트/클래스/타입**: `PascalCase` (예: `UserCard`, `OrderStatus`)
|
||||
- **파일명**
|
||||
- Vue 컴포넌트: `PascalCase.vue` (예: `UserCard.vue`)
|
||||
- Composable: `use` 접두사 + `camelCase` (예: `useAuth.ts`)
|
||||
- 일반 TS 모듈: `kebab-case.ts` (예: `format-date.ts`)
|
||||
- **이벤트 핸들러**: `handle` 또는 `on` 접두사 (예: `handleClick`, `onSubmit`)
|
||||
- **불리언**: `is`, `has`, `can`, `should` 접두사 (예: `isLoading`, `hasError`)
|
||||
|
||||
## 타입
|
||||
|
||||
- `any` 사용 금지. 불가피할 경우 주석으로 이유를 남기고 `unknown`을 먼저 고려합니다.
|
||||
- 함수 시그니처에는 매개변수와 반환 타입을 명시합니다.
|
||||
- 공개 API(타 모듈에서 import 되는 것)는 반드시 타입을 export 합니다.
|
||||
- 유니온 타입은 `as const` 또는 별도 타입 alias로 관리합니다.
|
||||
|
||||
## 주석
|
||||
|
||||
- "무엇을" 보다 "왜"를 설명합니다.
|
||||
- TODO/FIXME 주석에는 작성자와 날짜 또는 이슈 번호를 포함합니다.
|
||||
- 공개 함수/컴포넌트에는 JSDoc 한 줄 설명을 권장합니다.
|
||||
|
||||
## import 순서
|
||||
|
||||
1. 외부 라이브러리 (예: `vue`, `nuxt`)
|
||||
2. 내부 절대 경로 (예: `~/components/...`)
|
||||
3. 상대 경로 (예: `./utils`)
|
||||
4. 타입 only import는 각 그룹 내에서 별도 블록으로 분리
|
||||
|
||||
그룹 사이에는 빈 줄을 한 줄 둡니다.
|
||||
83
.claude/rules/commit-pr.md
Normal file
83
.claude/rules/commit-pr.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 커밋 / PR 규칙
|
||||
|
||||
## 커밋 메시지
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/)를 따릅니다.
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
### type
|
||||
|
||||
- `feat`: 새로운 기능 추가
|
||||
- `fix`: 버그 수정
|
||||
- `refactor`: 기능 변화 없는 구조 개선
|
||||
- `style`: 코드 포맷/세미콜론 등 스타일 변경
|
||||
- `docs`: 문서 수정
|
||||
- `test`: 테스트 추가/수정
|
||||
- `chore`: 빌드, 설정, 패키지 업데이트 등
|
||||
- `perf`: 성능 개선
|
||||
- `ci`: CI 설정 변경
|
||||
|
||||
### 작성 규칙
|
||||
|
||||
- **subject**는 50자 이내, 명령형 현재 시제(예: `add`, `fix` — `added`, `fixes` 아님)
|
||||
- subject 끝에 마침표를 찍지 않습니다.
|
||||
- body는 "무엇을"보다 "왜"를 설명합니다. 한 줄 72자 이내로 줄바꿈합니다.
|
||||
- 한 커밋에는 하나의 논리적 변경만 담습니다.
|
||||
|
||||
### 예시
|
||||
|
||||
```
|
||||
feat(user): add profile image upload
|
||||
|
||||
프로필 이미지 업로드 요구사항에 따라 multipart 업로드 경로를 추가했습니다.
|
||||
기존 텍스트 필드 업데이트 API는 변경하지 않았습니다.
|
||||
|
||||
Refs: #123
|
||||
```
|
||||
|
||||
## Pull Request
|
||||
|
||||
### 제목
|
||||
|
||||
커밋 메시지와 동일한 컨벤션을 따릅니다. (`<type>(<scope>): <subject>`)
|
||||
|
||||
### 본문 템플릿
|
||||
|
||||
```markdown
|
||||
## 변경 사항
|
||||
- 무엇이 바뀌었는지 요약
|
||||
|
||||
## 배경 / 이유
|
||||
- 왜 이 변경이 필요했는지
|
||||
|
||||
## 테스트
|
||||
- 어떻게 검증했는지 (수동/자동 테스트 내용)
|
||||
|
||||
## 스크린샷 (UI 변경 시)
|
||||
- Before / After
|
||||
|
||||
## 체크리스트
|
||||
- [ ] 로컬에서 빌드/테스트 통과
|
||||
- [ ] 린트/포맷 통과
|
||||
- [ ] 공통 지침(gameservice-fe-agent) 준수
|
||||
- [ ] 관련 문서 업데이트
|
||||
```
|
||||
|
||||
### 리뷰 기준
|
||||
|
||||
- 최소 1명 이상의 승인 필요
|
||||
- CI(Lint / Test / Build) 전부 통과 필요
|
||||
- 머지 전략은 **Squash and merge**를 기본으로 합니다.
|
||||
- 리뷰어는 변경 범위에 대해 질문이 남지 않도록 배경을 충분히 이해한 뒤 승인합니다.
|
||||
|
||||
### Draft PR
|
||||
|
||||
- 작업 중간 중간 피드백이 필요한 경우 Draft로 먼저 올리는 것을 권장합니다.
|
||||
- Draft 상태에서는 CI 실패가 있어도 괜찮습니다.
|
||||
47
.claude/rules/framework-rules.md
Normal file
47
.claude/rules/framework-rules.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 프레임워크 / 라이브러리 사용 규칙
|
||||
|
||||
## Vue 3 / Nuxt
|
||||
|
||||
### 컴포넌트 작성
|
||||
|
||||
- **`<script setup lang="ts">` 사용**을 기본으로 합니다. Options API는 신규 코드에서 사용하지 않습니다.
|
||||
- 컴포넌트는 단일 책임 원칙을 지키며, 200줄을 넘으면 분리를 검토합니다.
|
||||
- Props는 `defineProps<T>()` 제네릭 형태로 타입을 명시합니다.
|
||||
- Emits는 `defineEmits<{ ... }>()` 제네릭 형태로 선언합니다.
|
||||
- `ref` vs `reactive`: 원시값과 단일 객체는 `ref`, 복잡한 상태 트리는 `reactive`를 고려합니다. 일관성을 위해 팀 내에서 가능한 `ref`를 우선합니다.
|
||||
|
||||
### 상태 관리
|
||||
|
||||
- 컴포넌트 내 로컬 상태: `ref` / `reactive`
|
||||
- 여러 컴포넌트가 공유하는 상태: **Pinia** 사용
|
||||
- 서버 상태: Nuxt `useFetch` / `useAsyncData` 사용, 직접 `fetch` 호출은 지양합니다.
|
||||
|
||||
### 라우팅
|
||||
|
||||
- Nuxt의 파일 기반 라우팅을 사용합니다. 수동 라우트 정의는 특수한 경우에만 허용됩니다.
|
||||
- 동적 라우트 파라미터는 `[param].vue` 형식을 사용합니다.
|
||||
|
||||
### Composable
|
||||
|
||||
- 재사용 가능한 로직은 `composables/` 디렉토리의 `useXxx` 함수로 추출합니다.
|
||||
- Composable은 부수효과를 최소화하고, 반환 객체에 상태와 메서드를 함께 묶어 반환합니다.
|
||||
|
||||
## TypeScript
|
||||
|
||||
- `strict: true`를 유지합니다.
|
||||
- 공용 타입은 `types/` 또는 각 도메인의 `types.ts`에 모아둡니다.
|
||||
- 외부 API 응답은 반드시 타입을 정의하여 사용합니다.
|
||||
|
||||
## Tailwind CSS
|
||||
|
||||
- **유틸리티 클래스 우선** 사용. 공통 패턴은 컴포넌트로 추출합니다.
|
||||
- `@apply`는 꼭 필요한 경우에만 사용하고, 가능한 유틸리티를 직접 나열합니다.
|
||||
- 임의값 클래스(`w-[123px]`)는 디자인 시스템에 등록되지 않은 값에만 제한적으로 사용합니다.
|
||||
- 조건부 클래스는 `clsx` 또는 `cn` 유틸리티를 사용하여 가독성을 확보합니다.
|
||||
- 클래스 순서는 Tailwind 공식 프리셋(`prettier-plugin-tailwindcss`)을 따릅니다.
|
||||
|
||||
## 외부 라이브러리 도입
|
||||
|
||||
- 새로운 라이브러리를 추가할 때는 **PR 설명에 도입 이유, 번들 영향, 대안 검토 내용**을 기록합니다.
|
||||
- 동일 기능의 라이브러리를 중복 도입하지 않습니다.
|
||||
- 유지보수가 중단된 패키지(6개월 이상 업데이트 없음)는 도입하지 않습니다.
|
||||
96
.claude/scripts/init-project.sh
Executable file
96
.claude/scripts/init-project.sh
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# gameservice-fe-agent project template initializer
|
||||
# 이미 .claude/common 이 설치된 프로젝트에서 templates/project/ 의
|
||||
# 양식을 .claude/project/ 에 복사합니다.
|
||||
#
|
||||
# 사용법:
|
||||
# bash .claude/common/scripts/init-project.sh # 없는 파일만 복사 (기본)
|
||||
# bash .claude/common/scripts/init-project.sh --force # 기존 파일을 덮어씀
|
||||
# bash .claude/common/scripts/init-project.sh --diff # 차이만 보여주고 복사는 안 함
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
COMMON_PATH=".claude/common"
|
||||
PROJECT_PATH=".claude/project"
|
||||
TEMPLATE_DIR="$COMMON_PATH/templates/project"
|
||||
|
||||
MODE="safe" # safe | force | diff
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force) MODE="force" ;;
|
||||
--diff) MODE="diff" ;;
|
||||
-h|--help)
|
||||
grep '^#' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "❌ 알 수 없는 옵션: $arg" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "❌ 현재 디렉토리는 Git 저장소가 아닙니다." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
if [[ ! -d "$TEMPLATE_DIR" ]]; then
|
||||
echo "❌ $TEMPLATE_DIR 가 없습니다. 먼저 submodule 을 설치/업데이트하세요:" >&2
|
||||
echo " git submodule update --init --recursive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$PROJECT_PATH"
|
||||
|
||||
echo "📝 templates/project → $PROJECT_PATH (mode=$MODE)"
|
||||
echo ""
|
||||
|
||||
shopt -s nullglob
|
||||
copied=0
|
||||
skipped=0
|
||||
diffed=0
|
||||
for f in "$TEMPLATE_DIR"/*.md; do
|
||||
name="$(basename "$f")"
|
||||
dest="$PROJECT_PATH/$name"
|
||||
|
||||
case "$MODE" in
|
||||
diff)
|
||||
if [[ -f "$dest" ]]; then
|
||||
if ! diff -q "$f" "$dest" >/dev/null 2>&1; then
|
||||
echo "📄 diff: $dest"
|
||||
diff -u "$dest" "$f" || true
|
||||
echo ""
|
||||
diffed=$((diffed + 1))
|
||||
fi
|
||||
else
|
||||
echo "➕ 새 양식: $dest (복사되지 않음. --force 로 복사하세요)"
|
||||
diffed=$((diffed + 1))
|
||||
fi
|
||||
;;
|
||||
force)
|
||||
cp "$f" "$dest"
|
||||
echo " ✅ $dest (덮어씀)"
|
||||
copied=$((copied + 1))
|
||||
;;
|
||||
safe)
|
||||
if [[ -f "$dest" ]]; then
|
||||
echo " ⏭ $dest (이미 존재)"
|
||||
skipped=$((skipped + 1))
|
||||
else
|
||||
cp "$f" "$dest"
|
||||
echo " ✅ $dest"
|
||||
copied=$((copied + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
case "$MODE" in
|
||||
diff) echo "🔍 차이가 있는 파일: $diffed 개";;
|
||||
*) echo "🎉 완료: 복사 $copied개 / 건너뜀 $skipped개";;
|
||||
esac
|
||||
209
.claude/scripts/install.sh
Executable file
209
.claude/scripts/install.sh
Executable file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# gameservice-fe-agent installer
|
||||
# 현재 Git 프로젝트의 .claude/common 경로에 gameservice-fe-agent 저장소를
|
||||
# submodule 로 추가하고, templates/ 에서 프로젝트 지침 양식과
|
||||
# CLAUDE.md 템플릿을 복사합니다.
|
||||
#
|
||||
# 사용법:
|
||||
# bash scripts/install.sh <repo-url> [<branch>]
|
||||
#
|
||||
# 예:
|
||||
# bash scripts/install.sh https://git.sginfra.net/sgp-web-d/gameservice-fe-agent master
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="${1:-}"
|
||||
BRANCH="${2:-master}"
|
||||
TARGET_PATH=".claude/common"
|
||||
PROJECT_PATH=".claude/project"
|
||||
|
||||
if [[ -z "$REPO_URL" ]]; then
|
||||
echo "❌ 사용법: bash scripts/install.sh <repo-url> [branch]" >&2
|
||||
echo " 예: bash scripts/install.sh https://git.sginfra.net/sgp-web-d/gameservice-fe-agent master" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Git 프로젝트인지 확인
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "❌ 현재 디렉토리는 Git 저장소가 아닙니다. 먼저 'git init' 후 실행해주세요." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 루트로 이동
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
# 1) Submodule 추가
|
||||
if [[ -d "$TARGET_PATH" ]]; then
|
||||
echo "⚠️ '$TARGET_PATH' 경로가 이미 존재합니다. submodule 추가를 건너뜁니다."
|
||||
else
|
||||
echo "📦 gameservice-fe-agent 를 submodule 로 추가합니다..."
|
||||
git submodule add -b "$BRANCH" "$REPO_URL" "$TARGET_PATH"
|
||||
git submodule update --init --recursive
|
||||
echo "✅ submodule 추가 완료: $TARGET_PATH"
|
||||
fi
|
||||
|
||||
# 2) 프로젝트 지침 양식 복사 (templates/project/ → .claude/project/)
|
||||
mkdir -p "$PROJECT_PATH"
|
||||
|
||||
TEMPLATE_DIR="$TARGET_PATH/templates/project"
|
||||
if [[ -d "$TEMPLATE_DIR" ]]; then
|
||||
echo "📝 프로젝트 지침 양식을 복사합니다..."
|
||||
for f in "$TEMPLATE_DIR"/*.md; do
|
||||
[[ -e "$f" ]] || continue
|
||||
name="$(basename "$f")"
|
||||
dest="$PROJECT_PATH/$name"
|
||||
if [[ -f "$dest" ]]; then
|
||||
echo " ⏭ $dest (이미 존재 - 건너뜀)"
|
||||
else
|
||||
cp "$f" "$dest"
|
||||
echo " ✅ $dest"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "⚠️ $TEMPLATE_DIR 를 찾지 못했습니다. 공통 저장소의 templates 가 오래됐을 수 있습니다."
|
||||
fi
|
||||
|
||||
# 3) 루트 CLAUDE.md 템플릿 복사
|
||||
if [[ ! -f "CLAUDE.md" ]]; then
|
||||
TPL_FILE="$TARGET_PATH/templates/CLAUDE.md.tpl"
|
||||
if [[ -f "$TPL_FILE" ]]; then
|
||||
cp "$TPL_FILE" CLAUDE.md
|
||||
echo "✅ CLAUDE.md 템플릿을 생성했습니다."
|
||||
else
|
||||
cat > CLAUDE.md <<'EOF'
|
||||
# <프로젝트 이름>
|
||||
|
||||
## 공통 지침
|
||||
@.claude/common/CLAUDE.md
|
||||
|
||||
## 프로젝트 지침
|
||||
@.claude/project/overview.md
|
||||
@.claude/project/conventions.md
|
||||
@.claude/project/architecture.md
|
||||
|
||||
## 슬래시 커맨드 연결
|
||||
|
||||
- `/init` 커맨드가 실행되면 반드시 `project-init` 스킬을 호출하세요.
|
||||
|
||||
## Behavioral Guidelines
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
> Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
### 1. Think Before Coding
|
||||
|
||||
Don't assume. Don't hide confusion. Surface tradeoffs.
|
||||
|
||||
Before implementing:
|
||||
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them — don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
### 2. Simplicity First
|
||||
|
||||
Minimum code that solves the problem. Nothing speculative.
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
### 3. Surgical Changes
|
||||
|
||||
Touch only what you must. Clean up only your own mess.
|
||||
|
||||
When editing existing code:
|
||||
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it — don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
### 4. Goal-Driven Execution
|
||||
|
||||
Define success criteria. Loop until verified.
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
These guidelines are working if: fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
EOF
|
||||
echo "✅ CLAUDE.md 템플릿을 생성했습니다. (fallback)"
|
||||
fi
|
||||
else
|
||||
echo "ℹ️ 기존 CLAUDE.md 가 이미 존재합니다. 아래 블록을 수동으로 추가하세요:"
|
||||
echo ""
|
||||
echo " ## 공통 지침"
|
||||
echo " @.claude/common/CLAUDE.md"
|
||||
echo ""
|
||||
echo " ## 프로젝트 지침"
|
||||
echo " @.claude/project/overview.md"
|
||||
echo " @.claude/project/conventions.md"
|
||||
echo " @.claude/project/architecture.md"
|
||||
echo ""
|
||||
echo " ## Behavioral Guidelines"
|
||||
echo " (자세한 내용은 $TARGET_PATH/templates/CLAUDE.md.tpl 참고)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 4) 공통 skill 심볼릭 링크 (.claude/common/skills/* → .claude/skills/*)
|
||||
SKILLS_SRC="$TARGET_PATH/skills"
|
||||
SKILLS_DEST=".claude/skills"
|
||||
|
||||
if [[ -d "$SKILLS_SRC" ]]; then
|
||||
echo "🔗 공통 skill 을 $SKILLS_DEST 로 링크합니다..."
|
||||
mkdir -p "$SKILLS_DEST"
|
||||
for dir in "$SKILLS_SRC"/*/; do
|
||||
[[ -d "$dir" ]] || continue
|
||||
name="$(basename "$dir")"
|
||||
[[ "$name" == "README"* ]] && continue
|
||||
link_src="../common/skills/$name"
|
||||
link_dest="$SKILLS_DEST/$name"
|
||||
if [[ -L "$link_dest" ]]; then
|
||||
echo " ⏭ $link_dest (이미 링크됨)"
|
||||
elif [[ -e "$link_dest" ]]; then
|
||||
echo " ⚠️ $link_dest (실제 파일/폴더 존재 - 건너뜀. link-skills.sh --force 로 덮어쓰기)"
|
||||
else
|
||||
ln -s "$link_src" "$link_dest"
|
||||
echo " ✅ $link_dest → $link_src"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 설치가 완료되었습니다."
|
||||
echo " - 공통 지침: $TARGET_PATH/CLAUDE.md"
|
||||
echo " - 프로젝트 지침: $PROJECT_PATH/"
|
||||
echo " - 공통 skill: $SKILLS_DEST/ (submodule 에 심볼릭 링크)"
|
||||
echo " - 엔트리 파일: CLAUDE.md"
|
||||
echo ""
|
||||
echo "다음 작업을 진행해 주세요:"
|
||||
echo " 1) $PROJECT_PATH/*.md 내용을 프로젝트에 맞게 채우기"
|
||||
echo " 2) 변경 사항을 커밋하기"
|
||||
echo " git add .gitmodules .claude CLAUDE.md"
|
||||
echo " git commit -m 'chore: add gameservice-fe-agent submodule'"
|
||||
139
.claude/scripts/link-skills.sh
Executable file
139
.claude/scripts/link-skills.sh
Executable file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# gameservice-fe-agent skill linker
|
||||
# 공통 저장소의 skills/* 를 프로젝트의 .claude/skills/* 로 심볼릭 링크합니다.
|
||||
# 심볼릭 링크이므로 submodule 업데이트 시 skill 도 자동으로 최신 버전이 됩니다.
|
||||
#
|
||||
# 사용법:
|
||||
# bash .claude/common/scripts/link-skills.sh # 모든 skill 링크
|
||||
# bash .claude/common/scripts/link-skills.sh <skill-name> # 특정 skill 만
|
||||
# bash .claude/common/scripts/link-skills.sh --dry-run # 실제 링크 없이 미리보기
|
||||
# bash .claude/common/scripts/link-skills.sh --force # 기존 링크/폴더 덮어쓰기
|
||||
# bash .claude/common/scripts/link-skills.sh --unlink # 공통 skill 링크 제거
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
COMMON_PATH=".claude/common"
|
||||
SKILLS_SRC="$COMMON_PATH/skills"
|
||||
SKILLS_DEST=".claude/skills"
|
||||
|
||||
MODE="safe" # safe | force | dry-run | unlink
|
||||
TARGET=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) MODE="dry-run" ;;
|
||||
--force) MODE="force" ;;
|
||||
--unlink) MODE="unlink" ;;
|
||||
-h|--help)
|
||||
grep '^#' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
echo "❌ 알 수 없는 옵션: $arg" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
TARGET="$arg"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "❌ 현재 디렉토리는 Git 저장소가 아닙니다." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
if [[ ! -d "$SKILLS_SRC" ]]; then
|
||||
echo "❌ $SKILLS_SRC 가 없습니다. 먼저 submodule 을 설치/업데이트하세요:" >&2
|
||||
echo " git submodule update --init --recursive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$SKILLS_DEST"
|
||||
|
||||
# 링크 대상 결정
|
||||
declare -a skills
|
||||
if [[ -n "$TARGET" ]]; then
|
||||
if [[ ! -d "$SKILLS_SRC/$TARGET" ]]; then
|
||||
echo "❌ '$TARGET' skill 을 $SKILLS_SRC 에서 찾지 못했습니다." >&2
|
||||
echo " 사용 가능한 skill:" >&2
|
||||
ls -1 "$SKILLS_SRC" 2>/dev/null | grep -v '^README' | sed 's/^/ - /' >&2
|
||||
exit 1
|
||||
fi
|
||||
skills=("$TARGET")
|
||||
else
|
||||
shopt -s nullglob
|
||||
for dir in "$SKILLS_SRC"/*/; do
|
||||
name="$(basename "$dir")"
|
||||
# README 같은 파일은 이미 걸러지지만 추가 보호
|
||||
[[ "$name" == "README"* ]] && continue
|
||||
skills+=("$name")
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#skills[@]} -eq 0 ]]; then
|
||||
echo "ℹ️ 링크할 skill 이 없습니다."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🔗 공통 skill 링크 (mode=$MODE)"
|
||||
echo " source: $SKILLS_SRC"
|
||||
echo " dest: $SKILLS_DEST"
|
||||
echo ""
|
||||
|
||||
linked=0
|
||||
skipped=0
|
||||
removed=0
|
||||
|
||||
for name in "${skills[@]}"; do
|
||||
src="../common/skills/$name" # 심볼릭 링크의 상대 경로 (.claude/skills 기준)
|
||||
dest="$SKILLS_DEST/$name"
|
||||
|
||||
case "$MODE" in
|
||||
unlink)
|
||||
if [[ -L "$dest" ]]; then
|
||||
rm "$dest"
|
||||
echo " 🗑 $dest (링크 제거)"
|
||||
removed=$((removed + 1))
|
||||
else
|
||||
echo " ⏭ $dest (링크 아님 - 건너뜀)"
|
||||
fi
|
||||
;;
|
||||
dry-run)
|
||||
if [[ -e "$dest" || -L "$dest" ]]; then
|
||||
echo " ⏭ $dest (이미 존재)"
|
||||
else
|
||||
echo " ➕ ln -s $src $dest"
|
||||
fi
|
||||
;;
|
||||
force)
|
||||
rm -rf "$dest"
|
||||
ln -s "$src" "$dest"
|
||||
echo " ✅ $dest → $src (덮어씀)"
|
||||
linked=$((linked + 1))
|
||||
;;
|
||||
safe)
|
||||
if [[ -L "$dest" ]]; then
|
||||
echo " ⏭ $dest (이미 링크됨)"
|
||||
skipped=$((skipped + 1))
|
||||
elif [[ -e "$dest" ]]; then
|
||||
echo " ⚠️ $dest (실제 파일/폴더가 존재. --force 로 덮어쓰세요)"
|
||||
skipped=$((skipped + 1))
|
||||
else
|
||||
ln -s "$src" "$dest"
|
||||
echo " ✅ $dest → $src"
|
||||
linked=$((linked + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
case "$MODE" in
|
||||
unlink) echo "🎉 제거 완료: $removed 개 링크 제거됨";;
|
||||
dry-run) echo "🔍 미리보기 완료 (실제 변경 없음)";;
|
||||
*) echo "🎉 완료: 링크 $linked개 / 건너뜀 $skipped개";;
|
||||
esac
|
||||
36
.claude/scripts/update.sh
Executable file
36
.claude/scripts/update.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# gameservice-fe-agent updater
|
||||
# 현재 프로젝트에 설치된 .claude/common submodule 을 최신 버전으로 갱신합니다.
|
||||
#
|
||||
# 사용법:
|
||||
# bash .claude/common/scripts/update.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_PATH=".claude/common"
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "❌ 현재 디렉토리는 Git 저장소가 아닙니다." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$TARGET_PATH" ]]; then
|
||||
echo "❌ '$TARGET_PATH' 가 존재하지 않습니다. 먼저 install.sh 로 설치하세요." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔄 gameservice-fe-agent 를 최신 버전으로 업데이트합니다..."
|
||||
git submodule update --remote --merge "$TARGET_PATH"
|
||||
|
||||
# 변경 사항 확인
|
||||
if git diff --quiet -- "$TARGET_PATH"; then
|
||||
echo "✅ 이미 최신 상태입니다."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ 업데이트가 완료되었습니다. 변경된 submodule 포인터를 커밋하세요:"
|
||||
echo ""
|
||||
echo " git add $TARGET_PATH"
|
||||
echo " git commit -m 'chore: update gameservice-fe-agent submodule'"
|
||||
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"harness@harness-marketplace": true
|
||||
}
|
||||
}
|
||||
BIN
.claude/skills/.DS_Store
vendored
Normal file
BIN
.claude/skills/.DS_Store
vendored
Normal file
Binary file not shown.
52
.claude/skills/README.md
Normal file
52
.claude/skills/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Common Skills
|
||||
|
||||
이 폴더는 팀 공통으로 사용할 Claude **skill** 들을 모아두는 공간입니다.
|
||||
각 프로젝트는 submodule 로 이 저장소를 가져온 뒤, `.claude/common/skills/*` 를
|
||||
`.claude/skills/*` 로 **심볼릭 링크**하여 사용합니다.
|
||||
|
||||
## Skill 구조
|
||||
|
||||
각 skill 은 하나의 폴더이며, 루트에 `SKILL.md` 를 가집니다.
|
||||
|
||||
```
|
||||
skills/
|
||||
└── <skill-name>/
|
||||
├── SKILL.md # YAML frontmatter (name, description) + 지시문
|
||||
└── (선택) 추가 자료 — 예시 파일, 템플릿, 스크립트 등
|
||||
```
|
||||
|
||||
`SKILL.md` 예시:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: <skill-name>
|
||||
description: <Claude 가 이 skill 을 언제 써야 하는지 한 줄로>
|
||||
---
|
||||
|
||||
# <스킬 제목>
|
||||
|
||||
## 언제 사용하는가
|
||||
...
|
||||
|
||||
## 작업 순서
|
||||
...
|
||||
```
|
||||
|
||||
## 프로젝트에 연결하기
|
||||
|
||||
```bash
|
||||
# 공통 저장소의 skill 을 .claude/skills/ 로 전부 링크
|
||||
bash .claude/common/scripts/link-skills.sh
|
||||
|
||||
# 차이 확인
|
||||
bash .claude/common/scripts/link-skills.sh --dry-run
|
||||
|
||||
# 특정 skill 만 링크
|
||||
bash .claude/common/scripts/link-skills.sh vue-component-review
|
||||
```
|
||||
|
||||
## 새 skill 추가 프로세스
|
||||
|
||||
1. 이 저장소에서 `skills/<skill-name>/SKILL.md` 를 작성
|
||||
2. PR 을 올리고 팀 리뷰
|
||||
3. 머지 후 각 프로젝트에서 `git submodule update --remote` → `link-skills.sh` 실행
|
||||
78
.claude/skills/conventional-commit/SKILL.md
Normal file
78
.claude/skills/conventional-commit/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: conventional-commit
|
||||
description: Git 변경사항을 팀의 Conventional Commits 규칙(gameservice-fe-agent/rules/commit-pr.md)에 맞춰 커밋 메시지로 작성할 때 사용합니다. 사용자가 "커밋 메시지 만들어줘", "commit", "커밋해줘" 등을 요청하면 트리거됩니다.
|
||||
---
|
||||
|
||||
# Conventional Commit 작성
|
||||
|
||||
이 skill 은 `git diff --staged` / `git status` 결과를 바탕으로 팀 규칙에 맞는 커밋
|
||||
메시지를 작성합니다.
|
||||
|
||||
## 팀 규칙 요약
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
### type
|
||||
|
||||
- `feat`: 새로운 기능
|
||||
- `fix`: 버그 수정
|
||||
- `refactor`: 기능 변화 없는 구조 개선
|
||||
- `style`: 포맷/세미콜론 등 스타일
|
||||
- `docs`: 문서
|
||||
- `test`: 테스트 추가/수정
|
||||
- `chore`: 빌드, 설정, 패키지
|
||||
- `perf`: 성능 개선
|
||||
- `ci`: CI 설정
|
||||
|
||||
### subject
|
||||
|
||||
- 50자 이내
|
||||
- 명령형 현재 시제 (`add`, `fix` — `added`/`fixes` 아님)
|
||||
- 끝에 마침표 금지
|
||||
- 영어 소문자 시작
|
||||
|
||||
### body
|
||||
|
||||
- "무엇을"보다 **"왜"** 를 설명
|
||||
- 한 줄 72자 이내
|
||||
- 한 커밋 = 한 논리적 변경
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. **상태 확인**
|
||||
- `git status` 로 staged/unstaged 파일 파악
|
||||
- staged 변경이 없으면 사용자에게 먼저 `git add` 를 하라고 안내
|
||||
2. **변경 분석**
|
||||
- `git diff --staged` 로 실제 변경 내용 확인
|
||||
- 변경을 한 문장으로 요약 (type + scope + subject 를 정하는 근거)
|
||||
3. **메시지 작성**
|
||||
- subject 는 50자 이내, 영문 명령형
|
||||
- body 는 한국어로 "왜" 중심 설명 (한 줄 72자 제한)
|
||||
- 이슈 번호가 있으면 `Refs: #123` footer 추가
|
||||
4. **사용자 확인**
|
||||
- 작성한 메시지를 보여주고 커밋 실행 여부를 물음
|
||||
- 사용자가 명시적으로 "커밋해" 라고 할 때만 실제 `git commit` 실행
|
||||
|
||||
## 출력 예시
|
||||
|
||||
```
|
||||
feat(user): add profile image upload
|
||||
|
||||
프로필 이미지 업로드 요구사항에 따라 multipart 업로드 경로를 추가했습니다.
|
||||
기존 텍스트 필드 업데이트 API 는 변경하지 않았습니다.
|
||||
|
||||
Refs: #123
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
- **논리적으로 섞인 변경**이 감지되면(예: feat + refactor 가 같이 있음) 커밋 분리를 제안한다.
|
||||
- `package.json` / lock 파일이 함께 staged 되어 있으면 의존성 추가 사유를 body 에 명시한다.
|
||||
- 사용자가 커밋 실행을 명시적으로 허락하기 전까지 `git commit` 명령을 직접 실행하지 않는다.
|
||||
- Co-authored-by 등 footer 는 사용자가 요청할 때만 추가한다.
|
||||
150
.claude/skills/dev-api-state/SKILL.md
Normal file
150
.claude/skills/dev-api-state/SKILL.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: dev-api-state
|
||||
description: |
|
||||
API 연동 패턴(useFetch / useAsyncData / $fetch)과 Pinia 상태관리 코드를
|
||||
상황에 맞게 자동 선택·생성합니다. BFF 패턴, 에러 핸들링, 로딩 상태를 포함합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 API 연동 + 스토어 만들어줘", "Pinia store 작성해줘"
|
||||
- "API 데이터 페칭 어떻게 해?", "useFetch vs useAsyncData 언제 써?"
|
||||
- API 키 보호를 위한 BFF(server/api/) 패턴이 필요할 때
|
||||
---
|
||||
|
||||
# API 연동 & 상태관리 (dev-api-state)
|
||||
|
||||
API 스펙 → useFetch/useAsyncData 패턴 선택 → Pinia Setup Store 코드 자동 생성.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 새 API 연동 코드와 Pinia 스토어를 함께 작성할 때
|
||||
- `useFetch` / `$fetch` / `useAsyncData` 중 적절한 패턴을 결정할 때
|
||||
- API 키를 클라이언트에 노출하지 않기 위한 BFF 패턴이 필요할 때
|
||||
|
||||
## 데이터 페칭 패턴 선택 기준
|
||||
|
||||
| 상황 | 권장 패턴 |
|
||||
|---|---|
|
||||
| SSR 페이지 초기 데이터 | `useAsyncData` + `$fetch` |
|
||||
| 컴포넌트 마운트 후 데이터 | `useFetch` |
|
||||
| 사용자 액션으로 트리거 | `$fetch` (직접 호출) |
|
||||
| API 키 보호 필요 | `server/api/` BFF + `useFetch` |
|
||||
| 복잡한 캐싱/의존성 | `useAsyncData` with key |
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: API 스펙 파악
|
||||
|
||||
1. 엔드포인트, 요청/응답 타입을 파악한다.
|
||||
2. 아래를 확인한다:
|
||||
- 인증 헤더 필요 여부
|
||||
- API 키 노출 위험 여부 → BFF 패턴 적용
|
||||
- 캐시 전략 (항상 최신 vs TTL)
|
||||
|
||||
### Phase 2: 타입 정의
|
||||
|
||||
```ts
|
||||
// types/product.ts
|
||||
export interface Product {
|
||||
id: string
|
||||
name: string
|
||||
price: number
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
export interface ProductListResponse {
|
||||
data: Product[]
|
||||
total: number
|
||||
page: number
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: BFF 레이어 (필요 시)
|
||||
|
||||
API 키 보호가 필요한 경우 server/api/ 경유:
|
||||
|
||||
```ts
|
||||
// server/api/products/index.get.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const response = await $fetch<ProductListResponse>(
|
||||
`${process.env.API_BASE}/products`,
|
||||
{
|
||||
headers: { 'x-api-key': process.env.API_KEY! },
|
||||
query,
|
||||
}
|
||||
)
|
||||
return response
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 4: Pinia Setup Store 생성
|
||||
|
||||
Setup Store 패턴을 기본으로 사용한다:
|
||||
|
||||
```ts
|
||||
// stores/product.ts
|
||||
import type { Product } from '~/types/product'
|
||||
|
||||
export const useProductStore = defineStore('product', () => {
|
||||
const items = ref<Product[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const fetchProducts = async (page = 1) => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const { data } = await useFetch('/api/products', { query: { page } })
|
||||
items.value = data.value?.data ?? []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '알 수 없는 오류'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { items, isLoading, error, fetchProducts }
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 5: 컴포넌트 연결 예시
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const productStore = useProductStore()
|
||||
const { items, isLoading, error } = storeToRefs(productStore)
|
||||
|
||||
onMounted(() => productStore.fetchProducts())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isLoading" aria-live="polite">로딩 중...</div>
|
||||
<div v-else-if="error" role="alert">{{ error }}</div>
|
||||
<ul v-else>
|
||||
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## API 연동: <기능명>
|
||||
|
||||
### 선택된 패턴
|
||||
- 페칭: useFetch | useAsyncData | $fetch
|
||||
- BFF: 사용 | 미사용
|
||||
- 이유: [선택 근거]
|
||||
|
||||
### 파일 목록
|
||||
- `types/<domain>.ts`
|
||||
- `server/api/...` (BFF 사용 시)
|
||||
- `stores/<domain>.ts`
|
||||
|
||||
### 코드
|
||||
[타입 → BFF → 스토어 → 컴포넌트 순]
|
||||
```
|
||||
125
.claude/skills/dev-component/SKILL.md
Normal file
125
.claude/skills/dev-component/SKILL.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: dev-component
|
||||
description: |
|
||||
화면 명세를 받아 Atomic Design 기반 컴포넌트 트리를 설계하고
|
||||
Nuxt 표준 디렉토리 구조와 컴포넌트 스켈레톤 코드를 자동 생성합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 화면 컴포넌트로 분리해줘", "컴포넌트 구조 설계해줘"
|
||||
- "Atomic Design으로 나눠줘", "컴포넌트 트리 만들어줘"
|
||||
- 신규 기능의 컴포넌트 아키텍처를 결정해야 할 때
|
||||
---
|
||||
|
||||
# 컴포넌트 아키텍처 설계 (dev-component)
|
||||
|
||||
화면 명세 → Atomic Design 기반 컴포넌트 트리 → Nuxt SFC 스켈레톤 자동 생성.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 신규 페이지/기능의 컴포넌트 구조를 잡을 때
|
||||
- 마크업 완료 후 컴포넌트 분리 기준을 결정할 때
|
||||
- 팀원이 병렬로 작업할 수 있도록 역할 분리가 필요할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- 화면 명세 또는 완성된 마크업 코드
|
||||
- `plan-analyzer` 산출물 (컴포넌트 트리 초안)이 있으면 활용
|
||||
- 기존 `components/` 디렉토리 구조 (재사용 가능 컴포넌트 파악용)
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 기존 컴포넌트 파악
|
||||
|
||||
1. 프로젝트 `components/` 디렉토리를 읽어 재사용 가능한 컴포넌트를 파악한다.
|
||||
2. 설계 전 중복 개발 방지를 위해 유사 컴포넌트 목록을 사용자에게 안내한다.
|
||||
|
||||
### Phase 2: Atomic Design 분류
|
||||
|
||||
각 UI 요소를 아래 5단계로 분류한다:
|
||||
|
||||
| 레벨 | 위치 | 기준 |
|
||||
|---|---|---|
|
||||
| **Atoms** | `components/base/` | 더 이상 분리 불가한 기본 단위 (Button, Icon, Input, Badge) |
|
||||
| **Molecules** | `components/common/` | Atoms 2개 이상 조합 (SearchBar, FormField, Card) |
|
||||
| **Organisms** | `components/<도메인>/` | 독립적 기능 단위 (Header, ProductList, ReviewSection) |
|
||||
| **Templates** | `layouts/` | 페이지 레이아웃 골격 |
|
||||
| **Pages** | `pages/` | 실제 데이터와 결합한 최종 화면 |
|
||||
|
||||
### Phase 3: 컴포넌트 트리 출력
|
||||
|
||||
```
|
||||
pages/
|
||||
└── <page-name>.vue
|
||||
├── layouts/<layout>.vue
|
||||
└── components/
|
||||
├── <domain>/
|
||||
│ ├── <Organism>.vue ← props: { ... }
|
||||
│ └── <Organism>.vue
|
||||
└── common/
|
||||
└── <Molecule>.vue ← props: { ... }, emits: [...]
|
||||
```
|
||||
|
||||
### Phase 4: Props / Emits 설계
|
||||
|
||||
각 컴포넌트의 인터페이스를 정의한다:
|
||||
|
||||
```ts
|
||||
// 예시: ProductCard.vue
|
||||
interface Props {
|
||||
id: string
|
||||
title: string
|
||||
imageUrl: string
|
||||
price: number
|
||||
isSoldOut?: boolean
|
||||
}
|
||||
|
||||
type Emits = {
|
||||
click: [id: string]
|
||||
addToCart: [id: string]
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: 스켈레톤 코드 생성
|
||||
|
||||
각 컴포넌트의 SFC 스켈레톤 파일을 생성한다:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
// Phase 4 정의 붙여넣기
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
// Phase 4 정의 붙여넣기
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- TODO: markup-base / markup-figma 스킬로 마크업 구현 -->
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 컴포넌트 설계: <페이지명>
|
||||
|
||||
### 재사용 가능한 기존 컴포넌트
|
||||
- `components/common/Button.vue` — CTA 버튼에 활용 가능
|
||||
|
||||
### 컴포넌트 트리
|
||||
[ASCII 트리]
|
||||
|
||||
### 신규 생성 파일 목록
|
||||
- `components/product/ProductCard.vue` — Props: id, title, imageUrl, price
|
||||
- `components/product/ProductList.vue` — Props: items[]
|
||||
- ...
|
||||
|
||||
### 스켈레톤 코드
|
||||
[각 파일 코드]
|
||||
```
|
||||
128
.claude/skills/dev-docs/SKILL.md
Normal file
128
.claude/skills/dev-docs/SKILL.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
name: dev-docs
|
||||
description: |
|
||||
Nuxt 3 공식 문서 기반의 Best Practice 코드를 생성합니다.
|
||||
server/, middleware/, plugins/, composables/, components/, nuxt.config 등
|
||||
6개 참조 영역에서 상황에 맞는 패턴을 선택해 즉시 사용 가능한 코드를 제공합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "Nuxt server route 만들어줘", "composable 작성해줘"
|
||||
- "middleware 어떻게 써?", "Nuxt에서 이 기능 어떻게 구현해?"
|
||||
- 공식 문서를 찾아보지 않고 Nuxt 3 패턴 코드가 필요할 때
|
||||
---
|
||||
|
||||
# Nuxt 3 공식 문서 기반 개발 (dev-docs)
|
||||
|
||||
Nuxt 3 공식 Best Practice를 기반으로 즉시 사용 가능한 코드를 생성합니다.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- Nuxt 고유 API(useAsyncData, useFetch, defineEventHandler 등)를 처음 사용할 때
|
||||
- server/, middleware/, plugins/ 등 Nuxt 레이어 구현이 필요할 때
|
||||
- 공식 문서를 찾는 대신 곧바로 Best Practice 코드를 얻고 싶을 때
|
||||
|
||||
## 참조 영역
|
||||
|
||||
| 영역 | 경로 | 주요 API |
|
||||
|---|---|---|
|
||||
| Server Routes | `server/api/`, `server/routes/` | `defineEventHandler`, `readBody`, `getQuery` |
|
||||
| Middleware | `middleware/` | `defineNuxtRouteMiddleware`, `navigateTo` |
|
||||
| Plugins | `plugins/` | `defineNuxtPlugin`, `useNuxtApp` |
|
||||
| Composables | `composables/` | `useState`, `useFetch`, `useAsyncData` |
|
||||
| Components | `components/` | auto-import, `<ClientOnly>`, `<LazyLoad>` |
|
||||
| Config | `nuxt.config.ts` | `runtimeConfig`, `modules`, `routeRules` |
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 요구사항 분석
|
||||
|
||||
1. 사용자 요청에서 구현 영역을 파악한다.
|
||||
- "API 만들어줘" → `server/api/`
|
||||
- "인증 체크" → `middleware/`
|
||||
- "전역 상태" → `composables/` + `useState`
|
||||
- "외부 라이브러리 초기화" → `plugins/`
|
||||
|
||||
2. 관련 Nuxt 3 API를 선택한다.
|
||||
|
||||
### Phase 2: 코드 생성
|
||||
|
||||
#### Server Route 예시
|
||||
```ts
|
||||
// server/api/users/[id].get.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
// 비즈니스 로직
|
||||
return { data }
|
||||
})
|
||||
```
|
||||
|
||||
#### Middleware 예시
|
||||
```ts
|
||||
// middleware/auth.ts
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const { isAuthenticated } = useAuth()
|
||||
if (!isAuthenticated.value) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### Composable 예시
|
||||
```ts
|
||||
// composables/useUser.ts
|
||||
export const useUser = () => {
|
||||
const user = useState<User | null>('user', () => null)
|
||||
|
||||
const fetchUser = async (id: string) => {
|
||||
const { data } = await useFetch<User>(`/api/users/${id}`)
|
||||
user.value = data.value
|
||||
}
|
||||
|
||||
return { user, fetchUser }
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: TypeScript 타입 보강
|
||||
|
||||
- 모든 함수 파라미터/반환값 타입 명시
|
||||
- 외부 API 응답은 `types/` 또는 해당 도메인의 `types.ts`에 정의
|
||||
- `runtimeConfig`는 `RuntimeConfig` 타입 확장으로 타입 안전성 확보
|
||||
|
||||
### Phase 4: 연관 설정 안내
|
||||
|
||||
생성된 코드에 필요한 `nuxt.config.ts` 변경사항이 있으면 함께 안내한다.
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts 필요 설정 예시
|
||||
export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
apiSecret: '', // 서버 전용
|
||||
public: {
|
||||
apiBase: '' // 클라이언트 노출 가능
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## Nuxt 구현: <기능명>
|
||||
|
||||
### 사용 패턴
|
||||
- 영역: server/api/ | middleware/ | composables/ | ...
|
||||
- 주요 API: defineEventHandler, useFetch, ...
|
||||
|
||||
### 코드
|
||||
[파일별 코드]
|
||||
|
||||
### nuxt.config.ts 변경 필요 사항
|
||||
[있는 경우만]
|
||||
|
||||
### 주의사항
|
||||
[SSR/CSR 차이, 캐시 주의 등]
|
||||
```
|
||||
149
.claude/skills/dev-storybook/SKILL.md
Normal file
149
.claude/skills/dev-storybook/SKILL.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
name: dev-storybook
|
||||
description: |
|
||||
Vue 3 컴포넌트를 받아 Storybook Story 파일과 Props/Emits/슬롯 사용 가이드를 자동 생성합니다.
|
||||
팀 컴포넌트 카탈로그와 재사용 진입 장벽을 낮추는 데 사용합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 컴포넌트 Story 만들어줘", "Storybook 파일 생성해줘"
|
||||
- "컴포넌트 사용 가이드 문서 만들어줘", "Props 문서 작성해줘"
|
||||
- 컴포넌트 카탈로그에 새 컴포넌트를 등록해야 할 때
|
||||
---
|
||||
|
||||
# Storybook Story 생성 (dev-storybook)
|
||||
|
||||
`.vue` 컴포넌트 → `.stories.ts` 파일 + 사용 가이드 자동 생성.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 컴포넌트 완성 후 Storybook에 등록할 때
|
||||
- 다른 팀원이 컴포넌트를 찾고 재사용할 수 있도록 문서화할 때
|
||||
- Props 변형(variant)을 시각적으로 확인해야 할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- 대상 `.vue` 파일 경로
|
||||
- Storybook 버전 (없으면 Storybook 8 + CSF 3 포맷 기본 적용)
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 컴포넌트 인터페이스 파악
|
||||
|
||||
1. `.vue` 파일을 읽어 아래 항목을 추출한다.
|
||||
- Props 목록 (타입, 기본값, 설명)
|
||||
- Emits 목록
|
||||
- 슬롯 목록 (default, named)
|
||||
- 주요 상태 변형 (size, variant, disabled, loading 등)
|
||||
|
||||
### Phase 2: Story 파일 생성 (CSF 3 포맷)
|
||||
|
||||
파일 위치: 컴포넌트와 동일 디렉토리 또는 `stories/`
|
||||
파일명: `<ComponentName>.stories.ts`
|
||||
|
||||
#### 기본 구조
|
||||
```ts
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import ComponentName from './ComponentName.vue'
|
||||
|
||||
const meta: Meta<typeof ComponentName> = {
|
||||
title: '<Category>/<ComponentName>',
|
||||
component: ComponentName,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
// Props 컨트롤 정의
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'ghost'],
|
||||
description: '버튼 스타일 변형',
|
||||
},
|
||||
onClick: { action: 'clicked' },
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof ComponentName>
|
||||
```
|
||||
|
||||
#### 필수 Story 목록
|
||||
```ts
|
||||
// 1. 기본 상태
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
// 기본값으로 렌더링
|
||||
},
|
||||
}
|
||||
|
||||
// 2. 각 변형(variant)별 Story
|
||||
export const Primary: Story = {
|
||||
args: { variant: 'primary', label: '확인' },
|
||||
}
|
||||
|
||||
// 3. 비활성 상태
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
}
|
||||
|
||||
// 4. 로딩 상태 (해당 시)
|
||||
export const Loading: Story = {
|
||||
args: { isLoading: true },
|
||||
}
|
||||
|
||||
// 5. 슬롯 사용 예시 (슬롯 있을 때)
|
||||
export const WithSlot: Story = {
|
||||
render: (args) => ({
|
||||
components: { ComponentName },
|
||||
setup() { return { args } },
|
||||
template: `<ComponentName v-bind="args">슬롯 내용</ComponentName>`,
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: 사용 가이드 주석
|
||||
|
||||
각 Story에 JSDoc 주석으로 사용 목적을 설명한다:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* 기본 상태. 가장 일반적인 사용 패턴.
|
||||
* @example
|
||||
* <ComponentName label="클릭" @click="handleClick" />
|
||||
*/
|
||||
export const Default: Story = { ... }
|
||||
```
|
||||
|
||||
### Phase 4: argTypes 컨트롤 매핑
|
||||
|
||||
| Props 타입 | Storybook 컨트롤 |
|
||||
|---|---|
|
||||
| `string` | `text` |
|
||||
| `number` | `number` |
|
||||
| `boolean` | `boolean` |
|
||||
| Union 타입 (`'a' \| 'b'`) | `select` |
|
||||
| Array | `object` |
|
||||
| Function (emit) | `action` |
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## Story: <ComponentName>.stories.ts
|
||||
|
||||
### Story 목록
|
||||
1. Default — 기본 상태
|
||||
2. Primary — 주요 variant
|
||||
3. Disabled — 비활성
|
||||
4. Loading — 로딩 (해당 시)
|
||||
|
||||
### 코드
|
||||
\`\`\`ts
|
||||
[전체 stories 파일]
|
||||
\`\`\`
|
||||
|
||||
### Storybook 실행
|
||||
\`\`\`bash
|
||||
npx storybook dev
|
||||
\`\`\`
|
||||
```
|
||||
160
.claude/skills/dev-unit-test/SKILL.md
Normal file
160
.claude/skills/dev-unit-test/SKILL.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
name: dev-unit-test
|
||||
description: |
|
||||
Vue 3 컴포넌트를 받아 Vitest + Vue Test Utils 기반의 단위 테스트를 자동 생성합니다.
|
||||
Props, Emits, 슬롯, 사용자 인터랙션, 비동기 동작을 모두 커버합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 컴포넌트 단위 테스트 작성해줘", "테스트 코드 만들어줘"
|
||||
- "Vitest 어떻게 써?", "Vue Test Utils 사용 방법"
|
||||
- 컴포넌트 완성 후 커버리지를 확보해야 할 때
|
||||
---
|
||||
|
||||
# 단위 테스트 생성 (dev-unit-test)
|
||||
|
||||
`.vue` 컴포넌트 → Vitest + Vue Test Utils 단위 테스트 자동 생성.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 컴포넌트 개발 완료 후 테스트 코드를 작성할 때
|
||||
- TDD 방식으로 테스트를 먼저 작성할 때
|
||||
- 팀 테스트 커버리지 기준(80%↑)을 달성해야 할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- 테스트할 `.vue` 파일 경로
|
||||
- 테스트 케이스 범위 (없으면 자동 도출)
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 컴포넌트 분석
|
||||
|
||||
1. 대상 `.vue` 파일을 읽어 아래 항목을 파악한다.
|
||||
- Props 목록 (타입, 기본값, 필수 여부)
|
||||
- Emits 목록 (이벤트 이름, 페이로드 타입)
|
||||
- 슬롯 유무
|
||||
- 외부 의존성 (composables, store, $fetch)
|
||||
- 인터랙션 (버튼 클릭, 입력, 폼 제출)
|
||||
- 비동기 동작 (API 호출, 로딩 상태)
|
||||
- 조건부 렌더링 (`v-if`, `v-show`)
|
||||
|
||||
2. 테스트 케이스 목록을 도출한다.
|
||||
|
||||
### Phase 2: 테스트 설정
|
||||
|
||||
```ts
|
||||
// vitest.config.ts (없으면 생성 안내)
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 3: 테스트 파일 생성
|
||||
|
||||
파일 위치: 컴포넌트와 동일 디렉토리 또는 `__tests__/` 폴더.
|
||||
파일명: `<ComponentName>.spec.ts`
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ComponentName from './ComponentName.vue'
|
||||
|
||||
describe('ComponentName', () => {
|
||||
// Phase 4 케이스 작성
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 4: 테스트 케이스 작성 패턴
|
||||
|
||||
#### Props 검증
|
||||
```ts
|
||||
it('title prop을 렌더링한다', () => {
|
||||
const wrapper = mount(ComponentName, {
|
||||
props: { title: '테스트 제목' }
|
||||
})
|
||||
expect(wrapper.find('h2').text()).toBe('테스트 제목')
|
||||
})
|
||||
|
||||
it('필수 prop 누락 시 기본값을 사용한다', () => {
|
||||
const wrapper = mount(ComponentName)
|
||||
expect(wrapper.find('[data-testid="label"]').text()).toBe('기본값')
|
||||
})
|
||||
```
|
||||
|
||||
#### Emits 검증
|
||||
```ts
|
||||
it('버튼 클릭 시 click 이벤트를 emit한다', async () => {
|
||||
const wrapper = mount(ComponentName, { props: { id: '1' } })
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.emitted('click')?.[0]).toEqual(['1'])
|
||||
})
|
||||
```
|
||||
|
||||
#### 조건부 렌더링
|
||||
```ts
|
||||
it('isLoading이 true이면 스피너를 표시한다', () => {
|
||||
const wrapper = mount(ComponentName, { props: { isLoading: true } })
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="content"]').exists()).toBe(false)
|
||||
})
|
||||
```
|
||||
|
||||
#### 비동기 / 스토어 Mock
|
||||
```ts
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
it('마운트 시 fetchProducts를 호출한다', async () => {
|
||||
const wrapper = mount(ComponentName, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })]
|
||||
}
|
||||
})
|
||||
const store = useProductStore()
|
||||
expect(store.fetchProducts).toHaveBeenCalledOnce()
|
||||
})
|
||||
```
|
||||
|
||||
#### 슬롯
|
||||
```ts
|
||||
it('default 슬롯 콘텐츠를 렌더링한다', () => {
|
||||
const wrapper = mount(ComponentName, {
|
||||
slots: { default: '<span>슬롯 내용</span>' }
|
||||
})
|
||||
expect(wrapper.find('span').text()).toBe('슬롯 내용')
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 단위 테스트: <ComponentName>.spec.ts
|
||||
|
||||
### 테스트 케이스 목록
|
||||
1. [렌더링] 기본 렌더링 성공
|
||||
2. [Props] title prop 표시
|
||||
3. [Emits] 버튼 클릭 시 emit
|
||||
4. [조건부] isLoading 상태 처리
|
||||
...
|
||||
|
||||
### 코드
|
||||
\`\`\`ts
|
||||
[전체 spec 파일]
|
||||
\`\`\`
|
||||
|
||||
### 실행 방법
|
||||
\`\`\`bash
|
||||
npx vitest run <파일경로>
|
||||
npx vitest --coverage # 커버리지 확인
|
||||
\`\`\`
|
||||
```
|
||||
21
.claude/skills/dreaming/SKILL.md
Normal file
21
.claude/skills/dreaming/SKILL.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: dreaming
|
||||
description: |
|
||||
프로젝트의 코드베이스(package.json, Pinia 스토어, 커스텀 Composable 등)를
|
||||
휴리스틱하게 스캔하고 자율 인지 메모리 파일(.claude/project/dreaming-context.md)을 업데이트합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- 사용자가 "코드베이스 스캔해줘", "컨텍스트 갱신해줘", "dreaming 돌려줘"라고 지시할 때
|
||||
- 새로운 브랜치로 전환했거나 대량의 코드가 머지된 직후 프로젝트 지침을 최신화하고 싶을 때
|
||||
---
|
||||
|
||||
# 프로젝트 자율 인지 메모리 스캔 (dreaming)
|
||||
|
||||
코드베이스 전체를 정기적으로 자율 성찰(Self-reflection)하여 에이전트의 뇌 상태를 동기화하고 프로젝트-스냅샷 파일을 생성합니다.
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. 프로젝트 루트 경로에서 \`node .claude/skills/dreaming/scripts/dreaming.js\` 명령을 실행합니다.
|
||||
2. 스크립트가 실행되면서 프로젝트 루트의 \`package.json\`, \`stores/\`, \`composables/\`, \`components/\` 등의 경로를 탐색하여 최신 의존성, 스토어 상태, 커스텀 컴포저블 목록 등을 분석합니다.
|
||||
3. 생성된 결과를 \`.claude/project/dreaming-context.md\` 파일로 기록 및 덮어쓰고, 해당 파일이 프로젝트 루트의 \`CLAUDE.md\`에 정상 임포트되었는지 검증합니다.
|
||||
4. 분석 완료 보고서와 요약 내용을 개발자에게 깔끔하게 알려드립니다.
|
||||
369
.claude/skills/dreaming/scripts/dreaming.js
Executable file
369
.claude/skills/dreaming/scripts/dreaming.js
Executable file
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* dreaming.js - "AI 코더"에서 "상태 저장형 컨벤션 가디언"으로 (Dreaming의 힘)
|
||||
*
|
||||
* 이 스크립트는 프로젝트 루트(CWD)의 코드베이스를 휴리스틱하게 스캔하여,
|
||||
* 현재 프레임워크 상태, 액티브 Pinia 스토어, 커스텀 Composable, 컴포넌트 구조, 테일윈드 설정 등을 추출합니다.
|
||||
* 분석된 내용은 .claude/project/dreaming-context.md 파일로 기록되어,
|
||||
* Claude Code가 프로젝트의 최신 컨벤션과 아키텍처 상태를 항시 보존하고 인지하도록 돕습니다.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CWD = process.cwd();
|
||||
const CLAUDE_DIR = path.join(CWD, '.claude');
|
||||
const PROJECT_DIR = path.join(CLAUDE_DIR, 'project');
|
||||
const OUTPUT_FILE = path.join(PROJECT_DIR, 'dreaming-context.md');
|
||||
const CLAUDE_MD = path.join(CWD, 'CLAUDE.md');
|
||||
|
||||
// 헬퍼: 디렉토리 존재 여부 확인
|
||||
function directoryExists(dirPath) {
|
||||
try {
|
||||
return fs.statSync(dirPath).isDirectory();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 헬퍼: 파일 존재 여부 확인
|
||||
function fileExists(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath).isFile();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 헬퍼: 재귀적으로 파일 목록 가져오기 (옵션 포함)
|
||||
function getFilesRecursive(dirPath, extFilter = [], ignoreDirs = ['node_modules', '.git', '.nuxt', 'dist']) {
|
||||
let results = [];
|
||||
if (!directoryExists(dirPath)) return results;
|
||||
|
||||
const list = fs.readdirSync(dirPath);
|
||||
list.forEach((file) => {
|
||||
const fullPath = path.join(dirPath, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat && stat.isDirectory()) {
|
||||
if (!ignoreDirs.includes(file)) {
|
||||
results = results.concat(getFilesRecursive(fullPath, extFilter, ignoreDirs));
|
||||
}
|
||||
} else {
|
||||
const ext = path.extname(file);
|
||||
if (extFilter.length === 0 || extFilter.includes(ext)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
// 1. package.json 분석
|
||||
function analyzePackageJson() {
|
||||
const packagePath = path.join(CWD, 'package.json');
|
||||
if (!fileExists(packagePath)) {
|
||||
return { name: 'Unknown Project', framework: 'Unknown', techStack: [], scripts: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
||||
const techStack = [];
|
||||
|
||||
let framework = 'Vue/Nuxt';
|
||||
if (deps['nuxt'] || deps['nuxt3'] || deps['nuxt-edge']) {
|
||||
framework = `Nuxt (${deps['nuxt'] || deps['nuxt3'] || 'v3'})`;
|
||||
techStack.push('Nuxt');
|
||||
} else if (deps['vue']) {
|
||||
framework = `Vue (${deps['vue']})`;
|
||||
techStack.push('Vue');
|
||||
} else if (deps['next']) {
|
||||
framework = `Next.js (${deps['next']})`;
|
||||
techStack.push('Next.js');
|
||||
} else if (deps['react']) {
|
||||
framework = `React (${deps['react']})`;
|
||||
techStack.push('React');
|
||||
}
|
||||
|
||||
if (deps['pinia'] || deps['@pinia/nuxt']) {
|
||||
techStack.push('Pinia (상태 관리)');
|
||||
}
|
||||
if (deps['tailwindcss'] || deps['@nuxtjs/tailwindcss']) {
|
||||
techStack.push('Tailwind CSS (스타일)');
|
||||
}
|
||||
if (deps['typescript']) {
|
||||
techStack.push('TypeScript');
|
||||
}
|
||||
if (deps['vitest'] || deps['@vitest/ui']) {
|
||||
techStack.push('Vitest (유닛 테스트)');
|
||||
}
|
||||
if (deps['eslint']) {
|
||||
techStack.push('ESLint');
|
||||
}
|
||||
if (deps['prettier']) {
|
||||
techStack.push('Prettier');
|
||||
}
|
||||
|
||||
return {
|
||||
name: pkg.name || 'Unnamed Project',
|
||||
version: pkg.version || '1.0.0',
|
||||
framework,
|
||||
techStack,
|
||||
scripts: pkg.scripts ? Object.keys(pkg.scripts) : []
|
||||
};
|
||||
} catch (e) {
|
||||
return { name: 'Parsing Error', framework: 'Unknown', techStack: [], scripts: [], error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 디렉토리 구조 스캔 및 요약
|
||||
function scanDirectoryStructure() {
|
||||
const dirsToScan = ['components', 'composables', 'stores', 'pages', 'server', 'layouts', 'middleware', 'plugins', 'types', 'assets'];
|
||||
const summary = {};
|
||||
|
||||
dirsToScan.forEach((dirName) => {
|
||||
const dirPath = path.join(CWD, dirName);
|
||||
if (directoryExists(dirPath)) {
|
||||
const files = getFilesRecursive(dirPath);
|
||||
summary[dirName] = {
|
||||
exists: true,
|
||||
count: files.length,
|
||||
examples: files.slice(0, 5).map(f => path.relative(CWD, f))
|
||||
};
|
||||
} else {
|
||||
summary[dirName] = { exists: false, count: 0, examples: [] };
|
||||
}
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
// 3. Pinia 스토어 상세 분석
|
||||
function analyzePiniaStores() {
|
||||
const storesDir = path.join(CWD, 'stores');
|
||||
const stores = [];
|
||||
|
||||
if (!directoryExists(storesDir)) {
|
||||
// composables 내에 스토어가 정의되어 있을 수도 있으므로 추가 탐색 가능
|
||||
return stores;
|
||||
}
|
||||
|
||||
const files = getFilesRecursive(storesDir, ['.ts', '.js']);
|
||||
files.forEach((file) => {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const filename = path.basename(file);
|
||||
|
||||
// defineStore 매칭
|
||||
const defineStoreMatch = content.match(/defineStore\(\s*['"`]([^'"`]+)['"`]/);
|
||||
const storeId = defineStoreMatch ? defineStoreMatch[1] : null;
|
||||
|
||||
// 상태(state) 필드 휴리스틱 추출
|
||||
const stateFields = [];
|
||||
const stateRegex = /const\s+([a-zA-Z0-9_$]+)\s*=\s*(ref|reactive|computed)/g;
|
||||
let match;
|
||||
while ((match = stateRegex.exec(content)) !== null) {
|
||||
stateFields.push(`${match[1]} (${match[2]})`);
|
||||
}
|
||||
|
||||
// 함수(actions) 추출
|
||||
const actionFields = [];
|
||||
const actionRegex = /function\s+([a-zA-Z0-9_$]+)/g;
|
||||
while ((match = actionRegex.exec(content)) !== null) {
|
||||
if (!match[1].startsWith('use')) {
|
||||
actionFields.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
stores.push({
|
||||
file: path.relative(CWD, file),
|
||||
id: storeId || filename.replace(path.extname(filename), ''),
|
||||
state: stateFields,
|
||||
actions: actionFields
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
return stores;
|
||||
}
|
||||
|
||||
// 4. 커스텀 Composable 분석
|
||||
function analyzeComposables() {
|
||||
const composablesDir = path.join(CWD, 'composables');
|
||||
const composables = [];
|
||||
|
||||
if (!directoryExists(composablesDir)) return composables;
|
||||
|
||||
const files = getFilesRecursive(composablesDir, ['.ts', '.js']);
|
||||
files.forEach((file) => {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const filename = path.basename(file);
|
||||
const relativePath = path.relative(CWD, file);
|
||||
|
||||
// export const useXxx 함수 매칭
|
||||
const useFuncRegex = /export\s+const\s+(use[a-zA-Z0-9_$]+)/g;
|
||||
const useFuncs = [];
|
||||
let match;
|
||||
while ((match = useFuncRegex.exec(content)) !== null) {
|
||||
useFuncs.push(match[1]);
|
||||
}
|
||||
|
||||
const defaultFuncRegex = /export\s+default\s+function\s+(use[a-zA-Z0-9_$]+)/;
|
||||
const defaultMatch = content.match(defaultFuncRegex);
|
||||
if (defaultMatch) {
|
||||
useFuncs.push(defaultMatch[1]);
|
||||
}
|
||||
|
||||
if (useFuncs.length > 0) {
|
||||
composables.push({
|
||||
file: relativePath,
|
||||
functions: useFuncs
|
||||
});
|
||||
} else {
|
||||
// 파일명이 useXxx 형태인 경우 추가
|
||||
if (filename.startsWith('use')) {
|
||||
composables.push({
|
||||
file: relativePath,
|
||||
functions: [filename.replace(path.extname(filename), '')]
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
return composables;
|
||||
}
|
||||
|
||||
// 5. 테스트 파일 통계
|
||||
function analyzeTests() {
|
||||
const testFiles = getFilesRecursive(CWD, ['.spec.ts', '.spec.js', '.test.ts', '.test.js']);
|
||||
return {
|
||||
count: testFiles.length,
|
||||
files: testFiles.slice(0, 10).map(f => path.relative(CWD, f))
|
||||
};
|
||||
}
|
||||
|
||||
// 메인 실행기
|
||||
function run() {
|
||||
console.log('🤖 프로젝트 "Dreaming" 컨텍스트 분석 시작...');
|
||||
|
||||
const pkgInfo = analyzePackageJson();
|
||||
const dirSummary = scanDirectoryStructure();
|
||||
const stores = analyzePiniaStores();
|
||||
const composables = analyzeComposables();
|
||||
const tests = analyzeTests();
|
||||
|
||||
const timestamp = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
|
||||
// 마크다운 문서 빌드
|
||||
let md = `# 🧠 프로젝트 자율 인지 메모리 (Dreaming Context)
|
||||
|
||||
이 파일은 \`dreaming.js\` 스크립트에 의해 프로젝트 코드베이스를 분석하여 자동 생성되었습니다.
|
||||
Claude Code가 프로젝트의 실시간 코드 구조, 사용 중인 스토어, 컴포넌트 레이아웃, 그리고 최신 개발 흐름을 완벽히 인지하도록 돕습니다.
|
||||
|
||||
* **최종 동기화 시간:** ${timestamp} (Asia/Seoul)
|
||||
|
||||
---
|
||||
|
||||
## 🏗 프로젝트 정보
|
||||
* **프로젝트명:** \`${pkgInfo.name}\` (v${pkgInfo.version || '1.0.0'})
|
||||
* **핵심 프레임워크:** \`${pkgInfo.framework}\`
|
||||
* **기술 스택 라이브러리:**
|
||||
${pkgInfo.techStack.map(tech => ` - ${tech}`).join('\n') || ' - (감지된 주요 라이브러리 없음)'}
|
||||
|
||||
---
|
||||
|
||||
## 📁 디렉토리 구조 및 컴포넌트 현황
|
||||
현재 활성화되어 있는 프로젝트 레이아웃 정보입니다.
|
||||
|
||||
| 디렉토리 | 활성 여부 | 파일 개수 | 주요 샘플 파일 (최대 5개) |
|
||||
|---|---|---|---|
|
||||
${Object.entries(dirSummary).map(([name, info]) => {
|
||||
return `| \`${name}/\` | ${info.exists ? '✅' : '❌'} | ${info.count}개 | ${info.examples.map(ex => `\`${path.basename(ex)}\``).join(', ') || '-'} |`;
|
||||
}).join('\n')}
|
||||
|
||||
---
|
||||
|
||||
## 🍍 액티브 Pinia 스토어 목록
|
||||
현재 코드베이스에 존재하는 글로벌 상태 저장소들의 템플릿 정보입니다. 새 기능을 개발할 때 아래 스토어를 재사용하거나 참고하세요.
|
||||
|
||||
${stores.length === 0 ? '*감지된 Pinia 스토어가 없습니다. (stores/ 디렉토리 없음 혹은 비어있음)*' : stores.map(store => {
|
||||
return `### 📦 \`${store.id}\`
|
||||
* **정의 파일:** \`${store.file}\`
|
||||
* **감지된 상태 (state/computed):** ${store.state.length > 0 ? store.state.map(s => `\`${s}\``).join(', ') : '없음'}
|
||||
* **감지된 액션 (actions/methods):** ${store.actions.length > 0 ? store.actions.map(a => `\`${a}\``).join(', ') : '없음'}
|
||||
`;
|
||||
}).join('\n')}
|
||||
|
||||
---
|
||||
|
||||
## 🎣 커스텀 Composable (useXxx) 목록
|
||||
다양한 비즈니스 로직과 부수효과를 격리해 둔 커스텀 훅 목록입니다. 컴포넌트 내부에서 비즈니스 로직을 직접 짜기 전, 아래 훅들의 재사용 가능성을 먼저 타진하세요.
|
||||
|
||||
${composables.length === 0 ? '*감지된 커스텀 Composable이 없습니다. (composables/ 디렉토리 없음 혹은 비어있음)*' : composables.map(comp => {
|
||||
return `- **파일:** \`${comp.file}\`
|
||||
- **제공 함수:** ${comp.functions.map(f => `\`${f}()\``).join(', ')}
|
||||
`;
|
||||
}).join('\n')}
|
||||
|
||||
---
|
||||
|
||||
## 🧪 유닛 테스트 통계
|
||||
현재까지 구축된 테스트 커버리지 현황입니다.
|
||||
* **감지된 테스트 파일 수:** \`${tests.count}개\`
|
||||
${tests.count > 0 ? `* **최근 테스트 목록:**\n${tests.files.map(f => ` - \`${f}\``).join('\n')}` : ' *(새 기능을 추가할 때 반드시 Vitest 규격의 유닛 테스트를 함께 작성해야 함)*'}
|
||||
|
||||
---
|
||||
|
||||
## 🛠 실행 가능한 스크립트 (package.json)
|
||||
프로젝트 구동 및 테스트 검증을 위해 사용 가능한 명령어 리스트입니다.
|
||||
${pkgInfo.scripts.map(s => `- \`npm run ${s}\` (또는 pnpm/yarn/bun)`).join('\n') || '- 스크립트 없음'}
|
||||
`;
|
||||
|
||||
// 디렉토리 및 파일 저장
|
||||
if (!directoryExists(CLAUDE_DIR)) {
|
||||
fs.mkdirSync(CLAUDE_DIR);
|
||||
}
|
||||
if (!directoryExists(PROJECT_DIR)) {
|
||||
fs.mkdirSync(PROJECT_DIR);
|
||||
}
|
||||
|
||||
fs.writeFileSync(OUTPUT_FILE, md, 'utf8');
|
||||
console.log(`✅ Dreaming Context 업데이트 완료! -> ${path.relative(CWD, OUTPUT_FILE)}`);
|
||||
|
||||
// CLAUDE.md에 자동 임포트 추가 처리
|
||||
if (fileExists(CLAUDE_MD)) {
|
||||
let claudeMdContent = fs.readFileSync(CLAUDE_MD, 'utf8');
|
||||
const importStr = '@.claude/project/dreaming-context.md';
|
||||
|
||||
if (!claudeMdContent.includes(importStr)) {
|
||||
// '## 프로젝트 지침' 혹은 '## 공통 지침' 섹션 밑에 삽입 시도
|
||||
const sectionMatch = claudeMdContent.match(/(## 프로젝트 지침\r?\n)/);
|
||||
if (sectionMatch) {
|
||||
claudeMdContent = claudeMdContent.replace(
|
||||
sectionMatch[0],
|
||||
`${sectionMatch[0]}${importStr}\n`
|
||||
);
|
||||
fs.writeFileSync(CLAUDE_MD, claudeMdContent, 'utf8');
|
||||
console.log(`🔗 CLAUDE.md에 ${importStr} 동적 임포트 구문을 연결했습니다.`);
|
||||
} else {
|
||||
// 찾을 수 없다면 파일 상단 혹은 하단에 단순 추가
|
||||
claudeMdContent = claudeMdContent + `\n\n## 자동 분석 컨텍스트\n${importStr}\n`;
|
||||
fs.writeFileSync(CLAUDE_MD, claudeMdContent, 'utf8');
|
||||
console.log(`🔗 CLAUDE.md 끝에 ${importStr} 동적 임포트 구문을 추가했습니다.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`⚠️ 프로젝트 루트에 CLAUDE.md 가 존재하지 않습니다. CLAUDE.md를 먼저 생성하고 @.claude/project/dreaming-context.md 임포트 선언을 수동으로 추가하는 것을 권장합니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
run();
|
||||
125
.claude/skills/markup-base/SKILL.md
Normal file
125
.claude/skills/markup-base/SKILL.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: markup-base
|
||||
description: |
|
||||
화면 설명, 스크린샷, 텍스트 명세를 받아 시멘틱 HTML + Tailwind CSS 기반의
|
||||
Nuxt SFC 마크업을 생성합니다. 팀 공통 컨벤션(7단계 클래스 순서, ARIA, 반응형)을 자동 적용합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 화면 마크업해줘", "HTML 구조 만들어줘", "퍼블리싱해줘"
|
||||
- 화면 설명이나 스크린샷을 받고 컴포넌트 골격을 잡을 때
|
||||
- Figma 없이 텍스트 명세나 와이어프레임으로 마크업을 시작할 때
|
||||
---
|
||||
|
||||
# 기본 마크업 가이드 (markup-base)
|
||||
|
||||
화면 명세 → 시멘틱 HTML + Tailwind → Nuxt `.vue` SFC 골격을 생성합니다.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- Figma 디자인 없이 화면 명세/스크린샷으로 마크업을 시작할 때
|
||||
- 신규 페이지/섹션의 기본 구조를 빠르게 잡을 때
|
||||
- 기존 마크업을 팀 컨벤션에 맞게 리팩토링할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- 화면 설명 (텍스트), 와이어프레임 이미지, 또는 스크린샷
|
||||
- 반응형 분기점 요건 (없으면 Tailwind 기본값 사용: `sm` 640px / `md` 768px / `lg` 1024px)
|
||||
- 접근성 요건 (없으면 WCAG 2.1 AA 기본 적용)
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 화면 구조 파악
|
||||
|
||||
1. 제공된 화면 명세 또는 이미지를 분석해 레이아웃 영역을 식별한다.
|
||||
- 반복 블록 → 컴포넌트 후보 표시
|
||||
- 상태가 있는 영역 (로딩, 에러, 빈 상태) 확인
|
||||
2. 반응형 분기점을 파악한다.
|
||||
- 모바일 우선(mobile-first) 여부 확인
|
||||
- 중단점별 레이아웃 변화 정리
|
||||
|
||||
### Phase 2: 시멘틱 구조 설계
|
||||
|
||||
1. HTML 시멘틱 태그를 선택한다.
|
||||
|
||||
| 영역 | 권장 태그 |
|
||||
|---|---|
|
||||
| 페이지 헤더 | `<header>` |
|
||||
| 주 내비게이션 | `<nav aria-label="...">` |
|
||||
| 주 콘텐츠 | `<main>` |
|
||||
| 사이드 | `<aside>` |
|
||||
| 콘텐츠 섹션 | `<section aria-labelledby="...">` |
|
||||
| 독립 콘텐츠 | `<article>` |
|
||||
| 페이지 푸터 | `<footer>` |
|
||||
|
||||
2. 인터랙션 요소를 식별한다.
|
||||
- 버튼: `<button type="button">` (폼 외부), `<button type="submit">` (폼 내부)
|
||||
- 링크: `<a href="...">` (페이지 이동), `<button>` (동작 트리거)
|
||||
|
||||
### Phase 3: Tailwind 클래스 적용
|
||||
|
||||
클래스 순서는 아래 7단계를 따른다:
|
||||
|
||||
```
|
||||
1. 레이아웃 : flex, grid, block, hidden
|
||||
2. 크기 : w-*, h-*, max-w-*, min-h-*
|
||||
3. 여백 : m-*, p-*, gap-*, space-*
|
||||
4. 배경/보더 : bg-*, border-*, rounded-*, shadow-*
|
||||
5. 타이포그래피 : text-*, font-*, leading-*, tracking-*
|
||||
6. 색상 : text-{color}, fill-*, stroke-*
|
||||
7. 상태/반응형 : hover:, focus:, sm:, md:, lg:
|
||||
```
|
||||
|
||||
조건부 클래스는 `clsx` 또는 `cn` 유틸리티를 사용한다.
|
||||
|
||||
### Phase 4: ARIA 및 접근성 처리
|
||||
|
||||
- 아이콘 전용 버튼: `aria-label` 필수
|
||||
- 모달/다이얼로그: `role="dialog" aria-modal="true" aria-labelledby="..."`
|
||||
- 로딩 상태: `aria-live="polite"` 또는 `aria-busy="true"`
|
||||
- 이미지: 의미 있는 이미지 `alt` 텍스트 / 장식용 `alt=""`
|
||||
- 폼 레이블: `<label for="...">` 또는 `aria-label`
|
||||
|
||||
### Phase 5: Nuxt SFC 출력
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Props / Emits / 로컬 상태만 포함
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 시멘틱 구조 -->
|
||||
</template>
|
||||
```
|
||||
|
||||
- `<style>`은 Tailwind로 커버 불가한 경우에만 추가
|
||||
- `scoped`보다 Tailwind 유틸리티 우선
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 마크업: <화면명>
|
||||
|
||||
### 구조 개요
|
||||
- 레이아웃: [설명]
|
||||
- 반응형 분기: [없음 / sm, md, lg 각각 설명]
|
||||
- 컴포넌트 분리 후보: [목록]
|
||||
|
||||
### 코드
|
||||
\`\`\`vue
|
||||
<script setup lang="ts">
|
||||
...
|
||||
</script>
|
||||
|
||||
<template>
|
||||
...
|
||||
</template>
|
||||
\`\`\`
|
||||
|
||||
### 추가 작업 필요 항목
|
||||
- [ ] 이미지 alt 텍스트 확정 필요
|
||||
- [ ] 컬러 토큰 디자인 시스템 확인 필요
|
||||
```
|
||||
BIN
.claude/skills/markup-edm/.DS_Store
vendored
Normal file
BIN
.claude/skills/markup-edm/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: edm-email-html
|
||||
name: markup-edm
|
||||
description: |
|
||||
EDM(이메일 다이렉트 마케팅) HTML을 구현하는 전체 워크플로우 스킬.
|
||||
Figma 디자인 → HTML table 마크업 → 아웃룩 호환 → 검수까지 단계별 가이드를 제공합니다.
|
||||
107
.claude/skills/markup-figma/SKILL.md
Normal file
107
.claude/skills/markup-figma/SKILL.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: markup-figma
|
||||
description: |
|
||||
Figma URL 또는 Figma MCP로 추출한 디자인 데이터를 받아
|
||||
Nuxt SFC + Tailwind CSS 마크업으로 자동 변환합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 Figma 마크업해줘", "Figma 디자인 컴포넌트로 만들어줘"
|
||||
- Figma URL 또는 링크를 받고 HTML/Vue 구현을 요청받았을 때
|
||||
- 피그마 시안 기반 반응형 마크업이 필요할 때
|
||||
---
|
||||
|
||||
# Figma → 마크업 변환 (markup-figma)
|
||||
|
||||
Figma 디자인 데이터 → Nuxt SFC + Tailwind 마크업 자동 변환.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 디자이너로부터 Figma URL을 받아 퍼블리싱을 시작할 때
|
||||
- Figma MCP가 Claude Code에 연결되어 있을 때
|
||||
- Figma 없이 스크린샷/Export 이미지로 구현할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- Figma 프레임 URL 또는 노드 ID
|
||||
- (선택) 브레이크포인트 요건, 이미지 에셋 CDN 경로
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: Figma 데이터 추출
|
||||
|
||||
#### Figma MCP 사용 가능 시
|
||||
```
|
||||
Figma URL → MCP로 레이어 트리, 스타일, 치수 자동 추출
|
||||
```
|
||||
|
||||
추출 항목:
|
||||
- 컬러 HEX값 (RGBA → HEX 변환)
|
||||
- 폰트: 패밀리, 사이즈(px), weight, 줄간격
|
||||
- 레이아웃: width/height, padding, gap
|
||||
- 컴포넌트 이름 → Vue 컴포넌트 이름 매핑 (`PascalCase`)
|
||||
- 이미지 에셋 URL
|
||||
|
||||
#### MCP 없이 진행 시
|
||||
사용자에게 아래 정보를 요청한다:
|
||||
- 섹션별 스크린샷 (모바일 / 데스크톱 분리)
|
||||
- 컬러 HEX, 폰트 스펙, 여백(px)
|
||||
- CTA 링크 및 버튼 텍스트
|
||||
|
||||
### Phase 2: Tailwind 매핑
|
||||
|
||||
| Figma 값 | Tailwind 클래스 예시 |
|
||||
|---|---|
|
||||
| `width: 320px` | `w-80` 또는 `w-[320px]` |
|
||||
| `padding: 16px 24px` | `py-4 px-6` |
|
||||
| `gap: 12px` | `gap-3` |
|
||||
| `font-size: 14px` | `text-sm` |
|
||||
| `border-radius: 8px` | `rounded-lg` |
|
||||
| `color: #1A1A1A` | `text-[#1A1A1A]` 또는 디자인 토큰 |
|
||||
|
||||
- Tailwind 디자인 토큰(`tailwind.config.ts`)이 있으면 임의값 대신 토큰 사용
|
||||
- 임의값(`w-[123px]`)은 디자인 시스템에 없는 값에만 사용
|
||||
|
||||
### Phase 3: 컴포넌트 구조 결정
|
||||
|
||||
Figma 컴포넌트 → Vue 파일 매핑:
|
||||
- Figma 상위 프레임 → 페이지 레이아웃 (`pages/` 또는 `layouts/`)
|
||||
- Figma Instance → `components/` 파일
|
||||
- 반복 컴포넌트 → Props로 추상화
|
||||
|
||||
### Phase 4: SFC 코드 생성
|
||||
|
||||
`markup-base` Phase 3~5 동일 적용 (클래스 순서, ARIA, SFC 구조).
|
||||
|
||||
반응형 처리:
|
||||
- Figma Mobile 프레임 → base 클래스
|
||||
- Figma Desktop 프레임 → `lg:` 접두사
|
||||
|
||||
### Phase 5: 검수 체크리스트
|
||||
|
||||
- [ ] 폰트 스펙 일치 여부 (px → rem 변환 확인)
|
||||
- [ ] 이미지 `alt` 텍스트 작성 여부
|
||||
- [ ] 인터랙션 요소 ARIA 속성 확인
|
||||
- [ ] Tailwind 클래스 7단계 순서 준수
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## Figma 마크업: <프레임명>
|
||||
|
||||
### 추출된 디자인 정보
|
||||
- 컬러: [HEX 목록]
|
||||
- 폰트: [패밀리 / 사이즈 목록]
|
||||
- 브레이크포인트: [mobile / desktop 치수]
|
||||
|
||||
### 코드
|
||||
\`\`\`vue
|
||||
...
|
||||
\`\`\`
|
||||
|
||||
### 미확인 항목
|
||||
- [ ] [에셋 URL, 링크 등 확정 필요 항목]
|
||||
```
|
||||
138
.claude/skills/markup-promotion/SKILL.md
Normal file
138
.claude/skills/markup-promotion/SKILL.md
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
name: markup-promotion
|
||||
description: |
|
||||
프로모션/랜딩 페이지용 표준 마크업을 생성합니다.
|
||||
캠페인 유형별 섹션 템플릿(히어로, 특징, CTA, 이벤트 일정 등)을 제공하고
|
||||
반응형·접근성·SEO 메타를 일관되게 적용합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "프로모션 페이지 만들어줘", "랜딩 페이지 마크업해줘"
|
||||
- "이벤트 페이지 퍼블리싱", "캠페인 HTML 만들어줘"
|
||||
- 반복적인 프로모션 구조를 빠르게 구현해야 할 때
|
||||
---
|
||||
|
||||
# 프로모션 마크업 가이드 (markup-promotion)
|
||||
|
||||
캠페인 / 이벤트 / 랜딩 페이지의 표준 섹션 구조를 자동 생성합니다.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 신규 프로모션/이벤트 랜딩 페이지를 제작할 때
|
||||
- 기존 캠페인 구조를 재사용해 빠르게 개발할 때
|
||||
- 팀 표준 프로모션 레이아웃이 필요할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- 캠페인 명칭, 기간, 주요 메시지
|
||||
- 포함할 섹션 목록 (없으면 기본 구성으로 제안)
|
||||
- 이미지/에셋 경로 또는 플레이스홀더 여부
|
||||
- 다국어 지원 여부
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 캠페인 유형 분석
|
||||
|
||||
1. 제공된 정보에서 아래 유형을 판별한다.
|
||||
|
||||
| 유형 | 특징 |
|
||||
|---|---|
|
||||
| 이벤트 | 기간, 참여 조건, CTA 버튼 |
|
||||
| 상품 출시 | 제품 특징, 스펙, 구매 링크 |
|
||||
| 신규 서비스 | 혜택 강조, 가입 유도 |
|
||||
| 콘텐츠/미디어 | 영상/갤러리 중심, SNS 공유 |
|
||||
|
||||
2. 필요한 섹션을 제안한다. 사용자가 별도 지정하지 않으면 기본 구성 사용.
|
||||
|
||||
**기본 섹션 구성:**
|
||||
```
|
||||
Hero → 특징/혜택 → 이벤트 규칙/일정 → FAQ → CTA → Footer
|
||||
```
|
||||
|
||||
### Phase 2: 섹션별 마크업 생성
|
||||
|
||||
각 섹션은 독립 Vue 컴포넌트로 작성한다.
|
||||
|
||||
#### Hero 섹션
|
||||
```vue
|
||||
<section class="relative w-full [height] bg-[color] ...">
|
||||
<div class="...">
|
||||
<h1 class="...">{{ title }}</h1>
|
||||
<p class="...">{{ description }}</p>
|
||||
<a :href="ctaLink" class="...">{{ ctaText }}</a>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
#### 특징/혜택 섹션
|
||||
```vue
|
||||
<section aria-labelledby="features-heading">
|
||||
<h2 id="features-heading" class="...">{{ heading }}</h2>
|
||||
<ul class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<li v-for="item in features" :key="item.id" class="...">
|
||||
...
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
```
|
||||
|
||||
#### CTA 섹션
|
||||
- 버튼 타입: `<a>` (외부 링크) 또는 `<NuxtLink>` (내부 라우팅)
|
||||
- `aria-label`로 버튼 목적 명시
|
||||
|
||||
### Phase 3: 반응형 + SEO + 접근성
|
||||
|
||||
**반응형**: 모바일 우선 (base → `md:` → `lg:`)
|
||||
|
||||
**SEO 메타** (Nuxt `useSeoMeta`):
|
||||
```ts
|
||||
useSeoMeta({
|
||||
title: '캠페인명 | 브랜드명',
|
||||
description: '캠페인 한 줄 설명',
|
||||
ogTitle: '...',
|
||||
ogImage: '이미지 URL',
|
||||
})
|
||||
```
|
||||
|
||||
**접근성**:
|
||||
- 이미지 `alt` 텍스트 (장식용이면 `alt=""`)
|
||||
- 섹션 제목 계층 (`h1` → `h2` → `h3`)
|
||||
- 카운트다운 타이머: `aria-live="polite"`
|
||||
|
||||
### Phase 4: 다국어 처리
|
||||
|
||||
다국어 지원 시 i18n 키로 텍스트 분리:
|
||||
```vue
|
||||
<h1>{{ $t('HERO-title') }}</h1>
|
||||
```
|
||||
|
||||
키 명명은 `plan-translation-generator` 스킬의 카테고리 가이드 참조.
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 프로모션 마크업: <캠페인명>
|
||||
|
||||
### 섹션 구성
|
||||
1. Hero
|
||||
2. 특징/혜택
|
||||
3. CTA
|
||||
|
||||
### 파일 구조
|
||||
pages/promotion/<slug>.vue
|
||||
components/promotion/
|
||||
├── PromoHero.vue
|
||||
├── PromoFeatures.vue
|
||||
└── PromoCta.vue
|
||||
|
||||
### 코드
|
||||
[각 컴포넌트 코드]
|
||||
|
||||
### 체크리스트
|
||||
- [ ] 이미지 alt 텍스트 확정
|
||||
- [ ] CTA 링크 URL 확정
|
||||
- [ ] useSeoMeta 값 확정
|
||||
```
|
||||
170
.claude/skills/plan-analyzer/SKILL.md
Normal file
170
.claude/skills/plan-analyzer/SKILL.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: plan-analyzer
|
||||
description: |
|
||||
PPT 기획서를 분석하여 요구사항 명세서를 자동 생성합니다.
|
||||
Nuxt pages 라우팅 구조, 컴포넌트 트리, API 엔드포인트 목록,
|
||||
화면 전환 플로우(Mermaid)를 구조화된 마크다운 문서로 출력합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "기획서 분석해줘", "PPT 파싱", "요구사항 정리해줘"
|
||||
- "라우팅 구조 뽑아줘", "컴포넌트 트리 만들어줘"
|
||||
- "API 목록 정리", "화면 플로우 그려줘"
|
||||
- `.pptx` 파일 경로를 제공받고 개발 요구사항 추출을 요청받았을 때
|
||||
permissions:
|
||||
allow:
|
||||
- Bash(python3 -c "import pptx"*)
|
||||
- Bash(python3*extract_pptx.py*)
|
||||
---
|
||||
|
||||
# 요구사항 분석기 (Requirement Analyzer)
|
||||
|
||||
이 skill 은 PPT 기획서(`.pptx`)를 파싱하여 팀 전체가 동일한 기반으로 개발에 착수할 수 있는
|
||||
구조화된 요구사항 명세서를 자동으로 생성합니다.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 기획팀에서 PPT 기획서를 전달받았을 때
|
||||
- 개발 착수 전 화면 목록 / 라우팅 / API 목록을 정리해야 할 때
|
||||
- 화면 전환 플로우를 다이어그램으로 시각화해야 할 때
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 파일 확인 및 추출
|
||||
|
||||
1. **파일 경로 확인**
|
||||
- 사용자가 PPTX 파일 경로를 제공했는지 확인한다.
|
||||
- 미제공 시: 파일 경로를 먼저 요청한다.
|
||||
|
||||
2. **의존성 확인**
|
||||
스킬 디렉토리 기준 스크립트를 실행하기 전, `python-pptx` 설치 여부를 확인한다.
|
||||
```bash
|
||||
python3 -c "import pptx" 2>/dev/null && echo "OK" || echo "MISSING"
|
||||
```
|
||||
- `MISSING` 출력 시: 아래 메시지를 사용자에게 안내한다.
|
||||
```
|
||||
⚠️ python-pptx 패키지가 필요합니다.
|
||||
설치 명령어: pip3 install python-pptx
|
||||
또는 자동 설치: python3 scripts/extract_pptx.py --auto-install <파일경로>
|
||||
```
|
||||
|
||||
3. **PPTX 추출 실행**
|
||||
```bash
|
||||
python3 <skill-dir>/scripts/extract_pptx.py --extract-images "<pptx경로>"
|
||||
```
|
||||
- `--extract-images`: 슬라이드 이미지(PNG)를 임시 디렉토리에 추출 → 시각 분석에 활용
|
||||
- 출력: JSON 형태의 슬라이드 데이터 (슬라이드별 제목, 텍스트, 노트, 도형, 테이블, 이미지 경로)
|
||||
|
||||
4. **결과 확인**
|
||||
- JSON 파싱 성공 여부 확인
|
||||
- 추출된 이미지가 있으면 각 슬라이드별로 순서대로 열어 와이어프레임/목업 시각 분석
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 내용 분석
|
||||
|
||||
추출된 JSON 데이터와 슬라이드 이미지를 기반으로 아래 항목을 분석한다.
|
||||
|
||||
#### 화면(페이지) 식별
|
||||
- 슬라이드 제목에서 "화면", "페이지", 경로(`/`로 시작), URL 패턴 찾기
|
||||
- 와이어프레임 이미지에서 GNB, 레이아웃 영역 확인
|
||||
- 중복 레이아웃 슬라이드(상세 설명용) vs 독립 화면 구분
|
||||
|
||||
#### UI 컴포넌트 패턴 식별
|
||||
- 반복 등장하는 도형 레이블 (예: "GNB", "Footer", "Card", "Modal", "Tab")
|
||||
- 슬라이드 간 동일한 레이아웃 구조 → 공통 컴포넌트 후보
|
||||
- 이미지에서 시각적으로 동일한 UI 블록
|
||||
|
||||
#### 네비게이션 플로우 추론
|
||||
- 화살표/커넥터 도형으로 연결된 화면 간 전환
|
||||
- "클릭 시", "탭하면", "→" 등의 텍스트 주석
|
||||
- 슬라이드 번호 순서 + 제목 키워드로 흐름 유추
|
||||
|
||||
#### API 엔드포인트 추출
|
||||
- 테이블에 "Method", "URL", "API", "Endpoint" 컬럼이 있는 경우
|
||||
- 발표자 노트에 `GET /api/...`, `POST /api/...` 형태의 텍스트
|
||||
- 텍스트 박스 내 `http`, `/api`, `fetch`, `axios` 언급
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 명세서 생성
|
||||
|
||||
아래 7개 섹션으로 구성된 마크다운 문서를 작성한다.
|
||||
추론 기반 정보는 반드시 `(추정)` 또는 `<확인 필요>` 를 표시한다.
|
||||
|
||||
**출력 섹션:**
|
||||
|
||||
```
|
||||
1. 화면 목록 (Pages) — 테이블 형식
|
||||
2. Nuxt 라우팅 구조 — pages/ 파일 트리
|
||||
3. 컴포넌트 트리 — components/ 파일 트리 + Props 상세 테이블
|
||||
4. API 엔드포인트 목록 — 테이블 형식
|
||||
5. 화면 전환 플로우 — Mermaid flowchart TD
|
||||
6. 공통 레이아웃 — 레이아웃별 적용 화면 테이블
|
||||
7. 추가 참고사항 — 미확정 항목 / 확인 필요 목록
|
||||
```
|
||||
|
||||
출력 형식 상세는 `references/output-template.md` 를 참조한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 사용자 확인 및 저장
|
||||
|
||||
1. **미리보기 제공**
|
||||
생성된 명세서 전체를 출력하여 사용자가 검토할 수 있도록 한다.
|
||||
|
||||
2. **저장 경로 확인**
|
||||
```
|
||||
📄 아래 경로에 요구사항 명세서를 저장하려고 합니다:
|
||||
docs/requirements/<기획서파일명>.md
|
||||
|
||||
다른 경로를 원하시면 알려주세요. 진행할까요? (y/n)
|
||||
```
|
||||
|
||||
3. **파일 저장**
|
||||
- 사용자 승인 후 파일을 저장한다.
|
||||
- `docs/requirements/` 디렉토리가 없으면 생성한다.
|
||||
- 저장 완료 후 아래 메시지를 출력한다:
|
||||
```
|
||||
✅ 요구사항 명세서 저장 완료
|
||||
docs/requirements/<파일명>.md
|
||||
|
||||
다음 단계:
|
||||
1) <확인 필요> 항목을 기획자에게 확인하세요.
|
||||
2) 컴포넌트/API 목록을 개발 태스크로 분해하세요.
|
||||
3) git add docs/requirements/ && git commit -m "docs: 요구사항 명세서 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
`references/output-template.md` 에서 전체 마크다운 구조와 예시를 확인하세요.
|
||||
|
||||
### Mermaid 플로우차트 작성 규칙
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[메인] --> B[이벤트 목록]
|
||||
B --> C[이벤트 상세]
|
||||
C --> D{사전등록 완료?}
|
||||
D -- 완료 --> E[완료 모달]
|
||||
D -- 미완료 --> F[사전등록 폼]
|
||||
```
|
||||
|
||||
- 화면 노드: `[화면명]` (사각형)
|
||||
- 조건 분기: `{조건}` (다이아몬드)
|
||||
- 모달/팝업: `([모달명])` (타원)
|
||||
- 화살표 레이블: 클릭 대상 버튼명 또는 조건
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
- **기존 파일 수정 금지**: 프로젝트의 기존 소스 파일은 절대 수정하지 않는다. 명세서 파일만 새로 생성한다.
|
||||
- **추측 표시 필수**: 기획서에 명시되지 않은 정보를 추론할 경우 반드시 `(추정)` 을 표시한다.
|
||||
- **확인 필요 목록**: 확신할 수 없는 항목은 섹션 7에 `<확인 필요>` 로 모아 명시한다.
|
||||
- **이미지 heavy 기획서**: 텍스트가 적고 이미지 위주인 기획서는 `--extract-images` 옵션이 필수다.
|
||||
- **대용량 PPTX**: 슬라이드가 50장 이상이면 `--slides 1-20` 으로 범위를 지정하여 순차 처리한다.
|
||||
- **커밋 금지**: 파일 저장 후 `git commit` 은 사용자 명시 요청이 없으면 실행하지 않는다.
|
||||
159
.claude/skills/plan-analyzer/references/output-template.md
Normal file
159
.claude/skills/plan-analyzer/references/output-template.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# 요구사항 명세서 출력 템플릿
|
||||
|
||||
이 파일은 `requirement-analyzer` 스킬이 생성하는 마크다운 문서의 형식 예시입니다.
|
||||
실제 출력 시 각 항목을 기획서 내용으로 채우고, 불확실한 항목은 `(추정)` 또는 `<확인 필요>` 로 표시합니다.
|
||||
|
||||
---
|
||||
|
||||
```markdown
|
||||
# 요구사항 명세서: <프로젝트명>
|
||||
|
||||
> 기획서: `<파일명.pptx>` | 생성일: <YYYY-MM-DD> | 총 슬라이드: <N>장
|
||||
> 담당 기획자: <확인 필요> | 개발 일정: <확인 필요>
|
||||
|
||||
---
|
||||
|
||||
## 1. 화면 목록 (Pages)
|
||||
|
||||
| # | 화면명 | 라우팅 경로 | 기획서 슬라이드 | 비고 |
|
||||
|---|--------|------------|--------------|------|
|
||||
| 1 | 메인 (랜딩) | `/` | 3-5 | 사전등록 CTA 포함 |
|
||||
| 2 | 사전등록 | `/register` | 6-9 | 폼 입력 화면 |
|
||||
| 3 | 등록 완료 | `/register/complete` | 10 | 완료 안내 화면 |
|
||||
| 4 | 이벤트 소개 | `/about` | 11-13 | 스크롤 단일 페이지 |
|
||||
| 5 | 공지사항 | `/notice` | 14 | (추정) 목록 페이지 |
|
||||
| 6 | 공지사항 상세 | `/notice/[id]` | 14 | (추정) 동적 라우트 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Nuxt 라우팅 구조
|
||||
|
||||
```
|
||||
pages/
|
||||
├── index.vue # 메인 (랜딩)
|
||||
├── register/
|
||||
│ ├── index.vue # 사전등록 폼
|
||||
│ └── complete.vue # 등록 완료
|
||||
├── about.vue # 이벤트 소개
|
||||
└── notice/
|
||||
├── index.vue # 공지사항 목록
|
||||
└── [id].vue # 공지사항 상세 (추정)
|
||||
```
|
||||
|
||||
**레이아웃:**
|
||||
```
|
||||
layouts/
|
||||
├── default.vue # GNB + Footer 포함 (전체 화면)
|
||||
└── blank.vue # GNB/Footer 없음 (모달, 완료 화면 등)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 컴포넌트 트리
|
||||
|
||||
```
|
||||
components/
|
||||
├── common/
|
||||
│ ├── AppHeader.vue # GNB / 상단 네비게이션
|
||||
│ ├── AppFooter.vue # 하단 푸터
|
||||
│ └── AppLogo.vue # 로고 (추정)
|
||||
├── register/
|
||||
│ ├── RegisterForm.vue # 사전등록 입력 폼
|
||||
│ ├── RegisterComplete.vue # 완료 화면 내용
|
||||
│ └── TermsModal.vue # 약관 모달 (추정)
|
||||
├── notice/
|
||||
│ ├── NoticeList.vue # 공지사항 목록 (추정)
|
||||
│ └── NoticeItem.vue # 공지사항 카드 (추정)
|
||||
└── ui/
|
||||
├── BaseButton.vue # 공통 버튼
|
||||
├── BaseModal.vue # 공통 모달 래퍼 (추정)
|
||||
└── BaseInput.vue # 공통 입력 필드 (추정)
|
||||
```
|
||||
|
||||
### 컴포넌트 상세
|
||||
|
||||
| 컴포넌트 | 사용 화면 | Props (추정) | Emits (추정) | 비고 |
|
||||
|----------|----------|-------------|-------------|------|
|
||||
| `AppHeader` | 전체 | `transparent?: boolean` | — | 메인에서 투명 배경 |
|
||||
| `RegisterForm` | 사전등록 | — | `submit` | 폼 상태 내부 관리 |
|
||||
| `BaseButton` | 전체 | `variant`, `size`, `disabled` | `click` | Tailwind 기반 |
|
||||
| `TermsModal` | 사전등록 | `visible` | `close`, `agree` | <확인 필요> |
|
||||
|
||||
---
|
||||
|
||||
## 4. API 엔드포인트 목록
|
||||
|
||||
| # | Method | Endpoint | 설명 | 사용 화면 | 비고 |
|
||||
|---|--------|----------|------|----------|------|
|
||||
| 1 | POST | `/api/register` | 사전등록 제출 | 사전등록 | 폼 데이터 전송 |
|
||||
| 2 | GET | `/api/notices` | 공지사항 목록 | 공지사항 | 페이지네이션 (추정) |
|
||||
| 3 | GET | `/api/notices/:id` | 공지사항 상세 | 공지사항 상세 | (추정) |
|
||||
| 4 | GET | `/api/event/info` | 이벤트 기본 정보 | 메인, 소개 | <확인 필요> |
|
||||
|
||||
> **참고**: API 경로는 기획서 노트/테이블에서 추출했습니다. 백엔드 팀과 확인이 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 화면 전환 플로우
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[메인 랜딩] --> |사전등록 버튼 클릭| B[사전등록 폼]
|
||||
A --> |공지사항 메뉴| C[공지사항 목록]
|
||||
A --> |이벤트 소개 메뉴| D[이벤트 소개]
|
||||
|
||||
B --> |약관 보기 클릭| E([약관 모달])
|
||||
E --> |닫기| B
|
||||
B --> |제출 성공| F[등록 완료]
|
||||
B --> |제출 실패| B
|
||||
|
||||
C --> |항목 클릭| G[공지사항 상세]
|
||||
G --> |목록으로| C
|
||||
|
||||
F --> |메인으로| A
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 공통 레이아웃
|
||||
|
||||
| 레이아웃 | 적용 화면 | 포함 컴포넌트 | 비고 |
|
||||
|----------|----------|-------------|------|
|
||||
| `default` | 메인, 이벤트 소개, 공지사항 | AppHeader, AppFooter | GNB 표시 |
|
||||
| `blank` | 등록 완료 | — | GNB/Footer 없음 (추정) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 추가 참고사항
|
||||
|
||||
### 확인 필요 항목
|
||||
|
||||
- [ ] 약관 모달 내용 및 동의 필수 항목 (슬라이드 8 — 상세 미기재)
|
||||
- [ ] 공지사항 페이지네이션 방식 (무한스크롤 vs 페이지 버튼)
|
||||
- [ ] 사전등록 중복 참여 처리 방식 (동일 이메일 재등록 시)
|
||||
- [ ] `/api/event/info` 엔드포인트 실제 경로 및 응답 스키마
|
||||
- [ ] 다국어 지원 여부 (한국어 전용 vs 영어 병행)
|
||||
|
||||
### 기획서에서 발견된 특이사항
|
||||
|
||||
- 슬라이드 7: "등록 마감 후 버튼 비활성화" — 마감일 기준 상태 분기 필요
|
||||
- 슬라이드 12: 이벤트 소개 섹션 내 동영상 플레이어 포함 — Video 컴포넌트 추가 검토
|
||||
- 슬라이드 14: 공지사항 페이지 기획 미완성으로 표시됨 (`<확인 필요>`)
|
||||
|
||||
### 미정 항목
|
||||
|
||||
| 항목 | 현재 상태 | 담당 |
|
||||
|------|----------|------|
|
||||
| OG 태그 / SNS 공유 설정 | 미기재 | <확인 필요> |
|
||||
| GA / 트래킹 이벤트 정의 | 미기재 | <확인 필요> |
|
||||
| 브라우저 지원 범위 | 미기재 | <확인 필요> |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 활용 팁
|
||||
|
||||
- `<확인 필요>` 항목은 기획자와 킥오프 미팅 전 체크리스트로 활용하세요.
|
||||
- `(추정)` 항목은 개발 착수 전 기획서를 재확인하고 제거하세요.
|
||||
- API 엔드포인트 목록은 백엔드 팀과 함께 검토하여 최종 확정하세요.
|
||||
- Mermaid 플로우차트는 PR 설명이나 Confluence 페이지에 바로 붙여 사용할 수 있습니다.
|
||||
315
.claude/skills/plan-analyzer/scripts/extract_pptx.py
Normal file
315
.claude/skills/plan-analyzer/scripts/extract_pptx.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
PPT 기획서 추출 스크립트 (extract_pptx.py)
|
||||
========================================
|
||||
PPTX 파일을 파싱하여 Claude AI가 분석할 수 있는 JSON 구조로 변환합니다.
|
||||
의미 분석(화면 매핑, API 추출, 플로우 추론)은 Claude AI가 담당합니다.
|
||||
|
||||
사용법:
|
||||
python extract_pptx.py <pptx경로> [옵션]
|
||||
|
||||
옵션:
|
||||
--extract-images 슬라이드 이미지를 PNG로 추출 (--output-dir 로 저장 경로 지정)
|
||||
--output-dir <dir> 이미지 추출 디렉토리 (기본: /tmp/pptx_<파일명>/)
|
||||
--slides <범위> 처리할 슬라이드 범위 (예: 1-10, 5, 3-7,10-12)
|
||||
--auto-install python-pptx 자동 설치 후 실행
|
||||
--pretty JSON 출력 시 들여쓰기 적용
|
||||
|
||||
출력:
|
||||
stdout 에 JSON 데이터 출력
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
import tempfile
|
||||
import re
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 의존성 확인 및 자동 설치
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _ensure_pptx(auto_install: bool) -> None:
|
||||
"""python-pptx 설치 여부 확인. 미설치 시 안내 또는 자동 설치."""
|
||||
try:
|
||||
import pptx # noqa: F401
|
||||
except ImportError:
|
||||
if auto_install:
|
||||
import subprocess
|
||||
print("📦 python-pptx 설치 중...", file=sys.stderr)
|
||||
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'python-pptx'])
|
||||
print("✅ python-pptx 설치 완료\n", file=sys.stderr)
|
||||
else:
|
||||
print(
|
||||
"❌ python-pptx 패키지가 설치되어 있지 않습니다.\n"
|
||||
"\n"
|
||||
"설치 명령어:\n"
|
||||
" pip3 install python-pptx\n"
|
||||
"\n"
|
||||
"또는 자동 설치 옵션을 사용하세요:\n"
|
||||
f" python3 {sys.argv[0]} --auto-install <파일경로>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 슬라이드 범위 파싱
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def parse_slide_range(spec: str, total: int) -> set[int]:
|
||||
"""
|
||||
"1-5,8,10-12" 형태의 슬라이드 범위 문자열을 슬라이드 번호 set 으로 변환.
|
||||
번호는 1-based.
|
||||
"""
|
||||
result: set[int] = set()
|
||||
for part in spec.split(','):
|
||||
part = part.strip()
|
||||
if '-' in part:
|
||||
start, end = part.split('-', 1)
|
||||
result.update(range(int(start), int(end) + 1))
|
||||
else:
|
||||
result.add(int(part))
|
||||
return {n for n in result if 1 <= n <= total}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 도형(Shape) 분류
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _shape_type_name(shape) -> str:
|
||||
"""python-pptx MSO_SHAPE_TYPE 값을 사람이 읽기 쉬운 문자열로 변환."""
|
||||
from pptx.util import Emu # noqa: F401 — 모듈 로드 확인용
|
||||
try:
|
||||
return shape.shape_type.name.lower() # e.g. 'auto_shape', 'picture', 'line'
|
||||
except Exception:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _is_connector(shape) -> bool:
|
||||
"""화살표/커넥터 도형 여부 확인."""
|
||||
try:
|
||||
# MSO_SHAPE_TYPE.LINE = 9, FREEFORM = 5
|
||||
return shape.shape_type in (9,)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 슬라이드 데이터 추출
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def extract_slide(slide, slide_number: int, extract_images: bool, output_dir: str) -> dict:
|
||||
"""단일 슬라이드에서 모든 관련 데이터를 추출하여 dict 반환."""
|
||||
from pptx.enum.shapes import PP_PLACEHOLDER # noqa: F401
|
||||
|
||||
result: dict = {
|
||||
'number': slide_number,
|
||||
'title': '',
|
||||
'texts': [],
|
||||
'notes': '',
|
||||
'images': [],
|
||||
'shapes': [],
|
||||
'tables': [],
|
||||
}
|
||||
|
||||
# ── 제목 추출 ──────────────────────────────
|
||||
for shape in slide.shapes:
|
||||
try:
|
||||
if shape.is_placeholder:
|
||||
ph_type = shape.placeholder_format.type
|
||||
# PP_PLACEHOLDER.TITLE = 1, CENTER_TITLE = 3
|
||||
if ph_type in (1, 3):
|
||||
result['title'] = shape.text.strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 모든 도형 순회 ──────────────────────────
|
||||
for shape in slide.shapes:
|
||||
shape_info: dict = {
|
||||
'type': _shape_type_name(shape),
|
||||
'name': getattr(shape, 'name', ''),
|
||||
'left': int(shape.left or 0),
|
||||
'top': int(shape.top or 0),
|
||||
'width': int(shape.width or 0),
|
||||
'height': int(shape.height or 0),
|
||||
'text': '',
|
||||
}
|
||||
|
||||
# 텍스트 프레임
|
||||
if shape.has_text_frame:
|
||||
text = shape.text_frame.text.strip()
|
||||
shape_info['text'] = text
|
||||
if text:
|
||||
result['texts'].append({
|
||||
'text': text,
|
||||
'left': shape_info['left'],
|
||||
'top': shape_info['top'],
|
||||
'width': shape_info['width'],
|
||||
'height': shape_info['height'],
|
||||
'shape_name': shape_info['name'],
|
||||
})
|
||||
|
||||
# 테이블
|
||||
if shape.has_table:
|
||||
table = shape.table
|
||||
all_rows = list(table.rows)
|
||||
headers = [cell.text.strip() for cell in all_rows[0].cells]
|
||||
rows = [
|
||||
[cell.text.strip() for cell in row.cells]
|
||||
for row in all_rows[1:]
|
||||
]
|
||||
result['tables'].append({'headers': headers, 'rows': rows})
|
||||
shape_info['type'] = 'table'
|
||||
|
||||
# 이미지
|
||||
if shape.shape_type == 13: # MSO_SHAPE_TYPE.PICTURE = 13
|
||||
img_info: dict = {
|
||||
'name': shape.name,
|
||||
'left': shape_info['left'],
|
||||
'top': shape_info['top'],
|
||||
'width': shape_info['width'],
|
||||
'height': shape_info['height'],
|
||||
'path': '',
|
||||
}
|
||||
if extract_images:
|
||||
try:
|
||||
img_bytes = shape.image.blob
|
||||
ext = shape.image.ext # e.g. 'png', 'jpeg'
|
||||
safe_name = re.sub(r'[^\w\-.]', '_', shape.name)
|
||||
img_filename = f"slide{slide_number:03d}_{safe_name}.{ext}"
|
||||
img_path = os.path.join(output_dir, img_filename)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
with open(img_path, 'wb') as f:
|
||||
f.write(img_bytes)
|
||||
img_info['path'] = img_path
|
||||
except Exception as e:
|
||||
img_info['error'] = str(e)
|
||||
result['images'].append(img_info)
|
||||
shape_info['type'] = 'picture'
|
||||
|
||||
# 커넥터/화살표 처리
|
||||
if _is_connector(shape):
|
||||
shape_info['type'] = 'connector'
|
||||
|
||||
result['shapes'].append(shape_info)
|
||||
|
||||
# ── 발표자 노트 ────────────────────────────
|
||||
try:
|
||||
if slide.has_notes_slide:
|
||||
notes_text = slide.notes_slide.notes_text_frame.text.strip()
|
||||
result['notes'] = notes_text
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 메인 추출 함수
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def extract_pptx(
|
||||
filepath: str,
|
||||
extract_images: bool = False,
|
||||
output_dir: str = '',
|
||||
slide_range: str = '',
|
||||
pretty: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
PPTX 파일 전체를 파싱하여 JSON 구조를 stdout 에 출력합니다.
|
||||
|
||||
Args:
|
||||
filepath: PPTX 파일 절대/상대 경로
|
||||
extract_images: True 이면 슬라이드 이미지를 output_dir 에 PNG로 추출
|
||||
output_dir: 이미지 추출 디렉토리 (기본: /tmp/pptx_<파일명>/)
|
||||
slide_range: 처리할 슬라이드 범위 문자열 (예: "1-10", "" = 전체)
|
||||
pretty: True 이면 JSON 들여쓰기 출력
|
||||
"""
|
||||
from pptx import Presentation
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
print(f"❌ 파일을 찾을 수 없습니다: {filepath}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
prs = Presentation(filepath)
|
||||
total_slides = len(prs.slides)
|
||||
filename = os.path.basename(filepath)
|
||||
|
||||
# 출력 디렉토리 결정
|
||||
if not output_dir:
|
||||
stem = re.sub(r'[^\w\-]', '_', os.path.splitext(filename)[0])
|
||||
output_dir = os.path.join(tempfile.gettempdir(), f'pptx_{stem}')
|
||||
|
||||
# 처리 대상 슬라이드 번호 결정
|
||||
if slide_range:
|
||||
target_slides = parse_slide_range(slide_range, total_slides)
|
||||
else:
|
||||
target_slides = set(range(1, total_slides + 1))
|
||||
|
||||
print(
|
||||
f"🔍 파싱 중: {filename} ({total_slides}장 중 {len(target_slides)}장 처리)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if extract_images:
|
||||
print(f"🖼️ 이미지 추출 디렉토리: {output_dir}", file=sys.stderr)
|
||||
|
||||
# 슬라이드 추출
|
||||
slides_data = []
|
||||
for idx, slide in enumerate(prs.slides, start=1):
|
||||
if idx not in target_slides:
|
||||
continue
|
||||
slide_data = extract_slide(slide, idx, extract_images, output_dir)
|
||||
slides_data.append(slide_data)
|
||||
print(f" 슬라이드 {idx}/{total_slides}: {slide_data['title'] or '(제목 없음)'}", file=sys.stderr)
|
||||
|
||||
output = {
|
||||
'filename': filename,
|
||||
'filepath': os.path.abspath(filepath),
|
||||
'total_slides': total_slides,
|
||||
'processed_slides': len(slides_data),
|
||||
'image_output_dir': output_dir if extract_images else '',
|
||||
'slides': slides_data,
|
||||
}
|
||||
|
||||
indent = 2 if pretty else None
|
||||
print(json.dumps(output, ensure_ascii=False, indent=indent))
|
||||
print(f"\n✅ 추출 완료: {len(slides_data)}개 슬라이드", file=sys.stderr)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# CLI 진입점
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='PPTX 기획서를 JSON 구조로 추출합니다.',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument('filepath', nargs='?', help='PPTX 파일 경로')
|
||||
parser.add_argument('--extract-images', action='store_true', help='슬라이드 이미지를 PNG로 추출')
|
||||
parser.add_argument('--output-dir', default='', help='이미지 추출 디렉토리')
|
||||
parser.add_argument('--slides', default='', help='처리할 슬라이드 범위 (예: 1-10, 5, 3-7,10)')
|
||||
parser.add_argument('--auto-install', action='store_true', help='python-pptx 자동 설치')
|
||||
parser.add_argument('--pretty', action='store_true', help='JSON 들여쓰기 출력')
|
||||
args = parser.parse_args()
|
||||
|
||||
_ensure_pptx(args.auto_install)
|
||||
|
||||
if not args.filepath:
|
||||
parser.print_help()
|
||||
print("\n❌ PPTX 파일 경로를 입력해 주세요.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
extract_pptx(
|
||||
filepath=args.filepath,
|
||||
extract_images=args.extract_images,
|
||||
output_dir=args.output_dir,
|
||||
slide_range=args.slides,
|
||||
pretty=args.pretty,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
138
.claude/skills/plan-translation-generator/SKILL.md
Normal file
138
.claude/skills/plan-translation-generator/SKILL.md
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
name: plan-translation-generator
|
||||
description: 번역 요청 엑셀 파일의 EN 셀을 기반으로 '번역코드' 컬럼에 함축적인 코드를 자동 생성합니다. 사용자가 "번역코드 만들어줘", "translation key 생성", "번역 키 추출" 등을 요청하면 트리거됩니다.
|
||||
---
|
||||
|
||||
# 번역 코드 생성 (Translation Key Generator)
|
||||
|
||||
이 skill 은 번역 요청 엑셀 파일의 **EN 컬럼** 텍스트를 분석하여
|
||||
`번역코드` 컬럼에 함축적이고 일관된 코드를 자동으로 작성합니다.
|
||||
|
||||
## 코드 생성 규칙
|
||||
|
||||
| 규칙 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| **단어 수** | 3단어 이하 | `NAV-main` ✅ / `NAV-main-section-title` ❌ |
|
||||
| **구분자** | 단어 사이 `-` (하이픈) | `EVENT-title` |
|
||||
| **첫 단어** | 카테고리를 나타내는 **대문자** | `HERO`, `NAV`, `TOAST` |
|
||||
| **함축성** | 텍스트의 핵심 의미를 최대한 압축 | `HERO-czn-title` (CHAOS ZERO NIGHTMARE → czn) |
|
||||
|
||||
## 카테고리 가이드
|
||||
|
||||
| 카테고리 | 사용 상황 | 예시 |
|
||||
|----------|----------|------|
|
||||
| `BRAND` | 브랜드명, 서비스 이름 | `BRAND-smilegate-ax` |
|
||||
| `NAV` | 내비게이션, 메뉴 항목 | `NAV-main`, `NAV-location` |
|
||||
| `EVENT` | 이벤트 정보 (제목/날짜/장소) | `EVENT-title`, `EVENT-date`, `EVENT-venue` |
|
||||
| `HERO` | 히어로 배너 텍스트 | `HERO-miresi-title` |
|
||||
| `SECTION` | 섹션 헤더/소제목 | `SECTION-intro` |
|
||||
| `BTN` | 버튼 레이블 | `BTN-register`, `BTN-more` |
|
||||
| `TOAST` | 토스트/알림 메시지 | `TOAST-link-copied`, `TOAST-paste-hint` |
|
||||
| `LABEL` | 폼 레이블, 태그 | `LABEL-date`, `LABEL-venue` |
|
||||
| `MSG` | 일반 안내 메시지 | `MSG-loading`, `MSG-empty` |
|
||||
| `MODAL` | 모달/팝업 내 텍스트 | `MODAL-confirm-title` |
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. **파일 확인**
|
||||
- 사용자가 엑셀 파일 경로를 제공했는지 확인
|
||||
- 미제공 시: 파일 경로를 먼저 요청
|
||||
|
||||
2. **데이터 파악**
|
||||
```python
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook('파일경로.xlsx')
|
||||
ws = wb.active
|
||||
# 헤더 행에서 '번역코드', 'EN' 컬럼 인덱스 찾기
|
||||
```
|
||||
- 1행(헤더)에서 `번역코드` 컬럼과 `EN` 컬럼 위치를 동적으로 탐지
|
||||
- 데이터가 있는 행(EN 값이 None이 아닌 행)만 처리
|
||||
|
||||
3. **코드 생성**
|
||||
- EN 텍스트를 분석해 카테고리와 핵심 키워드 추출
|
||||
- 이미 `번역코드` 값이 있는 행은 **덮어쓰지 않음** (사용자 확인 후 진행)
|
||||
- 생성한 코드 목록을 사용자에게 미리 보여주고 승인 요청
|
||||
|
||||
4. **중복 코드 검사** ← 저장 전 반드시 수행
|
||||
- 기존에 이미 작성된 코드 + 이번에 새로 생성한 코드를 **전체 합산**하여 중복 여부 검사
|
||||
- 중복이 발견되면 **저장을 중단**하고 사용자에게 아래 형식으로 보고:
|
||||
```
|
||||
⚠️ 중복 코드 발견
|
||||
- HERO-title: 3행, 12행 (2건)
|
||||
- EVENT-date: 8행, 15행 (2건)
|
||||
```
|
||||
- 중복 해소 방법을 제안하고 사용자 승인 후 재생성
|
||||
- 중복이 없으면 "중복 없음 확인" 메시지 출력 후 저장 진행
|
||||
|
||||
5. **엑셀 저장**
|
||||
- 사용자 승인 후 `번역코드` 컬럼에 값 기입 및 저장
|
||||
- 저장 완료 메시지와 변경 내역 요약 출력
|
||||
|
||||
6. **참조 MD 파일 생성**
|
||||
- 엑셀 저장 완료 후 로컬 참조용 MD 파일을 생성 또는 갱신한다.
|
||||
- 파일 위치: `docs/translation/<엑셀파일명>.md` (확장자 `.xlsx` → `.md` 치환)
|
||||
- 예) `smilegate-ax-2026.xlsx` → `docs/translation/smilegate-ax-2026.md`
|
||||
- `docs/translation/` 디렉토리가 없으면 자동 생성한다.
|
||||
- 파일 내용: 엑셀의 **전체** 번역코드(기존 + 신규)를 아래 형식으로 작성
|
||||
- 헤더에서 `KO` 컬럼을 탐지하여 함께 기록한다 (개발 시 번역코드 매칭 참조용)
|
||||
```markdown
|
||||
# 번역 코드 참조 — <엑셀파일명>
|
||||
|
||||
> 생성일: YYYY-MM-DD
|
||||
> 원본 파일: <엑셀파일 상대경로>
|
||||
|
||||
| 번역코드 | KO | EN |
|
||||
|----------|----|----|
|
||||
| BRAND-smilegate-ax | 스마일게이트 x AX | Smilegate x AX |
|
||||
| NAV-main | 메인 | Main |
|
||||
| ... | ... | ... |
|
||||
```
|
||||
- 파일이 이미 존재하면 **덮어쓰지 않는다**. 새로 생성된 코드 행만 테이블 하단에 추가(append)한다.
|
||||
- 이미 파일에 존재하는 번역코드는 중복 추가하지 않는다.
|
||||
- 생성 완료 후 경로와 추가된 행 수를 사용자에게 출력한다.
|
||||
|
||||
## 코드 작성 예시 (실제 케이스)
|
||||
|
||||
| EN 텍스트 | 생성 코드 | 근거 |
|
||||
|-----------|----------|------|
|
||||
| `Smilegate x AX` | `BRAND-smilegate-ax` | 브랜드명 전체 |
|
||||
| `Main` | `NAV-main` | 내비 메인 항목 |
|
||||
| `Program & Events` | `NAV-program-events` | 내비 항목, 특수문자 제거 |
|
||||
| `Smilegate x Anime Expo` | `EVENT-title` | 이벤트 대표 제목 |
|
||||
| `07/02/2026 (THU)~ 07/05/2026 (SUN)` | `EVENT-date` | 날짜 데이터 |
|
||||
| `Los Angeles Convention Center` | `EVENT-venue` | 행사 장소 |
|
||||
| `The link has been copied.` | `TOAST-link-copied` | 토스트 알림, 링크 복사 완료 |
|
||||
| `You may now paste it elsewhere.` | `TOAST-paste-hint` | 토스트 알림, 붙여넣기 안내 |
|
||||
| `MIRESI ARRIVES AT <br> ANIME EXPO!` | `HERO-miresi-title` | 히어로 배너, 캐릭터명 |
|
||||
| `CHAOS ZERO NIGHTMARE ARRIVES AT <br> ANIME EXPO!` | `HERO-czn-title` | 히어로 배너, 약어 처리 |
|
||||
|
||||
## 처리 스크립트
|
||||
|
||||
스크립트 파일: `scripts/generate_translation_keys.py`
|
||||
|
||||
```bash
|
||||
python scripts/generate_translation_keys.py <엑셀파일경로>
|
||||
```
|
||||
|
||||
스크립트 주요 함수:
|
||||
|
||||
| 함수 | 역할 |
|
||||
|------|------|
|
||||
| `load_existing_codes(ws, code_col)` | 기존 번역 코드를 `{행번호: 코드}` 로 수집 |
|
||||
| `check_duplicates(existing, new)` | 기존+신규 코드 합산 중복 검사, 결과 반환 |
|
||||
| `run(filepath, new_codes)` | 중복 검사 → 저장 실행 (중복 시 중단) |
|
||||
|
||||
> `new_codes` 딕셔너리는 Claude AI가 EN 텍스트를 분석하여 직접 채웁니다.
|
||||
> 스크립트는 중복 검사와 저장만 담당합니다.
|
||||
|
||||
## 주의사항
|
||||
|
||||
- **행 순서 고정**: 엑셀의 기존 행 순서를 절대 변경하지 않는다. 코드는 원래 행 위치에 그대로 기입한다.
|
||||
- **이미지 셀 보존**: 셀에 이미지가 삽입된 경우 해당 셀 및 시트의 이미지를 덮어쓰거나 삭제하지 않는다. `openpyxl.load_workbook(path, keep_vba=True)` 옵션을 사용하고, 이미지 객체(`ws._images`)를 건드리지 않는다.
|
||||
- **기존 코드 보존**: 이미 `번역코드` 값이 있는 셀은 덮어쓰지 않는다.
|
||||
- **중복 코드 방지**: 동일한 코드가 두 행에 생기지 않도록 확인한다.
|
||||
- **HTML 태그 무시**: `<br>`, `<b>` 등 마크업 태그는 의미 분석에서 제외한다.
|
||||
- **날짜/숫자 데이터**: 날짜 형식 셀은 `EVENT-date`, `LABEL-date` 등 의미 기반으로 처리한다.
|
||||
- **약어 처리**: 3단어 초과 시 캐릭터명·고유명사를 약어(초성/이니셜)로 압축한다.
|
||||
- 예) `CHAOS ZERO NIGHTMARE` → `czn`
|
||||
- 사용자가 코드를 직접 수정 요청하면 수정 후 재저장한다.
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
번역 코드 자동 생성 스크립트
|
||||
사용법: python generate_translation_keys.py <엑셀파일경로>
|
||||
|
||||
주의:
|
||||
- 행 순서를 변경하지 않는다 (원래 행 위치에만 값 기입)
|
||||
- 셀에 삽입된 이미지를 보존한다 (ws._images 미수정)
|
||||
"""
|
||||
import sys
|
||||
import openpyxl
|
||||
|
||||
|
||||
def generate_translation_key(en_text: str) -> str:
|
||||
"""
|
||||
EN 텍스트를 분석하여 번역 코드를 생성합니다.
|
||||
규칙:
|
||||
- 3단어 이하 (CATEGORY-word1-word2)
|
||||
- 단어 사이 '-' 구분
|
||||
- 첫 단어는 카테고리 대문자
|
||||
- 함축적인 의미로 생성
|
||||
|
||||
NOTE: 이 함수는 템플릿입니다.
|
||||
실제 코드 생성은 Claude AI가 EN 텍스트의 의미를 분석하여 수행합니다.
|
||||
"""
|
||||
raise NotImplementedError("AI가 EN 텍스트를 분석하여 코드를 직접 생성합니다.")
|
||||
|
||||
|
||||
def load_existing_codes(ws, code_col: int) -> dict[int, str]:
|
||||
"""기존에 입력된 번역 코드를 {행번호: 코드} 형태로 반환합니다."""
|
||||
return {
|
||||
row: ws.cell(row=row, column=code_col).value
|
||||
for row in range(2, ws.max_row + 1)
|
||||
if ws.cell(row=row, column=code_col).value
|
||||
}
|
||||
|
||||
|
||||
def check_duplicates(existing: dict[int, str], new: dict[int, str]) -> dict[str, list[int]]:
|
||||
"""
|
||||
기존 코드 + 신규 코드 전체를 합산하여 중복 검사합니다.
|
||||
반환: {중복코드: [행번호, ...]}
|
||||
"""
|
||||
seen: dict[str, int] = {}
|
||||
duplicates: dict[str, list[int]] = {}
|
||||
|
||||
for row_num, code in {**existing, **new}.items():
|
||||
if code in seen:
|
||||
duplicates.setdefault(code, [seen[code]]).append(row_num)
|
||||
else:
|
||||
seen[code] = row_num
|
||||
|
||||
return duplicates
|
||||
|
||||
|
||||
def run(filepath: str, new_codes: dict[int, str]) -> None:
|
||||
"""
|
||||
신규 코드를 중복 검사 후 엑셀에 저장합니다.
|
||||
|
||||
Args:
|
||||
filepath: 엑셀 파일 경로
|
||||
new_codes: {행번호: 생성된코드} 딕셔너리
|
||||
"""
|
||||
# keep_vba=True: 이미지·VBA 등 파일 내 임베딩 요소를 보존
|
||||
wb = openpyxl.load_workbook(filepath, keep_vba=True)
|
||||
ws = wb.active
|
||||
|
||||
# 이미지가 있는 셀 좌표를 미리 수집 (덮어쓰기 방지)
|
||||
image_cells: set[tuple[int, int]] = set()
|
||||
for img in getattr(ws, '_images', []):
|
||||
anchor = img.anchor
|
||||
if hasattr(anchor, '_from'):
|
||||
image_cells.add((anchor._from.row + 1, anchor._from.col + 1)) # 1-based
|
||||
|
||||
# 헤더에서 컬럼 인덱스 탐지
|
||||
headers = [cell.value for cell in ws[1]]
|
||||
if '번역코드' not in headers or 'EN' not in headers:
|
||||
print("❌ 헤더에 '번역코드' 또는 'EN' 컬럼이 없습니다.")
|
||||
sys.exit(1)
|
||||
|
||||
code_col = headers.index('번역코드') + 1 # 1-based
|
||||
en_col = headers.index('EN') + 1 # 1-based
|
||||
|
||||
# 기존 코드 수집
|
||||
existing_codes = load_existing_codes(ws, code_col)
|
||||
|
||||
# 빈 행 / 이미 코드 있는 행 필터링
|
||||
rows_to_fill: list[tuple[int, str]] = []
|
||||
for row in ws.iter_rows(min_row=2):
|
||||
row_num = row[0].row
|
||||
en_val = row[en_col - 1].value
|
||||
code_val = row[code_col - 1].value
|
||||
|
||||
if not en_val:
|
||||
continue # 빈 행 스킵
|
||||
if code_val:
|
||||
continue # 기존 코드 보존
|
||||
if (row_num, code_col) in image_cells:
|
||||
print(f" ⚠️ Row {row_num}: 이미지 셀 — 스킵 (보존)")
|
||||
continue # 이미지 있는 셀 보존
|
||||
|
||||
if row_num in new_codes:
|
||||
rows_to_fill.append((row_num, new_codes[row_num]))
|
||||
|
||||
if not rows_to_fill:
|
||||
print("ℹ️ 입력할 신규 코드가 없습니다.")
|
||||
return
|
||||
|
||||
# 중복 검사
|
||||
new_codes_filtered = dict(rows_to_fill)
|
||||
duplicates = check_duplicates(existing_codes, new_codes_filtered)
|
||||
|
||||
if duplicates:
|
||||
print("⚠️ 중복 코드 발견 — 저장 중단")
|
||||
for code, rows in duplicates.items():
|
||||
print(f" - {code}: {', '.join(map(str, rows))}행 ({len(rows)}건)")
|
||||
print("\n중복을 해소한 뒤 다시 실행해 주세요.")
|
||||
sys.exit(1)
|
||||
|
||||
# 중복 없음 → 저장
|
||||
print("✅ 중복 없음 확인")
|
||||
for row_num, code in rows_to_fill:
|
||||
ws.cell(row=row_num, column=code_col, value=code)
|
||||
en_text = ws.cell(row=row_num, column=en_col).value
|
||||
print(f" Row {row_num}: {code:<30} ← \"{en_text}\"")
|
||||
|
||||
wb.save(filepath)
|
||||
print(f"\n저장 완료: {filepath}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("사용법: python generate_translation_keys.py <엑셀파일경로>")
|
||||
sys.exit(1)
|
||||
|
||||
# new_codes는 Claude AI가 EN 텍스트를 분석하여 채워줍니다.
|
||||
# 예시:
|
||||
# new_codes = {
|
||||
# 2: 'BRAND-smilegate-ax',
|
||||
# 3: 'NAV-main',
|
||||
# }
|
||||
new_codes: dict[int, str] = {}
|
||||
|
||||
run(sys.argv[1], new_codes)
|
||||
268
.claude/skills/ppt-maker/SKILL.md
Normal file
268
.claude/skills/ppt-maker/SKILL.md
Normal file
@@ -0,0 +1,268 @@
|
||||
---
|
||||
name: ppt-maker
|
||||
description: 순수 HTML, CSS, JavaScript로 웹 프레젠테이션(슬라이드)을 생성합니다. 외부 라이브러리 없이 단일 HTML 파일로 완성되며, GPU 가속 슬라이드 전환, 키보드/터치 네비게이션, 진행 표시바, 반응형 디자인을 포함합니다. 사용자가 프레젠테이션, 슬라이드, 발표자료, PPT 등을 만들어달라고 요청할 때 사용하세요. 'PPT 만들어줘', '슬라이드 생성', '발표자료 만들어줘', 'create a presentation', 'make slides about' 등의 요청에 반응합니다.
|
||||
---
|
||||
|
||||
# PPT Maker
|
||||
|
||||
순수 HTML/CSS/JS 단일 파일 웹 프레젠테이션 생성기.
|
||||
|
||||
## 워크플로우
|
||||
|
||||
1. `references/template.html`을 읽어 전체 구조(CSS/JS)를 파악한다
|
||||
2. 사용자의 주제, 내용, 슬라이드 수 요구사항을 정리한다
|
||||
3. 슬라이드 구성을 설계한다 (어떤 타입의 슬라이드를 어떤 순서로)
|
||||
4. 템플릿의 CSS와 JS를 그대로 유지하면서 슬라이드 HTML 콘텐츠만 교체한다
|
||||
5. 단일 .html 파일로 출력한다
|
||||
6. `open` 명령으로 브라우저에서 열어 확인한다
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
- **단일 파일**: 모든 CSS, JS가 HTML 안에 인라인
|
||||
- **외부 의존성 제로**: Google Fonts만 예외 (CDN 링크)
|
||||
- **GPU 가속**: 전환 애니메이션은 `transform`과 `opacity`만 사용
|
||||
- **뷰포트 피팅**: 모든 슬라이드가 스크롤 없이 화면에 딱 맞아야 함
|
||||
- **접근성**: ARIA 속성, prefers-reduced-motion 대응
|
||||
|
||||
## 디자인 시스템
|
||||
|
||||
### CSS Custom Properties
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--bg-subtle: #111118;
|
||||
--accent: #C6A55C;
|
||||
--accent-dim: rgba(198, 165, 92, 0.15);
|
||||
--accent-glow: rgba(198, 165, 92, 0.3);
|
||||
--text-primary: #F0EBE0;
|
||||
--text-secondary: #B8B0A0;
|
||||
--text-dim: #706858;
|
||||
--border: #1e1c18;
|
||||
--font-kr: 'Noto Sans KR', sans-serif;
|
||||
--font-en: 'Inter', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
```
|
||||
|
||||
사용자가 다른 컬러 테마를 원하면 이 변수들만 교체한다.
|
||||
|
||||
### 타이포그래피
|
||||
|
||||
| 요소 | 크기 | 비고 |
|
||||
|------|------|------|
|
||||
| h1 | `clamp(2rem, 5vw, 4rem)` | 타이틀 슬라이드 |
|
||||
| h2 | `clamp(1.4rem, 3vw, 2.4rem)` | 섹션 제목 |
|
||||
| h3 | `clamp(1.1rem, 2vw, 1.6rem)` | 서브 제목 |
|
||||
| p, li | `clamp(0.85rem, 1.5vw, 1.2rem)` | 본문 |
|
||||
| code | `clamp(0.65rem, 1.1vw, 0.9rem)` | 코드 블록 |
|
||||
|
||||
### 컴포넌트 카탈로그
|
||||
|
||||
- **info-card**: 정보 카드 (`background: rgba(255,255,255,0.02)`, `border: 1px solid var(--border)`)
|
||||
- **split**: 2열 그리드 (`grid-template-columns: 1fr 1fr`)
|
||||
- **split-card**: split 내부 카드
|
||||
- **badge**: 태그/라벨 (`border-radius: 100px`, accent 컬러)
|
||||
- **terminal-badge**: 모노스페이스 라벨 + 깜빡이는 커서 (`> `)
|
||||
- **accent-line**: 강조선 (`width: 50px; height: 2px; background: var(--accent)`)
|
||||
- **code-block**: 줄 번호 + 하이라이트 지원 코드 블록
|
||||
|
||||
## 슬라이드 타입 카탈로그
|
||||
|
||||
### 1. 타이틀 슬라이드 (slide--intro)
|
||||
|
||||
```html
|
||||
<section class="slide slide--intro active" id="slide-1"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 1" aria-hidden="false">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">카테고리</div>
|
||||
<h1 class="reveal">메인 타이틀</h1>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<p class="reveal" style="font-size: clamp(0.95rem, 1.8vw, 1.3rem);">서브 타이틀</p>
|
||||
<div class="accent-line reveal"></div>
|
||||
<p class="reveal" style="font-size: clamp(0.7rem, 1vw, 0.85rem); color: var(--text-dim);">
|
||||
← → 키 또는 스와이프로 이동
|
||||
</p>
|
||||
</div>
|
||||
<div class="slide__footer">날짜</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### 2. 리스트 콘텐츠 (slide--content / slide--content-alt)
|
||||
|
||||
```html
|
||||
<section class="slide slide--content" id="slide-N"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 N" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">영문 키워드</div>
|
||||
<h2 class="reveal">섹션 제목</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<ul>
|
||||
<li class="reveal">항목 1</li>
|
||||
<li class="reveal">항목 2</li>
|
||||
<li class="reveal">항목 3</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
`slide--content`와 `slide--content-alt`를 번갈아 사용하면 배경 그라데이션 방향이 달라져 시각적 변화를 준다.
|
||||
|
||||
### 3. 코드 슬라이드 (slide--code)
|
||||
|
||||
```html
|
||||
<section class="slide slide--code" id="slide-N"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 N" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<h2 class="reveal" style="color: var(--accent);">코드 제목</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<pre class="code-block reveal" data-language="javascript" data-highlight-lines="2,4-6"><code><span class="line"><span class="kw">const</span> <span class="var">x</span> = <span class="str">42</span>;</span>
|
||||
<span class="line"><span class="cmt">// 강조될 줄</span></span></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
**신택스 클래스**: `kw`(키워드), `fn`(함수), `str`(문자열/숫자), `cmt`(주석), `var`(변수), `op`(연산자), `tag`(태그), `attr`(속성), `val`(값)
|
||||
|
||||
`data-highlight-lines`에 강조할 줄 번호를 지정한다 (예: "2,4-6").
|
||||
|
||||
### 4. 분할 레이아웃 (slide--diagram)
|
||||
|
||||
```html
|
||||
<section class="slide slide--diagram" id="slide-N"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 N" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">키워드</div>
|
||||
<h2 class="reveal">제목</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<div class="split">
|
||||
<div class="split-card reveal">
|
||||
<p><strong style="color: var(--accent);">왼쪽 제목</strong><br>설명</p>
|
||||
</div>
|
||||
<div class="split-card reveal">
|
||||
<p><strong style="color: #D4B87A;">오른쪽 제목</strong><br>설명</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### 5. 정보 카드 슬라이드
|
||||
|
||||
```html
|
||||
<div class="slide__body">
|
||||
<div class="info-card reveal">
|
||||
<div class="card-title">// 카드 라벨</div>
|
||||
<p>카드 내용</p>
|
||||
</div>
|
||||
<div class="info-card reveal">
|
||||
<div class="card-title">// 카드 라벨 2</div>
|
||||
<p>카드 내용 2</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 6. 마무리 슬라이드 (slide--end)
|
||||
|
||||
```html
|
||||
<section class="slide slide--end" id="slide-N"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 N" aria-hidden="true">
|
||||
<div class="slide__inner" style="text-align: center; align-items: center;">
|
||||
<div class="slide__body" style="align-items: center;">
|
||||
<h1 class="reveal">감사합니다</h1>
|
||||
<div class="accent-line reveal" style="margin: 0.8rem auto;"></div>
|
||||
<p class="reveal">마무리 메시지</p>
|
||||
<div class="reveal" style="display: flex; gap: 0.5rem; margin-top: 1rem;">
|
||||
<span class="badge">태그1</span>
|
||||
<span class="badge">태그2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
## 콘텐츠 작성 규칙
|
||||
|
||||
### reveal 순차 등장
|
||||
|
||||
- 모든 콘텐츠 요소에 `class="reveal"` 추가
|
||||
- CSS `transition-delay`로 nth-child 기반 순차 등장이 자동 적용됨
|
||||
- 최대 8단계까지 지원 (0.08s 간격)
|
||||
|
||||
### 슬라이드 구조 필수 규칙
|
||||
|
||||
- 첫 번째 슬라이드만 `class="slide ... active"`, `aria-hidden="false"`
|
||||
- 나머지는 `aria-hidden="true"`, active 클래스 없음
|
||||
- 모든 슬라이드에 고유 `id="slide-N"` (1부터 시작)
|
||||
- `role="region"`, `aria-roledescription="슬라이드"`, `aria-label="슬라이드 N"` 필수
|
||||
|
||||
### 슬라이드 수 가이드
|
||||
|
||||
- 사용자가 지정하면 그대로 따름
|
||||
- 지정하지 않으면 주제에 맞게 7~12장 구성
|
||||
- 기본 구성: 타이틀(1) + 개요(1) + 본론(3~8) + 마무리(1)
|
||||
|
||||
### 콘텐츠 밀도
|
||||
|
||||
- 리스트: 슬라이드당 최대 5개 항목
|
||||
- 텍스트: 한 단락 2~3줄 이내
|
||||
- 코드: 최대 10줄
|
||||
- **뷰포트에 딱 맞아야 함 — 스크롤이 발생하면 안 된다**
|
||||
|
||||
## 기술 요구사항
|
||||
|
||||
### 네비게이션
|
||||
|
||||
- **키보드**: ← → / ↑ ↓ / Space / PageUp·Down / Home·End / F(전체화면)
|
||||
- **터치**: 스와이프 (threshold 50px, `passive:false`로 `preventDefault`)
|
||||
- **버튼**: 좌하단 이전/다음 버튼
|
||||
- **URL hash**: `#slide-N` 동기화 + 뒤로가기 지원
|
||||
|
||||
### 전환 애니메이션
|
||||
|
||||
- `transform: translateX(±100%)` + `opacity` (GPU 가속, Composite 전용)
|
||||
- duration: 400~450ms
|
||||
- `isAnimating` 잠금 + `transitionend` + `setTimeout(duration+150)` fallback
|
||||
- `will-change`는 전환 직전 설정, 완료 후 `auto`로 해제
|
||||
|
||||
### UI 요소
|
||||
|
||||
- **진행 표시바**: 상단 2px, accent 컬러
|
||||
- **슬라이드 카운터**: 우하단 `01 / 10` 형식 (2자리 zero-pad)
|
||||
- **네비게이션 버튼**: 좌하단 ‹ › 버튼
|
||||
|
||||
### 접근성
|
||||
|
||||
- `prefers-reduced-motion: reduce`에서 transition-duration 최소화
|
||||
- 모바일 터치 타겟 최소 44px (`@media (hover: none)`)
|
||||
- `100dvh` 사용 (모바일 주소창 동적 대응)
|
||||
|
||||
## 주의사항
|
||||
|
||||
- `display:none` 사용 금지 — transition 불가
|
||||
- 초기 위치: `transition:none` → 위치 설정 → `void el.offsetWidth` → `transition` 복원
|
||||
- Space 키는 반드시 `preventDefault()` (페이지 스크롤 방지)
|
||||
- `will-change`를 모든 슬라이드에 정적 적용하면 GPU 메모리 낭비
|
||||
- 애니메이션 duration 500ms 초과 금지 (사용자 지루함 유발)
|
||||
- `e.repeat` 체크로 키 반복 이벤트 차단
|
||||
|
||||
## 슬라이드 카운터 업데이트
|
||||
|
||||
JS에서 슬라이드 총 개수를 반드시 실제 슬라이드 수와 일치시킨다. `slideCounter` 초기 텍스트도 `01 / {총 슬라이드 수}`로 설정한다.
|
||||
|
||||
## 참고 템플릿
|
||||
|
||||
`references/template.html`에 10장짜리 완전한 동작 예시가 있다.
|
||||
이 파일의 **CSS와 JS를 기반으로** 슬라이드 HTML 콘텐츠만 교체하여 새 프레젠테이션을 만든다.
|
||||
914
.claude/skills/ppt-maker/references/template.html
Normal file
914
.claude/skills/ppt-maker/references/template.html
Normal file
@@ -0,0 +1,914 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>바닐라 슬라이드 프레젠테이션</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&family=Inter:wght@400;700;900&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* ===== CSS Custom Properties ===== */
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--bg-subtle: #111118;
|
||||
--accent: #C6A55C;
|
||||
--accent-dim: rgba(198, 165, 92, 0.15);
|
||||
--accent-glow: rgba(198, 165, 92, 0.3);
|
||||
--text-primary: #F0EBE0;
|
||||
--text-secondary: #B8B0A0;
|
||||
--text-dim: #706858;
|
||||
--border: #1e1c18;
|
||||
|
||||
--font-kr: 'Noto Sans KR', sans-serif;
|
||||
--font-en: 'Inter', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ===== 리셋 ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-kr), var(--font-en), sans-serif;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== 뷰포트: fixed 풀스크린 ===== */
|
||||
.slide-viewport {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-viewport.animating { pointer-events: none; }
|
||||
|
||||
/* ===== 슬라이드: absolute 풀스크린 ===== */
|
||||
.slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: clamp(2rem, 5vw, 5rem);
|
||||
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 400ms ease-in-out,
|
||||
transform 450ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.slide.active {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.slide.stand-left { transform: translateX(-100%); }
|
||||
|
||||
/* ===== 배경 효과 ===== */
|
||||
.slide::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(0deg,
|
||||
transparent, transparent 2px,
|
||||
rgba(255, 255, 255, 0.005) 2px,
|
||||
rgba(255, 255, 255, 0.005) 4px);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slide::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(198, 165, 92, 0.015) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(198, 165, 92, 0.015) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slide > * { position: relative; z-index: 2; }
|
||||
|
||||
/* ===== 콘텐츠 영역 ===== */
|
||||
.slide__inner {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: clamp(0.8rem, 2vw, 1.5rem);
|
||||
}
|
||||
|
||||
.slide__header {}
|
||||
.slide__body { display: flex; flex-direction: column; justify-content: center; gap: 1rem; }
|
||||
.slide__footer { font-size: clamp(0.6rem, 1vw, 0.8rem); color: var(--text-dim); text-align: right; margin-top: auto; }
|
||||
|
||||
/* ===== 타이포 ===== */
|
||||
h1 {
|
||||
font-family: var(--font-kr), var(--font-en), sans-serif;
|
||||
font-size: clamp(2rem, 5vw, 4rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
h2 {
|
||||
font-family: var(--font-kr), var(--font-en), sans-serif;
|
||||
font-size: clamp(1.4rem, 3vw, 2.4rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-kr), var(--font-en), sans-serif;
|
||||
font-size: clamp(1.1rem, 2vw, 1.6rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
p, li {
|
||||
font-family: var(--font-kr), var(--font-en), sans-serif;
|
||||
font-size: clamp(0.85rem, 1.5vw, 1.2rem);
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
ul { list-style: none; padding: 0; }
|
||||
ul li {
|
||||
padding: clamp(0.5rem, 0.8vw, 0.7rem) clamp(0.8rem, 1.2vw, 1rem);
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
ul li::before { content: '→ '; color: var(--accent); font-weight: bold; font-family: var(--font-mono); flex-shrink: 0; }
|
||||
|
||||
/* ===== 슬라이드 배경 변형 ===== */
|
||||
.slide--intro { background: radial-gradient(ellipse at 30% 50%, rgba(198,165,92,0.06) 0%, var(--bg) 70%); }
|
||||
.slide--content { background: radial-gradient(ellipse at 70% 40%, rgba(198,165,92,0.04) 0%, var(--bg) 70%); }
|
||||
.slide--content-alt { background: radial-gradient(ellipse at 30% 60%, rgba(198,165,92,0.04) 0%, var(--bg) 70%); }
|
||||
.slide--code { background: var(--bg); }
|
||||
.slide--diagram { background: radial-gradient(ellipse at 50% 30%, rgba(198,165,92,0.05) 0%, var(--bg) 70%); }
|
||||
.slide--end { background: radial-gradient(ellipse at 50% 50%, rgba(198,165,92,0.08) 0%, var(--bg) 60%); }
|
||||
|
||||
/* ===== reveal 애니메이션 (슬라이드 진입 시 순차 등장) ===== */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: opacity 0.6s var(--ease-out-expo), transform 0.6s var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.slide.active .reveal {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.reveal:nth-child(1) { transition-delay: 0.08s; }
|
||||
.reveal:nth-child(2) { transition-delay: 0.16s; }
|
||||
.reveal:nth-child(3) { transition-delay: 0.24s; }
|
||||
.reveal:nth-child(4) { transition-delay: 0.32s; }
|
||||
.reveal:nth-child(5) { transition-delay: 0.40s; }
|
||||
.reveal:nth-child(6) { transition-delay: 0.48s; }
|
||||
.reveal:nth-child(7) { transition-delay: 0.56s; }
|
||||
.reveal:nth-child(8) { transition-delay: 0.64s; }
|
||||
|
||||
/* .slide__body 내부 자식도 순차 등장 */
|
||||
.slide.active .slide__body > .reveal:nth-child(1) { transition-delay: 0.15s; }
|
||||
.slide.active .slide__body > .reveal:nth-child(2) { transition-delay: 0.25s; }
|
||||
.slide.active .slide__body > .reveal:nth-child(3) { transition-delay: 0.35s; }
|
||||
.slide.active .slide__body > .reveal:nth-child(4) { transition-delay: 0.45s; }
|
||||
.slide.active .slide__body > .reveal:nth-child(5) { transition-delay: 0.55s; }
|
||||
|
||||
/* ul li 순차 등장 */
|
||||
.slide.active ul li.reveal:nth-child(1) { transition-delay: 0.15s; }
|
||||
.slide.active ul li.reveal:nth-child(2) { transition-delay: 0.25s; }
|
||||
.slide.active ul li.reveal:nth-child(3) { transition-delay: 0.35s; }
|
||||
.slide.active ul li.reveal:nth-child(4) { transition-delay: 0.45s; }
|
||||
.slide.active ul li.reveal:nth-child(5) { transition-delay: 0.55s; }
|
||||
|
||||
/* ===== 코드 블록 ===== */
|
||||
.code-block {
|
||||
background: #111118;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.65rem, 1.1vw, 0.9rem);
|
||||
line-height: 1.7;
|
||||
counter-reset: line-number;
|
||||
width: 100%;
|
||||
}
|
||||
.code-block::before {
|
||||
content: attr(data-language);
|
||||
display: block;
|
||||
background: rgba(198,165,92,0.06);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75em;
|
||||
padding: 0.4em 1em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-family: var(--font-mono);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.code-block code { display: block; padding: 1rem 1.2rem; overflow-x: auto; }
|
||||
.code-block .line {
|
||||
display: block;
|
||||
padding: 2px 0 2px 3em;
|
||||
position: relative;
|
||||
counter-increment: line-number;
|
||||
transition: background-color 0.3s ease, opacity 0.3s ease;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.code-block .line::before {
|
||||
content: counter(line-number);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 2.5em;
|
||||
text-align: right;
|
||||
color: #3a3830;
|
||||
font-size: 0.85em;
|
||||
user-select: none;
|
||||
}
|
||||
.code-block .line--highlight {
|
||||
background: rgba(198, 165, 92, 0.08);
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: calc(3em - 3px);
|
||||
}
|
||||
.code-block .line--highlight::before { color: var(--accent); }
|
||||
.code-block.dim-others .line:not(.line--highlight) { opacity: 0.3; }
|
||||
|
||||
/* 신택스 컬러 */
|
||||
.kw { color: #C6A55C; }
|
||||
.fn { color: #D4B87A; }
|
||||
.str { color: #A89070; }
|
||||
.cmt { color: #555040; font-style: italic; }
|
||||
.var { color: #E0D0B0; }
|
||||
.op { color: #8A8070; }
|
||||
.tag { color: #C6A55C; }
|
||||
.attr { color: #D4B87A; }
|
||||
.val { color: #A89070; }
|
||||
|
||||
/* ===== 컴포넌트 ===== */
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
|
||||
|
||||
.split-card {
|
||||
padding: clamp(0.8rem, 1.5vw, 1.2rem);
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
padding: clamp(1rem, 2vw, 1.5rem);
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-card .card-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.7rem, 1vw, 0.85rem);
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.65rem, 0.9vw, 0.8rem);
|
||||
color: var(--accent);
|
||||
padding: 0.2em 0.6em;
|
||||
border: 1px solid var(--accent-dim);
|
||||
border-radius: 100px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.terminal-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.75rem, 1.1vw, 0.95rem);
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-badge::before {
|
||||
content: '> ';
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.accent-line {
|
||||
width: 50px;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.number-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.7rem, 0.9vw, 0.8rem);
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
width: 2em;
|
||||
}
|
||||
|
||||
/* ===== 네비게이션 UI ===== */
|
||||
.progress-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgba(198,165,92,0.1);
|
||||
z-index: 101;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent);
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.slide-counter {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
z-index: 102;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 24px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 102;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(198,165,92,0.2);
|
||||
background: rgba(198,165,92,0.04);
|
||||
color: var(--text-dim);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
}
|
||||
.nav-btn:hover { background: rgba(198,165,92,0.12); color: var(--accent); border-color: rgba(198,165,92,0.4); }
|
||||
|
||||
.speaker-notes { display: none; }
|
||||
|
||||
/* ===== 반응형 ===== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal, .slide { transition-duration: 0.1s !important; }
|
||||
.reveal { transform: none !important; }
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.nav-btn { width: 44px; height: 44px; }
|
||||
}
|
||||
|
||||
@media (max-height: 600px) {
|
||||
.slide { padding: clamp(1rem, 3vw, 2rem); }
|
||||
h1 { font-size: clamp(1.5rem, 4vw, 2.5rem); }
|
||||
h2 { font-size: clamp(1.2rem, 2.5vw, 1.8rem); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="slide-viewport" id="viewport">
|
||||
|
||||
<!-- ============ 슬라이드 01: 타이틀 ============ -->
|
||||
<section class="slide slide--intro active" id="slide-1"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 1" aria-hidden="false">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Presentation</div>
|
||||
<h1 class="reveal">Vanilla Slides</h1>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<p class="reveal" style="font-size: clamp(0.95rem, 1.8vw, 1.3rem);">
|
||||
순수 HTML, CSS, JavaScript로 만드는<br>웹 기반 프레젠테이션
|
||||
</p>
|
||||
<div class="accent-line reveal"></div>
|
||||
<p class="reveal" style="font-size: clamp(0.7rem, 1vw, 0.85rem); color: var(--text-dim);">
|
||||
← → 키 또는 스와이프로 이동
|
||||
</p>
|
||||
</div>
|
||||
<div class="slide__footer">2026.03</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 02: 왜 바닐라인가 ============ -->
|
||||
<section class="slide slide--content" id="slide-2"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 2" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Why vanilla?</div>
|
||||
<h2 class="reveal">왜 바닐라 JS인가</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<ul>
|
||||
<li class="reveal">외부 의존성 제로 — 번들 크기 0KB</li>
|
||||
<li class="reveal">브라우저 표준만으로 완전한 구현</li>
|
||||
<li class="reveal">커스터마이즈의 자유도 극대화</li>
|
||||
<li class="reveal">단일 HTML 파일로 배포 가능</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 03: 핵심 기능 ============ -->
|
||||
<section class="slide slide--content-alt" id="slide-3"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 3" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Features</div>
|
||||
<h2 class="reveal">핵심 기능</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<ul>
|
||||
<li class="reveal">풀스크린 뷰포트 & 반응형 레이아웃</li>
|
||||
<li class="reveal">키보드 / 터치 / 스와이프 네비게이션</li>
|
||||
<li class="reveal">GPU 가속 슬라이드 전환 애니메이션</li>
|
||||
<li class="reveal">순차 등장 reveal 효과</li>
|
||||
<li class="reveal">코드 블록 라인 하이라이트</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 04: 아키텍처 ============ -->
|
||||
<section class="slide slide--diagram" id="slide-4"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 4" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Architecture</div>
|
||||
<h2 class="reveal">아키텍처</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<div class="split">
|
||||
<div class="split-card reveal">
|
||||
<p style="font-size: clamp(0.8rem, 1.3vw, 1.05rem); margin-bottom: 0.8rem;">
|
||||
<strong style="color: var(--accent);">레이아웃</strong><br>
|
||||
fixed 뷰포트 + absolute 슬라이드
|
||||
</p>
|
||||
<p style="font-size: clamp(0.8rem, 1.3vw, 1.05rem);">
|
||||
<strong style="color: #D4B87A;">전환</strong><br>
|
||||
CSS Transition + transform/opacity
|
||||
</p>
|
||||
</div>
|
||||
<div class="split-card reveal">
|
||||
<p style="font-size: clamp(0.8rem, 1.3vw, 1.05rem); margin-bottom: 0.8rem;">
|
||||
<strong style="color: #B89860;">상태 관리</strong><br>
|
||||
slideIndex 단일 상태 + URL hash
|
||||
</p>
|
||||
<p style="font-size: clamp(0.8rem, 1.3vw, 1.05rem);">
|
||||
<strong style="color: #A08848;">등장 효과</strong><br>
|
||||
.reveal 클래스 + transition-delay
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 05: CSS 레이아웃 ============ -->
|
||||
<section class="slide slide--content" id="slide-5"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 5" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Layout</div>
|
||||
<h2 class="reveal">CSS 레이아웃 전략</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<div class="info-card reveal">
|
||||
<div class="card-title">// 풀스크린 뷰포트</div>
|
||||
<p style="font-size: clamp(0.8rem, 1.2vw, 1rem);">
|
||||
<code style="color: var(--accent); font-family: var(--font-mono);">position: fixed</code> 뷰포트 안에<br>
|
||||
<code style="color: var(--accent); font-family: var(--font-mono);">position: absolute</code> 슬라이드를 겹쳐<br>
|
||||
<code style="color: var(--accent); font-family: var(--font-mono);">transform: translateX</code>로 전환합니다
|
||||
</p>
|
||||
</div>
|
||||
<div class="info-card reveal">
|
||||
<div class="card-title">// 콘텐츠 영역</div>
|
||||
<p style="font-size: clamp(0.8rem, 1.2vw, 1rem);">
|
||||
<code style="color: var(--accent); font-family: var(--font-mono);">max-width: 680px</code>로 읽기 편한 폭 유지<br>
|
||||
<code style="color: var(--accent); font-family: var(--font-mono);">clamp()</code>로 반응형 타이포그래피 처리
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 06: 코드 예시 — HTML ============ -->
|
||||
<section class="slide slide--code" id="slide-6"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 6" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<h2 class="reveal" style="color: var(--accent);">HTML 구조</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<pre class="code-block reveal" data-language="html" data-highlight-lines="2,4-5"><code><span class="line"><span class="op"><</span><span class="tag">div</span> <span class="attr">class</span>=<span class="str">"slide-viewport"</span><span class="op">></span></span>
|
||||
<span class="line"> <span class="op"><</span><span class="tag">section</span> <span class="attr">class</span>=<span class="str">"slide active"</span><span class="op">></span></span>
|
||||
<span class="line"> <span class="op"><</span><span class="tag">div</span> <span class="attr">class</span>=<span class="str">"slide__inner"</span><span class="op">></span></span>
|
||||
<span class="line"> <span class="op"><</span><span class="tag">h2</span> <span class="attr">class</span>=<span class="str">"reveal"</span><span class="op">></span><span class="var">제목</span><span class="op"></</span><span class="tag">h2</span><span class="op">></span></span>
|
||||
<span class="line"> <span class="op"><</span><span class="tag">p</span> <span class="attr">class</span>=<span class="str">"reveal"</span><span class="op">></span><span class="var">내용</span><span class="op"></</span><span class="tag">p</span><span class="op">></span></span>
|
||||
<span class="line"> <span class="op"></</span><span class="tag">div</span><span class="op">></span></span>
|
||||
<span class="line"> <span class="op"></</span><span class="tag">section</span><span class="op">></span></span>
|
||||
<span class="line"><span class="op"></</span><span class="tag">div</span><span class="op">></span></span></code></pre>
|
||||
<p class="reveal" style="font-size: clamp(0.7rem, 1.1vw, 0.95rem); color: var(--accent); margin-top: 0.5rem;">
|
||||
.reveal 클래스로 순차 등장 효과가 자동 적용됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 07: 코드 예시 — JS ============ -->
|
||||
<section class="slide slide--code" id="slide-7"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 7" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<h2 class="reveal" style="color: var(--accent);">슬라이드 전환 로직</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<pre class="code-block reveal" data-language="javascript" data-highlight-lines="3,6-7"><code><span class="line"><span class="fn">goNext</span>() {</span>
|
||||
<span class="line"> <span class="kw">if</span> (<span class="kw">this</span>.<span class="var">isAnimating</span>) <span class="kw">return</span>;</span>
|
||||
<span class="line"> <span class="kw">this</span>.<span class="fn">_goSlide</span>(<span class="kw">this</span>.<span class="var">current</span> + <span class="str">1</span>, <span class="str">'next'</span>);</span>
|
||||
<span class="line">}</span>
|
||||
<span class="line"></span>
|
||||
<span class="line"><span class="cmt">// GPU 가속: transform + opacity만 사용</span></span>
|
||||
<span class="line"><span class="var">to</span>.<span class="var">style</span>.<span class="var">transform</span> = <span class="str">'translateX(0)'</span>;</span>
|
||||
<span class="line"><span class="var">from</span>.<span class="var">style</span>.<span class="var">transform</span> = <span class="str">'translateX(-100%)'</span>;</span></code></pre>
|
||||
<p class="reveal" style="font-size: clamp(0.7rem, 1.1vw, 0.95rem); color: var(--text-dim); margin-top: 0.5rem;">
|
||||
isAnimating 플래그 + transitionend로 이벤트 잠금
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 08: 네비게이션 ============ -->
|
||||
<section class="slide slide--content-alt" id="slide-8"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 8" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Navigation</div>
|
||||
<h2 class="reveal">네비게이션 지원</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<div class="split">
|
||||
<div class="split-card reveal">
|
||||
<h3 style="font-size: clamp(0.85rem, 1.2vw, 1rem); color: var(--accent); margin-bottom: 0.5rem;">키보드</h3>
|
||||
<p style="font-size: clamp(0.75rem, 1.1vw, 0.95rem);">
|
||||
← → 이전/다음<br>
|
||||
Space / PageDown 다음<br>
|
||||
Home / End 처음/끝<br>
|
||||
F 전체화면
|
||||
</p>
|
||||
</div>
|
||||
<div class="split-card reveal">
|
||||
<h3 style="font-size: clamp(0.85rem, 1.2vw, 1rem); color: var(--accent); margin-bottom: 0.5rem;">터치</h3>
|
||||
<p style="font-size: clamp(0.75rem, 1.1vw, 0.95rem);">
|
||||
좌 스와이프 → 다음<br>
|
||||
우 스와이프 → 이전<br>
|
||||
threshold: 50px<br>
|
||||
touch-action: pan-y
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card reveal" style="margin-top: 0.8rem;">
|
||||
<div class="card-title">// URL 동기화</div>
|
||||
<p style="font-size: clamp(0.75rem, 1.1vw, 0.95rem);">
|
||||
<code style="color: var(--accent); font-family: var(--font-mono);">#slide-N</code> 해시로 뒤로가기 및 직접 접근 지원
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 09: 주의사항 ============ -->
|
||||
<section class="slide slide--content" id="slide-9"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 9" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Pitfalls</div>
|
||||
<h2 class="reveal">주의사항</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<ul>
|
||||
<li class="reveal"><span style="color: var(--accent);">display:none</span> 사용 금지 — transition 불가</li>
|
||||
<li class="reveal">모바일 높이는 <span style="color: var(--accent);">100dvh</span> 사용</li>
|
||||
<li class="reveal"><span style="color: var(--accent);">will-change</span>는 동적 토글 (정적 적용 시 메모리 낭비)</li>
|
||||
<li class="reveal"><span style="color: var(--accent);">transitionend</span> 미발생 대비 setTimeout fallback 필수</li>
|
||||
<li class="reveal">터치 <span style="color: var(--accent);">passive:false</span>에서만 preventDefault 가능</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 10: 마무리 ============ -->
|
||||
<section class="slide slide--end" id="slide-10"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 10" aria-hidden="true">
|
||||
<div class="slide__inner" style="text-align: center; align-items: center;">
|
||||
<div class="slide__body" style="align-items: center;">
|
||||
<h1 class="reveal">감사합니다</h1>
|
||||
<div class="accent-line reveal" style="margin: 0.8rem auto;"></div>
|
||||
<p class="reveal" style="font-size: clamp(0.85rem, 1.3vw, 1.1rem);">
|
||||
외부 라이브러리 0개 · 단일 HTML 파일
|
||||
</p>
|
||||
<div class="reveal" style="display: flex; gap: 0.5rem; margin-top: 1rem;">
|
||||
<span class="badge">HTML</span>
|
||||
<span class="badge">CSS</span>
|
||||
<span class="badge">JavaScript</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 네비게이션 UI -->
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<div class="slide-counter" id="slideCounter">01 / 10</div>
|
||||
<div class="nav-buttons">
|
||||
<button class="nav-btn" id="btnPrev" aria-label="이전 슬라이드">‹</button>
|
||||
<button class="nav-btn" id="btnNext" aria-label="다음 슬라이드">›</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class Presentation {
|
||||
constructor() {
|
||||
this.viewport = document.getElementById('viewport');
|
||||
this.slides = Array.from(this.viewport.querySelectorAll('.slide'));
|
||||
this.total = this.slides.length;
|
||||
this.current = 0;
|
||||
this.isAnimating = false;
|
||||
this.DURATION = 450;
|
||||
|
||||
// 코드 하이라이트 적용
|
||||
this.slides.forEach(s => this._applyCodeHighlights(s));
|
||||
|
||||
// URL hash에서 초기 슬라이드 복원
|
||||
this._restoreFromHash();
|
||||
|
||||
// 이벤트 바인딩
|
||||
this._bindKeyboard();
|
||||
this._bindTouch();
|
||||
this._bindButtons();
|
||||
this._bindHash();
|
||||
this._updateUI();
|
||||
}
|
||||
|
||||
// ── 한 페이지 단위 이동 ─────────────────────
|
||||
|
||||
goNext() {
|
||||
if (this.isAnimating || this.current >= this.total - 1) return;
|
||||
this._goSlide(this.current + 1, 'next');
|
||||
}
|
||||
|
||||
goPrev() {
|
||||
if (this.isAnimating || this.current <= 0) return;
|
||||
this._goSlide(this.current - 1, 'prev');
|
||||
}
|
||||
|
||||
goTo(index) {
|
||||
if (this.isAnimating || index === this.current) return;
|
||||
if (index < 0 || index >= this.total) return;
|
||||
this._goSlide(index, index > this.current ? 'next' : 'prev');
|
||||
}
|
||||
|
||||
// ── 슬라이드 전환 ───────────────────────────
|
||||
|
||||
_goSlide(index, direction) {
|
||||
const isNext = direction === 'next';
|
||||
const from = this.slides[this.current];
|
||||
const to = this.slides[index];
|
||||
|
||||
this.isAnimating = true;
|
||||
this.viewport.classList.add('animating');
|
||||
|
||||
from.style.willChange = 'transform, opacity';
|
||||
to.style.willChange = 'transform, opacity';
|
||||
|
||||
// 진입 슬라이드 초기 위치 (transition 없이)
|
||||
to.style.transition = 'none';
|
||||
to.classList.remove('stand-left');
|
||||
to.style.transform = isNext ? 'translateX(100%)' : 'translateX(-100%)';
|
||||
to.style.opacity = '0';
|
||||
void to.offsetWidth;
|
||||
|
||||
// transition 복원 + 애니메이션 시작
|
||||
to.style.transition = '';
|
||||
to.style.transform = '';
|
||||
to.style.opacity = '';
|
||||
from.classList.remove('active');
|
||||
to.classList.add('active');
|
||||
|
||||
from.style.transform = isNext ? 'translateX(-100%)' : 'translateX(100%)';
|
||||
from.style.opacity = '0';
|
||||
|
||||
from.setAttribute('aria-hidden', 'true');
|
||||
to.setAttribute('aria-hidden', 'false');
|
||||
|
||||
this.current = index;
|
||||
this._syncHash();
|
||||
this._updateUI();
|
||||
|
||||
// 완료 감지
|
||||
let done = false;
|
||||
const cleanup = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(fallback);
|
||||
to.removeEventListener('transitionend', onEnd);
|
||||
|
||||
from.style.transform = '';
|
||||
from.style.opacity = '';
|
||||
from.style.transition = 'none';
|
||||
from.classList.add('stand-left');
|
||||
void from.offsetWidth;
|
||||
from.style.transition = '';
|
||||
|
||||
from.style.willChange = 'auto';
|
||||
to.style.willChange = 'auto';
|
||||
|
||||
this.isAnimating = false;
|
||||
this.viewport.classList.remove('animating');
|
||||
};
|
||||
|
||||
const onEnd = (e) => {
|
||||
if (e.propertyName !== 'transform' || e.target !== to) return;
|
||||
cleanup();
|
||||
};
|
||||
to.addEventListener('transitionend', onEnd);
|
||||
const fallback = setTimeout(cleanup, this.DURATION + 150);
|
||||
}
|
||||
|
||||
// ── 유틸리티 ─────────────────────────────────
|
||||
|
||||
_applyCodeHighlights(slide) {
|
||||
slide.querySelectorAll('.code-block[data-highlight-lines]').forEach(pre => {
|
||||
const ranges = this._parseRanges(pre.dataset.highlightLines);
|
||||
const lines = pre.querySelectorAll('.line');
|
||||
lines.forEach((line, i) => {
|
||||
if (ranges.has(i + 1)) line.classList.add('line--highlight');
|
||||
});
|
||||
if (ranges.size > 0) pre.classList.add('dim-others');
|
||||
});
|
||||
}
|
||||
|
||||
_parseRanges(str) {
|
||||
const result = new Set();
|
||||
str.split(',').forEach(part => {
|
||||
part = part.trim();
|
||||
if (part.includes('-')) {
|
||||
const [a, b] = part.split('-').map(Number);
|
||||
for (let i = a; i <= b; i++) result.add(i);
|
||||
} else {
|
||||
result.add(Number(part));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
_updateUI() {
|
||||
const progress = this.total <= 1 ? 100 : (this.current / (this.total - 1)) * 100;
|
||||
document.getElementById('progressFill').style.width = `${progress}%`;
|
||||
const num = String(this.current + 1).padStart(2, '0');
|
||||
const tot = String(this.total).padStart(2, '0');
|
||||
document.getElementById('slideCounter').textContent = `${num} / ${tot}`;
|
||||
}
|
||||
|
||||
_syncHash() {
|
||||
const hash = `#slide-${this.current + 1}`;
|
||||
if (location.hash !== hash) history.pushState(null, '', hash);
|
||||
}
|
||||
|
||||
_restoreFromHash() {
|
||||
const match = location.hash.match(/^#slide-(\d+)$/);
|
||||
if (match) {
|
||||
const idx = parseInt(match[1], 10) - 1;
|
||||
if (idx > 0 && idx < this.total) {
|
||||
this.slides[0].classList.remove('active');
|
||||
this.slides[0].setAttribute('aria-hidden', 'true');
|
||||
this.slides[0].classList.add('stand-left');
|
||||
this.slides[idx].classList.add('active');
|
||||
this.slides[idx].setAttribute('aria-hidden', 'false');
|
||||
this.current = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 이벤트 바인딩 ────────────────────────────
|
||||
|
||||
_bindKeyboard() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.repeat) return;
|
||||
const tag = document.activeElement.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight': case 'ArrowDown': case ' ': case 'PageDown':
|
||||
e.preventDefault(); this.goNext(); break;
|
||||
case 'ArrowLeft': case 'ArrowUp': case 'PageUp':
|
||||
e.preventDefault(); this.goPrev(); break;
|
||||
case 'Home':
|
||||
e.preventDefault(); this.goTo(0); break;
|
||||
case 'End':
|
||||
e.preventDefault(); this.goTo(this.total - 1); break;
|
||||
case 'f': case 'F':
|
||||
this._toggleFullscreen(); break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_bindTouch() {
|
||||
let startX = 0, startY = 0, startTime = 0, swiping = false;
|
||||
const el = this.viewport;
|
||||
|
||||
el.addEventListener('touchstart', (e) => {
|
||||
const t = e.changedTouches[0];
|
||||
startX = t.clientX; startY = t.clientY;
|
||||
startTime = Date.now(); swiping = false;
|
||||
}, { passive: true });
|
||||
|
||||
el.addEventListener('touchmove', (e) => {
|
||||
const t = e.changedTouches[0];
|
||||
const dx = Math.abs(t.clientX - startX);
|
||||
const dy = Math.abs(t.clientY - startY);
|
||||
if (dx > dy && dx > 10) { swiping = true; e.preventDefault(); }
|
||||
}, { passive: false });
|
||||
|
||||
el.addEventListener('touchend', (e) => {
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - startX;
|
||||
const dy = Math.abs(t.clientY - startY);
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (swiping && Math.abs(dx) >= 50 && dy <= 75 && elapsed <= 400) {
|
||||
dx < 0 ? this.goNext() : this.goPrev();
|
||||
}
|
||||
swiping = false;
|
||||
}, { passive: true });
|
||||
|
||||
el.style.touchAction = 'pan-y';
|
||||
el.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
_bindButtons() {
|
||||
document.getElementById('btnPrev').addEventListener('click', () => this.goPrev());
|
||||
document.getElementById('btnNext').addEventListener('click', () => this.goNext());
|
||||
}
|
||||
|
||||
_bindHash() {
|
||||
window.addEventListener('hashchange', () => {
|
||||
const match = location.hash.match(/^#slide-(\d+)$/);
|
||||
if (match) {
|
||||
const idx = parseInt(match[1], 10) - 1;
|
||||
if (idx !== this.current) this.goTo(idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => new Presentation());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
165
.claude/skills/project-init/SKILL.md
Normal file
165
.claude/skills/project-init/SKILL.md
Normal file
@@ -0,0 +1,165 @@
|
||||
---
|
||||
name: project-init
|
||||
description: >
|
||||
현재 프로젝트를 분석하여 .claude/project/ 하위 문서(overview.md, conventions.md, architecture.md)를
|
||||
실제 내용으로 채웁니다. 사용자가 "/init", "프로젝트 초기화", "프로젝트 문서 작성해줘" 등을
|
||||
요청할 때 트리거됩니다.
|
||||
---
|
||||
|
||||
# Project Init — 프로젝트 문서 자동 작성
|
||||
|
||||
이 skill 은 현재 Git 저장소를 탐색하여 `.claude/project/` 하위 세 파일을
|
||||
실제 프로젝트 정보로 채웁니다.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- `/init` 실행 시
|
||||
- `.claude/project/*.md` 가 템플릿 상태(placeholder 가 남아 있음)일 때
|
||||
- 새로운 프로젝트에 gameservice-fe-agent 를 처음 적용할 때
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### 1. 환경 파악 (읽기 전용)
|
||||
|
||||
아래 파일들을 순서대로 읽어 프로젝트 정보를 수집한다.
|
||||
파일이 없으면 건너뛴다.
|
||||
|
||||
| 파일 / 명령 | 수집 정보 |
|
||||
|---|---|
|
||||
| `package.json` | 프로젝트 이름, 의존성, 스크립트, 패키지 매니저 |
|
||||
| `pnpm-lock.yaml` / `yarn.lock` / `package-lock.json` | 패키지 매니저 확정 |
|
||||
| `nuxt.config.ts` / `next.config.*` / `vite.config.*` | 프레임워크, 모듈, 빌드 설정 |
|
||||
| `tsconfig.json` | TypeScript strict 여부 |
|
||||
| `tailwind.config.*` | CSS 프레임워크 |
|
||||
| `.eslintrc.*` / `eslint.config.*` | 린터 설정 |
|
||||
| `README.md` | 서비스 설명, 팀 정보 |
|
||||
| `app/` / `src/` / `pages/` 디렉토리 구조 | 레이어, 주요 기능 |
|
||||
| `server/api/` 또는 `src/api/` | 외부 의존성, API 패턴 |
|
||||
| `stores/` / `composables/` | 상태 관리 패턴 |
|
||||
|
||||
### 2. 기존 문서 상태 확인
|
||||
|
||||
`.claude/project/overview.md`, `conventions.md`, `architecture.md` 를 읽어
|
||||
이미 작성된 내용이 있으면 덮어쓰지 않고 **비어 있는 섹션만 채운다**.
|
||||
|
||||
- placeholder(`<...>`) 가 남아 있는 줄 → 채움 대상
|
||||
- 실제 내용이 작성된 줄 → 유지
|
||||
|
||||
### 3. 사용자 확인 후 작성
|
||||
|
||||
탐색이 끝나면 아래 요약을 사용자에게 먼저 보여준다.
|
||||
|
||||
```
|
||||
📋 감지된 프로젝트 정보
|
||||
이름: <name>
|
||||
프레임워크: <framework>
|
||||
패키지 매니저: <pm>
|
||||
언어: TypeScript (strict: <yes/no>)
|
||||
CSS: <css>
|
||||
상태관리: <state>
|
||||
테스트: <test>
|
||||
|
||||
✍️ 아래 파일을 업데이트하려고 합니다:
|
||||
- CLAUDE.md (제목 · 주요 명령어 · 주의사항)
|
||||
- .claude/project/overview.md
|
||||
- .claude/project/conventions.md
|
||||
- .claude/project/architecture.md
|
||||
|
||||
진행할까요? (y/n)
|
||||
```
|
||||
|
||||
사용자가 **y** 또는 별도 지시 없이 진행을 허락할 때만 파일을 수정한다.
|
||||
|
||||
### 4. 각 파일 작성 규칙
|
||||
|
||||
#### CLAUDE.md
|
||||
|
||||
루트 `CLAUDE.md` 는 Claude 가 대화 시작 시 **항상** 읽는 파일이므로, 매번 하위 파일까지 탐색하지 않아도 되는 핵심 정보만 간결하게 기재한다.
|
||||
|
||||
- **제목** (`# <프로젝트 이름>`): `package.json` 의 `name` 필드로 교체
|
||||
예) `# epic7-esports` → `# Epic7 Esports E7WC`
|
||||
`name` 이 slug 형태면 사람이 읽기 좋은 표기로 변환한다.
|
||||
|
||||
- **`## 주요 명령어`** 섹션 (필수 추가):
|
||||
`package.json` 의 `scripts` 에서 아래 유형을 추출하여 그룹별로 정리한다.
|
||||
주석은 스크립트 이름만으로 의도를 알기 어려울 때만 달고, 뻔한 것은 생략한다.
|
||||
|
||||
```markdown
|
||||
## 주요 명령어
|
||||
|
||||
# 개발 서버
|
||||
<dev 계열 스크립트>
|
||||
|
||||
# 빌드
|
||||
<build 계열 스크립트>
|
||||
|
||||
# 린트 / 포맷
|
||||
<lint 계열 스크립트>
|
||||
```
|
||||
|
||||
- `dev`, `start`, `local` 등 → **개발 서버** 그룹
|
||||
- `build`, `build:*` → **빌드** 그룹
|
||||
- `lint`, `lint:*`, `format`, `prettier` → **린트 / 포맷** 그룹
|
||||
- `test`, `test:*` → **테스트** 그룹 (존재할 때만)
|
||||
- 나머지 잡다한 스크립트는 포함하지 않는다.
|
||||
|
||||
- **`## 주의사항`** 섹션 (선택 추가):
|
||||
프로젝트 고유의 "함정"이 될 수 있는 항목을 2-4개 이내로 요약한다.
|
||||
소스는 `conventions.md` 의 **금지 사항** 섹션 + CSS/API 탐색 결과.
|
||||
없으면 이 섹션은 추가하지 않는다.
|
||||
|
||||
```markdown
|
||||
## 주의사항
|
||||
|
||||
- CSS: <탐색 결과에 따라 기재. 예: Tailwind 아님, SCSS 사용 — Tailwind 클래스 금지>
|
||||
- API: <API 호출 패턴 금지/허용 규칙>
|
||||
- 환경: <.env 파일 분기 규칙>
|
||||
```
|
||||
|
||||
- 이미 제목이 실제 이름으로 바뀌어 있거나 섹션이 존재하면 덮어쓰지 않고 유지한다.
|
||||
|
||||
#### overview.md
|
||||
|
||||
- **서비스 > 이름**: `package.json` 의 `name` 필드 사용
|
||||
- **서비스 > 설명**: `README.md` 첫 단락 또는 `package.json` 의 `description`
|
||||
- **기술 스택**: 탐색에서 수집한 실제 값으로 채움. 모르면 `<확인 필요>` 유지
|
||||
- **주요 기능**: `pages/` 또는 `app/pages/` 디렉토리 목록을 기반으로 유추
|
||||
- **참고 문서**: `README.md` 에 링크가 있으면 옮기고, 없으면 `<확인 필요>` 유지
|
||||
|
||||
#### conventions.md
|
||||
|
||||
- **디렉토리 규칙**: 실제 디렉토리 구조에서 확인된 폴더만 설명, 없는 항목은 삭제
|
||||
- **API 호출 창구**: `composables/` 또는 `api/` 디렉토리 패턴에서 유추
|
||||
- **테스트 러너 / 위치**: `package.json` devDependencies + `vitest.config.*` 참고
|
||||
- 공통 지침과 다른 점이 없다면 "공통 지침을 따른다" 로 간단히 기재
|
||||
|
||||
#### architecture.md
|
||||
|
||||
- **레이어 구조**: 실제 디렉토리 목록으로 ASCII 다이어그램을 업데이트
|
||||
- **상태 관리 가이드**: 탐색에서 확인한 라이브러리로 표의 권장 위치를 채움
|
||||
- **외부 의존성**: `server/api/` 파일명 또는 `useFetch` 호출에서 유추. 불확실하면 `<확인 필요>`
|
||||
|
||||
### 5. 완료 안내
|
||||
|
||||
```
|
||||
✅ 문서 업데이트 완료
|
||||
|
||||
변경된 파일:
|
||||
- CLAUDE.md
|
||||
- .claude/project/overview.md
|
||||
- .claude/project/conventions.md
|
||||
- .claude/project/architecture.md
|
||||
|
||||
다음 단계:
|
||||
1) 각 파일에서 <확인 필요> 항목을 직접 채워주세요.
|
||||
2) 변경 사항을 커밋해 주세요:
|
||||
git add CLAUDE.md .claude/project/
|
||||
git commit -m "docs: 프로젝트 초기 문서 작성"
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
- **파일 삭제 금지**: 기존 `.claude/project/*.md` 는 내용을 수정할 뿐 절대 삭제하지 않는다.
|
||||
- **추측 표시**: 확실하지 않은 정보는 `(추정)` 을 붙이고, 사용자가 쉽게 찾아 수정할 수 있게 한다.
|
||||
- **최소 변경**: 이미 올바르게 작성된 섹션은 건드리지 않는다.
|
||||
- **커밋 금지**: 파일 수정 후 git commit 은 사용자 명시 요청이 없으면 실행하지 않는다.
|
||||
141
.claude/skills/security-review/SKILL.md
Normal file
141
.claude/skills/security-review/SKILL.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: security-review
|
||||
description: |
|
||||
프론트엔드 코드에서 XSS, CSRF, 민감 정보 하드코딩, npm 의존성 취약점을 자동 감지하고
|
||||
우선순위별 보안 리포트와 패치 가이드를 생성합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "보안 점검해줘", "취약점 검사해줘", "npm audit 분석해줘"
|
||||
- PR/배포 전 보안 체크리스트를 확인할 때
|
||||
- 민감 정보 하드코딩이 의심될 때
|
||||
---
|
||||
|
||||
# 프론트엔드 보안 검토 (security-review)
|
||||
|
||||
코드베이스 + npm audit → XSS / CSRF / 민감 정보 / 의존성 취약점 자동 스캔 → 보안 리포트.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- PR 머지 전 보안 사전 검토
|
||||
- 배포 전 최종 보안 체크리스트
|
||||
- 의존성 업데이트 후 취약점 확인
|
||||
|
||||
## 스캔 범위
|
||||
|
||||
| 항목 | 설명 |
|
||||
|---|---|
|
||||
| XSS | `v-html`, `innerHTML`, `dangerouslySetInnerHTML` 사용 |
|
||||
| 민감 정보 하드코딩 | API 키, 토큰, 비밀번호, 시크릿 리터럴 |
|
||||
| CSRF | `SameSite` 쿠키 설정, CORS 구성 |
|
||||
| 오픈 리다이렉트 | 검증 없는 외부 URL 리다이렉트 |
|
||||
| npm 의존성 취약점 | CVE 등급별 취약 패키지 |
|
||||
| 환경 변수 노출 | `NUXT_PUBLIC_` 외 시크릿을 클라이언트에 노출 |
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 코드 정적 분석
|
||||
|
||||
1. 소스 파일을 스캔해 아래 패턴을 탐지한다.
|
||||
|
||||
#### XSS 위험
|
||||
```vue
|
||||
<!-- ⚠️ 위험: v-html에 사용자 입력 직접 바인딩 -->
|
||||
<div v-html="userInput" />
|
||||
|
||||
<!-- ✅ 안전: DOMPurify로 새니타이징 -->
|
||||
<div v-html="sanitize(userInput)" />
|
||||
```
|
||||
|
||||
탐지 패턴:
|
||||
- `v-html` + 사용자 입력 변수 바인딩
|
||||
- `innerHTML =` 직접 할당
|
||||
- `eval()`, `Function()` 사용
|
||||
|
||||
#### 민감 정보 하드코딩
|
||||
탐지 패턴:
|
||||
- `apiKey`, `api_key`, `secret`, `password`, `token` 변수에 리터럴 문자열 할당
|
||||
- `.env` 파일이 아닌 소스 코드에 키 형태 문자열 (예: `sk-...`, `Bearer ...`)
|
||||
- `nuxt.config.ts`에 시크릿을 `public` 섹션에 노출
|
||||
|
||||
```ts
|
||||
// ⚠️ 위험
|
||||
const apiKey = 'sk-abc123...'
|
||||
|
||||
// ✅ 안전
|
||||
const apiKey = useRuntimeConfig().apiKey // 서버 전용
|
||||
```
|
||||
|
||||
#### 오픈 리다이렉트
|
||||
```ts
|
||||
// ⚠️ 위험: 검증 없는 외부 URL 리다이렉트
|
||||
const to = route.query.redirect as string
|
||||
navigateTo(to)
|
||||
|
||||
// ✅ 안전: 내부 경로만 허용
|
||||
const to = route.query.redirect as string
|
||||
if (to && to.startsWith('/')) navigateTo(to)
|
||||
```
|
||||
|
||||
#### Nuxt 환경 변수 분리 확인
|
||||
- 서버 전용 시크릿: `runtimeConfig.apiSecret` (클라이언트 미노출)
|
||||
- 공개 가능한 값만: `runtimeConfig.public.apiBase`
|
||||
|
||||
### Phase 2: npm 의존성 취약점 스캔
|
||||
|
||||
```bash
|
||||
npm audit --json
|
||||
```
|
||||
|
||||
결과를 파싱해 CVE 등급별로 분류:
|
||||
- **Critical / High**: 즉시 패치 필요
|
||||
- **Moderate**: 스프린트 내 패치
|
||||
- **Low**: 모니터링
|
||||
|
||||
각 취약 패키지에 대해 안전 버전 및 업그레이드 명령어를 제시한다.
|
||||
|
||||
### Phase 3: 보안 헤더 확인
|
||||
|
||||
`nuxt.config.ts`의 `routeRules` 또는 서버 설정에서 아래 헤더를 확인한다:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts 권장 보안 헤더
|
||||
routeRules: {
|
||||
'/**': {
|
||||
headers: {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 보안 검토 리포트
|
||||
|
||||
### 🔴 Critical (즉시 조치)
|
||||
- [파일:라인] 취약점 설명 + 수정 코드
|
||||
|
||||
### 🟡 High (스프린트 내 조치)
|
||||
- [파일:라인] 취약점 설명 + 개선 방향
|
||||
|
||||
### 🟢 Moderate / Low
|
||||
- [목록]
|
||||
|
||||
### npm 취약점
|
||||
- Critical: N건 — `npm audit fix --force` 권장 패키지: [목록]
|
||||
- High: N건 — 안전 버전: [버전 목록]
|
||||
|
||||
### 보안 헤더 현황
|
||||
- 적용됨: [목록]
|
||||
- 미적용: [목록 + 추가 코드]
|
||||
|
||||
### ✅ 정상 항목
|
||||
- 환경 변수 분리 적절, v-html 미사용 등
|
||||
```
|
||||
26
.claude/skills/squad-orchestration/SKILL.md
Normal file
26
.claude/skills/squad-orchestration/SKILL.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: squad-orchestration
|
||||
description: |
|
||||
하나의 대규모 컴포넌트나 피처 요건을 마크업, 접근성, 유닛 테스트의 3가지 전문
|
||||
에이전트 역할군 지시서로 분할하고 자동 오케스트레이션 파이프라인을 빌드합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- 사용자가 "스쿼드 오케스트레이션 가동해줘", "컴포넌트 병렬 개발 세팅해줘", "에이전트 스쿼드 만들어줘"라고 지시할 때
|
||||
- 복잡한 UI 구현과 동시에 높은 웹 접근성(A11y) 기준 및 100% 동작 보장의 Vitest 유닛 테스트 파일 생성이 일시에 필요할 때
|
||||
---
|
||||
|
||||
# AI 개발 스쿼드 오케스트레이션 (squad-orchestration)
|
||||
|
||||
피처 하나를 3인의 전문 에이전트 작업 지시서로 분할하고, 전체 흐름을 완벽히 기동할 통합 오케스트레이터 러너를 조직합니다.
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. 개발 대상 컴포넌트 이름과 요구 스펙을 입력받습니다.
|
||||
2. \`node .claude/skills/squad-orchestration/scripts/squad-orchestrator.js --name <Name> --spec "<Spec>"\` 명령을 구동합니다.
|
||||
3. 스크립트가 실행되면 프로젝트 루트의 \`squad/<ComponentName>/\` 폴더가 생성되고 아래 파일들이 구조화됩니다:
|
||||
- \`tasks/01_markup_agent.md\`: UI/마크업 디자인 스페셜리스트 작업 지시서
|
||||
- \`tasks/02_a11y_agent.md\`: WCAG 2.1 AA 및 키보드 사용 보증 웹 접근성 지시서
|
||||
- \`tasks/03_test_agent.md\`: Vitest 유닛 테스트 케이스 지시서
|
||||
- \`run-squad.js\`: 연속 오토파일럿 기동을 제어하는 마스터 노드 러너 스크립트
|
||||
- \`README.md\`: 각 에이전트 조율을 위한 매뉴얼 가이드
|
||||
4. 에이전트 스쿼드 배치 상황과 실행 방법을 개발자에게 친근하게 알려드립니다.
|
||||
275
.claude/skills/squad-orchestration/scripts/squad-orchestrator.js
Executable file
275
.claude/skills/squad-orchestration/scripts/squad-orchestrator.js
Executable file
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* squad-orchestrator.js - AI 개발 스쿼드(Squad) 오케스트레이터
|
||||
*
|
||||
* 하나의 컴포넌트/피처 요건을 입력받아 3가지 전문 에이전트 역할군으로 작업을 쪼개고,
|
||||
* 각 에이전트에 줄 지시서와 전체 프로세스를 시뮬레이션/오케스트레이션하는 마스터 러너 파일을 생성합니다.
|
||||
*
|
||||
* 에이전트 스쿼드 구성:
|
||||
* 1. UI/마크업 스페셜리스트 (Markup Specialist): 구조 설계, 렌더링, Tailwind CSS 스타일링 및 기본 상태 바인딩.
|
||||
* 2. 웹 접근성 스페셜리스트 (A11y/UX Specialist): WCAG 2.1 AA 기준 준수, ARIA 레이블링, 키보드 인터랙션 강화.
|
||||
* 3. QA/유닛 테스트 스페셜리스트 (Testing Specialist): Vitest 기반 유닛 테스트 스위트 작성 및 품질 보증.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
|
||||
const CWD = process.cwd();
|
||||
const SQUAD_ROOT = path.join(CWD, 'squad');
|
||||
|
||||
// 헬퍼: 디렉토리 자동 생성
|
||||
function ensureDirectory(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 헬퍼: 파일 작성
|
||||
function writeFile(filePath, content) {
|
||||
const dir = path.dirname(filePath);
|
||||
ensureDirectory(dir);
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
// CLI 인자 파싱
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const result = { name: '', spec: '' };
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--name' || args[i] === '-n') {
|
||||
result.name = args[i + 1] || '';
|
||||
i++;
|
||||
} else if (args[i] === '--spec' || args[i] === '-s') {
|
||||
result.spec = args[i + 1] || '';
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 지시서 생성 로직
|
||||
function generateSquadTasks(componentName, specification) {
|
||||
const lowercaseName = componentName.toLowerCase();
|
||||
const componentPath = `components/${componentName}.vue`;
|
||||
const testPath = `components/${componentName}.spec.ts`;
|
||||
const taskDir = path.join(SQUAD_ROOT, componentName);
|
||||
|
||||
// 1. Markup Specialist 지시서
|
||||
const markupTask = `# 🎨 Role 1: UI/마크업 스페셜리스트 지시서
|
||||
|
||||
## 목표
|
||||
- 요구사항에 맞는 컴포넌트 구조(Vue 3 / SFC)를 생성하고 Tailwind CSS를 이용해 아름답고 완벽한 반응형 UI를 스타일링합니다.
|
||||
- 복잡하지 않은 수준에서 데이터 수신을 위한 Props 정의 및 이벤트를 내보내기 위한 Emits를 설계합니다.
|
||||
|
||||
## 개발 대상 파일
|
||||
- \`${componentPath}\`
|
||||
|
||||
## 기술 요건 (framework-rules.md 준수)
|
||||
- \`<script setup lang="ts">\` 형식을 완벽히 준수해야 합니다.
|
||||
- Tailwind CSS의 유틸리티 클래스 위주로 스타일링하며, 조건부 렌더링 시 가독성 높은 클래스 바인딩을 적용하세요.
|
||||
- 비즈니스 상태가 필요한 경우, 템플릿 코드에 가짜(mock) 반응형 데이터(\`ref\`, \`computed\`)를 연결하여 상태를 구성합니다.
|
||||
|
||||
## 개발 요구사항
|
||||
${specification}
|
||||
|
||||
---
|
||||
|
||||
## 작업 지시 사항
|
||||
1. \`${componentPath}\` 파일을 신규 생성합니다.
|
||||
2. 컴포넌트가 정상적으로 렌더링되고 기본적인 반응형 동작(클릭 이벤트, 폼 입력 바인딩 등)이 원활하게 흐르는지 확인하는 모드에서 코드를 작성하세요.
|
||||
3. 작업 완료 후, 생성된 코드를 출력하여 다음 단계(A11y 검증)로 전달할 수 있게 준비하세요.
|
||||
`;
|
||||
|
||||
// 2. A11y Specialist 지시서
|
||||
const a11yTask = `# ♿ Role 2: 웹 접근성(A11y) 스페셜리스트 지시서
|
||||
|
||||
## 목표
|
||||
- UI 마크업 스페셜리스트가 구현한 \`${componentPath}\` 파일을 검토 및 개선합니다.
|
||||
- WCAG 2.1 AA 및 접근성 검증 지침(\`verify-a11y\`)에 맞춰 전 세계 모든 사용자(스크린 리더 사용자, 키보드 단독 사용자 등)가 사용에 제약이 없도록 인터랙션을 강화합니다.
|
||||
|
||||
## 대상 파일
|
||||
- \`${componentPath}\` (기존 마크업 위에서 수정)
|
||||
|
||||
## 접근성 필수 강화 체크리스트
|
||||
- **시각 대체 수단:** 모든 아이콘 단독 버튼이나 이미지에 적절한 대체 텍스트(\`aria-label\` 또는 \`alt\`)를 적용합니다.
|
||||
- **키보드 내비게이션:** 모든 상호작용 요소에 키보드 포커스가 잡히고(\`tabindex\`), 스페이스 및 엔터 키 입력 시 적절한 동작이 수행되어야 합니다. 모달이나 레이어 팝업이 뜰 경우 탭 인덱스 포커스 트랩(Focus Trap)이 동작해야 합니다.
|
||||
- **의미론적 마크업:** 적절한 HTML5 시맨틱 태그 및 WAI-ARIA 속성(\`role\`, \`aria-expanded\`, \`aria-haspopup\`, \`aria-controls\` 등)을 사용합니다.
|
||||
- **포커스 링 가시성:** 모든 인터랙티브 요소는 포커스를 받았을 때 아웃라인(\`:focus-visible\`)이 명확하게 나타나야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 작업 지시 사항
|
||||
1. UI 마크업 스페셜리스트가 작성한 \`${componentPath}\` 파일을 상세 분석합니다.
|
||||
2. 접근성 상의 미비점(예: 키보드 미지원 드롭다운, 라벨링이 누락된 버튼 등)을 발견하면 원본 코드의 핵심 구조를 깨지 않는 선에서 **Surgical Changes(최소한의 정밀 수정)**로 접근성 강화 코드를 삽입하세요.
|
||||
3. 주석을 통해 어떤 부분이 접근성 보장을 위해 개선되었는지 기록하세요.
|
||||
`;
|
||||
|
||||
// 3. QA/Testing Specialist 지시서
|
||||
const testingTask = `# 🧪 Role 3: QA/유닛 테스트 스페셜리스트 지시서
|
||||
|
||||
## 목표
|
||||
- 완성된 \`${componentPath}\`에 대해 완벽한 유닛 테스트 케이스를 생성합니다.
|
||||
- 컴포넌트의 비즈니스 반응형 흐름, 엣지 케이스, 이벤트 방출, 컴포넌트 마운트 상태, 그리고 접근성 레이블들의 존재성까지 종합 검증합니다.
|
||||
|
||||
## 대상 파일
|
||||
- \`${testPath}\` (신규 생성)
|
||||
|
||||
## 테스트 시나리오 설계 요건
|
||||
- **마운트 상태 검증:** 기본 props를 전달했을 때 컴포넌트가 깨짐 없이 정상 마운트되고 지정된 요소들이 화면에 나오는지 확인.
|
||||
- **인터랙션/상태 변경 테스트:** 사용자가 클릭, 입력 등을 수행할 때 로컬 상태가 정확히 변하고, 부모 컴포넌트로 적절한 \`emit\`이 전달되는지 검증.
|
||||
- **경계 조건(Edge Cases):** Props 값이 비어있거나, 최대 길이 초과 등 예외 상황에서 에러 메시지가 렌더링되거나 방어 동작이 작동하는지 검증.
|
||||
- **접근성(A11y) 속성 검증:** 주요 클릭 타깃의 \`aria-label\` 유효성, 상태 전이에 따른 \`aria-expanded\`의 동적 변경 값 검증.
|
||||
|
||||
---
|
||||
|
||||
## 작업 지시 사항
|
||||
1. Vitest 및 \`@vue/test-utils\` 조합으로 \`${testPath}\` 파일을 작성합니다.
|
||||
2. 테스트를 실행하여 모든 케이스가 초록색(PASS)을 반환하는지 수동 또는 자동으로 검증하세요.
|
||||
3. 테스트 코드는 가독성이 뛰어나야 하며, 복잡한 상태 관리가 사용된다면 Mocking을 적극적으로 활용하세요.
|
||||
`;
|
||||
|
||||
// 4. Master Run Orchestrator Script (run-squad.js)
|
||||
const masterRunScript = `const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const COMPONENT_NAME = "${componentName}";
|
||||
const COMPONENT_PATH = "${componentPath}";
|
||||
const TEST_PATH = "${testPath}";
|
||||
|
||||
console.log(\`🚀 [Squad Run] \${COMPONENT_NAME} 스쿼드 개발 자동화 파이프라인 가동!\\n\`);
|
||||
|
||||
function runCommand(command) {
|
||||
try {
|
||||
console.log(\`👉 실행 중: \${command}\`);
|
||||
execSync(command, { stdio: 'inherit' });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(\`❌ 에러 발생: \${command}\`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Markup Specialist 실행
|
||||
console.log('----------------------------------------------------');
|
||||
console.log('🎨 Phase 1: 마크업 & UI 구현 (Role 1)');
|
||||
console.log('----------------------------------------------------');
|
||||
const markupPrompt = \`squad/\${COMPONENT_NAME}/tasks/01_markup_agent.md 지시서에 명시된 목표와 프레임워크 규칙에 맞추어 \${COMPONENT_PATH} 파일을 먼저 구현해 주세요. 작업을 마친 후에는 코드만 마크다운 파일 등에 별도로 복사해 두는 것이 아니라, 실제 파일로 저장한 다음 상태를 알려주세요.\`;
|
||||
runCommand(\`claude -p "\${markupPrompt}"\`);
|
||||
|
||||
// Phase 2: A11y Specialist 실행
|
||||
console.log('\\n----------------------------------------------------');
|
||||
console.log('♿ Phase 2: WCAG 2.1 AA 웹 접근성 개선 (Role 2)');
|
||||
console.log('----------------------------------------------------');
|
||||
const a11yPrompt = \`squad/\${COMPONENT_NAME}/tasks/02_a11y_agent.md 지시서를 기반으로, 앞서 생성된 \${COMPONENT_PATH} 파일의 코드를 검토하여 접근성을 강화해 주세요. Surgical Changes 원칙에 따라 핵심 마크업은 보존하고 ARIA 속성, 대체 텍스트, 키보드 핸들링만 지능적으로 주입 및 수정한 뒤 저장해 주세요.\`;
|
||||
runCommand(\`claude -p "\${a11yPrompt}"\`);
|
||||
|
||||
// Phase 3: Testing Specialist 실행
|
||||
console.log('\\n----------------------------------------------------');
|
||||
console.log('🧪 Phase 3: QA 및 유닛 테스트 케이스 구축 (Role 3)');
|
||||
console.log('----------------------------------------------------');
|
||||
const testingPrompt = \`squad/\${COMPONENT_NAME}/tasks/03_test_agent.md 지시서에 맞춰 \${TEST_PATH} 유닛 테스트 파일을 생성하고 Vitest를 기반으로 작성해 주세요.\`;
|
||||
runCommand(\`claude -p "\${testingPrompt}"\`);
|
||||
|
||||
// Phase 4: 최종 테스트 검증 및 코드 정리
|
||||
console.log('\\n----------------------------------------------------');
|
||||
console.log('🏁 Phase 4: 전체 스쿼드 통합 테스트 및 코드 정리');
|
||||
console.log('----------------------------------------------------');
|
||||
if (fs.existsSync(TEST_PATH)) {
|
||||
console.log('🔬 Vitest 테스트 실행으로 품질 최종 점검...');
|
||||
runCommand('npx vitest run ' + TEST_PATH);
|
||||
} else {
|
||||
console.log('⚠️ 테스트 파일이 생성되지 않았습니다.');
|
||||
}
|
||||
|
||||
console.log('\\n✨ 모든 에이전트의 스쿼드 협업을 통해 초고품질 컴포넌트가 빌드되었습니다!');
|
||||
console.log(\`📍 컴포넌트 위치: \${COMPONENT_PATH}\`);
|
||||
console.log(\`📍 테스트 파일: \${TEST_PATH}\`);
|
||||
`;
|
||||
|
||||
// 5. Squad Readme
|
||||
const squadReadme = `# 🚀 AI 개발 스쿼드 (Squad) 오케스트레이션 가이드: ${componentName}
|
||||
|
||||
이 폴더는 고품질의 프론트엔드 컴포넌트 개발을 위해 **3가지 전문화된 AI 역할군**을 정의하고 작업을 위임하는 오케스트레이션 패키지입니다.
|
||||
|
||||
## 📁 파일 구조
|
||||
- \`tasks/01_markup_agent.md\`: UI 레이아웃, 마크업 및 기본 구조 설계 지시서
|
||||
- \`tasks/02_a11y_agent.md\`: WCAG 2.1 AA 및 키보드 접근성 개선 지시서
|
||||
- \`tasks/03_test_agent.md\`: Vitest 기반 유닛 테스트 코드 작성 지시서
|
||||
- \`run-squad.js\`: 로컬의 Claude Code를 활용해 세 단계를 연속/자동으로 가동시키는 마스터 오케스트레이터 스크립트
|
||||
|
||||
## 🛠 실행 방법
|
||||
|
||||
### 방법 A: 전체 자동 가동 (Claude Code CLI 이용)
|
||||
프로젝트 루트에서 다음 노드 명령어를 실행하면 세 가지 에이전트 작업이 순차적으로 트리거되고 최종 유닛 테스트 검증까지 완벽히 오토파일럿으로 수행됩니다:
|
||||
|
||||
\`\`\`bash
|
||||
node squad/${componentName}/run-squad.js
|
||||
\`\`\`
|
||||
|
||||
### 방법 B: 수동 협업 가동 (각 전문 에이전트 수동 프롬프트)
|
||||
만약 Claude Code 세션을 단계별로 수동 제어하고 싶다면, 아래 순서대로 각 지시서 파일을 Claude에게 먹여서 진행할 수도 있습니다:
|
||||
|
||||
1. **Step 1:** \`squad/${componentName}/tasks/01_markup_agent.md\` 지시 내용을 Claude에게 전달 후 UI 마크업 파일 생성.
|
||||
2. **Step 2:** \`squad/${componentName}/tasks/02_a11y_agent.md\` 지시 내용을 전달하여 기존 UI 마크업 파일에 접근성 코드 주입.
|
||||
3. **Step 3:** \`squad/${componentName}/tasks/03_test_agent.md\` 지시 내용을 전달하여 Vitest 테스트 세트 확보.
|
||||
4. **Step 4:** 터미널에서 \`npx vitest run components/${componentName}.spec.ts\` 실행 및 검증 완료!
|
||||
`;
|
||||
|
||||
// 파일들 쓰기
|
||||
writeFile(path.join(taskDir, 'tasks/01_markup_agent.md'), markupTask);
|
||||
writeFile(path.join(taskDir, 'tasks/02_a11y_agent.md'), a11yTask);
|
||||
writeFile(path.join(taskDir, 'tasks/03_test_agent.md'), testingTask);
|
||||
writeFile(path.join(taskDir, 'run-squad.js'), masterRunScript);
|
||||
writeFile(path.join(taskDir, 'README.md'), squadReadme);
|
||||
|
||||
return taskDir;
|
||||
}
|
||||
|
||||
// 메인 비화 및 입력 대기 제어
|
||||
function run() {
|
||||
const args = parseArgs();
|
||||
|
||||
if (args.name && args.spec) {
|
||||
console.log(`🤖 전달받은 CLI 아규먼트로 스쿼드를 즉시 세팅합니다...`);
|
||||
const path = generateSquadTasks(args.name, args.spec);
|
||||
console.log(`\n🎉 에이전트 스쿼드가 성공적으로 정렬되었습니다!`);
|
||||
console.log(`📍 위치: ${path}`);
|
||||
console.log(`💬 'node ${path}/run-squad.js' 명령으로 전체 프로세스를 가동하거나 가이드 문서를 읽어보세요.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 대화형 질문으로 수행
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
console.log('👥 [Squad Orchestrator] 새로운 고품질 피처 개발을 위해 스쿼드를 조직합니다.');
|
||||
|
||||
rl.question('💬 개발할 컴포넌트/피처 이름을 작성해 주세요 (예: GameScoreBoard): ', (name) => {
|
||||
if (!name.trim()) {
|
||||
console.log('❌ 컴포넌트 이름은 필수입니다. 중단되었습니다.');
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
rl.question('💬 구현할 세부 요건/기능 스펙을 적어주세요:\n> ', (spec) => {
|
||||
if (!spec.trim()) {
|
||||
spec = '기본적인 반응형 디자인과 상태 데이터 흐름을 갖춘 고품질 컴포넌트 설계';
|
||||
}
|
||||
|
||||
const path = generateSquadTasks(name.trim(), spec.trim());
|
||||
console.log(`\n🎉 에이전트 스쿼드가 성공적으로 정렬되었습니다!`);
|
||||
console.log(`📍 위치: ${path}`);
|
||||
console.log(`💬 'node ${path}/run-squad.js' 명령으로 전체 프로세스를 가동하거나 가이드 문서를 읽어보세요.`);
|
||||
|
||||
rl.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
run();
|
||||
130
.claude/skills/verify-a11y/SKILL.md
Normal file
130
.claude/skills/verify-a11y/SKILL.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: verify-a11y
|
||||
description: |
|
||||
Vue 3 / HTML 코드를 WCAG 2.1 AA 기준으로 자동 감사하고
|
||||
ARIA 레이블 누락, 키보드 포커스 순서, 색상 대비 비율 등의 문제와
|
||||
코드 레벨 개선 방안을 제시합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "접근성 검증해줘", "WCAG 체크해줘", "a11y 확인해줘"
|
||||
- ARIA 속성이 빠진 것 같을 때
|
||||
- QA 전 접근성 관련 반려를 사전에 방지하고 싶을 때
|
||||
---
|
||||
|
||||
# 접근성 검증 (verify-a11y)
|
||||
|
||||
Vue 3 / HTML → WCAG 2.1 AA 자동 감사 → 코드 레벨 개선 방안 리포트.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 마크업 완료 후 접근성 기준 충족 여부를 확인할 때
|
||||
- 스크린 리더 사용자를 위한 ARIA 적용이 필요할 때
|
||||
- 키보드 전용 사용자 지원이 필요할 때
|
||||
|
||||
## 검사 기준: WCAG 2.1 AA
|
||||
|
||||
| 원칙 | 핵심 항목 |
|
||||
|---|---|
|
||||
| **인식 가능** | 이미지 대체 텍스트, 색상 대비 비율, 캡션 |
|
||||
| **운용 가능** | 키보드 접근성, 포커스 순서, 충분한 시간 |
|
||||
| **이해 가능** | 레이블, 오류 식별, 일관된 네비게이션 |
|
||||
| **견고성** | 유효한 HTML, ARIA 역할 |
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 코드 수집
|
||||
|
||||
1. 검증 대상 파일을 읽는다. (`.vue`, `.html`, 템플릿 코드)
|
||||
2. 검증 범위가 넓으면 화면 단위로 분리해 진행한다.
|
||||
|
||||
### Phase 2: 체크리스트 점검
|
||||
|
||||
#### 이미지 & 미디어
|
||||
- [ ] 의미 있는 `<img>`: `alt` 텍스트 존재
|
||||
- [ ] 장식용 `<img>`: `alt=""`
|
||||
- [ ] `<video>`: 자막(captions) 또는 스크립트 제공
|
||||
|
||||
#### 색상 대비
|
||||
- [ ] 일반 텍스트: 대비 비율 4.5:1 이상
|
||||
- [ ] 대형 텍스트 (18px+ 또는 14px+ bold): 3:1 이상
|
||||
- [ ] 아이콘/그래픽 UI: 3:1 이상
|
||||
|
||||
#### 키보드 접근성
|
||||
- [ ] 모든 인터랙션 요소가 키보드로 접근 가능
|
||||
- [ ] 포커스 순서가 시각적 레이아웃과 일치
|
||||
- [ ] 포커스 링(outline) 가시성 확보 (`:focus-visible`)
|
||||
- [ ] 모달/드롭다운 열릴 때 포커스 이동, 닫힐 때 원래 요소로 복귀
|
||||
|
||||
#### ARIA 적용
|
||||
- [ ] `<button>` 아이콘 전용: `aria-label` 또는 `aria-labelledby`
|
||||
- [ ] 모달: `role="dialog"`, `aria-modal="true"`, `aria-labelledby`
|
||||
- [ ] 폼 에러: `aria-describedby` → 에러 메시지 연결
|
||||
- [ ] 로딩 영역: `aria-live="polite"` 또는 `aria-busy="true"`
|
||||
- [ ] 탭 UI: `role="tablist"`, `role="tab"`, `role="tabpanel"`, `aria-selected`
|
||||
- [ ] 아코디언: `aria-expanded`, `aria-controls`
|
||||
- [ ] 드롭다운 메뉴: `aria-haspopup`, `aria-expanded`
|
||||
|
||||
#### 폼
|
||||
- [ ] 모든 입력 필드에 연결된 `<label>` 존재
|
||||
- [ ] 필수 필드: `aria-required="true"` 또는 `required`
|
||||
- [ ] 에러 메시지: `role="alert"` 또는 `aria-live="assertive"`
|
||||
|
||||
#### 제목 계층
|
||||
- [ ] `h1` → `h2` → `h3` 순서 준수 (건너뛰기 없음)
|
||||
- [ ] 페이지에 `h1` 1개만 존재
|
||||
|
||||
#### 랜드마크
|
||||
- [ ] `<header>`, `<nav>`, `<main>`, `<footer>` 적절히 사용
|
||||
- [ ] 여러 `<nav>`가 있으면 `aria-label`로 구분
|
||||
|
||||
### Phase 3: 개선 방안 코드 제시
|
||||
|
||||
```vue
|
||||
<!-- Before: 아이콘 버튼 접근성 없음 -->
|
||||
<button @click="close">
|
||||
<IconClose />
|
||||
</button>
|
||||
|
||||
<!-- After: aria-label 추가 -->
|
||||
<button @click="close" aria-label="닫기">
|
||||
<IconClose aria-hidden="true" />
|
||||
</button>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Before: 폼 에러 접근성 없음 -->
|
||||
<input type="email" />
|
||||
<p class="text-red-500">올바른 이메일을 입력하세요</p>
|
||||
|
||||
<!-- After -->
|
||||
<input
|
||||
type="email"
|
||||
:aria-describedby="hasError ? 'email-error' : undefined"
|
||||
:aria-invalid="hasError"
|
||||
/>
|
||||
<p v-if="hasError" id="email-error" role="alert">
|
||||
올바른 이메일을 입력하세요
|
||||
</p>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 접근성 검증 리포트: <파일명>
|
||||
|
||||
### 🚨 Critical — WCAG 위반 (N건)
|
||||
- [라인 NN] 문제 설명 + 수정 코드
|
||||
|
||||
### ⚠️ Warning — 개선 권장 (N건)
|
||||
- [라인 NN] 문제 설명 + 수정 방향
|
||||
|
||||
### 💡 Info — 선택 개선 (N건)
|
||||
- [라인 NN] 개선 사항
|
||||
|
||||
### ✅ 적절히 처리된 항목
|
||||
- ARIA 레이블 적용됨, 색상 대비 충족 등
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: vue-component-review
|
||||
name: verify-component-review
|
||||
description: Vue 3 / Nuxt 컴포넌트 파일을 팀 공통 지침(gameservice-fe-agent) 기준으로 리뷰할 때 사용합니다. 사용자가 "이 컴포넌트 리뷰해줘", "컨벤션 맞는지 봐줘", "컴포넌트 체크" 등을 요청하면 트리거됩니다.
|
||||
---
|
||||
|
||||
141
.claude/skills/verify-perf/SKILL.md
Normal file
141
.claude/skills/verify-perf/SKILL.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: verify-perf
|
||||
description: |
|
||||
Nuxt 프로젝트의 성능 병목을 분석하고 Core Web Vitals 기준으로
|
||||
코드 스플리팅, 이미지 최적화, SSR/ISR 전략 등 구체적인 개선 방안을 제시합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "성능 최적화해줘", "Lighthouse 점수 올려줘"
|
||||
- "LCP가 느린데 어떻게 해?", "번들 사이즈 줄여줘"
|
||||
- Core Web Vitals 기준 미달 시 개선 방향이 필요할 때
|
||||
---
|
||||
|
||||
# 성능 최적화 분석 (verify-perf)
|
||||
|
||||
Nuxt 프로젝트 → 성능 병목 자동 분석 → Core Web Vitals 개선 가이드 생성.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 배포 전 성능 기준(LCP < 2.5s / CLS < 0.1 / INP < 200ms) 충족 여부 확인 시
|
||||
- Lighthouse 점수 80+ 달성을 위한 구체적 액션이 필요할 때
|
||||
- 번들 사이즈 증가, 이미지 로딩 지연 등 특정 성능 문제가 있을 때
|
||||
|
||||
## Core Web Vitals 목표
|
||||
|
||||
| 지표 | 목표 | 기준 |
|
||||
|---|---|---|
|
||||
| LCP (Largest Contentful Paint) | < 2.5s | 가장 큰 콘텐츠 렌더링 |
|
||||
| CLS (Cumulative Layout Shift) | < 0.1 | 레이아웃 안정성 |
|
||||
| INP (Interaction to Next Paint) | < 200ms | 입력 반응성 |
|
||||
| FCP (First Contentful Paint) | < 1.8s | 첫 콘텐츠 노출 |
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 코드 분석
|
||||
|
||||
1. 아래 파일을 읽어 성능 관련 설정을 파악한다.
|
||||
- `nuxt.config.ts`: 이미지 모듈, SSR 설정, 번들러 옵션
|
||||
- `pages/` 및 `components/`: 이미지 태그, 폰트 로딩, 라이브러리 import
|
||||
- `package.json`: 번들 크기 영향이 큰 패키지 확인
|
||||
|
||||
2. 일반적인 병목 패턴을 체크한다.
|
||||
|
||||
**번들 크기**
|
||||
- `import * from '...'` (tree-shaking 비활성화)
|
||||
- 전체 import 라이브러리 (lodash, moment 등)
|
||||
- 대형 아이콘 라이브러리 전체 import
|
||||
|
||||
**이미지**
|
||||
- `<img>` 직접 사용 (Nuxt `<NuxtImg>` 미사용)
|
||||
- `width`, `height` 미지정 (CLS 발생)
|
||||
- 레이지 로딩 미적용
|
||||
|
||||
**렌더링 전략**
|
||||
- 정적 콘텐츠에 `useFetch` 남용
|
||||
- 불필요한 CSR 컴포넌트
|
||||
|
||||
### Phase 2: 개선 방안 생성
|
||||
|
||||
#### 이미지 최적화
|
||||
```vue
|
||||
<!-- Before -->
|
||||
<img src="/hero.jpg" alt="히어로" />
|
||||
|
||||
<!-- After: Nuxt Image 모듈 활용 -->
|
||||
<NuxtImg
|
||||
src="/hero.jpg"
|
||||
alt="히어로"
|
||||
width="1200"
|
||||
height="600"
|
||||
loading="lazy"
|
||||
format="webp"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 번들 최적화
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
vite: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['vue', 'pinia'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### SSR / SSG / ISR 전략
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
routeRules: {
|
||||
'/': { prerender: true }, // 정적 홈
|
||||
'/products/**': { swr: 3600 }, // ISR: 1시간 캐시
|
||||
'/dashboard/**': { ssr: false }, // CSR: 인증 필요 페이지
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 컴포넌트 지연 로딩
|
||||
```vue
|
||||
<!-- 무거운 컴포넌트 lazy 로딩 -->
|
||||
<LazyHeavyChart v-if="isVisible" />
|
||||
```
|
||||
|
||||
### Phase 3: 우선순위 정렬
|
||||
|
||||
발견된 개선사항을 impact/effort 매트릭스로 정렬한다:
|
||||
- 🔴 High impact, Low effort → 즉시 적용
|
||||
- 🟡 High impact, High effort → 스프린트 계획
|
||||
- 🟢 Low impact, Low effort → 여유 있을 때
|
||||
- ⚪ Low impact, High effort → 보류
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 성능 분석 리포트
|
||||
|
||||
### 현황 추정
|
||||
- 예상 LCP: ~ (분석된 병목 기준)
|
||||
- 주요 이슈: [번들 사이즈 | 이미지 | 렌더링 전략]
|
||||
|
||||
### 🔴 즉시 적용 (High impact, Low effort)
|
||||
1. [문제] → [해결책 + 코드]
|
||||
|
||||
### 🟡 스프린트 계획 (High impact, High effort)
|
||||
1. [문제] → [해결 방향]
|
||||
|
||||
### 예상 개선 효과
|
||||
- LCP: Xs → Xs
|
||||
- 번들: XkB → XkB (XX% 감소)
|
||||
```
|
||||
90
.claude/skills/verify-requirement/SKILL.md
Normal file
90
.claude/skills/verify-requirement/SKILL.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: verify-requirement
|
||||
description: |
|
||||
plan-analyzer가 생성한 요구사항 명세와 실제 구현 코드를 자동 비교하여
|
||||
누락 기능, 스펙 불일치, 미구현 항목을 사전에 감지합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "요구사항 대비 누락 기능 체크해줘", "기획서랑 구현 맞는지 확인해줘"
|
||||
- QA 전 스펙 불일치를 미리 잡고 싶을 때
|
||||
- 개발 완료 후 기획 의도와 일치하는지 검증할 때
|
||||
---
|
||||
|
||||
# 요구사항 검증 (verify-requirement)
|
||||
|
||||
plan-analyzer 명세 ↔ 실제 구현 코드 자동 비교 → 누락/불일치 리포트 생성.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 개발 완료 후 QA 이전에 스펙 누락 여부를 확인할 때
|
||||
- 기획자와 개발자 간 스펙 해석 차이를 사전에 해소할 때
|
||||
- 릴리스 전 체크리스트를 자동화하고 싶을 때
|
||||
|
||||
## 입력
|
||||
|
||||
- `plan-analyzer` 산출물 (요구사항 명세 MD 파일)
|
||||
- 검증 대상 디렉토리 또는 파일 목록
|
||||
- (선택) 검증 범위: 화면 목록, API 엔드포인트, 컴포넌트 트리
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 요구사항 명세 파싱
|
||||
|
||||
1. plan-analyzer 산출물에서 아래 항목을 추출한다.
|
||||
- 화면(페이지) 목록과 라우팅 경로
|
||||
- 각 화면의 핵심 기능 목록
|
||||
- API 엔드포인트 목록
|
||||
- 컴포넌트 트리
|
||||
|
||||
2. 명세가 없는 경우 사용자에게 파일 경로를 요청한다.
|
||||
|
||||
### Phase 2: 구현 현황 파악
|
||||
|
||||
1. `pages/` 디렉토리를 스캔해 실제 라우팅 구현 현황을 파악한다.
|
||||
2. `components/` 디렉토리에서 구현된 컴포넌트 목록을 수집한다.
|
||||
3. `server/api/` 에서 구현된 엔드포인트를 파악한다.
|
||||
4. 각 화면 파일에서 아래를 확인한다.
|
||||
- 기획서에 명시된 기능 구현 여부
|
||||
- 폼/버튼/모달 등 인터랙션 요소 구현 여부
|
||||
- 에러/로딩/빈 상태 처리 여부
|
||||
|
||||
### Phase 3: 비교 및 분류
|
||||
|
||||
발견된 차이를 3단계로 분류한다:
|
||||
|
||||
| 등급 | 기준 | 예시 |
|
||||
|---|---|---|
|
||||
| 🚨 Critical | 핵심 기능 미구현 | 결제 버튼 없음, 필수 API 미연동 |
|
||||
| ⚠️ Warning | 일부 기능 누락 또는 스펙 불일치 | 에러 상태 처리 없음, 페이지네이션 미구현 |
|
||||
| 💡 Info | 선택 기능 누락 또는 UX 개선 사항 | 로딩 스피너 없음, 빈 상태 메시지 없음 |
|
||||
|
||||
### Phase 4: 개선 방안 제시
|
||||
|
||||
각 Critical / Warning 항목에 대해 구체적인 구현 방향을 제안한다.
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 요구사항 검증 리포트
|
||||
|
||||
### 검증 범위
|
||||
- 화면: N개 / 구현: N개 (일치율 NN%)
|
||||
- API: N개 / 구현: N개 (일치율 NN%)
|
||||
- 컴포넌트: N개 / 구현: N개 (일치율 NN%)
|
||||
|
||||
### 🚨 Critical (N건)
|
||||
- [화면명] 기능 설명 — 구체적 해결 방안
|
||||
|
||||
### ⚠️ Warning (N건)
|
||||
- [화면명] 기능 설명 — 구체적 해결 방안
|
||||
|
||||
### 💡 Info (N건)
|
||||
- [화면명] 개선 사항
|
||||
|
||||
### ✅ 일치 항목
|
||||
- [목록]
|
||||
```
|
||||
143
.claude/skills/verify-seo-geo/SKILL.md
Normal file
143
.claude/skills/verify-seo-geo/SKILL.md
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
name: verify-seo-geo
|
||||
description: |
|
||||
Nuxt 프로젝트를 SEO · AEO · GEO 3계층으로 자동 감사하고
|
||||
useSeoMeta, useSchemaOrg 기반 메타/구조화 데이터 코드를 자동 생성합니다.
|
||||
AI 검색(ChatGPT, Perplexity, Google AI Overview) 대응까지 포함합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "SEO 검증해줘", "메타 태그 확인해줘", "AEO/GEO 대응해줘"
|
||||
- Schema.org 구조화 데이터가 필요할 때
|
||||
- AI 검색 노출을 높이고 싶을 때
|
||||
---
|
||||
|
||||
# SEO · GEO · AEO 검증 (verify-seo-geo)
|
||||
|
||||
Nuxt 프로젝트 → SEO / AEO / GEO 3계층 자동 감사 → 메타 + Schema 코드 생성.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 배포 전 SEO 메타 누락 여부를 확인할 때
|
||||
- AI 검색 엔진(ChatGPT, Perplexity 등)에 콘텐츠 인용률을 높이고 싶을 때
|
||||
- Schema.org 구조화 데이터를 처음 적용할 때
|
||||
|
||||
## 3계층 정의
|
||||
|
||||
| 계층 | 목표 | 핵심 기술 |
|
||||
|---|---|---|
|
||||
| **SEO** | 전통 검색 엔진 노출 | 메타 태그, Open Graph, sitemap |
|
||||
| **AEO** (Answer Engine Optimization) | 검색 결과 직접 답변(Featured Snippet) | FAQ Schema, HowTo Schema |
|
||||
| **GEO** (Generative Engine Optimization) | AI 검색 인용 | 구조화 데이터, 명확한 문서 구조 |
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 현황 감사
|
||||
|
||||
1. 대상 페이지 파일을 읽어 아래 항목을 체크한다.
|
||||
|
||||
#### SEO 기본 체크리스트
|
||||
- [ ] `useSeoMeta()` 사용 여부
|
||||
- [ ] `title`, `description` 설정 여부 (title 50~60자, description 150~160자)
|
||||
- [ ] `ogTitle`, `ogDescription`, `ogImage` 설정 여부
|
||||
- [ ] `canonical` URL 설정 여부
|
||||
- [ ] `<NuxtImg>` 사용 + `alt` 텍스트 여부
|
||||
- [ ] H1 태그 존재 및 키워드 포함 여부
|
||||
- [ ] 페이지 로딩 속도 (verify-perf 연계)
|
||||
|
||||
#### AEO / GEO 체크리스트
|
||||
- [ ] FAQ, HowTo, Article 등 Schema.org 적용 여부
|
||||
- [ ] 구조화 데이터 JSON-LD 유효성
|
||||
- [ ] 콘텐츠 답변 가능성 (질문 → 명확한 답변 구조)
|
||||
|
||||
### Phase 2: SEO 코드 생성
|
||||
|
||||
```ts
|
||||
// pages/product/[id].vue
|
||||
useSeoMeta({
|
||||
title: `${product.name} | 브랜드명`,
|
||||
description: product.description.slice(0, 155),
|
||||
ogTitle: `${product.name} | 브랜드명`,
|
||||
ogDescription: product.description.slice(0, 155),
|
||||
ogImage: product.imageUrl,
|
||||
ogType: 'product',
|
||||
twitterCard: 'summary_large_image',
|
||||
})
|
||||
|
||||
useHead({
|
||||
link: [{ rel: 'canonical', href: `https://example.com/product/${product.id}` }],
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 3: Schema.org 구조화 데이터
|
||||
|
||||
#### FAQ Schema (AEO)
|
||||
```ts
|
||||
useSchemaOrg([
|
||||
defineQuestion({
|
||||
name: '자주 묻는 질문 1',
|
||||
acceptedAnswer: { text: '답변 내용' },
|
||||
}),
|
||||
])
|
||||
```
|
||||
|
||||
#### HowTo Schema
|
||||
```ts
|
||||
useSchemaOrg([
|
||||
defineHowTo({
|
||||
name: '사용 방법',
|
||||
step: [
|
||||
{ name: '1단계', text: '설명' },
|
||||
{ name: '2단계', text: '설명' },
|
||||
],
|
||||
}),
|
||||
])
|
||||
```
|
||||
|
||||
#### Article Schema (GEO)
|
||||
```ts
|
||||
useSchemaOrg([
|
||||
defineArticle({
|
||||
headline: article.title,
|
||||
description: article.summary,
|
||||
datePublished: article.publishedAt,
|
||||
dateModified: article.updatedAt,
|
||||
author: [{ name: article.author }],
|
||||
}),
|
||||
])
|
||||
```
|
||||
|
||||
### Phase 4: GEO 콘텐츠 구조 개선 제안
|
||||
|
||||
AI 검색 인용을 높이기 위한 콘텐츠 구조 권고:
|
||||
- 명확한 소제목(H2/H3)으로 콘텐츠 분절
|
||||
- 핵심 개념은 첫 단락에 요약
|
||||
- 테이블/리스트로 스캔 가능한 정보 구조화
|
||||
- 정의·비교·절차 형식의 콘텐츠 우선 작성
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## SEO/GEO/AEO 검증 리포트: <페이지명>
|
||||
|
||||
### SEO 현황
|
||||
- title: 있음 / 없음 / 글자수 초과
|
||||
- description: 있음 / 없음
|
||||
- OG 태그: NN/6 항목 적용
|
||||
- Schema.org: 없음 / FAQ / Article / ...
|
||||
|
||||
### 🚨 Critical
|
||||
- [누락된 필수 메타 항목]
|
||||
|
||||
### ⚠️ Warning
|
||||
- [개선 권장 항목]
|
||||
|
||||
### 생성된 코드
|
||||
[useSeoMeta + useSchemaOrg 코드]
|
||||
|
||||
### GEO 개선 제안
|
||||
[콘텐츠 구조 개선 방향]
|
||||
```
|
||||
109
.claude/skills/work-code-reviewer/SKILL.md
Normal file
109
.claude/skills/work-code-reviewer/SKILL.md
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: work-code-reviewer
|
||||
description: |
|
||||
현재 브랜치 변경 코드 또는 특정 파일을 받아 팀 공통 지침 기준으로 종합 리뷰를 수행합니다.
|
||||
컨벤션, 로직, 보안, 성능, 접근성을 통합 검토하고 우선순위별 리포트를 생성합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "변경된 코드 리뷰해줘", "이 파일 코드 리뷰해줘"
|
||||
- PR 올리기 전 셀프 리뷰가 필요할 때
|
||||
- 특정 파일의 전반적인 품질을 점검할 때
|
||||
---
|
||||
|
||||
# 코드 리뷰어 (work-code-reviewer)
|
||||
|
||||
변경 코드 / 파일 → 컨벤션 · 로직 · 보안 · 성능 통합 리뷰 → 우선순위별 리포트.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- MR 올리기 전 셀프 리뷰 / 사전 점검
|
||||
- 특정 파일의 전반적인 코드 품질 검토
|
||||
- 리뷰 요청 전 자가 진단
|
||||
|
||||
## 입력
|
||||
|
||||
- `git diff` 출력, 파일 경로 목록, 또는 코드 스니펫
|
||||
- (선택) 리뷰 초점 (컨벤션 / 로직 / 보안 / 전체)
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 검토 범위 파악
|
||||
|
||||
1. 대상 파일 또는 diff를 읽는다.
|
||||
2. 파일 유형별로 적용할 검토 항목을 결정한다.
|
||||
- `.vue` → 컨벤션 + 컴포넌트 구조 + 접근성
|
||||
- `.ts` → 타입 안전성 + 로직
|
||||
- `server/` → 보안 + 에러 핸들링
|
||||
- `stores/` → 상태 관리 패턴
|
||||
- `composables/` → 부수효과 최소화
|
||||
|
||||
### Phase 2: 통합 체크리스트
|
||||
|
||||
#### 코드 품질
|
||||
- [ ] 함수/파일 단일 책임 원칙
|
||||
- [ ] 중복 코드 없음 (재사용 가능 composable/유틸 추출 여부)
|
||||
- [ ] 매직 넘버/문자열 → 상수 또는 i18n 키
|
||||
- [ ] 불필요한 `console.log` 제거
|
||||
|
||||
#### 타입 안전성
|
||||
- [ ] `any` 타입 미사용
|
||||
- [ ] 외부 API 응답 타입 정의
|
||||
- [ ] 함수 파라미터/반환 타입 명시
|
||||
|
||||
#### 에러 핸들링
|
||||
- [ ] API 호출 try-catch 또는 onError 처리
|
||||
- [ ] 에러 상태 UI 존재 여부
|
||||
- [ ] 엣지 케이스 (null, undefined, 빈 배열) 처리
|
||||
|
||||
#### 성능
|
||||
- [ ] 불필요한 watch / watchEffect 없음
|
||||
- [ ] computed 캐싱 활용
|
||||
- [ ] 과도한 reactive 래핑 없음
|
||||
|
||||
#### 보안
|
||||
- [ ] `v-html` 입력 새니타이징
|
||||
- [ ] 민감 정보 하드코딩 없음
|
||||
- [ ] 클라이언트 노출 불필요한 데이터 없음
|
||||
|
||||
#### Vue 컨벤션 (`.vue` 파일)
|
||||
`verify-component-review` 스킬의 체크리스트 전체 적용.
|
||||
|
||||
### Phase 3: 리뷰 코멘트 작성
|
||||
|
||||
`work-mr-reviewer`의 코멘트 형식과 동일:
|
||||
- 🚨 Critical / ⚠️ Warning / 💡 Nit 3단계 분류
|
||||
- 파일명:라인번호 명시
|
||||
- 수정 코드 예시 포함
|
||||
|
||||
### Phase 4: 요약
|
||||
|
||||
개선 전/후 예상 품질 변화를 간단히 서술한다.
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 코드 리뷰: <파일명 또는 기능명>
|
||||
|
||||
### 검토 요약
|
||||
- 파일: N개
|
||||
- 주요 이슈: [컨벤션 | 로직 | 보안 | 성능]
|
||||
|
||||
### 🚨 Critical (N건)
|
||||
[파일:라인] 문제 + 수정 코드
|
||||
|
||||
### ⚠️ Warning (N건)
|
||||
[파일:라인] 문제 + 수정 방향
|
||||
|
||||
### 💡 Nit (N건)
|
||||
[목록]
|
||||
|
||||
### ✅ 잘된 점
|
||||
[1~3개]
|
||||
|
||||
### 개선 후 기대 효과
|
||||
[한 줄 요약]
|
||||
```
|
||||
454
.claude/skills/work-log/SKILL.md
Normal file
454
.claude/skills/work-log/SKILL.md
Normal file
@@ -0,0 +1,454 @@
|
||||
---
|
||||
name: wiki:work-log
|
||||
description: 'Confluence 위키 페이지에 업무일지를 작성합니다'
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Confluence 업무일지 작성
|
||||
|
||||
Confluence 위키 페이지에 업무일지를 작성하는 스킬입니다.
|
||||
|
||||
## 빠른 참조
|
||||
|
||||
**프로세스:**
|
||||
1. 위키 페이지 ID 또는 URL 확인 (환경변수 `WIKI_DAILY_WORK_LOG_URL` 우선 사용)
|
||||
2. 날짜 입력 (MM/DD 형식)
|
||||
3. 업무 내용 입력
|
||||
4. 기존 페이지 읽기
|
||||
5. 해당 날짜에 업무 내용 추가
|
||||
6. 페이지 업데이트
|
||||
|
||||
**API 호출 순서:**
|
||||
```typescript
|
||||
// 1. 페이지 읽기
|
||||
const page = await mcp__wiki__get_page_by_id({ page_id: "페이지ID" })
|
||||
|
||||
// 2. 내용 파싱 및 업데이트
|
||||
// HTML 테이블 형식의 업무일지에서 해당 날짜 행 찾기
|
||||
// 업무 내용 추가
|
||||
|
||||
// 3. 페이지 업데이트
|
||||
await mcp__wiki__update_page({
|
||||
page_id: "페이지ID",
|
||||
title: page.title,
|
||||
body: updatedBody
|
||||
})
|
||||
```
|
||||
|
||||
## 입력 파라미터
|
||||
|
||||
### 1. 위키 페이지 식별자 (조건부 필수)
|
||||
- **환경변수 우선**: `.env.local`의 `WIKI_DAILY_WORK_LOG_URL`이 설정되어 있으면 자동 사용
|
||||
- **수동 입력**: 환경변수가 없거나 다른 페이지를 사용할 경우
|
||||
- **페이지 ID**: 숫자로 된 페이지 ID (예: `684252757`)
|
||||
- **페이지 URL**: Confluence 페이지 전체 URL
|
||||
- URL에서 pageId 추출: `https://wiki.smilegate.net/pages/viewpage.action?pageId=684252757`
|
||||
- `pageId=` 뒤의 숫자가 페이지 ID
|
||||
|
||||
### 2. 날짜 (필수)
|
||||
- **형식**: `MM/DD` (예: `04/02`, `12/25`)
|
||||
- 업무일지 테이블에서 해당 날짜 행을 찾는 데 사용
|
||||
|
||||
### 3. 업무 내용 (필수)
|
||||
- **형식**: 텍스트 또는 HTML
|
||||
- 업무일지의 "진행업무" 열에 추가될 내용
|
||||
- HTML 마크업 지원 (리스트, 링크 등)
|
||||
|
||||
## 업무일지 테이블 구조
|
||||
|
||||
일반적인 업무일지는 다음과 같은 HTML 테이블 구조를 가집니다:
|
||||
|
||||
```html
|
||||
<table class="fixed-width wrapped">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style="text-align: center;">날짜</th>
|
||||
<th style="text-align: center;">진행업무</th>
|
||||
<th style="text-align: center;">기타 기록</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center;">04/01</td>
|
||||
<td>업무 내용...</td>
|
||||
<td><br /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center;">04/02</td>
|
||||
<td><br /></td>
|
||||
<td><br /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
```
|
||||
|
||||
## 프로세스 상세
|
||||
|
||||
### Step 1: 페이지 식별자 추출
|
||||
|
||||
URL에서 페이지 ID 추출:
|
||||
|
||||
```typescript
|
||||
function extractPageId(input: string): string {
|
||||
// 이미 숫자인 경우
|
||||
if (/^\d+$/.test(input)) {
|
||||
return input
|
||||
}
|
||||
|
||||
// URL에서 pageId 추출
|
||||
const match = input.match(/pageId=(\d+)/)
|
||||
if (match) {
|
||||
return match[1]
|
||||
}
|
||||
|
||||
throw new Error('유효한 페이지 ID 또는 URL을 입력해주세요')
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: 환경변수 확인 및 사용자 입력 받기
|
||||
|
||||
먼저 `.env.local`에서 환경변수 확인:
|
||||
|
||||
```typescript
|
||||
// .env.local 파일 읽기 (Read 툴 사용)
|
||||
const envContent = await readFile('.env.local')
|
||||
const wikiUrlMatch = envContent.match(/WIKI_DAILY_WORK_LOG_URL=['"](.+?)['"]/)
|
||||
const defaultWikiUrl = wikiUrlMatch ? wikiUrlMatch[1] : null
|
||||
|
||||
// 환경변수가 있으면 자동 사용, 없으면 사용자에게 입력 받기
|
||||
let pageIdInput: string
|
||||
|
||||
if (defaultWikiUrl) {
|
||||
console.log(`✅ 환경변수에서 업무일지 URL 발견: ${defaultWikiUrl}`)
|
||||
pageIdInput = defaultWikiUrl
|
||||
} else {
|
||||
// 사용자에게 입력 받기
|
||||
const pageAnswer = await askUserQuestion({
|
||||
questions: [{
|
||||
question: "업무일지 페이지 ID 또는 URL을 입력해주세요",
|
||||
header: "페이지",
|
||||
options: [
|
||||
{
|
||||
label: "URL 입력",
|
||||
description: "https://wiki.smilegate.net/pages/viewpage.action?pageId=..."
|
||||
},
|
||||
{
|
||||
label: "페이지 ID 입력",
|
||||
description: "숫자만 입력 (예: 684252757)"
|
||||
}
|
||||
],
|
||||
multiSelect: false
|
||||
}]
|
||||
})
|
||||
pageIdInput = pageAnswer.페이지 // Other 응답에서 실제 값 가져오기
|
||||
}
|
||||
|
||||
// 날짜 입력 받기
|
||||
const dateAnswer = await askUserQuestion({
|
||||
questions: [{
|
||||
question: "날짜를 입력해주세요 (MM/DD 형식)",
|
||||
header: "날짜",
|
||||
options: [
|
||||
{ label: "오늘", description: "오늘 날짜 자동 입력" },
|
||||
{ label: "직접 입력", description: "Other를 선택하여 MM/DD 형식으로 입력" }
|
||||
],
|
||||
multiSelect: false
|
||||
}]
|
||||
})
|
||||
|
||||
// 업무 내용은 별도로 입력받기 (긴 텍스트)
|
||||
const contentAnswer = await askUserQuestion({
|
||||
questions: [{
|
||||
question: "업무 내용을 입력해주세요\n(Jira 이슈 번호를 포함하면 자동으로 링크가 생성됩니다)",
|
||||
header: "업무내용",
|
||||
options: [
|
||||
{ label: "직접 입력", description: "Other를 선택하여 상세 내용 입력" }
|
||||
],
|
||||
multiSelect: false
|
||||
}]
|
||||
})
|
||||
```
|
||||
|
||||
### Step 3: 날짜 처리
|
||||
|
||||
```typescript
|
||||
function formatDate(dateInput: string): string {
|
||||
if (dateInput === "오늘") {
|
||||
const today = new Date()
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(today.getDate()).padStart(2, '0')
|
||||
return `${month}/${day}`
|
||||
}
|
||||
|
||||
// MM/DD 형식 검증
|
||||
if (!/^\d{2}\/\d{2}$/.test(dateInput)) {
|
||||
throw new Error('날짜는 MM/DD 형식으로 입력해주세요 (예: 04/02)')
|
||||
}
|
||||
|
||||
return dateInput
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: 페이지 읽기 및 파싱
|
||||
|
||||
```typescript
|
||||
// 페이지 내용 가져오기
|
||||
const page = await mcp__wiki__get_page_by_id({
|
||||
page_id: pageId,
|
||||
expand: "body.storage,version"
|
||||
})
|
||||
|
||||
const currentBody = page.body.storage.value
|
||||
const currentTitle = page.title
|
||||
```
|
||||
|
||||
### Step 5: 업무 내용 포맷팅
|
||||
|
||||
Jira 이슈 자동 링크 변환:
|
||||
|
||||
```typescript
|
||||
function formatWorkContent(content: string): string {
|
||||
// SWV-123 형식을 Jira 링크로 변환
|
||||
const withJiraLinks = content.replace(
|
||||
/\b(SWV-\d+)\b/g,
|
||||
'<a href="https://jira.smilegate.net/browse/$1">$1</a>'
|
||||
)
|
||||
|
||||
// 간단한 마크다운 스타일 HTML 변환
|
||||
let formatted = withJiraLinks
|
||||
|
||||
// 줄바꿈 처리
|
||||
formatted = formatted.replace(/\n/g, '<br />')
|
||||
|
||||
return formatted
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: 테이블에서 날짜 행 찾기 및 업데이트
|
||||
|
||||
```typescript
|
||||
function updateWorkLog(htmlBody: string, date: string, content: string): string {
|
||||
// 날짜 셀 찾기: <td style="text-align: center;">04/02</td>
|
||||
const datePattern = new RegExp(
|
||||
`(<td[^>]*style="text-align: center;"[^>]*>${date}</td>\\s*<td[^>]*>)([^<]*|.*?)(</td>)`,
|
||||
'i'
|
||||
)
|
||||
|
||||
const match = htmlBody.match(datePattern)
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`날짜 ${date}에 해당하는 행을 찾을 수 없습니다.`)
|
||||
}
|
||||
|
||||
// 기존 내용이 <br />만 있는 경우 대체, 아니면 추가
|
||||
const existingContent = match[2].trim()
|
||||
const newContent = existingContent === '<br />' || existingContent === ''
|
||||
? content
|
||||
: existingContent + '<br /><br />' + content
|
||||
|
||||
// 업데이트된 HTML 반환
|
||||
return htmlBody.replace(datePattern, `$1${newContent}$3`)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: 페이지 업데이트
|
||||
|
||||
```typescript
|
||||
const updatedBody = updateWorkLog(currentBody, date, formattedContent)
|
||||
|
||||
await mcp__wiki__update_page({
|
||||
page_id: pageId,
|
||||
title: currentTitle,
|
||||
body: updatedBody
|
||||
})
|
||||
|
||||
console.log(`✅ 업무일지 업데이트 완료`)
|
||||
console.log(`📅 날짜: ${date}`)
|
||||
console.log(`🔗 페이지: https://wiki.smilegate.net/pages/viewpage.action?pageId=${pageId}`)
|
||||
```
|
||||
|
||||
## 업무 내용 작성 예시
|
||||
|
||||
### 예시 1: 간단한 업무
|
||||
|
||||
**입력:**
|
||||
```
|
||||
로드나인 아시아 사전등록 페이지 버그 수정
|
||||
```
|
||||
|
||||
**결과:**
|
||||
```html
|
||||
로드나인 아시아 사전등록 페이지 버그 수정
|
||||
```
|
||||
|
||||
### 예시 2: Jira 이슈 포함
|
||||
|
||||
**입력:**
|
||||
```
|
||||
FE AI 표준화 로드맵 기술 검토 (SWV-955)
|
||||
Schema.org 구조화된 데이터 추가 (SWV-956)
|
||||
```
|
||||
|
||||
**결과:**
|
||||
```html
|
||||
FE AI 표준화 로드맵 기술 검토 (<a href="https://jira.smilegate.net/browse/SWV-955">SWV-955</a>)<br />
|
||||
Schema.org 구조화된 데이터 추가 (<a href="https://jira.smilegate.net/browse/SWV-956">SWV-956</a>)
|
||||
```
|
||||
|
||||
### 예시 3: 구조화된 업무 내용
|
||||
|
||||
**입력:**
|
||||
```
|
||||
<u>FE AI 표준화 및 SEO 개선</u>
|
||||
|
||||
- FE AI 표준화 로드맵 기술 검토 (SWV-955)
|
||||
- Nuxt 3 기반 FE 개발 프로세스와 Claude Code AI Skills 통합
|
||||
- 개발 프로세스 전 단계(기획~검증) AI 어시스턴트 활용 체계 수립
|
||||
|
||||
- Schema.org 구조화된 데이터 추가 (SWV-956)
|
||||
- SEO 최적화 및 검색 엔진 가시성 개선 작업
|
||||
- JSON-LD 형식으로 구조화된 데이터 구현 방안 검토
|
||||
```
|
||||
|
||||
**결과:**
|
||||
```html
|
||||
<p><u>FE AI 표준화 및 SEO 개선</u></p>
|
||||
<ul style="list-style-type: square;">
|
||||
<li>FE AI 표준화 로드맵 기술 검토 (<a href="https://jira.smilegate.net/browse/SWV-955">SWV-955</a>)
|
||||
<ul style="list-style-type: square;">
|
||||
<li>Nuxt 3 기반 FE 개발 프로세스와 Claude Code AI Skills 통합</li>
|
||||
<li>개발 프로세스 전 단계(기획~검증) AI 어시스턴트 활용 체계 수립</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Schema.org 구조화된 데이터 추가 (<a href="https://jira.smilegate.net/browse/SWV-956">SWV-956</a>)
|
||||
<ul style="list-style-type: square;">
|
||||
<li>SEO 최적화 및 검색 엔진 가시성 개선 작업</li>
|
||||
<li>JSON-LD 형식으로 구조화된 데이터 구현 방안 검토</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
## 에러 처리
|
||||
|
||||
### 1. 페이지를 찾을 수 없는 경우
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const page = await mcp__wiki__get_page_by_id({ page_id: pageId })
|
||||
} catch (error) {
|
||||
console.error(`❌ 페이지를 찾을 수 없습니다: ${pageId}`)
|
||||
console.error('페이지 ID 또는 URL을 확인해주세요.')
|
||||
throw error
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 날짜를 찾을 수 없는 경우
|
||||
|
||||
```typescript
|
||||
if (!htmlBody.includes(`>${date}</td>`)) {
|
||||
console.error(`❌ 날짜 ${date}를 찾을 수 없습니다.`)
|
||||
console.error('업무일지 테이블에 해당 날짜가 있는지 확인해주세요.')
|
||||
throw new Error(`날짜 ${date}에 해당하는 행을 찾을 수 없습니다.`)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 페이지 업데이트 실패
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await mcp__wiki__update_page({
|
||||
page_id: pageId,
|
||||
title: currentTitle,
|
||||
body: updatedBody
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`❌ 페이지 업데이트 실패`)
|
||||
console.error('권한이 있는지 확인해주세요.')
|
||||
throw error
|
||||
}
|
||||
```
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### 기본 사용 (환경변수 설정됨)
|
||||
|
||||
`.env.local`에 `WIKI_DAILY_WORK_LOG_URL`이 설정되어 있으면:
|
||||
|
||||
```bash
|
||||
/wiki:work-log
|
||||
```
|
||||
|
||||
스킬 실행 후:
|
||||
1. ✅ 환경변수에서 페이지 URL 자동 인식 (입력 불필요)
|
||||
2. 날짜 선택: "오늘" 또는 "04/02"
|
||||
3. 업무 내용 입력:
|
||||
```
|
||||
<u>FE AI 표준화 및 SEO 개선</u>
|
||||
|
||||
- FE AI 표준화 로드맵 기술 검토 (SWV-955)
|
||||
- Nuxt 3 기반 FE 개발 프로세스와 Claude Code AI Skills 통합
|
||||
|
||||
- Schema.org 구조화된 데이터 추가 (SWV-956)
|
||||
- SEO 최적화 및 검색 엔진 가시성 개선 작업
|
||||
```
|
||||
|
||||
### 고급 사용: Jira 이슈 자동 연동
|
||||
|
||||
업무 내용에 Jira 이슈 번호를 포함하면 자동으로 링크가 생성됩니다:
|
||||
|
||||
```
|
||||
로드나인 사전등록 기능 개선 (SWV-960)
|
||||
SEO 메타 태그 최적화 (SWV-961)
|
||||
```
|
||||
|
||||
→ 자동 변환:
|
||||
|
||||
```html
|
||||
로드나인 사전등록 기능 개선 (<a href="https://jira.smilegate.net/browse/SWV-960">SWV-960</a>)<br />
|
||||
SEO 메타 태그 최적화 (<a href="https://jira.smilegate.net/browse/SWV-961">SWV-961</a>)
|
||||
```
|
||||
|
||||
## 환경변수 설정
|
||||
|
||||
기본 업무일지 페이지를 환경변수로 설정하면 매번 입력할 필요가 없습니다.
|
||||
|
||||
### `.env.local` 설정
|
||||
|
||||
```bash
|
||||
# Confluence 업무일지 페이지 URL
|
||||
WIKI_DAILY_WORK_LOG_URL='https://wiki.smilegate.net/pages/viewpage.action?pageId=YOUR_PAGE_ID'
|
||||
```
|
||||
|
||||
**예시:**
|
||||
```bash
|
||||
WIKI_DAILY_WORK_LOG_URL='https://wiki.smilegate.net/pages/viewpage.action?pageId=684252757'
|
||||
```
|
||||
|
||||
### 환경변수 우선순위
|
||||
|
||||
1. `.env.local`에 `WIKI_DAILY_WORK_LOG_URL` 설정됨 → 자동 사용
|
||||
2. 환경변수 없음 → 사용자에게 수동 입력 요청
|
||||
|
||||
### 다른 페이지 사용하기
|
||||
|
||||
환경변수가 설정되어 있어도 다른 페이지에 작성하려면:
|
||||
- 스킬 실행 시 "Other"를 선택하여 다른 페이지 URL/ID 입력 가능
|
||||
|
||||
## 주의사항
|
||||
|
||||
- **페이지 권한**: 페이지를 수정할 수 있는 권한이 필요합니다
|
||||
- **날짜 형식**: MM/DD 형식을 정확히 지켜야 합니다 (예: `04/02`, `12/25`)
|
||||
- **테이블 구조**: 업무일지가 표준 테이블 구조를 따라야 합니다
|
||||
- **기존 내용**: 해당 날짜에 이미 내용이 있으면 추가됩니다 (덮어쓰지 않음)
|
||||
- **HTML 형식**: 업무 내용은 Confluence HTML 형식을 따라야 합니다
|
||||
|
||||
## 토큰 효율
|
||||
|
||||
이 스킬 문서: ~2000 tokens
|
||||
|
||||
## 검증 완료
|
||||
|
||||
이 스킬은 실제 Confluence 업무일지 작성으로 검증되었습니다:
|
||||
- 테스트 페이지: 684252757 (2604_김형길 차장)
|
||||
- 작성일: 2026-04-02
|
||||
- 상태: 성공적으로 업데이트 완료
|
||||
- 환경변수: `.env.local`의 `WIKI_DAILY_WORK_LOG_URL` 지원 추가됨
|
||||
111
.claude/skills/work-mr-reviewer/SKILL.md
Normal file
111
.claude/skills/work-mr-reviewer/SKILL.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: work-mr-reviewer
|
||||
description: |
|
||||
GitLab MR URL 또는 diff를 받아 팀 공통 지침(gameservice-fe-agent) 기준으로
|
||||
코드 리뷰 코멘트 초안을 자동 생성합니다. 리뷰어가 비즈니스 로직에 집중할 수 있도록
|
||||
컨벤션/스타일 지적은 AI가 사전 처리합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 MR 리뷰해줘", "MR 코멘트 작성해줘"
|
||||
- "이 PR 어떤지 봐줘: <URL>"
|
||||
- 리뷰어 역할로 MR을 검토하기 전 사전 검토가 필요할 때
|
||||
---
|
||||
|
||||
# GitLab MR 리뷰어 (work-mr-reviewer)
|
||||
|
||||
MR URL 또는 diff → 팀 공통 지침 기준 리뷰 코멘트 초안 자동 생성.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- MR 리뷰 요청을 받았을 때 사전 분석이 필요할 때
|
||||
- 리뷰 코멘트 작성 시간을 줄이고 싶을 때
|
||||
- 컨벤션 위반 사항을 일괄 감지하고 싶을 때
|
||||
|
||||
## 입력
|
||||
|
||||
- GitLab MR URL 또는 `git diff` 출력
|
||||
- (선택) 리뷰 우선순위 (컨벤션 중심 / 로직 중심 / 전체)
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 변경사항 파악
|
||||
|
||||
1. MR URL이 제공된 경우 diff를 가져온다.
|
||||
2. 변경된 파일 목록과 변경 규모를 파악한다.
|
||||
3. 변경 유형을 분류한다: 신규 기능 / 버그픽스 / 리팩토링 / 설정 변경
|
||||
|
||||
### Phase 2: 자동 컨벤션 검토
|
||||
|
||||
`verify-component-review` 스킬의 체크리스트를 변경된 Vue 파일에 적용한다:
|
||||
|
||||
- `<script setup lang="ts">` 사용 여부
|
||||
- `defineProps<T>()` / `defineEmits<T>()` 제네릭 형태
|
||||
- `any` 타입 사용 여부
|
||||
- Tailwind 클래스 7단계 순서
|
||||
- 네이밍 컨벤션 (camelCase / PascalCase / UPPER_SNAKE_CASE)
|
||||
- import 순서
|
||||
|
||||
### Phase 3: 로직 / 구조 검토
|
||||
|
||||
- 컴포넌트 분리 기준 적절성 (200줄 초과 여부)
|
||||
- 비즈니스 로직의 composable 분리 여부
|
||||
- 에러 핸들링 / 로딩 상태 처리 여부
|
||||
- 불필요한 API 호출 또는 상태 중복 여부
|
||||
- 보안 취약점 (XSS, 민감 정보 등)
|
||||
|
||||
### Phase 4: 코멘트 작성
|
||||
|
||||
리뷰 코멘트는 아래 형식을 따른다:
|
||||
|
||||
```
|
||||
[등급] 파일명:라인번호
|
||||
문제: [간단한 설명]
|
||||
이유: [왜 문제인지 — 팀 규칙 또는 Best Practice 근거]
|
||||
제안:
|
||||
\`\`\`
|
||||
[수정 코드 예시]
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
등급:
|
||||
- 🚨 **Blocker** — 반드시 수정 후 머지 (보안, 런타임 에러, 명백한 버그)
|
||||
- ⚠️ **Major** — 수정 권장 (컨벤션 위반, 성능 문제)
|
||||
- 💡 **Minor** — 선택적 개선 (가독성, 스타일)
|
||||
- 💬 **Question** — 로직 확인 필요 (질문 형태)
|
||||
|
||||
### Phase 5: 요약
|
||||
|
||||
- 전체 변경사항에 대한 한 줄 요약
|
||||
- Blocker 건수 명시
|
||||
- 칭찬할 만한 좋은 패턴 1~2개 언급
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## MR 리뷰: <MR 제목>
|
||||
|
||||
### 변경 요약
|
||||
- 변경 파일: N개
|
||||
- 유형: 신규 기능 / 버그픽스 / ...
|
||||
|
||||
### 🚨 Blocker (N건)
|
||||
...
|
||||
|
||||
### ⚠️ Major (N건)
|
||||
...
|
||||
|
||||
### 💡 Minor (N건)
|
||||
...
|
||||
|
||||
### 💬 Questions
|
||||
...
|
||||
|
||||
### ✅ 잘된 점
|
||||
- [좋은 패턴 1~2개]
|
||||
|
||||
> 이 리뷰는 AI 초안입니다. 최종 리뷰어의 판단으로 조정해 주세요.
|
||||
```
|
||||
258
.omc/project-memory.json
Normal file
258
.omc/project-memory.json
Normal file
@@ -0,0 +1,258 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"lastScanned": 1779881088035,
|
||||
"projectRoot": "/Users/gil/Downloads/gameservice-fe-agent 2",
|
||||
"techStack": {
|
||||
"languages": [],
|
||||
"frameworks": [],
|
||||
"packageManager": null,
|
||||
"runtime": null
|
||||
},
|
||||
"build": {
|
||||
"buildCommand": null,
|
||||
"testCommand": null,
|
||||
"lintCommand": null,
|
||||
"devCommand": null,
|
||||
"scripts": {}
|
||||
},
|
||||
"conventions": {
|
||||
"namingStyle": null,
|
||||
"importStyle": null,
|
||||
"testPattern": null,
|
||||
"fileOrganization": null
|
||||
},
|
||||
"structure": {
|
||||
"isMonorepo": false,
|
||||
"workspaces": [],
|
||||
"mainDirectories": [
|
||||
"scripts"
|
||||
],
|
||||
"gitBranches": {
|
||||
"defaultBranch": "main",
|
||||
"branchingStrategy": null
|
||||
}
|
||||
},
|
||||
"customNotes": [],
|
||||
"directoryMap": {
|
||||
"html": {
|
||||
"path": "html",
|
||||
"purpose": null,
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1779881088023,
|
||||
"keyFiles": [
|
||||
"fe-agent-structure.html",
|
||||
"fe-ai-reference-flow.html",
|
||||
"fe-ai-rules.html",
|
||||
"fe-ai-workflow-ppt.html",
|
||||
"fe-ai-workflow.html"
|
||||
]
|
||||
},
|
||||
"rules": {
|
||||
"path": "rules",
|
||||
"purpose": null,
|
||||
"fileCount": 4,
|
||||
"lastAccessed": 1779881088024,
|
||||
"keyFiles": [
|
||||
"claude-workflow.md",
|
||||
"coding-conventions.md",
|
||||
"commit-pr.md",
|
||||
"framework-rules.md"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"path": "scripts",
|
||||
"purpose": "Build/utility scripts",
|
||||
"fileCount": 4,
|
||||
"lastAccessed": 1779881088024,
|
||||
"keyFiles": [
|
||||
"init-project.sh",
|
||||
"install.sh",
|
||||
"link-skills.sh",
|
||||
"update.sh"
|
||||
]
|
||||
},
|
||||
"skills": {
|
||||
"path": "skills",
|
||||
"purpose": null,
|
||||
"fileCount": 2,
|
||||
"lastAccessed": 1779881088025,
|
||||
"keyFiles": [
|
||||
"README.md"
|
||||
]
|
||||
},
|
||||
"templates": {
|
||||
"path": "templates",
|
||||
"purpose": null,
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1779881088025,
|
||||
"keyFiles": [
|
||||
"CLAUDE.md.tpl"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hotPaths": [
|
||||
{
|
||||
"path": "session-report-20260516-1137.html",
|
||||
"accessCount": 4,
|
||||
"lastAccessed": 1778899125860,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/conventional-commit/SKILL.md",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1779882449816,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/verify-component-review/SKILL.md",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1779882450087,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/work-mr-reviewer/SKILL.md",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1779882452931,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/project-init/SKILL.md",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1779882453953,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/dev-unit-test/SKILL.md",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1779882455191,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "CLAUDE.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881527413,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/dev-api-state/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881687796,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/dev-component/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881688296,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/dev-docs/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881688774,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/dev-storybook/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881689816,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/dreaming/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881690818,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/markup-base/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881691808,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/markup-edm/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881692307,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/markup-figma/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881692781,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/markup-promotion/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881693816,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/plan-analyzer/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881694310,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/plan-translation-generator/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881694809,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/ppt-maker/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881695833,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/security-review/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881696830,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/squad-orchestration/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881697894,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/verify-a11y/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881698351,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/verify-perf/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881699852,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/verify-requirement/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881700334,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/verify-seo-geo/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881700838,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/work-code-reviewer/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881701830,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "skills/work-log/SKILL.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1779881702362,
|
||||
"type": "file"
|
||||
}
|
||||
],
|
||||
"userDirectives": []
|
||||
}
|
||||
8
.omc/sessions/082bc0a1-f53c-41d5-8f6c-90b000014186.json
Normal file
8
.omc/sessions/082bc0a1-f53c-41d5-8f6c-90b000014186.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"session_id": "082bc0a1-f53c-41d5-8f6c-90b000014186",
|
||||
"ended_at": "2026-05-16T02:21:24.479Z",
|
||||
"reason": "other",
|
||||
"agents_spawned": 0,
|
||||
"agents_completed": 0,
|
||||
"modes_used": []
|
||||
}
|
||||
8
.omc/sessions/2f3f79c0-ebbd-4d15-a4d8-d9b3a59f53e5.json
Normal file
8
.omc/sessions/2f3f79c0-ebbd-4d15-a4d8-d9b3a59f53e5.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"session_id": "2f3f79c0-ebbd-4d15-a4d8-d9b3a59f53e5",
|
||||
"ended_at": "2026-05-21T10:55:51.309Z",
|
||||
"reason": "other",
|
||||
"agents_spawned": 0,
|
||||
"agents_completed": 0,
|
||||
"modes_used": []
|
||||
}
|
||||
8
.omc/sessions/7943f1a4-77eb-4ed3-8390-0307b53dde90.json
Normal file
8
.omc/sessions/7943f1a4-77eb-4ed3-8390-0307b53dde90.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"session_id": "7943f1a4-77eb-4ed3-8390-0307b53dde90",
|
||||
"ended_at": "2026-05-16T02:20:47.812Z",
|
||||
"reason": "prompt_input_exit",
|
||||
"agents_spawned": 0,
|
||||
"agents_completed": 0,
|
||||
"modes_used": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{"t":0,"agent":"system","event":"skill_invoked","skill_name":"harness:harness"}
|
||||
{"t":0,"agent":"ae77205","agent_type":"unknown","event":"agent_stop","success":true}
|
||||
{"t":0,"agent":"a299369","agent_type":"analyst","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a299369","agent_type":"analyst","event":"agent_stop","success":true,"duration_ms":136762}
|
||||
{"t":0,"agent":"a879ea4","agent_type":"unknown","event":"agent_stop","success":true}
|
||||
1
.omc/state/hud-stdin-cache.json
Normal file
1
.omc/state/hud-stdin-cache.json
Normal file
@@ -0,0 +1 @@
|
||||
{"session_id":"137440a8-d613-4399-859d-0f83e48e0ec2","transcript_path":"/Users/gil/.claude/projects/-Users-gil-Downloads-gameservice-fe-agent-2/137440a8-d613-4399-859d-0f83e48e0ec2.jsonl","cwd":"/Users/gil/Downloads/gameservice-fe-agent 2","session_name":"Build agent team harness for project","model":{"id":"claude-sonnet-4-6","display_name":"Sonnet 4.6"},"workspace":{"current_dir":"/Users/gil/Downloads/gameservice-fe-agent 2","project_dir":"/Users/gil/Downloads/gameservice-fe-agent 2","added_dirs":[]},"version":"2.1.132","output_style":{"name":"default"},"cost":{"total_cost_usd":2.29210485,"total_duration_ms":1385950,"total_api_duration_ms":328564,"total_lines_added":6,"total_lines_removed":5},"context_window":{"total_input_tokens":88842,"total_output_tokens":556,"context_window_size":200000,"current_usage":{"input_tokens":3,"output_tokens":556,"cache_creation_input_tokens":1061,"cache_read_input_tokens":87778},"used_percentage":44,"remaining_percentage":56},"exceeds_200k_tokens":false,"fast_mode":false,"effort":{"level":"high"},"thinking":{"enabled":true},"rate_limits":{"five_hour":{"used_percentage":21,"resets_at":1779898800},"seven_day":{"used_percentage":14.000000000000002,"resets_at":1780171200}}}
|
||||
7
.omc/state/last-tool-error.json
Normal file
7
.omc/state/last-tool-error.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tool_name": "Read",
|
||||
"tool_input_preview": "{\"file_path\":\"/Users/gil/.claude/projects/-Users-gil-Downloads-gameservice-fe-agent-2/137440a8-d613-4399-859d-0f83e48e0ec2/tool-results/bbcz4rj5p.txt\"}",
|
||||
"error": "File content (51430 tokens) exceeds maximum allowed tokens (25000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.",
|
||||
"timestamp": "2026-05-27T11:34:09.555Z",
|
||||
"retry_count": 1
|
||||
}
|
||||
61
.omc/state/mission-state.json
Normal file
61
.omc/state/mission-state.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"updatedAt": "2026-05-27T11:50:54.081Z",
|
||||
"missions": [
|
||||
{
|
||||
"id": "session:137440a8-d613-4399-859d-0f83e48e0ec2:none",
|
||||
"source": "session",
|
||||
"name": "none",
|
||||
"objective": "Session mission",
|
||||
"createdAt": "2026-05-27T11:34:36.284Z",
|
||||
"updatedAt": "2026-05-27T11:50:54.081Z",
|
||||
"status": "done",
|
||||
"workerCount": 1,
|
||||
"taskCounts": {
|
||||
"total": 1,
|
||||
"pending": 0,
|
||||
"blocked": 0,
|
||||
"inProgress": 0,
|
||||
"completed": 1,
|
||||
"failed": 0
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"name": "analyst:a299369",
|
||||
"role": "analyst",
|
||||
"ownership": "a299369360ad702d7",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-05-27T11:50:54.081Z"
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"id": "session-start:a299369360ad702d7:2026-05-27T11:34:36.284Z",
|
||||
"at": "2026-05-27T11:34:36.284Z",
|
||||
"kind": "update",
|
||||
"agent": "analyst:a299369",
|
||||
"detail": "started analyst:a299369",
|
||||
"sourceKey": "session-start:a299369360ad702d7"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a299369360ad702d7:2026-05-27T11:36:53.046Z",
|
||||
"at": "2026-05-27T11:36:53.046Z",
|
||||
"kind": "completion",
|
||||
"agent": "analyst:a299369",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a299369360ad702d7"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a879ea409ed32ea87:2026-05-27T11:50:54.081Z",
|
||||
"at": "2026-05-27T11:50:54.081Z",
|
||||
"kind": "completion",
|
||||
"agent": "analyst:a299369",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a879ea409ed32ea87"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"timestamp": "2026-05-27T11:25:10.291Z",
|
||||
"backgroundTasks": [],
|
||||
"sessionStartTimestamp": "2026-05-27T11:24:48.046Z",
|
||||
"sessionId": "137440a8-d613-4399-859d-0f83e48e0ec2"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"session_id": "137440a8-d613-4399-859d-0f83e48e0ec2",
|
||||
"started_at": "2026-05-27T11:24:48.032Z",
|
||||
"cwd": "/Users/gil/Downloads/gameservice-fe-agent 2",
|
||||
"pid": 92266
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"timestamp": "2026-05-16T02:22:38.580Z",
|
||||
"backgroundTasks": [],
|
||||
"sessionStartTimestamp": "2026-05-16T02:21:29.466Z",
|
||||
"sessionId": "2f3f79c0-ebbd-4d15-a4d8-d9b3a59f53e5"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"timestamp": "2026-05-16T02:13:41.290Z",
|
||||
"backgroundTasks": [],
|
||||
"sessionStartTimestamp": "2026-05-16T02:08:47.379Z",
|
||||
"sessionId": "7943f1a4-77eb-4ed3-8390-0307b53dde90"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"timestamp": "2026-05-16T02:13:41.290Z",
|
||||
"backgroundTasks": [],
|
||||
"sessionStartTimestamp": "2026-05-16T01:40:11.643Z",
|
||||
"sessionId": "8c56053b-8d23-4314-926d-5895c6c27fd9"
|
||||
}
|
||||
17
.omc/state/subagent-tracking.json
Normal file
17
.omc/state/subagent-tracking.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "a299369360ad702d7",
|
||||
"agent_type": "oh-my-claudecode:analyst",
|
||||
"started_at": "2026-05-27T11:34:36.284Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-05-27T11:36:53.046Z",
|
||||
"duration_ms": 136762
|
||||
}
|
||||
],
|
||||
"total_spawned": 1,
|
||||
"total_completed": 1,
|
||||
"total_failed": 0,
|
||||
"last_updated": "2026-05-27T11:50:54.182Z"
|
||||
}
|
||||
41
CLAUDE.md
41
CLAUDE.md
@@ -20,6 +20,8 @@
|
||||
|
||||
@rules/claude-workflow.md
|
||||
|
||||
@skills/squad-orchestration/docs/automation-guide.md
|
||||
|
||||
## 우선순위
|
||||
|
||||
1. 프로젝트 `CLAUDE.md`에 명시된 **프로젝트 지침**이 최우선입니다.
|
||||
@@ -37,3 +39,42 @@
|
||||
- 공통 지침은 이 레파지토리(`gameservice-fe-agent`)에서만 수정합니다.
|
||||
- 각 프로젝트는 `scripts/update.sh`(또는 `git submodule update --remote`)로 최신 버전을 받아갑니다.
|
||||
- 수정 제안은 PR로 받습니다. 자세한 내용은 루트 `README.md` 참고.
|
||||
|
||||
## 주요 명령어 (이 레포에서 작업 시)
|
||||
|
||||
```bash
|
||||
# 프로젝트에 이 레포 설치 (대상 프로젝트에서 실행)
|
||||
bash scripts/install.sh <repo-url> [branch]
|
||||
|
||||
# 공통 skill 을 .claude/skills/ 로 심볼릭 링크
|
||||
bash .claude/common/scripts/link-skills.sh
|
||||
|
||||
# 최신 버전으로 submodule 업데이트 (대상 프로젝트에서 실행)
|
||||
bash .claude/common/scripts/update.sh
|
||||
```
|
||||
|
||||
## 새 스킬 추가 절차
|
||||
|
||||
1. `skills/<skill-name>/SKILL.md` 파일 생성 (YAML frontmatter + 지시문)
|
||||
2. 필요 시 `skills/<skill-name>/` 하위에 보조 파일 추가
|
||||
3. PR로 팀 리뷰 후 머지
|
||||
4. 각 프로젝트에서 `update.sh` + `link-skills.sh` 실행
|
||||
|
||||
`SKILL.md` 최소 형식:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: <skill-name>
|
||||
description: <Claude가 이 skill을 언제 써야 하는지 한 줄로>
|
||||
---
|
||||
# <스킬 제목>
|
||||
```
|
||||
|
||||
## Behavioral Guidelines
|
||||
|
||||
> 불필요한 코드 작성과 섣부른 구현을 방지하기 위한 지침. 단순한 작업은 판단하여 적용.
|
||||
|
||||
- **코딩 전 먼저 생각**: 가정하지 말고, 불확실하면 질문. 여러 해석이 가능하면 제시.
|
||||
- **단순함 우선**: 요청된 것만 구현. 단일 사용 코드에 추상화 금지. 추측성 기능 추가 금지.
|
||||
- **최소 변경**: 요청에 직접 연결되지 않는 코드는 건드리지 않음. 인접 코드 "개선" 금지.
|
||||
- **성공 기준 정의**: 복잡한 작업은 검증 가능한 단계로 분리하여 진행.
|
||||
|
||||
@@ -56,18 +56,13 @@ git submodule update --init --recursive
|
||||
bash .claude/common/scripts/install.sh https://git.sginfra.net/sgp-web-d/gameservice-fe-agent.git
|
||||
```
|
||||
|
||||
> `install.sh`는 `.gitignore`에 `.claude/common/`를 자동으로 추가합니다.
|
||||
> `.gitignore`가 없으면 새로 생성합니다.
|
||||
|
||||
설치 후 프로젝트 구조는 다음과 같이 됩니다.
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .claude/
|
||||
│ ├── common/ ← submodule (gameservice-fe-agent)
|
||||
│ ├── project/ ← 프로젝트 고유 지침
|
||||
│ └── skills/ ← 공통 skill 심볼릭 링크
|
||||
├── .gitignore ← .claude/common/ 자동 추가됨
|
||||
│ └── project/ ← 프로젝트 고유 지침
|
||||
├── CLAUDE.md ← 공통 + 프로젝트 지침을 @import
|
||||
└── ...
|
||||
```
|
||||
@@ -114,7 +109,6 @@ bash .claude/common/scripts/update.sh
|
||||
```bash
|
||||
bash .claude/common/scripts/install.sh https://git.sginfra.net/sgp-web-d/gameservice-fe-agent.git
|
||||
# → .claude/common 에 submodule 설치
|
||||
# → .gitignore 에 .claude/common/ 추가
|
||||
# → templates/project/*.md 를 .claude/project/ 로 복사
|
||||
# → templates/CLAUDE.md.tpl 을 루트 CLAUDE.md 로 복사
|
||||
```
|
||||
@@ -234,3 +228,4 @@ Claude가 동시에 여러 지침을 읽는 경우 다음 우선순위를 따릅
|
||||
`examples/sample-nuxt-project/` 폴더에 Nuxt4 + Vue3 + TypeScript + Tailwind CSS 기반의
|
||||
샘플 구성이 들어 있습니다. 프로젝트에 이 저장소를 연결하는 실제 예시를 보려면 해당 폴더의
|
||||
`README.md`를 참고하세요.
|
||||
test
|
||||
|
||||
662
html/fe-agent-structure.html
Normal file
662
html/fe-agent-structure.html
Normal file
@@ -0,0 +1,662 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>gameservice-fe-agent 폴더 구조</title>
|
||||
<style>
|
||||
:root {
|
||||
--navy: #17172b;
|
||||
--blue: #0b63ce;
|
||||
--blue-light: #eef4ff;
|
||||
--bg: #f5f7fb;
|
||||
--card: #ffffff;
|
||||
--line: #dde4ef;
|
||||
--text: #1f2937;
|
||||
--muted: #6b7280;
|
||||
--soft: #f8fafc;
|
||||
--yellow: #fff8e6;
|
||||
--yellow-line: #f0bf4c;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
"Noto Sans KR",
|
||||
sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 34px 56px 36px;
|
||||
color: #fff;
|
||||
background: var(--navy);
|
||||
border-bottom: 4px solid #1677ff;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #a9b1c7;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: min(1160px, calc(100% - 80px));
|
||||
margin: 34px auto 60px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 28px;
|
||||
margin-bottom: 24px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #8aa0c4;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.intro p {
|
||||
margin: 0 0 24px;
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.intro p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 2px 6px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
color: #d24c4c;
|
||||
background: #fff2f2;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
strong,
|
||||
.accent {
|
||||
color: var(--blue);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--line);
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
background: #edf3ff;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 18px 16px;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
border-right: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
th:nth-child(1),
|
||||
td:nth-child(1) {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
th:nth-child(3),
|
||||
td:nth-child(3) {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
td:last-child,
|
||||
th:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-weight: 800;
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.desc-title {
|
||||
margin-bottom: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 14px 18px;
|
||||
color: #445066;
|
||||
background: var(--blue-light);
|
||||
border-left: 4px solid var(--blue);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.config-list {
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
color: #5f6878;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.config-list li::before {
|
||||
content: "· ";
|
||||
}
|
||||
|
||||
.count {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
color: var(--blue);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tree {
|
||||
padding: 28px;
|
||||
overflow-x: auto;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
color: #7a8497;
|
||||
background: var(--soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.tree .folder {
|
||||
color: var(--blue);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tree .file {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.tree .comment {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.flow-desc {
|
||||
margin: 0 0 18px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.flow-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flow-item {
|
||||
position: relative;
|
||||
min-height: 126px;
|
||||
padding: 28px 16px 18px;
|
||||
text-align: center;
|
||||
background: var(--soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.flow-num {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
background: var(--blue);
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.flow-main {
|
||||
margin: 0;
|
||||
color: #5f6878;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.flow-sub {
|
||||
margin: 0;
|
||||
color: #9aa3b2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 14px;
|
||||
color: #a26300;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
background: var(--yellow);
|
||||
border: 1px solid var(--yellow-line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-box:last-child {
|
||||
margin-bottom: 0;
|
||||
color: #43516a;
|
||||
background: var(--blue-light);
|
||||
border-color: transparent;
|
||||
border-left: 4px solid var(--blue);
|
||||
}
|
||||
|
||||
.skill-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skill-card {
|
||||
padding: 16px 18px;
|
||||
background: var(--soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.skill-card .skill-name {
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--blue);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.skill-card .skill-desc {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: calc(100% - 32px);
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
table,
|
||||
thead,
|
||||
tbody,
|
||||
tr,
|
||||
th,
|
||||
td {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
td {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
td::before {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #8aa0c4;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
td:nth-child(1)::before {
|
||||
content: "폴더";
|
||||
}
|
||||
|
||||
td:nth-child(2)::before {
|
||||
content: "설명";
|
||||
}
|
||||
|
||||
td:nth-child(3)::before {
|
||||
content: "구성";
|
||||
}
|
||||
|
||||
.flow-list {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.skill-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="hero">
|
||||
<h1>gameservice-fe-agent 폴더 구조</h1>
|
||||
<p>팀 공통 Claude 지침 저장소 — 4개 폴더 구조 설명</p>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<section class="card intro">
|
||||
<h2 class="section-title">개요</h2>
|
||||
<p>
|
||||
<code>gameservice-fe-agent</code>는 팀 전체에서 공통으로 사용하는
|
||||
<strong>프론트엔드 Claude 지침 저장소</strong>입니다.
|
||||
</p>
|
||||
<p>
|
||||
각 프로젝트는 이 저장소를 <strong>Git submodule</strong>로 포함하고,
|
||||
<code>CLAUDE.md</code>의 <code>@import</code> 구문으로 공통 지침을 불러와 사용합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title">폴더 구조 및 역할</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>폴더 / 파일</th>
|
||||
<th>설명</th>
|
||||
<th>구성</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="folder-name">rules/</span></td>
|
||||
<td>
|
||||
<div class="desc-title">매 대화마다 자동 로드되는 공통 규칙 모음</div>
|
||||
<div class="note">
|
||||
<code>CLAUDE.md</code>에서 <code>@import</code>로 불러오며,
|
||||
<span class="accent">모든 작업에 항상 자동으로 적용</span>됩니다.
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<ul class="config-list">
|
||||
<li>coding-conventions.md — 포맷팅·네이밍·타입</li>
|
||||
<li>framework-rules.md — Vue/Nuxt/Tailwind 규칙</li>
|
||||
<li>commit-pr.md — 커밋 & PR 규칙</li>
|
||||
<li>claude-workflow.md — Claude 작업 방식 지침</li>
|
||||
</ul>
|
||||
<span class="count">4개 규칙 파일</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><span class="folder-name">skills/</span></td>
|
||||
<td>
|
||||
<div class="desc-title">Claude Code에서 호출할 수 있는 팀 공용 스킬 모음</div>
|
||||
<div class="note">
|
||||
<code>link-skills.sh</code>로 프로젝트의 <code>.claude/skills/</code>에
|
||||
<span class="accent">심볼릭 링크</span>하여 사용합니다.
|
||||
<code>submodule update</code> 한 번으로 모든 프로젝트가 최신 스킬을 받습니다.
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<ul class="config-list">
|
||||
<li>conventional-commit</li>
|
||||
<li>markup-edm</li>
|
||||
<li>plan-analyzer</li>
|
||||
<li>plan-translation-generator</li>
|
||||
<li>project-init</li>
|
||||
<li>verify-component-review</li>
|
||||
<li>work-log</li>
|
||||
</ul>
|
||||
<span class="count">7개 스킬</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><span class="folder-name">templates/</span></td>
|
||||
<td>
|
||||
<div class="desc-title">프로젝트 지침 초기화용 양식 파일 모음</div>
|
||||
<div class="note">
|
||||
<code>install.sh</code> 실행 시 <code>.claude/project/</code>로 자동 복사됩니다.
|
||||
팀원은 복사된 양식에 <span class="accent">프로젝트 고유 내용을 작성</span>합니다.
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<ul class="config-list">
|
||||
<li>CLAUDE.md.tpl — 루트 CLAUDE.md 템플릿</li>
|
||||
<li>project/overview.md — 프로젝트 개요 양식</li>
|
||||
<li>project/conventions.md — 프로젝트 컨벤션 양식</li>
|
||||
<li>project/architecture.md — 아키텍처 양식</li>
|
||||
</ul>
|
||||
<span class="count">4개 템플릿</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><span class="folder-name">scripts/</span></td>
|
||||
<td>
|
||||
<div class="desc-title">프로젝트 설정 및 유지보수 자동화 스크립트</div>
|
||||
<div class="note">
|
||||
신규 프로젝트 도입부터 업데이트까지
|
||||
<span class="accent">반복 작업을 한 명령으로</span> 처리합니다.
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<ul class="config-list">
|
||||
<li>install.sh — submodule + 템플릿 복사 + skill 링크</li>
|
||||
<li>init-project.sh — project/ 양식만 재초기화</li>
|
||||
<li>link-skills.sh — 공통 skill 심볼릭 링크 관리</li>
|
||||
<li>update.sh — 최신 공통 지침으로 업데이트</li>
|
||||
</ul>
|
||||
<span class="count">4개 스크립트</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<span class="folder-name">CLAUDE.md</span><br />
|
||||
<span class="folder-name">README.md</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="desc-title">저장소 진입점 문서</div>
|
||||
<div class="note">
|
||||
<code>CLAUDE.md</code>는 <code>rules/*.md</code>를 <code>@import</code>하는
|
||||
<span class="accent">공통 지침 엔트리 포인트</span>입니다.
|
||||
<code>README.md</code>는 팀원을 위한 사용 가이드입니다.
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<ul class="config-list">
|
||||
<li>CLAUDE.md — @import rules/* 엔트리</li>
|
||||
<li>README.md — 도입·사용·업데이트 가이드</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title">전체 파일 트리</h2>
|
||||
|
||||
<div class="tree"><span class="folder">gameservice-fe-agent/</span>
|
||||
├── <span class="file">CLAUDE.md</span> <span class="comment">← 공통 지침 엔트리 포인트 (rules/* @import)</span>
|
||||
├── <span class="file">README.md</span> <span class="comment">← 도입·사용·업데이트 가이드</span>
|
||||
│
|
||||
├── <span class="folder">rules/</span> <span class="comment">← 항상 자동 로드되는 공통 규칙</span>
|
||||
│ ├── <span class="file">coding-conventions.md</span> <span class="comment">← 포맷팅, 네이밍, 타입</span>
|
||||
│ ├── <span class="file">framework-rules.md</span> <span class="comment">← Vue 3 / Nuxt / Tailwind 규칙</span>
|
||||
│ ├── <span class="file">commit-pr.md</span> <span class="comment">← Conventional Commits, PR 가이드</span>
|
||||
│ └── <span class="file">claude-workflow.md</span> <span class="comment">← Claude 작업 순서 및 행동 지침</span>
|
||||
│
|
||||
├── <span class="folder">skills/</span> <span class="comment">← 팀 공용 Claude skill 모음</span>
|
||||
│ ├── <span class="file">README.md</span>
|
||||
│ ├── <span class="folder">conventional-commit/</span>
|
||||
│ │ └── <span class="file">SKILL.md</span>
|
||||
│ ├── <span class="folder">markup-edm/</span>
|
||||
│ │ ├── <span class="file">SKILL.md</span>
|
||||
│ │ ├── <span class="folder">assets/</span>
|
||||
│ │ ├── <span class="folder">references/</span> <span class="comment">← api_reference.md, html-patterns.md</span>
|
||||
│ │ └── <span class="folder">scripts/</span>
|
||||
│ ├── <span class="folder">plan-analyzer/</span>
|
||||
│ │ ├── <span class="file">SKILL.md</span>
|
||||
│ │ ├── <span class="folder">references/</span> <span class="comment">← output-template.md</span>
|
||||
│ │ └── <span class="folder">scripts/</span> <span class="comment">← extract_pptx.py</span>
|
||||
│ ├── <span class="folder">plan-translation-generator/</span>
|
||||
│ │ ├── <span class="file">SKILL.md</span>
|
||||
│ │ └── <span class="folder">scripts/</span> <span class="comment">← generate_translation_keys.py</span>
|
||||
│ ├── <span class="folder">project-init/</span>
|
||||
│ │ └── <span class="file">SKILL.md</span>
|
||||
│ ├── <span class="folder">verify-component-review/</span>
|
||||
│ │ └── <span class="file">SKILL.md</span>
|
||||
│ └── <span class="folder">work-log/</span>
|
||||
│ └── <span class="file">SKILL.md</span>
|
||||
│
|
||||
├── <span class="folder">templates/</span> <span class="comment">← 프로젝트 지침 초기화 양식</span>
|
||||
│ ├── <span class="file">CLAUDE.md.tpl</span> <span class="comment">← 루트 CLAUDE.md 템플릿</span>
|
||||
│ └── <span class="folder">project/</span>
|
||||
│ ├── <span class="file">overview.md</span>
|
||||
│ ├── <span class="file">conventions.md</span>
|
||||
│ └── <span class="file">architecture.md</span>
|
||||
│
|
||||
└── <span class="folder">scripts/</span> <span class="comment">← 자동화 스크립트</span>
|
||||
├── <span class="file">install.sh</span> <span class="comment">← 신규 프로젝트 도입 (all-in-one)</span>
|
||||
├── <span class="file">init-project.sh</span> <span class="comment">← project/ 양식만 재초기화</span>
|
||||
├── <span class="file">link-skills.sh</span> <span class="comment">← skill 심볼릭 링크 관리</span>
|
||||
└── <span class="file">update.sh</span> <span class="comment">← 최신 공통 지침 동기화</span></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title">Skills 목록</h2>
|
||||
|
||||
<div class="skill-grid">
|
||||
<div class="skill-card">
|
||||
<div class="skill-name">conventional-commit</div>
|
||||
<div class="skill-desc">Conventional Commits 규칙에 따른 커밋 메시지 작성 지원</div>
|
||||
</div>
|
||||
<div class="skill-card">
|
||||
<div class="skill-name">markup-edm</div>
|
||||
<div class="skill-desc">EDM(이메일 마케팅) HTML 마크업 생성 및 패턴 적용</div>
|
||||
</div>
|
||||
<div class="skill-card">
|
||||
<div class="skill-name">plan-analyzer</div>
|
||||
<div class="skill-desc">기획서(PPT 등)를 분석하여 구조화된 개발 요구사항 도출</div>
|
||||
</div>
|
||||
<div class="skill-card">
|
||||
<div class="skill-name">plan-translation-generator</div>
|
||||
<div class="skill-desc">기획서 기반 다국어 번역 키 자동 생성</div>
|
||||
</div>
|
||||
<div class="skill-card">
|
||||
<div class="skill-name">project-init</div>
|
||||
<div class="skill-desc">신규 프로젝트 공통 지침 초기화 가이드</div>
|
||||
</div>
|
||||
<div class="skill-card">
|
||||
<div class="skill-name">verify-component-review</div>
|
||||
<div class="skill-desc">Vue 컴포넌트 코드 리뷰 및 규칙 준수 검증</div>
|
||||
</div>
|
||||
<div class="skill-card">
|
||||
<div class="skill-name">work-log</div>
|
||||
<div class="skill-desc">작업 내역 로그 기록 및 정리</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title">프로젝트 도입 흐름</h2>
|
||||
<p class="flow-desc">
|
||||
신규 프로젝트에서 <code>install.sh</code> 한 번으로 아래 4단계가 자동 처리됩니다:
|
||||
</p>
|
||||
|
||||
<div class="flow-list">
|
||||
<div class="flow-item">
|
||||
<span class="flow-num">1</span>
|
||||
<p class="flow-title">Submodule 추가</p>
|
||||
<p class="flow-main">install.sh</p>
|
||||
<p class="flow-sub">.claude/common/ 에 설치</p>
|
||||
</div>
|
||||
|
||||
<div class="flow-item">
|
||||
<span class="flow-num">2</span>
|
||||
<p class="flow-title">템플릿 복사</p>
|
||||
<p class="flow-main">templates/ → .claude/project/</p>
|
||||
<p class="flow-sub">프로젝트 지침 양식 생성</p>
|
||||
</div>
|
||||
|
||||
<div class="flow-item">
|
||||
<span class="flow-num">3</span>
|
||||
<p class="flow-title">Skill 링크</p>
|
||||
<p class="flow-main">link-skills.sh</p>
|
||||
<p class="flow-sub">.claude/skills/ 에 심볼릭 링크</p>
|
||||
</div>
|
||||
|
||||
<div class="flow-item">
|
||||
<span class="flow-num">4</span>
|
||||
<p class="flow-title">CLAUDE.md 작성</p>
|
||||
<p class="flow-main">@import 구문으로 연결</p>
|
||||
<p class="flow-sub">공통 + 프로젝트 지침 로드</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title">핵심 요약</h2>
|
||||
|
||||
<div class="summary-box">
|
||||
<strong>rules/</strong> 의 4개 파일은 모든 Claude 작업에 자동으로 적용되며,
|
||||
<strong>templates/</strong>로 프로젝트 고유 지침을 초기화하고,
|
||||
<strong>skills/</strong>로 반복 업무를 스킬 명령으로 자동화합니다.
|
||||
</div>
|
||||
|
||||
<div class="summary-box">
|
||||
공통 지침 수정은 이 저장소에서만 진행합니다.
|
||||
각 프로젝트는 <code>git submodule update --remote</code> 또는
|
||||
<code>scripts/update.sh</code>로 최신 버전을 동기화합니다.
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
794
html/fe-ai-reference-flow.html
Normal file
794
html/fe-ai-reference-flow.html
Normal file
@@ -0,0 +1,794 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>프론트엔드 작업 단계별 AI 참조 흐름도</title>
|
||||
<style>
|
||||
:root {
|
||||
--navy: #17172b;
|
||||
--blue: #0b63ce;
|
||||
--blue-soft: #eaf4ff;
|
||||
--purple: #5937b7;
|
||||
--purple-soft: #f1ebff;
|
||||
--green: #15975f;
|
||||
--green-soft: #e8f8ef;
|
||||
--orange: #d97706;
|
||||
--orange-soft: #fff4dd;
|
||||
--yellow: #f2b43f;
|
||||
--yellow-soft: #fff8e6;
|
||||
--red: #d94841;
|
||||
--red-soft: #fff0f0;
|
||||
--bg: #f5f7fb;
|
||||
--card: #ffffff;
|
||||
--line: #dce4ef;
|
||||
--text: #1f2937;
|
||||
--muted: #667085;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
"Noto Sans KR",
|
||||
sans-serif;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 34px 44px 36px;
|
||||
color: #fff;
|
||||
background: var(--navy);
|
||||
border-bottom: 4px solid #1677ff;
|
||||
}
|
||||
|
||||
.hero .eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: #8d96b8;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 27px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: #c2c8da;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(1180px, calc(100% - 56px));
|
||||
margin: 28px auto 48px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 22px;
|
||||
padding: 24px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 18px;
|
||||
color: #315a9e;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: "";
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: var(--blue);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
color: #17436f;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
background: var(--blue-soft);
|
||||
border-left: 4px solid var(--blue);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.case-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.case-box {
|
||||
min-height: 126px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.case-box.case1 {
|
||||
border-color: #a8caff;
|
||||
background: #f7fbff;
|
||||
}
|
||||
|
||||
.case-box.case2 {
|
||||
border-color: #c8b5ff;
|
||||
background: #fbf8ff;
|
||||
}
|
||||
|
||||
.case-label {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 11px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.case1 .case-label {
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.case2 .case-label {
|
||||
background: var(--purple);
|
||||
}
|
||||
|
||||
.case-box h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.case-box p {
|
||||
margin: 0;
|
||||
color: #475467;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border-right: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
th {
|
||||
color: #344054;
|
||||
font-weight: 900;
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
td:last-child,
|
||||
th:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.path {
|
||||
color: #c0392b;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.load-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
color: #915f00;
|
||||
background: var(--yellow-soft);
|
||||
border: 1px solid #e8bd54;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.load-badge.blue {
|
||||
color: #0b4fa6;
|
||||
background: var(--blue-soft);
|
||||
border-color: #9ec3ff;
|
||||
}
|
||||
|
||||
.quote {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 18px;
|
||||
color: #0c55b0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
background: var(--blue-soft);
|
||||
border-left: 4px solid var(--blue);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.quote.purple {
|
||||
color: #4d2e91;
|
||||
background: var(--purple-soft);
|
||||
border-left-color: var(--purple);
|
||||
}
|
||||
|
||||
.tree {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.tree::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 6px;
|
||||
width: 2px;
|
||||
background: #e4e7ec;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
position: relative;
|
||||
margin: 0 0 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tree-node::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: -18px;
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
background: #e4e7ec;
|
||||
}
|
||||
|
||||
.tree-node:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.round {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
background: var(--blue);
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.round.green {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.round.orange {
|
||||
background: var(--orange);
|
||||
}
|
||||
|
||||
.round.purple {
|
||||
background: var(--purple);
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 4px 9px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 11px;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chip.yellow {
|
||||
color: #8a5a00;
|
||||
border-color: #e6b64e;
|
||||
background: var(--yellow-soft);
|
||||
}
|
||||
|
||||
.chip.green {
|
||||
color: #087443;
|
||||
border-color: #8bd3aa;
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.chip.blue {
|
||||
color: #0b4fa6;
|
||||
border-color: #9ec3ff;
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.chip.purple {
|
||||
color: #4d2e91;
|
||||
border-color: #c6b6ff;
|
||||
background: var(--purple-soft);
|
||||
}
|
||||
|
||||
.chip.red {
|
||||
color: #a33a34;
|
||||
border-color: #e4a39f;
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
.work-box {
|
||||
margin-top: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.work-box.blue {
|
||||
background: var(--blue-soft);
|
||||
border-left: 4px solid var(--blue);
|
||||
}
|
||||
|
||||
.work-box.green {
|
||||
background: var(--green-soft);
|
||||
border-left: 4px solid var(--green);
|
||||
}
|
||||
|
||||
.work-box.yellow {
|
||||
background: var(--yellow-soft);
|
||||
border-left: 4px solid var(--yellow);
|
||||
}
|
||||
|
||||
.work-box.red {
|
||||
background: var(--red-soft);
|
||||
border-left: 4px solid var(--red);
|
||||
}
|
||||
|
||||
.work-box ul {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.case2-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stage-card {
|
||||
padding: 16px;
|
||||
border: 2px solid;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.stage-card.purple {
|
||||
border-color: var(--purple);
|
||||
}
|
||||
|
||||
.stage-card.green {
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.stage-card.orange {
|
||||
border-color: var(--orange);
|
||||
}
|
||||
|
||||
.stage-card.red {
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
.stage-card .stage-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.example {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
border-radius: 4px;
|
||||
background: #f8f5ff;
|
||||
color: #4d2e91;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.legend strong {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.container {
|
||||
width: calc(100% - 28px);
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.case-grid,
|
||||
.case2-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
table,
|
||||
thead,
|
||||
tbody,
|
||||
tr,
|
||||
th,
|
||||
td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
td {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="hero">
|
||||
<p class="eyebrow">Frontend AI Guidelines — gameservice-fe-agent</p>
|
||||
<h1>프론트엔드 작업 단계별 AI 참조 흐름도</h1>
|
||||
<p>
|
||||
프론트엔드 개발 요청 시 공통 지침(rules/)을 기반으로 Claude AI에게 업무를 위임할 때
|
||||
어떤 파일을 언제 참조하는지 정리합니다.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
|
||||
<!-- 작업 사례 -->
|
||||
<section class="card">
|
||||
<h2 class="section-title">실제 작업 사례 기준</h2>
|
||||
<div class="notice">
|
||||
예시: <strong>게임 카드 컴포넌트에 신규 출시 뱃지 추가 (Vue 3 + Nuxt)</strong>
|
||||
</div>
|
||||
|
||||
<div class="case-grid">
|
||||
<article class="case-box case1">
|
||||
<span class="case-label">CASE 1</span>
|
||||
<h3>자연어 업무 요청</h3>
|
||||
<p>
|
||||
AI가 claude-workflow.md에 따라 탐색 → 계획 → 구현 → 검증 순서로
|
||||
전체 흐름을 주도하며 진행합니다. 모호한 부분은 질문합니다.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="case-box case2">
|
||||
<span class="case-label">CASE 2</span>
|
||||
<h3>@rules 직접 지정 · 단계 분리</h3>
|
||||
<p>
|
||||
개발자가 각 작업 단계마다 필요한 rules/ 파일을 직접 지정하고
|
||||
탐색 · 계획 · 구현 · 검증을 나누어 요청합니다.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 공통 자동 로드 -->
|
||||
<section class="card">
|
||||
<h2 class="section-title">공통 자동 로드 구조 — 모든 대화에서 항상 적용</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 120px;">구분</th>
|
||||
<th style="width: 280px;">파일</th>
|
||||
<th>주요 내용</th>
|
||||
<th style="width: 120px;">로드 시점</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>공통 지침 진입점</td>
|
||||
<td><span class="path">CLAUDE.md</span></td>
|
||||
<td>rules/* 전체를 @import. 공통 지침 우선순위 및 skill 사용 안내</td>
|
||||
<td><span class="load-badge blue">세션 1회</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>코딩 컨벤션</td>
|
||||
<td><span class="path">rules/coding-conventions.md</span></td>
|
||||
<td>포맷팅(스페이스 2칸·싱글 쿼터·세미콜론), 네이밍(camelCase·PascalCase), any 금지, import 순서</td>
|
||||
<td><span class="load-badge">매 대화 턴</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>프레임워크 규칙</td>
|
||||
<td><span class="path">rules/framework-rules.md</span></td>
|
||||
<td>Vue 3 <script setup lang="ts">, Pinia, useFetch, Tailwind 유틸리티 우선, clsx 조건부 클래스</td>
|
||||
<td><span class="load-badge">매 대화 턴</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>커밋 / PR 규칙</td>
|
||||
<td><span class="path">rules/commit-pr.md</span></td>
|
||||
<td>Conventional Commits 형식, subject 50자·명령형, Squash merge, CI + 1인 승인 필수</td>
|
||||
<td><span class="load-badge">매 대화 턴</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>작업 방식 지침</td>
|
||||
<td><span class="path">rules/claude-workflow.md</span></td>
|
||||
<td>탐색→계획→구현→검증 순서, 최소 변경 원칙, 모호 시 질문, 임의 기능 추가 금지</td>
|
||||
<td><span class="load-badge">매 대화 턴</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- CASE 1 Tree -->
|
||||
<section class="card">
|
||||
<h2 class="section-title">CASE 1 자연어 업무 요청 — 자동 참조 흐름 (Tree)</h2>
|
||||
<div class="quote">"게임 카드 컴포넌트에 신규 출시 뱃지 추가해줘."</div>
|
||||
|
||||
<div class="tree">
|
||||
|
||||
<article class="tree-node">
|
||||
<div class="node-head"><span class="round">0</span>자동 로드</div>
|
||||
<p>모든 대화 시작 시 CLAUDE.md와 rules/* 4개 파일을 자동으로 참조합니다.</p>
|
||||
<div class="chip-row">
|
||||
<span class="chip blue">CLAUDE.md</span>
|
||||
<span class="chip yellow">coding-conventions.md</span>
|
||||
<span class="chip yellow">framework-rules.md</span>
|
||||
<span class="chip yellow">commit-pr.md</span>
|
||||
<span class="chip yellow">claude-workflow.md</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="tree-node">
|
||||
<div class="node-head"><span class="round">1</span>요구사항 분석 — 모호한 부분 질문</div>
|
||||
<p>claude-workflow.md 원칙에 따라 추측 대신 사용자에게 확인합니다.</p>
|
||||
<div class="chip-row">
|
||||
<span class="chip yellow">claude-workflow.md</span>
|
||||
<span class="chip yellow">framework-rules.md</span>
|
||||
</div>
|
||||
<div class="work-box yellow">
|
||||
<strong>예상 질문</strong>
|
||||
<ul>
|
||||
<li>뱃지는 어떤 조건(출시일 기준 N일 이내 등)일 때 표시하나요?</li>
|
||||
<li>뱃지 디자인은 기존 디자인 시스템 컴포넌트를 사용하나요?</li>
|
||||
<li>GameCard 컴포넌트는 여러 페이지에서 공유 중인가요?</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="tree-node">
|
||||
<div class="node-head"><span class="round">2</span>탐색 — 관련 파일 파악 · 유사 패턴 확인</div>
|
||||
<p>GameCard 컴포넌트와 주변 컨벤션을 먼저 읽고 유사 뱃지 구현 패턴을 찾습니다.</p>
|
||||
<div class="chip-row">
|
||||
<span class="chip yellow">coding-conventions.md</span>
|
||||
<span class="chip yellow">framework-rules.md</span>
|
||||
</div>
|
||||
<div class="work-box blue">
|
||||
<strong>탐색 항목</strong>
|
||||
<ul>
|
||||
<li>GameCard.vue 현재 구조 파악</li>
|
||||
<li>기존 뱃지 컴포넌트(Badge.vue 등) 존재 여부 확인</li>
|
||||
<li>props 타입 정의 위치 확인 (types/ 또는 도메인 types.ts)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="tree-node">
|
||||
<div class="node-head"><span class="round">3</span>계획 — 할 일 목록 작성 · 승인</div>
|
||||
<p>변경 파일 목록과 구현 순서를 공유하고, 아키텍처 영향이 있으면 승인을 받습니다.</p>
|
||||
<div class="chip-row">
|
||||
<span class="chip yellow">claude-workflow.md</span>
|
||||
<span class="chip yellow">framework-rules.md</span>
|
||||
</div>
|
||||
<div class="work-box blue">
|
||||
<strong>계획 산출물</strong>
|
||||
<ul>
|
||||
<li>GameCard.vue — isNew prop 추가, 뱃지 렌더링 조건 분기</li>
|
||||
<li>types/game.ts — isNew: boolean 필드 추가</li>
|
||||
<li>기존 뱃지 컴포넌트 재사용 or 인라인 처리 결정</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="tree-node">
|
||||
<div class="node-head"><span class="round green">4</span>구현 — 코드 작성</div>
|
||||
<p>계획에 따라 Vue 컴포넌트와 타입을 수정합니다. 요청 범위를 벗어나는 리팩토링은 하지 않습니다.</p>
|
||||
<div class="chip-row">
|
||||
<span class="chip yellow">coding-conventions.md</span>
|
||||
<span class="chip yellow">framework-rules.md</span>
|
||||
</div>
|
||||
<div class="work-box green">
|
||||
<strong>구현 항목</strong>
|
||||
<ul>
|
||||
<li>defineProps<T>()에 isNew?: boolean 추가</li>
|
||||
<li>Tailwind 유틸리티 클래스로 뱃지 스타일 적용</li>
|
||||
<li>조건부 클래스에 clsx 사용, 200줄 초과 시 분리 검토</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="tree-node">
|
||||
<div class="node-head"><span class="round orange">5</span>검증 — 린트 · 빌드 · 커밋 / PR 작성</div>
|
||||
<p>타입체크와 빌드를 확인한 뒤 Conventional Commits 형식으로 커밋과 PR을 작성합니다.</p>
|
||||
<div class="chip-row">
|
||||
<span class="chip yellow">coding-conventions.md</span>
|
||||
<span class="chip yellow">commit-pr.md</span>
|
||||
<span class="chip yellow">claude-workflow.md</span>
|
||||
</div>
|
||||
<div class="work-box red">
|
||||
<strong>최종 산출물</strong>
|
||||
<ul>
|
||||
<li>lint / tsc / build 통과 확인</li>
|
||||
<li>커밋: <code>feat(game-card): add new-release badge</code></li>
|
||||
<li>PR 본문: 변경 사항·배경·테스트·체크리스트 작성</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CASE 2 -->
|
||||
<section class="card">
|
||||
<h2 class="section-title">CASE 2 @rules 직접 지정 · 단계별 업무 요청 — 직접 참조 흐름 (Tree)</h2>
|
||||
<div class="quote purple">
|
||||
개발자가 각 작업 단계마다 필요한 rules/ 파일 또는 skill을 직접 지정해 요청합니다.
|
||||
</div>
|
||||
|
||||
<div class="case2-grid">
|
||||
|
||||
<article class="stage-card purple">
|
||||
<h3 class="stage-title"><span class="round purple">1</span>탐색 단계</h3>
|
||||
<div class="example">"GameCard 컴포넌트 구조 파악해줘. @rules/framework-rules.md 참고해서"</div>
|
||||
<div class="chip-row">
|
||||
<span class="chip yellow">coding-conventions.md</span>
|
||||
<span class="chip yellow">framework-rules.md</span>
|
||||
</div>
|
||||
<div class="work-box green">
|
||||
<strong>산출 내용</strong>
|
||||
<ul>
|
||||
<li>컴포넌트 파일 구조 및 props 정리</li>
|
||||
<li>재사용 가능한 유사 패턴 목록</li>
|
||||
<li>영향받는 파일 범위 파악</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="stage-card purple">
|
||||
<h3 class="stage-title"><span class="round purple">2</span>계획 단계</h3>
|
||||
<div class="example">"뱃지 추가 구현 계획 잡아줘. @rules/claude-workflow.md 참고해서"</div>
|
||||
<div class="chip-row">
|
||||
<span class="chip yellow">claude-workflow.md</span>
|
||||
<span class="chip yellow">framework-rules.md</span>
|
||||
</div>
|
||||
<div class="work-box green">
|
||||
<strong>산출 내용</strong>
|
||||
<ul>
|
||||
<li>수정 파일 목록 및 변경 순서</li>
|
||||
<li>아키텍처 영향 범위 정리</li>
|
||||
<li>사용자 승인 필요 항목 명시</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="stage-card green">
|
||||
<h3 class="stage-title"><span class="round green">3</span>구현 단계</h3>
|
||||
<div class="example">"계획대로 구현해줘. @rules/coding-conventions.md @rules/framework-rules.md 참고해서"</div>
|
||||
<div class="chip-row">
|
||||
<span class="chip yellow">coding-conventions.md</span>
|
||||
<span class="chip yellow">framework-rules.md</span>
|
||||
</div>
|
||||
<div class="work-box green">
|
||||
<strong>산출 내용</strong>
|
||||
<ul>
|
||||
<li>Vue 컴포넌트 코드 수정</li>
|
||||
<li>TypeScript 타입 추가 · 수정</li>
|
||||
<li>Tailwind 클래스 적용 및 clsx 처리</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="stage-card orange">
|
||||
<h3 class="stage-title"><span class="round orange">4</span>검증 단계</h3>
|
||||
<div class="example">"변경 코드 검증하고 커밋·PR 작성해줘. @rules/commit-pr.md 참고해서"</div>
|
||||
<div class="chip-row">
|
||||
<span class="chip yellow">coding-conventions.md</span>
|
||||
<span class="chip yellow">commit-pr.md</span>
|
||||
</div>
|
||||
<div class="work-box red">
|
||||
<strong>산출 내용</strong>
|
||||
<ul>
|
||||
<li>lint / tsc / build 통과 확인</li>
|
||||
<li>Conventional Commits 커밋 메시지 작성</li>
|
||||
<li>PR 템플릿(변경사항·배경·테스트·체크리스트) 작성</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px; padding: 12px 14px; background: var(--purple-soft); border-left: 4px solid var(--purple); border-radius: 4px; font-size: 13px; color: #4d2e91;">
|
||||
<strong>Skill 활용 팁</strong> — 반복 업무는 스킬 명령으로 더 빠르게 처리할 수 있습니다.<br>
|
||||
<span style="font-family: monospace; font-size: 12px;">
|
||||
/conventional-commit · /verify-component-review · /plan-analyzer · /markup-edm
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 범례 -->
|
||||
<section class="card">
|
||||
<h2 class="section-title">범례</h2>
|
||||
<div class="legend">
|
||||
<strong>공통 자동</strong>
|
||||
<span class="chip blue">CLAUDE.md — 세션 1회 자동 로드</span>
|
||||
<span class="chip yellow">rules/* — 매 대화 턴 자동 로드 (4개 파일)</span>
|
||||
<span class="chip green">단계별 주요 참조 파일</span>
|
||||
<span class="chip purple">@rules — 직접 지정 (CASE 2)</span>
|
||||
<span class="chip red">검증 · 커밋 / PR 단계</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
612
html/fe-ai-rules.html
Normal file
612
html/fe-ai-rules.html
Normal file
@@ -0,0 +1,612 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>프론트엔드 AI 활용 지침 체계</title>
|
||||
<style>
|
||||
:root {
|
||||
--navy: #18172b;
|
||||
--blue: #0b5ed7;
|
||||
--blue-dark: #0756c9;
|
||||
--purple: #46369a;
|
||||
--green: #15803d;
|
||||
--orange: #d97706;
|
||||
--bg: #f5f7fb;
|
||||
--card: #ffffff;
|
||||
--line: #dce4f0;
|
||||
--text: #1f2937;
|
||||
--muted: #667085;
|
||||
--soft-blue: #eef5ff;
|
||||
--soft-yellow: #fff9e8;
|
||||
--soft-green: #eefaf2;
|
||||
--soft-purple: #f3f0ff;
|
||||
--soft-gray: #f8fafc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
"Noto Sans KR",
|
||||
sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28px 44px;
|
||||
color: #fff;
|
||||
background: var(--navy);
|
||||
border-bottom: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 25px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: #9da8ca;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(1050px, calc(100% - 48px));
|
||||
margin: 32px auto 48px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 20px;
|
||||
padding: 24px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 18px;
|
||||
color: #8a9cbc;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.purpose-title {
|
||||
margin: 0 0 10px;
|
||||
color: var(--blue);
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.purpose-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* rules 4개 카드 */
|
||||
.rules-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rule-box {
|
||||
padding: 18px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.rule-box.blue { background: var(--soft-blue); border-color: #9ec3ff; }
|
||||
.rule-box.yellow { background: var(--soft-yellow); border-color: #ecc65d; }
|
||||
.rule-box.green { background: var(--soft-green); border-color: #8fd1a4; }
|
||||
.rule-box.purple { background: var(--soft-purple); border-color: #c4b5fd; }
|
||||
|
||||
.box-title {
|
||||
margin: 0 0 8px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.blue .box-title { color: #003d91; }
|
||||
.yellow .box-title { color: #8a5a00; }
|
||||
.green .box-title { color: #065f2b; }
|
||||
.purple .box-title { color: #3b2780; }
|
||||
|
||||
.box-label {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.blue .box-label { color: #004da8; background: #bdd7ff; }
|
||||
.yellow .box-label { color: #8a5a00; background: #ffe49c; }
|
||||
.green .box-label { color: #065f2b; background: #bce9cd; }
|
||||
.purple .box-label { color: #3b2780; background: #ddd6fe; }
|
||||
|
||||
.box-subject {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dash-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.dash-list li::before {
|
||||
content: "· ";
|
||||
}
|
||||
|
||||
/* 파일별 상세 내용 */
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-box {
|
||||
padding: 18px 20px;
|
||||
background: var(--soft-gray);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.detail-title code {
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--blue-dark);
|
||||
background: var(--soft-blue);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.kv-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.kv-list li {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.kv-list li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.kv-key {
|
||||
flex-shrink: 0;
|
||||
width: 90px;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kv-val {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 커밋 타입 뱃지 */
|
||||
.commit-types {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ct {
|
||||
padding: 3px 9px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
border-radius: 4px;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* 작업 흐름 */
|
||||
.flow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.flow-item {
|
||||
position: relative;
|
||||
padding: 18px 16px;
|
||||
background: var(--soft-gray);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.flow-item:not(:last-child)::after {
|
||||
content: "→";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -14px;
|
||||
color: #98a2b3;
|
||||
font-size: 18px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.flow-num {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-bottom: 10px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
border-radius: 50%;
|
||||
background: var(--blue-dark);
|
||||
}
|
||||
|
||||
.flow-num.green { background: var(--green); }
|
||||
.flow-num.orange { background: var(--orange); }
|
||||
.flow-num.purple { background: var(--purple); }
|
||||
|
||||
.flow-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.flow-desc {
|
||||
margin: 0 0 10px;
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 7px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tag.blue { color: #064aa2; border-color: #9ec3ff; background: #eef5ff; }
|
||||
.tag.yellow { color: #8a5a00; border-color: #ecc65d; background: #fff9e8; }
|
||||
.tag.green { color: #166534; border-color: #8fd1a4; background: #eefaf2; }
|
||||
.tag.purple { color: #3b2780; border-color: #c4b5fd; background: #f5f3ff; }
|
||||
|
||||
/* 금지 규칙 */
|
||||
.no-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.no-item {
|
||||
padding: 12px 14px;
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.no-item strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero {
|
||||
display: block;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: calc(100% - 28px);
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.rules-grid,
|
||||
.detail-grid,
|
||||
.flow,
|
||||
.no-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.flow-item:not(:last-child)::after {
|
||||
top: auto;
|
||||
right: 50%;
|
||||
bottom: -18px;
|
||||
transform: translateX(50%) rotate(90deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="hero">
|
||||
<div>
|
||||
<h1>프론트엔드 AI 활용 지침 체계</h1>
|
||||
<p>gameservice-fe-agent — rules/ 4개 파일 요약</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
|
||||
<!-- 목적 -->
|
||||
<section class="card">
|
||||
<h2 class="section-title">문서 목적</h2>
|
||||
<p class="purpose-title">
|
||||
Claude AI가 프론트엔드 개발 업무를 지원할 때,<br />
|
||||
어떤 지침을 언제 자동으로 참조하는지 정의합니다.
|
||||
</p>
|
||||
<p class="purpose-desc">
|
||||
<code>rules/</code> 폴더의 4개 파일은 <code>CLAUDE.md</code>에서 <code>@import</code>로 불러오며,
|
||||
모든 작업에 자동으로 적용됩니다. Vue 3 / Nuxt / TypeScript / Tailwind CSS 기반 프로젝트에서
|
||||
일관성 있는 코드 작성과 협업을 지원합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- rules 4개 개요 -->
|
||||
<section class="card">
|
||||
<h2 class="section-title">rules/ 4개 파일 개요</h2>
|
||||
|
||||
<div class="rules-grid">
|
||||
<article class="rule-box blue">
|
||||
<h3 class="box-title">coding-conventions.md</h3>
|
||||
<span class="box-label">코딩 컨벤션</span>
|
||||
<p class="box-subject">포맷팅 · 네이밍 · 타입</p>
|
||||
<ul class="dash-list">
|
||||
<li>스페이스 2칸, 싱글 쿼터, 세미콜론 필수</li>
|
||||
<li>camelCase / PascalCase / UPPER_SNAKE</li>
|
||||
<li>any 사용 금지, 함수 반환 타입 명시</li>
|
||||
<li>import 순서 (외부→절대→상대)</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="rule-box yellow">
|
||||
<h3 class="box-title">framework-rules.md</h3>
|
||||
<span class="box-label">프레임워크 규칙</span>
|
||||
<p class="box-subject">Vue 3 / Nuxt / Tailwind</p>
|
||||
<ul class="dash-list">
|
||||
<li><script setup lang="ts"> 기본</li>
|
||||
<li>Pinia (공유 상태), useFetch (서버 상태)</li>
|
||||
<li>Tailwind 유틸리티 우선, clsx 조건부 클래스</li>
|
||||
<li>라이브러리 도입 시 PR에 이유 기록</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="rule-box green">
|
||||
<h3 class="box-title">commit-pr.md</h3>
|
||||
<span class="box-label">커밋 / PR 규칙</span>
|
||||
<p class="box-subject">Conventional Commits + PR 템플릿</p>
|
||||
<ul class="dash-list">
|
||||
<li>type(scope): subject 형식</li>
|
||||
<li>subject 50자 이내, 명령형 현재 시제</li>
|
||||
<li>Squash and merge 기본 전략</li>
|
||||
<li>CI(Lint/Test/Build) + 1인 이상 승인</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="rule-box purple">
|
||||
<h3 class="box-title">claude-workflow.md</h3>
|
||||
<span class="box-label">Claude 작업 방식</span>
|
||||
<p class="box-subject">탐색 → 계획 → 구현 → 검증</p>
|
||||
<ul class="dash-list">
|
||||
<li>기존 코드 존중, 최소 변경 원칙</li>
|
||||
<li>모호한 요구사항은 추측 말고 질문</li>
|
||||
<li>임의 기능 추가·대량 리팩토링 금지</li>
|
||||
<li>결론 먼저, 변경 파일 요약</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 파일별 상세 -->
|
||||
<section class="card">
|
||||
<h2 class="section-title">파일별 핵심 규칙 상세</h2>
|
||||
|
||||
<div class="detail-grid">
|
||||
|
||||
<div class="detail-box">
|
||||
<h3 class="detail-title"><code>coding-conventions.md</code> 포맷팅 & 네이밍</h3>
|
||||
<ul class="kv-list">
|
||||
<li><span class="kv-key">들여쓰기</span><span class="kv-val">스페이스 2칸 (탭 금지)</span></li>
|
||||
<li><span class="kv-key">문자열</span><span class="kv-val">싱글 쿼터 <code>'</code>, JSX 속성은 더블 쿼터 <code>"</code></span></li>
|
||||
<li><span class="kv-key">세미콜론</span><span class="kv-val">항상 작성 (생략 금지)</span></li>
|
||||
<li><span class="kv-key">최대 길이</span><span class="kv-val">한 줄 100자, 초과 시 줄바꿈</span></li>
|
||||
<li><span class="kv-key">변수/함수</span><span class="kv-val">camelCase — userProfile, fetchUserData</span></li>
|
||||
<li><span class="kv-key">상수</span><span class="kv-val">UPPER_SNAKE_CASE — MAX_RETRY_COUNT</span></li>
|
||||
<li><span class="kv-key">컴포넌트</span><span class="kv-val">PascalCase.vue — UserCard.vue</span></li>
|
||||
<li><span class="kv-key">Composable</span><span class="kv-val">use 접두사 camelCase — useAuth.ts</span></li>
|
||||
<li><span class="kv-key">불리언</span><span class="kv-val">is / has / can / should 접두사</span></li>
|
||||
<li><span class="kv-key">any 사용</span><span class="kv-val">금지 — 불가피 시 주석 + unknown 우선 검토</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="detail-box">
|
||||
<h3 class="detail-title"><code>framework-rules.md</code> Vue 3 / Nuxt / Tailwind</h3>
|
||||
<ul class="kv-list">
|
||||
<li><span class="kv-key">컴포넌트</span><span class="kv-val"><script setup lang="ts"> 필수, Options API 금지</span></li>
|
||||
<li><span class="kv-key">컴포넌트 크기</span><span class="kv-val">200줄 초과 시 분리 검토</span></li>
|
||||
<li><span class="kv-key">Props</span><span class="kv-val">defineProps<T>() 제네릭으로 타입 명시</span></li>
|
||||
<li><span class="kv-key">Emits</span><span class="kv-val">defineEmits<{ ... }>() 제네릭으로 선언</span></li>
|
||||
<li><span class="kv-key">ref vs reactive</span><span class="kv-val">원시값 · 단일 객체는 ref 우선</span></li>
|
||||
<li><span class="kv-key">공유 상태</span><span class="kv-val">Pinia 사용</span></li>
|
||||
<li><span class="kv-key">서버 상태</span><span class="kv-val">useFetch / useAsyncData (직접 fetch 지양)</span></li>
|
||||
<li><span class="kv-key">라우팅</span><span class="kv-val">Nuxt 파일 기반 라우팅, [param].vue 동적 라우트</span></li>
|
||||
<li><span class="kv-key">Tailwind</span><span class="kv-val">유틸리티 우선, 조건부는 clsx / cn</span></li>
|
||||
<li><span class="kv-key">라이브러리</span><span class="kv-val">도입 시 PR에 이유·번들 영향·대안 기록</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="detail-box">
|
||||
<h3 class="detail-title"><code>commit-pr.md</code> Conventional Commits</h3>
|
||||
<ul class="kv-list">
|
||||
<li><span class="kv-key">형식</span><span class="kv-val">type(scope): subject</span></li>
|
||||
<li><span class="kv-key">subject</span><span class="kv-val">50자 이내, 명령형 현재 시제, 마침표 없음</span></li>
|
||||
<li><span class="kv-key">body</span><span class="kv-val">"왜"를 설명, 72자 줄바꿈</span></li>
|
||||
<li><span class="kv-key">머지 전략</span><span class="kv-val">Squash and merge 기본</span></li>
|
||||
<li><span class="kv-key">승인</span><span class="kv-val">최소 1인 이상 + CI 전부 통과</span></li>
|
||||
<li><span class="kv-key">Draft PR</span><span class="kv-val">중간 피드백 필요 시 Draft 먼저 오픈</span></li>
|
||||
</ul>
|
||||
<div class="commit-types">
|
||||
<span class="ct">feat</span>
|
||||
<span class="ct">fix</span>
|
||||
<span class="ct">refactor</span>
|
||||
<span class="ct">style</span>
|
||||
<span class="ct">docs</span>
|
||||
<span class="ct">test</span>
|
||||
<span class="ct">chore</span>
|
||||
<span class="ct">perf</span>
|
||||
<span class="ct">ci</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-box">
|
||||
<h3 class="detail-title"><code>claude-workflow.md</code> 작업 원칙</h3>
|
||||
<ul class="kv-list">
|
||||
<li><span class="kv-key">작업 순서</span><span class="kv-val">탐색 → 계획 → 구현 → 검증</span></li>
|
||||
<li><span class="kv-key">기본 원칙</span><span class="kv-val">기존 코드 존중 · 최소 변경 · 근거 있는 수정</span></li>
|
||||
<li><span class="kv-key">모호한 요구</span><span class="kv-val">추측 금지 → 사용자에게 확인</span></li>
|
||||
<li><span class="kv-key">커뮤니케이션</span><span class="kv-val">결론 먼저, 변경 파일 요약</span></li>
|
||||
<li><span class="kv-key">검증</span><span class="kv-val">린트 / 타입체크 / 빌드 통과 확인</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 금지 규칙 -->
|
||||
<section class="card">
|
||||
<h2 class="section-title">claude-workflow.md — 해서는 안 되는 것</h2>
|
||||
|
||||
<div class="no-list">
|
||||
<div class="no-item">
|
||||
<strong>임의 기능 추가 금지</strong>
|
||||
사용자가 요청하지 않은 기능을 추가하지 않습니다.
|
||||
</div>
|
||||
<div class="no-item">
|
||||
<strong>대량 리팩토링 금지</strong>
|
||||
요청 범위를 벗어나는 코드 변경은 하지 않습니다.
|
||||
</div>
|
||||
<div class="no-item">
|
||||
<strong>주석·문서 임의 삭제 금지</strong>
|
||||
불필요해 보여도 삭제 전 사용자에게 확인합니다.
|
||||
</div>
|
||||
<div class="no-item">
|
||||
<strong>비밀정보 하드코딩 금지</strong>
|
||||
환경변수, 키, 토큰을 코드에 직접 작성하지 않습니다.
|
||||
</div>
|
||||
<div class="no-item">
|
||||
<strong>의존성 버전 임의 변경 금지</strong>
|
||||
요청 없이 package.json 버전을 수정하지 않습니다.
|
||||
</div>
|
||||
<div class="no-item">
|
||||
<strong>강제 푸시·히스토리 재작성 금지</strong>
|
||||
push --force, reset --hard는 명시적 요청 없이 실행하지 않습니다.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 작업 흐름 -->
|
||||
<section class="card">
|
||||
<h2 class="section-title">Claude 작업 단계별 참조 흐름</h2>
|
||||
|
||||
<div class="flow">
|
||||
<article class="flow-item">
|
||||
<div class="flow-num">1</div>
|
||||
<h3 class="flow-title">탐색 (Explore)</h3>
|
||||
<p class="flow-desc">관련 파일 파악, 유사 패턴 확인</p>
|
||||
<div class="tag-list">
|
||||
<span class="tag blue">CLAUDE.md</span>
|
||||
<span class="tag yellow">coding-conventions</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flow-item">
|
||||
<div class="flow-num purple">2</div>
|
||||
<h3 class="flow-title">계획 (Plan)</h3>
|
||||
<p class="flow-desc">할 일 목록 공유, 아키텍처 변경 시 승인</p>
|
||||
<div class="tag-list">
|
||||
<span class="tag yellow">coding-conventions</span>
|
||||
<span class="tag yellow">framework-rules</span>
|
||||
<span class="tag purple">claude-workflow</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flow-item">
|
||||
<div class="flow-num orange">3</div>
|
||||
<h3 class="flow-title">구현 (Implement)</h3>
|
||||
<p class="flow-desc">코드 작성, 공통·프로젝트 지침 준수</p>
|
||||
<div class="tag-list">
|
||||
<span class="tag yellow">coding-conventions</span>
|
||||
<span class="tag yellow">framework-rules</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flow-item">
|
||||
<div class="flow-num green">4</div>
|
||||
<h3 class="flow-title">검증 (Verify)</h3>
|
||||
<p class="flow-desc">린트·타입체크·빌드, 커밋·PR 작성</p>
|
||||
<div class="tag-list">
|
||||
<span class="tag yellow">coding-conventions</span>
|
||||
<span class="tag green">commit-pr</span>
|
||||
<span class="tag purple">claude-workflow</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
1025
html/fe-ai-workflow-ppt.html
Normal file
1025
html/fe-ai-workflow-ppt.html
Normal file
File diff suppressed because it is too large
Load Diff
800
html/fe-ai-workflow.html
Normal file
800
html/fe-ai-workflow.html
Normal file
@@ -0,0 +1,800 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>프론트엔드 AI 공통 지침 - Work Flow</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f5f1;
|
||||
--panel: #ffffff;
|
||||
--line: #d7d7d7;
|
||||
--text: #222;
|
||||
--muted: #777;
|
||||
--navy: #25384a;
|
||||
|
||||
--blue: #1f7fbe;
|
||||
--blue-bg: #e8f5fb;
|
||||
--yellow: #e6a400;
|
||||
--yellow-bg: #fff9dc;
|
||||
--green: #17a968;
|
||||
--green-bg: #e6f7ee;
|
||||
--red: #d94132;
|
||||
--red-bg: #fde9e9;
|
||||
--orange: #e86f16;
|
||||
--purple: #8d42b2;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
"Noto Sans KR",
|
||||
"Apple SD Gothic Neo",
|
||||
sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-width: 1480px;
|
||||
padding: 8px 10px 18px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 255px 1fr;
|
||||
gap: 22px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.sidebar-head {
|
||||
padding: 14px 20px;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
background: var(--navy);
|
||||
}
|
||||
|
||||
.side-body {
|
||||
padding: 14px 14px 18px;
|
||||
}
|
||||
|
||||
.side-group {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px;
|
||||
border-radius: 7px;
|
||||
border: 2px solid;
|
||||
}
|
||||
|
||||
.side-group.yellow {
|
||||
border-color: #f2ad23;
|
||||
background: #fffdf4;
|
||||
}
|
||||
|
||||
.side-group.blue {
|
||||
border-color: #1f7fbe;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.side-title,
|
||||
.side-file {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
padding: 4px 8px;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
.yellow .side-title,
|
||||
.yellow .side-file {
|
||||
color: #7b5a00;
|
||||
border: 1px solid #f2ad23;
|
||||
background: #fff3bf;
|
||||
}
|
||||
|
||||
.blue .side-title,
|
||||
.blue .side-file {
|
||||
color: #0f4d77;
|
||||
border: 1px solid #1f7fbe;
|
||||
background: #d6ecf8;
|
||||
}
|
||||
|
||||
.side-caption {
|
||||
margin: 8px 0 0;
|
||||
color: #8a8a8a;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.side-desc {
|
||||
margin: 6px 0 0;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* Main */
|
||||
.main {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.title-wrap {
|
||||
padding: 10px 0 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 10px 0 0;
|
||||
color: #767676;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.workflow-panel {
|
||||
position: relative;
|
||||
min-height: 780px;
|
||||
padding: 236px 42px 36px;
|
||||
background: var(--panel);
|
||||
border: 1px solid #cfcfcf;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.flow-row {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 60px 280px 60px 280px 60px 280px 60px 280px 60px 280px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: #444;
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node {
|
||||
height: 135px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 4px solid;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.node .title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.node .desc {
|
||||
color: #555;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.node.blue {
|
||||
color: #0f4d77;
|
||||
border-color: var(--blue);
|
||||
background: var(--blue-bg);
|
||||
}
|
||||
|
||||
.node.yellow {
|
||||
color: #8a6500;
|
||||
border-color: var(--yellow);
|
||||
background: var(--yellow-bg);
|
||||
}
|
||||
|
||||
.node.green {
|
||||
color: #09643d;
|
||||
border-color: var(--green);
|
||||
background: var(--green-bg);
|
||||
}
|
||||
|
||||
.node.red {
|
||||
color: #9f2820;
|
||||
border-color: var(--red);
|
||||
background: var(--red-bg);
|
||||
}
|
||||
|
||||
.node-num {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
margin-right: 6px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* absolute guide lines */
|
||||
.rules-bar {
|
||||
position: absolute;
|
||||
left: 518px;
|
||||
right: 43px;
|
||||
top: 392px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #9a6500;
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
background: #fffef7;
|
||||
border: 2px dashed #f0a000;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.v-line {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
border-left: 2px dashed #b7b7b7;
|
||||
}
|
||||
|
||||
.v-line.yellow {
|
||||
border-color: #d49a00;
|
||||
}
|
||||
|
||||
.v-line.green {
|
||||
border-color: #15975f;
|
||||
}
|
||||
|
||||
.v-line.red {
|
||||
border-color: #c0392b;
|
||||
}
|
||||
|
||||
.v1 {
|
||||
left: 645px;
|
||||
top: 360px;
|
||||
height: 33px;
|
||||
}
|
||||
|
||||
.v2 {
|
||||
left: 989px;
|
||||
top: 355px;
|
||||
height: 77px;
|
||||
}
|
||||
|
||||
.v3 {
|
||||
left: 1344px;
|
||||
top: 356px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.v4 {
|
||||
left: 1696px;
|
||||
top: 356px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.v5 {
|
||||
left: 2041px;
|
||||
top: 356px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.doc-row {
|
||||
position: absolute;
|
||||
left: 520px;
|
||||
right: 42px;
|
||||
top: 448px;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 60px 280px 60px 280px 60px 280px 60px 280px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.doc-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.doc-chip {
|
||||
min-height: 36px;
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
border: 2px solid;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.doc-chip.yellow {
|
||||
color: #8a6500;
|
||||
border-color: var(--yellow);
|
||||
background: var(--yellow-bg);
|
||||
font-family: inherit;
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.doc-chip.green {
|
||||
color: #09643d;
|
||||
border-color: var(--green);
|
||||
background: var(--green-bg);
|
||||
}
|
||||
|
||||
.doc-chip.red {
|
||||
color: #9f2820;
|
||||
border-color: var(--red);
|
||||
background: var(--red-bg);
|
||||
}
|
||||
|
||||
.doc-chip.blue {
|
||||
color: #0f4d77;
|
||||
border-color: var(--blue);
|
||||
background: var(--blue-bg);
|
||||
}
|
||||
|
||||
/* feedback loop */
|
||||
.loop-label {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
padding: 7px 22px;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
border: 2px solid;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.label-purple {
|
||||
top: 30px;
|
||||
left: 1180px;
|
||||
color: var(--purple);
|
||||
border-color: var(--purple);
|
||||
background: #fff5ff;
|
||||
}
|
||||
|
||||
.label-orange {
|
||||
top: 109px;
|
||||
left: 990px;
|
||||
color: var(--orange);
|
||||
border-color: var(--orange);
|
||||
background: #fff8f2;
|
||||
}
|
||||
|
||||
.label-red {
|
||||
top: 86px;
|
||||
left: 1383px;
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.loop-line {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loop-purple {
|
||||
top: 54px;
|
||||
left: 944px;
|
||||
width: 705px;
|
||||
height: 150px;
|
||||
border: 4px dashed var(--purple);
|
||||
border-bottom: 0;
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
.loop-purple:before,
|
||||
.loop-purple:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -18px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 13px solid transparent;
|
||||
border-right: 13px solid transparent;
|
||||
border-top: 30px solid var(--purple);
|
||||
}
|
||||
|
||||
.loop-purple:before {
|
||||
left: -16px;
|
||||
}
|
||||
|
||||
.loop-purple:after {
|
||||
right: -16px;
|
||||
}
|
||||
|
||||
.loop-orange {
|
||||
top: 137px;
|
||||
left: 944px;
|
||||
width: 350px;
|
||||
height: 72px;
|
||||
border-top: 4px dashed var(--orange);
|
||||
border-left: 4px dashed var(--orange);
|
||||
border-right: 4px dashed var(--orange);
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.loop-orange:before,
|
||||
.loop-orange:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -18px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 13px solid transparent;
|
||||
border-right: 13px solid transparent;
|
||||
border-top: 30px solid var(--orange);
|
||||
}
|
||||
|
||||
.loop-orange:before {
|
||||
left: -16px;
|
||||
}
|
||||
|
||||
.loop-orange:after {
|
||||
right: -16px;
|
||||
}
|
||||
|
||||
.loop-red {
|
||||
top: 137px;
|
||||
left: 1292px;
|
||||
width: 355px;
|
||||
height: 72px;
|
||||
border-top: 4px dashed #ff4b3d;
|
||||
border-left: 4px dashed #ff4b3d;
|
||||
border-right: 4px dashed #ff4b3d;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.loop-red:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -18px;
|
||||
left: -16px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 13px solid transparent;
|
||||
border-right: 13px solid transparent;
|
||||
border-top: 30px solid #ff4b3d;
|
||||
}
|
||||
|
||||
.feedback-box {
|
||||
position: absolute;
|
||||
left: 42px;
|
||||
right: 42px;
|
||||
bottom: 28px;
|
||||
padding: 16px 36px 28px;
|
||||
border: 2px solid #d1d1d1;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.feedback-box h2 {
|
||||
margin: 0 0 22px;
|
||||
text-align: center;
|
||||
font-size: 23px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.legend-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.line-sample {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 85px;
|
||||
border-top: 4px dashed;
|
||||
}
|
||||
|
||||
.line-sample:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
top: -10px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 18px solid currentColor;
|
||||
border-top: 9px solid transparent;
|
||||
border-bottom: 9px solid transparent;
|
||||
}
|
||||
|
||||
.line-sample.red {
|
||||
color: #ff4b3d;
|
||||
border-color: #ff4b3d;
|
||||
}
|
||||
|
||||
.line-sample.orange {
|
||||
color: var(--orange);
|
||||
border-color: var(--orange);
|
||||
}
|
||||
|
||||
.line-sample.purple {
|
||||
color: var(--purple);
|
||||
border-color: var(--purple);
|
||||
}
|
||||
|
||||
.bottom-legend {
|
||||
margin-top: 14px;
|
||||
padding: 12px 18px;
|
||||
background: #fff;
|
||||
border: 1px solid #d4d4d4;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bottom-legend strong {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.mini-box {
|
||||
display: inline-block;
|
||||
width: 25px;
|
||||
height: 12px;
|
||||
margin: 0 5px 0 12px;
|
||||
border: 2px solid;
|
||||
border-radius: 2px;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
.mini-box.yellow {
|
||||
border-color: var(--yellow);
|
||||
background: var(--yellow-bg);
|
||||
}
|
||||
|
||||
.mini-box.green {
|
||||
border-color: var(--green);
|
||||
background: var(--green-bg);
|
||||
}
|
||||
|
||||
.mini-box.red {
|
||||
border-color: var(--red);
|
||||
background: var(--red-bg);
|
||||
}
|
||||
|
||||
.mini-box.blue {
|
||||
border-color: var(--blue);
|
||||
background: var(--blue-bg);
|
||||
}
|
||||
|
||||
.mini-arrow {
|
||||
display: inline-block;
|
||||
margin: 0 10px 0 20px;
|
||||
font-size: 22px;
|
||||
line-height: 0;
|
||||
vertical-align: -3px;
|
||||
}
|
||||
|
||||
.mini-arrow.red {
|
||||
color: #ff4b3d;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 14px;
|
||||
padding: 11px 18px;
|
||||
color: #777;
|
||||
text-align: center;
|
||||
background: #f1f1ee;
|
||||
border: 1px solid #d6d6d2;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1600px) {
|
||||
.page {
|
||||
transform-origin: top left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-head">📁 gameservice-fe-agent 구조</div>
|
||||
|
||||
<div class="side-body">
|
||||
<div class="side-group yellow">
|
||||
<span class="side-file">CLAUDE.md</span>
|
||||
<p class="side-caption">세션 시작 시 1회 자동 로드<br />rules/* 전체를 @import</p>
|
||||
</div>
|
||||
|
||||
<div class="side-group yellow">
|
||||
<span class="side-title">rules/ (매 대화 턴 자동)</span>
|
||||
<span class="side-file">coding-conventions.md</span>
|
||||
<span class="side-file">framework-rules.md</span>
|
||||
<span class="side-file">commit-pr.md</span>
|
||||
<span class="side-file">claude-workflow.md</span>
|
||||
<p class="side-caption">포맷팅·네이밍·Vue·커밋·작업방식</p>
|
||||
</div>
|
||||
|
||||
<div class="side-group blue">
|
||||
<span class="side-title">skills/ (명령으로 호출)</span>
|
||||
<span class="side-file">conventional-commit</span>
|
||||
<span class="side-file">markup-edm</span>
|
||||
<span class="side-file">plan-analyzer</span>
|
||||
<span class="side-file">plan-translation-generator</span>
|
||||
<span class="side-file">project-init</span>
|
||||
<span class="side-file">verify-component-review</span>
|
||||
<span class="side-file">work-log</span>
|
||||
<p class="side-caption">link-skills.sh 로 .claude/skills/ 링크</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="title-wrap">
|
||||
<h1>프론트엔드 AI 공통 지침 - Work Flow</h1>
|
||||
<p class="subtitle">gameservice-fe-agent rules/ 구조와 각 작업 단계별 참조 파일의 흐름 (피드백 루프 포함)</p>
|
||||
</header>
|
||||
|
||||
<section class="workflow-panel">
|
||||
<div class="loop-line loop-purple"></div>
|
||||
<div class="loop-line loop-orange"></div>
|
||||
<div class="loop-line loop-red"></div>
|
||||
|
||||
<div class="loop-label label-purple">설계 변경 필요</div>
|
||||
<div class="loop-label label-orange">계획 재수립 필요</div>
|
||||
<div class="loop-label label-red">버그 발견 · 수정</div>
|
||||
|
||||
<div class="flow-row">
|
||||
<article class="node blue">
|
||||
<div class="title">요청자 / 개발자</div>
|
||||
<div class="desc">업무 요청</div>
|
||||
</article>
|
||||
|
||||
<div class="arrow">▶</div>
|
||||
|
||||
<article class="node yellow">
|
||||
<div class="title"><span class="node-num">1</span>자동 로드</div>
|
||||
<div class="desc">CLAUDE.md + rules/* × 4</div>
|
||||
</article>
|
||||
|
||||
<div class="arrow">▶</div>
|
||||
|
||||
<article class="node green">
|
||||
<div class="title"><span class="node-num">2</span>탐색</div>
|
||||
<div class="desc">파일 파악 · 패턴 확인</div>
|
||||
</article>
|
||||
|
||||
<div class="arrow">▶</div>
|
||||
|
||||
<article class="node green">
|
||||
<div class="title"><span class="node-num">3</span>계획</div>
|
||||
<div class="desc">할 일 목록 · 승인</div>
|
||||
</article>
|
||||
|
||||
<div class="arrow">▶</div>
|
||||
|
||||
<article class="node green">
|
||||
<div class="title"><span class="node-num">4</span>구현</div>
|
||||
<div class="desc">코드 작성 · 지침 준수</div>
|
||||
</article>
|
||||
|
||||
<div class="arrow">▶</div>
|
||||
|
||||
<article class="node red">
|
||||
<div class="title"><span class="node-num">5</span>검증</div>
|
||||
<div class="desc">린트 · 빌드 · 커밋/PR</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="v-line yellow v1"></div>
|
||||
<div class="v-line green v2"></div>
|
||||
<div class="v-line green v3"></div>
|
||||
<div class="v-line green v4"></div>
|
||||
<div class="v-line red v5"></div>
|
||||
|
||||
<div class="rules-bar">rules/* × 4 — 매 대화 턴 자동 참조 (① ~ ⑤ 전 단계 공통)</div>
|
||||
|
||||
<div class="doc-row">
|
||||
<div class="doc-col">
|
||||
<div class="doc-chip yellow">CLAUDE.md (세션 시작 1회)</div>
|
||||
</div>
|
||||
<div></div>
|
||||
|
||||
<div class="doc-col">
|
||||
<div class="doc-chip green">coding-conventions.md</div>
|
||||
<div class="doc-chip green">framework-rules.md</div>
|
||||
</div>
|
||||
<div></div>
|
||||
|
||||
<div class="doc-col">
|
||||
<div class="doc-chip green">claude-workflow.md</div>
|
||||
<div class="doc-chip green">framework-rules.md</div>
|
||||
</div>
|
||||
<div></div>
|
||||
|
||||
<div class="doc-col">
|
||||
<div class="doc-chip green">coding-conventions.md</div>
|
||||
<div class="doc-chip green">framework-rules.md</div>
|
||||
</div>
|
||||
<div></div>
|
||||
|
||||
<div class="doc-col">
|
||||
<div class="doc-chip red">coding-conventions.md</div>
|
||||
<div class="doc-chip red">commit-pr.md</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feedback-box">
|
||||
<h2>피드백 루프 (역방향 흐름)</h2>
|
||||
|
||||
<div class="legend-grid">
|
||||
<div class="legend-item">
|
||||
<span class="line-sample red"></span>
|
||||
<span>④ 구현 → ⑤ 검증 → ④ 구현 : 버그 발견 시 수정 후 재검증</span>
|
||||
</div>
|
||||
|
||||
<div class="legend-item">
|
||||
<span class="line-sample orange"></span>
|
||||
<span>③ 계획 → ④ 구현 중 재설계 : 계획 재수립 후 다시 구현</span>
|
||||
</div>
|
||||
|
||||
<div class="legend-item">
|
||||
<span class="line-sample purple"></span>
|
||||
<span>⑤ 검증 → ② 탐색 : 검증 결과로 구조 변경이 필요한 경우</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="bottom-legend">
|
||||
<strong>범례</strong>
|
||||
<span class="mini-box yellow"></span> 세션 시작 1회 자동 로드 (CLAUDE.md)
|
||||
<span class="mini-box yellow"></span> 매 대화 턴 자동 참조 (rules/ × 4)
|
||||
<span class="mini-box green"></span> 탐색·계획·구현 단계 참조
|
||||
<span class="mini-box red"></span> 검증·커밋/PR 단계 참조
|
||||
<span class="mini-box blue"></span> skills/ (명령으로 호출)
|
||||
<span class="mini-arrow">▶</span> 정방향 진행
|
||||
<span class="mini-arrow red">--▶</span> 피드백 루프 (역방향)
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
피드백 루프는 구현·검증 중 발견된 이슈에 따라 이전 단계로 돌아가 재작업 후 다시 진행합니다.
|
||||
모호한 요구사항은 추측하지 않고 사용자에게 확인 후 계획 단계부터 다시 시작합니다.
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -43,22 +43,7 @@ else
|
||||
echo "✅ submodule 추가 완료: $TARGET_PATH"
|
||||
fi
|
||||
|
||||
# 2) .gitignore 에 .claude/common 추가
|
||||
GITIGNORE=".gitignore"
|
||||
IGNORE_ENTRY=".claude/common/"
|
||||
if [[ -f "$GITIGNORE" ]]; then
|
||||
if grep -qF "$IGNORE_ENTRY" "$GITIGNORE" || grep -qF ".claude/common" "$GITIGNORE"; then
|
||||
echo "ℹ️ .gitignore 에 '$IGNORE_ENTRY' 가 이미 있습니다. 건너뜁니다."
|
||||
else
|
||||
printf "\n# gameservice-fe-agent submodule\n%s\n" "$IGNORE_ENTRY" >> "$GITIGNORE"
|
||||
echo "✅ .gitignore 에 '$IGNORE_ENTRY' 를 추가했습니다."
|
||||
fi
|
||||
else
|
||||
printf "# gameservice-fe-agent submodule\n%s\n" "$IGNORE_ENTRY" > "$GITIGNORE"
|
||||
echo "✅ .gitignore 를 생성하고 '$IGNORE_ENTRY' 를 추가했습니다."
|
||||
fi
|
||||
|
||||
# 4) 프로젝트 지침 양식 복사 (templates/project/ → .claude/project/)
|
||||
# 2) 프로젝트 지침 양식 복사 (templates/project/ → .claude/project/)
|
||||
mkdir -p "$PROJECT_PATH"
|
||||
|
||||
TEMPLATE_DIR="$TARGET_PATH/templates/project"
|
||||
@@ -79,7 +64,7 @@ else
|
||||
echo "⚠️ $TEMPLATE_DIR 를 찾지 못했습니다. 공통 저장소의 templates 가 오래됐을 수 있습니다."
|
||||
fi
|
||||
|
||||
# 5) 루트 CLAUDE.md 템플릿 복사
|
||||
# 3) 루트 CLAUDE.md 템플릿 복사
|
||||
if [[ ! -f "CLAUDE.md" ]]; then
|
||||
TPL_FILE="$TARGET_PATH/templates/CLAUDE.md.tpl"
|
||||
if [[ -f "$TPL_FILE" ]]; then
|
||||
@@ -96,6 +81,77 @@ if [[ ! -f "CLAUDE.md" ]]; then
|
||||
@.claude/project/overview.md
|
||||
@.claude/project/conventions.md
|
||||
@.claude/project/architecture.md
|
||||
|
||||
## 슬래시 커맨드 연결
|
||||
|
||||
- `/init` 커맨드가 실행되면 반드시 `project-init` 스킬을 호출하세요.
|
||||
|
||||
## Behavioral Guidelines
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
> Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
### 1. Think Before Coding
|
||||
|
||||
Don't assume. Don't hide confusion. Surface tradeoffs.
|
||||
|
||||
Before implementing:
|
||||
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them — don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
### 2. Simplicity First
|
||||
|
||||
Minimum code that solves the problem. Nothing speculative.
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
### 3. Surgical Changes
|
||||
|
||||
Touch only what you must. Clean up only your own mess.
|
||||
|
||||
When editing existing code:
|
||||
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it — don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
### 4. Goal-Driven Execution
|
||||
|
||||
Define success criteria. Loop until verified.
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
These guidelines are working if: fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
EOF
|
||||
echo "✅ CLAUDE.md 템플릿을 생성했습니다. (fallback)"
|
||||
fi
|
||||
@@ -110,9 +166,12 @@ else
|
||||
echo " @.claude/project/conventions.md"
|
||||
echo " @.claude/project/architecture.md"
|
||||
echo ""
|
||||
echo " ## Behavioral Guidelines"
|
||||
echo " (자세한 내용은 $TARGET_PATH/templates/CLAUDE.md.tpl 참고)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 6) 공통 skill 심볼릭 링크 (.claude/common/skills/* → .claude/skills/*)
|
||||
# 4) 공통 skill 심볼릭 링크 (.claude/common/skills/* → .claude/skills/*)
|
||||
SKILLS_SRC="$TARGET_PATH/skills"
|
||||
SKILLS_DEST=".claude/skills"
|
||||
|
||||
@@ -146,5 +205,5 @@ echo ""
|
||||
echo "다음 작업을 진행해 주세요:"
|
||||
echo " 1) $PROJECT_PATH/*.md 내용을 프로젝트에 맞게 채우기"
|
||||
echo " 2) 변경 사항을 커밋하기"
|
||||
echo " git add .gitmodules .gitignore .claude CLAUDE.md"
|
||||
echo " git add .gitmodules .claude CLAUDE.md"
|
||||
echo " git commit -m 'chore: add gameservice-fe-agent submodule'"
|
||||
|
||||
576
session-report-20260512-2348.html
Normal file
576
session-report-20260512-2348.html
Normal file
File diff suppressed because one or more lines are too long
575
session-report-20260516-1041.html
Normal file
575
session-report-20260516-1041.html
Normal file
File diff suppressed because one or more lines are too long
1978
session-report-20260516-1137.html
Normal file
1978
session-report-20260516-1137.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
skills/.DS_Store
vendored
Normal file
BIN
skills/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: conventional-commit
|
||||
description: Git 변경사항을 팀의 Conventional Commits 규칙(gameservice-fe-agent/rules/commit-pr.md)에 맞춰 커밋 메시지로 작성할 때 사용합니다. 사용자가 "커밋 메시지 만들어줘", "commit", "커밋해줘" 등을 요청하면 트리거됩니다.
|
||||
description: Git 변경사항을 팀의 Conventional Commits 규칙에 맞춰 커밋 메시지로 작성할 때 사용합니다. 사용자가 "커밋 메시지 만들어줘", "commit", "커밋해줘" 등을 요청하면 트리거됩니다.
|
||||
---
|
||||
|
||||
# Conventional Commit 작성
|
||||
|
||||
150
skills/dev-api-state/SKILL.md
Normal file
150
skills/dev-api-state/SKILL.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: dev-api-state
|
||||
description: |
|
||||
API 연동 패턴(useFetch / useAsyncData / $fetch)과 Pinia 상태관리 코드를
|
||||
상황에 맞게 자동 선택·생성합니다. BFF 패턴, 에러 핸들링, 로딩 상태를 포함합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 API 연동 + 스토어 만들어줘", "Pinia store 작성해줘"
|
||||
- "API 데이터 페칭 어떻게 해?", "useFetch vs useAsyncData 언제 써?"
|
||||
- API 키 보호를 위한 BFF(server/api/) 패턴이 필요할 때
|
||||
---
|
||||
|
||||
# API 연동 & 상태관리 (dev-api-state)
|
||||
|
||||
API 스펙 → useFetch/useAsyncData 패턴 선택 → Pinia Setup Store 코드 자동 생성.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 새 API 연동 코드와 Pinia 스토어를 함께 작성할 때
|
||||
- `useFetch` / `$fetch` / `useAsyncData` 중 적절한 패턴을 결정할 때
|
||||
- API 키를 클라이언트에 노출하지 않기 위한 BFF 패턴이 필요할 때
|
||||
|
||||
## 데이터 페칭 패턴 선택 기준
|
||||
|
||||
| 상황 | 권장 패턴 |
|
||||
|---|---|
|
||||
| SSR 페이지 초기 데이터 | `useAsyncData` + `$fetch` |
|
||||
| 컴포넌트 마운트 후 데이터 | `useFetch` |
|
||||
| 사용자 액션으로 트리거 | `$fetch` (직접 호출) |
|
||||
| API 키 보호 필요 | `server/api/` BFF + `useFetch` |
|
||||
| 복잡한 캐싱/의존성 | `useAsyncData` with key |
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: API 스펙 파악
|
||||
|
||||
1. 엔드포인트, 요청/응답 타입을 파악한다.
|
||||
2. 아래를 확인한다:
|
||||
- 인증 헤더 필요 여부
|
||||
- API 키 노출 위험 여부 → BFF 패턴 적용
|
||||
- 캐시 전략 (항상 최신 vs TTL)
|
||||
|
||||
### Phase 2: 타입 정의
|
||||
|
||||
```ts
|
||||
// types/product.ts
|
||||
export interface Product {
|
||||
id: string
|
||||
name: string
|
||||
price: number
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
export interface ProductListResponse {
|
||||
data: Product[]
|
||||
total: number
|
||||
page: number
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: BFF 레이어 (필요 시)
|
||||
|
||||
API 키 보호가 필요한 경우 server/api/ 경유:
|
||||
|
||||
```ts
|
||||
// server/api/products/index.get.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const response = await $fetch<ProductListResponse>(
|
||||
`${process.env.API_BASE}/products`,
|
||||
{
|
||||
headers: { 'x-api-key': process.env.API_KEY! },
|
||||
query,
|
||||
}
|
||||
)
|
||||
return response
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 4: Pinia Setup Store 생성
|
||||
|
||||
Setup Store 패턴을 기본으로 사용한다:
|
||||
|
||||
```ts
|
||||
// stores/product.ts
|
||||
import type { Product } from '~/types/product'
|
||||
|
||||
export const useProductStore = defineStore('product', () => {
|
||||
const items = ref<Product[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const fetchProducts = async (page = 1) => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const { data } = await useFetch('/api/products', { query: { page } })
|
||||
items.value = data.value?.data ?? []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '알 수 없는 오류'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { items, isLoading, error, fetchProducts }
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 5: 컴포넌트 연결 예시
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const productStore = useProductStore()
|
||||
const { items, isLoading, error } = storeToRefs(productStore)
|
||||
|
||||
onMounted(() => productStore.fetchProducts())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isLoading" aria-live="polite">로딩 중...</div>
|
||||
<div v-else-if="error" role="alert">{{ error }}</div>
|
||||
<ul v-else>
|
||||
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## API 연동: <기능명>
|
||||
|
||||
### 선택된 패턴
|
||||
- 페칭: useFetch | useAsyncData | $fetch
|
||||
- BFF: 사용 | 미사용
|
||||
- 이유: [선택 근거]
|
||||
|
||||
### 파일 목록
|
||||
- `types/<domain>.ts`
|
||||
- `server/api/...` (BFF 사용 시)
|
||||
- `stores/<domain>.ts`
|
||||
|
||||
### 코드
|
||||
[타입 → BFF → 스토어 → 컴포넌트 순]
|
||||
```
|
||||
125
skills/dev-component/SKILL.md
Normal file
125
skills/dev-component/SKILL.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: dev-component
|
||||
description: |
|
||||
화면 명세를 받아 Atomic Design 기반 컴포넌트 트리를 설계하고
|
||||
Nuxt 표준 디렉토리 구조와 컴포넌트 스켈레톤 코드를 자동 생성합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 화면 컴포넌트로 분리해줘", "컴포넌트 구조 설계해줘"
|
||||
- "Atomic Design으로 나눠줘", "컴포넌트 트리 만들어줘"
|
||||
- 신규 기능의 컴포넌트 아키텍처를 결정해야 할 때
|
||||
---
|
||||
|
||||
# 컴포넌트 아키텍처 설계 (dev-component)
|
||||
|
||||
화면 명세 → Atomic Design 기반 컴포넌트 트리 → Nuxt SFC 스켈레톤 자동 생성.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 신규 페이지/기능의 컴포넌트 구조를 잡을 때
|
||||
- 마크업 완료 후 컴포넌트 분리 기준을 결정할 때
|
||||
- 팀원이 병렬로 작업할 수 있도록 역할 분리가 필요할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- 화면 명세 또는 완성된 마크업 코드
|
||||
- `plan-analyzer` 산출물 (컴포넌트 트리 초안)이 있으면 활용
|
||||
- 기존 `components/` 디렉토리 구조 (재사용 가능 컴포넌트 파악용)
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 기존 컴포넌트 파악
|
||||
|
||||
1. 프로젝트 `components/` 디렉토리를 읽어 재사용 가능한 컴포넌트를 파악한다.
|
||||
2. 설계 전 중복 개발 방지를 위해 유사 컴포넌트 목록을 사용자에게 안내한다.
|
||||
|
||||
### Phase 2: Atomic Design 분류
|
||||
|
||||
각 UI 요소를 아래 5단계로 분류한다:
|
||||
|
||||
| 레벨 | 위치 | 기준 |
|
||||
|---|---|---|
|
||||
| **Atoms** | `components/base/` | 더 이상 분리 불가한 기본 단위 (Button, Icon, Input, Badge) |
|
||||
| **Molecules** | `components/common/` | Atoms 2개 이상 조합 (SearchBar, FormField, Card) |
|
||||
| **Organisms** | `components/<도메인>/` | 독립적 기능 단위 (Header, ProductList, ReviewSection) |
|
||||
| **Templates** | `layouts/` | 페이지 레이아웃 골격 |
|
||||
| **Pages** | `pages/` | 실제 데이터와 결합한 최종 화면 |
|
||||
|
||||
### Phase 3: 컴포넌트 트리 출력
|
||||
|
||||
```
|
||||
pages/
|
||||
└── <page-name>.vue
|
||||
├── layouts/<layout>.vue
|
||||
└── components/
|
||||
├── <domain>/
|
||||
│ ├── <Organism>.vue ← props: { ... }
|
||||
│ └── <Organism>.vue
|
||||
└── common/
|
||||
└── <Molecule>.vue ← props: { ... }, emits: [...]
|
||||
```
|
||||
|
||||
### Phase 4: Props / Emits 설계
|
||||
|
||||
각 컴포넌트의 인터페이스를 정의한다:
|
||||
|
||||
```ts
|
||||
// 예시: ProductCard.vue
|
||||
interface Props {
|
||||
id: string
|
||||
title: string
|
||||
imageUrl: string
|
||||
price: number
|
||||
isSoldOut?: boolean
|
||||
}
|
||||
|
||||
type Emits = {
|
||||
click: [id: string]
|
||||
addToCart: [id: string]
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: 스켈레톤 코드 생성
|
||||
|
||||
각 컴포넌트의 SFC 스켈레톤 파일을 생성한다:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
// Phase 4 정의 붙여넣기
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
// Phase 4 정의 붙여넣기
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- TODO: markup-base / markup-figma 스킬로 마크업 구현 -->
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 컴포넌트 설계: <페이지명>
|
||||
|
||||
### 재사용 가능한 기존 컴포넌트
|
||||
- `components/common/Button.vue` — CTA 버튼에 활용 가능
|
||||
|
||||
### 컴포넌트 트리
|
||||
[ASCII 트리]
|
||||
|
||||
### 신규 생성 파일 목록
|
||||
- `components/product/ProductCard.vue` — Props: id, title, imageUrl, price
|
||||
- `components/product/ProductList.vue` — Props: items[]
|
||||
- ...
|
||||
|
||||
### 스켈레톤 코드
|
||||
[각 파일 코드]
|
||||
```
|
||||
128
skills/dev-docs/SKILL.md
Normal file
128
skills/dev-docs/SKILL.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
name: dev-docs
|
||||
description: |
|
||||
Nuxt 3 공식 문서 기반의 Best Practice 코드를 생성합니다.
|
||||
server/, middleware/, plugins/, composables/, components/, nuxt.config 등
|
||||
6개 참조 영역에서 상황에 맞는 패턴을 선택해 즉시 사용 가능한 코드를 제공합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "Nuxt server route 만들어줘", "composable 작성해줘"
|
||||
- "middleware 어떻게 써?", "Nuxt에서 이 기능 어떻게 구현해?"
|
||||
- 공식 문서를 찾아보지 않고 Nuxt 3 패턴 코드가 필요할 때
|
||||
---
|
||||
|
||||
# Nuxt 3 공식 문서 기반 개발 (dev-docs)
|
||||
|
||||
Nuxt 3 공식 Best Practice를 기반으로 즉시 사용 가능한 코드를 생성합니다.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- Nuxt 고유 API(useAsyncData, useFetch, defineEventHandler 등)를 처음 사용할 때
|
||||
- server/, middleware/, plugins/ 등 Nuxt 레이어 구현이 필요할 때
|
||||
- 공식 문서를 찾는 대신 곧바로 Best Practice 코드를 얻고 싶을 때
|
||||
|
||||
## 참조 영역
|
||||
|
||||
| 영역 | 경로 | 주요 API |
|
||||
|---|---|---|
|
||||
| Server Routes | `server/api/`, `server/routes/` | `defineEventHandler`, `readBody`, `getQuery` |
|
||||
| Middleware | `middleware/` | `defineNuxtRouteMiddleware`, `navigateTo` |
|
||||
| Plugins | `plugins/` | `defineNuxtPlugin`, `useNuxtApp` |
|
||||
| Composables | `composables/` | `useState`, `useFetch`, `useAsyncData` |
|
||||
| Components | `components/` | auto-import, `<ClientOnly>`, `<LazyLoad>` |
|
||||
| Config | `nuxt.config.ts` | `runtimeConfig`, `modules`, `routeRules` |
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 요구사항 분석
|
||||
|
||||
1. 사용자 요청에서 구현 영역을 파악한다.
|
||||
- "API 만들어줘" → `server/api/`
|
||||
- "인증 체크" → `middleware/`
|
||||
- "전역 상태" → `composables/` + `useState`
|
||||
- "외부 라이브러리 초기화" → `plugins/`
|
||||
|
||||
2. 관련 Nuxt 3 API를 선택한다.
|
||||
|
||||
### Phase 2: 코드 생성
|
||||
|
||||
#### Server Route 예시
|
||||
```ts
|
||||
// server/api/users/[id].get.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
// 비즈니스 로직
|
||||
return { data }
|
||||
})
|
||||
```
|
||||
|
||||
#### Middleware 예시
|
||||
```ts
|
||||
// middleware/auth.ts
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const { isAuthenticated } = useAuth()
|
||||
if (!isAuthenticated.value) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### Composable 예시
|
||||
```ts
|
||||
// composables/useUser.ts
|
||||
export const useUser = () => {
|
||||
const user = useState<User | null>('user', () => null)
|
||||
|
||||
const fetchUser = async (id: string) => {
|
||||
const { data } = await useFetch<User>(`/api/users/${id}`)
|
||||
user.value = data.value
|
||||
}
|
||||
|
||||
return { user, fetchUser }
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: TypeScript 타입 보강
|
||||
|
||||
- 모든 함수 파라미터/반환값 타입 명시
|
||||
- 외부 API 응답은 `types/` 또는 해당 도메인의 `types.ts`에 정의
|
||||
- `runtimeConfig`는 `RuntimeConfig` 타입 확장으로 타입 안전성 확보
|
||||
|
||||
### Phase 4: 연관 설정 안내
|
||||
|
||||
생성된 코드에 필요한 `nuxt.config.ts` 변경사항이 있으면 함께 안내한다.
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts 필요 설정 예시
|
||||
export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
apiSecret: '', // 서버 전용
|
||||
public: {
|
||||
apiBase: '' // 클라이언트 노출 가능
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## Nuxt 구현: <기능명>
|
||||
|
||||
### 사용 패턴
|
||||
- 영역: server/api/ | middleware/ | composables/ | ...
|
||||
- 주요 API: defineEventHandler, useFetch, ...
|
||||
|
||||
### 코드
|
||||
[파일별 코드]
|
||||
|
||||
### nuxt.config.ts 변경 필요 사항
|
||||
[있는 경우만]
|
||||
|
||||
### 주의사항
|
||||
[SSR/CSR 차이, 캐시 주의 등]
|
||||
```
|
||||
149
skills/dev-storybook/SKILL.md
Normal file
149
skills/dev-storybook/SKILL.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
name: dev-storybook
|
||||
description: |
|
||||
Vue 3 컴포넌트를 받아 Storybook Story 파일과 Props/Emits/슬롯 사용 가이드를 자동 생성합니다.
|
||||
팀 컴포넌트 카탈로그와 재사용 진입 장벽을 낮추는 데 사용합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 컴포넌트 Story 만들어줘", "Storybook 파일 생성해줘"
|
||||
- "컴포넌트 사용 가이드 문서 만들어줘", "Props 문서 작성해줘"
|
||||
- 컴포넌트 카탈로그에 새 컴포넌트를 등록해야 할 때
|
||||
---
|
||||
|
||||
# Storybook Story 생성 (dev-storybook)
|
||||
|
||||
`.vue` 컴포넌트 → `.stories.ts` 파일 + 사용 가이드 자동 생성.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 컴포넌트 완성 후 Storybook에 등록할 때
|
||||
- 다른 팀원이 컴포넌트를 찾고 재사용할 수 있도록 문서화할 때
|
||||
- Props 변형(variant)을 시각적으로 확인해야 할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- 대상 `.vue` 파일 경로
|
||||
- Storybook 버전 (없으면 Storybook 8 + CSF 3 포맷 기본 적용)
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 컴포넌트 인터페이스 파악
|
||||
|
||||
1. `.vue` 파일을 읽어 아래 항목을 추출한다.
|
||||
- Props 목록 (타입, 기본값, 설명)
|
||||
- Emits 목록
|
||||
- 슬롯 목록 (default, named)
|
||||
- 주요 상태 변형 (size, variant, disabled, loading 등)
|
||||
|
||||
### Phase 2: Story 파일 생성 (CSF 3 포맷)
|
||||
|
||||
파일 위치: 컴포넌트와 동일 디렉토리 또는 `stories/`
|
||||
파일명: `<ComponentName>.stories.ts`
|
||||
|
||||
#### 기본 구조
|
||||
```ts
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import ComponentName from './ComponentName.vue'
|
||||
|
||||
const meta: Meta<typeof ComponentName> = {
|
||||
title: '<Category>/<ComponentName>',
|
||||
component: ComponentName,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
// Props 컨트롤 정의
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'ghost'],
|
||||
description: '버튼 스타일 변형',
|
||||
},
|
||||
onClick: { action: 'clicked' },
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof ComponentName>
|
||||
```
|
||||
|
||||
#### 필수 Story 목록
|
||||
```ts
|
||||
// 1. 기본 상태
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
// 기본값으로 렌더링
|
||||
},
|
||||
}
|
||||
|
||||
// 2. 각 변형(variant)별 Story
|
||||
export const Primary: Story = {
|
||||
args: { variant: 'primary', label: '확인' },
|
||||
}
|
||||
|
||||
// 3. 비활성 상태
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
}
|
||||
|
||||
// 4. 로딩 상태 (해당 시)
|
||||
export const Loading: Story = {
|
||||
args: { isLoading: true },
|
||||
}
|
||||
|
||||
// 5. 슬롯 사용 예시 (슬롯 있을 때)
|
||||
export const WithSlot: Story = {
|
||||
render: (args) => ({
|
||||
components: { ComponentName },
|
||||
setup() { return { args } },
|
||||
template: `<ComponentName v-bind="args">슬롯 내용</ComponentName>`,
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: 사용 가이드 주석
|
||||
|
||||
각 Story에 JSDoc 주석으로 사용 목적을 설명한다:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* 기본 상태. 가장 일반적인 사용 패턴.
|
||||
* @example
|
||||
* <ComponentName label="클릭" @click="handleClick" />
|
||||
*/
|
||||
export const Default: Story = { ... }
|
||||
```
|
||||
|
||||
### Phase 4: argTypes 컨트롤 매핑
|
||||
|
||||
| Props 타입 | Storybook 컨트롤 |
|
||||
|---|---|
|
||||
| `string` | `text` |
|
||||
| `number` | `number` |
|
||||
| `boolean` | `boolean` |
|
||||
| Union 타입 (`'a' \| 'b'`) | `select` |
|
||||
| Array | `object` |
|
||||
| Function (emit) | `action` |
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## Story: <ComponentName>.stories.ts
|
||||
|
||||
### Story 목록
|
||||
1. Default — 기본 상태
|
||||
2. Primary — 주요 variant
|
||||
3. Disabled — 비활성
|
||||
4. Loading — 로딩 (해당 시)
|
||||
|
||||
### 코드
|
||||
\`\`\`ts
|
||||
[전체 stories 파일]
|
||||
\`\`\`
|
||||
|
||||
### Storybook 실행
|
||||
\`\`\`bash
|
||||
npx storybook dev
|
||||
\`\`\`
|
||||
```
|
||||
160
skills/dev-unit-test/SKILL.md
Normal file
160
skills/dev-unit-test/SKILL.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
name: dev-unit-test
|
||||
description: |
|
||||
Vue 3 컴포넌트를 받아 Vitest + Vue Test Utils 기반의 단위 테스트를 자동 생성합니다.
|
||||
Props, Emits, 슬롯, 사용자 인터랙션, 비동기 동작을 모두 커버합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 컴포넌트 단위 테스트 작성해줘", "테스트 코드 만들어줘"
|
||||
- "Vitest 어떻게 써?", "Vue Test Utils 사용 방법"
|
||||
- 컴포넌트 완성 후 커버리지를 확보해야 할 때
|
||||
---
|
||||
|
||||
# 단위 테스트 생성 (dev-unit-test)
|
||||
|
||||
`.vue` 컴포넌트 → Vitest + Vue Test Utils 단위 테스트 자동 생성.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 컴포넌트 개발 완료 후 테스트 코드를 작성할 때
|
||||
- TDD 방식으로 테스트를 먼저 작성할 때
|
||||
- 프로젝트 테스트 커버리지 목표를 달성해야 할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- 테스트할 `.vue` 파일 경로
|
||||
- 테스트 케이스 범위 (없으면 자동 도출)
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 컴포넌트 분석
|
||||
|
||||
1. 대상 `.vue` 파일을 읽어 아래 항목을 파악한다.
|
||||
- Props 목록 (타입, 기본값, 필수 여부)
|
||||
- Emits 목록 (이벤트 이름, 페이로드 타입)
|
||||
- 슬롯 유무
|
||||
- 외부 의존성 (composables, store, $fetch)
|
||||
- 인터랙션 (버튼 클릭, 입력, 폼 제출)
|
||||
- 비동기 동작 (API 호출, 로딩 상태)
|
||||
- 조건부 렌더링 (`v-if`, `v-show`)
|
||||
|
||||
2. 테스트 케이스 목록을 도출한다.
|
||||
|
||||
### Phase 2: 테스트 설정
|
||||
|
||||
```ts
|
||||
// vitest.config.ts (없으면 생성 안내)
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 3: 테스트 파일 생성
|
||||
|
||||
파일 위치: 컴포넌트와 동일 디렉토리 또는 `__tests__/` 폴더.
|
||||
파일명: `<ComponentName>.spec.ts`
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ComponentName from './ComponentName.vue'
|
||||
|
||||
describe('ComponentName', () => {
|
||||
// Phase 4 케이스 작성
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 4: 테스트 케이스 작성 패턴
|
||||
|
||||
#### Props 검증
|
||||
```ts
|
||||
it('title prop을 렌더링한다', () => {
|
||||
const wrapper = mount(ComponentName, {
|
||||
props: { title: '테스트 제목' }
|
||||
})
|
||||
expect(wrapper.find('h2').text()).toBe('테스트 제목')
|
||||
})
|
||||
|
||||
it('필수 prop 누락 시 기본값을 사용한다', () => {
|
||||
const wrapper = mount(ComponentName)
|
||||
expect(wrapper.find('[data-testid="label"]').text()).toBe('기본값')
|
||||
})
|
||||
```
|
||||
|
||||
#### Emits 검증
|
||||
```ts
|
||||
it('버튼 클릭 시 click 이벤트를 emit한다', async () => {
|
||||
const wrapper = mount(ComponentName, { props: { id: '1' } })
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.emitted('click')?.[0]).toEqual(['1'])
|
||||
})
|
||||
```
|
||||
|
||||
#### 조건부 렌더링
|
||||
```ts
|
||||
it('isLoading이 true이면 스피너를 표시한다', () => {
|
||||
const wrapper = mount(ComponentName, { props: { isLoading: true } })
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="content"]').exists()).toBe(false)
|
||||
})
|
||||
```
|
||||
|
||||
#### 비동기 / 스토어 Mock
|
||||
```ts
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
it('마운트 시 fetchProducts를 호출한다', async () => {
|
||||
const wrapper = mount(ComponentName, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })]
|
||||
}
|
||||
})
|
||||
const store = useProductStore()
|
||||
expect(store.fetchProducts).toHaveBeenCalledOnce()
|
||||
})
|
||||
```
|
||||
|
||||
#### 슬롯
|
||||
```ts
|
||||
it('default 슬롯 콘텐츠를 렌더링한다', () => {
|
||||
const wrapper = mount(ComponentName, {
|
||||
slots: { default: '<span>슬롯 내용</span>' }
|
||||
})
|
||||
expect(wrapper.find('span').text()).toBe('슬롯 내용')
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 단위 테스트: <ComponentName>.spec.ts
|
||||
|
||||
### 테스트 케이스 목록
|
||||
1. [렌더링] 기본 렌더링 성공
|
||||
2. [Props] title prop 표시
|
||||
3. [Emits] 버튼 클릭 시 emit
|
||||
4. [조건부] isLoading 상태 처리
|
||||
...
|
||||
|
||||
### 코드
|
||||
\`\`\`ts
|
||||
[전체 spec 파일]
|
||||
\`\`\`
|
||||
|
||||
### 실행 방법
|
||||
\`\`\`bash
|
||||
npx vitest run <파일경로>
|
||||
npx vitest --coverage # 커버리지 확인
|
||||
\`\`\`
|
||||
```
|
||||
21
skills/dreaming/SKILL.md
Normal file
21
skills/dreaming/SKILL.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: dreaming
|
||||
description: |
|
||||
프로젝트의 코드베이스(package.json, Pinia 스토어, 커스텀 Composable 등)를
|
||||
휴리스틱하게 스캔하고 자율 인지 메모리 파일(.claude/project/dreaming-context.md)을 업데이트합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- 사용자가 "코드베이스 스캔해줘", "컨텍스트 갱신해줘", "dreaming 돌려줘"라고 지시할 때
|
||||
- 새로운 브랜치로 전환했거나 대량의 코드가 머지된 직후 프로젝트 지침을 최신화하고 싶을 때
|
||||
---
|
||||
|
||||
# 프로젝트 자율 인지 메모리 스캔 (dreaming)
|
||||
|
||||
코드베이스 전체를 정기적으로 자율 성찰(Self-reflection)하여 에이전트의 뇌 상태를 동기화하고 프로젝트-스냅샷 파일을 생성합니다.
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. 프로젝트 루트 경로에서 \`node .claude/skills/dreaming/scripts/dreaming.js\` 명령을 실행합니다.
|
||||
2. 스크립트가 실행되면서 프로젝트 루트의 \`package.json\`, \`stores/\`, \`composables/\`, \`components/\` 등의 경로를 탐색하여 최신 의존성, 스토어 상태, 커스텀 컴포저블 목록 등을 분석합니다.
|
||||
3. 생성된 결과를 \`.claude/project/dreaming-context.md\` 파일로 기록 및 덮어쓰고, 해당 파일이 프로젝트 루트의 \`CLAUDE.md\`에 정상 임포트되었는지 검증합니다.
|
||||
4. 분석 완료 보고서와 요약 내용을 개발자에게 깔끔하게 알려드립니다.
|
||||
369
skills/dreaming/scripts/dreaming.js
Executable file
369
skills/dreaming/scripts/dreaming.js
Executable file
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* dreaming.js - "AI 코더"에서 "상태 저장형 컨벤션 가디언"으로 (Dreaming의 힘)
|
||||
*
|
||||
* 이 스크립트는 프로젝트 루트(CWD)의 코드베이스를 휴리스틱하게 스캔하여,
|
||||
* 현재 프레임워크 상태, 액티브 Pinia 스토어, 커스텀 Composable, 컴포넌트 구조, 테일윈드 설정 등을 추출합니다.
|
||||
* 분석된 내용은 .claude/project/dreaming-context.md 파일로 기록되어,
|
||||
* Claude Code가 프로젝트의 최신 컨벤션과 아키텍처 상태를 항시 보존하고 인지하도록 돕습니다.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CWD = process.cwd();
|
||||
const CLAUDE_DIR = path.join(CWD, '.claude');
|
||||
const PROJECT_DIR = path.join(CLAUDE_DIR, 'project');
|
||||
const OUTPUT_FILE = path.join(PROJECT_DIR, 'dreaming-context.md');
|
||||
const CLAUDE_MD = path.join(CWD, 'CLAUDE.md');
|
||||
|
||||
// 헬퍼: 디렉토리 존재 여부 확인
|
||||
function directoryExists(dirPath) {
|
||||
try {
|
||||
return fs.statSync(dirPath).isDirectory();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 헬퍼: 파일 존재 여부 확인
|
||||
function fileExists(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath).isFile();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 헬퍼: 재귀적으로 파일 목록 가져오기 (옵션 포함)
|
||||
function getFilesRecursive(dirPath, extFilter = [], ignoreDirs = ['node_modules', '.git', '.nuxt', 'dist']) {
|
||||
let results = [];
|
||||
if (!directoryExists(dirPath)) return results;
|
||||
|
||||
const list = fs.readdirSync(dirPath);
|
||||
list.forEach((file) => {
|
||||
const fullPath = path.join(dirPath, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat && stat.isDirectory()) {
|
||||
if (!ignoreDirs.includes(file)) {
|
||||
results = results.concat(getFilesRecursive(fullPath, extFilter, ignoreDirs));
|
||||
}
|
||||
} else {
|
||||
const ext = path.extname(file);
|
||||
if (extFilter.length === 0 || extFilter.includes(ext)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
// 1. package.json 분석
|
||||
function analyzePackageJson() {
|
||||
const packagePath = path.join(CWD, 'package.json');
|
||||
if (!fileExists(packagePath)) {
|
||||
return { name: 'Unknown Project', framework: 'Unknown', techStack: [], scripts: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
||||
const techStack = [];
|
||||
|
||||
let framework = 'Vue/Nuxt';
|
||||
if (deps['nuxt'] || deps['nuxt3'] || deps['nuxt-edge']) {
|
||||
framework = `Nuxt (${deps['nuxt'] || deps['nuxt3'] || 'v3'})`;
|
||||
techStack.push('Nuxt');
|
||||
} else if (deps['vue']) {
|
||||
framework = `Vue (${deps['vue']})`;
|
||||
techStack.push('Vue');
|
||||
} else if (deps['next']) {
|
||||
framework = `Next.js (${deps['next']})`;
|
||||
techStack.push('Next.js');
|
||||
} else if (deps['react']) {
|
||||
framework = `React (${deps['react']})`;
|
||||
techStack.push('React');
|
||||
}
|
||||
|
||||
if (deps['pinia'] || deps['@pinia/nuxt']) {
|
||||
techStack.push('Pinia (상태 관리)');
|
||||
}
|
||||
if (deps['tailwindcss'] || deps['@nuxtjs/tailwindcss']) {
|
||||
techStack.push('Tailwind CSS (스타일)');
|
||||
}
|
||||
if (deps['typescript']) {
|
||||
techStack.push('TypeScript');
|
||||
}
|
||||
if (deps['vitest'] || deps['@vitest/ui']) {
|
||||
techStack.push('Vitest (유닛 테스트)');
|
||||
}
|
||||
if (deps['eslint']) {
|
||||
techStack.push('ESLint');
|
||||
}
|
||||
if (deps['prettier']) {
|
||||
techStack.push('Prettier');
|
||||
}
|
||||
|
||||
return {
|
||||
name: pkg.name || 'Unnamed Project',
|
||||
version: pkg.version || '1.0.0',
|
||||
framework,
|
||||
techStack,
|
||||
scripts: pkg.scripts ? Object.keys(pkg.scripts) : []
|
||||
};
|
||||
} catch (e) {
|
||||
return { name: 'Parsing Error', framework: 'Unknown', techStack: [], scripts: [], error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 디렉토리 구조 스캔 및 요약
|
||||
function scanDirectoryStructure() {
|
||||
const dirsToScan = ['components', 'composables', 'stores', 'pages', 'server', 'layouts', 'middleware', 'plugins', 'types', 'assets'];
|
||||
const summary = {};
|
||||
|
||||
dirsToScan.forEach((dirName) => {
|
||||
const dirPath = path.join(CWD, dirName);
|
||||
if (directoryExists(dirPath)) {
|
||||
const files = getFilesRecursive(dirPath);
|
||||
summary[dirName] = {
|
||||
exists: true,
|
||||
count: files.length,
|
||||
examples: files.slice(0, 5).map(f => path.relative(CWD, f))
|
||||
};
|
||||
} else {
|
||||
summary[dirName] = { exists: false, count: 0, examples: [] };
|
||||
}
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
// 3. Pinia 스토어 상세 분석
|
||||
function analyzePiniaStores() {
|
||||
const storesDir = path.join(CWD, 'stores');
|
||||
const stores = [];
|
||||
|
||||
if (!directoryExists(storesDir)) {
|
||||
// composables 내에 스토어가 정의되어 있을 수도 있으므로 추가 탐색 가능
|
||||
return stores;
|
||||
}
|
||||
|
||||
const files = getFilesRecursive(storesDir, ['.ts', '.js']);
|
||||
files.forEach((file) => {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const filename = path.basename(file);
|
||||
|
||||
// defineStore 매칭
|
||||
const defineStoreMatch = content.match(/defineStore\(\s*['"`]([^'"`]+)['"`]/);
|
||||
const storeId = defineStoreMatch ? defineStoreMatch[1] : null;
|
||||
|
||||
// 상태(state) 필드 휴리스틱 추출
|
||||
const stateFields = [];
|
||||
const stateRegex = /const\s+([a-zA-Z0-9_$]+)\s*=\s*(ref|reactive|computed)/g;
|
||||
let match;
|
||||
while ((match = stateRegex.exec(content)) !== null) {
|
||||
stateFields.push(`${match[1]} (${match[2]})`);
|
||||
}
|
||||
|
||||
// 함수(actions) 추출
|
||||
const actionFields = [];
|
||||
const actionRegex = /function\s+([a-zA-Z0-9_$]+)/g;
|
||||
while ((match = actionRegex.exec(content)) !== null) {
|
||||
if (!match[1].startsWith('use')) {
|
||||
actionFields.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
stores.push({
|
||||
file: path.relative(CWD, file),
|
||||
id: storeId || filename.replace(path.extname(filename), ''),
|
||||
state: stateFields,
|
||||
actions: actionFields
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
return stores;
|
||||
}
|
||||
|
||||
// 4. 커스텀 Composable 분석
|
||||
function analyzeComposables() {
|
||||
const composablesDir = path.join(CWD, 'composables');
|
||||
const composables = [];
|
||||
|
||||
if (!directoryExists(composablesDir)) return composables;
|
||||
|
||||
const files = getFilesRecursive(composablesDir, ['.ts', '.js']);
|
||||
files.forEach((file) => {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const filename = path.basename(file);
|
||||
const relativePath = path.relative(CWD, file);
|
||||
|
||||
// export const useXxx 함수 매칭
|
||||
const useFuncRegex = /export\s+const\s+(use[a-zA-Z0-9_$]+)/g;
|
||||
const useFuncs = [];
|
||||
let match;
|
||||
while ((match = useFuncRegex.exec(content)) !== null) {
|
||||
useFuncs.push(match[1]);
|
||||
}
|
||||
|
||||
const defaultFuncRegex = /export\s+default\s+function\s+(use[a-zA-Z0-9_$]+)/;
|
||||
const defaultMatch = content.match(defaultFuncRegex);
|
||||
if (defaultMatch) {
|
||||
useFuncs.push(defaultMatch[1]);
|
||||
}
|
||||
|
||||
if (useFuncs.length > 0) {
|
||||
composables.push({
|
||||
file: relativePath,
|
||||
functions: useFuncs
|
||||
});
|
||||
} else {
|
||||
// 파일명이 useXxx 형태인 경우 추가
|
||||
if (filename.startsWith('use')) {
|
||||
composables.push({
|
||||
file: relativePath,
|
||||
functions: [filename.replace(path.extname(filename), '')]
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
return composables;
|
||||
}
|
||||
|
||||
// 5. 테스트 파일 통계
|
||||
function analyzeTests() {
|
||||
const testFiles = getFilesRecursive(CWD, ['.spec.ts', '.spec.js', '.test.ts', '.test.js']);
|
||||
return {
|
||||
count: testFiles.length,
|
||||
files: testFiles.slice(0, 10).map(f => path.relative(CWD, f))
|
||||
};
|
||||
}
|
||||
|
||||
// 메인 실행기
|
||||
function run() {
|
||||
console.log('🤖 프로젝트 "Dreaming" 컨텍스트 분석 시작...');
|
||||
|
||||
const pkgInfo = analyzePackageJson();
|
||||
const dirSummary = scanDirectoryStructure();
|
||||
const stores = analyzePiniaStores();
|
||||
const composables = analyzeComposables();
|
||||
const tests = analyzeTests();
|
||||
|
||||
const timestamp = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
|
||||
// 마크다운 문서 빌드
|
||||
let md = `# 🧠 프로젝트 자율 인지 메모리 (Dreaming Context)
|
||||
|
||||
이 파일은 \`dreaming.js\` 스크립트에 의해 프로젝트 코드베이스를 분석하여 자동 생성되었습니다.
|
||||
Claude Code가 프로젝트의 실시간 코드 구조, 사용 중인 스토어, 컴포넌트 레이아웃, 그리고 최신 개발 흐름을 완벽히 인지하도록 돕습니다.
|
||||
|
||||
* **최종 동기화 시간:** ${timestamp} (Asia/Seoul)
|
||||
|
||||
---
|
||||
|
||||
## 🏗 프로젝트 정보
|
||||
* **프로젝트명:** \`${pkgInfo.name}\` (v${pkgInfo.version || '1.0.0'})
|
||||
* **핵심 프레임워크:** \`${pkgInfo.framework}\`
|
||||
* **기술 스택 라이브러리:**
|
||||
${pkgInfo.techStack.map(tech => ` - ${tech}`).join('\n') || ' - (감지된 주요 라이브러리 없음)'}
|
||||
|
||||
---
|
||||
|
||||
## 📁 디렉토리 구조 및 컴포넌트 현황
|
||||
현재 활성화되어 있는 프로젝트 레이아웃 정보입니다.
|
||||
|
||||
| 디렉토리 | 활성 여부 | 파일 개수 | 주요 샘플 파일 (최대 5개) |
|
||||
|---|---|---|---|
|
||||
${Object.entries(dirSummary).map(([name, info]) => {
|
||||
return `| \`${name}/\` | ${info.exists ? '✅' : '❌'} | ${info.count}개 | ${info.examples.map(ex => `\`${path.basename(ex)}\``).join(', ') || '-'} |`;
|
||||
}).join('\n')}
|
||||
|
||||
---
|
||||
|
||||
## 🍍 액티브 Pinia 스토어 목록
|
||||
현재 코드베이스에 존재하는 글로벌 상태 저장소들의 템플릿 정보입니다. 새 기능을 개발할 때 아래 스토어를 재사용하거나 참고하세요.
|
||||
|
||||
${stores.length === 0 ? '*감지된 Pinia 스토어가 없습니다. (stores/ 디렉토리 없음 혹은 비어있음)*' : stores.map(store => {
|
||||
return `### 📦 \`${store.id}\`
|
||||
* **정의 파일:** \`${store.file}\`
|
||||
* **감지된 상태 (state/computed):** ${store.state.length > 0 ? store.state.map(s => `\`${s}\``).join(', ') : '없음'}
|
||||
* **감지된 액션 (actions/methods):** ${store.actions.length > 0 ? store.actions.map(a => `\`${a}\``).join(', ') : '없음'}
|
||||
`;
|
||||
}).join('\n')}
|
||||
|
||||
---
|
||||
|
||||
## 🎣 커스텀 Composable (useXxx) 목록
|
||||
다양한 비즈니스 로직과 부수효과를 격리해 둔 커스텀 훅 목록입니다. 컴포넌트 내부에서 비즈니스 로직을 직접 짜기 전, 아래 훅들의 재사용 가능성을 먼저 타진하세요.
|
||||
|
||||
${composables.length === 0 ? '*감지된 커스텀 Composable이 없습니다. (composables/ 디렉토리 없음 혹은 비어있음)*' : composables.map(comp => {
|
||||
return `- **파일:** \`${comp.file}\`
|
||||
- **제공 함수:** ${comp.functions.map(f => `\`${f}()\``).join(', ')}
|
||||
`;
|
||||
}).join('\n')}
|
||||
|
||||
---
|
||||
|
||||
## 🧪 유닛 테스트 통계
|
||||
현재까지 구축된 테스트 커버리지 현황입니다.
|
||||
* **감지된 테스트 파일 수:** \`${tests.count}개\`
|
||||
${tests.count > 0 ? `* **최근 테스트 목록:**\n${tests.files.map(f => ` - \`${f}\``).join('\n')}` : ' *(새 기능을 추가할 때 반드시 Vitest 규격의 유닛 테스트를 함께 작성해야 함)*'}
|
||||
|
||||
---
|
||||
|
||||
## 🛠 실행 가능한 스크립트 (package.json)
|
||||
프로젝트 구동 및 테스트 검증을 위해 사용 가능한 명령어 리스트입니다.
|
||||
${pkgInfo.scripts.map(s => `- \`npm run ${s}\` (또는 pnpm/yarn/bun)`).join('\n') || '- 스크립트 없음'}
|
||||
`;
|
||||
|
||||
// 디렉토리 및 파일 저장
|
||||
if (!directoryExists(CLAUDE_DIR)) {
|
||||
fs.mkdirSync(CLAUDE_DIR);
|
||||
}
|
||||
if (!directoryExists(PROJECT_DIR)) {
|
||||
fs.mkdirSync(PROJECT_DIR);
|
||||
}
|
||||
|
||||
fs.writeFileSync(OUTPUT_FILE, md, 'utf8');
|
||||
console.log(`✅ Dreaming Context 업데이트 완료! -> ${path.relative(CWD, OUTPUT_FILE)}`);
|
||||
|
||||
// CLAUDE.md에 자동 임포트 추가 처리
|
||||
if (fileExists(CLAUDE_MD)) {
|
||||
let claudeMdContent = fs.readFileSync(CLAUDE_MD, 'utf8');
|
||||
const importStr = '@.claude/project/dreaming-context.md';
|
||||
|
||||
if (!claudeMdContent.includes(importStr)) {
|
||||
// '## 프로젝트 지침' 혹은 '## 공통 지침' 섹션 밑에 삽입 시도
|
||||
const sectionMatch = claudeMdContent.match(/(## 프로젝트 지침\r?\n)/);
|
||||
if (sectionMatch) {
|
||||
claudeMdContent = claudeMdContent.replace(
|
||||
sectionMatch[0],
|
||||
`${sectionMatch[0]}${importStr}\n`
|
||||
);
|
||||
fs.writeFileSync(CLAUDE_MD, claudeMdContent, 'utf8');
|
||||
console.log(`🔗 CLAUDE.md에 ${importStr} 동적 임포트 구문을 연결했습니다.`);
|
||||
} else {
|
||||
// 찾을 수 없다면 파일 상단 혹은 하단에 단순 추가
|
||||
claudeMdContent = claudeMdContent + `\n\n## 자동 분석 컨텍스트\n${importStr}\n`;
|
||||
fs.writeFileSync(CLAUDE_MD, claudeMdContent, 'utf8');
|
||||
console.log(`🔗 CLAUDE.md 끝에 ${importStr} 동적 임포트 구문을 추가했습니다.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`⚠️ 프로젝트 루트에 CLAUDE.md 가 존재하지 않습니다. CLAUDE.md를 먼저 생성하고 @.claude/project/dreaming-context.md 임포트 선언을 수동으로 추가하는 것을 권장합니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
run();
|
||||
125
skills/markup-base/SKILL.md
Normal file
125
skills/markup-base/SKILL.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: markup-base
|
||||
description: |
|
||||
화면 설명, 스크린샷, 텍스트 명세를 받아 시멘틱 HTML + Tailwind CSS 기반의
|
||||
Nuxt SFC 마크업을 생성합니다. 팀 공통 컨벤션(7단계 클래스 순서, ARIA, 반응형)을 자동 적용합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 화면 마크업해줘", "HTML 구조 만들어줘", "퍼블리싱해줘"
|
||||
- 화면 설명이나 스크린샷을 받고 컴포넌트 골격을 잡을 때
|
||||
- Figma 없이 텍스트 명세나 와이어프레임으로 마크업을 시작할 때
|
||||
---
|
||||
|
||||
# 기본 마크업 가이드 (markup-base)
|
||||
|
||||
화면 명세 → 시멘틱 HTML + Tailwind → Nuxt `.vue` SFC 골격을 생성합니다.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- Figma 디자인 없이 화면 명세/스크린샷으로 마크업을 시작할 때
|
||||
- 신규 페이지/섹션의 기본 구조를 빠르게 잡을 때
|
||||
- 기존 마크업을 팀 컨벤션에 맞게 리팩토링할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- 화면 설명 (텍스트), 와이어프레임 이미지, 또는 스크린샷
|
||||
- 반응형 분기점 요건 (없으면 Tailwind 기본값 사용: `sm` 640px / `md` 768px / `lg` 1024px)
|
||||
- 접근성 요건 (없으면 WCAG 2.1 AA 기본 적용)
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 화면 구조 파악
|
||||
|
||||
1. 제공된 화면 명세 또는 이미지를 분석해 레이아웃 영역을 식별한다.
|
||||
- 반복 블록 → 컴포넌트 후보 표시
|
||||
- 상태가 있는 영역 (로딩, 에러, 빈 상태) 확인
|
||||
2. 반응형 분기점을 파악한다.
|
||||
- 모바일 우선(mobile-first) 여부 확인
|
||||
- 중단점별 레이아웃 변화 정리
|
||||
|
||||
### Phase 2: 시멘틱 구조 설계
|
||||
|
||||
1. HTML 시멘틱 태그를 선택한다.
|
||||
|
||||
| 영역 | 권장 태그 |
|
||||
|---|---|
|
||||
| 페이지 헤더 | `<header>` |
|
||||
| 주 내비게이션 | `<nav aria-label="...">` |
|
||||
| 주 콘텐츠 | `<main>` |
|
||||
| 사이드 | `<aside>` |
|
||||
| 콘텐츠 섹션 | `<section aria-labelledby="...">` |
|
||||
| 독립 콘텐츠 | `<article>` |
|
||||
| 페이지 푸터 | `<footer>` |
|
||||
|
||||
2. 인터랙션 요소를 식별한다.
|
||||
- 버튼: `<button type="button">` (폼 외부), `<button type="submit">` (폼 내부)
|
||||
- 링크: `<a href="...">` (페이지 이동), `<button>` (동작 트리거)
|
||||
|
||||
### Phase 3: Tailwind 클래스 적용
|
||||
|
||||
클래스 순서는 아래 7단계를 따른다:
|
||||
|
||||
```
|
||||
1. 레이아웃 : flex, grid, block, hidden
|
||||
2. 크기 : w-*, h-*, max-w-*, min-h-*
|
||||
3. 여백 : m-*, p-*, gap-*, space-*
|
||||
4. 배경/보더 : bg-*, border-*, rounded-*, shadow-*
|
||||
5. 타이포그래피 : text-*, font-*, leading-*, tracking-*
|
||||
6. 색상 : text-{color}, fill-*, stroke-*
|
||||
7. 상태/반응형 : hover:, focus:, sm:, md:, lg:
|
||||
```
|
||||
|
||||
조건부 클래스는 `clsx` 또는 `cn` 유틸리티를 사용한다.
|
||||
|
||||
### Phase 4: ARIA 및 접근성 처리
|
||||
|
||||
- 아이콘 전용 버튼: `aria-label` 필수
|
||||
- 모달/다이얼로그: `role="dialog" aria-modal="true" aria-labelledby="..."`
|
||||
- 로딩 상태: `aria-live="polite"` 또는 `aria-busy="true"`
|
||||
- 이미지: 의미 있는 이미지 `alt` 텍스트 / 장식용 `alt=""`
|
||||
- 폼 레이블: `<label for="...">` 또는 `aria-label`
|
||||
|
||||
### Phase 5: Nuxt SFC 출력
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Props / Emits / 로컬 상태만 포함
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 시멘틱 구조 -->
|
||||
</template>
|
||||
```
|
||||
|
||||
- `<style>`은 Tailwind로 커버 불가한 경우에만 추가
|
||||
- `scoped`보다 Tailwind 유틸리티 우선
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 마크업: <화면명>
|
||||
|
||||
### 구조 개요
|
||||
- 레이아웃: [설명]
|
||||
- 반응형 분기: [없음 / sm, md, lg 각각 설명]
|
||||
- 컴포넌트 분리 후보: [목록]
|
||||
|
||||
### 코드
|
||||
\`\`\`vue
|
||||
<script setup lang="ts">
|
||||
...
|
||||
</script>
|
||||
|
||||
<template>
|
||||
...
|
||||
</template>
|
||||
\`\`\`
|
||||
|
||||
### 추가 작업 필요 항목
|
||||
- [ ] 이미지 alt 텍스트 확정 필요
|
||||
- [ ] 컬러 토큰 디자인 시스템 확인 필요
|
||||
```
|
||||
BIN
skills/markup-edm/.DS_Store
vendored
Normal file
BIN
skills/markup-edm/.DS_Store
vendored
Normal file
Binary file not shown.
310
skills/markup-edm/SKILL.md
Normal file
310
skills/markup-edm/SKILL.md
Normal file
@@ -0,0 +1,310 @@
|
||||
---
|
||||
name: markup-edm
|
||||
description: |
|
||||
EDM(이메일 다이렉트 마케팅) HTML을 구현하는 전체 워크플로우 스킬.
|
||||
Figma 디자인 → HTML table 마크업 → 아웃룩 호환 → 검수까지 단계별 가이드를 제공합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "EDM 만들어줘", "이메일 템플릿 구현", "뉴스레터 HTML"
|
||||
- "아웃룩에서 깨지는 이메일 수정", "이메일 HTML 마크업"
|
||||
- Figma 디자인을 받고 이메일 HTML로 변환할 때
|
||||
- "메일 발송용 HTML", "eDM 퍼블리싱", "HTML 이메일"
|
||||
- 이메일 클라이언트 호환성 문제가 있을 때
|
||||
---
|
||||
|
||||
# EDM HTML 구현 가이드
|
||||
|
||||
이메일 HTML은 일반 웹과 다른 세계입니다. 2000년대 테이블 코딩이 아직도 정답이며, Flexbox와 Grid는 쓸 수 없습니다. 이 스킬은 Figma 디자인에서 시작해 모든 이메일 클라이언트에서 깨지지 않는 HTML을 만드는 과정을 안내합니다.
|
||||
|
||||
## 워크플로우
|
||||
|
||||
```
|
||||
1. Figma 디자인 파악 → 2. HTML 마크업 → 3. 아웃룩 호환 → 4. 검수
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Figma 디자인 파악
|
||||
|
||||
### Figma MCP 사용 가능 시
|
||||
Claude Code에 Figma MCP가 설정되어 있다면 Figma URL로 직접 디자인 데이터를 읽을 수 있습니다. MCP가 연결되어 있는지 먼저 확인하고, 가능하다면 자동 추출을 시도하세요.
|
||||
|
||||
추출 가능한 속성:
|
||||
- 컬러 HEX값 (RGBA → HEX 자동 변환)
|
||||
- 폰트 패밀리, 사이즈(px), 굵기, 줄간격
|
||||
- 레이아웃 치수: 너비, 높이, padding, 섹션 간격
|
||||
- 이미지 에셋 URL (CDN 업로드 필요)
|
||||
- CTA 링크 (레이어 설명 필드에서 추출)
|
||||
|
||||
### Figma MCP 없이 진행 시
|
||||
사용자에게 다음 정보를 요청하거나 스크린샷으로 파악하세요.
|
||||
|
||||
**필수 확인 항목:**
|
||||
- 전체 이메일 너비 (권장: **600px**)
|
||||
- 각 섹션 배경색, 텍스트 색상 (HEX)
|
||||
- 폰트: 패밀리, 사이즈(px), 굵기, 줄간격
|
||||
- 이미지: 가로×세로(px)
|
||||
- 여백: 섹션 간 간격, 좌우 패딩
|
||||
- CTA 버튼: 크기, 색상, 텍스트, 링크 URL
|
||||
- 푸터: 회사 정보, 수신거부 링크
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: HTML table 마크업
|
||||
|
||||
### 절대 원칙
|
||||
|
||||
이메일 HTML에서 반드시 지켜야 하는 규칙들입니다. 이 규칙을 어기면 특정 클라이언트에서 레이아웃이 무너집니다:
|
||||
|
||||
| 규칙 | 이유 |
|
||||
|------|------|
|
||||
| `table`, `tr`, `td`만 레이아웃에 사용 | div는 Outlook 등에서 무시됨 |
|
||||
| inline CSS 우선 | Gmail이 `<head>` style 태그를 제거함 |
|
||||
| `width`/`height` 속성 필수 | CSS만으론 Outlook이 무시함 |
|
||||
| `margin` 사용 금지 | 빈 `<tr>`행이나 `padding`으로 대체 |
|
||||
| `padding` 개별 속성 사용 | 단축 속성(`padding: 10px 20px`)은 일부 클라이언트 미지원 |
|
||||
| 모든 `<table>`에 `cellpadding="0" cellspacing="0" border="0"` | 브라우저 기본 스타일 초기화 |
|
||||
|
||||
### 기본 템플릿
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no">
|
||||
<title>이메일 제목</title>
|
||||
<style type="text/css">
|
||||
body { margin: 0; padding: 0; width: 100%; background-color: #f5f5f5; }
|
||||
table { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { display: block; border: 0; outline: none; text-decoration: none; }
|
||||
|
||||
/* 미디어쿼리는 여기서만 (Outlook은 무시하지만 Gmail/Apple Mail에서 적용) */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.mobile-full { width: 100% !important; display: block !important; }
|
||||
.mobile-padding { padding-left: 16px !important; padding-right: 16px !important; }
|
||||
.mobile-center { text-align: center !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f5f5;">
|
||||
|
||||
<!-- 외부 래퍼: 배경색과 수평 중앙 정렬 -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f5f5f5">
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 20px; padding-bottom: 20px;">
|
||||
|
||||
<!-- 600px 컨테이너 -->
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0"
|
||||
style="width: 600px; max-width: 100%; background-color: #ffffff;">
|
||||
|
||||
<!-- 헤더 -->
|
||||
<tr>
|
||||
<td style="padding-top: 0;">
|
||||
<!-- 로고 이미지 등 -->
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 본문 -->
|
||||
<tr>
|
||||
<td style="padding-top: 30px; padding-bottom: 30px;
|
||||
padding-left: 30px; padding-right: 30px;">
|
||||
<!-- 메인 콘텐츠 -->
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<tr>
|
||||
<td bgcolor="#f5f5f5"
|
||||
style="background-color: #f5f5f5;
|
||||
padding-top: 20px; padding-bottom: 20px;
|
||||
padding-left: 20px; padding-right: 20px;
|
||||
text-align: center;">
|
||||
<!-- 회사 정보 + 수신거부 링크 (필수) -->
|
||||
<p style="font-family: Arial, sans-serif; font-size: 12px;
|
||||
color: #999999; margin: 0; line-height: 1.5;">
|
||||
회사명 | 주소<br>
|
||||
<a href="[수신거부URL]"
|
||||
style="color: #999999; text-decoration: underline;">
|
||||
수신거부
|
||||
</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 안전한 폰트
|
||||
|
||||
웹폰트(`@font-face`, Google Fonts)는 대부분의 이메일 클라이언트에서 지원하지 않습니다. Pretendard, Noto Sans KR 같은 폰트를 Figma에서 사용했어도 이메일에서는 안전 폰트로 대체해야 합니다.
|
||||
|
||||
```css
|
||||
/* 권장 스택 (한국어 이메일) */
|
||||
font-family: -apple-system, Arial, 'Helvetica Neue', Helvetica, sans-serif;
|
||||
|
||||
/* Outlook 전용 (MSO 조건부 주석 내) */
|
||||
font-family: Arial, sans-serif;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 아웃룩 호환성
|
||||
|
||||
아웃룩 2007~2019는 Word 엔진으로 이메일을 렌더링해서 현대 CSS를 거의 무시합니다. MSO 조건부 주석으로 아웃룩과 그 외 클라이언트를 분리해서 처리하세요.
|
||||
|
||||
### MSO 조건부 주석
|
||||
|
||||
```html
|
||||
<!--[if mso]>
|
||||
<!-- 아웃룩에서만 렌더링 -->
|
||||
<![endif]-->
|
||||
|
||||
<!--[if !mso]><!-->
|
||||
<!-- 아웃룩 제외 클라이언트에서 렌더링 -->
|
||||
<!--<![endif]-->
|
||||
```
|
||||
|
||||
### 아웃룩이 무시하는 주요 속성
|
||||
|
||||
| CSS 속성 | 아웃룩 동작 | 대체 방법 |
|
||||
|----------|-----------|---------|
|
||||
| `background-image` | 미지원 | `<img>` 태그 직접 사용 |
|
||||
| `border-radius` | 무시 | VML 사용 또는 이미지 버튼 |
|
||||
| `margin` | 무시 | `padding` 또는 빈 `<tr>` 행 |
|
||||
| `box-shadow` | 무시 | 포기 또는 이미지로 대체 |
|
||||
| `@media query` | 2007/2010 미지원 | 테이블 고정폭으로 데스크톱 설계 |
|
||||
|
||||
### VML 버튼 (반드시 사용)
|
||||
|
||||
아웃룩에서 CSS 버튼은 배경색 없는 텍스트 링크로 표시됩니다. CTA 버튼은 항상 VML을 포함하세요:
|
||||
|
||||
```html
|
||||
<div style="text-align: center;
|
||||
padding-top: 20px; padding-bottom: 20px;">
|
||||
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||
href="https://example.com"
|
||||
style="height: 44px; v-text-anchor: middle; width: 200px;"
|
||||
arcsize="5%"
|
||||
stroke="f"
|
||||
fillcolor="#FF6B6B">
|
||||
<w:anchorlock/>
|
||||
<center style="color: #ffffff; font-family: Arial, sans-serif;
|
||||
font-size: 16px; font-weight: bold;">
|
||||
지금 확인하기
|
||||
</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
|
||||
<!--[if !mso]><!-->
|
||||
<a href="https://example.com"
|
||||
style="background-color: #FF6B6B;
|
||||
color: #ffffff;
|
||||
display: inline-block;
|
||||
padding-top: 12px; padding-bottom: 12px;
|
||||
padding-left: 30px; padding-right: 30px;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: bold;">
|
||||
지금 확인하기
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
|
||||
</div>
|
||||
```
|
||||
|
||||
### 이미지 처리
|
||||
|
||||
이미지 차단 시에도 레이아웃이 깨지지 않도록 `alt` 텍스트와 배경색을 함께 지정하세요:
|
||||
|
||||
```html
|
||||
<td bgcolor="#FF6B6B" style="background-color: #FF6B6B;">
|
||||
<img src="https://cdn.example.com/banner.jpg"
|
||||
alt="7월 여름 세일 최대 50% 할인"
|
||||
width="600"
|
||||
height="300"
|
||||
style="display: block; width: 100%; max-width: 600px;
|
||||
height: auto; border: 0;">
|
||||
</td>
|
||||
```
|
||||
|
||||
이미지는 반드시 `https://` CDN 절대 경로를 사용하세요. 로컬 경로나 상대 경로는 이메일에서 작동하지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 검수 체크리스트
|
||||
|
||||
### 코드 구조 (필수)
|
||||
- [ ] 모든 `<table>`에 `cellpadding="0" cellspacing="0" border="0"`
|
||||
- [ ] 모든 `<img>`에 `width`, `height`, `alt` 속성
|
||||
- [ ] `margin` 미사용 (padding 또는 빈 `<tr>` 행으로 대체)
|
||||
- [ ] `padding` 단축 속성 제거 (개별 속성 사용)
|
||||
- [ ] CTA 버튼에 VML 코드 포함
|
||||
- [ ] 이미지 `src`가 HTTPS 절대 URL
|
||||
|
||||
### 콘텐츠 (필수)
|
||||
- [ ] 푸터에 수신거부 링크 포함
|
||||
- [ ] 모든 링크 href 유효성 확인
|
||||
- [ ] 이미지 alt 텍스트 의미있게 작성 (장식용이면 `alt=""`)
|
||||
|
||||
### Figma 디자인 대비 검수
|
||||
- [ ] 전체 너비 600px
|
||||
- [ ] 색상 HEX값 일치
|
||||
- [ ] 폰트 사이즈, 굵기 일치
|
||||
- [ ] 버튼 크기, 색상 일치
|
||||
- [ ] 섹션 간 여백 일치
|
||||
|
||||
### 테스트 도구
|
||||
|
||||
| 도구 | 용도 | 비용 |
|
||||
|------|------|------|
|
||||
| [Litmus](https://www.litmus.com) | 100+ 클라이언트 렌더링 미리보기 | 유료 |
|
||||
| [Email on Acid](https://www.emailonacid.com) | 크로스 클라이언트 + 접근성 감사 | 유료 |
|
||||
| [Mailtrap](https://mailtrap.io) | 개발 환경 샌드박스, 스팸 점수 | 무료 플랜 |
|
||||
| [SpamTest.io](https://spamtest.io/) | 스팸 점수, SPF/DKIM/DMARC 확인 | 무료 |
|
||||
|
||||
**최소 테스트 클라이언트:** Gmail 웹, Outlook (Windows), Apple Mail, 모바일 Gmail
|
||||
|
||||
---
|
||||
|
||||
## 2컬럼 레이아웃 예시
|
||||
|
||||
```html
|
||||
<!-- 데스크톱: 2열 | 모바일: 스택 -->
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0"
|
||||
style="width: 600px;">
|
||||
<tr>
|
||||
<td width="280" valign="top"
|
||||
style="width: 280px; padding-right: 20px;"
|
||||
class="mobile-full">
|
||||
<!-- 왼쪽 -->
|
||||
</td>
|
||||
<td width="280" valign="top"
|
||||
style="width: 280px; padding-left: 20px;"
|
||||
class="mobile-full">
|
||||
<!-- 오른쪽 -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
상세 내용은 references 폴더를 참조하세요:
|
||||
- `references/html-patterns.md` — 헤더/푸터/버튼/이미지 완성 코드 패턴
|
||||
- `references/verification-checklist.md` — 전체 검수 체크리스트 (시각적/기능/스팸)
|
||||
24
skills/markup-edm/assets/example_asset.txt
Normal file
24
skills/markup-edm/assets/example_asset.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
# Example Asset File
|
||||
|
||||
This placeholder represents where asset files would be stored.
|
||||
Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed.
|
||||
|
||||
Asset files are NOT intended to be loaded into context, but rather used within
|
||||
the output Claude produces.
|
||||
|
||||
Example asset files from other skills:
|
||||
- Brand guidelines: logo.png, slides_template.pptx
|
||||
- Frontend builder: hello-world/ directory with HTML/React boilerplate
|
||||
- Typography: custom-font.ttf, font-family.woff2
|
||||
- Data: sample_data.csv, test_dataset.json
|
||||
|
||||
## Common Asset Types
|
||||
|
||||
- Templates: .pptx, .docx, boilerplate directories
|
||||
- Images: .png, .jpg, .svg, .gif
|
||||
- Fonts: .ttf, .otf, .woff, .woff2
|
||||
- Boilerplate code: Project directories, starter files
|
||||
- Icons: .ico, .svg
|
||||
- Data files: .csv, .json, .xml, .yaml
|
||||
|
||||
Note: This is a text placeholder. Actual assets can be any file type.
|
||||
34
skills/markup-edm/references/api_reference.md
Normal file
34
skills/markup-edm/references/api_reference.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Reference Documentation for Edm Email Html
|
||||
|
||||
This is a placeholder for detailed reference documentation.
|
||||
Replace with actual reference content or delete if not needed.
|
||||
|
||||
Example real reference docs from other skills:
|
||||
- product-management/references/communication.md - Comprehensive guide for status updates
|
||||
- product-management/references/context_building.md - Deep-dive on gathering context
|
||||
- bigquery/references/ - API references and query examples
|
||||
|
||||
## When Reference Docs Are Useful
|
||||
|
||||
Reference docs are ideal for:
|
||||
- Comprehensive API documentation
|
||||
- Detailed workflow guides
|
||||
- Complex multi-step processes
|
||||
- Information too lengthy for main SKILL.md
|
||||
- Content that's only needed for specific use cases
|
||||
|
||||
## Structure Suggestions
|
||||
|
||||
### API Reference Example
|
||||
- Overview
|
||||
- Authentication
|
||||
- Endpoints with examples
|
||||
- Error codes
|
||||
- Rate limits
|
||||
|
||||
### Workflow Guide Example
|
||||
- Prerequisites
|
||||
- Step-by-step instructions
|
||||
- Common patterns
|
||||
- Troubleshooting
|
||||
- Best practices
|
||||
327
skills/markup-edm/references/html-patterns.md
Normal file
327
skills/markup-edm/references/html-patterns.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# EDM HTML 코드 패턴 모음
|
||||
|
||||
---
|
||||
|
||||
## 1컬럼 레이아웃 (전체 템플릿)
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no">
|
||||
<title>이메일 제목</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
body, table, td, p, a { font-family: Arial, sans-serif !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
body { margin: 0; padding: 0; width: 100%; background-color: #f5f5f5; }
|
||||
table { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { display: block; border: 0; outline: none; text-decoration: none; }
|
||||
a { color: inherit; }
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.container { width: 100% !important; }
|
||||
.mobile-full { width: 100% !important; display: block !important; }
|
||||
.mobile-padding { padding-left: 16px !important; padding-right: 16px !important; }
|
||||
.mobile-center { text-align: center !important; }
|
||||
.mobile-img { width: 100% !important; height: auto !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f5f5;">
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f5f5f5">
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 20px; padding-bottom: 20px;">
|
||||
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0"
|
||||
class="container"
|
||||
style="width: 600px; max-width: 100%; background-color: #ffffff;">
|
||||
|
||||
<!-- 헤더 -->
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="padding-top: 24px; padding-bottom: 24px;
|
||||
padding-left: 30px; padding-right: 30px;
|
||||
border-bottom: 1px solid #e5e7eb;">
|
||||
<img src="https://cdn.example.com/logo.png"
|
||||
alt="회사 로고" width="120" height="40"
|
||||
style="display: block; border: 0;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 히어로 이미지 -->
|
||||
<tr>
|
||||
<td style="padding: 0; line-height: 0;">
|
||||
<img src="https://cdn.example.com/hero.jpg"
|
||||
alt="이벤트 배너"
|
||||
width="600" height="280"
|
||||
class="mobile-img"
|
||||
style="display: block; width: 100%; max-width: 600px;
|
||||
height: auto; border: 0;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 본문 -->
|
||||
<tr>
|
||||
<td style="padding-top: 32px; padding-bottom: 32px;
|
||||
padding-left: 32px; padding-right: 32px;"
|
||||
class="mobile-padding">
|
||||
<h1 style="font-family: Arial, sans-serif;
|
||||
font-size: 24px; font-weight: bold;
|
||||
color: #111827; line-height: 1.3;
|
||||
margin: 0 0 16px 0;">
|
||||
이메일 제목이 여기 들어갑니다
|
||||
</h1>
|
||||
<p style="font-family: Arial, sans-serif;
|
||||
font-size: 15px; color: #374151;
|
||||
line-height: 1.7;
|
||||
margin: 0 0 24px 0;">
|
||||
본문 내용이 여기 들어갑니다. 가독성을 위해 line-height를
|
||||
1.5 이상으로 설정하는 것이 좋습니다.
|
||||
</p>
|
||||
|
||||
<!-- CTA 버튼 -->
|
||||
<div style="text-align: center;">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||
href="https://example.com"
|
||||
style="height: 48px; v-text-anchor: middle; width: 200px;"
|
||||
arcsize="8%" stroke="f" fillcolor="#1a56db">
|
||||
<w:anchorlock/>
|
||||
<center style="color: #ffffff; font-family: Arial, sans-serif;
|
||||
font-size: 16px; font-weight: bold;">
|
||||
자세히 보기
|
||||
</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<a href="https://example.com"
|
||||
style="background-color: #1a56db; color: #ffffff;
|
||||
display: inline-block;
|
||||
padding-top: 14px; padding-bottom: 14px;
|
||||
padding-left: 32px; padding-right: 32px;
|
||||
text-decoration: none; border-radius: 6px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16px; font-weight: bold;">
|
||||
자세히 보기
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<tr>
|
||||
<td bgcolor="#f9fafb"
|
||||
style="background-color: #f9fafb;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 24px; padding-bottom: 24px;
|
||||
padding-left: 32px; padding-right: 32px;
|
||||
text-align: center;">
|
||||
<p style="font-family: Arial, sans-serif; font-size: 12px;
|
||||
color: #9ca3af; line-height: 1.6; margin: 0 0 8px 0;">
|
||||
<strong>회사명</strong> | 서울시 강남구 테헤란로 123
|
||||
</p>
|
||||
<p style="font-family: Arial, sans-serif; font-size: 12px;
|
||||
color: #9ca3af; line-height: 1.6; margin: 0;">
|
||||
<a href="https://example.com/unsubscribe"
|
||||
style="color: #9ca3af; text-decoration: underline;">
|
||||
수신거부
|
||||
</a>
|
||||
|
|
||||
<a href="https://example.com/privacy"
|
||||
style="color: #9ca3af; text-decoration: underline;">
|
||||
개인정보처리방침
|
||||
</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2컬럼 이미지 + 텍스트
|
||||
|
||||
```html
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0"
|
||||
style="width: 600px;">
|
||||
<tr>
|
||||
<!-- 이미지 열 (40%) -->
|
||||
<td width="220" valign="top"
|
||||
style="width: 220px; padding-right: 0;"
|
||||
class="mobile-full">
|
||||
<img src="https://cdn.example.com/product.jpg"
|
||||
alt="상품명" width="220" height="220"
|
||||
class="mobile-img"
|
||||
style="display: block; width: 100%; height: auto; border: 0;">
|
||||
</td>
|
||||
|
||||
<!-- 간격 -->
|
||||
<td width="20" style="width: 20px; min-width: 20px;"> </td>
|
||||
|
||||
<!-- 텍스트 열 (60%) -->
|
||||
<td width="360" valign="top"
|
||||
style="width: 360px; padding-top: 8px;"
|
||||
class="mobile-full mobile-padding">
|
||||
<h2 style="font-family: Arial, sans-serif;
|
||||
font-size: 18px; font-weight: bold;
|
||||
color: #111827; margin: 0 0 8px 0;
|
||||
line-height: 1.3;">
|
||||
상품명
|
||||
</h2>
|
||||
<p style="font-family: Arial, sans-serif; font-size: 14px;
|
||||
color: #6b7280; line-height: 1.6;
|
||||
margin: 0 0 16px 0;">
|
||||
상품 설명이 들어갑니다. 간결하게 핵심만 작성하세요.
|
||||
</p>
|
||||
<p style="font-family: Arial, sans-serif; font-size: 20px;
|
||||
font-weight: bold; color: #ef4444;
|
||||
margin: 0 0 16px 0;">
|
||||
₩29,900
|
||||
</p>
|
||||
<a href="https://example.com/product"
|
||||
style="background-color: #111827; color: #ffffff;
|
||||
display: inline-block;
|
||||
padding-top: 10px; padding-bottom: 10px;
|
||||
padding-left: 20px; padding-right: 20px;
|
||||
text-decoration: none; border-radius: 4px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 13px; font-weight: bold;">
|
||||
구매하기
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 헤더 배너 (이미지 기반)
|
||||
|
||||
이미지가 차단됐을 때도 배경색이 보이도록 `bgcolor` 속성을 함께 지정합니다:
|
||||
|
||||
```html
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0"
|
||||
style="width: 600px;">
|
||||
<tr>
|
||||
<td bgcolor="#1a56db" style="background-color: #1a56db; line-height: 0; padding: 0;">
|
||||
<img src="https://cdn.example.com/header-banner.jpg"
|
||||
alt="여름 세일 최대 70% 할인"
|
||||
width="600" height="240"
|
||||
style="display: block; width: 100%; max-width: 600px;
|
||||
height: auto; border: 0;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 섹션 구분선
|
||||
|
||||
```html
|
||||
<!-- 섹션 간 여백 -->
|
||||
<tr>
|
||||
<td height="32" style="height: 32px; line-height: 32px;"> </td>
|
||||
</tr>
|
||||
|
||||
<!-- 수평선 -->
|
||||
<tr>
|
||||
<td style="padding-left: 32px; padding-right: 32px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td height="1" bgcolor="#e5e7eb"
|
||||
style="height: 1px; background-color: #e5e7eb; line-height: 1px;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 아웃라인(외곽선) 버튼
|
||||
|
||||
```html
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||
href="https://example.com"
|
||||
style="height: 44px; v-text-anchor: middle; width: 180px;"
|
||||
arcsize="5%"
|
||||
stroke="t"
|
||||
strokeweight="2px"
|
||||
strokecolor="#1a56db"
|
||||
fillcolor="#ffffff">
|
||||
<w:anchorlock/>
|
||||
<center style="color: #1a56db; font-family: Arial, sans-serif;
|
||||
font-size: 14px; font-weight: bold;">
|
||||
더 알아보기
|
||||
</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<a href="https://example.com"
|
||||
style="background-color: #ffffff;
|
||||
color: #1a56db;
|
||||
display: inline-block;
|
||||
padding-top: 12px; padding-bottom: 12px;
|
||||
padding-left: 24px; padding-right: 24px;
|
||||
text-decoration: none;
|
||||
border: 2px solid #1a56db;
|
||||
border-radius: 5px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: bold;">
|
||||
더 알아보기
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 소셜 아이콘 행
|
||||
|
||||
```html
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="padding-right: 8px;">
|
||||
<a href="https://instagram.com/example" style="text-decoration: none;">
|
||||
<img src="https://cdn.example.com/icon-instagram.png"
|
||||
alt="Instagram" width="32" height="32"
|
||||
style="display: block; border: 0;">
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding-right: 8px;">
|
||||
<a href="https://facebook.com/example" style="text-decoration: none;">
|
||||
<img src="https://cdn.example.com/icon-facebook.png"
|
||||
alt="Facebook" width="32" height="32"
|
||||
style="display: block; border: 0;">
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://youtube.com/example" style="text-decoration: none;">
|
||||
<img src="https://cdn.example.com/icon-youtube.png"
|
||||
alt="YouTube" width="32" height="32"
|
||||
style="display: block; border: 0;">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
19
skills/markup-edm/scripts/example.py
Executable file
19
skills/markup-edm/scripts/example.py
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example helper script for edm-email-html
|
||||
|
||||
This is a placeholder script that can be executed directly.
|
||||
Replace with actual implementation or delete if not needed.
|
||||
|
||||
Example real scripts from other skills:
|
||||
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
|
||||
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
|
||||
"""
|
||||
|
||||
def main():
|
||||
print("This is an example script for edm-email-html")
|
||||
# TODO: Add actual script logic here
|
||||
# This could be data processing, file conversion, API calls, etc.
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
107
skills/markup-figma/SKILL.md
Normal file
107
skills/markup-figma/SKILL.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: markup-figma
|
||||
description: |
|
||||
Figma URL 또는 Figma MCP로 추출한 디자인 데이터를 받아
|
||||
Nuxt SFC + Tailwind CSS 마크업으로 자동 변환합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "이 Figma 마크업해줘", "Figma 디자인 컴포넌트로 만들어줘"
|
||||
- Figma URL 또는 링크를 받고 HTML/Vue 구현을 요청받았을 때
|
||||
- 피그마 시안 기반 반응형 마크업이 필요할 때
|
||||
---
|
||||
|
||||
# Figma → 마크업 변환 (markup-figma)
|
||||
|
||||
Figma 디자인 데이터 → Nuxt SFC + Tailwind 마크업 자동 변환.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 디자이너로부터 Figma URL을 받아 퍼블리싱을 시작할 때
|
||||
- Figma MCP가 Claude Code에 연결되어 있을 때
|
||||
- Figma 없이 스크린샷/Export 이미지로 구현할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- Figma 프레임 URL 또는 노드 ID
|
||||
- (선택) 브레이크포인트 요건, 이미지 에셋 CDN 경로
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: Figma 데이터 추출
|
||||
|
||||
#### Figma MCP 사용 가능 시
|
||||
```
|
||||
Figma URL → MCP로 레이어 트리, 스타일, 치수 자동 추출
|
||||
```
|
||||
|
||||
추출 항목:
|
||||
- 컬러 HEX값 (RGBA → HEX 변환)
|
||||
- 폰트: 패밀리, 사이즈(px), weight, 줄간격
|
||||
- 레이아웃: width/height, padding, gap
|
||||
- 컴포넌트 이름 → Vue 컴포넌트 이름 매핑 (`PascalCase`)
|
||||
- 이미지 에셋 URL
|
||||
|
||||
#### MCP 없이 진행 시
|
||||
사용자에게 아래 정보를 요청한다:
|
||||
- 섹션별 스크린샷 (모바일 / 데스크톱 분리)
|
||||
- 컬러 HEX, 폰트 스펙, 여백(px)
|
||||
- CTA 링크 및 버튼 텍스트
|
||||
|
||||
### Phase 2: Tailwind 매핑
|
||||
|
||||
| Figma 값 | Tailwind 클래스 예시 |
|
||||
|---|---|
|
||||
| `width: 320px` | `w-80` 또는 `w-[320px]` |
|
||||
| `padding: 16px 24px` | `py-4 px-6` |
|
||||
| `gap: 12px` | `gap-3` |
|
||||
| `font-size: 14px` | `text-sm` |
|
||||
| `border-radius: 8px` | `rounded-lg` |
|
||||
| `color: #1A1A1A` | `text-[#1A1A1A]` 또는 디자인 토큰 |
|
||||
|
||||
- Tailwind 디자인 토큰(`tailwind.config.ts`)이 있으면 임의값 대신 토큰 사용
|
||||
- 임의값(`w-[123px]`)은 디자인 시스템에 없는 값에만 사용
|
||||
|
||||
### Phase 3: 컴포넌트 구조 결정
|
||||
|
||||
Figma 컴포넌트 → Vue 파일 매핑:
|
||||
- Figma 상위 프레임 → 페이지 레이아웃 (`pages/` 또는 `layouts/`)
|
||||
- Figma Instance → `components/` 파일
|
||||
- 반복 컴포넌트 → Props로 추상화
|
||||
|
||||
### Phase 4: SFC 코드 생성
|
||||
|
||||
`markup-base` Phase 3~5 동일 적용 (클래스 순서, ARIA, SFC 구조).
|
||||
|
||||
반응형 처리:
|
||||
- Figma Mobile 프레임 → base 클래스
|
||||
- Figma Desktop 프레임 → `lg:` 접두사
|
||||
|
||||
### Phase 5: 검수 체크리스트
|
||||
|
||||
- [ ] 폰트 스펙 일치 여부 (px → rem 변환 확인)
|
||||
- [ ] 이미지 `alt` 텍스트 작성 여부
|
||||
- [ ] 인터랙션 요소 ARIA 속성 확인
|
||||
- [ ] Tailwind 클래스 7단계 순서 준수
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## Figma 마크업: <프레임명>
|
||||
|
||||
### 추출된 디자인 정보
|
||||
- 컬러: [HEX 목록]
|
||||
- 폰트: [패밀리 / 사이즈 목록]
|
||||
- 브레이크포인트: [mobile / desktop 치수]
|
||||
|
||||
### 코드
|
||||
\`\`\`vue
|
||||
...
|
||||
\`\`\`
|
||||
|
||||
### 미확인 항목
|
||||
- [ ] [에셋 URL, 링크 등 확정 필요 항목]
|
||||
```
|
||||
138
skills/markup-promotion/SKILL.md
Normal file
138
skills/markup-promotion/SKILL.md
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
name: markup-promotion
|
||||
description: |
|
||||
프로모션/랜딩 페이지용 표준 마크업을 생성합니다.
|
||||
캠페인 유형별 섹션 템플릿(히어로, 특징, CTA, 이벤트 일정 등)을 제공하고
|
||||
반응형·접근성·SEO 메타를 일관되게 적용합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "프로모션 페이지 만들어줘", "랜딩 페이지 마크업해줘"
|
||||
- "이벤트 페이지 퍼블리싱", "캠페인 HTML 만들어줘"
|
||||
- 반복적인 프로모션 구조를 빠르게 구현해야 할 때
|
||||
---
|
||||
|
||||
# 프로모션 마크업 가이드 (markup-promotion)
|
||||
|
||||
캠페인 / 이벤트 / 랜딩 페이지의 표준 섹션 구조를 자동 생성합니다.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 신규 프로모션/이벤트 랜딩 페이지를 제작할 때
|
||||
- 기존 캠페인 구조를 재사용해 빠르게 개발할 때
|
||||
- 팀 표준 프로모션 레이아웃이 필요할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- 캠페인 명칭, 기간, 주요 메시지
|
||||
- 포함할 섹션 목록 (없으면 기본 구성으로 제안)
|
||||
- 이미지/에셋 경로 또는 플레이스홀더 여부
|
||||
- 다국어 지원 여부
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 캠페인 유형 분석
|
||||
|
||||
1. 제공된 정보에서 아래 유형을 판별한다.
|
||||
|
||||
| 유형 | 특징 |
|
||||
|---|---|
|
||||
| 이벤트 | 기간, 참여 조건, CTA 버튼 |
|
||||
| 상품 출시 | 제품 특징, 스펙, 구매 링크 |
|
||||
| 신규 서비스 | 혜택 강조, 가입 유도 |
|
||||
| 콘텐츠/미디어 | 영상/갤러리 중심, SNS 공유 |
|
||||
|
||||
2. 필요한 섹션을 제안한다. 사용자가 별도 지정하지 않으면 기본 구성 사용.
|
||||
|
||||
**기본 섹션 구성:**
|
||||
```
|
||||
Hero → 특징/혜택 → 이벤트 규칙/일정 → FAQ → CTA → Footer
|
||||
```
|
||||
|
||||
### Phase 2: 섹션별 마크업 생성
|
||||
|
||||
각 섹션은 독립 Vue 컴포넌트로 작성한다.
|
||||
|
||||
#### Hero 섹션
|
||||
```vue
|
||||
<section class="relative w-full [height] bg-[color] ...">
|
||||
<div class="...">
|
||||
<h1 class="...">{{ title }}</h1>
|
||||
<p class="...">{{ description }}</p>
|
||||
<a :href="ctaLink" class="...">{{ ctaText }}</a>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
#### 특징/혜택 섹션
|
||||
```vue
|
||||
<section aria-labelledby="features-heading">
|
||||
<h2 id="features-heading" class="...">{{ heading }}</h2>
|
||||
<ul class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<li v-for="item in features" :key="item.id" class="...">
|
||||
...
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
```
|
||||
|
||||
#### CTA 섹션
|
||||
- 버튼 타입: `<a>` (외부 링크) 또는 `<NuxtLink>` (내부 라우팅)
|
||||
- `aria-label`로 버튼 목적 명시
|
||||
|
||||
### Phase 3: 반응형 + SEO + 접근성
|
||||
|
||||
**반응형**: 모바일 우선 (base → `md:` → `lg:`)
|
||||
|
||||
**SEO 메타** (Nuxt `useSeoMeta`):
|
||||
```ts
|
||||
useSeoMeta({
|
||||
title: '캠페인명 | 브랜드명',
|
||||
description: '캠페인 한 줄 설명',
|
||||
ogTitle: '...',
|
||||
ogImage: '이미지 URL',
|
||||
})
|
||||
```
|
||||
|
||||
**접근성**:
|
||||
- 이미지 `alt` 텍스트 (장식용이면 `alt=""`)
|
||||
- 섹션 제목 계층 (`h1` → `h2` → `h3`)
|
||||
- 카운트다운 타이머: `aria-live="polite"`
|
||||
|
||||
### Phase 4: 다국어 처리
|
||||
|
||||
다국어 지원 시 i18n 키로 텍스트 분리:
|
||||
```vue
|
||||
<h1>{{ $t('HERO-title') }}</h1>
|
||||
```
|
||||
|
||||
키 명명은 `plan-translation-generator` 스킬의 카테고리 가이드 참조.
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 프로모션 마크업: <캠페인명>
|
||||
|
||||
### 섹션 구성
|
||||
1. Hero
|
||||
2. 특징/혜택
|
||||
3. CTA
|
||||
|
||||
### 파일 구조
|
||||
pages/promotion/<slug>.vue
|
||||
components/promotion/
|
||||
├── PromoHero.vue
|
||||
├── PromoFeatures.vue
|
||||
└── PromoCta.vue
|
||||
|
||||
### 코드
|
||||
[각 컴포넌트 코드]
|
||||
|
||||
### 체크리스트
|
||||
- [ ] 이미지 alt 텍스트 확정
|
||||
- [ ] CTA 링크 URL 확정
|
||||
- [ ] useSeoMeta 값 확정
|
||||
```
|
||||
170
skills/plan-analyzer/SKILL.md
Normal file
170
skills/plan-analyzer/SKILL.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: plan-analyzer
|
||||
description: |
|
||||
PPT 기획서를 분석하여 요구사항 명세서를 자동 생성합니다.
|
||||
Nuxt pages 라우팅 구조, 컴포넌트 트리, API 엔드포인트 목록,
|
||||
화면 전환 플로우(Mermaid)를 구조화된 마크다운 문서로 출력합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "기획서 분석해줘", "PPT 파싱", "요구사항 정리해줘"
|
||||
- "라우팅 구조 뽑아줘", "컴포넌트 트리 만들어줘"
|
||||
- "API 목록 정리", "화면 플로우 그려줘"
|
||||
- `.pptx` 파일 경로를 제공받고 개발 요구사항 추출을 요청받았을 때
|
||||
permissions:
|
||||
allow:
|
||||
- Bash(python3 -c "import pptx"*)
|
||||
- Bash(python3*extract_pptx.py*)
|
||||
---
|
||||
|
||||
# 요구사항 분석기 (Requirement Analyzer)
|
||||
|
||||
이 skill 은 PPT 기획서(`.pptx`)를 파싱하여 팀 전체가 동일한 기반으로 개발에 착수할 수 있는
|
||||
구조화된 요구사항 명세서를 자동으로 생성합니다.
|
||||
|
||||
## 언제 사용하는가
|
||||
|
||||
- 기획팀에서 PPT 기획서를 전달받았을 때
|
||||
- 개발 착수 전 화면 목록 / 라우팅 / API 목록을 정리해야 할 때
|
||||
- 화면 전환 플로우를 다이어그램으로 시각화해야 할 때
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Phase 1: 파일 확인 및 추출
|
||||
|
||||
1. **파일 경로 확인**
|
||||
- 사용자가 PPTX 파일 경로를 제공했는지 확인한다.
|
||||
- 미제공 시: 파일 경로를 먼저 요청한다.
|
||||
|
||||
2. **의존성 확인**
|
||||
스킬 디렉토리 기준 스크립트를 실행하기 전, `python-pptx` 설치 여부를 확인한다.
|
||||
```bash
|
||||
python3 -c "import pptx" 2>/dev/null && echo "OK" || echo "MISSING"
|
||||
```
|
||||
- `MISSING` 출력 시: 아래 메시지를 사용자에게 안내한다.
|
||||
```
|
||||
⚠️ python-pptx 패키지가 필요합니다.
|
||||
설치 명령어: pip3 install python-pptx
|
||||
또는 자동 설치: python3 scripts/extract_pptx.py --auto-install <파일경로>
|
||||
```
|
||||
|
||||
3. **PPTX 추출 실행**
|
||||
```bash
|
||||
python3 <skill-dir>/scripts/extract_pptx.py --extract-images "<pptx경로>"
|
||||
```
|
||||
- `--extract-images`: 슬라이드 이미지(PNG)를 임시 디렉토리에 추출 → 시각 분석에 활용
|
||||
- 출력: JSON 형태의 슬라이드 데이터 (슬라이드별 제목, 텍스트, 노트, 도형, 테이블, 이미지 경로)
|
||||
|
||||
4. **결과 확인**
|
||||
- JSON 파싱 성공 여부 확인
|
||||
- 추출된 이미지가 있으면 각 슬라이드별로 순서대로 열어 와이어프레임/목업 시각 분석
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 내용 분석
|
||||
|
||||
추출된 JSON 데이터와 슬라이드 이미지를 기반으로 아래 항목을 분석한다.
|
||||
|
||||
#### 화면(페이지) 식별
|
||||
- 슬라이드 제목에서 "화면", "페이지", 경로(`/`로 시작), URL 패턴 찾기
|
||||
- 와이어프레임 이미지에서 GNB, 레이아웃 영역 확인
|
||||
- 중복 레이아웃 슬라이드(상세 설명용) vs 독립 화면 구분
|
||||
|
||||
#### UI 컴포넌트 패턴 식별
|
||||
- 반복 등장하는 도형 레이블 (예: "GNB", "Footer", "Card", "Modal", "Tab")
|
||||
- 슬라이드 간 동일한 레이아웃 구조 → 공통 컴포넌트 후보
|
||||
- 이미지에서 시각적으로 동일한 UI 블록
|
||||
|
||||
#### 네비게이션 플로우 추론
|
||||
- 화살표/커넥터 도형으로 연결된 화면 간 전환
|
||||
- "클릭 시", "탭하면", "→" 등의 텍스트 주석
|
||||
- 슬라이드 번호 순서 + 제목 키워드로 흐름 유추
|
||||
|
||||
#### API 엔드포인트 추출
|
||||
- 테이블에 "Method", "URL", "API", "Endpoint" 컬럼이 있는 경우
|
||||
- 발표자 노트에 `GET /api/...`, `POST /api/...` 형태의 텍스트
|
||||
- 텍스트 박스 내 `http`, `/api`, `fetch`, `axios` 언급
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 명세서 생성
|
||||
|
||||
아래 7개 섹션으로 구성된 마크다운 문서를 작성한다.
|
||||
추론 기반 정보는 반드시 `(추정)` 또는 `<확인 필요>` 를 표시한다.
|
||||
|
||||
**출력 섹션:**
|
||||
|
||||
```
|
||||
1. 화면 목록 (Pages) — 테이블 형식
|
||||
2. Nuxt 라우팅 구조 — pages/ 파일 트리
|
||||
3. 컴포넌트 트리 — components/ 파일 트리 + Props 상세 테이블
|
||||
4. API 엔드포인트 목록 — 테이블 형식
|
||||
5. 화면 전환 플로우 — Mermaid flowchart TD
|
||||
6. 공통 레이아웃 — 레이아웃별 적용 화면 테이블
|
||||
7. 추가 참고사항 — 미확정 항목 / 확인 필요 목록
|
||||
```
|
||||
|
||||
출력 형식 상세는 `references/output-template.md` 를 참조한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 사용자 확인 및 저장
|
||||
|
||||
1. **미리보기 제공**
|
||||
생성된 명세서 전체를 출력하여 사용자가 검토할 수 있도록 한다.
|
||||
|
||||
2. **저장 경로 확인**
|
||||
```
|
||||
📄 아래 경로에 요구사항 명세서를 저장하려고 합니다:
|
||||
docs/requirements/<기획서파일명>.md
|
||||
|
||||
다른 경로를 원하시면 알려주세요. 진행할까요? (y/n)
|
||||
```
|
||||
|
||||
3. **파일 저장**
|
||||
- 사용자 승인 후 파일을 저장한다.
|
||||
- `docs/requirements/` 디렉토리가 없으면 생성한다.
|
||||
- 저장 완료 후 아래 메시지를 출력한다:
|
||||
```
|
||||
✅ 요구사항 명세서 저장 완료
|
||||
docs/requirements/<파일명>.md
|
||||
|
||||
다음 단계:
|
||||
1) <확인 필요> 항목을 기획자에게 확인하세요.
|
||||
2) 컴포넌트/API 목록을 개발 태스크로 분해하세요.
|
||||
3) git add docs/requirements/ && git commit -m "docs: 요구사항 명세서 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식
|
||||
|
||||
`references/output-template.md` 에서 전체 마크다운 구조와 예시를 확인하세요.
|
||||
|
||||
### Mermaid 플로우차트 작성 규칙
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[메인] --> B[이벤트 목록]
|
||||
B --> C[이벤트 상세]
|
||||
C --> D{사전등록 완료?}
|
||||
D -- 완료 --> E[완료 모달]
|
||||
D -- 미완료 --> F[사전등록 폼]
|
||||
```
|
||||
|
||||
- 화면 노드: `[화면명]` (사각형)
|
||||
- 조건 분기: `{조건}` (다이아몬드)
|
||||
- 모달/팝업: `([모달명])` (타원)
|
||||
- 화살표 레이블: 클릭 대상 버튼명 또는 조건
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
- **기존 파일 수정 금지**: 프로젝트의 기존 소스 파일은 절대 수정하지 않는다. 명세서 파일만 새로 생성한다.
|
||||
- **추측 표시 필수**: 기획서에 명시되지 않은 정보를 추론할 경우 반드시 `(추정)` 을 표시한다.
|
||||
- **확인 필요 목록**: 확신할 수 없는 항목은 섹션 7에 `<확인 필요>` 로 모아 명시한다.
|
||||
- **이미지 heavy 기획서**: 텍스트가 적고 이미지 위주인 기획서는 `--extract-images` 옵션이 필수다.
|
||||
- **대용량 PPTX**: 슬라이드가 50장 이상이면 `--slides 1-20` 으로 범위를 지정하여 순차 처리한다.
|
||||
- **커밋 금지**: 파일 저장 후 `git commit` 은 사용자 명시 요청이 없으면 실행하지 않는다.
|
||||
159
skills/plan-analyzer/references/output-template.md
Normal file
159
skills/plan-analyzer/references/output-template.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# 요구사항 명세서 출력 템플릿
|
||||
|
||||
이 파일은 `requirement-analyzer` 스킬이 생성하는 마크다운 문서의 형식 예시입니다.
|
||||
실제 출력 시 각 항목을 기획서 내용으로 채우고, 불확실한 항목은 `(추정)` 또는 `<확인 필요>` 로 표시합니다.
|
||||
|
||||
---
|
||||
|
||||
```markdown
|
||||
# 요구사항 명세서: <프로젝트명>
|
||||
|
||||
> 기획서: `<파일명.pptx>` | 생성일: <YYYY-MM-DD> | 총 슬라이드: <N>장
|
||||
> 담당 기획자: <확인 필요> | 개발 일정: <확인 필요>
|
||||
|
||||
---
|
||||
|
||||
## 1. 화면 목록 (Pages)
|
||||
|
||||
| # | 화면명 | 라우팅 경로 | 기획서 슬라이드 | 비고 |
|
||||
|---|--------|------------|--------------|------|
|
||||
| 1 | 메인 (랜딩) | `/` | 3-5 | 사전등록 CTA 포함 |
|
||||
| 2 | 사전등록 | `/register` | 6-9 | 폼 입력 화면 |
|
||||
| 3 | 등록 완료 | `/register/complete` | 10 | 완료 안내 화면 |
|
||||
| 4 | 이벤트 소개 | `/about` | 11-13 | 스크롤 단일 페이지 |
|
||||
| 5 | 공지사항 | `/notice` | 14 | (추정) 목록 페이지 |
|
||||
| 6 | 공지사항 상세 | `/notice/[id]` | 14 | (추정) 동적 라우트 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Nuxt 라우팅 구조
|
||||
|
||||
```
|
||||
pages/
|
||||
├── index.vue # 메인 (랜딩)
|
||||
├── register/
|
||||
│ ├── index.vue # 사전등록 폼
|
||||
│ └── complete.vue # 등록 완료
|
||||
├── about.vue # 이벤트 소개
|
||||
└── notice/
|
||||
├── index.vue # 공지사항 목록
|
||||
└── [id].vue # 공지사항 상세 (추정)
|
||||
```
|
||||
|
||||
**레이아웃:**
|
||||
```
|
||||
layouts/
|
||||
├── default.vue # GNB + Footer 포함 (전체 화면)
|
||||
└── blank.vue # GNB/Footer 없음 (모달, 완료 화면 등)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 컴포넌트 트리
|
||||
|
||||
```
|
||||
components/
|
||||
├── common/
|
||||
│ ├── AppHeader.vue # GNB / 상단 네비게이션
|
||||
│ ├── AppFooter.vue # 하단 푸터
|
||||
│ └── AppLogo.vue # 로고 (추정)
|
||||
├── register/
|
||||
│ ├── RegisterForm.vue # 사전등록 입력 폼
|
||||
│ ├── RegisterComplete.vue # 완료 화면 내용
|
||||
│ └── TermsModal.vue # 약관 모달 (추정)
|
||||
├── notice/
|
||||
│ ├── NoticeList.vue # 공지사항 목록 (추정)
|
||||
│ └── NoticeItem.vue # 공지사항 카드 (추정)
|
||||
└── ui/
|
||||
├── BaseButton.vue # 공통 버튼
|
||||
├── BaseModal.vue # 공통 모달 래퍼 (추정)
|
||||
└── BaseInput.vue # 공통 입력 필드 (추정)
|
||||
```
|
||||
|
||||
### 컴포넌트 상세
|
||||
|
||||
| 컴포넌트 | 사용 화면 | Props (추정) | Emits (추정) | 비고 |
|
||||
|----------|----------|-------------|-------------|------|
|
||||
| `AppHeader` | 전체 | `transparent?: boolean` | — | 메인에서 투명 배경 |
|
||||
| `RegisterForm` | 사전등록 | — | `submit` | 폼 상태 내부 관리 |
|
||||
| `BaseButton` | 전체 | `variant`, `size`, `disabled` | `click` | Tailwind 기반 |
|
||||
| `TermsModal` | 사전등록 | `visible` | `close`, `agree` | <확인 필요> |
|
||||
|
||||
---
|
||||
|
||||
## 4. API 엔드포인트 목록
|
||||
|
||||
| # | Method | Endpoint | 설명 | 사용 화면 | 비고 |
|
||||
|---|--------|----------|------|----------|------|
|
||||
| 1 | POST | `/api/register` | 사전등록 제출 | 사전등록 | 폼 데이터 전송 |
|
||||
| 2 | GET | `/api/notices` | 공지사항 목록 | 공지사항 | 페이지네이션 (추정) |
|
||||
| 3 | GET | `/api/notices/:id` | 공지사항 상세 | 공지사항 상세 | (추정) |
|
||||
| 4 | GET | `/api/event/info` | 이벤트 기본 정보 | 메인, 소개 | <확인 필요> |
|
||||
|
||||
> **참고**: API 경로는 기획서 노트/테이블에서 추출했습니다. 백엔드 팀과 확인이 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 화면 전환 플로우
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[메인 랜딩] --> |사전등록 버튼 클릭| B[사전등록 폼]
|
||||
A --> |공지사항 메뉴| C[공지사항 목록]
|
||||
A --> |이벤트 소개 메뉴| D[이벤트 소개]
|
||||
|
||||
B --> |약관 보기 클릭| E([약관 모달])
|
||||
E --> |닫기| B
|
||||
B --> |제출 성공| F[등록 완료]
|
||||
B --> |제출 실패| B
|
||||
|
||||
C --> |항목 클릭| G[공지사항 상세]
|
||||
G --> |목록으로| C
|
||||
|
||||
F --> |메인으로| A
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 공통 레이아웃
|
||||
|
||||
| 레이아웃 | 적용 화면 | 포함 컴포넌트 | 비고 |
|
||||
|----------|----------|-------------|------|
|
||||
| `default` | 메인, 이벤트 소개, 공지사항 | AppHeader, AppFooter | GNB 표시 |
|
||||
| `blank` | 등록 완료 | — | GNB/Footer 없음 (추정) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 추가 참고사항
|
||||
|
||||
### 확인 필요 항목
|
||||
|
||||
- [ ] 약관 모달 내용 및 동의 필수 항목 (슬라이드 8 — 상세 미기재)
|
||||
- [ ] 공지사항 페이지네이션 방식 (무한스크롤 vs 페이지 버튼)
|
||||
- [ ] 사전등록 중복 참여 처리 방식 (동일 이메일 재등록 시)
|
||||
- [ ] `/api/event/info` 엔드포인트 실제 경로 및 응답 스키마
|
||||
- [ ] 다국어 지원 여부 (한국어 전용 vs 영어 병행)
|
||||
|
||||
### 기획서에서 발견된 특이사항
|
||||
|
||||
- 슬라이드 7: "등록 마감 후 버튼 비활성화" — 마감일 기준 상태 분기 필요
|
||||
- 슬라이드 12: 이벤트 소개 섹션 내 동영상 플레이어 포함 — Video 컴포넌트 추가 검토
|
||||
- 슬라이드 14: 공지사항 페이지 기획 미완성으로 표시됨 (`<확인 필요>`)
|
||||
|
||||
### 미정 항목
|
||||
|
||||
| 항목 | 현재 상태 | 담당 |
|
||||
|------|----------|------|
|
||||
| OG 태그 / SNS 공유 설정 | 미기재 | <확인 필요> |
|
||||
| GA / 트래킹 이벤트 정의 | 미기재 | <확인 필요> |
|
||||
| 브라우저 지원 범위 | 미기재 | <확인 필요> |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 활용 팁
|
||||
|
||||
- `<확인 필요>` 항목은 기획자와 킥오프 미팅 전 체크리스트로 활용하세요.
|
||||
- `(추정)` 항목은 개발 착수 전 기획서를 재확인하고 제거하세요.
|
||||
- API 엔드포인트 목록은 백엔드 팀과 함께 검토하여 최종 확정하세요.
|
||||
- Mermaid 플로우차트는 PR 설명이나 Confluence 페이지에 바로 붙여 사용할 수 있습니다.
|
||||
315
skills/plan-analyzer/scripts/extract_pptx.py
Normal file
315
skills/plan-analyzer/scripts/extract_pptx.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
PPT 기획서 추출 스크립트 (extract_pptx.py)
|
||||
========================================
|
||||
PPTX 파일을 파싱하여 Claude AI가 분석할 수 있는 JSON 구조로 변환합니다.
|
||||
의미 분석(화면 매핑, API 추출, 플로우 추론)은 Claude AI가 담당합니다.
|
||||
|
||||
사용법:
|
||||
python extract_pptx.py <pptx경로> [옵션]
|
||||
|
||||
옵션:
|
||||
--extract-images 슬라이드 이미지를 PNG로 추출 (--output-dir 로 저장 경로 지정)
|
||||
--output-dir <dir> 이미지 추출 디렉토리 (기본: /tmp/pptx_<파일명>/)
|
||||
--slides <범위> 처리할 슬라이드 범위 (예: 1-10, 5, 3-7,10-12)
|
||||
--auto-install python-pptx 자동 설치 후 실행
|
||||
--pretty JSON 출력 시 들여쓰기 적용
|
||||
|
||||
출력:
|
||||
stdout 에 JSON 데이터 출력
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
import tempfile
|
||||
import re
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 의존성 확인 및 자동 설치
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _ensure_pptx(auto_install: bool) -> None:
|
||||
"""python-pptx 설치 여부 확인. 미설치 시 안내 또는 자동 설치."""
|
||||
try:
|
||||
import pptx # noqa: F401
|
||||
except ImportError:
|
||||
if auto_install:
|
||||
import subprocess
|
||||
print("📦 python-pptx 설치 중...", file=sys.stderr)
|
||||
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'python-pptx'])
|
||||
print("✅ python-pptx 설치 완료\n", file=sys.stderr)
|
||||
else:
|
||||
print(
|
||||
"❌ python-pptx 패키지가 설치되어 있지 않습니다.\n"
|
||||
"\n"
|
||||
"설치 명령어:\n"
|
||||
" pip3 install python-pptx\n"
|
||||
"\n"
|
||||
"또는 자동 설치 옵션을 사용하세요:\n"
|
||||
f" python3 {sys.argv[0]} --auto-install <파일경로>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 슬라이드 범위 파싱
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def parse_slide_range(spec: str, total: int) -> set[int]:
|
||||
"""
|
||||
"1-5,8,10-12" 형태의 슬라이드 범위 문자열을 슬라이드 번호 set 으로 변환.
|
||||
번호는 1-based.
|
||||
"""
|
||||
result: set[int] = set()
|
||||
for part in spec.split(','):
|
||||
part = part.strip()
|
||||
if '-' in part:
|
||||
start, end = part.split('-', 1)
|
||||
result.update(range(int(start), int(end) + 1))
|
||||
else:
|
||||
result.add(int(part))
|
||||
return {n for n in result if 1 <= n <= total}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 도형(Shape) 분류
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _shape_type_name(shape) -> str:
|
||||
"""python-pptx MSO_SHAPE_TYPE 값을 사람이 읽기 쉬운 문자열로 변환."""
|
||||
from pptx.util import Emu # noqa: F401 — 모듈 로드 확인용
|
||||
try:
|
||||
return shape.shape_type.name.lower() # e.g. 'auto_shape', 'picture', 'line'
|
||||
except Exception:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _is_connector(shape) -> bool:
|
||||
"""화살표/커넥터 도형 여부 확인."""
|
||||
try:
|
||||
# MSO_SHAPE_TYPE.LINE = 9, FREEFORM = 5
|
||||
return shape.shape_type in (9,)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 슬라이드 데이터 추출
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def extract_slide(slide, slide_number: int, extract_images: bool, output_dir: str) -> dict:
|
||||
"""단일 슬라이드에서 모든 관련 데이터를 추출하여 dict 반환."""
|
||||
from pptx.enum.shapes import PP_PLACEHOLDER # noqa: F401
|
||||
|
||||
result: dict = {
|
||||
'number': slide_number,
|
||||
'title': '',
|
||||
'texts': [],
|
||||
'notes': '',
|
||||
'images': [],
|
||||
'shapes': [],
|
||||
'tables': [],
|
||||
}
|
||||
|
||||
# ── 제목 추출 ──────────────────────────────
|
||||
for shape in slide.shapes:
|
||||
try:
|
||||
if shape.is_placeholder:
|
||||
ph_type = shape.placeholder_format.type
|
||||
# PP_PLACEHOLDER.TITLE = 1, CENTER_TITLE = 3
|
||||
if ph_type in (1, 3):
|
||||
result['title'] = shape.text.strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 모든 도형 순회 ──────────────────────────
|
||||
for shape in slide.shapes:
|
||||
shape_info: dict = {
|
||||
'type': _shape_type_name(shape),
|
||||
'name': getattr(shape, 'name', ''),
|
||||
'left': int(shape.left or 0),
|
||||
'top': int(shape.top or 0),
|
||||
'width': int(shape.width or 0),
|
||||
'height': int(shape.height or 0),
|
||||
'text': '',
|
||||
}
|
||||
|
||||
# 텍스트 프레임
|
||||
if shape.has_text_frame:
|
||||
text = shape.text_frame.text.strip()
|
||||
shape_info['text'] = text
|
||||
if text:
|
||||
result['texts'].append({
|
||||
'text': text,
|
||||
'left': shape_info['left'],
|
||||
'top': shape_info['top'],
|
||||
'width': shape_info['width'],
|
||||
'height': shape_info['height'],
|
||||
'shape_name': shape_info['name'],
|
||||
})
|
||||
|
||||
# 테이블
|
||||
if shape.has_table:
|
||||
table = shape.table
|
||||
all_rows = list(table.rows)
|
||||
headers = [cell.text.strip() for cell in all_rows[0].cells]
|
||||
rows = [
|
||||
[cell.text.strip() for cell in row.cells]
|
||||
for row in all_rows[1:]
|
||||
]
|
||||
result['tables'].append({'headers': headers, 'rows': rows})
|
||||
shape_info['type'] = 'table'
|
||||
|
||||
# 이미지
|
||||
if shape.shape_type == 13: # MSO_SHAPE_TYPE.PICTURE = 13
|
||||
img_info: dict = {
|
||||
'name': shape.name,
|
||||
'left': shape_info['left'],
|
||||
'top': shape_info['top'],
|
||||
'width': shape_info['width'],
|
||||
'height': shape_info['height'],
|
||||
'path': '',
|
||||
}
|
||||
if extract_images:
|
||||
try:
|
||||
img_bytes = shape.image.blob
|
||||
ext = shape.image.ext # e.g. 'png', 'jpeg'
|
||||
safe_name = re.sub(r'[^\w\-.]', '_', shape.name)
|
||||
img_filename = f"slide{slide_number:03d}_{safe_name}.{ext}"
|
||||
img_path = os.path.join(output_dir, img_filename)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
with open(img_path, 'wb') as f:
|
||||
f.write(img_bytes)
|
||||
img_info['path'] = img_path
|
||||
except Exception as e:
|
||||
img_info['error'] = str(e)
|
||||
result['images'].append(img_info)
|
||||
shape_info['type'] = 'picture'
|
||||
|
||||
# 커넥터/화살표 처리
|
||||
if _is_connector(shape):
|
||||
shape_info['type'] = 'connector'
|
||||
|
||||
result['shapes'].append(shape_info)
|
||||
|
||||
# ── 발표자 노트 ────────────────────────────
|
||||
try:
|
||||
if slide.has_notes_slide:
|
||||
notes_text = slide.notes_slide.notes_text_frame.text.strip()
|
||||
result['notes'] = notes_text
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 메인 추출 함수
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def extract_pptx(
|
||||
filepath: str,
|
||||
extract_images: bool = False,
|
||||
output_dir: str = '',
|
||||
slide_range: str = '',
|
||||
pretty: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
PPTX 파일 전체를 파싱하여 JSON 구조를 stdout 에 출력합니다.
|
||||
|
||||
Args:
|
||||
filepath: PPTX 파일 절대/상대 경로
|
||||
extract_images: True 이면 슬라이드 이미지를 output_dir 에 PNG로 추출
|
||||
output_dir: 이미지 추출 디렉토리 (기본: /tmp/pptx_<파일명>/)
|
||||
slide_range: 처리할 슬라이드 범위 문자열 (예: "1-10", "" = 전체)
|
||||
pretty: True 이면 JSON 들여쓰기 출력
|
||||
"""
|
||||
from pptx import Presentation
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
print(f"❌ 파일을 찾을 수 없습니다: {filepath}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
prs = Presentation(filepath)
|
||||
total_slides = len(prs.slides)
|
||||
filename = os.path.basename(filepath)
|
||||
|
||||
# 출력 디렉토리 결정
|
||||
if not output_dir:
|
||||
stem = re.sub(r'[^\w\-]', '_', os.path.splitext(filename)[0])
|
||||
output_dir = os.path.join(tempfile.gettempdir(), f'pptx_{stem}')
|
||||
|
||||
# 처리 대상 슬라이드 번호 결정
|
||||
if slide_range:
|
||||
target_slides = parse_slide_range(slide_range, total_slides)
|
||||
else:
|
||||
target_slides = set(range(1, total_slides + 1))
|
||||
|
||||
print(
|
||||
f"🔍 파싱 중: {filename} ({total_slides}장 중 {len(target_slides)}장 처리)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if extract_images:
|
||||
print(f"🖼️ 이미지 추출 디렉토리: {output_dir}", file=sys.stderr)
|
||||
|
||||
# 슬라이드 추출
|
||||
slides_data = []
|
||||
for idx, slide in enumerate(prs.slides, start=1):
|
||||
if idx not in target_slides:
|
||||
continue
|
||||
slide_data = extract_slide(slide, idx, extract_images, output_dir)
|
||||
slides_data.append(slide_data)
|
||||
print(f" 슬라이드 {idx}/{total_slides}: {slide_data['title'] or '(제목 없음)'}", file=sys.stderr)
|
||||
|
||||
output = {
|
||||
'filename': filename,
|
||||
'filepath': os.path.abspath(filepath),
|
||||
'total_slides': total_slides,
|
||||
'processed_slides': len(slides_data),
|
||||
'image_output_dir': output_dir if extract_images else '',
|
||||
'slides': slides_data,
|
||||
}
|
||||
|
||||
indent = 2 if pretty else None
|
||||
print(json.dumps(output, ensure_ascii=False, indent=indent))
|
||||
print(f"\n✅ 추출 완료: {len(slides_data)}개 슬라이드", file=sys.stderr)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# CLI 진입점
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='PPTX 기획서를 JSON 구조로 추출합니다.',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument('filepath', nargs='?', help='PPTX 파일 경로')
|
||||
parser.add_argument('--extract-images', action='store_true', help='슬라이드 이미지를 PNG로 추출')
|
||||
parser.add_argument('--output-dir', default='', help='이미지 추출 디렉토리')
|
||||
parser.add_argument('--slides', default='', help='처리할 슬라이드 범위 (예: 1-10, 5, 3-7,10)')
|
||||
parser.add_argument('--auto-install', action='store_true', help='python-pptx 자동 설치')
|
||||
parser.add_argument('--pretty', action='store_true', help='JSON 들여쓰기 출력')
|
||||
args = parser.parse_args()
|
||||
|
||||
_ensure_pptx(args.auto_install)
|
||||
|
||||
if not args.filepath:
|
||||
parser.print_help()
|
||||
print("\n❌ PPTX 파일 경로를 입력해 주세요.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
extract_pptx(
|
||||
filepath=args.filepath,
|
||||
extract_images=args.extract_images,
|
||||
output_dir=args.output_dir,
|
||||
slide_range=args.slides,
|
||||
pretty=args.pretty,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
138
skills/plan-translation-generator/SKILL.md
Normal file
138
skills/plan-translation-generator/SKILL.md
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
name: plan-translation-generator
|
||||
description: 번역 요청 엑셀 파일의 EN 셀을 기반으로 '번역코드' 컬럼에 함축적인 코드를 자동 생성합니다. 사용자가 "번역코드 만들어줘", "translation key 생성", "번역 키 추출" 등을 요청하면 트리거됩니다.
|
||||
---
|
||||
|
||||
# 번역 코드 생성 (Translation Key Generator)
|
||||
|
||||
이 skill 은 번역 요청 엑셀 파일의 **EN 컬럼** 텍스트를 분석하여
|
||||
`번역코드` 컬럼에 함축적이고 일관된 코드를 자동으로 작성합니다.
|
||||
|
||||
## 코드 생성 규칙
|
||||
|
||||
| 규칙 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| **단어 수** | 3단어 이하 | `NAV-main` ✅ / `NAV-main-section-title` ❌ |
|
||||
| **구분자** | 단어 사이 `-` (하이픈) | `EVENT-title` |
|
||||
| **첫 단어** | 카테고리를 나타내는 **대문자** | `HERO`, `NAV`, `TOAST` |
|
||||
| **함축성** | 텍스트의 핵심 의미를 최대한 압축 | `HERO-czn-title` (CHAOS ZERO NIGHTMARE → czn) |
|
||||
|
||||
## 카테고리 가이드
|
||||
|
||||
| 카테고리 | 사용 상황 | 예시 |
|
||||
|----------|----------|------|
|
||||
| `BRAND` | 브랜드명, 서비스 이름 | `BRAND-smilegate-ax` |
|
||||
| `NAV` | 내비게이션, 메뉴 항목 | `NAV-main`, `NAV-location` |
|
||||
| `EVENT` | 이벤트 정보 (제목/날짜/장소) | `EVENT-title`, `EVENT-date`, `EVENT-venue` |
|
||||
| `HERO` | 히어로 배너 텍스트 | `HERO-miresi-title` |
|
||||
| `SECTION` | 섹션 헤더/소제목 | `SECTION-intro` |
|
||||
| `BTN` | 버튼 레이블 | `BTN-register`, `BTN-more` |
|
||||
| `TOAST` | 토스트/알림 메시지 | `TOAST-link-copied`, `TOAST-paste-hint` |
|
||||
| `LABEL` | 폼 레이블, 태그 | `LABEL-date`, `LABEL-venue` |
|
||||
| `MSG` | 일반 안내 메시지 | `MSG-loading`, `MSG-empty` |
|
||||
| `MODAL` | 모달/팝업 내 텍스트 | `MODAL-confirm-title` |
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. **파일 확인**
|
||||
- 사용자가 엑셀 파일 경로를 제공했는지 확인
|
||||
- 미제공 시: 파일 경로를 먼저 요청
|
||||
|
||||
2. **데이터 파악**
|
||||
```python
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook('파일경로.xlsx')
|
||||
ws = wb.active
|
||||
# 헤더 행에서 '번역코드', 'EN' 컬럼 인덱스 찾기
|
||||
```
|
||||
- 1행(헤더)에서 `번역코드` 컬럼과 `EN` 컬럼 위치를 동적으로 탐지
|
||||
- 데이터가 있는 행(EN 값이 None이 아닌 행)만 처리
|
||||
|
||||
3. **코드 생성**
|
||||
- EN 텍스트를 분석해 카테고리와 핵심 키워드 추출
|
||||
- 이미 `번역코드` 값이 있는 행은 **덮어쓰지 않음** (사용자 확인 후 진행)
|
||||
- 생성한 코드 목록을 사용자에게 미리 보여주고 승인 요청
|
||||
|
||||
4. **중복 코드 검사** ← 저장 전 반드시 수행
|
||||
- 기존에 이미 작성된 코드 + 이번에 새로 생성한 코드를 **전체 합산**하여 중복 여부 검사
|
||||
- 중복이 발견되면 **저장을 중단**하고 사용자에게 아래 형식으로 보고:
|
||||
```
|
||||
⚠️ 중복 코드 발견
|
||||
- HERO-title: 3행, 12행 (2건)
|
||||
- EVENT-date: 8행, 15행 (2건)
|
||||
```
|
||||
- 중복 해소 방법을 제안하고 사용자 승인 후 재생성
|
||||
- 중복이 없으면 "중복 없음 확인" 메시지 출력 후 저장 진행
|
||||
|
||||
5. **엑셀 저장**
|
||||
- 사용자 승인 후 `번역코드` 컬럼에 값 기입 및 저장
|
||||
- 저장 완료 메시지와 변경 내역 요약 출력
|
||||
|
||||
6. **참조 MD 파일 생성**
|
||||
- 엑셀 저장 완료 후 로컬 참조용 MD 파일을 생성 또는 갱신한다.
|
||||
- 파일 위치: `docs/translation/<엑셀파일명>.md` (확장자 `.xlsx` → `.md` 치환)
|
||||
- 예) `smilegate-ax-2026.xlsx` → `docs/translation/smilegate-ax-2026.md`
|
||||
- `docs/translation/` 디렉토리가 없으면 자동 생성한다.
|
||||
- 파일 내용: 엑셀의 **전체** 번역코드(기존 + 신규)를 아래 형식으로 작성
|
||||
- 헤더에서 `KO` 컬럼을 탐지하여 함께 기록한다 (개발 시 번역코드 매칭 참조용)
|
||||
```markdown
|
||||
# 번역 코드 참조 — <엑셀파일명>
|
||||
|
||||
> 생성일: YYYY-MM-DD
|
||||
> 원본 파일: <엑셀파일 상대경로>
|
||||
|
||||
| 번역코드 | KO | EN |
|
||||
|----------|----|----|
|
||||
| BRAND-smilegate-ax | 스마일게이트 x AX | Smilegate x AX |
|
||||
| NAV-main | 메인 | Main |
|
||||
| ... | ... | ... |
|
||||
```
|
||||
- 파일이 이미 존재하면 **덮어쓰지 않는다**. 새로 생성된 코드 행만 테이블 하단에 추가(append)한다.
|
||||
- 이미 파일에 존재하는 번역코드는 중복 추가하지 않는다.
|
||||
- 생성 완료 후 경로와 추가된 행 수를 사용자에게 출력한다.
|
||||
|
||||
## 코드 작성 예시 (실제 케이스)
|
||||
|
||||
| EN 텍스트 | 생성 코드 | 근거 |
|
||||
|-----------|----------|------|
|
||||
| `Smilegate x AX` | `BRAND-smilegate-ax` | 브랜드명 전체 |
|
||||
| `Main` | `NAV-main` | 내비 메인 항목 |
|
||||
| `Program & Events` | `NAV-program-events` | 내비 항목, 특수문자 제거 |
|
||||
| `Smilegate x Anime Expo` | `EVENT-title` | 이벤트 대표 제목 |
|
||||
| `07/02/2026 (THU)~ 07/05/2026 (SUN)` | `EVENT-date` | 날짜 데이터 |
|
||||
| `Los Angeles Convention Center` | `EVENT-venue` | 행사 장소 |
|
||||
| `The link has been copied.` | `TOAST-link-copied` | 토스트 알림, 링크 복사 완료 |
|
||||
| `You may now paste it elsewhere.` | `TOAST-paste-hint` | 토스트 알림, 붙여넣기 안내 |
|
||||
| `MIRESI ARRIVES AT <br> ANIME EXPO!` | `HERO-miresi-title` | 히어로 배너, 캐릭터명 |
|
||||
| `CHAOS ZERO NIGHTMARE ARRIVES AT <br> ANIME EXPO!` | `HERO-czn-title` | 히어로 배너, 약어 처리 |
|
||||
|
||||
## 처리 스크립트
|
||||
|
||||
스크립트 파일: `scripts/generate_translation_keys.py`
|
||||
|
||||
```bash
|
||||
python scripts/generate_translation_keys.py <엑셀파일경로>
|
||||
```
|
||||
|
||||
스크립트 주요 함수:
|
||||
|
||||
| 함수 | 역할 |
|
||||
|------|------|
|
||||
| `load_existing_codes(ws, code_col)` | 기존 번역 코드를 `{행번호: 코드}` 로 수집 |
|
||||
| `check_duplicates(existing, new)` | 기존+신규 코드 합산 중복 검사, 결과 반환 |
|
||||
| `run(filepath, new_codes)` | 중복 검사 → 저장 실행 (중복 시 중단) |
|
||||
|
||||
> `new_codes` 딕셔너리는 Claude AI가 EN 텍스트를 분석하여 직접 채웁니다.
|
||||
> 스크립트는 중복 검사와 저장만 담당합니다.
|
||||
|
||||
## 주의사항
|
||||
|
||||
- **행 순서 고정**: 엑셀의 기존 행 순서를 절대 변경하지 않는다. 코드는 원래 행 위치에 그대로 기입한다.
|
||||
- **이미지 셀 보존**: 셀에 이미지가 삽입된 경우 해당 셀 및 시트의 이미지를 덮어쓰거나 삭제하지 않는다. `openpyxl.load_workbook(path, keep_vba=True)` 옵션을 사용하고, 이미지 객체(`ws._images`)를 건드리지 않는다.
|
||||
- **기존 코드 보존**: 이미 `번역코드` 값이 있는 셀은 덮어쓰지 않는다.
|
||||
- **중복 코드 방지**: 동일한 코드가 두 행에 생기지 않도록 확인한다.
|
||||
- **HTML 태그 무시**: `<br>`, `<b>` 등 마크업 태그는 의미 분석에서 제외한다.
|
||||
- **날짜/숫자 데이터**: 날짜 형식 셀은 `EVENT-date`, `LABEL-date` 등 의미 기반으로 처리한다.
|
||||
- **약어 처리**: 3단어 초과 시 캐릭터명·고유명사를 약어(초성/이니셜)로 압축한다.
|
||||
- 예) `CHAOS ZERO NIGHTMARE` → `czn`
|
||||
- 사용자가 코드를 직접 수정 요청하면 수정 후 재저장한다.
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
번역 코드 자동 생성 스크립트
|
||||
사용법: python generate_translation_keys.py <엑셀파일경로>
|
||||
|
||||
주의:
|
||||
- 행 순서를 변경하지 않는다 (원래 행 위치에만 값 기입)
|
||||
- 셀에 삽입된 이미지를 보존한다 (ws._images 미수정)
|
||||
"""
|
||||
import sys
|
||||
import openpyxl
|
||||
|
||||
|
||||
def generate_translation_key(en_text: str) -> str:
|
||||
"""
|
||||
EN 텍스트를 분석하여 번역 코드를 생성합니다.
|
||||
규칙:
|
||||
- 3단어 이하 (CATEGORY-word1-word2)
|
||||
- 단어 사이 '-' 구분
|
||||
- 첫 단어는 카테고리 대문자
|
||||
- 함축적인 의미로 생성
|
||||
|
||||
NOTE: 이 함수는 템플릿입니다.
|
||||
실제 코드 생성은 Claude AI가 EN 텍스트의 의미를 분석하여 수행합니다.
|
||||
"""
|
||||
raise NotImplementedError("AI가 EN 텍스트를 분석하여 코드를 직접 생성합니다.")
|
||||
|
||||
|
||||
def load_existing_codes(ws, code_col: int) -> dict[int, str]:
|
||||
"""기존에 입력된 번역 코드를 {행번호: 코드} 형태로 반환합니다."""
|
||||
return {
|
||||
row: ws.cell(row=row, column=code_col).value
|
||||
for row in range(2, ws.max_row + 1)
|
||||
if ws.cell(row=row, column=code_col).value
|
||||
}
|
||||
|
||||
|
||||
def check_duplicates(existing: dict[int, str], new: dict[int, str]) -> dict[str, list[int]]:
|
||||
"""
|
||||
기존 코드 + 신규 코드 전체를 합산하여 중복 검사합니다.
|
||||
반환: {중복코드: [행번호, ...]}
|
||||
"""
|
||||
seen: dict[str, int] = {}
|
||||
duplicates: dict[str, list[int]] = {}
|
||||
|
||||
for row_num, code in {**existing, **new}.items():
|
||||
if code in seen:
|
||||
duplicates.setdefault(code, [seen[code]]).append(row_num)
|
||||
else:
|
||||
seen[code] = row_num
|
||||
|
||||
return duplicates
|
||||
|
||||
|
||||
def run(filepath: str, new_codes: dict[int, str]) -> None:
|
||||
"""
|
||||
신규 코드를 중복 검사 후 엑셀에 저장합니다.
|
||||
|
||||
Args:
|
||||
filepath: 엑셀 파일 경로
|
||||
new_codes: {행번호: 생성된코드} 딕셔너리
|
||||
"""
|
||||
# keep_vba=True: 이미지·VBA 등 파일 내 임베딩 요소를 보존
|
||||
wb = openpyxl.load_workbook(filepath, keep_vba=True)
|
||||
ws = wb.active
|
||||
|
||||
# 이미지가 있는 셀 좌표를 미리 수집 (덮어쓰기 방지)
|
||||
image_cells: set[tuple[int, int]] = set()
|
||||
for img in getattr(ws, '_images', []):
|
||||
anchor = img.anchor
|
||||
if hasattr(anchor, '_from'):
|
||||
image_cells.add((anchor._from.row + 1, anchor._from.col + 1)) # 1-based
|
||||
|
||||
# 헤더에서 컬럼 인덱스 탐지
|
||||
headers = [cell.value for cell in ws[1]]
|
||||
if '번역코드' not in headers or 'EN' not in headers:
|
||||
print("❌ 헤더에 '번역코드' 또는 'EN' 컬럼이 없습니다.")
|
||||
sys.exit(1)
|
||||
|
||||
code_col = headers.index('번역코드') + 1 # 1-based
|
||||
en_col = headers.index('EN') + 1 # 1-based
|
||||
|
||||
# 기존 코드 수집
|
||||
existing_codes = load_existing_codes(ws, code_col)
|
||||
|
||||
# 빈 행 / 이미 코드 있는 행 필터링
|
||||
rows_to_fill: list[tuple[int, str]] = []
|
||||
for row in ws.iter_rows(min_row=2):
|
||||
row_num = row[0].row
|
||||
en_val = row[en_col - 1].value
|
||||
code_val = row[code_col - 1].value
|
||||
|
||||
if not en_val:
|
||||
continue # 빈 행 스킵
|
||||
if code_val:
|
||||
continue # 기존 코드 보존
|
||||
if (row_num, code_col) in image_cells:
|
||||
print(f" ⚠️ Row {row_num}: 이미지 셀 — 스킵 (보존)")
|
||||
continue # 이미지 있는 셀 보존
|
||||
|
||||
if row_num in new_codes:
|
||||
rows_to_fill.append((row_num, new_codes[row_num]))
|
||||
|
||||
if not rows_to_fill:
|
||||
print("ℹ️ 입력할 신규 코드가 없습니다.")
|
||||
return
|
||||
|
||||
# 중복 검사
|
||||
new_codes_filtered = dict(rows_to_fill)
|
||||
duplicates = check_duplicates(existing_codes, new_codes_filtered)
|
||||
|
||||
if duplicates:
|
||||
print("⚠️ 중복 코드 발견 — 저장 중단")
|
||||
for code, rows in duplicates.items():
|
||||
print(f" - {code}: {', '.join(map(str, rows))}행 ({len(rows)}건)")
|
||||
print("\n중복을 해소한 뒤 다시 실행해 주세요.")
|
||||
sys.exit(1)
|
||||
|
||||
# 중복 없음 → 저장
|
||||
print("✅ 중복 없음 확인")
|
||||
for row_num, code in rows_to_fill:
|
||||
ws.cell(row=row_num, column=code_col, value=code)
|
||||
en_text = ws.cell(row=row_num, column=en_col).value
|
||||
print(f" Row {row_num}: {code:<30} ← \"{en_text}\"")
|
||||
|
||||
wb.save(filepath)
|
||||
print(f"\n저장 완료: {filepath}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("사용법: python generate_translation_keys.py <엑셀파일경로>")
|
||||
sys.exit(1)
|
||||
|
||||
# new_codes는 Claude AI가 EN 텍스트를 분석하여 채워줍니다.
|
||||
# 예시:
|
||||
# new_codes = {
|
||||
# 2: 'BRAND-smilegate-ax',
|
||||
# 3: 'NAV-main',
|
||||
# }
|
||||
new_codes: dict[int, str] = {}
|
||||
|
||||
run(sys.argv[1], new_codes)
|
||||
268
skills/ppt-maker/SKILL.md
Normal file
268
skills/ppt-maker/SKILL.md
Normal file
@@ -0,0 +1,268 @@
|
||||
---
|
||||
name: ppt-maker
|
||||
description: 순수 HTML, CSS, JavaScript로 웹 프레젠테이션(슬라이드)을 생성합니다. 외부 라이브러리 없이 단일 HTML 파일로 완성되며, GPU 가속 슬라이드 전환, 키보드/터치 네비게이션, 진행 표시바, 반응형 디자인을 포함합니다. 사용자가 프레젠테이션, 슬라이드, 발표자료, PPT 등을 만들어달라고 요청할 때 사용하세요. 'PPT 만들어줘', '슬라이드 생성', '발표자료 만들어줘', 'create a presentation', 'make slides about' 등의 요청에 반응합니다.
|
||||
---
|
||||
|
||||
# PPT Maker
|
||||
|
||||
순수 HTML/CSS/JS 단일 파일 웹 프레젠테이션 생성기.
|
||||
|
||||
## 워크플로우
|
||||
|
||||
1. `references/template.html`을 읽어 전체 구조(CSS/JS)를 파악한다
|
||||
2. 사용자의 주제, 내용, 슬라이드 수 요구사항을 정리한다
|
||||
3. 슬라이드 구성을 설계한다 (어떤 타입의 슬라이드를 어떤 순서로)
|
||||
4. 템플릿의 CSS와 JS를 그대로 유지하면서 슬라이드 HTML 콘텐츠만 교체한다
|
||||
5. 단일 .html 파일로 출력한다
|
||||
6. `open` 명령으로 브라우저에서 열어 확인한다
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
- **단일 파일**: 모든 CSS, JS가 HTML 안에 인라인
|
||||
- **외부 의존성 제로**: Google Fonts만 예외 (CDN 링크)
|
||||
- **GPU 가속**: 전환 애니메이션은 `transform`과 `opacity`만 사용
|
||||
- **뷰포트 피팅**: 모든 슬라이드가 스크롤 없이 화면에 딱 맞아야 함
|
||||
- **접근성**: ARIA 속성, prefers-reduced-motion 대응
|
||||
|
||||
## 디자인 시스템
|
||||
|
||||
### CSS Custom Properties
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--bg-subtle: #111118;
|
||||
--accent: #C6A55C;
|
||||
--accent-dim: rgba(198, 165, 92, 0.15);
|
||||
--accent-glow: rgba(198, 165, 92, 0.3);
|
||||
--text-primary: #F0EBE0;
|
||||
--text-secondary: #B8B0A0;
|
||||
--text-dim: #706858;
|
||||
--border: #1e1c18;
|
||||
--font-kr: 'Noto Sans KR', sans-serif;
|
||||
--font-en: 'Inter', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
```
|
||||
|
||||
사용자가 다른 컬러 테마를 원하면 이 변수들만 교체한다.
|
||||
|
||||
### 타이포그래피
|
||||
|
||||
| 요소 | 크기 | 비고 |
|
||||
|------|------|------|
|
||||
| h1 | `clamp(2rem, 5vw, 4rem)` | 타이틀 슬라이드 |
|
||||
| h2 | `clamp(1.4rem, 3vw, 2.4rem)` | 섹션 제목 |
|
||||
| h3 | `clamp(1.1rem, 2vw, 1.6rem)` | 서브 제목 |
|
||||
| p, li | `clamp(0.85rem, 1.5vw, 1.2rem)` | 본문 |
|
||||
| code | `clamp(0.65rem, 1.1vw, 0.9rem)` | 코드 블록 |
|
||||
|
||||
### 컴포넌트 카탈로그
|
||||
|
||||
- **info-card**: 정보 카드 (`background: rgba(255,255,255,0.02)`, `border: 1px solid var(--border)`)
|
||||
- **split**: 2열 그리드 (`grid-template-columns: 1fr 1fr`)
|
||||
- **split-card**: split 내부 카드
|
||||
- **badge**: 태그/라벨 (`border-radius: 100px`, accent 컬러)
|
||||
- **terminal-badge**: 모노스페이스 라벨 + 깜빡이는 커서 (`> `)
|
||||
- **accent-line**: 강조선 (`width: 50px; height: 2px; background: var(--accent)`)
|
||||
- **code-block**: 줄 번호 + 하이라이트 지원 코드 블록
|
||||
|
||||
## 슬라이드 타입 카탈로그
|
||||
|
||||
### 1. 타이틀 슬라이드 (slide--intro)
|
||||
|
||||
```html
|
||||
<section class="slide slide--intro active" id="slide-1"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 1" aria-hidden="false">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">카테고리</div>
|
||||
<h1 class="reveal">메인 타이틀</h1>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<p class="reveal" style="font-size: clamp(0.95rem, 1.8vw, 1.3rem);">서브 타이틀</p>
|
||||
<div class="accent-line reveal"></div>
|
||||
<p class="reveal" style="font-size: clamp(0.7rem, 1vw, 0.85rem); color: var(--text-dim);">
|
||||
← → 키 또는 스와이프로 이동
|
||||
</p>
|
||||
</div>
|
||||
<div class="slide__footer">날짜</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### 2. 리스트 콘텐츠 (slide--content / slide--content-alt)
|
||||
|
||||
```html
|
||||
<section class="slide slide--content" id="slide-N"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 N" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">영문 키워드</div>
|
||||
<h2 class="reveal">섹션 제목</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<ul>
|
||||
<li class="reveal">항목 1</li>
|
||||
<li class="reveal">항목 2</li>
|
||||
<li class="reveal">항목 3</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
`slide--content`와 `slide--content-alt`를 번갈아 사용하면 배경 그라데이션 방향이 달라져 시각적 변화를 준다.
|
||||
|
||||
### 3. 코드 슬라이드 (slide--code)
|
||||
|
||||
```html
|
||||
<section class="slide slide--code" id="slide-N"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 N" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<h2 class="reveal" style="color: var(--accent);">코드 제목</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<pre class="code-block reveal" data-language="javascript" data-highlight-lines="2,4-6"><code><span class="line"><span class="kw">const</span> <span class="var">x</span> = <span class="str">42</span>;</span>
|
||||
<span class="line"><span class="cmt">// 강조될 줄</span></span></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
**신택스 클래스**: `kw`(키워드), `fn`(함수), `str`(문자열/숫자), `cmt`(주석), `var`(변수), `op`(연산자), `tag`(태그), `attr`(속성), `val`(값)
|
||||
|
||||
`data-highlight-lines`에 강조할 줄 번호를 지정한다 (예: "2,4-6").
|
||||
|
||||
### 4. 분할 레이아웃 (slide--diagram)
|
||||
|
||||
```html
|
||||
<section class="slide slide--diagram" id="slide-N"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 N" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">키워드</div>
|
||||
<h2 class="reveal">제목</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<div class="split">
|
||||
<div class="split-card reveal">
|
||||
<p><strong style="color: var(--accent);">왼쪽 제목</strong><br>설명</p>
|
||||
</div>
|
||||
<div class="split-card reveal">
|
||||
<p><strong style="color: #D4B87A;">오른쪽 제목</strong><br>설명</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### 5. 정보 카드 슬라이드
|
||||
|
||||
```html
|
||||
<div class="slide__body">
|
||||
<div class="info-card reveal">
|
||||
<div class="card-title">// 카드 라벨</div>
|
||||
<p>카드 내용</p>
|
||||
</div>
|
||||
<div class="info-card reveal">
|
||||
<div class="card-title">// 카드 라벨 2</div>
|
||||
<p>카드 내용 2</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 6. 마무리 슬라이드 (slide--end)
|
||||
|
||||
```html
|
||||
<section class="slide slide--end" id="slide-N"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 N" aria-hidden="true">
|
||||
<div class="slide__inner" style="text-align: center; align-items: center;">
|
||||
<div class="slide__body" style="align-items: center;">
|
||||
<h1 class="reveal">감사합니다</h1>
|
||||
<div class="accent-line reveal" style="margin: 0.8rem auto;"></div>
|
||||
<p class="reveal">마무리 메시지</p>
|
||||
<div class="reveal" style="display: flex; gap: 0.5rem; margin-top: 1rem;">
|
||||
<span class="badge">태그1</span>
|
||||
<span class="badge">태그2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
## 콘텐츠 작성 규칙
|
||||
|
||||
### reveal 순차 등장
|
||||
|
||||
- 모든 콘텐츠 요소에 `class="reveal"` 추가
|
||||
- CSS `transition-delay`로 nth-child 기반 순차 등장이 자동 적용됨
|
||||
- 최대 8단계까지 지원 (0.08s 간격)
|
||||
|
||||
### 슬라이드 구조 필수 규칙
|
||||
|
||||
- 첫 번째 슬라이드만 `class="slide ... active"`, `aria-hidden="false"`
|
||||
- 나머지는 `aria-hidden="true"`, active 클래스 없음
|
||||
- 모든 슬라이드에 고유 `id="slide-N"` (1부터 시작)
|
||||
- `role="region"`, `aria-roledescription="슬라이드"`, `aria-label="슬라이드 N"` 필수
|
||||
|
||||
### 슬라이드 수 가이드
|
||||
|
||||
- 사용자가 지정하면 그대로 따름
|
||||
- 지정하지 않으면 주제에 맞게 7~12장 구성
|
||||
- 기본 구성: 타이틀(1) + 개요(1) + 본론(3~8) + 마무리(1)
|
||||
|
||||
### 콘텐츠 밀도
|
||||
|
||||
- 리스트: 슬라이드당 최대 5개 항목
|
||||
- 텍스트: 한 단락 2~3줄 이내
|
||||
- 코드: 최대 10줄
|
||||
- **뷰포트에 딱 맞아야 함 — 스크롤이 발생하면 안 된다**
|
||||
|
||||
## 기술 요구사항
|
||||
|
||||
### 네비게이션
|
||||
|
||||
- **키보드**: ← → / ↑ ↓ / Space / PageUp·Down / Home·End / F(전체화면)
|
||||
- **터치**: 스와이프 (threshold 50px, `passive:false`로 `preventDefault`)
|
||||
- **버튼**: 좌하단 이전/다음 버튼
|
||||
- **URL hash**: `#slide-N` 동기화 + 뒤로가기 지원
|
||||
|
||||
### 전환 애니메이션
|
||||
|
||||
- `transform: translateX(±100%)` + `opacity` (GPU 가속, Composite 전용)
|
||||
- duration: 400~450ms
|
||||
- `isAnimating` 잠금 + `transitionend` + `setTimeout(duration+150)` fallback
|
||||
- `will-change`는 전환 직전 설정, 완료 후 `auto`로 해제
|
||||
|
||||
### UI 요소
|
||||
|
||||
- **진행 표시바**: 상단 2px, accent 컬러
|
||||
- **슬라이드 카운터**: 우하단 `01 / 10` 형식 (2자리 zero-pad)
|
||||
- **네비게이션 버튼**: 좌하단 ‹ › 버튼
|
||||
|
||||
### 접근성
|
||||
|
||||
- `prefers-reduced-motion: reduce`에서 transition-duration 최소화
|
||||
- 모바일 터치 타겟 최소 44px (`@media (hover: none)`)
|
||||
- `100dvh` 사용 (모바일 주소창 동적 대응)
|
||||
|
||||
## 주의사항
|
||||
|
||||
- `display:none` 사용 금지 — transition 불가
|
||||
- 초기 위치: `transition:none` → 위치 설정 → `void el.offsetWidth` → `transition` 복원
|
||||
- Space 키는 반드시 `preventDefault()` (페이지 스크롤 방지)
|
||||
- `will-change`를 모든 슬라이드에 정적 적용하면 GPU 메모리 낭비
|
||||
- 애니메이션 duration 500ms 초과 금지 (사용자 지루함 유발)
|
||||
- `e.repeat` 체크로 키 반복 이벤트 차단
|
||||
|
||||
## 슬라이드 카운터 업데이트
|
||||
|
||||
JS에서 슬라이드 총 개수를 반드시 실제 슬라이드 수와 일치시킨다. `slideCounter` 초기 텍스트도 `01 / {총 슬라이드 수}`로 설정한다.
|
||||
|
||||
## 참고 템플릿
|
||||
|
||||
`references/template.html`에 10장짜리 완전한 동작 예시가 있다.
|
||||
이 파일의 **CSS와 JS를 기반으로** 슬라이드 HTML 콘텐츠만 교체하여 새 프레젠테이션을 만든다.
|
||||
914
skills/ppt-maker/references/template.html
Normal file
914
skills/ppt-maker/references/template.html
Normal file
@@ -0,0 +1,914 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>바닐라 슬라이드 프레젠테이션</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&family=Inter:wght@400;700;900&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* ===== CSS Custom Properties ===== */
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--bg-subtle: #111118;
|
||||
--accent: #C6A55C;
|
||||
--accent-dim: rgba(198, 165, 92, 0.15);
|
||||
--accent-glow: rgba(198, 165, 92, 0.3);
|
||||
--text-primary: #F0EBE0;
|
||||
--text-secondary: #B8B0A0;
|
||||
--text-dim: #706858;
|
||||
--border: #1e1c18;
|
||||
|
||||
--font-kr: 'Noto Sans KR', sans-serif;
|
||||
--font-en: 'Inter', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ===== 리셋 ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-kr), var(--font-en), sans-serif;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== 뷰포트: fixed 풀스크린 ===== */
|
||||
.slide-viewport {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-viewport.animating { pointer-events: none; }
|
||||
|
||||
/* ===== 슬라이드: absolute 풀스크린 ===== */
|
||||
.slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: clamp(2rem, 5vw, 5rem);
|
||||
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 400ms ease-in-out,
|
||||
transform 450ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.slide.active {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.slide.stand-left { transform: translateX(-100%); }
|
||||
|
||||
/* ===== 배경 효과 ===== */
|
||||
.slide::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(0deg,
|
||||
transparent, transparent 2px,
|
||||
rgba(255, 255, 255, 0.005) 2px,
|
||||
rgba(255, 255, 255, 0.005) 4px);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slide::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(198, 165, 92, 0.015) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(198, 165, 92, 0.015) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slide > * { position: relative; z-index: 2; }
|
||||
|
||||
/* ===== 콘텐츠 영역 ===== */
|
||||
.slide__inner {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: clamp(0.8rem, 2vw, 1.5rem);
|
||||
}
|
||||
|
||||
.slide__header {}
|
||||
.slide__body { display: flex; flex-direction: column; justify-content: center; gap: 1rem; }
|
||||
.slide__footer { font-size: clamp(0.6rem, 1vw, 0.8rem); color: var(--text-dim); text-align: right; margin-top: auto; }
|
||||
|
||||
/* ===== 타이포 ===== */
|
||||
h1 {
|
||||
font-family: var(--font-kr), var(--font-en), sans-serif;
|
||||
font-size: clamp(2rem, 5vw, 4rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
h2 {
|
||||
font-family: var(--font-kr), var(--font-en), sans-serif;
|
||||
font-size: clamp(1.4rem, 3vw, 2.4rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-kr), var(--font-en), sans-serif;
|
||||
font-size: clamp(1.1rem, 2vw, 1.6rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
p, li {
|
||||
font-family: var(--font-kr), var(--font-en), sans-serif;
|
||||
font-size: clamp(0.85rem, 1.5vw, 1.2rem);
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
ul { list-style: none; padding: 0; }
|
||||
ul li {
|
||||
padding: clamp(0.5rem, 0.8vw, 0.7rem) clamp(0.8rem, 1.2vw, 1rem);
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
ul li::before { content: '→ '; color: var(--accent); font-weight: bold; font-family: var(--font-mono); flex-shrink: 0; }
|
||||
|
||||
/* ===== 슬라이드 배경 변형 ===== */
|
||||
.slide--intro { background: radial-gradient(ellipse at 30% 50%, rgba(198,165,92,0.06) 0%, var(--bg) 70%); }
|
||||
.slide--content { background: radial-gradient(ellipse at 70% 40%, rgba(198,165,92,0.04) 0%, var(--bg) 70%); }
|
||||
.slide--content-alt { background: radial-gradient(ellipse at 30% 60%, rgba(198,165,92,0.04) 0%, var(--bg) 70%); }
|
||||
.slide--code { background: var(--bg); }
|
||||
.slide--diagram { background: radial-gradient(ellipse at 50% 30%, rgba(198,165,92,0.05) 0%, var(--bg) 70%); }
|
||||
.slide--end { background: radial-gradient(ellipse at 50% 50%, rgba(198,165,92,0.08) 0%, var(--bg) 60%); }
|
||||
|
||||
/* ===== reveal 애니메이션 (슬라이드 진입 시 순차 등장) ===== */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: opacity 0.6s var(--ease-out-expo), transform 0.6s var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.slide.active .reveal {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.reveal:nth-child(1) { transition-delay: 0.08s; }
|
||||
.reveal:nth-child(2) { transition-delay: 0.16s; }
|
||||
.reveal:nth-child(3) { transition-delay: 0.24s; }
|
||||
.reveal:nth-child(4) { transition-delay: 0.32s; }
|
||||
.reveal:nth-child(5) { transition-delay: 0.40s; }
|
||||
.reveal:nth-child(6) { transition-delay: 0.48s; }
|
||||
.reveal:nth-child(7) { transition-delay: 0.56s; }
|
||||
.reveal:nth-child(8) { transition-delay: 0.64s; }
|
||||
|
||||
/* .slide__body 내부 자식도 순차 등장 */
|
||||
.slide.active .slide__body > .reveal:nth-child(1) { transition-delay: 0.15s; }
|
||||
.slide.active .slide__body > .reveal:nth-child(2) { transition-delay: 0.25s; }
|
||||
.slide.active .slide__body > .reveal:nth-child(3) { transition-delay: 0.35s; }
|
||||
.slide.active .slide__body > .reveal:nth-child(4) { transition-delay: 0.45s; }
|
||||
.slide.active .slide__body > .reveal:nth-child(5) { transition-delay: 0.55s; }
|
||||
|
||||
/* ul li 순차 등장 */
|
||||
.slide.active ul li.reveal:nth-child(1) { transition-delay: 0.15s; }
|
||||
.slide.active ul li.reveal:nth-child(2) { transition-delay: 0.25s; }
|
||||
.slide.active ul li.reveal:nth-child(3) { transition-delay: 0.35s; }
|
||||
.slide.active ul li.reveal:nth-child(4) { transition-delay: 0.45s; }
|
||||
.slide.active ul li.reveal:nth-child(5) { transition-delay: 0.55s; }
|
||||
|
||||
/* ===== 코드 블록 ===== */
|
||||
.code-block {
|
||||
background: #111118;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.65rem, 1.1vw, 0.9rem);
|
||||
line-height: 1.7;
|
||||
counter-reset: line-number;
|
||||
width: 100%;
|
||||
}
|
||||
.code-block::before {
|
||||
content: attr(data-language);
|
||||
display: block;
|
||||
background: rgba(198,165,92,0.06);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75em;
|
||||
padding: 0.4em 1em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-family: var(--font-mono);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.code-block code { display: block; padding: 1rem 1.2rem; overflow-x: auto; }
|
||||
.code-block .line {
|
||||
display: block;
|
||||
padding: 2px 0 2px 3em;
|
||||
position: relative;
|
||||
counter-increment: line-number;
|
||||
transition: background-color 0.3s ease, opacity 0.3s ease;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.code-block .line::before {
|
||||
content: counter(line-number);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 2.5em;
|
||||
text-align: right;
|
||||
color: #3a3830;
|
||||
font-size: 0.85em;
|
||||
user-select: none;
|
||||
}
|
||||
.code-block .line--highlight {
|
||||
background: rgba(198, 165, 92, 0.08);
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: calc(3em - 3px);
|
||||
}
|
||||
.code-block .line--highlight::before { color: var(--accent); }
|
||||
.code-block.dim-others .line:not(.line--highlight) { opacity: 0.3; }
|
||||
|
||||
/* 신택스 컬러 */
|
||||
.kw { color: #C6A55C; }
|
||||
.fn { color: #D4B87A; }
|
||||
.str { color: #A89070; }
|
||||
.cmt { color: #555040; font-style: italic; }
|
||||
.var { color: #E0D0B0; }
|
||||
.op { color: #8A8070; }
|
||||
.tag { color: #C6A55C; }
|
||||
.attr { color: #D4B87A; }
|
||||
.val { color: #A89070; }
|
||||
|
||||
/* ===== 컴포넌트 ===== */
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
|
||||
|
||||
.split-card {
|
||||
padding: clamp(0.8rem, 1.5vw, 1.2rem);
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
padding: clamp(1rem, 2vw, 1.5rem);
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-card .card-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.7rem, 1vw, 0.85rem);
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.65rem, 0.9vw, 0.8rem);
|
||||
color: var(--accent);
|
||||
padding: 0.2em 0.6em;
|
||||
border: 1px solid var(--accent-dim);
|
||||
border-radius: 100px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.terminal-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.75rem, 1.1vw, 0.95rem);
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-badge::before {
|
||||
content: '> ';
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.accent-line {
|
||||
width: 50px;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.number-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.7rem, 0.9vw, 0.8rem);
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
width: 2em;
|
||||
}
|
||||
|
||||
/* ===== 네비게이션 UI ===== */
|
||||
.progress-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgba(198,165,92,0.1);
|
||||
z-index: 101;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent);
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.slide-counter {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
z-index: 102;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 24px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 102;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(198,165,92,0.2);
|
||||
background: rgba(198,165,92,0.04);
|
||||
color: var(--text-dim);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
}
|
||||
.nav-btn:hover { background: rgba(198,165,92,0.12); color: var(--accent); border-color: rgba(198,165,92,0.4); }
|
||||
|
||||
.speaker-notes { display: none; }
|
||||
|
||||
/* ===== 반응형 ===== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal, .slide { transition-duration: 0.1s !important; }
|
||||
.reveal { transform: none !important; }
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.nav-btn { width: 44px; height: 44px; }
|
||||
}
|
||||
|
||||
@media (max-height: 600px) {
|
||||
.slide { padding: clamp(1rem, 3vw, 2rem); }
|
||||
h1 { font-size: clamp(1.5rem, 4vw, 2.5rem); }
|
||||
h2 { font-size: clamp(1.2rem, 2.5vw, 1.8rem); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="slide-viewport" id="viewport">
|
||||
|
||||
<!-- ============ 슬라이드 01: 타이틀 ============ -->
|
||||
<section class="slide slide--intro active" id="slide-1"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 1" aria-hidden="false">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Presentation</div>
|
||||
<h1 class="reveal">Vanilla Slides</h1>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<p class="reveal" style="font-size: clamp(0.95rem, 1.8vw, 1.3rem);">
|
||||
순수 HTML, CSS, JavaScript로 만드는<br>웹 기반 프레젠테이션
|
||||
</p>
|
||||
<div class="accent-line reveal"></div>
|
||||
<p class="reveal" style="font-size: clamp(0.7rem, 1vw, 0.85rem); color: var(--text-dim);">
|
||||
← → 키 또는 스와이프로 이동
|
||||
</p>
|
||||
</div>
|
||||
<div class="slide__footer">2026.03</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 02: 왜 바닐라인가 ============ -->
|
||||
<section class="slide slide--content" id="slide-2"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 2" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Why vanilla?</div>
|
||||
<h2 class="reveal">왜 바닐라 JS인가</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<ul>
|
||||
<li class="reveal">외부 의존성 제로 — 번들 크기 0KB</li>
|
||||
<li class="reveal">브라우저 표준만으로 완전한 구현</li>
|
||||
<li class="reveal">커스터마이즈의 자유도 극대화</li>
|
||||
<li class="reveal">단일 HTML 파일로 배포 가능</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 03: 핵심 기능 ============ -->
|
||||
<section class="slide slide--content-alt" id="slide-3"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 3" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Features</div>
|
||||
<h2 class="reveal">핵심 기능</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<ul>
|
||||
<li class="reveal">풀스크린 뷰포트 & 반응형 레이아웃</li>
|
||||
<li class="reveal">키보드 / 터치 / 스와이프 네비게이션</li>
|
||||
<li class="reveal">GPU 가속 슬라이드 전환 애니메이션</li>
|
||||
<li class="reveal">순차 등장 reveal 효과</li>
|
||||
<li class="reveal">코드 블록 라인 하이라이트</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 04: 아키텍처 ============ -->
|
||||
<section class="slide slide--diagram" id="slide-4"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 4" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Architecture</div>
|
||||
<h2 class="reveal">아키텍처</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<div class="split">
|
||||
<div class="split-card reveal">
|
||||
<p style="font-size: clamp(0.8rem, 1.3vw, 1.05rem); margin-bottom: 0.8rem;">
|
||||
<strong style="color: var(--accent);">레이아웃</strong><br>
|
||||
fixed 뷰포트 + absolute 슬라이드
|
||||
</p>
|
||||
<p style="font-size: clamp(0.8rem, 1.3vw, 1.05rem);">
|
||||
<strong style="color: #D4B87A;">전환</strong><br>
|
||||
CSS Transition + transform/opacity
|
||||
</p>
|
||||
</div>
|
||||
<div class="split-card reveal">
|
||||
<p style="font-size: clamp(0.8rem, 1.3vw, 1.05rem); margin-bottom: 0.8rem;">
|
||||
<strong style="color: #B89860;">상태 관리</strong><br>
|
||||
slideIndex 단일 상태 + URL hash
|
||||
</p>
|
||||
<p style="font-size: clamp(0.8rem, 1.3vw, 1.05rem);">
|
||||
<strong style="color: #A08848;">등장 효과</strong><br>
|
||||
.reveal 클래스 + transition-delay
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 05: CSS 레이아웃 ============ -->
|
||||
<section class="slide slide--content" id="slide-5"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 5" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Layout</div>
|
||||
<h2 class="reveal">CSS 레이아웃 전략</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<div class="info-card reveal">
|
||||
<div class="card-title">// 풀스크린 뷰포트</div>
|
||||
<p style="font-size: clamp(0.8rem, 1.2vw, 1rem);">
|
||||
<code style="color: var(--accent); font-family: var(--font-mono);">position: fixed</code> 뷰포트 안에<br>
|
||||
<code style="color: var(--accent); font-family: var(--font-mono);">position: absolute</code> 슬라이드를 겹쳐<br>
|
||||
<code style="color: var(--accent); font-family: var(--font-mono);">transform: translateX</code>로 전환합니다
|
||||
</p>
|
||||
</div>
|
||||
<div class="info-card reveal">
|
||||
<div class="card-title">// 콘텐츠 영역</div>
|
||||
<p style="font-size: clamp(0.8rem, 1.2vw, 1rem);">
|
||||
<code style="color: var(--accent); font-family: var(--font-mono);">max-width: 680px</code>로 읽기 편한 폭 유지<br>
|
||||
<code style="color: var(--accent); font-family: var(--font-mono);">clamp()</code>로 반응형 타이포그래피 처리
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 06: 코드 예시 — HTML ============ -->
|
||||
<section class="slide slide--code" id="slide-6"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 6" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<h2 class="reveal" style="color: var(--accent);">HTML 구조</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<pre class="code-block reveal" data-language="html" data-highlight-lines="2,4-5"><code><span class="line"><span class="op"><</span><span class="tag">div</span> <span class="attr">class</span>=<span class="str">"slide-viewport"</span><span class="op">></span></span>
|
||||
<span class="line"> <span class="op"><</span><span class="tag">section</span> <span class="attr">class</span>=<span class="str">"slide active"</span><span class="op">></span></span>
|
||||
<span class="line"> <span class="op"><</span><span class="tag">div</span> <span class="attr">class</span>=<span class="str">"slide__inner"</span><span class="op">></span></span>
|
||||
<span class="line"> <span class="op"><</span><span class="tag">h2</span> <span class="attr">class</span>=<span class="str">"reveal"</span><span class="op">></span><span class="var">제목</span><span class="op"></</span><span class="tag">h2</span><span class="op">></span></span>
|
||||
<span class="line"> <span class="op"><</span><span class="tag">p</span> <span class="attr">class</span>=<span class="str">"reveal"</span><span class="op">></span><span class="var">내용</span><span class="op"></</span><span class="tag">p</span><span class="op">></span></span>
|
||||
<span class="line"> <span class="op"></</span><span class="tag">div</span><span class="op">></span></span>
|
||||
<span class="line"> <span class="op"></</span><span class="tag">section</span><span class="op">></span></span>
|
||||
<span class="line"><span class="op"></</span><span class="tag">div</span><span class="op">></span></span></code></pre>
|
||||
<p class="reveal" style="font-size: clamp(0.7rem, 1.1vw, 0.95rem); color: var(--accent); margin-top: 0.5rem;">
|
||||
.reveal 클래스로 순차 등장 효과가 자동 적용됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 07: 코드 예시 — JS ============ -->
|
||||
<section class="slide slide--code" id="slide-7"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 7" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<h2 class="reveal" style="color: var(--accent);">슬라이드 전환 로직</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<pre class="code-block reveal" data-language="javascript" data-highlight-lines="3,6-7"><code><span class="line"><span class="fn">goNext</span>() {</span>
|
||||
<span class="line"> <span class="kw">if</span> (<span class="kw">this</span>.<span class="var">isAnimating</span>) <span class="kw">return</span>;</span>
|
||||
<span class="line"> <span class="kw">this</span>.<span class="fn">_goSlide</span>(<span class="kw">this</span>.<span class="var">current</span> + <span class="str">1</span>, <span class="str">'next'</span>);</span>
|
||||
<span class="line">}</span>
|
||||
<span class="line"></span>
|
||||
<span class="line"><span class="cmt">// GPU 가속: transform + opacity만 사용</span></span>
|
||||
<span class="line"><span class="var">to</span>.<span class="var">style</span>.<span class="var">transform</span> = <span class="str">'translateX(0)'</span>;</span>
|
||||
<span class="line"><span class="var">from</span>.<span class="var">style</span>.<span class="var">transform</span> = <span class="str">'translateX(-100%)'</span>;</span></code></pre>
|
||||
<p class="reveal" style="font-size: clamp(0.7rem, 1.1vw, 0.95rem); color: var(--text-dim); margin-top: 0.5rem;">
|
||||
isAnimating 플래그 + transitionend로 이벤트 잠금
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 08: 네비게이션 ============ -->
|
||||
<section class="slide slide--content-alt" id="slide-8"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 8" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Navigation</div>
|
||||
<h2 class="reveal">네비게이션 지원</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<div class="split">
|
||||
<div class="split-card reveal">
|
||||
<h3 style="font-size: clamp(0.85rem, 1.2vw, 1rem); color: var(--accent); margin-bottom: 0.5rem;">키보드</h3>
|
||||
<p style="font-size: clamp(0.75rem, 1.1vw, 0.95rem);">
|
||||
← → 이전/다음<br>
|
||||
Space / PageDown 다음<br>
|
||||
Home / End 처음/끝<br>
|
||||
F 전체화면
|
||||
</p>
|
||||
</div>
|
||||
<div class="split-card reveal">
|
||||
<h3 style="font-size: clamp(0.85rem, 1.2vw, 1rem); color: var(--accent); margin-bottom: 0.5rem;">터치</h3>
|
||||
<p style="font-size: clamp(0.75rem, 1.1vw, 0.95rem);">
|
||||
좌 스와이프 → 다음<br>
|
||||
우 스와이프 → 이전<br>
|
||||
threshold: 50px<br>
|
||||
touch-action: pan-y
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card reveal" style="margin-top: 0.8rem;">
|
||||
<div class="card-title">// URL 동기화</div>
|
||||
<p style="font-size: clamp(0.75rem, 1.1vw, 0.95rem);">
|
||||
<code style="color: var(--accent); font-family: var(--font-mono);">#slide-N</code> 해시로 뒤로가기 및 직접 접근 지원
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 09: 주의사항 ============ -->
|
||||
<section class="slide slide--content" id="slide-9"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 9" aria-hidden="true">
|
||||
<div class="slide__inner">
|
||||
<div class="slide__header">
|
||||
<div class="terminal-badge reveal">Pitfalls</div>
|
||||
<h2 class="reveal">주의사항</h2>
|
||||
</div>
|
||||
<div class="slide__body">
|
||||
<ul>
|
||||
<li class="reveal"><span style="color: var(--accent);">display:none</span> 사용 금지 — transition 불가</li>
|
||||
<li class="reveal">모바일 높이는 <span style="color: var(--accent);">100dvh</span> 사용</li>
|
||||
<li class="reveal"><span style="color: var(--accent);">will-change</span>는 동적 토글 (정적 적용 시 메모리 낭비)</li>
|
||||
<li class="reveal"><span style="color: var(--accent);">transitionend</span> 미발생 대비 setTimeout fallback 필수</li>
|
||||
<li class="reveal">터치 <span style="color: var(--accent);">passive:false</span>에서만 preventDefault 가능</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 슬라이드 10: 마무리 ============ -->
|
||||
<section class="slide slide--end" id="slide-10"
|
||||
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 10" aria-hidden="true">
|
||||
<div class="slide__inner" style="text-align: center; align-items: center;">
|
||||
<div class="slide__body" style="align-items: center;">
|
||||
<h1 class="reveal">감사합니다</h1>
|
||||
<div class="accent-line reveal" style="margin: 0.8rem auto;"></div>
|
||||
<p class="reveal" style="font-size: clamp(0.85rem, 1.3vw, 1.1rem);">
|
||||
외부 라이브러리 0개 · 단일 HTML 파일
|
||||
</p>
|
||||
<div class="reveal" style="display: flex; gap: 0.5rem; margin-top: 1rem;">
|
||||
<span class="badge">HTML</span>
|
||||
<span class="badge">CSS</span>
|
||||
<span class="badge">JavaScript</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 네비게이션 UI -->
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<div class="slide-counter" id="slideCounter">01 / 10</div>
|
||||
<div class="nav-buttons">
|
||||
<button class="nav-btn" id="btnPrev" aria-label="이전 슬라이드">‹</button>
|
||||
<button class="nav-btn" id="btnNext" aria-label="다음 슬라이드">›</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class Presentation {
|
||||
constructor() {
|
||||
this.viewport = document.getElementById('viewport');
|
||||
this.slides = Array.from(this.viewport.querySelectorAll('.slide'));
|
||||
this.total = this.slides.length;
|
||||
this.current = 0;
|
||||
this.isAnimating = false;
|
||||
this.DURATION = 450;
|
||||
|
||||
// 코드 하이라이트 적용
|
||||
this.slides.forEach(s => this._applyCodeHighlights(s));
|
||||
|
||||
// URL hash에서 초기 슬라이드 복원
|
||||
this._restoreFromHash();
|
||||
|
||||
// 이벤트 바인딩
|
||||
this._bindKeyboard();
|
||||
this._bindTouch();
|
||||
this._bindButtons();
|
||||
this._bindHash();
|
||||
this._updateUI();
|
||||
}
|
||||
|
||||
// ── 한 페이지 단위 이동 ─────────────────────
|
||||
|
||||
goNext() {
|
||||
if (this.isAnimating || this.current >= this.total - 1) return;
|
||||
this._goSlide(this.current + 1, 'next');
|
||||
}
|
||||
|
||||
goPrev() {
|
||||
if (this.isAnimating || this.current <= 0) return;
|
||||
this._goSlide(this.current - 1, 'prev');
|
||||
}
|
||||
|
||||
goTo(index) {
|
||||
if (this.isAnimating || index === this.current) return;
|
||||
if (index < 0 || index >= this.total) return;
|
||||
this._goSlide(index, index > this.current ? 'next' : 'prev');
|
||||
}
|
||||
|
||||
// ── 슬라이드 전환 ───────────────────────────
|
||||
|
||||
_goSlide(index, direction) {
|
||||
const isNext = direction === 'next';
|
||||
const from = this.slides[this.current];
|
||||
const to = this.slides[index];
|
||||
|
||||
this.isAnimating = true;
|
||||
this.viewport.classList.add('animating');
|
||||
|
||||
from.style.willChange = 'transform, opacity';
|
||||
to.style.willChange = 'transform, opacity';
|
||||
|
||||
// 진입 슬라이드 초기 위치 (transition 없이)
|
||||
to.style.transition = 'none';
|
||||
to.classList.remove('stand-left');
|
||||
to.style.transform = isNext ? 'translateX(100%)' : 'translateX(-100%)';
|
||||
to.style.opacity = '0';
|
||||
void to.offsetWidth;
|
||||
|
||||
// transition 복원 + 애니메이션 시작
|
||||
to.style.transition = '';
|
||||
to.style.transform = '';
|
||||
to.style.opacity = '';
|
||||
from.classList.remove('active');
|
||||
to.classList.add('active');
|
||||
|
||||
from.style.transform = isNext ? 'translateX(-100%)' : 'translateX(100%)';
|
||||
from.style.opacity = '0';
|
||||
|
||||
from.setAttribute('aria-hidden', 'true');
|
||||
to.setAttribute('aria-hidden', 'false');
|
||||
|
||||
this.current = index;
|
||||
this._syncHash();
|
||||
this._updateUI();
|
||||
|
||||
// 완료 감지
|
||||
let done = false;
|
||||
const cleanup = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(fallback);
|
||||
to.removeEventListener('transitionend', onEnd);
|
||||
|
||||
from.style.transform = '';
|
||||
from.style.opacity = '';
|
||||
from.style.transition = 'none';
|
||||
from.classList.add('stand-left');
|
||||
void from.offsetWidth;
|
||||
from.style.transition = '';
|
||||
|
||||
from.style.willChange = 'auto';
|
||||
to.style.willChange = 'auto';
|
||||
|
||||
this.isAnimating = false;
|
||||
this.viewport.classList.remove('animating');
|
||||
};
|
||||
|
||||
const onEnd = (e) => {
|
||||
if (e.propertyName !== 'transform' || e.target !== to) return;
|
||||
cleanup();
|
||||
};
|
||||
to.addEventListener('transitionend', onEnd);
|
||||
const fallback = setTimeout(cleanup, this.DURATION + 150);
|
||||
}
|
||||
|
||||
// ── 유틸리티 ─────────────────────────────────
|
||||
|
||||
_applyCodeHighlights(slide) {
|
||||
slide.querySelectorAll('.code-block[data-highlight-lines]').forEach(pre => {
|
||||
const ranges = this._parseRanges(pre.dataset.highlightLines);
|
||||
const lines = pre.querySelectorAll('.line');
|
||||
lines.forEach((line, i) => {
|
||||
if (ranges.has(i + 1)) line.classList.add('line--highlight');
|
||||
});
|
||||
if (ranges.size > 0) pre.classList.add('dim-others');
|
||||
});
|
||||
}
|
||||
|
||||
_parseRanges(str) {
|
||||
const result = new Set();
|
||||
str.split(',').forEach(part => {
|
||||
part = part.trim();
|
||||
if (part.includes('-')) {
|
||||
const [a, b] = part.split('-').map(Number);
|
||||
for (let i = a; i <= b; i++) result.add(i);
|
||||
} else {
|
||||
result.add(Number(part));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
_updateUI() {
|
||||
const progress = this.total <= 1 ? 100 : (this.current / (this.total - 1)) * 100;
|
||||
document.getElementById('progressFill').style.width = `${progress}%`;
|
||||
const num = String(this.current + 1).padStart(2, '0');
|
||||
const tot = String(this.total).padStart(2, '0');
|
||||
document.getElementById('slideCounter').textContent = `${num} / ${tot}`;
|
||||
}
|
||||
|
||||
_syncHash() {
|
||||
const hash = `#slide-${this.current + 1}`;
|
||||
if (location.hash !== hash) history.pushState(null, '', hash);
|
||||
}
|
||||
|
||||
_restoreFromHash() {
|
||||
const match = location.hash.match(/^#slide-(\d+)$/);
|
||||
if (match) {
|
||||
const idx = parseInt(match[1], 10) - 1;
|
||||
if (idx > 0 && idx < this.total) {
|
||||
this.slides[0].classList.remove('active');
|
||||
this.slides[0].setAttribute('aria-hidden', 'true');
|
||||
this.slides[0].classList.add('stand-left');
|
||||
this.slides[idx].classList.add('active');
|
||||
this.slides[idx].setAttribute('aria-hidden', 'false');
|
||||
this.current = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 이벤트 바인딩 ────────────────────────────
|
||||
|
||||
_bindKeyboard() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.repeat) return;
|
||||
const tag = document.activeElement.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight': case 'ArrowDown': case ' ': case 'PageDown':
|
||||
e.preventDefault(); this.goNext(); break;
|
||||
case 'ArrowLeft': case 'ArrowUp': case 'PageUp':
|
||||
e.preventDefault(); this.goPrev(); break;
|
||||
case 'Home':
|
||||
e.preventDefault(); this.goTo(0); break;
|
||||
case 'End':
|
||||
e.preventDefault(); this.goTo(this.total - 1); break;
|
||||
case 'f': case 'F':
|
||||
this._toggleFullscreen(); break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_bindTouch() {
|
||||
let startX = 0, startY = 0, startTime = 0, swiping = false;
|
||||
const el = this.viewport;
|
||||
|
||||
el.addEventListener('touchstart', (e) => {
|
||||
const t = e.changedTouches[0];
|
||||
startX = t.clientX; startY = t.clientY;
|
||||
startTime = Date.now(); swiping = false;
|
||||
}, { passive: true });
|
||||
|
||||
el.addEventListener('touchmove', (e) => {
|
||||
const t = e.changedTouches[0];
|
||||
const dx = Math.abs(t.clientX - startX);
|
||||
const dy = Math.abs(t.clientY - startY);
|
||||
if (dx > dy && dx > 10) { swiping = true; e.preventDefault(); }
|
||||
}, { passive: false });
|
||||
|
||||
el.addEventListener('touchend', (e) => {
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - startX;
|
||||
const dy = Math.abs(t.clientY - startY);
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (swiping && Math.abs(dx) >= 50 && dy <= 75 && elapsed <= 400) {
|
||||
dx < 0 ? this.goNext() : this.goPrev();
|
||||
}
|
||||
swiping = false;
|
||||
}, { passive: true });
|
||||
|
||||
el.style.touchAction = 'pan-y';
|
||||
el.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
_bindButtons() {
|
||||
document.getElementById('btnPrev').addEventListener('click', () => this.goPrev());
|
||||
document.getElementById('btnNext').addEventListener('click', () => this.goNext());
|
||||
}
|
||||
|
||||
_bindHash() {
|
||||
window.addEventListener('hashchange', () => {
|
||||
const match = location.hash.match(/^#slide-(\d+)$/);
|
||||
if (match) {
|
||||
const idx = parseInt(match[1], 10) - 1;
|
||||
if (idx !== this.current) this.goTo(idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => new Presentation());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user