+ {/* 세로 스택 레이아웃(flex 컨테이너, 중앙 정렬, 아이템 간격)으로 상단에 마스코트, 하단에 마이트랙 로고 배치*/}
+ {/*브랜드 락업(마스코트+로고) 형태*/}
+
+
+
+ );
+}
+
+export default AppBrand;
diff --git a/src/shared/components/AppButton.jsx b/src/shared/components/AppButton.jsx
new file mode 100644
index 0000000..82f3307
--- /dev/null
+++ b/src/shared/components/AppButton.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+
+/*
+ * AppButton: 공통 버튼 컴포넌트
+ * variant/disabled/isSelected 프롭으로 상태·스타일을 제어하고, 공통 Tailwind 유틸을 기반으로 일관된 UI를 제공
+ * 기본 클릭 동작(onClick)과 네이티브 disabled 속성을 그대로 전달
+ */
+
+function AppButton({
+ label, // 버튼에 표시할 텍스트
+ onClick, // 클릭 핸들러
+ disabled, // 비활성화 여부 HTML disalabled와 클래스 선택에 사용
+ variant = 'default', // 버튼 스타일 종류: 'default', 'file', 'track'
+ isSelected = false, // 'track' variant에서 토글/선택 상태 표현용. 기본값 false
+}) {
+ const baseClasses = 'w-full rounded-lg py-3 font-bold transition';
+ // 공통 tailwind 유틸리티(풀 너비, 둥근 모서리, 패딩, 폰트 굵기, 트랜지션) 이후 각 variant별 클래스 추가
+
+ const variantClasses = {
+ // 객체 선언 시작: variant 값에 따른 클래스 문자열 매핑
+ default: disabled ? 'bg-gray-300 text-white' : 'bg-blue-600 text-white', // 기본 버튼: 비활성화 시 회색, 활성화 시 파란색/흰색
+ file: 'border border-blue-400 text-blue-400 bg-white', // 파일 업로드 버튼: 파란색 테두리/텍스트, 흰색 배경
+ track: isSelected // 트랙 토글 버튼: 선택 상태에 따라 스타일 변경
+ ? 'bg-blue-600 text-white border border-blue-600 rounded-full px-3 py-2' // 선택됨: 파란색 배경/테두리, 흰색 텍스트, 둥근 모서리, 패딩
+ : 'bg-white text-blue-500 border border-blue-300 rounded-full px-3 py-2', // 선택 안됨: 흰색 배경, 파란색 텍스트/테두리, 둥근 모서리, 패딩
+ };
+
+ return (
+ // JSX 반환 시작
+
+ {/* 전체 너비, 파란색 배경, 둥근 아래 모서리, 내부 여백, 세로 스택 레이아웃, 중앙 정렬, 약간의 음수 마진과 z-인덱스, 상대 위치 지정 */}
+ {children}
+ {/* 내부 콘텐츠 렌더링(슬롯 영역, 외부에서 전달한 임의의 콘텐츠를 그대로 감쌈) */}
+
+ );
+};
+
+export default AppPrimarySection;
diff --git a/src/shared/components/AppTextField.jsx b/src/shared/components/AppTextField.jsx
new file mode 100644
index 0000000..d74fbdb
--- /dev/null
+++ b/src/shared/components/AppTextField.jsx
@@ -0,0 +1,61 @@
+import React, { useState } from 'react'; // 함수형 컴포넌트에서 로컬 상태를 쓰기 위해 useState 훅을 임포트
+import { Eye, EyeOff, Search } from 'lucide-react'; // lucide-react에서 아이콘을 임포트
+
+/**
+ * AppTextField: 프로젝트 공용 스타일의 컨트롤드 입력 필드
+ * type='text' | 'password' | 'search'를 지원하며, 비밀번호 보기 토글과 에러 메시지 표시를 제공
+ * value/onChange로 외부 상태를 제어하고, 접근성 강화를 위해 aria-* 속성 추가를 권장
+ */
+
+// 프롭으로 타입, 플레이스홀더, 값, 변경 핸들러, 에러 메시지를 받음
+// 기본 타입은 'text'로 설정
+function AppTextField({ type = 'text', placeholder, value, onChange, error }) {
+ // 비밀번호 표시 토글 상태 (초기값 false 숨김)
+ const [showPassword, setShowPassword] = useState(false);
+ // 입력 타입이 'password'인지 'search'인지 확인
+ const isPassword = type === 'password';
+ const isSearch = type === 'search';
+
+ return (
+
+ {/* 스타일: 파란 배경, 흰 텍스트, 중앙 정렬, 기본 크기, 패딩, 둥근 모서리, 고정 너비 */}
+ {text} {/* 전달된 텍스트를 그대로 표시 */}
+
+ );
+};
+
+export default InfoLabel;
diff --git a/src/stories/AppBrand.stories.jsx b/src/stories/AppBrand.stories.jsx
new file mode 100644
index 0000000..b2bac33
--- /dev/null
+++ b/src/stories/AppBrand.stories.jsx
@@ -0,0 +1,19 @@
+/**
+ * AppBrand 스토리
+ * - 브랜드 락업(마스코트 + 워드마크)을 정적으로 렌더링
+ * - props 없이 고정된 브랜딩 묶음을 보여주는 컴포넌트
+ * - 온보딩/로그인 등 브랜드 아이덴티티 강조 화면에서 사용
+ */
+
+import AppBrand from '../shared/components/AppBrand';
+
+export default {
+ title: 'Brand/AppBrand',
+ component: AppBrand,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+};
+
+export const Default = {}; // 기본 렌더링 (프롭 없음)
diff --git a/src/stories/AppButton.stories.jsx b/src/stories/AppButton.stories.jsx
new file mode 100644
index 0000000..9cc9ff3
--- /dev/null
+++ b/src/stories/AppButton.stories.jsx
@@ -0,0 +1,62 @@
+/**
+ * AppButton 스토리
+ * - CSF3 포맷 사용 (default export + named stories)
+ * - Args/Controls/Actions를 설정하여 변형과 상호작용을 쉽게 테스트
+ * - 'autodocs' 태그: 컴포넌트의 JSDoc이 있으면 Docs 탭에 자동 반영
+ */
+
+import AppButton from '../shared/components/AppButton';
+
+export default {
+ title: 'Inputs/AppButton', // 스토리북 사이드바에서의 위치 (그룹/이름)
+ component: AppButton,
+ tags: ['autodocs'],
+ /*
+ * args: 모든 스토리에 공통으로 주입되는 기본 프롭 값
+ * Controls 패널에서 실시간으로 수정 가능
+ */
+ args: {
+ label: '확인',
+ variant: 'default', // 'default' | 'file' | 'track'
+ disabled: false,
+ isSelected: false, // 'track' variant에서만 사용
+ },
+ /*
+ * argTypes: Controls/Actions 동작 정의
+ * - variant는 select 컨트롤로 제한
+ * - onClick은 Actions 패널에 로깅
+ */
+ argTypes: {
+ variant: {
+ control: 'select',
+ options: ['default', 'file', 'track'],
+ description: "버튼 스타일 변형 ('default' | 'file' | 'track')",
+ },
+ isSelected: {
+ control: 'boolean',
+ description: 'track 변형에서 선택 토글 상태',
+ },
+ disabled: {
+ control: 'boolean',
+ description: '네이티브 비활성화',
+ },
+ onClick: { action: 'clicked' },
+ },
+ /*
+ * parameters: 스토리 단위 구성(전역은 .storybook/preview.js)
+ * - layout: centered → 캔버스 중앙 배치
+ */
+ parameters: {
+ layout: 'centered',
+ },
+};
+
+export const Default = {}; // 기본 버튼: 파란 배경, 흰 텍스트 (비활성화 아님)
+export const File = { args: { label: '파일 선택', variant: 'file' } }; // 파일 업로드 버튼: 흰 배경, 파란 테두리/텍스트
+export const Track_Unselected = {
+ args: { label: '트랙', variant: 'track', isSelected: false },
+}; // 트랙 토글 버튼: 흰 배경, 파란 테두리/텍스트 (선택 안됨)
+export const Track_Selected = {
+ args: { label: '트랙(선택)', variant: 'track', isSelected: true },
+}; // 트랙 토글 버튼: 파란 배경, 흰 텍스트 (선택됨)
+export const Disabled = { args: { label: '비활성', disabled: true } }; // 비활성화 버튼: 회색 배경, 흰 텍스트 (클릭 불가)
diff --git a/src/stories/AppPrimarySection.stories.jsx b/src/stories/AppPrimarySection.stories.jsx
new file mode 100644
index 0000000..ebec7d1
--- /dev/null
+++ b/src/stories/AppPrimarySection.stories.jsx
@@ -0,0 +1,47 @@
+/**
+ * AppPrimarySection 스토리
+ * - Blue-Primary 배경과 하단 라운드 처리된 섹션 래퍼
+ * - children을 중앙 정렬된 세로 스택으로 감싸 강조 섹션/카드 하단 띠 등에 사용
+ */
+
+import AppPrimarySection from '../shared/components/AppPrimarySection';
+
+export default {
+ title: 'Layout/AppPrimarySection',
+ component: AppPrimarySection,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ /**
+ * 배경 프리셋은 .storybook/preview.js에도 지정 가능
+ * 여기서는 대비 확인을 위해 밝은 배경을 기본 사용
+ */
+ backgrounds: {
+ default: 'plain',
+ values: [
+ { name: 'plain', value: '#ffffff' },
+ { name: 'app-gray', value: '#f5f5f5' },
+ { name: 'dark', value: '#1f2937' },
+ ],
+ },
+ },
+};
+
+// 기본 사용: 임의의 children을 감싸 강조 섹션처럼 보여줌
+export const Default = {
+ args: {}, // AppPrimarySection는 일반적으로 props가 없음
+ render: (args) => (
+
+ {/* 업로드 영역을 덮는 투명 오버레이(닫기 버튼 영역은 피함) */}
+
{
+ // 닫기 버튼 주변(우상단 48x48)은 통과시키고 나머지는 막음(대략값, 필요시 CSS로 정확화)
+ const rect = e.currentTarget.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+ const allowXStart = rect.width - 56;
+ const allowYEnd = 56;
+ if (x >= allowXStart && y <= allowYEnd) {
+ // 닫기 버튼 클릭은 통과
+ e.stopPropagation();
+ return;
+ }
+ // 기타 클릭 차단
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ />
+
+
+ ),
+};
+
+/**
+ * (참고) 상호작용을 안전하게 테스트하려면 두 가지 중 하나를 권장:
+ * 1) DI(의존성 주입)로 리팩터링
+ * - props로 uploadFn/setTrackData를 받게 하고 기본값은 실제 함수로 지정:
+ * function TrackDataUploadModal({ onClose, uploadFn = uploadStudentExcel, setTrackData = useTrackStore.getState().setTrackData }) { ... }
+ * - 스토리에서는 uploadFn/setTrackData에 목 함수를 넣어 클릭해도 네트워크/스토어를 건드리지 않음.
+ *
+ * 2) MSW(Mock Service Worker)로 네트워크 레이어 모킹
+ * - uploadStudentExcel 내부가 fetch/axios를 호출한다면 해당 HTTP 엔드포인트를 MSW 핸들러로 가로채기.
+ * - 스토리 파일 상단/preview.ts에서 msw-storybook-addon 설정 후, 각 스토리에 handlers를 지정.
+ */
diff --git a/src/stories/TrackInitButton.stories.jsx b/src/stories/TrackInitButton.stories.jsx
new file mode 100644
index 0000000..8d7c4a6
--- /dev/null
+++ b/src/stories/TrackInitButton.stories.jsx
@@ -0,0 +1,27 @@
+/**
+ * TrackInitButton Storybook
+ * - 클릭 액션과 비활성화 상태를 빠르게 검증하기 위한 스토리
+ * - 컴포넌트 라벨이 하드코딩되어 있으므로 Controls에는 disabled만 노출
+ */
+
+import React from 'react';
+import TrackInit from '../features/track-management/components/TrackInitButton';
+
+export default {
+ title: 'Buttons/TrackInitButton',
+ component: TrackInit,
+ tags: ['autodocs'],
+ parameters: { layout: 'centered' },
+ argTypes: {
+ disabled: { control: 'boolean', description: '비활성화 여부' },
+ onClick: { action: 'clicked', description: '클릭 핸들러' },
+ },
+};
+
+// 기본 상태: 클릭 시 Actions 패널에 로그가 남음
+export const Default = {};
+
+// 비활성화 상태: 시각적/기능적으로 클릭 불가
+export const Disabled = {
+ args: { disabled: true },
+};
diff --git a/src/stories/TrackIntroHeader.stories.jsx b/src/stories/TrackIntroHeader.stories.jsx
new file mode 100644
index 0000000..5e57315
--- /dev/null
+++ b/src/stories/TrackIntroHeader.stories.jsx
@@ -0,0 +1,60 @@
+/**
+ * TrackIntroHeader Storybook
+ * - Zustand store에 studentName을 주입한 뒤 렌더링
+ * - 컨텐츠가 좌측 정렬 + 여백(px-4) 기준이라, 폭을 제한해 실제 페이지와 유사하게 보여줌
+ */
+
+import React from 'react';
+import TrackIntroHeader from '../features/track-management/components/TrackIntroHeader';
+import useUserStore from '../entities/user/model/useUserStore'; // store 접근(주입용)
+
+// 공통 래퍼: 실제 페이지 폭을 시뮬레이션
+const Wrapper = ({ children, width = 420 }) => (
+
{children}
+);
+
+export default {
+ title: 'Sections/TrackIntroHeader',
+ component: TrackIntroHeader,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'plain',
+ values: [
+ { name: 'plain', value: '#ffffff' },
+ { name: 'app-gray', value: '#f5f5f5' },
+ ],
+ },
+ },
+ // 이 컴포넌트는 프롭이 없으므로 Controls는 생략
+};
+
+/**
+ * 기본 스토리: 일반 길이의 이름
+ * - 스토리 렌더링 직전에 Zustand store에 studentName을 주입
+ */
+export const Default = {
+ render: () => {
+ useUserStore.setState({ studentName: '예령' }); // 개인화 이름 주입
+ return (
+
+
+
+ );
+ },
+};
+
+// 긴 이름 케이스: 줄바꿈/폭 대응 확인
+export const LongName = {
+ render: () => {
+ useUserStore.setState({
+ studentName: '세종대학교-인공지능융합대학-매우긴이름학생',
+ });
+ return (
+
+
+
+ );
+ },
+};
diff --git a/src/stories/TrackProgressHeader.stories.jsx b/src/stories/TrackProgressHeader.stories.jsx
new file mode 100644
index 0000000..342025d
--- /dev/null
+++ b/src/stories/TrackProgressHeader.stories.jsx
@@ -0,0 +1,85 @@
+/**
+ * TrackProgressHeader Storybook
+ * - 업로드 없음/기본/0%/초과 100% 케이스를 시나리오별로 확인
+ * - 텍스트가 흰색이므로, 가시성을 위해 배경을 브랜드 블루 계열로 감싸서 예시를 제공
+ */
+
+import React from 'react';
+import TrackProgressHeader from '../features/track-management/components/TrackProgressHeader';
+
+// 공통 래퍼: 배경(예:#0259DD) 위에서 보이도록 감싸기
+const Wrapper = ({ children }) => (
+
+ {children}
+
+);
+
+export default {
+ title: 'Progress/TrackProgressHeader',
+ component: TrackProgressHeader,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'plain',
+ values: [
+ { name: 'plain', value: '#ffffff' },
+ { name: 'dark', value: '#111827' },
+ ],
+ },
+ },
+ argTypes: {
+ completedCount: { control: 'number', description: '이수 과목 수' },
+ total: { control: 'number', description: '전체 과목 수' },
+ hasData: {
+ control: 'boolean',
+ description: '데이터 존재 여부(업로드 완료?',
+ },
+ },
+};
+
+// 업로드 데이터가 없을 때의 빈 상태
+export const NoData = {
+ args: { hasData: false },
+ render: (args) => (
+
+
+
+ ),
+};
+
+// 기본 예시: 3/6 (50%)
+export const Half = {
+ args: { hasData: true, completedCount: 3, total: 6 },
+ render: (args) => (
+
+
+
+ ),
+};
+
+// 0% 진행
+export const Zero = {
+ args: { hasData: true, completedCount: 0, total: 6 },
+ render: (args) => (
+
+
+
+ ),
+};
+
+// 100%를 초과하는 입력(예: 8/6)도 시각적으로는 100%로 캡핑됨
+export const OverComplete = {
+ args: { hasData: true, completedCount: 8, total: 6 },
+ render: (args) => (
+
+
+
+ ),
+};
+
+/* 개선하면 좋은점
+- 0 분모 가드: const safePercent = total > 0 ? Math.min((completedCount / total) * 100, 100) : 0;처럼 방어 코드 추가
+- 접근성(role): 진행 바에 role="progressbar" 및 aria-valuemin={0} aria-valuemax={total} aria-valuenow={completedCount}를 부여하면 스크린리더 접근성이 좋아짐
+- 테마 토큰화: #FDB913 같은 하드코딩 색상은 디자인 토큰(예: Tailwind theme 확장)으로 관리하면 유지보수에 용이
+*/
diff --git a/src/stories/TrackStatus.stories.jsx b/src/stories/TrackStatus.stories.jsx
new file mode 100644
index 0000000..1b5557b
--- /dev/null
+++ b/src/stories/TrackStatus.stories.jsx
@@ -0,0 +1,94 @@
+/**
+ * TrackStatus Storybook
+ * - card / large 두 변형을 한 컴포넌트에서 미리보기
+ * - large 변형은 HTML 제목을 허용(allowHtmlTitle=true)
+ * - 캐릭터 클릭 액션(onClickCharacter)은 Actions 패널에서 확인
+ */
+
+import React from 'react';
+import TrackStatus from '../features/track-management/components/TrackStatus';
+
+export default {
+ title: 'Brand/TrackStatus',
+ component: TrackStatus,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'plain',
+ values: [
+ { name: 'plain', value: '#ffffff' },
+ { name: 'app-gray', value: '#f5f5f5' },
+ { name: 'dark', value: '#111827' },
+ ],
+ },
+ },
+ argTypes: {
+ variant: {
+ control: 'select',
+ options: ['card', 'large'],
+ description: '크기/간격 변형',
+ },
+ title: {
+ control: 'text',
+ description: '제목(allowHtmlTitle=true일 때 HTML 허용)',
+ },
+ allowHtmlTitle: { control: 'boolean', description: '제목에 HTML 허용' },
+ subtitle: { control: 'text', description: '선택적 소제목' },
+ onClickCharacter: {
+ action: 'character:click',
+ description: '캐릭터 클릭 콜백',
+ },
+ },
+};
+
+// 카드형 기본: 소제목 없음
+export const Card_Default = {
+ args: {
+ variant: 'card',
+ title: '수강이력 업로드',
+ },
+};
+
+/** 카드형 + 소제목 */
+export const Card_WithSubtitle = {
+ args: {
+ variant: 'card',
+ title: '수강이력 업로드',
+ subtitle: '학사시스템에서 엑셀을 내려받아 업로드하세요.',
+ },
+};
+
+//라지형 기본: HTML 미사용
+export const Large_Default = {
+ args: {
+ variant: 'large',
+ title: '수강이력 업로드',
+ allowHtmlTitle: false,
+ },
+};
+
+// 라지형 + HTML 제목 허용 (줄바꿈/강조 등)
+export const Large_WithHtmlTitle = {
+ args: {
+ variant: 'large',
+ title: '수강이력
업로드',
+ allowHtmlTitle: true,
+ },
+};
+
+// 캐릭터 클릭 인터랙션만 테스트 (Actions에서 로그 확인)
+export const ClickableCharacter = {
+ args: {
+ variant: 'card',
+ title: '캐릭터 클릭 샘플',
+ },
+ render: (args) => (
+
+ console.log('[storybook] character clicked')}
+ />
+
+ ),
+};
diff --git a/src/stories/TrackTabs.stories.jsx b/src/stories/TrackTabs.stories.jsx
new file mode 100644
index 0000000..1af5b5d
--- /dev/null
+++ b/src/stories/TrackTabs.stories.jsx
@@ -0,0 +1,62 @@
+/**
+ * TrackTabs Storybook
+ * - 스토리 내부에서 activeTab 상태를 관리하여 실제 전환을 확인
+ * - 탭이 많을 때의 줄바꿈(flex-wrap) 대응도 예시로 제공
+ */
+
+import React, { useState } from 'react';
+import TrackTabs from '../features/track-management/components/TrackTabs';
+
+const TABS = [
+ '인공지능시스템',
+ '메타버스 플랫폼',
+ '클라우드 컴퓨팅',
+ '공간비주얼 SW',
+ '인터렉티브 플랫폼',
+ '지능형에이전트',
+ 'AI 콘텐츠',
+ '데이터인텔리전스',
+];
+
+export default {
+ title: 'Navigation/TrackTabs',
+ component: TrackTabs,
+ tags: ['autodocs'],
+ parameters: { layout: 'centered' },
+ argTypes: {
+ tabs: { control: 'object', description: '탭 라벨 배열' },
+ activeTab: { control: false },
+ onChange: { action: 'change', description: '탭 변경 콜백' },
+ },
+};
+
+// 기본 탭 전환 데모
+export const Basic = {
+ args: { tabs: TABS },
+ render: (args) => {
+ const [activeTab, setActiveTab] = useState(TABS[0]);
+ return (
+
+
+
+ 현재 선택된 탭: {activeTab}
+
+
+ );
+ },
+};
+
+// 탭이 많은 경우: 줄바꿈/간격 확인
+export const ManyTabsWrapped = {
+ args: {
+ tabs: [...TABS, '로보틱스', '엣지AI', '시뮬레이션', '그래프러닝'],
+ },
+ render: (args) => {
+ const [activeTab, setActiveTab] = useState(args.tabs[0]);
+ return (
+
+
+
+ );
+ },
+};
diff --git a/src/stories/button.css b/src/stories/button.css
deleted file mode 100644
index 4e3620b..0000000
--- a/src/stories/button.css
+++ /dev/null
@@ -1,30 +0,0 @@
-.storybook-button {
- display: inline-block;
- cursor: pointer;
- border: 0;
- border-radius: 3em;
- font-weight: 700;
- line-height: 1;
- font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-}
-.storybook-button--primary {
- background-color: #555ab9;
- color: white;
-}
-.storybook-button--secondary {
- box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
- background-color: transparent;
- color: #333;
-}
-.storybook-button--small {
- padding: 10px 16px;
- font-size: 12px;
-}
-.storybook-button--medium {
- padding: 11px 20px;
- font-size: 14px;
-}
-.storybook-button--large {
- padding: 12px 24px;
- font-size: 16px;
-}