diff --git a/.changeset/accordion-single-mode.md b/.changeset/accordion-single-mode.md new file mode 100644 index 00000000..731582af --- /dev/null +++ b/.changeset/accordion-single-mode.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/accordion": minor +--- + +Add single/multiple open mode support to Accordion component. When `type="single"` is set on `Accordion.Root`, only one item can be open at a time. Also supports controlled mode via `value` and `onValueChange` props. diff --git a/.changeset/add-card-ghost-variant.md b/.changeset/add-card-ghost-variant.md new file mode 100644 index 00000000..2bf367b6 --- /dev/null +++ b/.changeset/add-card-ghost-variant.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/card": minor +--- + +Add ghost variant to Card component with transparent background, no border, and no padding diff --git a/.changeset/beige-cows-know.md b/.changeset/beige-cows-know.md new file mode 100644 index 00000000..f606943d --- /dev/null +++ b/.changeset/beige-cows-know.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/tooltip": major +--- + +Refactor tooltip API: replace trigger prop with disableHoverListener, add controlled mode (open/onOpen/onClose), improve WCAG compliance, and fix animation on mount diff --git a/.changeset/chip-normalize-package-json.md b/.changeset/chip-normalize-package-json.md new file mode 100644 index 00000000..9b2d3fa4 --- /dev/null +++ b/.changeset/chip-normalize-package-json.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/chip": patch +--- + +Normalize `@sipe-team/chip` package.json to match the canonical structure used across other `@sipe-team/*` packages. Adds `publishConfig` with `access`/`registry`/`exports`, moves source `exports` to `./src/index.ts`, adds `type: "module"`, `repository`, `sideEffects: ["**/*.css"]`, and aligns scripts/dependencies. Consumer module resolution now uses the `import`/`require` branches declared under `publishConfig.exports`. diff --git a/.changeset/package-json-divergence-cleanup.md b/.changeset/package-json-divergence-cleanup.md new file mode 100644 index 00000000..cc090b97 --- /dev/null +++ b/.changeset/package-json-divergence-cleanup.md @@ -0,0 +1,14 @@ +--- +"@sipe-team/accordion": patch +"@sipe-team/avatar": patch +"@sipe-team/checkbox": patch +"@sipe-team/divider": patch +"@sipe-team/plugin-figma-codegen": patch +"@sipe-team/radio": patch +"@sipe-team/side": patch +"@sipe-team/switch": patch +"@sipe-team/theme": patch +"@sipe-team/typography": patch +--- + +Align existing `@sipe-team/*` `package.json` metadata with the canonical shape. Adds `publishConfig.registry` to divider/radio/side/switch, unifies `lint` scripts on `pnpm exec biome lint`, normalizes `workspace:^` → `workspace:*` in avatar/switch/typography, and moves accordion/theme/checkbox direct `react`, `@types/react`, `react-dom`, and `@vanilla-extract/css` specifiers to the pnpm catalog. diff --git a/.changeset/semantic-tokens-foundation.md b/.changeset/semantic-tokens-foundation.md new file mode 100644 index 00000000..34e45734 --- /dev/null +++ b/.changeset/semantic-tokens-foundation.md @@ -0,0 +1,30 @@ +--- +"@sipe-team/tokens": major +"@sipe-team/button": patch +--- + +redesign token contract structure with semantic color, spacing, and radius hierarchy + +## Breaking Changes + +### `vars` path changes + +| Token | Before | After | +|-------|--------|-------| +| color | `vars.color.primary` `vars.color.background` `vars.color.text` | `vars.color.accent.{default\|hover\|subtle}` `vars.color.background.{base\|subtle\|muted}` `vars.color.foreground.{default\|subtle\|muted\|onAccent}` `vars.color.border.{default\|strong\|focus}` `vars.color.status.{success\|warning\|danger\|info}.{foreground\|background\|border}` | +| spacing | `vars.spacing.{xs\|sm\|md\|lg\|xl}` | `vars.spacing.component.{xs\|sm\|md\|lg\|xl}` `vars.spacing.layout.{sm\|md\|lg\|xl}` | +| radius | `vars.radius.{none\|sm\|md\|lg\|xl\|full}` | `vars.radius.component.{sm\|md\|lg\|xl\|full}` `vars.radius.layout.{sm\|md\|lg}` | + +`vars.color.gradient` and `vars.color.secondary` have been removed. + +### `defaultTheme` export removed + +`defaultTheme` is no longer exported from `@sipe-team/tokens`. Theme variables are now applied automatically via `createGlobalTheme` on `:root`. Remove any explicit `defaultTheme` import or usage. + +### Default color mode changed to dark + +The `:root` theme now defaults to `mode: 'dark'` (previously `mode: 'light'`). If your app relied on the light-mode defaults from `:root`, you will need to apply a `[data-theme]` attribute or supply your own light-mode overrides. + +### Deprecated named exports + +`opacity`, `zIndex`, `borderWidth`, `borderStyle`, `shadows`, `breakpoints`, `breakpointQuery`, `responsiveStyle`, `grid` and their associated types are deprecated and will be removed in a future release. diff --git a/.changeset/spicy-wasps-clap.md b/.changeset/spicy-wasps-clap.md new file mode 100644 index 00000000..45be6409 --- /dev/null +++ b/.changeset/spicy-wasps-clap.md @@ -0,0 +1,20 @@ +--- +"@sipe-team/accordion": patch +"@sipe-team/checkbox": patch +"@sipe-team/skeleton": patch +"@sipe-team/divider": patch +"@sipe-team/tooltip": patch +"@sipe-team/button": patch +"@sipe-team/switch": patch +"@sipe-team/badge": patch +"@sipe-team/input": patch +"@sipe-team/radio": patch +"@sipe-team/reset": patch +"@sipe-team/card": patch +"@sipe-team/flex": patch +"@sipe-team/grid": patch +"@sipe-team/side": patch +"@sipe-team/typography": patch +--- + +Preserve CSS imports in `sideEffects` so consumer bundlers don't tree-shake `./styles.css`. diff --git a/.changeset/wet-pants-train.md b/.changeset/wet-pants-train.md new file mode 100644 index 00000000..76211d3c --- /dev/null +++ b/.changeset/wet-pants-train.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/tooltip": patch +--- + +improve accessibility and refactor styles for Tooltip diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index cd78cfab..019ed607 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -100,36 +100,36 @@ Before proposing changes, please **open an issue** to discuss the bug or feature ### Create a Pull Request 1. **Create a Branch** - Follow the branch naming convention: `/-` (`ISSUENUMBER` is optional) + Follow the branch naming convention: `/-` + - `USERNAME` is required (at least one hyphen must be present) ```bash - git checkout -b your-branch + git checkout -b feature/button-john ``` - | **Category** | **Description** | - |--------------|-----------------------------------------------------| - | **feat** | Developing a new feature | - | **fix** | Fixing a bug | - | **hotfix** | Emergency fixes for immediate release | - | **chore** | Maintenance tasks or minor updates | - | **refactor** | Code refactoring without functional changes | - | **release** | Preparing for a new release version | - | **test** | Writing or modifying test cases | - | **docs** | Documentation updates or additions | - | **ci** | CI/CD pipeline updates | - | **build** | Changes to the build system or dependencies | + | **Category** | **Description** | + |---------------|-----------------------------------------------------| + | **feature** | Developing a new feature | + | **fix** | Fixing a bug | + | **docs** | Documentation updates or additions | + | **style** | Code style changes (formatting, no logic change) | + | **refactor** | Code refactoring without functional changes | + | **test** | Writing or modifying test cases | + | **deploy** | Deployment-related changes | + | **chore** | Maintenance tasks or minor updates | + | **settings** | Configuration or settings changes | 2. **Commit Changes** Write meaningful commit messages using the [Conventional Commits](https://www.conventionalcommits.org/) format: ```bash git commit -m "(): " ``` - We recommend following the Conventional Commits standard for clear and consistent commit messages. Below is the suggested structure: - + We follow the [Conventional Commits](https://www.conventionalcommits.org/) standard with [`@commitlint/config-conventional`](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional): + | Element | Requirement | Description | |--------------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| - | `` | **Required** | Describes the purpose of the commit. Examples: `feat`, `fix`, `docs`, `style`, `refactor`, `test` | - | `` | **Optional** | Specifies the affected module, file, or functionality. Limited to **20 characters** (e.g., `auth`, `header`). | - | `` | **Required** | A concise summary of the changes
- Starts with a lowercase letter
- Avoid ending with a period (.)
- Limited to **50 characters**
- Must contain only English characters, numbers, and basic punctuation (!@#$%^&*(),.?":{}|<>_-)
- Cannot be empty | + | `` | **Required** | Describes the purpose of the commit: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test` | + | `` | **Optional** | Specifies the affected module, file, or functionality (e.g., `auth`, `header`). | + | `` | **Required** | A concise summary of the changes
- Starts with a lowercase letter
- Avoid ending with a period (.)
- Cannot be empty | diff --git a/.github/decisions/token-architecture.md b/.github/decisions/token-architecture.md new file mode 100644 index 00000000..e3c46a53 --- /dev/null +++ b/.github/decisions/token-architecture.md @@ -0,0 +1,331 @@ +# SIDE v2 토큰 아키텍처 + +## 1. 레퍼런스 디자인 시스템 비교 + +| 시스템 | 계층 수 | Primitive 키 | Semantic 키 예시 | 다크모드 전환 | 비고 | +|---|---|---|---|---|---| +| **Toss TDS** | 2 (Primitive → Semantic) | `blue-500` | `color-background-primary` | `data-tds-theme` attribute | Semantic만 CSS var 노출 | +| **Atlassian** | 3 (Base → Semantic → Component) | `color.blue.500` | `color.text.accent.blue` | `data-color-mode` attribute | Component 계층이 방대해 유지보수 부담 | +| **Adobe Spectrum** | 3 (Global → Alias → Component) | `--spectrum-blue-500` | `--spectrum-accent-color-default` | CSS selector 교체 | Alias = Semantic, 가장 세분화 | +| **shadcn/ui** ⚠️ | 2 (Scale → Semantic) | 직접 노출 안 함 | `--background`, `--primary` | `.dark` class | 컴포넌트 모음이지 디자인 시스템이 아님 — 토큰 구조 레퍼런스로만 참고 | +| **Radix Colors** ⚠️ | 1 (Primitive scale 제공) | `--blue-9` (12단계) | Semantic은 소비자가 정의 | 별도 dark scale 제공 | 색상 팔레트 도구 — 디자인 시스템 아님. 단계별 의미 고정(9=solid, 11=text)이 Primitive 설계에 참고할 만함 | +| **11번가** | 2 (Brand → Semantic) | `Gray_01`, `11STREET_Red` | `text-primary`, `icon-disabled` | 없음 (라이트 전용) | 브랜드 색상 중심; CSS var 기반 테마 없음 | +| **쏘카 SOCARFRAME 2.0** | 3 (Primitive → Semantic → Component) | `tw-blue-100` | `tw-text-primary-strong` | `data-theme` attribute | Tailwind 기반 — VE 미사용; Figma Code Connect 연동 | +| **G마켓 GDS** | 2 (Core Palette → Semantic) | `Green-500`, `Gray-900` | `text-primary`, `bg-white` | 없음 (라이트 전용) | 2021년 v1.0; WCAG 2.0 기반 색상 스케일 | +| **라인 LDSM** | 2+ (Primitive → Semantic 확인) | 미공개 (SPA 렌더링) | `line-semantic-colors` | 미확인 | Semantic 계층 존재 확인; 컴포넌트 라이브러리 규모 큼 | +| **카카오스타일 지그재그** ✅ | 2 (Primitive → Semantic) | 미공개 | — | `data-theme` attribute | **VE `createGlobalThemeContract` 사용** — 현 프로젝트와 구조 가장 유사 | + +- **Toss**: Component 계층은 컴포넌트 `.css.ts` 내부 변수로 처리해서 2계층으로 충분함 +- **Atlassian**: Component 계층을 토큰 패키지에 넣으면 컴포넌트 변경마다 토큰 패키지 릴리스가 강제되는 결합이 생김 +- **Radix Colors**: 단계별 의미를 고정한 방식(9=solid bg, 11=text)이 Semantic 색상 역할 분리에 참고할 만함 +- **shadcn/ui**: HSL 분리 대신 opacity 변형이 필요한 색상은 별도 토큰으로 명시하는 게 VE 방식에 더 맞음 +- **카카오스타일**: VE 기반 한국 디자인 시스템 중 가장 직접적인 레퍼런스 — [기술 블로그](https://devblog.kakaostyle.com/ko/2024-12-13-1-rebuilding-frontend-design-system/) 참고 + +--- + +## 2. 계층 구조 결정 + +``` +┌──────────────────────────────────────────────────┐ +│ Semantic Layer (목적·의미 기반 alias) │ +│ vars.color.text.primary │ +│ vars.color.bg.surface │ +│ CSS: --side-color-text-primary │ +└────────────────────┬─────────────────────────────┘ + │ alias only +┌────────────────────▼─────────────────────────────┐ +│ Primitive Layer (원시값 척도) │ +│ vars.color.blue[500] │ +│ vars.spacing[4] │ +│ CSS: --side-color-blue-500 │ +└──────────────────────────────────────────────────┘ +``` + +## 3. 계층별 허용 범위 원칙 + +### Primitive Layer + +- hex, px, rem, ms, 숫자 스케일 값 등 원시값만 허용 +- 다른 토큰 참조, 의미론적 이름 사용은 하지 않음 +- 라이트/다크 모드에 따라 값이 바뀌지 않는다 — 순수 팔레트 + +```json +// 올바른 예 +{ "color": { "blue": { "500": "#3b82f6" } } } + +// 금지 예 — Primitive에 Semantic 명명을 쓰는 것 +{ "color": { "primary": "#3b82f6" } } +``` + +### Semantic Layer + +- Primitive token에 대한 alias만 허용 +- hex, px 등 원시값 직접 사용 금지 +- 모드(light/dark)마다 **동일 키에 다른 Primitive를 할당**하는 방식으로 구현 + +```json +// 올바른 예 — Primitive alias +{ "color": { "text": { "primary": "{color.gray.950}" } } } + +// 금지 예 — Primitive 값 직접 기입 +{ "color": { "text": { "primary": "#131518" } } } +``` + +--- + +## 4. Vanilla Extract 파일 역할 정의 + +### 파일 목록 + +``` +packages/tokens/src/ +├── primitive/ +│ └── theme.css.ts # createGlobalTheme — 단일 구현, contract 불필요 +├── semantic/ +│ ├── contract.css.ts # createGlobalThemeContract (prefix 적용) — Semantic CSS var 계약 선언 +│ ├── _values.ts # light/dark 순수 값 객체 (CSS 생성 없음) +│ ├── light.css.ts # createGlobalTheme(':root', ...) — 라이트 모드 값 +│ └── dark.css.ts # createGlobalTheme + globalStyle @media — 다크 모드 값 +└── index.ts # vars (semantic)만 re-export +``` + +### `primitive/theme.css.ts` + +구현체가 하나뿐이므로 contract 없이 `createGlobalTheme`만으로 충분하다. 반환값 `primitiveVars`는 패키지 내부(`semantic/`)에서만 참조하며 외부로 노출하지 않는다. + +```ts +import { createGlobalTheme } from '@vanilla-extract/css'; + +// 단일 전역 주입 — 모드와 무관하게 항상 동일한 값 +// primitiveVars는 semantic 계층 내부 전용 — index.ts에서 export하지 않는다 +export const primitiveVars = createGlobalTheme(':root', { + color: { + blue: { '500': '#3b82f6', '600': '#2563eb' }, + gray: { '50': '#fafafa', '950': '#111111' }, + }, + spacing: { '1': '4px', '2': '8px' }, + // ... +}); +``` + +### `semantic/contract.css.ts` + +```ts +import { createGlobalThemeContract } from '@vanilla-extract/css'; + +export const vars = createGlobalThemeContract( + { + color: { + text: { primary: null, secondary: null, disabled: null, inverse: null, /* ... */ }, + bg: { base: null, surface: null, overlay: null, /* ... */ }, + border: { default: null, focused: null, error: null, /* ... */ }, + icon: { default: null, secondary: null, disabled: null }, + status: { success: null, warning: null, error: null, info: null }, + // 테마 브랜드 색상 + brand: { primary: null, secondary: null, gradient: null }, + }, + // Semantic spacing/radius는 Primitive를 그대로 alias + // 컴포넌트에서 vars.radius.md 처럼 쓰기 위해 재노출 + radius: { none: null, sm: null, md: null, lg: null, xl: null, full: null }, + }, + (_, path) => `side-${path.join('-')}`, +); +``` + +### `semantic/_values.ts` + +CSS를 생성하지 않는 순수 값 객체. `light.css.ts`와 `dark.css.ts` 두 파일이 동일한 값 객체를 각각 참조해야 하므로 별도 분리한다. + +```ts +// _values.ts +import { primitiveVars } from '../primitive/theme.css'; + +export const lightValues = { + color: { + text: { + primary: primitiveVars.color.gray['950'], + secondary: primitiveVars.color.gray['600'], + disabled: primitiveVars.color.gray['400'], + inverse: primitiveVars.color.gray['50'], + }, + bg: { + base: primitiveVars.color.gray['50'], + surface: primitiveVars.color.white, + }, + brand: { + primary: '#f4a1a0', // 예외: 브랜드 색은 테마별 고유값 + }, + // ... + }, + radius: primitiveVars.radius, + // ... +}; + +export const darkValues = { + color: { + text: { + primary: primitiveVars.color.gray['50'], + secondary: primitiveVars.color.gray['400'], + disabled: primitiveVars.color.gray['600'], + inverse: primitiveVars.color.gray['950'], + }, + bg: { + base: primitiveVars.color.gray['950'], + surface: primitiveVars.color.gray['900'], + }, + // ... + }, + // ... +}; +``` + +### `semantic/dark.css.ts` + +다크 모드가 기본값이므로 `:root`에 darkValues를 주입한다. + +```ts +import { createGlobalTheme } from '@vanilla-extract/css'; +import { vars } from './contract.css'; +import { darkValues } from './_values'; + +// 기본값: 다크 모드 +createGlobalTheme(':root', vars, darkValues); +``` + +### `semantic/light.css.ts` + +`createGlobalTheme`은 빌드타임 함수라 미디어 쿼리 안에 직접 넣을 수 없다. `data-mode="light"` 단독으로는 `prefers-color-scheme: light` 유저를 JS 토글 없이 커버할 수 없으므로 `globalStyle`로 시스템 라이트모드를 별도 처리한다. + +우선순위: `[data-mode="light"]` selector specificity > `@media` → `data-mode`가 명시된 경우 항상 우선된다. 단, 사용자가 시스템을 라이트로 설정했는데 `data-mode="dark"`가 명시된 경우 `@media`가 이겨버리는 충돌이 생기므로 이를 방어하는 `[data-mode="dark"]` override를 추가한다. + +```ts +import { assignVars, createGlobalTheme, globalStyle } from '@vanilla-extract/css'; +import { vars } from './contract.css'; +import { darkValues, lightValues } from './_values'; + +// ① JS 토글 방식 (data-mode attribute 기반) +createGlobalTheme('[data-mode="light"]', vars, lightValues); + +// ② 시스템 라이트모드 (JS 토글 없이도 동작) +globalStyle('body', { + '@media': { + '(prefers-color-scheme: light)': { + vars: assignVars(vars, lightValues), + }, + }, +}); + +// ③ data-mode="dark" 명시 시 @media를 덮어씀 (우선순위 방어) +globalStyle('[data-mode="dark"]', { + vars: assignVars(vars, darkValues), +}); +``` + +> **설계 원칙**: `light.css.ts` / `dark.css.ts`는 **codegen 대상**이다. `tokens/**/*.json`에서 자동 생성되며, 수동 편집하지 않는다. `contract.css.ts`는 타입 계약이므로 JSON 스키마 변경 시 함께 갱신된다. + +--- + +## 5. 다크 모드 확장성 + +### 파일 구조 + +``` +tokens/ +├── primitive/ +│ ├── color.json # 불변 팔레트 (모드 무관) +│ ├── spacing.json +│ ├── radius.json +│ └── typography.json +└── semantic/ + ├── light/ + │ └── color.json # 라이트 모드 alias 맵 + ├── dark/ + │ └── color.json # 다크 모드 alias 맵 + └── brand/ + ├── 1st.json # 각 SIPE 기수별 브랜드 색 + ├── 2nd.json + ├── 3rd.json + └── 4th.json +``` + +### Theme Switching 메커니즘 + +``` + + → vars가 semantic/light + brand/4th 조합으로 결정됨 + + + → vars가 semantic/dark + brand/4th 조합으로 결정됨 +``` + +CSS 우선순위: +1. `:root` — 기본 다크 모드 (4th 테마) +2. `@media (prefers-color-scheme: light)` — 시스템 라이트모드 (JS 토글 없이 동작) +3. `[data-mode="light"]` — 라이트 모드 명시 override (②보다 specificity 높음) +4. `[data-mode="dark"]` — 다크 모드 명시 override (②의 @media 충돌 방어) +5. `[data-theme="Nth"]` — 브랜드 색상 override (primary, secondary, gradient) + +### Tokens Studio Sets 구조 + +``` +Sets: + - primitive/color (global, always active) + - primitive/spacing (global, always active) + - primitive/radius (global, always active) + - primitive/typography (global, always active) + - semantic/light/color (theme set: mode=light) + - semantic/dark/color (theme set: mode=dark) + - brand/1st (theme set: theme=1st) + - brand/2nd (theme set: theme=2nd) + - brand/3rd (theme set: theme=3rd) + - brand/4th (theme set: theme=4th) +``` + +--- + +## 6. Vanilla Extract Contract 패턴 + +### createGlobalThemeContract의 역할 + +`createGlobalThemeContract`는 **타입의 근거**다: +- CSS 변수명을 매핑 함수 하나로 일관되게 결정한다. +- TypeScript 타입을 생성해 잘못된 토큰 참조를 컴파일 타임에 잡는다. +- `createGlobalTheme`에 값 주입 시 계약 키를 모두 채웠는지 타입으로 강제한다. + +```ts +// contract가 있으면 이 코드는 타입 에러 +createGlobalTheme(':root', vars, { + color: { text: { primary: '#000' } } + // TS Error: color.text.secondary 누락 +}); +``` + +### 컴포넌트에서의 사용 + +```ts +// packages/button/src/Button.css.ts +import { vars } from '@sipe-team/tokens'; // Semantic vars만 import + +export const buttonRecipe = recipe({ + base: { + backgroundColor: vars.color.bg.surface, + color: vars.color.text.primary, + borderRadius: vars.radius.md, + }, + variants: { + variant: { + primary: { + backgroundColor: vars.color.brand.primary, + }, + }, + }, +}); +``` + +컴포넌트는 **Semantic vars만** import한다. `primitiveVars`는 `semantic/` 내부에서만 참조하며, `index.ts`에서 export하지 않는다. 소비자가 `primitiveVars.color.blue['500']`을 컴포넌트에 직접 사용하기 시작하면 semantic 계층이 형식적으로 전락하므로 노출 자체를 차단한다. + +```ts +// index.ts +export { vars } from './semantic/contract.css'; // 소비자용 +// primitiveVars는 export하지 않는다 +``` diff --git a/.github/decisions/token-naming-convention.md b/.github/decisions/token-naming-convention.md new file mode 100644 index 00000000..765f6ddf --- /dev/null +++ b/.github/decisions/token-naming-convention.md @@ -0,0 +1,420 @@ +# SIDE v2 토큰 네이밍 컨벤션 + +> 이 문서의 규칙은 `tokens/**/*.json` → codegen → `contract.css.ts` / `themes.css.ts` 파이프라인 전반에 적용된다. + +--- + +## 1. 구분자 결정 + +세 가지 표현 형식에 서로 다른 구분자를 사용하며, 변환 규칙은 고정이다. + +| 형식 | 구분자 | 예시 | +|---|---|---| +| **JSON 키** | `.` (점, 중첩 객체) | `color.blue.500` | +| **VE vars 경로** | `.` (객체 접근) + `[]` (숫자 키) | `vars.color.blue[500]` | +| **CSS 변수명** | `-` (하이픈) | `--side-color-blue-500` | + +### 변환 규칙 + +``` +JSON key path: color.blue.500 + ↓ join('-') +CSS var name: --side-color-blue-500 + ↓ TS 객체 경로 +VE vars path: vars.color.blue['500'] +``` + +> **camelCase 금지**: CSS 변수명에는 하이픈만 사용한다. `--side-fontWeight-bold`가 아니라 `--side-font-weight-bold`. + +--- + +## 2. Primitive Layer 네이밍 + +### 2.1 색상 (color) + +``` +color.. + +예시: + color.gray.50 → --side-color-gray-50 + color.blue.500 → --side-color-blue-500 + color.red.950 → --side-color-red-950 + color.black → --side-color-black + color.white → --side-color-white +``` + +- ``: `gray | red | orange | yellow | green | teal | blue | cyan | purple | pink | black | white` +- ``: `50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950` +- `black`, `white`는 step 없이 단일 값 +- v1의 `gray50` 형태(camelCase + 숫자 붙이기)는 **폐기** — `.` 분리가 표준 + +### 2.2 간격 (spacing) + +``` +spacing. + +예시: + spacing.1 → --side-spacing-1 (= 4px) + spacing.2 → --side-spacing-2 (= 8px) + spacing.4 → --side-spacing-4 (= 16px) +``` + +- 값은 `multiplier × 4px` 규칙으로 계산 +- v1의 키(0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24)를 유지하되 JSON에서 문자열 키로 저장 (`"1"`, `"2"`) +- `spacing.0`은 `0px`, 토큰으로 정의하되 사용 빈도는 낮다 + +### 2.3 둥근 모서리 (radius) + +``` +radius. + +예시: + radius.none → --side-radius-none (= 0) + radius.sm → --side-radius-sm (= 2px) + radius.md → --side-radius-md (= 4px) + radius.lg → --side-radius-lg (= 8px) + radius.xl → --side-radius-xl (= 12px) + radius.full → --side-radius-full (= 9999px) +``` + +- 스케일: `none | sm | md | lg | xl | full` (v1 동일 유지) + +### 2.4 타이포그래피 (typography) + +``` +typography.fontSize. +typography.fontWeight. +typography.lineHeight. +typography.fontFamily. + +예시: + typography.fontSize.12 → --side-typography-font-size-12 + typography.fontWeight.semiBold → --side-typography-font-weight-semi-bold + typography.lineHeight.regular → --side-typography-line-height-regular +``` + +- `fontSize`: 숫자 값을 키로 사용 (`12 | 14 | 16 | 18 | 20 | 24 | 28 | 32 | 36 | 48`) +- `fontWeight`: 시맨틱 이름 (`regular | medium | semiBold | bold`) +- CSS 변수명 변환 시 camelCase → kebab-case (`semiBold` → `semi-bold`) + +### 2.5 그림자 (shadow) + +``` +shadow. + +예시: + shadow.none → --side-shadow-none + shadow.sm → --side-shadow-sm + shadow.md → --side-shadow-md + shadow.lg → --side-shadow-lg + shadow.xl → --side-shadow-xl + shadow.2xl → --side-shadow-2xl +``` + +### 2.6 Z축 (z) + +``` +z. + +예시: + z.hide → --side-z-hide (= -1) + z.base → --side-z-base (= 0) + z.dropdown → --side-z-dropdown (= 1000) + z.sticky → --side-z-sticky (= 1100) + z.modal → --side-z-modal (= 1400) + z.toast → --side-z-toast (= 1600) + z.tooltip → --side-z-tooltip (= 1700) +``` + +- v1 `zIndex` 키 이름을 `z`로 단축 (CSS var에서 `z-index`와 혼동 없음) + +### 2.7 애니메이션 (motion) — v2 신규 + +``` +motion.duration. +motion.easing. + +예시: + motion.duration.fast → --side-motion-duration-fast (= 100ms) + motion.duration.normal → --side-motion-duration-normal (= 200ms) + motion.duration.slow → --side-motion-duration-slow (= 300ms) + motion.duration.slower → --side-motion-duration-slower (= 500ms) + + motion.easing.default → --side-motion-easing-default (= ease-in-out) + motion.easing.decelerate → --side-motion-easing-decelerate (= cubic-bezier(0,0,0.2,1)) + motion.easing.accelerate → --side-motion-easing-accelerate (= cubic-bezier(0.4,0,1,1)) + motion.easing.spring → --side-motion-easing-spring (= cubic-bezier(0.4,0,0.2,1)) +``` + +> v1 감사에서 확인된 분산된 duration 값 (`0.15s, 0.2s, 0.3s, 1.2s, 1.5s, 2s`)을 `fast/normal/slow/slower` + 별도 animation 전용 키로 통합 + +### 2.8 불투명도 (opacity) + +``` +opacity. + +예시: + opacity.0 → --side-opacity-0 (= 0) + opacity.50 → --side-opacity-50 (= 0.5) + opacity.100 → --side-opacity-100 (= 1) +``` + +- step: `0 | 5 | 10 | 20 | 25 | 30 | 40 | 50 | 60 | 70 | 75 | 80 | 90 | 95 | 100` +- 퍼센트 단위로 표현 (0.5 → `50`) + +### 2.9 테두리 (border) + +``` +border.width. +border.style. + +예시: + border.width.none → --side-border-width-none (= 0) + border.width.thin → --side-border-width-thin (= 1px) + border.width.medium → --side-border-width-medium (= 2px) + border.width.thick → --side-border-width-thick (= 4px) +``` + +--- + +## 3. Semantic Layer 네이밍 + +### 3.1 색상 카테고리 (color) + +Semantic 색상은 **역할(role)** 기반으로 명명한다. Primitive에 있는 색상 이름을 Semantic에 쓰지 않는다. + +``` +color..[.] + +카테고리: + text — 텍스트 + bg — 배경 + border — 경계선 + icon — 아이콘 + status — 상태 (success/warning/error/info) + brand — 테마 브랜드 색 +``` + +**text** + +| 키 | 용도 | +|---|---| +| `color.text.primary` | 본문 기본 텍스트 | +| `color.text.secondary` | 보조 텍스트, 설명 | +| `color.text.tertiary` | 더 약한 힌트, placeholder | +| `color.text.disabled` | 비활성 텍스트 | +| `color.text.inverse` | 어두운 배경 위 밝은 텍스트 | +| `color.text.link` | 링크 텍스트 | +| `color.text.onAccent` | accent 배경 위 텍스트 | + +**bg** + +| 키 | 용도 | +|---|---| +| `color.bg.base` | 페이지 최상위 배경 | +| `color.bg.surface` | 카드·패널 배경 | +| `color.bg.overlay` | 모달·드로어 배경 | +| `color.bg.subtle` | 구분선 없이 영역 구분할 때 | +| `color.bg.accent` | 강조 배경 (brand primary) | +| `color.bg.disabled` | 비활성 입력 배경 | + +**border** + +| 키 | 용도 | +|---|---| +| `color.border.default` | 일반 경계선 | +| `color.border.subtle` | 미세 구분선 | +| `color.border.focused` | 포커스 링 | +| `color.border.error` | 에러 상태 경계선 | +| `color.border.disabled` | 비활성 경계선 | + +**icon** + +| 키 | 용도 | +|---|---| +| `color.icon.default` | 기본 아이콘 | +| `color.icon.secondary` | 보조 아이콘 | +| `color.icon.disabled` | 비활성 아이콘 | +| `color.icon.onAccent` | accent 배경 위 아이콘 | + +**status** + +| 키 | 용도 | +|---|---| +| `color.status.success` | 성공·완료 | +| `color.status.warning` | 경고 | +| `color.status.error` | 오류 (v1 `danger` → `error`로 변경) | +| `color.status.info` | 정보 (v1 `positive` → `info`로 변경) | +| `color.status.success.bg` | 성공 상태 배경 | +| `color.status.error.bg` | 오류 상태 배경 | + +> v1 `danger` → `error`, `positive` → `info` 로 명칭 변경. 더 범용적이고 산업 표준에 가깝다. + +**brand** + +| 키 | 용도 | +|---|---| +| `color.brand.primary` | 테마 주 색상 (SIPE 기수별 상이) | +| `color.brand.secondary` | 테마 보조 색상 | +| `color.brand.gradient` | 그라디언트 (CSS value 전체) | +| `color.brand.onPrimary` | primary 배경 위 텍스트 | + +### 3.2 상태 접미사 (state suffix) + +상태를 가지는 토큰은 마지막 세그먼트에 상태를 붙인다. 기본 상태(default)는 접미사를 생략한다. + +| 접미사 | 의미 | 예시 | +|---|---|---| +| _(없음)_ | 기본 상태 | `color.text.primary` | +| `.hover` | 마우스 오버 | `color.bg.accent.hover` | +| `.active` | 클릭/누름 | `color.bg.accent.active` | +| `.disabled` | 비활성 | `color.text.disabled` | +| `.focus` | 키보드 포커스 | `color.border.focused` (별도 카테고리로 분리) | +| `.selected` | 선택됨 | `color.bg.surface.selected` | + +> `focus`는 `color.border.focused`처럼 `border` 카테고리 안에서 표현하는 것을 우선한다. 단독 `color.bg.xxx.focus`는 복합 상태에서만 사용. + +--- + +## 4. 계열 접두사 규칙 요약 + +| 카테고리 | Primitive 접두사 | Semantic 접두사 | VE vars 경로 | +|---|---|---|---| +| 색상 | `color..` | `color..*` | `vars.color.*` | +| 간격 | `spacing.` | — (Primitive 직접 사용) | `vars.spacing[n]` | +| 둥근 모서리 | `radius.` | — (Primitive 직접 alias) | `vars.radius.*` | +| 그림자 | `shadow.` | `shadow.` | `vars.shadow.*` | +| Z축 | `z.` | — (Primitive 직접 사용) | `vars.z.*` | +| 타이포그래피 | `typography.*` | — (Primitive 직접 사용) | `vars.typography.*` | +| 애니메이션 | `motion.duration.*` / `motion.easing.*` | — | `vars.motion.*` | +| 불투명도 | `opacity.` | — | `vars.opacity[n]` | +| 테두리 두께 | `border.width.*` | — | `vars.border.width.*` | + +Semantic이 없는 카테고리(spacing, radius 등)는 컴포넌트에서 Primitive vars를 직접 참조한다. + +--- + +## 5. VE Contract 키 규칙 + +### 숫자 키 처리 + +TypeScript 객체 키는 숫자로 시작할 수 없다. 숫자 키는 **문자열 키**로 선언한다. + +```ts +// 올바른 예 +const primitiveVars = createGlobalThemeContract({ + color: { + gray: { + '50': null, // 문자열 키 + '500': null, + '950': null, + }, + }, + spacing: { + '1': null, + '4': null, + }, +}); + +// 접근 시 +vars.color.gray['500'] // bracket notation +vars.spacing['4'] +``` + +### camelCase → kebab-case 변환 + +Contract 매핑 함수가 자동으로 변환한다: + +```ts +createGlobalThemeContract( + { typography: { fontWeight: { semiBold: null } } }, + (_, path) => `side-${path.map(toKebab).join('-')}`, + // → --side-typography-font-weight-semi-bold +); + +function toKebab(str: string) { + return str.replace(/([A-Z])/g, '-$1').toLowerCase(); +} +``` + +--- + +## 6. JSON 파일 키 예시 + +### `tokens/primitive/color.json` + +```json +{ + "color": { + "black": { "value": "#131518", "type": "color" }, + "white": { "value": "#ffffff", "type": "color" }, + "gray": { + "50": { "value": "#fafafa", "type": "color" }, + "500": { "value": "#71717a", "type": "color" }, + "950": { "value": "#111111", "type": "color" } + }, + "blue": { + "500": { "value": "#3b82f6", "type": "color" } + } + } +} +``` + +### `tokens/semantic/light/color.json` + +```json +{ + "color": { + "text": { + "primary": { "value": "{color.gray.950}", "type": "color" }, + "secondary": { "value": "{color.gray.600}", "type": "color" }, + "disabled": { "value": "{color.gray.400}", "type": "color" }, + "inverse": { "value": "{color.white}", "type": "color" } + }, + "bg": { + "base": { "value": "{color.gray.50}", "type": "color" }, + "surface": { "value": "{color.white}", "type": "color" } + }, + "status": { + "success": { "value": "{color.green.500}", "type": "color" }, + "warning": { "value": "{color.orange.400}", "type": "color" }, + "error": { "value": "{color.red.500}", "type": "color" }, + "info": { "value": "{color.blue.400}", "type": "color" } + } + } +} +``` + +### `tokens/semantic/dark/color.json` + +```json +{ + "color": { + "text": { + "primary": { "value": "{color.gray.50}", "type": "color" }, + "secondary": { "value": "{color.gray.400}", "type": "color" }, + "disabled": { "value": "{color.gray.600}", "type": "color" }, + "inverse": { "value": "{color.gray.950}", "type": "color" } + }, + "bg": { + "base": { "value": "{color.gray.950}", "type": "color" }, + "surface": { "value": "{color.gray.900}", "type": "color" } + } + } +} +``` + +--- + +## 7. 금지 패턴 + +| 금지 패턴 | 이유 | 대안 | +|---|---|---| +| Semantic에 hex 직접 사용 | 다크모드 전환 불가 | Primitive alias `{color.gray.950}` | +| Primitive에 의미론 이름 | 역할이 고정되어 확장 어려움 | `color.primary` → `color.brand.primary` (Semantic에) | +| 컴포넌트에서 `primitiveVars` import | Primitive는 내부 구현 세부사항 | `vars` (Semantic) 만 import | +| CSS var에 camelCase | CSS 관례 위반 | `--side-font-weight-semi-bold` | +| `danger`, `positive` 이름 | 비표준, 혼동 유발 | `error`, `info` | +| `vars.spacing.xs` 같은 t-shirt size | 범위가 불명확, 확장 어려움 | `vars.spacing['2']` (4px 단위 scale) | + +> v1의 `vars.spacing.xs/sm/md/lg/xl` 패턴은 **v2에서 폐기**. 숫자 스케일이 컴포넌트 조합 시 훨씬 예측 가능하다. diff --git a/.github/decisions/v1-audit.md b/.github/decisions/v1-audit.md new file mode 100644 index 00000000..c0e2de3a --- /dev/null +++ b/.github/decisions/v1-audit.md @@ -0,0 +1,292 @@ +# v1 토큰 사용 현황 감사 + +> 작성일: 2026-04-17 +> 목적: v2 설계 의사결정을 위한 v1 토큰 정의·사용 현황 파악 + +--- + +## 1. 토큰 정의 목록 (`packages/tokens`) + +### 1.1 Color + +**Primitive (팔레트)** + +| 그룹 | 범위 | 예시 | +|------|------|------| +| black/white | 2개 | `#131518`, `#ffffff` | +| gray | 50–950 (11단계) | `#fafafa` ~ `#111111` | +| red | 50–950 (11단계) | `#fef2f2` ~ `#1f0808` | +| orange | 50–950 (11단계) | `#fff7ed` ~ `#220a04` | +| yellow | 50–950 (11단계) | `#fefce8` ~ `#281304` | +| green | 50–950 (11단계) | `#f0fdf4` ~ `#03190c` | +| teal | 50–950 (11단계) | `#f0fdfa` ~ `#021716` | +| blue | 50–950 (11단계) | `#eff6ff` ~ `#0c142e` | +| cyan | 50–950 (11단계) | `#ecfeff` ~ `#051b24` | +| purple | 50–950 (11단계) | `#faf5ff` ~ `#1a032e` | +| pink | 50–950 (11단계) | `#fdf2f8` ~ `#2c0514` | + +**Semantic** + +| 키 | 참조 | 실제 값 | +|---|---|---| +| success | green500 | `#22c55e` | +| warning | orange400 | `#fb923c` | +| danger | red500 | `#ef4444` | +| positive | blue400 | `#60a5fa` | + +**Theme (4개 테마)** + +| 테마 | primary | secondary | background | +|------|---------|-----------|------------| +| theme1st | `#01fe13` | `#01fe13` | `#000000` | +| theme2nd | `#03ff31` | `#06ffe3` | `#131518` | +| theme3rd | `#00ffff` | `#00ff99` | `#0d0d0d` | +| theme4th | `#f4a1a0` | `#f4a1a0` | `#0f1010` | + +--- + +### 1.2 Typography + +| 카테고리 | 값 | +|---------|---| +| fontSize | 12, 14, 16, 18, 20, 24, 28, 32, 36, 48 | +| fontWeight | regular(400), medium(500), semiBold(600), bold(700) | +| lineHeight | regular(1.5), compact(1.3) | + +--- + +### 1.3 Effects + +**Border Radius** + +| 키 | 값 | +|---|---| +| none | 0 | +| sm | 2px | +| md | 4px | +| lg | 8px | +| xl | 12px | +| full | 9999px | + +**Shadows** + +| 키 | 값 | +|---|---| +| none | none | +| sm | `0 1px 2px 0 rgba(0,0,0,0.05)` | +| md | `0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06)` | +| lg | `0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05)` | +| xl | `0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04)` | +| 2xl | `0 25px 50px -12px rgba(0,0,0,0.25)` | + +**Z-Index** + +| 키 | 값 | +|---|---| +| hide | -1 | +| base | 0 | +| dropdown | 1000 | +| sticky | 1100 | +| fixed | 1200 | +| overlay | 1300 | +| modal | 1400 | +| popover | 1500 | +| toast | 1600 | +| tooltip | 1700 | + +**Opacity**: 0, 0.05, 0.1, 0.2, 0.25, 0.3, 0.4, 0.5, 0.6, 0.7, 0.75, 0.8, 0.9, 0.95, 1 (15단계) + +**Border Width**: none(0), thin(1px), medium(2px), thick(4px) + +**Border Style**: solid, dashed, dotted + +--- + +### 1.4 Layout + +**Spacing** + +| 키 | 값 | +|---|---| +| 0 | 0px | +| 1 | 4px | +| 2 | 8px | +| 3 | 12px | +| 4 | 16px | +| 5 | 20px | +| 6 | 24px | +| 8 | 32px | +| 10 | 40px | +| 12 | 48px | +| 16 | 64px | +| 20 | 80px | +| 24 | 96px | + +**Breakpoints**: sm(0), md(780px), lg(1060px) + +**Grid**: 12컬럼, gutter sm(8)/md(16)/lg(24) + +--- + +### 1.5 Theme Contract Vars (`vars`) + +`packages/tokens/src/theme/contract.css.ts` 에서 export되는 CSS 변수 계약: + +- `vars.color`: primary, secondary, background, text, gradient +- `vars.spacing`: xs, sm, md, lg, xl +- `vars.typography.fontSize`: 050–900 +- `vars.typography.lineHeight`: regular, compact +- `vars.typography.fontWeight`: regular, medium, semiBold, bold +- `vars.typography.fontFamily` +- `vars.radius`: none, sm, md, lg, xl, full +- `vars.shadows`: none, sm, md, lg, xl, 2xl +- `vars.mode`, `vars.theme` + +--- + +## 2. 컴포넌트별 토큰 사용 매트릭스 + +| 패키지 | `@sipe-team/tokens` import | `vars` 사용 | 하드코딩 | +|-------|---------------------------|-------------|---------| +| button | ✅ `vars` | ✅ | ✗ | +| input | ⚠️ `color` (raw 팔레트) | ✗ | ✅ | +| card | ⚠️ `color` (raw 팔레트) | ✗ | ✅ | +| badge | ⚠️ `color`, `fontSize` (raw) | ✗ | ✅ | +| chip | ⚠️ `color` (raw 팔레트) | ✗ | ✅ | +| checkbox | ⚠️ `color` (raw 팔레트) | ✗ | ✅ | +| radio | ⚠️ `color` (raw 팔레트) | ✗ | ✅ | +| switch | ⚠️ `color` (raw 팔레트) | ✗ | ✅ | +| accordion | ⚠️ `color` (raw 팔레트) | ✗ | ✅ | +| avatar | ✗ | ✗ | ✅ | +| skeleton | ⚠️ `color`, `radius` (raw) | ✅ (radius만) | ✅ | +| tooltip | ✗ | ✗ | ✅ | +| divider | ⚠️ `color` (raw 팔레트) | ✗ | ✅ | +| flex | ✗ | ✗ | ✗ (레이아웃 유틸) | +| grid | ✗ | ✗ | ✗ (레이아웃 유틸) | +| typography | ✅ `fontSize`, `fontWeight`, `lineHeight` | ✅ | ✗ | + +> **요약**: `button`, `typography` 만 `vars`를 올바르게 사용. 대부분의 컴포넌트는 raw 팔레트 값(`color.grayXXX`)을 직접 참조하거나 hex/px를 하드코딩. + +--- + +## 3. 누락 토큰 유형 + +| 카테고리 | 상태 | 비고 | +|---------|------|------| +| Animation Duration | ❌ 미정의 | 0.15s, 0.2s, 0.3s, 1.5s, 2s 등 분산 사용 | +| Animation Easing | ❌ 미정의 | ease-in-out, cubic-bezier(0.4,0,0.2,1), ease 등 분산 사용 | +| Component Dimensions | ❌ 미정의 | 버튼 height, 아바타 size, 칩 height 등 컴포넌트별 직접 기입 | +| Spacing (px단위 직접) | ⚠️ 부분 정의 | `vars.spacing` xs–xl 정의되어 있으나 컴포넌트에서 미사용 | +| Box Shadow (커스텀) | ⚠️ 부분 정의 | shadows 토큰 존재하나 컴포넌트에서 미사용 | +| Z-Index | ⚠️ 부분 정의 | 토큰 정의되어 있으나 Tooltip에서 `zIndex: 1000` 직접 기입 | +| Border Width | ⚠️ 부분 정의 | 토큰 정의되어 있으나 `1px` 직접 기입 | +| Outline / Focus Ring | ❌ 미정의 | outline-offset(2px, 3px), outline-color 직접 기입 | + +--- + +## 4. 컴포넌트별 하드코딩 값 수집 + +### 4.1 Border-Radius + +| 값 | 사용 컴포넌트 | 대응 토큰 | +|---|---|---| +| `2px` | skeleton (radius.sm 사용) | `vars.radius.sm` ✅ | +| `4px` | checkbox, avatar, tooltip, switch | `vars.radius.md` (미사용) | +| `8px` | card, accordion, tooltip, input | `vars.radius.lg` (미사용) | +| `12px` | card, accordion | `vars.radius.xl` (미사용) | +| `50%` / `100px` / `100px` | radio, avatar, chip | `vars.radius.full` (미사용) | +| `9999px` | chip | `vars.radius.full` (미사용) | + +### 4.2 Transition / Animation + +| 값 | 사용 컴포넌트 | +|---|---| +| `0.15s ease-in-out` | checkbox, radio | +| `0.2s ease-in-out` | button, chip | +| `0.3s ease-in-out` | tooltip | +| `0.3s cubic-bezier(0.4, 0, 0.2, 1)` | accordion (height) | +| `0.3s ease` | accordion (transform), skeleton | +| `150ms ease-in-out` | switch | +| `1.2s ease-in-out infinite` | skeleton | +| `1.5s ease-in-out infinite` | skeleton | +| `2s ease-in-out infinite` | skeleton | +| `2s infinite` | skeleton | + +### 4.3 Spacing (padding / margin / gap) + +| 값 | 사용 컴포넌트 | +|---|---| +| `4px` | chip, badge | +| `6px` | checkbox (container padding), radio (container gap) | +| `8px` | input, tooltip, radio, switch, badge, chip, checkbox | +| `10px` | checkbox, radio (lg size) | +| `12px` | input, badge, accordion, chip | +| `16px` | input, accordion, badge, chip | +| `20px` | card, accordion, chip | +| `24px` | badge | +| `-1px` | switch (thumb 위치 계산) | + +### 4.4 색상 하드코딩 (토큰 미참조) + +| 값 | 사용 컴포넌트 | 비고 | +|---|---|---| +| `#3B82F6` | checkbox | checked 상태 배경 (blue500에 해당) | +| `#1a202c` | accordion | 배경색 | +| `#2d3748` | accordion, avatar | 배경색 / 텍스트색 | +| `#e2e8f0` | avatar | 배경색 | +| `#000000` | tooltip | CSS 변수 fallback | +| `#ffffff` | tooltip | 텍스트색 | +| `rgba(0, 0, 0, 0.2)` | tooltip | box-shadow | +| `#ccc`, `#f9f9f9`, `#e6e6e6` | tooltip | 경계·배경 | + +### 4.5 Component Dimensions (height / width) + +| 컴포넌트 | 값 | +|---------|---| +| button | height: `32px`(sm), `48px`(lg) | +| input | defaultActionSize: `24px` | +| avatar | size: 24, 32, 40, 70, 96px | +| chip | height: `24px`, `32px`, `40px` | +| checkbox | inputSize: `16px`, `20px`, `24px` | +| radio | inputSize: `12px`, `16px`, `20px`; `::after` 4/6/8px | +| switch | 계산값: width 32–48px, height 16–24px | +| tooltip | maxWidth: `250px` | + +### 4.6 Typography 하드코딩 + +| 값 | 사용 컴포넌트 | 비고 | +|---|---|---| +| fontSize: 12, 14, 16px | chip, input, switch, tooltip, avatar | `0.8rem` 포함 | +| fontWeight: 600 | badge, chip | semiBold 토큰 미사용 | +| lineHeight: `16px`, `20px`, `24px` | chip | compact/regular 토큰 미사용 | + +--- + +## 5. 요약 및 v2 설계 시사점 + +### 토큰 정의 완성도 + +| 카테고리 | 정의 | `vars` 사용 | 평가 | +|---------|------|-------------|------| +| Color (팔레트) | ✅ 150+ | ⚠️ 선택적 | raw 팔레트 직접 참조가 주류 | +| Color (semantic) | ✅ 4개 | ❌ | 미사용 | +| FontSize | ✅ 10개 | ⚠️ typography만 | 대부분 px 문자열 직접 기입 | +| FontWeight | ✅ 4개 | ⚠️ typography만 | badge, chip 하드코딩 | +| LineHeight | ✅ 2개 | ⚠️ 부분 | chip은 px 직접 기입 | +| BorderRadius | ✅ 6개 | ⚠️ skeleton만 | 7개 컴포넌트 하드코딩 | +| Shadows | ✅ 6개 | ❌ | 전혀 미사용 | +| ZIndex | ✅ 10개 | ❌ | tooltip 하드코딩 | +| Opacity | ✅ 15개 | ❌ | 전혀 미사용 | +| Spacing | ✅ 13개 | ❌ | `vars.spacing` 미사용 | +| BorderWidth | ✅ 4개 | ❌ | `1px` 하드코딩 | +| **Animation Duration** | ❌ | ❌ | v2 신규 필요 | +| **Animation Easing** | ❌ | ❌ | v2 신규 필요 | +| **Component Dimensions** | ❌ | ❌ | v2 신규 필요 | + +### 핵심 문제 + +1. **토큰이 있어도 안 쓴다** — radius, shadows, zIndex, spacing 모두 정의되어 있지만 대부분 컴포넌트에서 미사용 +2. **raw 팔레트 직접 참조** — `vars` 대신 `color.grayXXX`를 직접 import하는 패턴이 주류 (button, typography 제외) +3. **animation 토큰 전무** — duration/easing이 컴포넌트마다 제각각 (5가지 이상의 서로 다른 duration 혼재) +4. **치수(dimension) 토큰 전무** — 컴포넌트 height/width/size를 직접 기입하는 방식 +5. **완전 미연동 컴포넌트** — avatar, tooltip은 토큰 import 자체 없음 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d8d7ff66..355b0381 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,10 +16,10 @@ jobs: - name: Install dependencies run: pnpm install - name: Lint - run: pnpm --filter="...[origin/${{ github.base_ref }}]" lint + run: pnpm --filter="...[origin/${{ github.base_ref }}]" --if-present lint - name: Test - run: pnpm --filter="...[origin/${{ github.base_ref }}]" test + run: pnpm --filter="...[origin/${{ github.base_ref }}]" --if-present test - name: Type check - run: pnpm --filter="...[origin/${{ github.base_ref }}]" typecheck + run: pnpm --filter="...[origin/${{ github.base_ref }}]" --if-present typecheck - name: Build - run: pnpm --filter="...[origin/${{ github.base_ref }}]" build + run: pnpm --filter="...[origin/${{ github.base_ref }}]" --if-present build diff --git a/.github/workflows/consistency.yaml b/.github/workflows/consistency.yaml new file mode 100644 index 00000000..4ad10fbe --- /dev/null +++ b/.github/workflows/consistency.yaml @@ -0,0 +1,27 @@ +name: Package consistency + +on: + pull_request: + +jobs: + consistency: + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - uses: jdx/mise-action@v2 + + - name: Install dependencies + continue-on-error: true + run: pnpm install --frozen-lockfile + + - name: Run package consistency checker + continue-on-error: true + run: | + set +e + pnpm lint:package --format markdown >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/tokens.yaml b/.github/workflows/tokens.yaml new file mode 100644 index 00000000..793cd963 --- /dev/null +++ b/.github/workflows/tokens.yaml @@ -0,0 +1,151 @@ +name: Tokens + +on: + push: + branches: + - 'tokens/figma-sync' + paths: + - 'tokens/**' + pull_request: + paths: + - 'tokens/**' + - 'packages/tokens/config.js' + +concurrency: + group: tokens-${{ github.ref }} + cancel-in-progress: true + +jobs: + # PR · push 공통: 변환 → 빌드 → 타입체크 → diff + validate: + name: Build & validate + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - uses: jdx/mise-action@v2 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # PR 리뷰용: base 브랜치 빌드 결과를 먼저 저장해 diff 비교 + - name: Build base tokens + id: base-build + if: github.event_name == 'pull_request' + continue-on-error: true + run: | + git checkout origin/${{ github.base_ref }} -- tokens + pnpm --filter @sipe-team/tokens build:tokens + mkdir -p /tmp/base-css + cp packages/tokens/dist/css/*.css /tmp/base-css/ + + - name: Restore head tokens + if: github.event_name == 'pull_request' + run: git checkout ${{ github.event.pull_request.head.sha }} -- tokens + + - name: Build tokens + run: pnpm --filter @sipe-team/tokens build:tokens + + - name: Typecheck + run: pnpm --filter @sipe-team/tokens typecheck + + - name: Upload token artifacts + uses: actions/upload-artifact@v4 + with: + name: tokens-${{ github.event.pull_request.number || github.run_id }} + path: packages/tokens/dist/ + retention-days: 7 + + - name: CSS diff summary + if: github.event_name == 'pull_request' + run: | + echo "## Token build" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + for f in packages/tokens/dist/css/*.css; do + name=$(basename "$f") + echo "### \`$name\`" >> $GITHUB_STEP_SUMMARY + + if [ -f "/tmp/base-css/$name" ]; then + diff_output=$(diff -u "/tmp/base-css/$name" "$f" || true) + if [ -z "$diff_output" ]; then + echo "_no changes_" >> $GITHUB_STEP_SUMMARY + else + echo '```diff' >> $GITHUB_STEP_SUMMARY + echo "$diff_output" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + else + echo "_new file_" >> $GITHUB_STEP_SUMMARY + echo '```css' >> $GITHUB_STEP_SUMMARY + cat "$f" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + done + + - name: Commit transformed tokens + if: github.event_name == 'push' && github.ref == 'refs/heads/tokens/figma-sync' + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore(tokens): transform and build tokens [skip ci]" + file_pattern: 'tokens/** packages/tokens/dist/**' + + # tokens/figma-sync Push 전용: validate 통과 후 main으로 PR 자동 생성 + sync: + name: Open sync PR to main + if: github.event_name == 'push' + needs: validate + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@v4 + with: + ref: tokens/figma-sync + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Open or update sync PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + existing=$(gh pr list \ + --head tokens/figma-sync \ + --base main \ + --json number \ + --jq '.[0].number // empty') + + if [ -n "$existing" ]; then + echo "PR #$existing already open — skipping creation" + exit 0 + fi + + gh pr create \ + --base main \ + --head tokens/figma-sync \ + --title "chore(tokens): sync Figma tokens" \ + --body "$(cat <<'BODY' + ## Figma Token Sync + + Tokens Studio에서 자동으로 생성된 PR입니다. + + ### 파이프라인 + 1. Tokens Studio → `tokens/figma-sync` Push + 2. Style Dictionary + @tokens-studio/sd-transforms: 변환 및 CSS/TS 빌드 + 3. 이 PR → main 머지 + + ### Review checklist + - [ ] `validate` 잡 Step Summary에서 CSS diff 확인 + - [ ] 의도하지 않은 토큰 삭제 없음 + - [ ] 머지 후 Storybook에서 시각적 변경 확인 + BODY + )" diff --git a/.gitignore b/.gitignore index 90d53faa..f2dc14e7 100644 --- a/.gitignore +++ b/.gitignore @@ -181,4 +181,10 @@ storybook-static # Docusaurus build/ -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json + +# oh-my-claudecode local state +.omc/ + +# Style Dictionary intermediate artifacts +**/tokens-transformed.json \ No newline at end of file diff --git a/.husky/pre-push b/.husky/pre-push index f99a9212..296eb3f8 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,17 +1,32 @@ # Get the current branch name BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) -BRANCH_REGEX='^(feat|fix|hotfix|chore|refactor|release|test|docs|ci|build)\/[a-z0-9-]+$' + +# Special branches that skip naming rules +SPECIAL_REGEX='^(main|dev\/.+)$' + +# Standard branch naming: /- +# At least one hyphen required (to enforce username suffix) +BRANCH_REGEX='^(feature|fix|docs|style|refactor|test|deploy|chore|settings)\/[a-zA-Z0-9]+(-[a-zA-Z0-9]+)+$' + +# Allow special branches to bypass the naming convention +if [[ $BRANCH_NAME =~ $SPECIAL_REGEX ]]; then + exit 0 +fi # Check if the branch name matches the defined regex if ! [[ $BRANCH_NAME =~ $BRANCH_REGEX ]]; then echo "Error: Invalid branch name format." - echo + echo echo "Please rename your branch using:" - echo "git branch -m / or git branch -m /-" - echo - echo "CATEGORY: feat, fix, hotfix, chore, refactor, release, test, docs, ci, build" - echo "SUBJECT: Use only lowercase letters(a-z), numbers(0-9), and hyphens(-)" - echo "ISSUENUMBER: Use only numbers(0-9)" + echo "git branch -m /-" + echo + echo "CATEGORY: feature, fix, docs, style, refactor, test, deploy, chore, settings" + echo "SUBJECT: Letters(a-zA-Z), numbers(0-9), and hyphens(-)" + echo "USERNAME: Your name (e.g., john)" + echo + echo "Examples:" + echo " feature/button-john" + echo " fix/233-login-bug-john" echo exit 1 fi diff --git a/.mise.toml b/.mise.toml index 97f4f456..625d7472 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,3 +1,3 @@ [tools] node = "22.22.2" -pnpm = "9.7.1" +pnpm = "10.33.0" diff --git a/.templates/component/.storybook/main.ts b/.templates/component/.storybook/main.ts deleted file mode 100644 index e7f301d3..00000000 --- a/.templates/component/.storybook/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { StorybookConfig } from '@storybook/react-vite'; - -export default { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, -} satisfies StorybookConfig; diff --git a/.templates/component/.storybook/preview.ts b/.templates/component/.storybook/preview.ts deleted file mode 100644 index 82ec7ed0..00000000 --- a/.templates/component/.storybook/preview.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Preview } from '@storybook/react'; - -export default { - tags: ['autodocs'], -} satisfies Preview; diff --git a/.templates/component/package.json b/.templates/component/package.json index 5e2cb05f..9f029070 100644 --- a/.templates/component/package.json +++ b/.templates/component/package.json @@ -14,28 +14,19 @@ ], "scripts": { "build": "tsup", - "build:storybook": "storybook build", - "dev:storybook": "storybook dev -p 6006", "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", - "prepack": "pnpm run build" + "prepack": "pnpm run build", + "clean": "rm -rf node_modules dist" }, "devDependencies": { - "@storybook/addon-essentials": "catalog:", - "@storybook/addon-interactions": "catalog:", - "@storybook/addon-links": "catalog:", - "@storybook/blocks": "catalog:", - "@storybook/react": "catalog:", - "@storybook/react-vite": "catalog:", - "@storybook/test": "catalog:", "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/react": "catalog:react", "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", - "storybook": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" @@ -61,6 +52,8 @@ "./styles.css": "./dist/index.css" } }, - "sideEffects": false, + "sideEffects": [ + "**/*.css" + ], "private": true } diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 72f42207..322d7ab5 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["biomejs.biome", "github.vscode-github-actions", "hverlin.mise-vscode",] + "recommendations": ["biomejs.biome", "github.vscode-github-actions", "hverlin.mise-vscode"] } diff --git a/AGENTS.md b/AGENTS.md index 5a88ceaa..f20d2498 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,22 +1,68 @@ # 저장소 가이드라인 +> AI 코딩 에이전트(Claude Code, Codex, Jules, Cursor 등)가 이 저장소에서 작업할 때 참고하는 가이드입니다. +> +> **References:** [AGENTS.md 공식 스펙](https://agents.md/) · [AGENTS.md 한국어 설명](https://daleseo.com/agents-md/) + ## 프로젝트 구조 및 모듈 구성 `packages/*`에 배포 가능한 컴포넌트와 유틸리티가 위치합니다. 각 패키지는 `src/`에 구현체를, `tsup.config.ts`에 빌드 설정을, 컴포넌트 파일 옆에 테스트/스토리를 둡니다. `packages/tokens`는 공유 디자인 토큰을 정의하고, `packages/side`는 전체 export를 집계합니다. `www/`는 Docusaurus 문서 앱이며, `docs/`와 `www/docs/`에 MDX 콘텐츠가 있습니다. 공유 정적 자산은 `public/`과 `www/static/`에 둡니다. 새 컴포넌트는 폴더를 복사하지 말고 `.templates/component`와 `scripts/createComponent.ts`로 스캐폴딩하세요. 배포 패키지가 변경되면 `.changeset/`에 릴리스 노트를 추가하세요. ## 빌드, 테스트, 개발 명령어 -- `pnpm install`: Node `22` 환경에서 워크스페이스를 설치합니다. -- `pnpm create:component`: 템플릿에서 새 컴포넌트 패키지를 스캐폴딩합니다. -- `pnpm dev:storybook`: `http://localhost:6006`에서 Storybook을 실행합니다. -- `pnpm test`: `packages/*`에 대한 Vitest 워크스페이스를 실행합니다. -- `pnpm --filter ./packages/button build`: `tsup`으로 단일 패키지를 빌드합니다. 편집 중인 패키지 경로로 교체하세요. -- `pnpm --filter ./www dev`: 문서 사이트를 로컬에서 시작합니다. -- `pnpm format` / `pnpm lint`: Biome 포맷팅 및 린트 수정을 적용합니다. +```bash +mise install # Node v22.22.2 + pnpm 10.33.0 설치 (.mise.toml 기반) +pnpm install # 의존성 설치 (pnpm 10.33.0, Node v22.22.2) +pnpm dev:storybook # Storybook 개발 서버 실행 (:6006) +pnpm build:storybook # Storybook 빌드 +pnpm lint # Biome 린트 + 수정 (변경된 패키지) +pnpm format # Biome 포맷팅 +pnpm test # Vitest (변경된 패키지) +pnpm create:component # 템플릿에서 새 컴포넌트 스캐폴딩 +pnpm cz # 대화형 conventional commit + +# 패키지 단위 +pnpm --filter @sipe-team/button test +pnpm --filter @sipe-team/button build +pnpm --filter @sipe-team/button typecheck + +# 단일 테스트 파일 실행 +pnpm --filter @sipe-team/button vitest run src/Button.test.tsx + +# 문서 사이트 +pnpm --filter ./www dev +pnpm --filter ./www build +``` + +## 컴포넌트 패턴 + +모든 컴포넌트는 다음 구조를 따릅니다: + +1. **`Component.tsx`** — `forwardRef` 래퍼, `ComponentProps<'element'>` 확장, variant enum을 `const` 객체로 정의하고 매칭 타입 생성, Radix `Slot`을 통한 `asChild` 지원 +2. **`Component.css.ts`** — Vanilla Extract `recipe()`에 enum 값을 키로 하는 variant 맵, `@sipe-team/tokens`의 `vars` 사용 +3. **`Component.stories.tsx`** — `Meta`/`StoryObj` 타입을 사용하는 Storybook +4. **`Component.test.tsx`** — Vitest + `@testing-library/react` (happy-dom 환경) +5. **`index.ts`** — Re-exports + +Variant enum 패턴 (값 객체 + 타입 유니온): +```ts +export const ButtonSize = { sm: 'sm', lg: 'lg' } as const; +export type ButtonSize = (typeof ButtonSize)[keyof typeof ButtonSize]; +``` + +클래스 조합: `clsx(styles.recipe({ variant, size }), conditionalStyles, className)` + +## 스타일링 규칙 + +- 모든 스타일은 **vanilla-extract**의 `recipe()`를 사용하여 variant 처리 +- 디자인 토큰은 반드시 `import { vars } from '@sipe-team/tokens'`로 import — 색상, 간격, 타이포그래피 값을 하드코딩하지 말 것 +- 각 패키지는 소비자를 위해 `./styles.css`를 export ## 코딩 스타일 및 네이밍 컨벤션 -strict TypeScript를 사용하고, 각 패키지의 `src/index.ts`에서 공개 API를 명시하세요. Biome이 포맷팅 기준입니다: 들여쓰기는 스페이스, 싱글 쿼트, 줄 너비 `120`자. 기존 네이밍 패턴을 따르세요: 패키지 폴더는 kebab-case, React 컴포넌트는 PascalCase, 테스트는 `*.test.tsx`, 스토리는 `*.stories.tsx`, vanilla-extract 스타일은 `*.css.ts`. 색상, 간격, 라운딩 값은 하드코딩하지 말고 `packages/tokens` 값을 사용하세요. +strict TypeScript를 사용하고, 각 패키지의 `src/index.ts`에서 공개 API를 명시하세요. Biome이 포맷팅 기준입니다: 들여쓰기는 스페이스, 싱글 쿼트, 줄 너비 `120`자. 기존 네이밍 패턴을 따르세요: 패키지 폴더는 kebab-case, React 컴포넌트는 PascalCase, 테스트는 `*.test.tsx`, 스토리는 `*.stories.tsx`, vanilla-extract 스타일은 `*.css.ts`. + +import 순서 강제: `node → react → @sipe-team/* → @vanilla-extract/* → @radix-ui/* → 외부 패키지 → 상대 경로` (그룹 사이 빈 줄). pre-commit hook이 lint-staged를 통해 `biome check --write --unsafe` 실행. ## 테스트 가이드라인 @@ -24,4 +70,8 @@ strict TypeScript를 사용하고, 각 패키지의 `src/index.ts`에서 공개 ## 커밋 및 PR 가이드라인 -`feat(chip): add removable variant`와 같은 Conventional Commits를 사용하세요. scope는 짧게, subject는 영어로, `commitlint.config.ts`를 만족하도록 50자 이내로 작성하세요. PR은 `.github/PULL_REQUEST_TEMPLATE.md`를 따르세요: 변경 사항 요약, UI 변경 시 시각 자료 첨부, 스펙과 테스트 추가 여부 확인. 배포 패키지에 영향을 주는 변경이면 `.changeset` 항목을 포함하고 PR 설명에 릴리스 영향을 명시하세요. +[Conventional Commits](https://www.conventionalcommits.org/) 사용. 커밋 타입: `build | chore | ci | docs | feat | fix | perf | refactor | revert | style | test` ([`config-conventional`](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional) 기준). 브랜치: `/-` (하이픈 최소 1개). 카테고리: `feature | fix | docs | style | refactor | test | deploy | chore | settings`. 특수 브랜치 `main`, `dev/*`는 규칙 제외. PR은 `.github/PULL_REQUEST_TEMPLATE.md` 준수. 배포 패키지 변경 시 `.changeset` 항목 포함. + +## 배포 + +Changesets 기반으로 GitHub Package Registry에 릴리스. 패키지는 타입과 함께 ESM + CJS를 export. 공개 API 변경 시 `.changeset` 파일을 포함하세요. diff --git a/CLAUDE.md b/CLAUDE.md index b41e5bb6..43c994c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,80 +1 @@ -# CLAUDE.md - -이 파일은 Claude Code(claude.ai/code)가 이 저장소의 코드를 다룰 때 참고하는 가이드입니다. - -## 프로젝트 개요 - -**Side (Sipe Design System)** — `@sipe-team/*` 이름으로 GitHub Package Registry에 배포되는 React 컴포넌트 패키지들의 pnpm 모노레포입니다. - -## 명령어 - -```bash -mise install # Node v22.22.2 + pnpm 9.7.1 설치 (.mise.toml 기반) -pnpm install # 의존성 설치 (pnpm 9.7.1, Node v22.22.2) -pnpm dev:storybook # Storybook 개발 서버 실행 (:6006) -pnpm build:storybook # Storybook 빌드 -pnpm lint # Biome 린트 + 수정 (변경된 패키지) -pnpm format # Biome 포맷팅 -pnpm test # Vitest (변경된 패키지) -pnpm create:component # 템플릿에서 새 컴포넌트 스캐폴딩 -pnpm cz # 대화형 conventional commit - -# 패키지 단위 -pnpm --filter @sipe-team/button test -pnpm --filter @sipe-team/button build -pnpm --filter @sipe-team/button typecheck - -# 단일 테스트 파일 실행 -pnpm --filter @sipe-team/button vitest run src/Button.test.tsx -``` - -## 아키텍처 - -- **`packages/*`** — 개별 컴포넌트 패키지 (button, input, card, chip, skeleton 등) -- **`packages/tokens`** — 디자인 토큰 (색상, 간격, 타이포그래피, 라운딩, 그림자, z-index)을 vanilla-extract contract vars로 export -- **`packages/theme`** — vanilla-extract `assignInlineVars`를 사용한 ThemeProvider, 런타임 테마 전환 (4개 테마, 라이트 모드 기본) -- **`packages/typography`** — Typography 컴포넌트 시스템 -- **`www/`** — Docusaurus 문서 사이트 - -## 컴포넌트 패턴 - -모든 컴포넌트는 다음 구조를 따릅니다: - -1. **`Component.tsx`** — `forwardRef` 래퍼, `ComponentProps<'element'>` 확장, variant enum을 `const` 객체로 정의하고 매칭 타입 생성, Radix `Slot`을 통한 `asChild` 지원 -2. **`Component.css.ts`** — Vanilla Extract `recipe()`에 enum 값을 키로 하는 variant 맵, `@sipe-team/tokens`의 `vars` 사용 -3. **`Component.stories.tsx`** — `Meta`/`StoryObj` 타입을 사용하는 Storybook -4. **`Component.test.tsx`** — Vitest + `@testing-library/react` (happy-dom 환경) -5. **`index.ts`** — Re-exports - -Variant enum 패턴 (값 객체 + 타입 유니온): -```ts -export const ButtonSize = { sm: 'sm', lg: 'lg' } as const; -export type ButtonSize = (typeof ButtonSize)[keyof typeof ButtonSize]; -``` - -클래스 조합: `clsx(styles.recipe({ variant, size }), conditionalStyles, className)` - -## 스타일링 규칙 - -- 모든 스타일은 **vanilla-extract**의 `recipe()`를 사용하여 variant 처리 -- 디자인 토큰은 반드시 `import { vars } from '@sipe-team/tokens'`로 import — 색상, 간격, 타이포그래피 값을 하드코딩하지 말 것 -- 각 패키지는 소비자를 위해 `./styles.css`를 export - -## 린팅 및 포맷팅 - -- **Biome** (ESLint/Prettier가 아님) — 싱글 쿼트, 스페이스, 줄 너비 120자 -- import 순서 강제: node → react → @sipe-team/* → @vanilla-extract/* → @radix-ui/* → 외부 패키지 → 상대 경로 (그룹 사이 빈 줄) -- pre-commit hook이 lint-staged를 통해 `biome check --write --unsafe` 실행 - -## 커밋 컨벤션 - -형식: `type(scope): subject` — 영어만, subject 최대 50자, scope 최대 20자. -타입: feat, fix, hotfix, chore, refactor, release, test, docs, ci, build. - -## 브랜치 네이밍 - -`/-` (이슈 번호는 선택) - -## 배포 - -Changesets 기반으로 GitHub Package Registry에 릴리스. 패키지는 타입과 함께 ESM + CJS를 export. 공개 API 변경 시 `.changeset` 파일을 포함하세요. +@AGENTS.md diff --git a/LICENSE b/LICENSE index 726334b1..fd2defc6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 SIPE +Copyright (c) 2024-2026 SIPE Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index da54a0d8..ef29f818 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ![](./public/og-image.png) # Sipe Design System -[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/sipe-team/side/blob/main/LICENSE) ![Tool Manager](https://img.shields.io/badge/mise-latest-purple?logo=mise) ![Package Manager](https://img.shields.io/badge/pnpm-9.7.1-orange?logo=pnpm) [![Storybook](https://img.shields.io/badge/Storybook-8.4.7-ff4785?logo=storybook)](https://storybook.sipe.team/?path=/docs/what-is-side--docs) ![Tests](https://img.shields.io/badge/Vitest-2.1.4-green?logo=vitest) [![codecov](https://codecov.io/gh/sipe-team/side/branch/changeset-release%2Fmain/graph/badge.svg?token=1TNLVUFPXC)](https://codecov.io/gh/sipe-team/side) Github Stars +[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/sipe-team/side/blob/main/LICENSE) ![Tool Manager](https://img.shields.io/badge/mise-latest-purple?logo=mise) ![Package Manager](https://img.shields.io/badge/pnpm-10.33.0-orange?logo=pnpm) [![Storybook](https://img.shields.io/badge/Storybook-8.4.7-ff4785?logo=storybook)](https://storybook.sipe.team/?path=/docs/what-is-side--docs) ![Tests](https://img.shields.io/badge/Vitest-2.1.8-green?logo=vitest) [![codecov](https://codecov.io/gh/sipe-team/side/branch/changeset-release%2Fmain/graph/badge.svg?token=1TNLVUFPXC)](https://codecov.io/gh/sipe-team/side) Github Stars Sipe Design System is a monorepo-based component library built to modernize and standardize the official Sipe website. Drawing inspiration from our existing design patterns, we're creating a robust, type-safe, and accessible component system that can be used across all Sipe projects. @@ -72,6 +72,6 @@ We warmly welcome contributions from the community, whether you're a Sipe team m ## License -Copyright (c) 2026 SIPE, Inc. See [LICENSE](./LICENSE) for details. +Copyright (c) 2024-2026 SIPE, Inc. See [LICENSE](./LICENSE) for details. diff --git a/commitlint.config.ts b/commitlint.config.ts index 4f0cec6d..79065713 100644 --- a/commitlint.config.ts +++ b/commitlint.config.ts @@ -1,40 +1,8 @@ -const englishOnly = /^[A-Za-z0-9\s!@#$%^&*(),.?":{}|<>_-]+$/; - -// https://commitlint.js.org/reference/configuration.html#typescript-configuration -import { RuleConfigSeverity, type UserConfig } from '@commitlint/types'; - -const Configuration: UserConfig = { +// https://www.conventionalcommits.org +// https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional +// +// @commitlint/config-conventional 허용 타입: +// build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test +export default { extends: ['@commitlint/config-conventional'], - // parserPreset: '', - // formatter: '', - // https://commitlint.js.org/reference/plugins.html#working-with-plugins - plugins: [ - { - rules: { - 'subject-english-only': ({ subject }) => { - if (!subject) return [true, '']; - const valid = englishOnly.test(subject); - return [valid, 'Commit subject must contain only English characters']; - }, - }, - }, - ], - rules: { - // https://commitlint.js.org/reference/rules.html - 'type-empty': [RuleConfigSeverity.Error, 'never'], - 'subject-empty': [RuleConfigSeverity.Error, 'never'], - 'subject-max-length': [RuleConfigSeverity.Error, 'always', 50], - 'scope-max-length': [RuleConfigSeverity.Error, 'always', 20], - - // custom rules - 'subject-english-only': [RuleConfigSeverity.Error, 'always'], - }, - - prompt: { - settings: {}, - messages: {}, - questions: {}, - }, }; - -export default Configuration; diff --git a/docs/tokens/semantic-proposal.md b/docs/tokens/semantic-proposal.md new file mode 100644 index 00000000..62cd6ff5 --- /dev/null +++ b/docs/tokens/semantic-proposal.md @@ -0,0 +1,210 @@ +# Semantic Token + +> 상태: 디자이너 검토 요청 중 +> 작성일: 2026-04-26 +> 기준 모드: **Dark (기본값)**. Light 모드 토큰은 추후 별도 작업 예정 (`tokens/semantic/light/` 현재 비어 있음). + +--- + +## 브랜드 컬러 변경 대응 전략 (리브랜딩 플로우) + +**배경:** 반기별로 기수가 바뀔 때마다 브랜드 컬러가 변경됨. + +**원칙:** 컴포넌트 코드나 Semantic 토큰 명칭을 수정하는 대신, Semantic 토큰이 참조하는 Primitive 값(alias)만 업데이트하여 일괄 변경. + +**관리 방식:** `tokens/` 하위 JSON 파일이 단일 소스(source of truth). Figma Token Studio → Style Dictionary → 코드 자동 생성 파이프라인 구축 예정. + +### 토큰 참조 흐름 + +``` +Primitive Semantic 컴포넌트 +───────────────────────────────── ─────────────────────── ───────────────────── +tokens/primitive/color.json tokens/semantic/dark/ + color.json +color.brand.default #ffb24d ──▶ color.accent.default ──▶ button background +color.brand.hover #d9963f ──▶ color.accent.hover ──▶ button:hover background +color.brand.subtle #3b2005 ──▶ color.accent.subtle ──▶ tag/chip background +``` + +### 기수 교체 시 수정 파일 + +| 파일 | 수정 내용 | +|------|----------| +| `tokens/primitive/color.json` | `color.brand.*` 값 3개 교체 | + +> **이 파일 하나만 수정하면 됨.** Semantic 토큰은 alias 참조이므로 자동 반영. 컴포넌트 코드 수정 불필요. + +> **파이프라인 구축 전 과도기 주의:** 현재 `.ts` 구현체(`themes.css.ts`)가 병행 존재하며, 브랜드 컬러가 hex로 직접 기입돼 있음. 파이프라인 구축 후 해당 파일은 제거 예정이므로 별도 관리 불필요. + +### JSON 포맷 (W3C Design Token 표준) + +현재 모든 토큰 파일은 W3C Design Token 형식(`$value`, `$type`, `$description`)을 사용하며, Token Studio v2 및 Style Dictionary v4와 호환됨. + +```json +// tokens/primitive/color.json +"brand": { + "default": { "$value": "#ffb24d", "$type": "color", "$description": "브랜드 기본 강조색 (기수별 교체)" }, + "hover": { "$value": "#d9963f", "$type": "color", "$description": "브랜드 hover 상태색 (기수별 교체)" }, + "subtle": { "$value": "#3b2005", "$type": "color", "$description": "브랜드 배경용 어두운 색 (기수별 교체)" } +} +``` + +### 기수별 아카이브 테마 + +과거 기수 전용 페이지 테마(1st~4th)는 브랜드 컬러와 별개로 영구 보존. 파이프라인 구축 후 `tokens/theme/` 하위로 이관 예정. + +--- + +## 목차 + +1. [Color](#1-color) +2. [Spacing](#2-spacing) +3. [Radius](#3-radius) +4. [Typography](#4-typography) + +--- + +## 1. Color + +### 1-1. Background + +| 토큰 | Primitive 참조 | 실제 값 | 의도 | 사용 시나리오 | +|------|---------------|---------|------|--------------| +| `color.background.base` | `{color.gray.950}` | `#111111` | 가장 어두운 기본 페이지 배경 | 1. 페이지 전체 기본 배경 (``) 2. 모달·시트 내부 배경 | +| `color.background.subtle` | `{color.gray.900}` | `#18181b` | base보다 약간 밝은 표면. 계층 구분 | 1. 카드·섹션 표면 배경 2. 사이드바·패널 배경 | +| `color.background.muted` | `{color.gray.800}` | `#27272a` | 비활성 또는 흐릿한 배경 | 1. 비활성(disabled) 입력 필드 배경 2. 스켈레톤 로딩 배경 | + +### 1-2. Foreground (텍스트·아이콘) + +| 토큰 | Primitive 참조 | 실제 값 | 의도 | 사용 시나리오 | +|------|---------------|---------|------|--------------| +| `color.foreground.default` | `{color.white}` | `#ffffff` | 어두운 배경 위에서 최고 대비를 가지는 기본 전경 | 1. 본문 텍스트 2. 헤딩·레이블 기본 색상 | +| `color.foreground.subtle` | `{color.gray.400}` | `#a1a1aa` | 보조 정보임을 시각적으로 구분 | 1. 보조 설명 텍스트 2. 인풋 힌트·캡션 | +| `color.foreground.muted` | `{color.gray.500}` | `#71717a` | 최소한의 대비로 존재감을 낮춤 | 1. 인풋 placeholder 텍스트 2. 비활성 아이콘 | +| `color.foreground.onAccent` | `{color.white}` | `#ffffff` | 강조색 배경 위에서 가독성 확보 | 1. 강조색 배경 위 버튼 레이블 2. 배지·칩 내부 텍스트 | + +### 1-3. Border + +| 토큰 | Primitive 참조 | 실제 값 | 의도 | 사용 시나리오 | +|------|---------------|---------|------|--------------| +| `color.border.default` | `{color.gray.700}` | `#3f3f46` | 기본 구분선. 어두운 배경에서 시각적으로 튀지 않는 수준 | 1. 인풋·셀렉트 기본 테두리 2. 카드·구분선 보더 | +| `color.border.strong` | `{color.gray.500}` | `#71717a` | 상호작용 또는 강조가 필요한 테두리 | 1. 호버 상태 인풋 테두리 강조 2. 구분 구역 강조 선 | +| `color.border.focus` | `{color.blue.400}` | `#60a5fa` | 포커스 상태임을 명확히 표시 (접근성) | 1. 키보드 포커스 링 색상 2. 활성 탭 언더라인 | + +### 1-4. Accent (인터랙티브 강조) + +> `color.brand.*` primitive는 기수가 바뀔 때 이 3개 값만 교체하면 전체 accent 색상이 일괄 변경됩니다. + +| 토큰 | Primitive 참조 | 실제 값 | 의도 | 사용 시나리오 | +|------|---------------|---------|------|--------------| +| `color.accent.default` | `{color.brand.default}` | `#ffb24d` | 주요 액션·브랜드 포인트 색상 (`--primary`) | 1. Primary CTA 버튼 배경 2. 인터랙티브 링크 텍스트 | +| `color.accent.hover` | `{color.brand.hover}` | `#d9963f` | 상호작용 피드백: hover·press 상태 (`--primary-hover`) | 1. Primary 버튼 호버·프레스 배경 2. 링크 호버 색상 | +| `color.accent.subtle` | `{color.brand.subtle}` | `#3b2005` | 강조색의 어두운 배경. 과도한 강조 없이 어필 | 1. 선택된 칩·태그 배경 2. 인포 배너 배경 | + +### 1-5. Status + +#### Success (성공·완료) + +| 토큰 | Primitive 참조 | 실제 값 | 의도 | 사용 시나리오 | +|------|---------------|---------|------|--------------| +| `color.status.success.foreground` | `{color.green.400}` | `#4ade80` | 어두운 배경에서 가독성 있는 성공 색상 | 1. 성공 상태 텍스트·아이콘 2. 완료 배지 레이블 | +| `color.status.success.background` | `{color.green.900}` | `#042713` | 성공 영역을 어둡고 은은하게 표현 | 1. 성공 토스트·배너 배경 2. 완료 상태 인풋 배경 | +| `color.status.success.border` | `{color.green.700}` | `#116932` | 성공 상태 컨테이너 테두리 | 1. 성공 인풋 테두리 2. 성공 알림 카드 보더 | + +#### Warning (경고·주의) + +| 토큰 | Primitive 참조 | 실제 값 | 의도 | 사용 시나리오 | +|------|---------------|---------|------|--------------| +| `color.status.warning.foreground` | `{color.orange.400}` | `#fb923c` | 어두운 배경에서 가독성 있는 경고 색상 | 1. 경고 텍스트·아이콘 2. 주의 배지 레이블 | +| `color.status.warning.background` | `{color.orange.900}` | `#3b1106` | 경고 영역을 어둡고 은은하게 표현 | 1. 경고 배너·토스트 배경 2. 주의 섹션 하이라이트 | +| `color.status.warning.border` | `{color.orange.700}` | `#92310a` | 경고 상태 컨테이너 테두리 | 1. 경고 인풋 테두리 2. 주의 알림 카드 보더 | + +#### Danger (오류·위험) + +| 토큰 | Primitive 참조 | 실제 값 | 의도 | 사용 시나리오 | +|------|---------------|---------|------|--------------| +| `color.status.danger.foreground` | `{color.red.400}` | `#f87171` | 어두운 배경에서 가독성 있는 오류 색상 | 1. 오류 메시지 텍스트·아이콘 2. 삭제·위험 액션 레이블 | +| `color.status.danger.background` | `{color.red.900}` | `#300c0c` | 오류 영역을 어둡고 은은하게 표현 | 1. 에러 토스트·배너 배경 2. 유효성 오류 인풋 배경 | +| `color.status.danger.border` | `{color.red.700}` | `#991919` | 오류 상태 컨테이너 테두리 | 1. 에러 인풋 테두리 2. 위험 액션 확인 다이얼로그 보더 | + +#### Info (정보·안내) + +| 토큰 | Primitive 참조 | 실제 값 | 의도 | 사용 시나리오 | +|------|---------------|---------|------|--------------| +| `color.status.info.foreground` | `{color.blue.400}` | `#60a5fa` | 어두운 배경에서 가독성 있는 정보 색상 | 1. 정보 텍스트·아이콘 2. 도움말 툴팁 레이블 | +| `color.status.info.background` | `{color.blue.900}` | `#14204a` | 정보 영역을 어둡고 은은하게 표현 | 1. 정보 배너 배경 2. 안내 섹션 배경 | +| `color.status.info.border` | `{color.blue.700}` | `#173da6` | 정보 상태 컨테이너 테두리 | 1. 정보 알림 카드 보더 2. 도움말 박스 테두리 | + +--- + +## 2. Spacing + +### 2-1. Component (컴포넌트 내부 간격) + +| 토큰 | Primitive 참조 | 실제 값 | 의도 | 사용 시나리오 | +|------|---------------|---------|------|--------------| +| `spacing.component.xs` | `{spacing.4}` | `4px` | 아주 촘촘한 내부 간격 | 1. 아이콘-텍스트 사이 간격 2. 태그·배지 내부 상하 패딩 | +| `spacing.component.sm` | `{spacing.8}` | `8px` | 작은 컴포넌트 내부 패딩 | 1. 버튼 상하 패딩 2. 인풋 상하 패딩 | +| `spacing.component.md` | `{spacing.12}` | `12px` | 중간 컴포넌트 내부 패딩 | 1. 인풋 좌우 패딩 2. 셀렉트 내부 패딩 | +| `spacing.component.lg` | `{spacing.16}` | `16px` | 넓은 내부 여백이 필요한 컴포넌트 | 1. 카드 내부 패딩 2. 드롭다운 메뉴 항목 패딩 | +| `spacing.component.xl` | `{spacing.24}` | `24px` | 오버레이 계열 컴포넌트의 여백 | 1. 모달 내부 패딩 2. 시트 상단 여백 | + +### 2-2. Layout (레이아웃 간격) + +| 토큰 | Primitive 참조 | 실제 값 | 의도 | 사용 시나리오 | +|------|---------------|---------|------|--------------| +| `spacing.layout.sm` | `{spacing.32}` | `32px` | 소규모 레이아웃 단위 간격 | 1. 섹션 간 수직 간격 2. 폼 그룹 사이 간격 | +| `spacing.layout.md` | `{spacing.40}` | `40px` | 중간 레이아웃 여백 | 1. 페이지 좌우 여백(모바일) 2. 카드 그리드 간격 | +| `spacing.layout.lg` | `{spacing.48}` | `48px` | 넓은 레이아웃 여백 | 1. 페이지 좌우 여백(태블릿) 2. 대형 섹션 내부 패딩 | +| `spacing.layout.xl` | `{spacing.64}` | `64px` | 최대 레이아웃 여백 | 1. 페이지 콘텐츠 최대 좌우 여백(데스크탑) 2. 히어로 섹션 상하 패딩 | + +--- + +## 3. Radius + +| 토큰 | Primitive 참조 | 실제 값 | 의도 | 사용 시나리오 | +|------|---------------|---------|------|--------------| +| `radius.component.sm` | `{radius.2}` | `2px` | 아주 미묘한 모서리 처리 | 1. 태그·배지 모서리 2. 툴팁 모서리 | +| `radius.component.md` | `{radius.4}` | `4px` | 일반 인터랙티브 요소의 기본 모서리 | 1. 버튼 모서리 2. 인풋·셀렉트 모서리 | +| `radius.component.lg` | `{radius.8}` | `8px` | 컨테이너 계열의 부드러운 모서리 | 1. 카드 모서리 2. 드롭다운·팝오버 모서리 | +| `radius.component.xl` | `{radius.12}` | `12px` | 오버레이 계열의 큰 모서리 | 1. 다이얼로그·모달 모서리 2. 바텀 시트 상단 모서리 | +| `radius.component.full` | `{radius.full}` | `9999px` | 완전한 원형·필(Pill) 처리 | 1. 아바타·프로필 이미지 원형 처리 2. 필(Pill) 스타일 버튼·칩 | +| `radius.layout.sm` | `{radius.4}` | `4px` | 페이지 레벨 인라인 박스의 모서리 | 1. 콜아웃·배너 모서리 2. 레이아웃 그리드 셀 모서리 | +| `radius.layout.md` | `{radius.8}` | `8px` | 섹션·패널 수준의 모서리 | 1. 섹션 컨테이너·페이지 카드 모서리 2. 사이드바 패널 모서리 | +| `radius.layout.lg` | `{radius.12}` | `12px` | 주요 콘텐츠 영역의 큰 모서리 | 1. 히어로·피처 섹션 배경 모서리 2. 주요 콘텐츠 영역 래퍼 모서리 | + +--- + +## 4. Typography + +### 4-1. Font Family / Line Height + +| 토큰 | Primitive 참조 | 의도 | 사용 시나리오 | +|------|---------------|------|--------------| +| `typography.fontFamily.base` | `{typography.fontFamily.base}` | 전체 UI의 단일 폰트 패밀리 | 1. 모든 UI 텍스트 기본 폰트 2. 코드 블록 외 전체 텍스트 | +| `typography.lineHeight.default` | `{typography.lineHeight.regular}` | 본문 읽기에 최적화된 행간 (1.5) | 1. 본문 단락 행간 2. 멀티라인 인풋 텍스트 행간 | +| `typography.lineHeight.tight` | `{typography.lineHeight.compact}` | 제목·UI 요소에 적합한 좁은 행간 (1.3) | 1. 헤딩·제목 행간 2. 버튼·레이블 단행 텍스트 행간 | + +### 4-2. 스케일 + +| 토큰 | fontSize | fontWeight | 의도 | 사용 시나리오 | +|------|----------|-----------|------|--------------| +| `typography.display` | `{typography.fontSize.48}` | `{typography.fontWeight.bold}` | 가장 크고 임팩트 있는 텍스트 | 1. 랜딩 히어로 헤딩 2. 최상위 마케팅 카피 | +| `typography.heading.lg` | `{typography.fontSize.32}` | `{typography.fontWeight.bold}` | 페이지 최상위 제목 | 1. 페이지 제목(h1) 2. 다이얼로그 메인 제목 | +| `typography.heading.md` | `{typography.fontSize.24}` | `{typography.fontWeight.semiBold}` | 섹션 단위 제목 | 1. 섹션 제목(h2) 2. 카드 헤딩 | +| `typography.heading.sm` | `{typography.fontSize.20}` | `{typography.fontWeight.semiBold}` | 서브섹션 제목 | 1. 서브섹션 제목(h3) 2. 리스트 그룹 레이블 | +| `typography.body.lg` | `{typography.fontSize.18}` | `{typography.fontWeight.regular}` | 읽기 편한 큰 본문 | 1. 긴 형식 본문 텍스트 2. 소개 단락 | +| `typography.body.md` | `{typography.fontSize.16}` | `{typography.fontWeight.regular}` | 일반 UI 본문의 기준 크기 | 1. 일반 UI 본문 텍스트 2. 폼 인풋 입력값 | +| `typography.body.sm` | `{typography.fontSize.14}` | `{typography.fontWeight.regular}` | 보조 텍스트용 작은 본문 | 1. 보조 설명 텍스트 2. 테이블 셀 텍스트 | +| `typography.label` | `{typography.fontSize.14}` | `{typography.fontWeight.medium}` | 폼·UI 레이블 (본문보다 약간 굵게) | 1. 폼 필드 레이블 2. 버튼 텍스트 | +| `typography.caption` | `{typography.fontSize.12}` | `{typography.fontWeight.regular}` | 부수적 정보의 최소 크기 | 1. 이미지 캡션 2. 에러·힌트 메시지 | + +--- + +## 검토 요청 사항 + +- [ ] `color.accent.*` — orange 계열로 반영됐습니다. 실제 UI에서 강조색 계층(default / hover / subtle)이 적절한지 확인 부탁드립니다. +- [ ] `color.background.*` — gray.950 / gray.900 / gray.800 3단계 계층이 실제 UI에서 충분히 구분되는지 확인 +- [ ] `color.status.*` — 각 상태별 400(fg) / 900(bg) / 700(border) 조합 적합성 검토 +- [ ] `spacing.layout.*` — 반응형 breakpoint별 여백 값 적합성 검토 +- [ ] Light 모드 semantic 토큰 — 향후 별도 검토 예정 (`tokens/semantic/light/` 현재 비어 있음) diff --git a/package-policy.json b/package-policy.json new file mode 100644 index 00000000..a30eeb0f --- /dev/null +++ b/package-policy.json @@ -0,0 +1,78 @@ +{ + "$schema": "./package-policy.schema.json", + "description": "Hard rules for packages/*/package.json consistency. Scripts listed under hardRules.scripts must be present with the exact declared value (or have an allowlist entry with a reason).", + "hardRules": { + "scripts": { + "build": "tsup", + "clean": "rm -rf node_modules dist", + "lint": "pnpm exec biome lint", + "prepack": "pnpm run build", + "test": "vitest", + "typecheck": "tsc" + }, + "publishConfig": { + "access": "public", + "registry": "https://npm.pkg.github.com" + }, + "workspaceDependencySpec": "workspace:*", + "catalogDependencies": { + "react": "catalog:react", + "react-dom": "catalog:react", + "@types/react": "catalog:react", + "@types/react-dom": "catalog:react", + "@vanilla-extract/css": "catalog:", + "@vanilla-extract/recipes": "catalog:", + "@vanilla-extract/dynamic": "catalog:", + "clsx": "catalog:" + }, + "sideEffectsAllowedValues": [false, ["**/*.css"]] + }, + "allowlist": [ + { + "packageName": "@sipe-team/tokens", + "rule": "scripts.test", + "reason": "design tokens only, no tests" + }, + { + "packageName": "@sipe-team/side", + "rule": "scripts.test", + "reason": "aggregator package, no tests" + }, + { + "packageName": "@sipe-team/chip", + "rule": "extraScript", + "fieldPath": "scripts.test:ui", + "reason": "chip-specific vitest UI runner" + }, + { + "packageName": "@sipe-team/chip", + "rule": "extraScript", + "fieldPath": "scripts.test:coverage", + "reason": "chip-specific coverage runner" + }, + { + "packageName": "@sipe-team/theme", + "rule": "extraScript", + "fieldPath": "scripts.test:ui", + "reason": "theme-specific vitest UI runner" + }, + { + "packageName": "@sipe-team/button", + "rule": "directDependency", + "fieldPath": "devDependencies.sanitize.css", + "reason": "sanitize.css is a button-specific reset layer; not shared with other packages" + }, + { + "packageName": "@sipe-team/switch", + "rule": "directDependency", + "fieldPath": "dependencies.@radix-ui/react-use-controllable-state", + "reason": "switch-specific radix utility, not used elsewhere" + }, + { + "packageName": "@sipe-team/icon", + "rule": "extraScript", + "fieldPath": "scripts.generate-icons", + "reason": "icon-specific codegen script for SVG -> React components" + } + ] +} diff --git a/package-policy.schema.json b/package-policy.schema.json new file mode 100644 index 00000000..586bcffa --- /dev/null +++ b/package-policy.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Package Policy", + "description": "Schema for package.json consistency rules across the monorepo", + "type": "object", + "required": ["hardRules", "allowlist"], + "properties": { + "$schema": { "type": "string" }, + "description": { "type": "string" }, + "hardRules": { + "type": "object", + "required": ["scripts", "publishConfig", "workspaceDependencySpec"], + "properties": { + "scripts": { + "type": "object", + "description": "Scripts that MUST exist with the exact declared value in every publishable package", + "additionalProperties": { "type": "string" } + }, + "publishConfig": { + "type": "object", + "required": ["access", "registry"], + "properties": { + "access": { "const": "public" }, + "registry": { "type": "string", "format": "uri" } + } + }, + "workspaceDependencySpec": { + "type": "string", + "description": "Expected spec for intra-workspace dependencies (e.g., workspace:*)" + }, + "catalogDependencies": { + "type": "object", + "description": "Dependency name -> required catalog reference. Any package using a direct version of these is a violation", + "additionalProperties": { "type": "string" } + }, + "sideEffectsAllowedValues": { + "type": "array", + "description": "Allowed shapes for the `sideEffects` field. Each item may be a boolean (e.g. false) or a string array of globs (e.g. [\"**/*.css\"]). Packages whose sideEffects value does not deep-equal any entry is a violation.", + "items": { + "oneOf": [{ "type": "boolean" }, { "type": "array", "items": { "type": "string" } }] + } + } + } + }, + "allowlist": { + "type": "array", + "description": "Intentional exceptions. Each entry requires a human-readable reason.", + "items": { + "type": "object", + "required": ["packageName", "rule", "reason"], + "properties": { + "packageName": { + "type": "string", + "description": "Full package name (e.g., @sipe-team/chip)" + }, + "rule": { + "type": "string", + "description": "Rule identifier being exempted (e.g., scripts.test, extraScript, directDependency)" + }, + "fieldPath": { + "type": "string", + "description": "Optional: specific field path for extraScript/directDependency exemptions" + }, + "reason": { + "type": "string", + "minLength": 1, + "description": "Human-readable justification for this exception" + } + } + } + } + } +} diff --git a/package.json b/package.json index 4a17ca6b..069bbf83 100644 --- a/package.json +++ b/package.json @@ -6,23 +6,28 @@ "prepare": "husky", "cz": "cz", "format": "biome format --write", - "lint": "biome lint", - "lint:fix": "biome check --write", + "lint": "pnpm lint:biome && pnpm lint:package", + "lint:biome": "biome lint", + "lint:biome:fix": "biome check --write", + "lint:package": "tsx scripts/checkPackageConsistency.ts", "dev:storybook": "storybook dev -p 6006 public", "build:storybook": "storybook build public", "serve:storybook": "serve storybook-static -p 6006", "create:component": "tsx scripts/createComponent.ts create", + "build": "pnpm --filter './packages/*' -r build", + "typecheck": "pnpm --filter './packages/*' -r typecheck", "test": "vitest", + "test:coverage": "vitest --coverage", "clean": "pnpm --filter './www' clean && pnpm --filter './packages/*' clean" }, "devDependencies": { "@biomejs/biome": "^2.4.10", "@changesets/cli": "^2.27.9", "@clack/prompts": "^0.9.0", - "@commitlint/cli": "^19.6.1", - "@commitlint/config-conventional": "^19.6.0", - "@commitlint/cz-commitlint": "^19.6.1", - "@commitlint/types": "^19.5.0", + "@commitlint/cli": "^20.5.0", + "@commitlint/config-conventional": "^20.5.0", + "@commitlint/cz-commitlint": "^20.5.1", + "@manypkg/get-packages": "^3.1.0", "@storybook/addon-docs": "^8.4.7", "@storybook/addon-essentials": "catalog:", "@storybook/addon-interactions": "catalog:", @@ -45,6 +50,7 @@ "commitizen": "^4.3.1", "globals": "^15.14.0", "husky": "^9.1.7", + "inquirer": "^9.3.8", "knip": "catalog:", "lint-staged": "^15.3.0", "sanitize.css": "^13.0.0", @@ -53,12 +59,13 @@ "tsx": "^4.19.2", "typescript": "catalog:", "vite": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "zod": "^4.3.6" }, - "packageManager": "pnpm@9.7.1", + "packageManager": "pnpm@10.33.0", "engines": { "node": ">=22.22.2 <23", - "pnpm": "9.7.1" + "pnpm": "10.33.0" }, "lint-staged": { "*.{ts,tsx,css,js}": [ diff --git a/packages/accordion/.storybook/main.ts b/packages/accordion/.storybook/main.ts deleted file mode 100644 index e7f301d3..00000000 --- a/packages/accordion/.storybook/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { StorybookConfig } from '@storybook/react-vite'; - -export default { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, -} satisfies StorybookConfig; diff --git a/packages/accordion/.storybook/preview.ts b/packages/accordion/.storybook/preview.ts deleted file mode 100644 index 6c95c9f1..00000000 --- a/packages/accordion/.storybook/preview.ts +++ /dev/null @@ -1,8 +0,0 @@ -import 'sanitize.css'; -import 'sanitize.css/typography.css'; - -import type { Preview } from '@storybook/react'; - -export default { - tags: ['autodocs'], -} satisfies Preview; diff --git a/packages/accordion/package.json b/packages/accordion/package.json index f89f41f5..bfc478f2 100644 --- a/packages/accordion/package.json +++ b/packages/accordion/package.json @@ -14,13 +14,11 @@ ], "scripts": { "build": "tsup", - "build:storybook": "storybook build", - "dev:storybook": "storybook dev -p 6006", - "lint:biome": "pnpm exec biome lint", - "lint:eslint": "pnpm exec eslint", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", - "prepack": "pnpm run build" + "prepack": "pnpm run build", + "clean": "rm -rf node_modules dist" }, "dependencies": { "@radix-ui/react-slot": "catalog:", @@ -32,20 +30,12 @@ }, "devDependencies": { "@sipe-team/typography": "workspace:*", - "@storybook/addon-essentials": "catalog:", - "@storybook/addon-interactions": "catalog:", - "@storybook/addon-links": "catalog:", - "@storybook/blocks": "catalog:", - "@storybook/react": "catalog:", - "@storybook/react-vite": "catalog:", - "@storybook/test": "catalog:", "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", - "@types/react": "^18.3.12", + "@types/react": "catalog:react", "happy-dom": "catalog:", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "storybook": "catalog:", + "react": "catalog:react", + "react-dom": "catalog:react", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" @@ -71,5 +61,7 @@ "./styles.css": "./dist/index.css" } }, - "sideEffects": false + "sideEffects": [ + "**/*.css" + ] } diff --git a/packages/accordion/src/Accordion.stories.tsx b/packages/accordion/src/Accordion.stories.tsx index e0e6407a..632d4a48 100644 --- a/packages/accordion/src/Accordion.stories.tsx +++ b/packages/accordion/src/Accordion.stories.tsx @@ -134,6 +134,52 @@ export const AccordionList: Story = { ), }; +export const SingleOpen: Story = { + render: () => ( + + + + 4기 선발 기준은 어떻게 되나요? + + + + + 함께 대화하고 싶은, 구성원들의 기술적 성장에 기여할 수 있는, 그리고 동아리 활동에 성실하게 참여 가능한 현직 + 개발자를 저희의 인재상으로 삼고 있습니다. + + + + + + + + 수도권에 거주하고 있지 않지만 주요 활동 지역은 수도권인데 활동을 할 수 있나요? + + + + + + 네 가능합니다. 다만, 모든 활동이 수도권에서 진행될 예정으로, 결석이나 지각을 하는 경우 수료 조건에 영향이 + 있을 수 있습니다. + + + + + + + 4기 선발 인원은 몇명인가요? + + + + + 4기는 40명 내외로 구성할 예정이며, 선발 인원은 지원자 수에 따라서 변동될 수 있습니다. + + + + + ), +}; + export const TriggerUsingAsChild: Story = { render: () => ( diff --git a/packages/accordion/src/Accordion.test.tsx b/packages/accordion/src/Accordion.test.tsx index 1d4948db..a3ad3c2b 100644 --- a/packages/accordion/src/Accordion.test.tsx +++ b/packages/accordion/src/Accordion.test.tsx @@ -3,8 +3,8 @@ import { describe, expect, test } from 'vitest'; import { Accordion } from './Accordion'; -describe('Accordion.Root 기본 스타일', () => { - test('Accordion의 Root에 border-radius를 주입하지 않으면 기본 값 "12px"으로 border-radius를 설정한다.', () => { +describe('Accordion.Root default styles', () => { + test('sets border-radius to default "12px" when no border-radius is provided', () => { render( @@ -17,7 +17,7 @@ describe('Accordion.Root 기본 스타일', () => { expect(root).toHaveStyle({ borderRadius: '12px' }); }); - test('Accordion의 Root에 border 옵션을 주입하지 않으면 기본 값 "1px solid #2d3748"로 border를 설정한다.', () => { + test('sets border to default "1px solid #2d3748" when no border is provided', () => { render( @@ -30,7 +30,7 @@ describe('Accordion.Root 기본 스타일', () => { expect(root).toHaveStyle({ border: '1px solid #2d3748' }); }); - test('Accordion의 Root에 background-color 옵션을 주입하지 않으면 기본 값 "#1a202c"로 background-color를 설정한다.', () => { + test('sets background-color to default "#1a202c" when no background-color is provided', () => { render( @@ -43,7 +43,7 @@ describe('Accordion.Root 기본 스타일', () => { expect(root).toHaveStyle({ backgroundColor: '#1a202c' }); }); - test('Accordion의 Root에 padding 옵션을 주입하지 않으면 기본 값 "20px"로 padding을 설정한다.', () => { + test('sets padding to default "20px" when no padding is provided', () => { render( @@ -57,8 +57,8 @@ describe('Accordion.Root 기본 스타일', () => { }); }); -describe('Accordion.Trigger 기본 스타일 및 컴포넌트 구조', () => { - test('Accordion의 Trigger에 존재하는 텍스트는 기본적으로 왼쪽 정렬된다.', () => { +describe('Accordion.Trigger default styles and component structure', () => { + test('text in Trigger is left-aligned by default', () => { render( @@ -73,7 +73,7 @@ describe('Accordion.Trigger 기본 스타일 및 컴포넌트 구조', () => { }); }); - test('aria-expanded를 통해 요소 확장 및 축소 여부를 확인할 수 있다', () => { + test('aria-expanded reflects the expanded/collapsed state of the element', () => { render( @@ -90,7 +90,7 @@ describe('Accordion.Trigger 기본 스타일 및 컴포넌트 구조', () => { expect(trigger).toHaveAttribute('aria-expanded', 'false'); }); - test('Accordion.Indicator를 사용하여 아이콘을 렌더링할 수 있다', () => { + test('renders an icon using Accordion.Indicator', () => { render( @@ -107,8 +107,8 @@ describe('Accordion.Trigger 기본 스타일 및 컴포넌트 구조', () => { }); }); -describe('Accordion.Content 기본 스타일', () => { - test('Accordion의 Content에 borderRadius를 주입하지 않으면 기본 값 8px으로 borderRadius를 설정한다.', () => { +describe('Accordion.Content default styles', () => { + test('sets borderRadius to default 8px when no borderRadius is provided', () => { render( @@ -122,7 +122,7 @@ describe('Accordion.Content 기본 스타일', () => { expect(contentElement).toHaveStyle({ borderRadius: '8px' }); }); - test('Accordion의 Content에 background-color를 주입하지 않으면 기본 값 #2d3748으로 background-color를 설정한다.', () => { + test('sets background-color to default #2d3748 when no background-color is provided', () => { render( @@ -136,7 +136,7 @@ describe('Accordion.Content 기본 스타일', () => { expect(contentElement).toHaveStyle({ backgroundColor: '#2d3748' }); }); - test('Accordion의 Content에 padding을 주입하지 않으면 기본 값 12px 16px으로 padding을 설정한다.', () => { + test('sets padding to default "12px 16px" when no padding is provided', () => { render( @@ -151,8 +151,8 @@ describe('Accordion.Content 기본 스타일', () => { }); }); -describe('Accordion 동작', () => { - test('Trigger 클릭 시 Content의 내용을 노출 및 숨김 처리할 수 있다', () => { +describe('Accordion behavior', () => { + test('clicking Trigger toggles Content visibility', () => { render( @@ -171,8 +171,61 @@ describe('Accordion 동작', () => { }); }); -describe('Accordion 구조', () => { - test('Accordion의 children으로 전달한 요소를 올바르게 렌더링할 수 있다', () => { +describe('Accordion single mode', () => { + test('opening one item closes the other when type="single"', () => { + render( + + + Trigger 1 + Content 1 + + + Trigger 2 + Content 2 + + , + ); + + const trigger1 = screen.getByText('Trigger 1'); + const trigger2 = screen.getByText('Trigger 2'); + const wrapper1 = screen.getByText('Content 1').closest('[class*="accordionContentWrapper"]'); + const wrapper2 = screen.getByText('Content 2').closest('[class*="accordionContentWrapper"]'); + + expect(wrapper1).toHaveAttribute('aria-hidden', 'true'); + expect(wrapper2).toHaveAttribute('aria-hidden', 'true'); + + fireEvent.click(trigger1); + expect(wrapper1).toHaveAttribute('aria-hidden', 'false'); + expect(wrapper2).toHaveAttribute('aria-hidden', 'true'); + + fireEvent.click(trigger2); + expect(wrapper1).toHaveAttribute('aria-hidden', 'true'); + expect(wrapper2).toHaveAttribute('aria-hidden', 'false'); + }); + + test('clicking an already open item closes it when type="single"', () => { + render( + + + Trigger 1 + Content 1 + + , + ); + + const trigger = screen.getByText('Trigger 1'); + const wrapper = screen.getByText('Content 1').closest('[class*="accordionContentWrapper"]'); + + fireEvent.click(trigger); + expect(wrapper).toHaveAttribute('aria-hidden', 'false'); + + fireEvent.click(trigger); + expect(wrapper).toHaveAttribute('aria-hidden', 'true'); + }); +}); + +describe('Accordion structure', () => { + test('renders children passed to Accordion correctly', () => { render( diff --git a/packages/accordion/src/Accordion.tsx b/packages/accordion/src/Accordion.tsx index 7f6e76f4..e63b4d31 100644 --- a/packages/accordion/src/Accordion.tsx +++ b/packages/accordion/src/Accordion.tsx @@ -9,15 +9,22 @@ import { clsx as cx } from 'clsx'; import * as styles from './Accordion.css'; import { AccordionItemContext, useAccordionItemContext } from './context/AccordionItemContext'; +import { AccordionRootContext, useAccordionRootContext } from './context/AccordionRootContext'; import { useAccordionAnimation } from './hooks/useAccordionAnimation'; + export interface AccordionRootProps extends ComponentProps<'div'> { children: ReactNode; asChild?: boolean; + type?: 'single' | 'multiple'; + initialValue?: string | null; + value?: string | null; + onValueChange?: (value: string | null) => void; } export interface AccordionItemProps { children: ReactNode; className?: string; defaultOpen?: boolean; + value?: string; } export interface AccordionTriggerProps extends ComponentProps<'button'> { @@ -33,25 +40,56 @@ export interface AccordionContentProps { } export const AccordionRoot = forwardRef(function AccordionRoot( - { children, asChild, className, ...props }: AccordionRootProps, + { + children, + asChild, + className, + type = 'multiple', + initialValue = null, + value, + onValueChange, + ...props + }: AccordionRootProps, ref: ForwardedRef, ) { + const [internalValue, setInternalValue] = useState(initialValue); + const activeValue = value !== undefined ? value : internalValue; + + const onItemToggle = (itemValue: string) => { + const newValue = activeValue === itemValue ? null : itemValue; + if (value === undefined) { + setInternalValue(newValue); + } + onValueChange?.(newValue); + }; + const Component = asChild ? Slot : 'div'; return ( - - {children} - + + + {children} + + ); }); export const AccordionItem = forwardRef(function AccordionItem( - { children, className, defaultOpen = false, ...props }: AccordionItemProps, + { children, className, defaultOpen = false, value, ...props }: AccordionItemProps, ref: ForwardedRef, ) { - const [isOpen, setIsOpen] = useState(defaultOpen); + const rootContext = useAccordionRootContext(); + const isSingleMode = rootContext?.type === 'single' && value !== undefined; + + const [localIsOpen, setLocalIsOpen] = useState(defaultOpen); + + const isOpen = isSingleMode ? rootContext.activeValue === value : localIsOpen; const toggleAccordion = () => { - setIsOpen((prev) => !prev); + if (isSingleMode) { + rootContext.onItemToggle(value); + } else { + setLocalIsOpen((prev) => !prev); + } }; const contextValue = { isOpen, toggleAccordion }; diff --git a/packages/accordion/src/context/AccordionItemContext.tsx b/packages/accordion/src/context/AccordionItemContext.tsx index 6bd4872a..129fc93f 100644 --- a/packages/accordion/src/context/AccordionItemContext.tsx +++ b/packages/accordion/src/context/AccordionItemContext.tsx @@ -5,10 +5,7 @@ interface AccordionItemContextValue { toggleAccordion: () => void; } -export const AccordionItemContext = createContext({ - isOpen: false, - toggleAccordion: () => {}, -}); +export const AccordionItemContext = createContext(null); export const useAccordionItemContext = () => { const context = useContext(AccordionItemContext); diff --git a/packages/accordion/src/context/AccordionRootContext.tsx b/packages/accordion/src/context/AccordionRootContext.tsx new file mode 100644 index 00000000..bdd0be50 --- /dev/null +++ b/packages/accordion/src/context/AccordionRootContext.tsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +interface AccordionRootContextValue { + type: 'single' | 'multiple'; + activeValue: string | null; + onItemToggle: (value: string) => void; +} + +export const AccordionRootContext = createContext(null); + +export const useAccordionRootContext = () => { + return useContext(AccordionRootContext); +}; diff --git a/packages/avatar/.storybook/main.ts b/packages/avatar/.storybook/main.ts deleted file mode 100644 index e7f301d3..00000000 --- a/packages/avatar/.storybook/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { StorybookConfig } from '@storybook/react-vite'; - -export default { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, -} satisfies StorybookConfig; diff --git a/packages/avatar/.storybook/preview.ts b/packages/avatar/.storybook/preview.ts deleted file mode 100644 index 82ec7ed0..00000000 --- a/packages/avatar/.storybook/preview.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Preview } from '@storybook/react'; - -export default { - tags: ['autodocs'], -} satisfies Preview; diff --git a/packages/avatar/package.json b/packages/avatar/package.json index f172ac61..ddeb8a90 100644 --- a/packages/avatar/package.json +++ b/packages/avatar/package.json @@ -14,29 +14,20 @@ ], "scripts": { "build": "tsup", - "build:storybook": "storybook build", - "clean": "rm -rf node_modules dist", - "dev:storybook": "storybook dev -p 6006", - "lint": "biome lint .", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", - "prepack": "pnpm run build" + "prepack": "pnpm run build", + "clean": "rm -rf node_modules dist" }, "dependencies": { "@radix-ui/react-slot": "catalog:", - "@sipe-team/typography": "workspace:^", + "@sipe-team/typography": "workspace:*", "@sipe-team/tokens": "workspace:*", "clsx": "catalog:" }, "devDependencies": { "@faker-js/faker": "catalog:", - "@storybook/addon-essentials": "catalog:", - "@storybook/addon-interactions": "catalog:", - "@storybook/addon-links": "catalog:", - "@storybook/blocks": "catalog:", - "@storybook/react": "catalog:", - "@storybook/react-vite": "catalog:", - "@storybook/test": "catalog:", "@types/react": "catalog:react", "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", @@ -44,7 +35,6 @@ "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", - "storybook": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/packages/avatar/vite.config.ts b/packages/avatar/vite.config.ts deleted file mode 100644 index 2484cb4e..00000000 --- a/packages/avatar/vite.config.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from '../../vite.config'; diff --git a/packages/badge/.storybook/main.ts b/packages/badge/.storybook/main.ts deleted file mode 100644 index 3724f800..00000000 --- a/packages/badge/.storybook/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { StorybookConfig } from '@storybook/react-vite'; - -export default { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: ['@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/addon-links'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, -} satisfies StorybookConfig; diff --git a/packages/badge/.storybook/preview.ts b/packages/badge/.storybook/preview.ts deleted file mode 100644 index 6c95c9f1..00000000 --- a/packages/badge/.storybook/preview.ts +++ /dev/null @@ -1,8 +0,0 @@ -import 'sanitize.css'; -import 'sanitize.css/typography.css'; - -import type { Preview } from '@storybook/react'; - -export default { - tags: ['autodocs'], -} satisfies Preview; diff --git a/packages/badge/package.json b/packages/badge/package.json index 5811f1ec..57b63536 100644 --- a/packages/badge/package.json +++ b/packages/badge/package.json @@ -14,13 +14,11 @@ ], "scripts": { "build": "tsup", - "build:storybook": "storybook build", - "clean": "rm -rf node_modules dist", - "dev:storybook": "storybook dev -p 6006", "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", - "prepack": "pnpm run build" + "prepack": "pnpm run build", + "clean": "rm -rf node_modules dist" }, "dependencies": { "@sipe-team/tokens": "workspace:*", @@ -28,13 +26,6 @@ "clsx": "catalog:" }, "devDependencies": { - "@storybook/addon-essentials": "catalog:", - "@storybook/addon-interactions": "catalog:", - "@storybook/addon-links": "catalog:", - "@storybook/blocks": "catalog:", - "@storybook/react": "catalog:", - "@storybook/react-vite": "catalog:", - "@storybook/test": "catalog:", "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/react": "catalog:react", @@ -43,7 +34,6 @@ "react": "catalog:react", "react-dom": "catalog:react", "sanitize.css": "^13.0.0", - "storybook": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" @@ -69,5 +59,7 @@ "./styles.css": "./dist/index.css" } }, - "sideEffects": false + "sideEffects": [ + "**/*.css" + ] } diff --git a/packages/button/.storybook/main.ts b/packages/button/.storybook/main.ts deleted file mode 100644 index e7f301d3..00000000 --- a/packages/button/.storybook/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { StorybookConfig } from '@storybook/react-vite'; - -export default { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, -} satisfies StorybookConfig; diff --git a/packages/button/.storybook/preview.ts b/packages/button/.storybook/preview.ts deleted file mode 100644 index 82ec7ed0..00000000 --- a/packages/button/.storybook/preview.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Preview } from '@storybook/react'; - -export default { - tags: ['autodocs'], -} satisfies Preview; diff --git a/packages/button/package.json b/packages/button/package.json index d1c3bc05..c5377f32 100644 --- a/packages/button/package.json +++ b/packages/button/package.json @@ -14,13 +14,11 @@ ], "scripts": { "build": "tsup", - "build:storybook": "storybook build", - "clean": "rm -rf node_modules dist", - "dev:storybook": "storybook dev -p 6006", "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", - "prepack": "pnpm run build" + "prepack": "pnpm run build", + "clean": "rm -rf node_modules dist" }, "dependencies": { "@radix-ui/react-slot": "catalog:", @@ -30,13 +28,6 @@ "clsx": "catalog:" }, "devDependencies": { - "@storybook/addon-essentials": "catalog:", - "@storybook/addon-interactions": "catalog:", - "@storybook/addon-links": "catalog:", - "@storybook/blocks": "catalog:", - "@storybook/react": "catalog:", - "@storybook/react-vite": "catalog:", - "@storybook/test": "catalog:", "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/react": "catalog:react", @@ -45,7 +36,6 @@ "react": "catalog:react", "react-dom": "catalog:react", "sanitize.css": "^13.0.0", - "storybook": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" @@ -71,5 +61,7 @@ "./styles.css": "./dist/index.css" } }, - "sideEffects": false + "sideEffects": [ + "**/*.css" + ] } diff --git a/packages/button/src/Button.css.ts b/packages/button/src/Button.css.ts index d6cbe620..f279a34b 100644 --- a/packages/button/src/Button.css.ts +++ b/packages/button/src/Button.css.ts @@ -16,51 +16,55 @@ export const button = recipe({ display: 'flex', alignItems: 'center', justifyContent: 'center', - borderRadius: vars.radius.md, + borderRadius: vars.radius.component.md, fontWeight: vars.typography.fontWeight.semiBold, cursor: 'pointer', transition: 'all 0.2s ease-in-out', border: 'none', fontFamily: vars.typography.fontFamily, + ':focus-visible': { + outline: `2px solid ${vars.color.border.focus}`, + outlineOffset: '2px', + }, }, variants: { variant: { [ButtonVariant.filled]: { - backgroundColor: vars.color.primary, - color: vars.color.background, + backgroundColor: vars.color.accent.default, + color: vars.color.foreground.onAccent, border: 'none', ':hover': { - opacity: 0.9, + backgroundColor: vars.color.accent.hover, }, }, [ButtonVariant.outline]: { backgroundColor: 'transparent', - border: `1px solid ${vars.color.primary}`, - color: vars.color.primary, + border: `1px solid ${vars.color.accent.default}`, + color: vars.color.accent.default, ':hover': { - backgroundColor: vars.color.primary, - color: vars.color.background, + backgroundColor: vars.color.accent.default, + color: vars.color.foreground.onAccent, }, }, [ButtonVariant.ghost]: { backgroundColor: 'transparent', border: 'none', - color: vars.color.primary, + color: vars.color.accent.default, ':hover': { - backgroundColor: `color-mix(in srgb, ${vars.color.primary} 10%, transparent)`, + backgroundColor: vars.color.accent.subtle, }, }, }, size: { [ButtonSize.sm]: { height: '32px', - padding: `0 ${vars.spacing.sm}`, + padding: `0 ${vars.spacing.component.sm}`, fontSize: vars.typography.fontSize['200'], lineHeight: vars.typography.lineHeight.compact, }, [ButtonSize.lg]: { height: '48px', - padding: `0 ${vars.spacing.lg}`, + padding: `0 ${vars.spacing.component.lg}`, fontSize: vars.typography.fontSize['400'], lineHeight: vars.typography.lineHeight.regular, }, diff --git a/packages/button/src/Button.test.tsx b/packages/button/src/Button.test.tsx index 4c513c63..7530a64d 100644 --- a/packages/button/src/Button.test.tsx +++ b/packages/button/src/Button.test.tsx @@ -78,3 +78,21 @@ test('large size works correctly', () => { expect(button).toBeInTheDocument(); expect(button.className).toBeTruthy(); }); + +test('type="button" is used as default when type is not provided', () => { + render(); + + const button = screen.getByRole('button'); + + // Should default to type="button" + expect(button).toHaveAttribute('type', 'button'); +}); + +test('type="submit" works correctly', () => { + render(); + + const button = screen.getByRole('button'); + + // Should allow overriding type + expect(button).toHaveAttribute('type', 'submit'); +}); diff --git a/packages/button/src/Button.tsx b/packages/button/src/Button.tsx index f5082d93..98a343ac 100644 --- a/packages/button/src/Button.tsx +++ b/packages/button/src/Button.tsx @@ -29,6 +29,7 @@ export const Button = forwardRef(function Button( { variant = ButtonVariant.filled, size = ButtonSize.lg, + type = 'button', asChild, disabled, className: _className, @@ -39,5 +40,5 @@ export const Button = forwardRef(function Button( const Comp = asChild ? Slot : 'button'; const className = cx(styles.button({ variant, size }), { [styles.disabled]: disabled }, _className); - return ; + return ; }); diff --git a/packages/card/.storybook/main.ts b/packages/card/.storybook/main.ts deleted file mode 100644 index 3724f800..00000000 --- a/packages/card/.storybook/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { StorybookConfig } from '@storybook/react-vite'; - -export default { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: ['@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/addon-links'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, -} satisfies StorybookConfig; diff --git a/packages/card/.storybook/preview.ts b/packages/card/.storybook/preview.ts deleted file mode 100644 index 6c95c9f1..00000000 --- a/packages/card/.storybook/preview.ts +++ /dev/null @@ -1,8 +0,0 @@ -import 'sanitize.css'; -import 'sanitize.css/typography.css'; - -import type { Preview } from '@storybook/react'; - -export default { - tags: ['autodocs'], -} satisfies Preview; diff --git a/packages/card/package.json b/packages/card/package.json index ddffe184..0988dcb4 100644 --- a/packages/card/package.json +++ b/packages/card/package.json @@ -14,13 +14,11 @@ ], "scripts": { "build": "tsup", - "build:storybook": "storybook build", - "clean": "rm -rf node_modules dist", - "dev:storybook": "storybook dev -p 6006", "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", - "prepack": "pnpm run build" + "prepack": "pnpm run build", + "clean": "rm -rf node_modules dist" }, "dependencies": { "@radix-ui/react-slot": "catalog:", @@ -28,13 +26,6 @@ "clsx": "catalog:" }, "devDependencies": { - "@storybook/addon-essentials": "catalog:", - "@storybook/addon-interactions": "catalog:", - "@storybook/addon-links": "catalog:", - "@storybook/blocks": "catalog:", - "@storybook/react": "catalog:", - "@storybook/react-vite": "catalog:", - "@storybook/test": "catalog:", "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/react": "catalog:react", @@ -44,7 +35,6 @@ "react": "catalog:react", "react-dom": "catalog:react", "sanitize.css": "^13.0.0", - "storybook": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" @@ -70,5 +60,7 @@ "./styles.css": "./dist/index.css" } }, - "sideEffects": false + "sideEffects": [ + "**/*.css" + ] } diff --git a/packages/card/src/Card.css.ts b/packages/card/src/Card.css.ts index 6e2720d2..cacce8d5 100644 --- a/packages/card/src/Card.css.ts +++ b/packages/card/src/Card.css.ts @@ -5,6 +5,7 @@ import { recipe } from '@vanilla-extract/recipes'; export const CardVariant = { filled: 'filled', outline: 'outline', + ghost: 'ghost', } as const; export const CardRatio = { @@ -36,6 +37,11 @@ export const card = recipe({ backgroundColor: color.gray50, border: `1px solid ${color.cyan300}`, }, + [CardVariant.ghost]: { + backgroundColor: 'transparent', + border: 'none', + padding: 0, + }, }, ratio: { [CardRatio.square]: { diff --git a/packages/card/src/Card.stories.tsx b/packages/card/src/Card.stories.tsx index c74e19db..99f29c2c 100644 --- a/packages/card/src/Card.stories.tsx +++ b/packages/card/src/Card.stories.tsx @@ -15,7 +15,7 @@ const meta = { }, variant: { control: 'select', - options: ['filled', 'outline'], + options: ['filled', 'outline', 'ghost'], }, }, } satisfies Meta; @@ -141,3 +141,48 @@ export const OutlineVariant: Story = { ), }; + +// Ghost variant with all ratios in a row +export const GhostVariant: Story = { + render: () => ( +
+

Ghost Variant - All Ratios

+
+
+ + + +
Rectangle (16:9)
+
+ +
+ + + +
Square (1:1)
+
+ +
+ + + +
Wide (21:9)
+
+ +
+ + + +
Portrait (3:4)
+
+ +
+ + + +
Auto (Custom Size)
+
+
+
+ ), +}; diff --git a/packages/card/src/Card.test.tsx b/packages/card/src/Card.test.tsx index c7078445..c5ae109f 100644 --- a/packages/card/src/Card.test.tsx +++ b/packages/card/src/Card.test.tsx @@ -81,3 +81,24 @@ test(`variant가 outline일 때 배경색이 ${color.gray50}이다.`, () => { backgroundColor: color.gray50, }); }); + +test('ghost variant has transparent background', () => { + render(Card); + expect(screen.getByText('Card')).toHaveStyle({ + backgroundColor: 'transparent', + }); +}); + +test('ghost variant has no border', () => { + render(Card); + expect(screen.getByText('Card')).toHaveStyle({ + border: 'none', + }); +}); + +test('ghost variant has no padding', () => { + render(Card); + expect(screen.getByText('Card')).toHaveStyle({ + padding: '0', + }); +}); diff --git a/packages/checkbox/.storybook/main.ts b/packages/checkbox/.storybook/main.ts deleted file mode 100644 index e7f301d3..00000000 --- a/packages/checkbox/.storybook/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { StorybookConfig } from '@storybook/react-vite'; - -export default { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, -} satisfies StorybookConfig; diff --git a/packages/checkbox/.storybook/preview.ts b/packages/checkbox/.storybook/preview.ts deleted file mode 100644 index 82ec7ed0..00000000 --- a/packages/checkbox/.storybook/preview.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Preview } from '@storybook/react'; - -export default { - tags: ['autodocs'], -} satisfies Preview; diff --git a/packages/checkbox/package.json b/packages/checkbox/package.json index cc2e8576..db4c92d0 100644 --- a/packages/checkbox/package.json +++ b/packages/checkbox/package.json @@ -14,13 +14,11 @@ ], "scripts": { "build": "tsup", - "build:storybook": "storybook build", - "clean": "rm -rf node_modules dist", - "dev:storybook": "storybook dev -p 6006", "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", - "prepack": "pnpm run build" + "prepack": "pnpm run build", + "clean": "rm -rf node_modules dist" }, "dependencies": { "@sipe-team/tokens": "workspace:*", @@ -28,22 +26,14 @@ "@vanilla-extract/recipes": "catalog:" }, "devDependencies": { - "@storybook/addon-essentials": "catalog:", - "@storybook/addon-interactions": "catalog:", - "@storybook/addon-links": "catalog:", - "@storybook/blocks": "catalog:", - "@storybook/react": "catalog:", - "@storybook/react-vite": "catalog:", - "@storybook/test": "catalog:", "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@testing-library/user-event": "catalog:", "@types/react": "catalog:react", - "@vanilla-extract/css": "^1.17.1", + "@vanilla-extract/css": "catalog:", "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", - "storybook": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" @@ -69,5 +59,7 @@ "./styles.css": "./dist/index.css" } }, - "sideEffects": false + "sideEffects": [ + "**/*.css" + ] } diff --git a/packages/chip/package.json b/packages/chip/package.json index a67cbde6..e0161919 100644 --- a/packages/chip/package.json +++ b/packages/chip/package.json @@ -1,57 +1,76 @@ { "name": "@sipe-team/chip", - "version": "0.0.1", "description": "Chip component for SIDE design system", - "main": "./dist/index.js", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./package.json": "./package.json" + "version": "0.0.1", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/sipe-team/side" }, + "type": "module", + "exports": "./src/index.ts", "files": [ "dist" ], "scripts": { "build": "tsup", - "dev": "tsup --watch", + "clean": "rm -rf node_modules dist", + "lint": "pnpm exec biome lint", "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", - "typecheck": "tsc --noEmit" + "typecheck": "tsc", + "prepack": "pnpm run build" }, "dependencies": { "@radix-ui/react-slot": "catalog:", - "@vanilla-extract/css": "catalog:", + "@sipe-team/tokens": "workspace:*", "@vanilla-extract/recipes": "catalog:", "clsx": "catalog:" }, "devDependencies": { - "@sipe-team/tokens": "workspace:*", + "@storybook/addon-essentials": "catalog:", + "@storybook/addon-interactions": "catalog:", + "@storybook/addon-links": "catalog:", + "@storybook/blocks": "catalog:", + "@storybook/react": "catalog:", + "@storybook/react-vite": "catalog:", + "@storybook/test": "catalog:", "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@testing-library/user-event": "catalog:", "@types/react": "catalog:react", - "jsdom": "^26.1.0", + "@vanilla-extract/css": "catalog:", + "happy-dom": "catalog:", "react": "catalog:react", + "react-dom": "catalog:react", + "storybook": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, "peerDependencies": { - "react": ">=18.0.0" + "react": ">= 18", + "react-dom": ">= 18" }, - "keywords": [ - "chip", - "tag", - "badge", - "design-system", - "react" - ], - "author": "SIDE Team", - "license": "MIT" + "publishConfig": { + "access": "public", + "registry": "https://npm.pkg.github.com", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./styles.css": "./dist/index.css" + } + }, + "sideEffects": [ + "**/*.css" + ] } diff --git a/packages/divider/.storybook/main.ts b/packages/divider/.storybook/main.ts deleted file mode 100644 index e7f301d3..00000000 --- a/packages/divider/.storybook/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { StorybookConfig } from '@storybook/react-vite'; - -export default { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, -} satisfies StorybookConfig; diff --git a/packages/divider/.storybook/preview.ts b/packages/divider/.storybook/preview.ts deleted file mode 100644 index 82ec7ed0..00000000 --- a/packages/divider/.storybook/preview.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Preview } from '@storybook/react'; - -export default { - tags: ['autodocs'], -} satisfies Preview; diff --git a/packages/divider/package.json b/packages/divider/package.json index f30a34a4..1c5d9db6 100644 --- a/packages/divider/package.json +++ b/packages/divider/package.json @@ -14,13 +14,11 @@ ], "scripts": { "build": "tsup", - "build:storybook": "storybook build", - "clean": "rm -rf node_modules dist", - "dev:storybook": "storybook dev -p 6006", "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", - "prepack": "pnpm run build" + "prepack": "pnpm run build", + "clean": "rm -rf node_modules dist" }, "dependencies": { "clsx": "catalog:", @@ -28,13 +26,6 @@ }, "devDependencies": { "@sipe-team/typography": "workspace:*", - "@storybook/addon-essentials": "catalog:", - "@storybook/addon-interactions": "catalog:", - "@storybook/addon-links": "catalog:", - "@storybook/blocks": "catalog:", - "@storybook/react": "catalog:", - "@storybook/react-vite": "catalog:", - "@storybook/test": "catalog:", "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/react": "catalog:react", @@ -42,7 +33,6 @@ "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", - "storybook": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" @@ -53,6 +43,7 @@ }, "publishConfig": { "access": "public", + "registry": "https://npm.pkg.github.com", "exports": { ".": { "import": { @@ -67,5 +58,7 @@ "./styles.css": "./dist/index.css" } }, - "sideEffects": false + "sideEffects": [ + "**/*.css" + ] } diff --git a/packages/flex/.storybook/main.ts b/packages/flex/.storybook/main.ts deleted file mode 100644 index e7f301d3..00000000 --- a/packages/flex/.storybook/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { StorybookConfig } from '@storybook/react-vite'; - -export default { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, -} satisfies StorybookConfig; diff --git a/packages/flex/.storybook/preview.ts b/packages/flex/.storybook/preview.ts deleted file mode 100644 index 82ec7ed0..00000000 --- a/packages/flex/.storybook/preview.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Preview } from '@storybook/react'; - -export default { - tags: ['autodocs'], -} satisfies Preview; diff --git a/packages/flex/package.json b/packages/flex/package.json index 3d350f5e..e774a2ea 100644 --- a/packages/flex/package.json +++ b/packages/flex/package.json @@ -14,13 +14,11 @@ ], "scripts": { "build": "tsup", - "build:storybook": "storybook build", - "clean": "rm -rf node_modules dist", - "dev:storybook": "storybook dev -p 6006", "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", - "prepack": "pnpm run build" + "prepack": "pnpm run build", + "clean": "rm -rf node_modules dist" }, "dependencies": { "@radix-ui/react-slot": "catalog:", @@ -28,13 +26,6 @@ }, "devDependencies": { "@faker-js/faker": "catalog:", - "@storybook/addon-essentials": "catalog:", - "@storybook/addon-interactions": "catalog:", - "@storybook/addon-links": "catalog:", - "@storybook/blocks": "catalog:", - "@storybook/react": "catalog:", - "@storybook/react-vite": "catalog:", - "@storybook/test": "catalog:", "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/react": "catalog:react", @@ -42,7 +33,6 @@ "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", - "storybook": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" @@ -68,5 +58,7 @@ "./styles.css": "./dist/index.css" } }, - "sideEffects": false + "sideEffects": [ + "**/*.css" + ] } diff --git a/packages/flex/src/Flex.stories.tsx b/packages/flex/src/Flex.stories.tsx index 5df52165..5b7c5bc1 100644 --- a/packages/flex/src/Flex.stories.tsx +++ b/packages/flex/src/Flex.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { FLEX_ALIGNS, FLEX_DIRECTIONS, FLEX_JUSTIFY_CONTENTS, FLEX_WRAPS } from './constants'; import { Flex } from './Flex'; const meta = { @@ -9,22 +10,22 @@ const meta = { argTypes: { direction: { control: 'select', - options: ['row', 'column', 'row-reverse', 'column-reverse'], + options: FLEX_DIRECTIONS, description: 'Flex direction', }, align: { control: 'select', - options: ['flex-start', 'flex-end', 'center', 'stretch', 'baseline', 'normal'], + options: FLEX_ALIGNS, description: 'Align items', }, justify: { control: 'select', - options: ['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly', 'normal'], + options: FLEX_JUSTIFY_CONTENTS, description: 'Justify content', }, wrap: { control: 'select', - options: ['nowrap', 'wrap', 'wrap-reverse'], + options: FLEX_WRAPS, description: 'Flex wrap', }, gap: { diff --git a/packages/flex/src/Flex.test.tsx b/packages/flex/src/Flex.test.tsx index eb71a4bd..b03362c9 100644 --- a/packages/flex/src/Flex.test.tsx +++ b/packages/flex/src/Flex.test.tsx @@ -1,13 +1,14 @@ -import { type CSSProperties, createElement } from 'react'; +import { type CSSProperties, createElement, createRef } from 'react'; import { faker } from '@faker-js/faker'; import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; +import { FLEX_ALIGNS, FLEX_DIRECTIONS, FLEX_JUSTIFY_CONTENTS, FLEX_WRAPS } from './constants'; import { Flex } from './Flex'; describe('Flex', () => { - it('flex 컴포넌트는 props를 전달하지 않으면 flex의 기본값으로 설정된다.', () => { + it('uses the default flex styles when no props are provided', () => { render(
item 1
@@ -26,24 +27,17 @@ describe('Flex', () => { }); }); - it('flex 컴포넌트에 className을 주입하면 추가로 전달한다.', () => { + it('passes through a custom className', () => { const customClassName = faker.word.noun(); render(); expect(screen.getByTestId('flex-container')).toHaveClass(customClassName); }); - describe('flex 속성', () => { + describe('flex props', () => { describe('justify', () => { - it.each([ - { justifyContent: 'flex-start' }, - { justifyContent: 'flex-end' }, - { justifyContent: 'center' }, - { justifyContent: 'space-between' }, - { justifyContent: 'space-around' }, - { justifyContent: 'space-evenly' }, - ] satisfies Array<{ - justifyContent: CSSProperties['justifyContent']; - }>)('flex의 justify prop에 $justifyContent 속성을 주입하면 해당 속성을 적용한다.', ({ justifyContent }) => { + it.each( + FLEX_JUSTIFY_CONTENTS.map((justifyContent) => ({ justifyContent })), + )('applies justifyContent: $justifyContent when justify is provided', ({ justifyContent }) => { render(
item 1
@@ -57,15 +51,9 @@ describe('Flex', () => { }); describe('align', () => { - it.each([ - { alignItems: 'flex-start' }, - { alignItems: 'flex-end' }, - { alignItems: 'center' }, - { alignItems: 'baseline' }, - { alignItems: 'stretch' }, - ] satisfies Array<{ - alignItems: CSSProperties['alignItems']; - }>)('flex의 align prop에 $alignItems 속성을 주입하면 해당 속성을 적용한다.', ({ alignItems }) => { + it.each( + FLEX_ALIGNS.map((alignItems) => ({ alignItems })), + )('applies alignItems: $alignItems when align is provided', ({ alignItems }) => { render(
item 1
@@ -79,9 +67,7 @@ describe('Flex', () => { }); describe('wrap', () => { - it.each([{ wrap: 'wrap' }, { wrap: 'nowrap' }, { wrap: 'wrap-reverse' }] satisfies Array<{ - wrap: CSSProperties['flexWrap']; - }>)('flex의 wrap prop에 $wrap 속성을 주입하면 해당 속성을 적용한다.', ({ wrap }) => { + it.each(FLEX_WRAPS.map((wrap) => ({ wrap })))('applies flexWrap: $wrap when wrap is provided', ({ wrap }) => { render(
item 1
@@ -95,15 +81,9 @@ describe('Flex', () => { }); describe('direction', () => { - it.each([ - { direction: 'row' }, - { direction: 'column' }, - { direction: 'row-reverse' }, - { direction: 'column-reverse' }, - { direction: 'column-reverse' }, - ] satisfies Array<{ - direction: CSSProperties['flexDirection']; - }>)('flex의 direction prop에 $direction 속성을 주입하면 해당 속성을 적용한다.', ({ direction }) => { + it.each( + FLEX_DIRECTIONS.map((direction) => ({ direction })), + )('applies flexDirection: $direction when direction is provided', ({ direction }) => { render(
item 1
@@ -114,89 +94,89 @@ describe('Flex', () => { const flexContainer = screen.getByTestId('flex-container'); expect(flexContainer).toHaveStyle({ flexDirection: direction }); }); + }); + + describe('basis', () => { + it.each([ + { basis: '100px' }, + { basis: '100%' }, + { basis: 'auto' }, + { basis: '10rem' }, + { basis: 'content' }, + ] satisfies Array<{ + basis: CSSProperties['flexBasis']; + }>)('applies flexBasis: $basis when basis is provided', ({ basis }) => { + render( + +
item 1
+
item 2
+
, + ); - describe('basis', () => { - it.each([ - { basis: '100px' }, - { basis: '100%' }, - { basis: 'auto' }, - { basis: '10rem' }, - { basis: 'content' }, - ] satisfies Array<{ - basis: CSSProperties['flexBasis']; - }>)('flex의 basis prop에 $basis 속성을 주입하면 해당 속성을 적용한다.', ({ basis }) => { - render( - -
item 1
-
item 2
-
, - ); - - const flexContainer = screen.getByTestId('flex-container'); - expect(flexContainer).toHaveStyle({ flexBasis: basis }); - }); + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ flexBasis: basis }); }); + }); + + describe('grow', () => { + it.each([{ grow: 0 }, { grow: 1 }, { grow: 2 }] satisfies Array<{ + grow: CSSProperties['flexGrow']; + }>)('applies flexGrow: $grow when grow is provided', ({ grow }) => { + render( + +
item 1
+
item 2
+
, + ); - describe('grow', () => { - it.each([{ grow: 0 }, { grow: 1 }, { grow: 2 }] satisfies Array<{ - grow: CSSProperties['flexGrow']; - }>)('flex의 grow prop에 $grow 속성을 주입하면 해당 속성을 적용한다.', ({ grow }) => { - render( - -
item 1
-
item 2
-
, - ); - - const flexContainer = screen.getByTestId('flex-container'); - expect(flexContainer).toHaveStyle({ flexGrow: grow }); - }); + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ flexGrow: grow }); }); + }); + + describe('shrink', () => { + it.each([{ shrink: 0 }, { shrink: 1 }, { shrink: 2 }] satisfies Array<{ + shrink: CSSProperties['flexShrink']; + }>)('applies flexShrink: $shrink when shrink is provided', ({ shrink }) => { + render( + +
item 1
+
item 2
+
, + ); - describe('shrink', () => { - it.each([{ shrink: 0 }, { shrink: 1 }, { shrink: 2 }] satisfies Array<{ - shrink: CSSProperties['flexShrink']; - }>)('flex의 shrink prop에 $shrink 속성을 주입하면 해당 속성을 적용한다.', ({ shrink }) => { - render( - -
item 1
-
item 2
-
, - ); - - const flexContainer = screen.getByTestId('flex-container'); - expect(flexContainer).toHaveStyle({ flexShrink: shrink }); - }); + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ flexShrink: shrink }); }); + }); + + describe('inline', () => { + it('renders with inline-flex display when inline is true', () => { + render( + +
item 1
+
item 2
+
, + ); - describe('inline', () => { - it('flex의 inline prop에 true 속성을 주입하면 해당 속성을 적용한다.', () => { - render( - -
item 1
-
item 2
-
, - ); - - const flexContainer = screen.getByTestId('flex-container'); - expect(flexContainer).toHaveStyle({ display: 'inline-flex' }); - }); + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ display: 'inline-flex' }); }); + }); + + describe('gap', () => { + it.each([{ gap: '10px' }, { gap: '1rem' }] satisfies Array<{ + gap: CSSProperties['gap']; + }>)('applies gap: $gap when gap is provided', ({ gap }) => { + render( + +
item 1
+
item 2
+
, + ); - describe('gap', () => { - it.each([{ gap: '10px' }, { gap: '1rem' }] satisfies Array<{ - gap: CSSProperties['gap']; - }>)('flex의 gap prop에 $gap 속성을 주입하면 해당 속성을 적용한다.', ({ gap }) => { - render( - -
item 1
-
item 2
-
, - ); - - const flexContainer = screen.getByTestId('flex-container'); - expect(flexContainer).toHaveStyle({ gap }); - }); + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ gap }); }); }); }); @@ -207,9 +187,7 @@ describe('Flex', () => { { style: { alignItems: 'center' } }, { style: { flexWrap: 'wrap' } }, { style: { flexDirection: 'column' } }, - ] satisfies Array<{ style: CSSProperties }>)('flex의 style prop에 $style 속성을 주입하면 해당 속성을 적용한다.', ({ - style, - }) => { + ] satisfies Array<{ style: CSSProperties }>)('applies style overrides from the style prop: $style', ({ style }) => { render(
item 1
@@ -220,6 +198,56 @@ describe('Flex', () => { const flexContainer = screen.getByTestId('flex-container'); expect(flexContainer).toHaveStyle(style); }); + + it('allows the style prop to override inline style values for the same CSS properties', () => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ + flexBasis: '50%', + gap: '16px', + }); + }); + }); + + describe('consumer usage patterns', () => { + it('preserves the expected layout styles when direction, align, and gap are combined', () => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '12px', + }); + }); + + it('preserves the expected layout styles when inline, justify, and gap are combined', () => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ + display: 'inline-flex', + justifyContent: 'center', + gap: '8px', + }); + }); }); describe('polymorphic', () => { @@ -230,7 +258,7 @@ describe('Flex', () => { 'input', 'label', 'div', - ])('flex의 asChild prop에 true 속성을 주입하면 자식으로 %s 엘리먼트가 전달되면 해당 엘리먼트의 태그로 렌더링된다', (element) => { + ])('renders as the child %s element when asChild is true', (element) => { render( {createElement(element)} @@ -241,5 +269,44 @@ describe('Flex', () => { expect(flexContainer).toBeInTheDocument(); expect(flexContainer.tagName.toLowerCase()).toBe(element); }); + + it('passes className, style, and rest props to the child element when asChild is used', () => { + render( + +