diff --git a/package.json b/package.json index 4c18d8b7..b25a582a 100644 --- a/package.json +++ b/package.json @@ -34,5 +34,10 @@ }, "resolutions": { "vite": "^6" + }, + "dependenciesMeta": { + "@modelcontextprotocol/sdk@1.24.3": { + "unplugged": true + } } } diff --git a/shared/design/convertStoryToMdx.ts b/shared/design/convertStoryToMdx.ts new file mode 100644 index 00000000..0500bdfe --- /dev/null +++ b/shared/design/convertStoryToMdx.ts @@ -0,0 +1,158 @@ +import { promises as fs } from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +type WalkOptions = { + filterExts?: Set +} + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +async function walk(dir: string, opts: WalkOptions = {}): Promise { + const out: string[] = [] + const entries = await fs.readdir(dir, { withFileTypes: true }) + for (const e of entries) { + if (e.name.startsWith(".")) continue // skip hidden + const full = path.join(dir, e.name) + if (e.isDirectory()) { + out.push(...(await walk(full, opts))) + } else if (e.isFile()) { + if (opts.filterExts) { + const ext = path.extname(e.name).toLowerCase() + if (!opts.filterExts.has(ext)) continue + } + out.push(full) + } + } + return out +} + +function longestBacktickRun(s: string): number { + let max = 0 + let cur = 0 + for (let i = 0; i < s.length; i++) { + if (s[i] === "`") { + cur++ + if (cur > max) max = cur + } else { + cur = 0 + } + } + return max +} + +function codeFenceFor(content: string): string { + const longest = longestBacktickRun(content) + const len = Math.max(3, longest + 1) + return "`".repeat(len) +} + +function languageForExt(ext: string): string { + switch (ext) { + case ".ts": + return "ts" + case ".tsx": + return "tsx" + case ".js": + return "js" + case ".jsx": + return "jsx" + case ".md": + return "md" + case ".mdx": + return "mdx" + case ".css": + return "css" + case ".json": + return "json" + default: + return "" + } +} + +async function ensureDir(p: string) { + await fs.mkdir(p, { recursive: true }) +} + +async function fileExists(p: string) { + try { + await fs.stat(p) + return true + } catch { + return false + } +} + +async function generateMDX(inputFile: string, inBase: string, outBase: string) { + const rel = path.relative(inBase, inputFile) + const srcExt = path.extname(inputFile) + const baseName = path.basename(inputFile) + const language = languageForExt(srcExt) + const content = await fs.readFile(inputFile, "utf8") + const fence = codeFenceFor(content) + + const frontmatter = [ + "---", + `title: ${baseName}`, + `source: ${path.join("src/stories", rel).replaceAll("\\", "/")}`, + `generated: ${new Date().toISOString()}`, + "---", + "", + ].join("\n") + + const heading = `# ${baseName}\n\n` + const codeBlock = [ + `${fence}${language ? language : ""}`.trimEnd(), + `// Source: ${path.join("src/stories", rel).replaceAll("\\", "/")}`, + content, + fence, + "", + ].join("\n") + + const mdx = frontmatter + heading + codeBlock + + const relOut = rel.replace(/\.[^.]+$/, ".mdx") + const outFile = path.join(outBase, relOut) + await ensureDir(path.dirname(outFile)) + await fs.writeFile(outFile, mdx, "utf8") + return outFile +} + +async function main() { + const inBase = path.resolve(__dirname, "src/stories") + const outBase = path.resolve(__dirname, "../figma-plugin/rules") + + if (!(await fileExists(inBase))) { + console.error(`Input folder not found: ${inBase}`) + process.exit(1) + } + await ensureDir(outBase) + + const filterExts = new Set([".ts", ".tsx", ".js", ".jsx", ".md", ".mdx"]) + const files = await walk(inBase, { filterExts }) + if (files.length === 0) { + console.warn( + "No files found under src/stories matching allowed extensions.", + ) + return + } + + console.log(`Found ${files.length} file(s). Generating MDX into: ${outBase}`) + + let count = 0 + for (const f of files) { + try { + const outFile = await generateMDX(f, inBase, outBase) + count++ + console.log(`✓ ${path.relative(outBase, outFile).replaceAll("\\", "/")}`) + } catch (err) { + console.error(`✗ Failed to process ${f}:`, err) + } + } + console.log(`Done. Wrote ${count} MDX file(s).`) +} + +// Run if invoked directly + +main() diff --git a/shared/figma-plugin/package.json b/shared/figma-plugin/package.json index 2cc72925..8c627ef8 100644 --- a/shared/figma-plugin/package.json +++ b/shared/figma-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@ject-5-fe/figma-plugin", - "version": "0.0.1", + "version": "0.3.3", "type": "module", "scripts": { "dev": "plugma dev", @@ -14,7 +14,8 @@ "dist/server/server.cjs" ], "dependencies": { - "@modelcontextprotocol/sdk": "^1", + "@modelcontextprotocol/sdk": "^1.24", + "es-toolkit": "^1.39.10", "react": "^18.3.1", "react-dom": "^18.3.1", "uuid": "^11", diff --git a/shared/figma-plugin/rules/accordion.mdx b/shared/figma-plugin/rules/accordion.mdx new file mode 100644 index 00000000..6ed1fbb2 --- /dev/null +++ b/shared/figma-plugin/rules/accordion.mdx @@ -0,0 +1,3 @@ +## accordion + +### Accordion은 쓰지마세요 diff --git a/shared/figma-plugin/rules/button.stories.mdx b/shared/figma-plugin/rules/button.stories.mdx new file mode 100644 index 00000000..ceca0498 --- /dev/null +++ b/shared/figma-plugin/rules/button.stories.mdx @@ -0,0 +1,544 @@ +--- +title: button.stories.tsx +source: src/stories/button.stories.tsx +generated: 2025-12-08T12:37:12.103Z +--- +# button.stories.tsx + +```tsx +// Source: src/stories/button.stories.tsx +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { + DestructiveSolidBoxButton, + PrimaryBoxButton, + PrimarySolidIconButton, + SecondaryGhostIconButton, + SecondaryOutlineBoxButton, + SecondaryPlainBoxButton, + SecondaryPlainIconButton, +} from "../components/button" +import { + Add, + Arrow, + Cross, + Edit, + Hidden, + Iconplaceholder_24px, + Magnifier, + Minus, + MoreDot, + Play, + Show, + Sun, + Trash, + Unshare, + Upload, +} from "../icons" + +const meta = { + title: "Components/Button", + parameters: { + layout: "centered", + docs: { + description: { + component: ` +# Button Component System + +버튼 컴포넌트는 **위계 > 스타일 > 형태** 구조로 체계적으로 설계되었습니다. +모든 버튼은 **Figma 디자인 시스템**과 완벽히 동기화되어 있어, 디자인과 개발 간의 일관성을 보장합니다. + +## 🏗️ 네이밍 컨벤션 + +모든 버튼 컴포넌트 네이밍은 \`[위계][스타일][형태] + Button\` 형식으로 구성됩니다: + +### 📊 위계 (Hierarchy) +- **Primary**: 메인 색상을 기준으로 하는 버튼입니다. 가장 중요한 액션에 사용됩니다 +- **Secondary**: secondary 색상을 기준으로 하는 버튼입니다. 보조 액션에 사용됩니다 +- **Destructive**: 파괴적인 액션(삭제, 제거 등)을 위한 버튼입니다 + +### 🎨 스타일 (Style) +- **Solid**: 배경이 채워진 스타일 +- **Outline**: 테두리만 있는 스타일 +- **Ghost**: 배경 없이 hover 시에만 배경이 나타나는 스타일 +- **Plain**: 가장 단순한 스타일, 배경 없음 + +### 🔷 형태 (Shape) +- **Box**: 텍스트를 포함할 수 있는 박스 형태의 버튼 +- **Icon**: 아이콘만 포함하는 버튼 + +### 📐 스타일 설정 방식 +- **Primary**: \`PrimaryBoxButton\`은 \`_style\` prop으로 solid/outline 스타일을 설정 가능 +- **Secondary**: 각 스타일별로 개별 컴포넌트로 존재 (Ghost, Outline, Plain) +- **Destructive**: Solid 스타일로 고정 + +## 🎯 Figma 연동 + +모든 버튼들은 피그마와 네이밍/variant가 동일하게 구현되어 있어, 피그마의 component 타입을 그대로 사용할 수 있습니다: + +\`\`\`tsx + +\`\`\` + +- **상태 변화**: state variant(default,hover,active)는 css 가상선택자로 구현이 되어있습니다. props로 주입하지 않아도 됩니다 +- **비활성화**: disabled 상태는 직접 주입해야 합니다(HTML 속성으로 주입하면 스타일이 적용됩니다) +- **충돌 방지**: html attribute와 상충되는 속성은, \`_\` prefix를 붙였습니다 + + 예시: \`style\` -> \`_style\` + +## ⚠️ 주의사항 + +**각 컴포넌트마다 모든 variant를 사용할 수 없습니다** + +## 🔗 asChild 패턴 + +asChild prop을 통해 다른 엘리먼트를 버튼 스타일로 렌더링할 수 있습니다. +예시) Next.js Link 컴포넌트를 버튼 스타일로 렌더링할 수 있습니다: + +\`\`\`tsx + + + Link as Button + + +\`\`\` + +## 📋 컴포넌트 목록 + +### Primary 버튼 (주요 액션) +- **PrimaryBoxButton**: 주요 액션을 위한 박스 형태의 버튼 (solid/outline 스타일 지원) +- **PrimarySolidIconButton**: 주요 액션을 위한 solid 스타일의 아이콘 버튼 + +### Secondary 버튼 (보조 액션) +- **SecondaryGhostIconButton**: 보조 액션을 위한 ghost 스타일의 아이콘 버튼 (배경 없음) +- **SecondaryOutlineBoxButton**: 보조 액션을 위한 outline 스타일의 박스 버튼 +- **SecondaryPlainBoxButton**: 보조 액션을 위한 plain 스타일의 박스 버튼 +- **SecondaryPlainIconButton**: 보조 액션을 위한 plain 스타일의 아이콘 버튼 + +### Destructive 버튼 (파괴적/삭제 액션) +- **DestructiveSolidBoxButton**: 삭제나 파괴적인 액션을 위한 solid 스타일의 박스 버튼 +- **DestructiveSolidIconButton**: 삭제나 파괴적인 액션을 위한 solid 스타일의 아이콘 버튼 + +## 접근성 특징 + +- 키보드 네비게이션 완전 지원 +- ARIA 속성 자동 설정 +- 포커스 관리 및 시각적 피드백 +- 스크린 리더 지원 + `, + }, + }, + }, + tags: ["autodocs"], +} satisfies Meta + +// eslint-disable-next-line storybook/csf-component +export default meta +type Story = StoryObj + +// 아이콘 매핑 객체 +const iconMap = { + Add, + Arrow, + Cross, + Edit, + Hidden, + Iconplaceholder_24px, + Magnifier, + Minus, + MoreDot, + Play, + Show, + Sun, + Trash, + Unshare, + Upload, +} + +export const BoxButtons: Story = { + parameters: { + docs: { + description: { + story: ` +박스 형태의 버튼들을 모두 볼 수 있습니다. 텍스트를 포함하며 다양한 크기와 스타일을 지원합니다. + +**특징:** +- **Primary**: solid/outline 스타일 선택 가능 +- **Secondary**: outline/plain 스타일별 개별 컴포넌트 +- **Destructive**: solid 스타일 고정 +- 모든 박스 버튼은 \`asChild\` 패턴 지원 + `, + }, + }, + }, + render: () => ( +
+ {/* Primary Box Buttons */} +
+

Primary Box Buttons

+
+ + Primary Solid + + + Primary Outline + + + Large Size + + + Disabled + +
+
+ + {/* Secondary Box Buttons */} +
+

Secondary Box Buttons

+
+ + Secondary Outline + + + Large Size + + Secondary Plain + + Disabled + +
+
+ + {/* Destructive Box Button */} +
+

Destructive Box Button

+
+ + Delete Item + + + Large Delete + + + Disabled + +
+
+
+ ), +} + +export const IconButtons: Story = { + parameters: { + docs: { + description: { + story: ` +아이콘 형태의 버튼들을 모두 볼 수 있습니다. 아이콘만 포함하며 공간 효율적인 인터페이스에 적합합니다. + +**특징:** +- **Primary Solid**: 주요 아이콘 액션 +- **Secondary Plain**: 3가지 크기 지원 (sm, md, lg) +- **Secondary Ghost**: 44x44px 고정 크기, 14가지 아이콘 지원 +- 모든 아이콘 버튼은 정사각형 형태 + `, + }, + }, + }, + render: () => ( +
+ {/* Primary Icon Button */} +
+

Primary Icon Button

+
+ + + + + + + + + + + + +
+
+ + {/* Secondary Plain Icon Buttons */} +
+

Secondary Plain Icon Buttons

+
+
+ Small: + + + + + + + + + +
+
+ Medium: + + + + + + + + + +
+
+ Large: + + + + + + + + + +
+
+
+ + {/* Secondary Ghost Icon Buttons */} +
+

+ Secondary Ghost Icon Buttons (44x44px) +

+
+ {Object.entries(iconMap).map(([name, IconComponent]) => ( +
+ + + + {name} +
+ ))} +
+
+
+ ), +} + +export const SizeComparison: Story = { + parameters: { + docs: { + description: { + story: ` +각 버튼 타입별로 지원하는 크기를 비교해볼 수 있습니다. + +**크기 지원 현황:** +- **PrimaryBoxButton**: xs, sm, md, lg, xl, 2xl (6가지) +- **DestructiveSolidBoxButton**: xs, sm, md, lg, xl, 2xl (6가지) +- **SecondaryOutlineBoxButton**: md, lg (2가지) +- **SecondaryPlainIconButton**: sm, md, lg (3가지) +- **기타**: 고정 크기 + `, + }, + }, + }, + render: () => ( +
+ {/* Primary Box Button Sizes */} +
+

Primary Box Button Sizes

+
+ {["xs", "sm", "md", "lg", "xl", "2xl"].map((size) => ( +
+ + {size.toUpperCase()} + + {size} +
+ ))} +
+
+ + {/* Destructive Box Button Sizes */} +
+

Destructive Box Button Sizes

+
+ {["xs", "sm", "md", "lg", "xl", "2xl"].map((size) => ( +
+ + Delete {size.toUpperCase()} + + {size} +
+ ))} +
+
+ + {/* Secondary Plain Icon Button Sizes */} +
+

+ Secondary Plain Icon Button Sizes +

+
+ {["sm", "md", "lg"].map((size) => ( +
+ + + + {size} +
+ ))} +
+
+
+ ), +} + +export const InteractiveStates: Story = { + parameters: { + docs: { + description: { + story: ` +버튼의 다양한 상태를 확인할 수 있습니다. 모든 상태는 CSS 가상선택자로 구현되어 자동으로 적용됩니다. + +**상태:** +- **Default**: 기본 상태 +- **Hover**: 마우스를 올렸을 때 (자동 적용) +- **Active**: 클릭했을 때 (자동 적용) +- **Focus**: 키보드 포커스 시 (자동 적용) +- **Disabled**: 비활성화 상태 (disabled 속성 사용) + `, + }, + }, + }, + render: () => ( +
+
+

Default vs Disabled States

+
+
+

Default States

+
+ + Primary Solid + + + Secondary Outline + + + Destructive + +
+ + + + + + + + + +
+
+
+ +
+

Disabled States

+
+ + Primary Solid + + + Secondary Outline + + + Destructive + +
+ + + + + + + + + +
+
+
+
+
+
+ ), +} + +export const AsChildPattern: Story = { + parameters: { + docs: { + description: { + story: ` +\`asChild\` 패턴을 사용하여 다른 요소를 버튼 스타일로 렌더링하는 예시입니다. +Next.js Link, React Router Link 등과 함께 사용할 때 매우 유용합니다. + +**사용법:** +\`\`\`tsx + + External Link + +\`\`\` + `, + }, + }, + }, + render: () => ( +
+
+

asChild Examples

+
+ + +
+

아이콘 버튼을 링크로 사용:

+ +
+
+
+
+ ), +} + +``` diff --git a/shared/figma-plugin/rules/dialog.stories.mdx b/shared/figma-plugin/rules/dialog.stories.mdx new file mode 100644 index 00000000..8834bdc4 --- /dev/null +++ b/shared/figma-plugin/rules/dialog.stories.mdx @@ -0,0 +1,284 @@ +--- +title: dialog.stories.tsx +source: src/stories/dialog.stories.tsx +generated: 2025-12-08T12:37:12.106Z +--- +# dialog.stories.tsx + +````tsx +// Source: src/stories/dialog.stories.tsx +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { + Dialog, + DialogBody, + DialogButton, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTrigger, +} from "../components/dialog" + +/** + * # Dialog Component + * + * 다양한 스타일의 다이얼로그를 제공하는 컴포넌트입니다. + * + * ## 접근성 향상 기능 + * + * **srTitle**: DialogHeader가 없는 경우 스크린 리더 전용 제목을 제공할 수 있습니다. + * - 시각적으로는 보이지 않지만 스크린 리더가 읽을 수 있어 접근성을 향상시킵니다 + * - DialogHeader가 존재하면 자동으로 무시됩니다 + * - WCAG 가이드라인에 따라 모든 다이얼로그는 적절한 제목을 가져야 합니다 + * + * ```tsx + * + * 정말로 이 항목을 삭제하시겠습니까? + * + * ``` + * + * ## 사용법 + * + * ```tsx + * import { + * Dialog, + * DialogTrigger, + * DialogContent, + * DialogHeader, + * DialogBody, + * DialogFooter, + * DialogClose, + * DialogButton + * } from "@ject-5-fe/design/components/dialog" + * + * function MyDialog() { + * return ( + * + * 다이얼로그 열기 + * + * 제목 + * 내용을 입력하세요 + * + * + * 취소 + * + * + * 확인 + * + * + * + * + * ) + * } + * ``` + * + * ## 구성 요소 + * - `Dialog`: 다이얼로그의 루트 컴포넌트 + * - `DialogTrigger`: 다이얼로그를 여는 트리거 버튼 + * - `DialogContent`: 다이얼로그의 메인 콘텐츠 영역 + * - `DialogHeader`: 다이얼로그의 제목 영역 (선택적) + * - `DialogBody`: 다이얼로그의 본문 영역 (선택적) + * - `DialogFooter`: 다이얼로그의 하단 버튼 영역 + * - `DialogClose`: 다이얼로그를 닫는 버튼 로직 (스타일 없음) + * - `DialogButton`: 다이얼로그용 버튼 컴포넌트들 (스타일 있음) + * - `DialogButton.Primary`: PrimaryBoxButton 기반 (파란색) + * - `DialogButton.Secondary`: SecondaryPlainBoxButton 기반 (회색) + * - `DialogButton.Destructive`: DestructiveSolidBoxButton 기반 (빨간색) + * + * ## 사용 설명 + * 1. `DialogClose`는 `asChild`와 함께 사용하기 + * - DialogClose는 닫기 기능만 제공하고 스타일은 없으므로, asChild prop을 사용해 DialogButton 컴포넌트에 닫기 기능을 전달하면서, 깔끔한 html구조를 유지할 수 있습니다 + * + * ```tsx + * + * 확인 + * + * ``` + * + * 2. `DialogButton`은 객체 패턴으로 사용하기 + * - DialogButton은 footer의 버튼 overrides에 대응하기 위해 여러 버튼 스타일을 제공하는 객체형태로, dot notation으로 원하는 스타일을 선택해서 사용합니다. + * + * ```tsx + * // PrimaryBoxButton 기반 + * 확인 + * + * // SecondaryPlainBoxButton 기반 + * 취소 + * + * // DestructiveSolidBoxButton 기반 + * 삭제 + * ``` + * + * 3. 접근성을 위한 `srTitle` 사용하기 + * - WCAG 접근성 원칙에 의하면, DialogHeader와 DialogBody를 모두 사용해야 하지만, UI 상 그렇지 않은 경우가 존재합니다 + * - DialogHeader를 사용하지 않는 다이얼로그에서는 DialogBody의 prop으로, 스크린 리더 사용자를 위한 srTitle을 추가할 수 있습니다 + * - srTitle은 시각적으로는 보이지 않지만 스크린 리더가 읽을 수 있는 제목을 제공합니다 + * + * ```tsx + * + * 정말로 삭제하시겠습니까? + * + * ``` + * + * ## 피그마 구현과 다른점 + * 피그마의 Dialog 컴포넌트에는 `style,type` variant가 있지만, 실제 구현에서는 사용하지 않습니다 + * + * - **피그마**: Dialog variant로 style = `onlyTitle`, `onlyBody` 등을 선택 + * - **실제 구현**: 필요한 컴포넌트만 조합해서 사용 + * - 제목만 필요한 경우: `DialogHeader`만 사용 + * - 본문만 필요한 경우: `DialogBody`만 사용 + * - 제목과 본문 모두 필요한 경우: `DialogHeader`와 `DialogBody` 모두 사용 + */ + +const meta = { + title: "Components/Dialog", + component: DialogContent, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + children: { + control: false, + description: "다이얼로그 내부 콘텐츠", + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Basic: Story = { + render: () => ( + + + 기본 다이얼로그 열기 + + + 이 게임을 라이브러리에 등록하시겠습니까? + + 등록된 게임은 모든 사용자와 공유되며, 등록 후에는 수정이 불가능합니다. + + + + 닫기 + + + + + + + + ), +} + +export const WithTitleOnly: Story = { + render: () => ( + + + 제목만 있는 다이얼로그 + + + 이 게임을 라이브러리에 등록하시겠습니까? + + + 취소 + + + 확인 + + + + + ), +} + +export const WithBodyOnly: Story = { + render: () => ( + + + 본문만 있는 다이얼로그 + + + + 등록된 게임은 모든 사용자와 공유되며, 등록 후에는 수정이 불가능합니다. + + + + 취소 + + + 삭제 + + + + + ), +} + +export const SingleButton: Story = { + render: () => ( + + + 버튼 하나만 있는 다이얼로그 + + + 알림 + 작업이 성공적으로 완료되었습니다. + + + 확인 + + + + + ), +} + +export const WithSrOnlyTitle: Story = { + render: () => ( + + + srTitle을 활용한 다이얼로그 + + + + 정말로 이 게임을 삭제하시겠습니까? 삭제된 게임은 복구할 수 없습니다. + + + + 취소 + + + 삭제 + + + + + ), + parameters: { + docs: { + description: { + story: ` +**접근성 향상을 위한 srTitle 사용 예시** + +DialogHeader가 없는 경우, 스크린 리더 사용자를 위해 \`srTitle\` prop을 사용할 수 있습니다. + +- \`srTitle\`은 시각적으로는 보이지 않지만 스크린 리더가 읽을 수 있는 제목을 제공합니다 +- DialogHeader가 존재하면 srTitle은 무시됩니다 + +\`\`\`tsx + + 정말로 이 게임을 삭제하시겠습니까? + +\`\`\` + +개발자 도구에서 Elements 탭을 확인하면 \`sr-only\` 클래스가 적용된 숨겨진 제목 요소를 볼 수 있습니다. + `, + }, + }, + }, +} + +```` diff --git a/shared/figma-plugin/rules/gameCreate.stories.mdx b/shared/figma-plugin/rules/gameCreate.stories.mdx new file mode 100644 index 00000000..e82810f2 --- /dev/null +++ b/shared/figma-plugin/rules/gameCreate.stories.mdx @@ -0,0 +1,115 @@ +--- +title: gameCreate.stories.tsx +source: src/stories/gameCreate.stories.tsx +generated: 2025-12-08T12:37:12.109Z +--- +# gameCreate.stories.tsx + +```tsx +// Source: src/stories/gameCreate.stories.tsx +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { GameCreate } from "../components/gameCreate" + +const meta = { + title: "Components/GameCreate", + component: GameCreate, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +# GameCreate Component + +게임 만들기 카드 컴포넌트입니다. 사용자가 새로운 게임을 생성할 때 사용되는 직관적인 인터페이스를 제공합니다. + +## 🏗️ 컴포넌트 구조 + +이 컴포넌트는 게임 생성의 시작점 역할을 하는 카드 형태의 컴포넌트입니다: + +- **아이콘 영역**: 게임 생성과 관련된 시각적 아이콘 (178px × 168px) +- **텍스트 영역**: "게임 만들기" 라벨 (typography-heading-sm-bold) + +## 🎨 디자인 특징 + +### 📐 크기 및 레이아웃 +- **전체 크기**: 178px × 192px (아이콘 168px + 텍스트 24px) +- **레이아웃**: 세로 방향 정렬 (flex-col) +- **간격**: 아이콘과 텍스트 사이 16px 간격 (gap-4) + +### 🎯 상호작용 +- **클릭 가능**: cursor-pointer로 클릭 가능함을 표시 +- **호버 효과**: 필요시 커스텀 클래스로 호버 효과 추가 가능 +- **접근성**: alt 텍스트로 스크린 리더 지원 + +## 💻 사용법 + +### 기본 사용법 +\`\`\`tsx +import { GameCreate } from "@shared/design/src/components/gameCreate" + +function GameLibrary() { + const handleCreateGame = () => { + // 게임 생성 페이지로 이동 + router.push("/create") + } + + return ( +
+ + {/* 다른 게임 카드들 */} +
+ ) +} +\`\`\` + +### 커스텀 스타일링 +\`\`\`tsx + +\`\`\` + +## 🎯 주요 Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| \`onClick\` | \`() => void\` | \`undefined\` | 클릭 시 실행할 함수 | +| \`className\` | \`string\` | \`""\` | 추가 CSS 클래스 | + +## 🎨 디자인 토큰 + +- **아이콘**: \`/create-game-icon.svg\` (178px × 168px) +- **텍스트**: \`typography-heading-sm-bold\` (19px, Bold, 120% line-height) +- **색상**: \`text-text-primary\` (기본 텍스트 색상) +- **텍스트 처리**: \`truncate\` (오버플로우 시 말줄임표) + +## 🔗 관련 컴포넌트 + +- **GameCard**: 기존 게임을 표시하는 카드 컴포넌트 +- **Button**: 다양한 버튼 컴포넌트들 +- **Dialog**: 모달 대화상자 컴포넌트 + +## ⚠️ 주의사항 + +- 아이콘 파일(\`/create-game-icon.svg\`)이 public 디렉토리에 있어야 합니다 +- 클릭 핸들러는 필수가 아니지만, 실제 사용 시에는 반드시 제공해야 합니다 +- 텍스트는 자동으로 말줄임표 처리되므로 긴 텍스트는 지원하지 않습니다 +`, + }, + }, + }, + tags: ["autodocs"], + argTypes: { + onClick: { action: "clicked" }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {}, +} +``` diff --git a/shared/figma-plugin/rules/icon.stories.mdx b/shared/figma-plugin/rules/icon.stories.mdx new file mode 100644 index 00000000..4098a11d --- /dev/null +++ b/shared/figma-plugin/rules/icon.stories.mdx @@ -0,0 +1,276 @@ +--- +title: icon.stories.tsx +source: src/stories/icon.stories.tsx +generated: 2025-12-08T12:37:12.105Z +--- +# icon.stories.tsx + +```tsx +// Source: src/stories/icon.stories.tsx +import { + Add, + Arrow, + Cross, + Edit, + Hidden, + Iconplaceholder_16px, + Iconplaceholder_24px, + Iconplaceholder_28px, + Iconplaceholder_32px, + Magnifier, + Minus, + MoreDot, + Play, + Show, + Sun, + Trash, + Unshare, + Upload, +} from "../icons" + +export default { + title: "Icons", + component: () => null, + parameters: { + docs: { + description: { + component: ` +# Icon Components + +디자인 시스템에서 사용되는 모든 아이콘 컴포넌트들입니다. +리액트 컴포넌트로 변환이 되었기 때문에, 피그마의 아이콘 네임으로 import하여 바로 사용할 수 있습니다. + +## 📋 기본 정보 + +- **기본 크기**: 24×24px (모든 아이콘의 기본 사이즈) +- **크기 변경**: 필요 시 \`className="size-*"\`로 변경 가능 +- **색상 적용**: \`className\` prop을 통해 색상 적용 + +## 📦 사용법 + +\`\`\`tsx +import { Add, Arrow, Cross } from "@ject-5-fe/design/icons" + +// 기본 사용법 (24×24px) + + +// 크기 변경 + // 16px + // 24px + // 32px + +// 색상 적용 + + +\`\`\` + +## 🎨 크기 옵션 + +| className | 크기 | +|-----------|------| +| \`size-4\` | 16×16px | +| \`size-6\` | 24×24px (기본) | +| \`size-7\` | 28×28px | +| \`size-8\` | 32×32px | + +## 🎯 색상 적용 + +Tailwind CSS의 색상 클래스를 사용하여 아이콘 색상을 변경할 수 있습니다: + +- \`text-black\`, \`text-white\` +- \`text-gray-500\`, \`text-blue-500\` 등 +- 커스텀 색상 토큰 사용 가능 + +## 📝 아이콘 목록 + +아래 스토리에서 모든 사용 가능한 아이콘들을 확인할 수 있습니다. + `, + }, + }, + }, + tags: ["autodocs"], + argTypes: { + size: { + control: { type: "select" }, + options: [16, 24, 28, 32], + defaultValue: 24, + description: "아이콘 크기 (px)", + table: { + type: { summary: "number" }, + defaultValue: { summary: "24" }, + }, + }, + color: { + control: { type: "select" }, + options: [ + "text-black", + "text-gray-500", + "text-blue-500", + "text-red-500", + "text-green-500", + ], + defaultValue: "text-black", + description: "아이콘 색상 클래스", + table: { + type: { summary: "string" }, + defaultValue: { summary: "text-black" }, + }, + }, + }, +} + +const icons = [ + { name: "Add", component: Add }, + { name: "Arrow", component: Arrow }, + { name: "Cross", component: Cross }, + { name: "Edit", component: Edit }, + { name: "Hidden", component: Hidden }, + { name: "Iconplaceholder 16px", component: Iconplaceholder_16px }, + { name: "Iconplaceholder 24px", component: Iconplaceholder_24px }, + { name: "Iconplaceholder 28px", component: Iconplaceholder_28px }, + { name: "Iconplaceholder 32px", component: Iconplaceholder_32px }, + { name: "Magnifier", component: Magnifier }, + { name: "Minus", component: Minus }, + { name: "MoreDot", component: MoreDot }, + { name: "Play", component: Play }, + { name: "Show", component: Show }, + { name: "Sun", component: Sun }, + { name: "Trash", component: Trash }, + { name: "Unshare", component: Unshare }, + { name: "Upload", component: Upload }, +] + +export const AllIcons = ({ size = 24, color = "text-black" }) => ( +
+ {icons.map(({ name, component: IconComponent }) => ( +
+ + {name} +
+ ))} +
+) + +AllIcons.parameters = { + docs: { + description: { + story: ` +모든 사용 가능한 아이콘들을 한 번에 볼 수 있습니다. + +**기능:** +- 실시간 크기 조절 (16px ~ 32px) +- 색상 변경 테스트 +- 아이콘 이름 표시 + +**사용법:** 컨트롤 패널에서 크기와 색상을 조절해보세요. + `, + }, + }, +} + +AllIcons.args = { + size: 24, + color: "text-black", +} + +export const ColorTests = () => ( +
+

Color Injection Tests

+ {[ + "text-black", + "text-gray-500", + "text-blue-500", + "text-red-500", + "text-green-500", + ].map((colorClass) => ( +
+

{colorClass}

+
+ {icons.slice(0, 5).map(({ name, component: IconComponent }) => ( +
+ + {name} +
+ ))} +
+
+ ))} +
+) + +ColorTests.parameters = { + docs: { + description: { + story: ` +다양한 색상 클래스가 아이콘에 어떻게 적용되는지 확인할 수 있습니다. + +**테스트 색상:** +- \`text-black\`: 기본 검정색 +- \`text-gray-500\`: 회색 (중간 톤) +- \`text-blue-500\`: 파란색 +- \`text-red-500\`: 빨간색 +- \`text-green-500\`: 초록색 + +**활용:** 원하는 색상 클래스를 복사해서 프로젝트에 적용하세요. + `, + }, + }, +} + +export const SizeTests = () => ( +
+

Size Injection Tests

+ {[16, 24, 28, 32].map((size) => ( +
+

Size: {size}px

+
+ {icons.slice(0, 5).map(({ name, component: IconComponent }) => ( +
+ + {name} + + size- + {size === 16 + ? "4" + : size === 24 + ? "6" + : size === 28 + ? "7" + : "8"} + +
+ ))} +
+
+ ))} +
+) + +SizeTests.parameters = { + docs: { + description: { + story: ` +아이콘의 다양한 크기를 테스트할 수 있습니다. + +**크기 옵션:** +- **16px**: \`className="size-4"\` - 작은 버튼, 인라인 텍스트용 +- **24px**: \`className="size-6"\` - 기본 크기 (권장) +- **28px**: \`className="size-7"\` - 중간 크기 버튼용 +- **32px**: \`className="size-8"\` - 큰 버튼, 헤더용 + +**참고:** 24×24px가 기본 크기이므로, 대부분의 경우 추가 클래스 없이 사용 가능합니다. + `, + }, + }, +} + +``` diff --git a/shared/figma-plugin/rules/input.stories.mdx b/shared/figma-plugin/rules/input.stories.mdx new file mode 100644 index 00000000..ad1f34cb --- /dev/null +++ b/shared/figma-plugin/rules/input.stories.mdx @@ -0,0 +1,431 @@ +--- +title: input.stories.tsx +source: src/stories/input.stories.tsx +generated: 2025-12-08T12:37:12.097Z +--- +# input.stories.tsx + +```tsx +// Source: src/stories/input.stories.tsx +import type { Meta, StoryObj } from "@storybook/react-vite" +import { Form } from "radix-ui" +import * as React from "react" + +import { Control, ErrorText, Field, Label } from "../components/input" + +const meta = { + title: "Components/Input", + component: Field, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +# Input Component + +**Radix UI Form**을 기반으로 구축된 폼 입력 필드 컴포넌트입니다. 다양한 스타일과 상태를 지원하며, 접근성과 사용자 경험을 고려하여 설계되었습니다. + +## 🏗️ Radix UI Form 기반 구조 + +이 컴포넌트는 **Radix UI Form**을 활용하여 구현되었습니다: + +- **Form.Root**: HTML form 태그를 렌더링하는 폼의 루트 컨테이너 +- **Form.Field**: 개별 필드 컨테이너 +- **Form.Label**: 접근성을 고려한 label 태그 +- **Form.Control**: 실제 input 태그를 래핑하는 요소 +- **Form.Message**: 에러 메시지를 표시하는 요소 + +## 컴포넌트 구성요소 + +### 🧩 합성 컴포넌트 패턴 +- **Root**: Form.Root (HTML form 태그) +- **Field**: 폼 필드 컨테이너, 타입과 상태 관리 +- **Label**: 접근성을 위한 HTML label 태그 컴포넌트 +- **Control**: 실제 HTML input 태그, 제어된 상태 관리 +- **ErrorText**: 에러 메시지 표시 요소 + +## 입력 타입별 특징 + +### 🔍 leftIcon (검색 입력) +- 크기: 871px × 64px (대형 검색 필드) +- 좌측 검색 아이콘 포함 +- 검색 인터페이스에 최적화 + +### 📝 noIcon (기본 입력) +- 크기: 320px (컴팩트 사이즈) +- 아이콘 없는 깔끔한 디자인 +- 일반적인 텍스트 입력에 적합 + +### 🏷️ labelOn (라벨 포함) +- 크기: 452px +- 상단 라벨과 입력 필드 조합 +- 폼 필드에 최적화된 레이아웃 + +### 🔄 reset (리셋 버튼) +- 크기: 452px +- 우측 삭제 버튼 포함 +- 입력 내용 즉시 삭제 가능 + +## 상태 관리 + +### 🎯 제어된/비제어된 컴포넌트 +- **useControllableState** (Radix UI)를 사용한 상태 관리 +- 제어된 모드와 비제어된 모드 모두 지원 + +**Props 타입:** +- \`value?: string\` - 제어된 모드에서 현재 입력값 +- \`defaultValue?: string\` - 비제어된 모드에서 초기 입력값 +- \`onChange?: (value: string) => void\` - 입력값이 변경될 때 호출되는 함수 + +**사용 모드:** +- **제어**: \`value\`와 \`onChange\`를 모두 제공하여 외부에서 상태 관리 +- **비제어**: \`defaultValue\`만 제공하거나 아무것도 제공하지 않아 내부에서 상태 관리 + +### 🎨 상태별 스타일링 +- **default**: 기본 상태 (포커스 시 파란색 테두리) +- **error**: 에러 상태 (빨간색 테두리, 에러 메시지) + +## 기본 사용법 + +\`\`\`tsx +import { Field, Label, Control, ErrorText, Root } from "@/components/input" + +function MyForm() { + const [value, setValue] = React.useState("") + + return ( + + + + + 사용자명은 필수 입력 항목입니다 + + + ) +} +\`\`\` + +## ⚠️ 주의사항 + +**Root는 필수로 사용해야 합니다** + +모든 Input 컴포넌트는 반드시 \`Root\` (Form.Root) 컴포넌트로 감싸야 합니다: + +\`\`\`tsx +// ✅ 올바른 사용법 + + + + + + +// ❌ 잘못된 사용법 - Root 없이 사용 + + + +\`\`\` + +## 접근성 특징 + +- **Radix UI Form**의 접근성 기능 활용 +- 스크린 리더 지원 +- 키보드 네비게이션 완전 지원 +- ARIA 속성 자동 설정 +- 에러 메시지와 필드 연결 + +## 디자인 토큰 + +CVA(Class Variance Authority)를 사용한 일관된 스타일링으로 디자인 시스템과 완벽히 통합됩니다. + `, + }, + }, + }, + tags: ["autodocs"], + argTypes: { + type: { + control: { type: "select" }, + options: ["leftIcon", "noIcon", "labelOn", "reset"], + description: "입력 필드의 타입을 선택합니다", + table: { + type: { summary: "'leftIcon' | 'noIcon' | 'labelOn' | 'reset'" }, + defaultValue: { summary: "undefined" }, + }, + }, + state: { + control: { type: "select" }, + options: ["default", "error"], + description: "입력 필드의 상태를 선택합니다", + table: { + type: { summary: "'default' | 'error'" }, + defaultValue: { summary: "'default'" }, + }, + }, + name: { + control: { type: "text" }, + description: "폼 필드 이름 (Form.Field의 name 속성)", + table: { + type: { summary: "string" }, + defaultValue: { summary: "undefined" }, + }, + }, + className: { + control: { type: "text" }, + description: "추가 CSS 클래스명", + table: { + type: { summary: "string" }, + defaultValue: { summary: "undefined" }, + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const NoIcon: Story = { + parameters: { + docs: { + description: { + story: ` +기본적인 입력 필드입니다. 아이콘 없이 깔끔한 디자인으로 일반적인 텍스트 입력에 적합합니다. + +**특징:** +- 320px 컴팩트 사이즈 +- 아이콘 없는 미니멀 디자인 +- Radix UI Form.Control 기반 접근성 + `, + }, + }, + }, + args: { + type: "noIcon", + state: "default", + name: "basic-input", + }, + render: (args) => { + const [value, setValue] = React.useState("") + + return ( + + + setValue(value)} + placeholder="텍스트를 입력하세요" + /> + + + ) + }, +} + +export const WithLabel: Story = { + parameters: { + docs: { + description: { + story: ` +라벨이 포함된 입력 필드입니다. 폼에서 각 필드의 목적을 명확히 표시할 때 사용합니다. + +**특징:** +- 452px 표준 사이즈 +- 상단 라벨로 명확한 필드 식별 +- Radix UI Form.Label로 접근성 보장 +- 라벨과 입력 필드 자동 연결 + `, + }, + }, + }, + args: { + type: "labelOn", + state: "default", + name: "labeled-input", + }, + render: (args) => { + const [value, setValue] = React.useState("") + + return ( + + + + setValue(value)} + placeholder="사용자명을 입력해주세요" + /> + + + ) + }, +} + +export const WithLeftIcon: Story = { + parameters: { + docs: { + description: { + story: ` +검색 아이콘이 포함된 대형 입력 필드입니다. 검색 인터페이스나 대형 입력이 필요한 경우에 사용합니다. + +**특징:** +- 871px × 64px 대형 사이즈 +- 좌측 검색(Magnifier) 아이콘 +- 검색 UI에 최적화된 디자인 +- 넓은 입력 영역으로 긴 텍스트 입력 지원 + `, + }, + }, + }, + args: { + type: "leftIcon", + state: "default", + name: "search-input", + }, + render: (args) => { + const [value, setValue] = React.useState("") + + return ( + + + + + + ) + }, +} + +export const WithReset: Story = { + parameters: { + docs: { + description: { + story: ` +삭제 버튼이 포함된 입력 필드입니다. 사용자가 입력 내용을 빠르게 지울 수 있는 기능을 제공합니다. + +**특징:** +- 452px 표준 사이즈 +- 우측 삭제(Trash) 버튼 +- 클릭 시 입력 내용 즉시 삭제 +- DestructiveSolidIconButton 컴포넌트 활용 + `, + }, + }, + }, + args: { + type: "reset", + state: "default", + name: "reset-input", + }, + render: (args) => { + const [value, setValue] = React.useState("") + + return ( + + + + + + ) + }, +} + +export const ErrorState: Story = { + parameters: { + docs: { + description: { + story: ` +에러 상태의 입력 필드입니다. 유효성 검사 실패 시 사용자에게 명확한 피드백을 제공합니다. + +**특징:** +- 빨간색 테두리로 에러 표시 +- Form.Message를 통한 에러 메시지 +- 접근성을 고려한 에러 메시지 연결 +- 포커스 시에도 에러 상태 유지 + `, + }, + }, + }, + args: { + type: "labelOn", + state: "error", + name: "error-input", + }, + render: (args) => { + const [value, setValue] = React.useState("invalid-email") + + return ( + + + + + 올바른 이메일 주소를 입력해주세요 + + + ) + }, +} + +export const ControlledInput: Story = { + parameters: { + docs: { + description: { + story: ` +제어된 컴포넌트로 사용하는 예시입니다. 외부 상태와 연동하여 입력값을 관리할 수 있습니다. + +**특징:** +- React.useState를 통한 상태 관리 +- value와 onChange props 활용 +- useControllableState (Radix UI) 내부 사용 +- 제어된/비제어된 모드 모두 지원 + `, + }, + }, + }, + args: { + type: "labelOn", + state: "default", + name: "controlled-input", + }, + render: (args) => { + const [value, setValue] = React.useState("초기값") + + return ( +
+
+ 현재 입력값: {value || "비어있음"} +
+ + + + + + + +
+ ) + }, +} + +``` diff --git a/shared/figma-plugin/rules/menu.stories.mdx b/shared/figma-plugin/rules/menu.stories.mdx new file mode 100644 index 00000000..d3b1d669 --- /dev/null +++ b/shared/figma-plugin/rules/menu.stories.mdx @@ -0,0 +1,134 @@ +--- +title: menu.stories.tsx +source: src/stories/menu.stories.tsx +generated: 2025-12-08T12:37:12.102Z +--- +# menu.stories.tsx + +````tsx +// Source: src/stories/menu.stories.tsx +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRoot, + DropdownMenuTrigger, +} from "../components/menu" +import { Edit, MoreDot, Trash, Unshare } from "../icons" + +/** + * # Menu Component + * + * 드롭다운 메뉴 컴포넌트입니다. 수직/수평 레이아웃과 텍스트/아이콘 타입을 지원합니다. + * + * ## 사용법 + * + * ```tsx + * import { + * DropdownMenuRoot, + * DropdownMenuTrigger, + * DropdownMenuContent, + * DropdownMenuItem + * } from "../components/menu" + * + * function MyMenu() { + * return ( + * + * 메뉴 + * + * 메뉴 아이템 1 + * 메뉴 아이템 2 + * + * + * ) + * } + * ``` + * + * ## 메뉴 타입 + * - `vertical`: 세로 레이아웃 (기본값) + * - `horizontal`: 가로 레이아웃 + * + * ## 콘텐츠 타입 + * - `text`: 텍스트만 표시 (기본값) + * - `icon`: 아이콘과 텍스트 함께 표시 + */ + +const meta = { + title: "Components/Menu", + component: DropdownMenuContent, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + type: { + control: { type: "select" }, + options: ["vertical", "horizontal"], + description: "메뉴의 레이아웃 방향을 선택합니다", + }, + contentType: { + control: { type: "select" }, + options: ["text", "icon"], + description: "메뉴 아이템의 콘텐츠 타입을 선택합니다", + }, + sideOffset: { + control: { type: "number", min: 0, max: 20 }, + description: "트리거와 메뉴 간의 거리", + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const VerticalText: Story = { + args: { + type: "vertical", + contentType: "text", + sideOffset: 4, + }, + render: (args) => ( + + + + + + 로그아웃 + 설정 + 도움말 + + + ), +} + +export const HorizontalIcon: Story = { + args: { + type: "horizontal", + contentType: "icon", + sideOffset: 4, + }, + render: (args) => ( + + + + + + + + 수정 + + + + 공유 + + + + 삭제 + + + + ), +} + +```` diff --git a/shared/figma-plugin/rules/playerStatus.stories.mdx b/shared/figma-plugin/rules/playerStatus.stories.mdx new file mode 100644 index 00000000..c636cefd --- /dev/null +++ b/shared/figma-plugin/rules/playerStatus.stories.mdx @@ -0,0 +1,326 @@ +--- +title: playerStatus.stories.tsx +source: src/stories/playerStatus.stories.tsx +generated: 2025-12-08T12:37:12.107Z +--- +# playerStatus.stories.tsx + +```tsx +// Source: src/stories/playerStatus.stories.tsx +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { PlayerStatus } from "../components/playerStatus" + +const meta = { + title: "Components/PlayerStatus", + component: PlayerStatus, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +## 🎯 PlayerStatus 컴포넌트 - 게임 플레이어 상태 카드 + +게임 내 플레이어/팀의 상태를 표시하고 점수를 관리하는 핵심 컴포넌트입니다. **상태 기반 반응형 레이아웃**을 통해 다양한 게임 상황에 대응합니다. + +### 🏗️ 주요 특징 + +- **상태 기반 레이아웃**: \`scoreView\` prop에 따른 자동 레이아웃 전환 +- **접근성 최적화**: 스크린리더 및 키보드 네비게이션 완벽 지원 +- **긴 텍스트 안전성**: 팀명 overflow 시 자동 truncate 처리 +- **인터랙티브 점수 조작**: 실시간 점수 증감 버튼 + +### 📱 언제 사용하나요? + +- **게임 진행 중**: 플레이어별 점수 표시 및 실시간 조작 +- **팀 관리 화면**: 점수 없이 팀명만 깔끔하게 표시 +- **사이드바/목록**: 플레이어 상태 요약 정보 제공 +- **점수판**: 게임 결과 및 순위 표시 + +### ⚠️ 주의사항 & Best Practices + +#### 📏 **레이아웃 제약사항** +- 카드 고정 너비: **350px** +- 내부 콘텐츠 최대 너비: **310px** +- 점수 섹션 고정 너비: **138px** (버튼 + 점수 영역) + +#### 🎨 **팀명 처리 규칙** +\`\`\`typescript +// ✅ 권장: 적절한 길이 + + + +// ⚠️ 긴 이름: 자동 truncate 적용 + +// → "아주아주긴팀이름입니..."로 표시 + +// 🔥 특수문자 지원 + +\`\`\` + +#### 🎮 **점수 관리 패턴** +\`\`\`typescript +// 게임 진행 중 - 점수 조작 가능 + updateScore(+1)} + onScoreDecrease={() => updateScore(-1)} +/> + +// 팀 설정 화면 - 이름만 표시 + + +// 게임 결과 - 점수만 표시 (조작 불가) + +\`\`\` + +### 🔗 관련 컴포넌트 + +- \`SecondaryPlainIconButton\`: 점수 증감 버튼 +- \`Add\`, \`Minus\`: 점수 조작 아이콘 +- \`GameSetup\`: PlayerStatus를 활용하는 상위 컴포넌트 + +### 🎯 접근성 (A11y) 상세 + +#### **ARIA 라벨링 시스템** +\`\`\`typescript +// 카드 전체 그룹 식별 +role="group" +aria-label="{팀명} 점수 카드" + +// 점수 증감 버튼 명확한 설명 +aria-label="{팀명} 점수 증가" +aria-label="{팀명} 점수 감소" + +// 현재 점수 상태 알림 +aria-label="{팀명} 현재 점수" +\`\`\` + +#### **키보드 네비게이션** +- \`Tab\`: 점수 버튼 간 순차 이동 +- \`Space/Enter\`: 버튼 활성화 +- \`Shift+Tab\`: 역순 이동 + +#### **스크린리더 지원** +- 카드 진입 시: "{팀명} 점수 카드" +- 버튼 포커스: "{팀명} 점수 증가 버튼" +- 점수 변경 시: 자동 상태 업데이트 알림 + +### 🔄 Figma 디자인 대비 주요 개선사항 + +#### 1. **반응형 레이아웃 시스템** +**변경 전 (Figma)**: 고정된 \`padding: 39px 20px\` +**변경 후 (현재)**: 상태별 동적 레이아웃 + +\`\`\`css +/* scoreView=true: 좌우 공간 분할 */ +.team-name { flex: 1; text-align: center; } +.score-section { width: 138px; } + +/* scoreView=false: 전체 중앙 정렬 */ +.team-name { width: 100%; text-align: center; } +\`\`\` + +#### 2. **E2E 테스트 친화적 접근성** +**추가된 기능**: 테스트 자동화를 위한 접근성 강화 +- 컴포넌트별 명확한 \`role\` 속성 +- 버튼별 고유 \`aria-label\` 제공 +- 상태 변경 감지 가능한 구조 + +#### 3. **확장 가능한 토큰 시스템** +**개선된 부분**: Design Token 기반 스타일링 +- \`typography-heading-xl-medium\`: 팀명 스타일 +- \`typography-heading-lg-semibold\`: 점수 스타일 +- \`background-primary\`: 카드 배경 토큰 + `, + }, + }, + }, + tags: ["autodocs"], + argTypes: { + name: { + description: "플레이어/팀 이름. 긴 이름은 자동으로 truncate 처리됩니다.", + control: "text", + }, + score: { + description: "현재 점수 표시 텍스트 (예: '0점', '15점')", + control: "text", + }, + scoreView: { + description: `점수 조작 UI 표시 여부 +- \`true\`: 점수와 증감 버튼 표시 (게임 진행 중) +- \`false\`: 팀명만 중앙 정렬로 표시 (팀 관리 화면)`, + control: "boolean", + }, + onScoreIncrease: { + description: "점수 증가 버튼 클릭 시 호출되는 함수", + }, + onScoreDecrease: { + description: "점수 감소 버튼 클릭 시 호출되는 함수", + }, + className: { + description: "추가 CSS 클래스 (선택사항)", + control: "text", + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: "Default (With Score)", + args: { + name: "A팀", + score: "0점", + scoreView: true, + onScoreIncrease: () => console.log("점수 증가"), + onScoreDecrease: () => console.log("점수 감소"), + }, +} + +export const NameOnly: Story = { + args: { + name: "B팀", + score: "0점", + scoreView: false, + }, + parameters: { + docs: { + description: { + story: + "팀 관리 화면이나 점수가 필요 없는 상황에서 사용. 팀명이 카드 중앙에 배치됩니다.", + }, + }, + }, +} + +export const HighScore: Story = { + args: { + name: "우승팀", + score: "42점", + scoreView: true, + onScoreIncrease: () => console.log("점수 증가"), + onScoreDecrease: () => console.log("점수 감소"), + }, + parameters: { + docs: { + description: { + story: "게임이 진행되면서 점수가 높아진 상태입니다.", + }, + }, + }, +} + +export const LongTeamName: Story = { + args: { + name: "아주아주아주아주긴팀이름입니다정말긴팀이름", + score: "7점", + scoreView: true, + onScoreIncrease: () => console.log("점수 증가"), + onScoreDecrease: () => console.log("점수 감소"), + }, + parameters: { + docs: { + description: { + story: + "팀명이 매우 길 경우 자동으로 truncate 처리되어 레이아웃이 깨지지 않습니다.", + }, + }, + }, +} + +export const LongTeamNameOnly: Story = { + name: "Long Team Name (No Score)", + args: { + name: "정말정말정말정말정말긴팀이름테스트용도입니다", + score: "0점", + scoreView: false, + }, + parameters: { + docs: { + description: { + story: + "점수가 숨겨진 상태에서도 긴 팀명이 적절히 처리되는지 확인할 수 있습니다.", + }, + }, + }, +} + +export const SpecialCharacters: Story = { + args: { + name: "🔥이모지가 포함된 팀명", + score: "3점", + scoreView: true, + onScoreIncrease: () => console.log("점수 증가"), + onScoreDecrease: () => console.log("점수 감소"), + }, + parameters: { + docs: { + description: { + story: "이모지나 특수 문자가 포함된 팀명도 정상적으로 처리됩니다.", + }, + }, + }, +} + +export const AllStates: Story = { + name: "All States Comparison", + args: { + name: "비교용", + score: "0점", + scoreView: true, + }, + render: () => ( +
+
점수 표시 상태
+
+ console.log("점수 증가")} + onScoreDecrease={() => console.log("점수 감소")} + /> + console.log("점수 증가")} + onScoreDecrease={() => console.log("점수 감소")} + /> + console.log("점수 증가")} + onScoreDecrease={() => console.log("점수 감소")} + /> +
+ +
팀명만 표시
+
+ + + +
+
+ ), + parameters: { + docs: { + description: { + story: + "다양한 상태의 PlayerStatus를 한 번에 비교해볼 수 있습니다. QA 테스트 시 참고용으로 활용하세요.", + }, + }, + }, +} + +``` diff --git a/shared/figma-plugin/rules/progress.stories.mdx b/shared/figma-plugin/rules/progress.stories.mdx new file mode 100644 index 00000000..d085845f --- /dev/null +++ b/shared/figma-plugin/rules/progress.stories.mdx @@ -0,0 +1,128 @@ +--- +title: progress.stories.tsx +source: src/stories/progress.stories.tsx +generated: 2025-12-08T12:37:12.109Z +--- +# progress.stories.tsx + +```tsx +// Source: src/stories/progress.stories.tsx +import type { Meta, StoryObj } from "@storybook/react-vite" +import * as React from "react" + +import { Progress } from "../components/progress" + +const meta = { + title: "Components/Progress", + component: Progress, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +# Progress Component + +진행률을 표시하는 컴포넌트입니다. Radix UI Progress를 기반으로 구축되었습니다. + +## 사용법 + +\`\`\`tsx +import { Progress } from "@/components/progress" + +function MyComponent() { + return ( + + ) +} +\`\`\` + +## Props + +- \`value\`: 진행률 값 (0-100) +- \`className\`: 추가 CSS 클래스 + `, + }, + }, + }, + tags: ["autodocs"], + argTypes: { + value: { + control: { type: "range", min: 0, max: 100, step: 1 }, + description: "진행률 값 (0-100)", + table: { + type: { summary: "number" }, + defaultValue: { summary: "0" }, + }, + }, + className: { + control: { type: "text" }, + description: "추가 CSS 클래스명", + table: { + type: { summary: "string" }, + defaultValue: { summary: "undefined" }, + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + value: 50, + }, +} + +export const Empty: Story = { + args: { + value: 0, + }, +} + +export const Full: Story = { + args: { + value: 100, + }, +} + +export const Low: Story = { + args: { + value: 25, + }, +} + +export const High: Story = { + args: { + value: 75, + }, +} + +export const Interactive: Story = { + render: () => { + const [value, setValue] = React.useState(30) + + return ( +
+
진행률: {value}%
+ +
+ + +
+
+ ) + }, +} + +``` diff --git a/shared/figma-plugin/rules/question.stories.mdx b/shared/figma-plugin/rules/question.stories.mdx new file mode 100644 index 00000000..b5ac1191 --- /dev/null +++ b/shared/figma-plugin/rules/question.stories.mdx @@ -0,0 +1,346 @@ +--- +title: question.stories.tsx +source: src/stories/question.stories.tsx +generated: 2025-12-08T12:37:12.108Z +--- +# question.stories.tsx + +```tsx +// Source: src/stories/question.stories.tsx +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { Question } from "../components/question" + +interface StoryArgs { + state: "default" | "selected" | "error" + index?: number + onClick?: () => void + title?: string + image?: string + canDelete?: boolean + onDelete?: () => void + onMoveUp?: () => void + onMoveDown?: () => void + customActions?: React.ReactNode +} + +const meta = { + title: "Components/Question", + component: Question, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +## 🎯 Question 컴포넌트 - 게임 질문 카드 + +게임 생성 시 사용되는 질문 카드 컴포넌트입니다. **Compound Component Pattern**을 사용하여 유연한 구성이 가능합니다. + +### 🏗️ 주요 특징 + +- **Radix UI 기반**: BaseButton(Slot 기반)을 사용한 안정적인 접근성 +- **순서 기반 접근성**: \`index\` prop으로 "n번째 문제" 자동 라벨링 +- **동적 상태 관리**: 실시간 상태 변경 및 에러 처리 지원 +- **유연한 구성**: Title, Image, Actions를 자유롭게 조합 가능 + +### 📱 언제 사용하나요? + +- **게임 생성 화면**: 질문 목록 표시 및 편집 +- **질문 관리**: 순서 변경, 삭제, 상태 관리 +- **에러 처리**: 필수 입력 누락 시 시각적 피드백 + +### ⚠️ 주의사항 + +- \`index\` prop은 1부터 시작 (배열 index + 1) +- 마지막 질문은 \`canDelete={false}\`로 삭제 방지 필요 +- 이미지는 78×78px 권장, shared/design에서는 일반 img 태그 사용 + +### 🔗 관련 컴포넌트 + +- \`DestructiveSolidIconButton\`: 삭제 버튼 +- \`SecondaryPlainIconButton\`: 이동 버튼들 +- \`QuestionList\`: 여러 질문을 관리하는 상위 컴포넌트 + +### 🔄 Figma 디자인 대비 주요 변경사항 + +#### 1. **Compound Component Pattern 도입** +**변경 전 (Figma)**: 6개 고정 배리언트 (\`state × image\` 조합) +**변경 후 (현재)**: 유연한 조합 가능한 합성 컴포넌트 + +\`\`\`typescript +// Figma: 고정된 배리언트 + + +// 현재: 자유로운 구성 + + 커스텀 제목 + + + + + // 사용자 정의 액션 추가 가능 + + +\`\`\` + +#### 2. **순서 기반 접근성 시스템** +**추가된 기능**: \`index\` prop으로 자동 접근성 라벨 생성 +- Question 카드: \`aria-label="n번째 문제"\` +- 삭제 버튼: \`aria-label="n번째 문제 삭제"\` +- 이동 버튼: \`aria-label="n번째 문제 위로/아래로 이동"\` + +#### 3. **이미지 처리 방식 개선** +**변경 전**: \`image={true/false}\` 고정 속성 +**변경 후**: 유연한 이미지 주입 시스템 + +\`\`\`typescript +// 일반 img 태그 (shared/design) + + 설명 + + +// Next.js Image 주입 가능 (service/app) + + + + +// 커스텀 fallback 지원 +} /> +\`\`\` + +#### 4. **동적 상태 관리** +**추가된 기능**: 런타임 상태 변경 및 인터랙션 지원 +- \`onClick\` 이벤트로 편집 모드 전환 +- \`canDelete\` prop으로 동적 삭제 제한 +- 실시간 에러 상태 반영 (\`state="error"\`) + `, + }, + }, + }, + tags: ["autodocs"], + argTypes: { + state: { + control: { type: "select" }, + options: ["default", "selected", "error"], + description: + "**문제 상태**\n- `default`: 기본 상태\n- `selected`: 현재 편집 중\n- `error`: 입력 누락/검증 실패", + }, + index: { + control: { type: "number", min: 1, max: 10 }, + description: + '**문제 순서** (1부터 시작)\n- 접근성 라벨 자동 생성: "n번째 문제"\n- 버튼 라벨에도 반영: "n번째 문제 삭제"', + }, + onClick: { + action: "clicked", + description: + "**클릭 이벤트**\n- 질문 선택 시 호출\n- 편집 모드 전환 등에 활용", + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const Template = (args: StoryArgs) => ( + + {args.title} + {args.image ? ( + + 문제 이미지 + + ) : ( + + )} + {args.customActions ? ( + {args.customActions} + ) : ( + <> + + + + )} + +) + +export const BasicTextQuestion: Story = { + args: { + state: "default", + index: 1, + title: "사용자의 취미는 무엇인가요?", + canDelete: true, + }, + render: Template, + parameters: { + docs: { + description: { + story: + "**가장 기본적인 질문 카드**입니다. 이미지 없이 텍스트만으로 구성됩니다.", + }, + }, + }, +} + +export const CurrentlyEditing: Story = { + args: { + state: "selected", + index: 2, + title: "가장 좋아하는 음식을 설명해 주세요", + canDelete: true, + }, + render: Template, + parameters: { + docs: { + description: { + story: + "**현재 편집 중인 상태**입니다. 사용자가 질문을 선택했을 때 나타나는 시각적 피드백을 보여줍니다.", + }, + }, + }, +} + +export const ValidationError: Story = { + args: { + state: "error", + index: 3, + title: "문제를 입력해주세요", + canDelete: true, + }, + render: Template, + parameters: { + docs: { + description: { + story: + "**검증 실패 상태**입니다. 필수 입력이 누락되거나 유효하지 않은 질문일 때 표시됩니다. 제목 앞에 ❗ 아이콘이 자동으로 추가됩니다.", + }, + }, + }, +} + +export const QuestionWithImage: Story = { + args: { + state: "default", + index: 4, + title: "이 이미지에서 무엇을 보시나요?", + image: "/exampleThumbnail.jpg", + canDelete: true, + }, + render: Template, +} + +export const LastRequiredQuestion: Story = { + args: { + state: "default", + index: 1, + title: "최소 하나의 문제은 필요합니다", + canDelete: false, + }, + render: Template, +} + +export const GameCreationScenario: Story = { + parameters: { + docs: { + description: { + story: + "**실제 게임 생성 시나리오**입니다. 여러 질문이 함께 있을 때의 모습과 다양한 상태를 한 번에 확인할 수 있습니다.", + }, + }, + }, + render: () => ( +
+

게임 문제 목록

+ + + 편집중인 문제 + + + + + + + 작성된 문제 + + 문제 이미지 + + + + + + + 문제를 입력해주세요 + + + + +
+ ), +} + +export const WithClickAction: Story = { + args: { + state: "default", + index: 1, + title: "클릭해서 편집 모드로 전환", + canDelete: true, + onClick: () => alert("문제가 선택되었습니다!"), + }, + render: Template, +} + +export const WithMoveActions: Story = { + args: { + state: "default", + index: 2, + title: "위/아래 이동 버튼 테스트", + canDelete: true, + onMoveUp: () => alert("위로 이동!"), + onMoveDown: () => alert("아래로 이동!"), + }, + render: Template, +} + +export const CustomActions: Story = { + args: { + state: "default", + index: 1, + title: "커스텀 액션 버튼들", + customActions: ( +
+ + +
+ ), + }, + render: Template, +} + +export const LongTextQuestion: Story = { + args: { + state: "default", + index: 1, + title: + "이것은 매우 긴 질문 제목입니다. 텍스트가 너무 길어서 한 줄을 넘어가는 경우에 어떻게 처리되는지 확인하기 위한 예시입니다.", + canDelete: true, + }, + render: Template, +} + +``` diff --git a/shared/figma-plugin/rules/stickyActionBar.stories.mdx b/shared/figma-plugin/rules/stickyActionBar.stories.mdx new file mode 100644 index 00000000..39d7b174 --- /dev/null +++ b/shared/figma-plugin/rules/stickyActionBar.stories.mdx @@ -0,0 +1,88 @@ +--- +title: stickyActionBar.stories.tsx +source: src/stories/stickyActionBar.stories.tsx +generated: 2025-12-08T12:37:12.107Z +--- +# stickyActionBar.stories.tsx + +```tsx +// Source: src/stories/stickyActionBar.stories.tsx +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { PrimaryBoxButton } from "../components/button" +import { StickyActionBar } from "../components/stickyActionBar" + +const meta = { + title: "Components/StickyActionBar", + component: StickyActionBar, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "스크롤이 길어도 주요 액션을 화면 하단에 고정해 노출할 수 있는 레이아웃 래퍼입니다.", + }, + }, + }, + tags: ["autodocs"], +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const ViewportFixedAction: Story = { + render: () => ( +
+
+
+

+ 문제 편집 화면 +

+

+ 스크롤을 내려도 하단의 “문제 추가하기” 버튼이 고정되어 있는 모습을 + 확인하세요. +

+
+ +
+ {[...Array(24)].map((_, index) => ( +
+

+ 섹션 {index + 1} +

+

+ 실제 화면에서 긴 설명, 입력 폼, 미디어 블록 등이 들어갈 수 있는 + 영역을 가정한 더미 컨텐츠입니다. +

+
+ ))} +
+
+ + + + 문제 추가하기 + + +
+ ), + parameters: { + docs: { + description: { + story: + "전체 뷰포트 기준으로 StickyActionBar를 고정한 예시입니다. 메인 컨텐츠가 스크롤되더라도 액션 버튼이 항상 하단에 노출됩니다.", + }, + }, + }, +} + +``` diff --git a/shared/figma-plugin/rules/textField.stories.mdx b/shared/figma-plugin/rules/textField.stories.mdx new file mode 100644 index 00000000..98468c59 --- /dev/null +++ b/shared/figma-plugin/rules/textField.stories.mdx @@ -0,0 +1,183 @@ +--- +title: textField.stories.tsx +source: src/stories/textField.stories.tsx +generated: 2025-12-08T12:37:12.104Z +--- +# textField.stories.tsx + +```tsx +// Source: src/stories/textField.stories.tsx +import type { Meta, StoryObj } from "@storybook/react-vite" +import { useState } from "react" +import { expect, userEvent, within } from "storybook/test" + +import * as TextField from "../components/textField" + +const meta: Meta = { + title: "Components/TextField", + component: TextField.Root, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +- Root (\`
\`): 단순 컨테이너로, 폼 제출이 필요하면 외부 \`
\`을 사용해야 합니다 + - name(required): 필수 prop으로, 하위 태그들의 attribute의 prefix로 사용됩니다 + - state: default | error : 기본값은 default로, error로 변경 시 input과 errorText에 스타일이 적용됩니다 + +- Label (\`