docs: audit frontend platform capabilities

This commit is contained in:
donghyeon-ka
2026-07-26 02:09:06 +09:00
parent cb195f8773
commit 68342e25ce
13 changed files with 5934 additions and 10 deletions
+868
View File
@@ -0,0 +1,868 @@
# 디자인 시스템 플랫폼 계약
이 문서는 도메인 기능을 추가하기 전에 프론트엔드 스켈레톤이 제공해야 하는
디자인 시스템의 소유권, 계층, 기본 구성요소, 외부 라이브러리 경계, 검증 방법을
정의한다. 목표는 특정 제품의 시각 언어를 미리 결정하는 것이 아니라, 제품 팀이
접근성·반응형·국제화·테스트 규칙을 다시 발명하지 않고 기능 화면을 만들 수 있게
하는 것이다.
이 문서에서 `필수`는 모든 제품이 기반으로 사용할 계약을 뜻한다. `선택`
컴포넌트의 경계와 도입 기준은 제공하지만 실제 의존성이나 구현은 제품 요구가
생긴 뒤 추가해도 되는 항목을 뜻한다.
## 1. 현재 기준선과 확인된 공백
현재 저장소에는 다음 기반이 이미 있다.
- `src/presentation/styles/theme.css`
- 의미 기반 light/dark 색상 토큰
- focus indicator
- 반응형 앱 셸
- reduced-motion 처리
- `src/presentation/providers/theme-provider.jsx`
- `system`, `light`, `dark` 선호도
- 저장소 포트를 통한 선호도 영속화
- 운영체제 색상 변경 구독
- `src/presentation/components/ui/`
- `Button`
- `TextField`
- `Card`
- `Alert`
- `Badge`
- `Dialog`
- `src/presentation/components/async-surface.jsx`
- 초기 로딩, 빈 화면, terminal error, background 상태
- `src/presentation/components/state-surfaces.jsx`
- 인증 필요, 권한 없음, 찾을 수 없음
- `/examples/ui`, `/examples/states`
- 실행 가능한 primitive와 상태 예제
- component/E2E/axe 테스트
- 필드 설명과 오류 연결
- native dialog 닫기와 trigger focus 복원
- 320px reflow
- Chromium, Firefox, WebKit
- 등록 라우트의 자동 접근성 검사
이 기반은 유효하지만 아직 디자인 시스템 플랫폼 전체는 아니다.
1. 토큰이 색상 중심이며 typography, elevation, motion, z-layer, control size,
breakpoint가 계약으로 닫혀 있지 않다.
2. 공통 입력은 `TextField` 하나뿐이어서 일반적인 폼을 공통 규칙으로 만들 수
없다.
3. 앱 셸과 예제 화면에서 공용 primitive 대신 raw `button`, `select`, 링크
class를 다시 작성하는 곳이 있다.
4. `☰`, `×` 같은 문자 glyph를 직접 사용하며 아이콘 공급자 경계가 없다.
5. `theme.css` 하나가 token, layout, primitive, pattern 스타일을 모두 소유한다.
6. runtime gallery는 있지만 격리된 story, interaction story, 시각 회귀 기준선이
없다.
7. 사용자 문구가 한국어 literal로 고정되어 locale과 RTL 계약이 없다.
8. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage` 같은 공통 페이지
템플릿이 없다.
9. 모바일 sidebar는 표시 전환은 되지만 modal drawer 수준의 focus 이동, focus
복원, 배경 비활성화 계약은 없다.
따라서 기존 구성요소는 폐기하지 않고 아래 목표 계층으로 이동·확장한다.
## 2. 소유권과 의존성 원칙
디자인 시스템은 `presentation` 계층이 소유한다. 색상, 아이콘, 키보드 상호작용,
포커스, 화면 배치와 같은 문제는 도메인 규칙이 아니다.
```text
product page
-> template
-> pattern
-> primitive
-> token
```
외부 UI 라이브러리를 사용하는 경우 흐름은 다음과 같다.
```text
product page
-> local design-system API
-> local vendor facade
-> Lucide / React Aria / Radix
```
다음 규칙은 필수다.
- 제품 페이지는 `lucide-react`, `react-aria-components`, `@radix-ui/*`를 직접
import하지 않는다.
- 외부 UI 라이브러리 type을 공용 컴포넌트 API로 그대로 노출하지 않는다.
- 도메인과 application 계층은 React component, CSS class, icon name을 알지
않는다.
- primitive는 API 요청, query cache, 인증 상태와 같은 외부 상태를 직접 읽지
않는다.
- pattern과 template도 application use case를 직접 선택하지 않는다. 필요한
상태와 command callback을 props/slot으로 받는다.
- 제품별 색상이나 명칭을 primitive 내부에 하드코딩하지 않는다.
- 같은 의미의 접근성·키보드 동작을 페이지마다 다시 구현하지 않는다.
- 세 번째 사용 사례가 확인되기 전에는 제품 전용 조합을 무리하게 primitive로
승격하지 않는다.
아이콘과 headless UI는 React inbound adapter의 vendor facade로 충분하다.
이들을 위한 application port를 만들지 않는다. HTTP, storage, telemetry처럼
런타임 외부 자원을 교체하는 capability와 UI 구현 라이브러리를 구분한다.
## 3. 목표 디렉터리
TypeScript 전환 이후의 목표 구조는 다음과 같다. 마이그레이션 중에는 기존
경로에서 같은 소유권 규칙을 지키고, 한 번에 전체 경로를 이동하지 않아도 된다.
```text
src/adapters/inbound/react/design-system/
index.ts
tokens/
primitive.css
semantic.css
component.css
token-contract.ts
primitives/
button/
field/
checkbox/
dialog/
...
patterns/
async-surface/
form/
data-table/
confirmation/
...
templates/
standard-page/
collection-page/
detail-page/
form-page/
status-page/
icons/
icon.tsx
icon-button.tsx
semantic-icons.tsx
vendors/
lucide.tsx
vendors/
react-aria/
testing/
story-decorators.tsx
render-design-system.tsx
```
`index.ts`는 제품의 React inbound 코드가 사용할 public API다. 내부 파일 deep
import는 디자인 시스템 자체와 테스트에만 허용한다. 이 경계는 ESLint의
`no-restricted-imports`로 검사한다.
현재 `src/presentation`은 React inbound adapter 역할을 한다. TypeScript
마이그레이션 동안 기존 경로를 유지할 수 있지만, 최종 canonical target은 위
경로다. 같은 컴포넌트를 `presentation``adapters/inbound/react` 양쪽에
복제하지 않고 feature 단위로 이동한다.
## 4. 계층 계약
### 4.1 Tokens
토큰은 시각적 결정을 이름으로 표현한다. 토큰은 세 계층으로 관리한다.
```text
primitive token -> semantic token -> component token
```
예:
```css
--palette-blue-600: ...;
--color-action: var(--palette-blue-600);
--button-primary-background: var(--color-action);
```
#### Primitive tokens
원시 palette와 scale이다. 제품 코드에서 직접 소비하지 않는다.
- palette
- spacing scale
- font size와 line height scale
- radius scale
- shadow scale
- duration과 easing scale
- fixed size scale
#### Semantic tokens
제품 코드와 대부분의 primitive가 소비하는 이름이다.
- `surface`, `surface-muted`, `surface-elevated`
- `content`, `content-muted`, `content-inverse`
- `border`, `border-strong`
- `action`, `action-hover`, `action-pressed`
- `danger`, `warning`, `success`, `info`
- `focus-ring`
- `disabled-content`, `disabled-surface`
#### Component tokens
특정 primitive가 여러 semantic token을 조합할 때만 사용한다.
- `button-primary-background`
- `field-border-invalid`
- `dialog-elevation`
- `navigation-active-background`
컴포넌트 토큰은 제품별 variant를 만들기 위한 우회 경로가 아니다. 두 개 이상의
컴포넌트가 같은 의미를 공유한다면 semantic token으로 승격한다.
#### 필수 토큰 범주
| 범주 | 필수 내용 |
| --- | --- |
| Color | surface/content/border/action/status/focus, light/dark |
| Typography | family, size, line-height, weight, letter-spacing |
| Spacing | inset, inline, stack, section, page spacing scale |
| Size | control height, icon size, touch target, container width |
| Border | width, style, semantic border |
| Radius | control, surface, modal, full |
| Elevation | panel, popover, dialog, sticky shell |
| Z-layer | base, sticky, navigation, popover, modal, toast |
| Motion | fast/normal/slow duration, standard/emphasized easing |
| Breakpoint | compact, medium, wide와 container 계약 |
| Opacity | disabled, scrim, skeleton |
토큰의 완료 조건은 다음과 같다.
- light와 dark에서 모든 semantic token이 정의된다.
- `forced-colors`에서도 focus와 control 경계가 사라지지 않는다.
- 상태는 색상만으로 구분하지 않는다.
- 같은 raw value가 반복되면 named token으로 승격한다.
- 사용자 입력으로 CSS class나 CSS variable 이름을 조립하지 않는다.
- token contract test가 필수 token의 누락을 차단한다.
- chart나 canvas처럼 JavaScript 값이 필요한 경우에만 typed token accessor를
제공한다.
## 5. 기본 컴포넌트 카탈로그
### 5.1 Primitives
Primitive는 하나의 접근 가능한 상호작용 또는 작은 시각 단위를 제공한다.
| 그룹 | 기본 제공 | 우선순위 | 핵심 계약 |
| --- | --- | --- | --- |
| Action | `Button` | 필수 | intent, size, disabled, pending, native semantics |
| Action | `LinkButton` | 필수 | navigation은 anchor/router link semantics 유지 |
| Action | `IconButton` | 필수 | accessible name 필수, 44px 권장 target |
| Form | `Field` | 필수 | label, description, error ID 조립 |
| Form | `TextField` | 필수 | text/email/password/search/autocomplete |
| Form | `TextArea` | 필수 | resize와 글자 수 안내 |
| Form | `Select` | 필수 | native 우선, 복합 선택은 headless 구현 |
| Form | `Checkbox` | 필수 | checked/indeterminate |
| Form | `RadioGroup` | 필수 | arrow-key와 group label |
| Form | `Switch` | 필수 | boolean setting 전용 |
| Form | `SearchField` | 필수 | clear action, submit semantics |
| Feedback | `Alert` | 필수 | inline feedback와 live-region 정책 분리 |
| Feedback | `Badge` | 필수 | color-only 금지 |
| Feedback | `Spinner` | 필수 | accessible label 또는 decorative |
| Feedback | `ProgressBar` | 필수 | determinate/indeterminate |
| Feedback | `Skeleton` | 필수 | 실제 layout과 유사한 크기, reduced motion |
| Feedback | `Toast` | 필수 | queue, 중복 방지, timeout pause |
| Surface | `Card` | 필수 | heading level 강제 금지, label 선택 가능 |
| Surface | `Separator` | 필수 | decorative/semantic 구분 |
| Overlay | `Dialog` | 필수 | modal semantics, focus trap/restore, Escape |
| Overlay | `Drawer` | 필수 | compact navigation과 side sheet |
| Overlay | `Popover` | 필수 | anchor, dismiss, collision |
| Overlay | `Tooltip` | 필수 | hover와 keyboard, 중요한 정보 단독 보유 금지 |
| Overlay | `Menu` | 필수 | roving focus, typeahead, Escape |
| Navigation | `Breadcrumbs` | 필수 | 현재 위치와 overflow |
| Navigation | `Tabs` | 필수 | manual/automatic activation 정책 |
| Navigation | `Pagination` | 필수 | current page, previous/next label |
| Utility | `VisuallyHidden` | 필수 | screen-reader-only content |
| Utility | `Portal` | 필수 | overlay root와 SSR-safe fallback |
| Utility | `FocusRing` | 필수 | input modality 인식 |
| Advanced | date/time picker | 선택 | locale/time zone 요구가 있을 때 |
| Advanced | file upload/dropzone | 선택 | upload adapter 요구가 있을 때 |
| Advanced | virtualizer/tree | 선택 | 실제 데이터 규모가 입증될 때 |
Primitive API는 다음 규칙을 지킨다.
- 기본 HTML semantics를 보존한다.
- `div onClick`로 button이나 link를 흉내 내지 않는다.
- `variant`, `size`, `tone`은 closed union과 정적 class map을 사용한다.
- pending은 focus를 잃게 하는 무조건적인 `disabled`와 구분한다.
- controlled와 uncontrolled 지원 여부를 문서화한다.
- ref 전달과 focus contract를 테스트한다.
- `className`은 escape hatch이지 public variant를 대체하지 않는다.
- 임의의 polymorphic `as`보다 `Button``LinkButton`처럼 semantics가 명확한
API를 우선한다.
- visual-only prop이 도메인 의미를 표현하지 않도록 한다.
### 5.2 Patterns
Pattern은 여러 primitive를 조합해 반복되는 사용자 문제를 해결한다.
| Pattern | 필수 내용 |
| --- | --- |
| `AsyncSurface` | loading, success, empty, terminal error, stale, refresh |
| `AccessSurface` | auth required, forbidden, not found |
| `Form` | submit, error summary, first-invalid focus, pending |
| `ConfirmationDialog` | destructive action 설명과 명시적 확인 |
| `ToastRegion` | queue, announcement, dismiss, focus policy |
| `SearchFilterToolbar` | search, filter, reset, result count |
| `DataTable` | caption, sorting, selection, responsive fallback |
| `PaginationBar` | result range와 page navigation |
| `NavigationDrawer` | mobile focus scope와 background inert |
| `DisclosureGroup` | help/settings section |
Pattern은 application failure object 전체를 렌더링하지 않는다. 안전한 message key,
사용자 action, 표시 가능한 metadata만 받는다.
### 5.3 Templates
Template은 페이지 레이아웃과 상태 slot을 제공한다. API 호출과 도메인 use case는
소유하지 않는다.
#### `StandardPageTemplate`
- breadcrumb 또는 back navigation
- eyebrow
- `h1`
- description
- status/metadata
- primary/secondary actions
- main content와 aside slot
#### `CollectionPageTemplate`
- page header
- search/filter/sort toolbar
- result count
- loading/empty/error slot
- table/list/card-grid slot
- pagination slot
- compact viewport에서의 대체 배치
#### `DetailPageTemplate`
- breadcrumb/back
- title과 entity status
- page actions
- description list/section slot
- loading/not-found/forbidden/error 상태
#### `FormPageTemplate`
- heading과 설명
- error summary
- field section
- sticky 또는 inline action bar
- submit/cancel
- submitting, success, conflict
- unsaved-change navigation blocker 연결 지점
#### `StatusPageTemplate`
- 401, 403, 404, 500, offline, maintenance
- safe description
- primary recovery action
- optional support reference
- raw endpoint, stack, token 노출 금지
Template의 heading 순서는 slot 소비자가 임의로 깨뜨리지 못하게 예제와 테스트로
고정한다. 다만 `Card` 같은 하위 primitive가 무조건 `h3`를 생성해서도 안 된다.
## 6. Lucide 아이콘 facade
[Lucide for React 공식 문서](https://lucide.dev/guide/react)는 각 아이콘을 독립
React component로 제공하며 명시적으로 import한 아이콘만 번들에 포함될 수 있는
tree-shaking 구조를 제공한다. 기본 아이콘 공급자로 사용하기에 적합하지만 제품
코드가 공급자 API에 결합되면 안 된다.
### 6.1 필수 규칙
1. `lucide-react` import는 `icons/vendors/lucide.tsx`에서만 허용한다.
2. 제품 코드에는 Lucide component type, icon name, stroke prop을 노출하지 않는다.
3. 디자인 시스템이 승인한 작은 아이콘 집합만 명시적으로 import한다.
4. 모든 아이콘을 이름으로 동적 import하는 범용 `DynamicIcon`은 기본 제공하지
않는다.
5. 아이콘 색은 기본적으로 `currentColor`를 사용한다.
6. 크기와 stroke는 `--icon-size-*`, `--icon-stroke-*` 토큰을 사용한다.
7. 장식 아이콘은 기본적으로 `aria-hidden="true"``focusable="false"`다.
8. 정보를 단독으로 전달하는 아이콘은 local `Icon` API의 `label`을 통해
`role="img"`와 accessible name을 가진다.
9. 버튼 안의 아이콘은 장식으로 처리하고 버튼 자체에 visible label 또는
`aria-label`을 둔다.
10. 상태를 아이콘이나 색상 하나만으로 전달하지 않는다.
### 6.2 목표 API 예시
아래 예시는 방향을 설명한다. 실제 type과 경로는 TypeScript 전환 작업에서
확정한다.
```tsx
import { MenuIcon } from "@/presentation/design-system/icons";
import { IconButton } from "@/presentation/design-system";
<IconButton accessibleName="메뉴 열기" onPress={openNavigation}>
<MenuIcon />
</IconButton>
```
vendor 파일은 의미 이름과 공급자 이름을 분리한다.
```tsx
import {
Menu as LucideMenu,
X as LucideClose,
AlertTriangle as LucideWarning,
} from "lucide-react";
export const MenuGlyph = LucideMenu;
export const CloseGlyph = LucideClose;
export const WarningGlyph = LucideWarning;
```
`MenuGlyph` 같은 vendor export는 `semantic-icons.tsx` 밖으로 다시 노출하지
않는다. 제품 코드는 `MenuIcon`, `CloseIcon`, `WarningIcon`만 사용한다.
### 6.3 아이콘 추가 완료 조건
- 기존 semantic icon으로 표현할 수 없는지 먼저 확인했다.
- 서로 다른 제품 기능이 같은 의미 아이콘을 공유한다.
- accessible name과 decorative 여부를 결정했다.
- light/dark/high-contrast에서 보인다.
- 200% zoom과 compact viewport에서 잘리지 않는다.
- 신규 import 후 bundle budget을 통과한다.
- 공급자 교체 시 제품 코드 수정이 필요하지 않다.
## 7. Headless UI 선택 기준
복잡한 overlay, collection, focus management를 직접 구현하기 전에 native
platform과 검증된 headless UI를 평가한다.
- [React Aria 공식 시작 문서](https://react-aria.adobe.com/getting-started)
- [Radix Primitives 공식 소개](https://www.radix-ui.com/primitives/docs/overview/introduction)
### 7.1 선택 순서
1. native HTML만으로 요구 semantics와 모든 지원 브라우저 동작을 충족하는지
확인한다.
2. native 구현이 불충분하면 React Aria와 Radix를 평가한다.
3. 한 프로젝트에서 같은 문제를 해결하는 headless 공급자를 둘 이상 기본값으로
사용하지 않는다.
4. 선택 결과와 버전·라이선스·bundle 영향을 ADR에 기록한다.
5. 공급자 component를 local primitive로 감싼 뒤에만 제품 코드에 노출한다.
### 7.2 평가표
| 기준 | 확인 질문 |
| --- | --- |
| Semantics | WAI-ARIA pattern과 native semantics를 올바르게 사용하는가 |
| Keyboard | roving focus, typeahead, Escape, arrow key가 완전한가 |
| Focus | trap, restore, initial focus, nested overlay가 안전한가 |
| Input modality | mouse, touch, keyboard, screen reader가 동일하게 동작하는가 |
| i18n | RTL, locale number/date, IME를 지원하는가 |
| Styling | semantic token과 Tailwind/CSS layer에 결합 가능한가 |
| Bundle | component 단위 import와 tree shaking이 가능한가 |
| React/Vite | 현재 React와 Vite 조합을 공식 지원하는가 |
| SSR | future SSR profile에서 hydration ID가 안정적인가 |
| Testing | 접근성·상호작용 테스트 자료와 utility가 있는가 |
| Maintenance | release cadence, security 대응, license가 허용 가능한가 |
현재 스켈레톤의 접근성·국제화 목표에는 React Aria Components가 우선 후보다.
Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다. 최종 선택은
실제 prototype으로 `Select`, `Menu`, `Drawer` 세 가지를 구현해 다음을 비교한 뒤
확정한다.
- keyboard matrix
- screen reader announcement
- RTL
- mobile touch
- dark/high-contrast
- gzip 증가량
- local API로 감쌀 때 필요한 코드량
공급자를 선택하더라도 `SelectProps = AriaSelectProps`처럼 vendor type alias를
그대로 공용 API로 만들지 않는다. 제품이 실제로 지원하는 의미만 local prop으로
닫는다.
## 8. 접근성 계약
목표는 WCAG 2.2 AA에 맞춘 기본 동작이다. 자동 도구 통과는 적합성 선언이 아니며,
키보드와 보조기술 검토를 함께 수행한다.
### 8.1 모든 컴포넌트의 공통 기준
- 올바른 native element와 role을 사용한다.
- visible label 또는 accessible name이 있다.
- keyboard만으로 모든 action을 실행할 수 있다.
- focus 순서가 DOM과 시각 순서에 맞는다.
- focus indicator를 제거하지 않는다.
- disabled와 readonly를 구분한다.
- 상태는 색상만으로 전달하지 않는다.
- 오류는 해당 control과 programmatically 연결한다.
- 필요한 변경만 live region으로 한 번 알린다.
- `prefers-reduced-motion`에서 불필요한 motion을 제거한다.
- 200% text zoom과 400% page zoom에서 정보가 손실되지 않는다.
- 320 CSS px reflow에서 양방향 scroll을 강요하지 않는다.
- touch target은 기본 44x44 CSS px를 목표로 한다.
- forced-colors에서 border, focus, selected state가 사라지지 않는다.
- RTL에서 logical direction과 key behavior를 검증한다.
- 한글·일본어·중국어 IME composition 중 입력을 조기에 검증하거나 제출하지
않는다.
### 8.2 Overlay 기준
- 열기 trigger를 기록한다.
- 열릴 때 의미 있는 initial focus를 둔다.
- modal일 때 focus가 내부를 벗어나지 않는다.
- 배경 content를 `inert` 또는 동등한 방식으로 비활성화한다.
- Escape와 명시적 닫기 action을 제공한다.
- 닫은 뒤 trigger 또는 안전한 대체 위치로 focus를 복원한다.
- nested overlay와 route transition 중 orphan portal을 남기지 않는다.
### 8.3 Form 기준
- label, description, error가 동일한 field ID 체계로 연결된다.
- required는 시각적 기호와 programmatic 상태를 함께 가진다.
- submit 실패 시 error summary로 focus를 이동한다.
- error summary 항목은 해당 field로 이동한다.
- async validation은 현재 입력보다 오래된 결과를 폐기한다.
- pending 상태를 screen reader에 알리되 같은 문구를 반복하지 않는다.
- 서버 오류의 raw body, stack, 내부 field path를 출력하지 않는다.
## 9. 국제화 계약
디자인 시스템 primitive는 한국어 문구를 내부 기본값으로 숨기지 않는다.
접근성 label이나 오류 문구가 필요하면 명시적인 prop 또는 message key를 받는다.
필수 국제화 기반:
- `LocaleProvider`
- typed message key
- fallback locale
- `<html lang>``dir` 갱신
- `Intl.DateTimeFormat`
- `Intl.NumberFormat`
- `Intl.ListFormat`
- plural/select message
- locale별 validation message
- pseudo-locale
- RTL story와 E2E smoke
금지 패턴:
- 번역 문장 중간에 JSX 문자열을 연결한다.
- 날짜를 `substring`이나 고정 구분자로 조립한다.
- icon direction을 locale 확인 없이 좌/우 이름으로 고정한다.
- route title, navigation label, toast message를 JSX literal로 분산한다.
- 번역 누락 시 빈 문자열을 렌더링한다.
Storybook toolbar에서 최소한 다음 조합을 전환할 수 있어야 한다.
- `ko-KR`, light
- `en-US`, light
- `en-US`, dark
- pseudo-locale
- 대표 RTL locale
## 10. Storybook과 시각 검증
현재 `/examples/ui`는 실행 중인 앱 셸과 primitive 통합을 확인하는 데 유지한다.
그러나 runtime gallery는 isolated component workshop을 대체하지 않는다.
[Storybook 공식 UI 테스트 문서](https://storybook.js.org/docs/writing-tests)는
story를 기반으로 interaction, accessibility, visual test를 구성하는 방법을
제공한다.
### 10.1 Storybook 필수 구성
- Vite 기반 Storybook
- TypeScript CSF story
- global theme toolbar
- locale/RTL toolbar
- compact/wide viewport
- Router, Theme, Locale, Session, Query decorator
- `@storybook/addon-a11y`
- a11y 결과를 CI에서 error로 처리
- interaction `play` test
- story build gate
### 10.2 각 컴포넌트의 필수 story
- default
- 모든 semantic variant
- disabled와 readonly
- pending/loading
- 오류
- 긴 문구
- 빈 값
- light/dark
- compact viewport
- keyboard interaction
- 해당되는 경우 open/closed, controlled/uncontrolled
Form과 overlay는 다음 story를 추가한다.
- submit 실패와 error summary
- async pending
- server field error
- Escape close
- outside interaction
- initial focus
- focus restoration
- nested content와 long content
### 10.3 시각 회귀
기본 시각 회귀는 Playwright `toHaveScreenshot()`을 사용해 저장소 안에서 실행할
수 있게 한다. Chromatic 같은 외부 서비스는 선택 사항이다.
필수 안정화:
- 동일한 Linux image, browser version, font
- animation과 caret 비활성화
- 시간·랜덤·network 응답 고정
- 동적 ID와 민감 데이터 mask
- light/dark baseline
- compact/wide baseline
- 변경된 baseline은 코드 리뷰 대상
failure screenshot은 시각 회귀가 아니다. 의도된 baseline과 실제 렌더링을
비교하는 assertion이 있어야 한다.
### 10.4 테스트 계층
| 계층 | 검증 대상 |
| --- | --- |
| Type check | prop union, ref, event와 slot type |
| Unit | token/variant map, pure formatter와 state reducer |
| Component | native semantics, keyboard, focus, callback |
| Story interaction | 실제 browser의 isolated behavior |
| Story a11y | 모든 주요 state의 axe |
| Visual | pixel/layout/theme regression |
| E2E | 앱 셸, route, form/query integration |
| Manual | screen reader, zoom, touch, forced-colors |
DOM snapshot은 보이는 UI를 보장하지 않으므로 public markup contract가 꼭 필요한
경우에만 사용한다.
## 11. 컴포넌트 추가 Recipe
새 component를 추가할 때 다음 순서를 생략하지 않는다.
### Step 1. 문제와 계층을 결정한다
다음 질문을 issue 또는 설계 메모에 기록한다.
- 단일 control인가, 반복되는 조합인가, 페이지 구조인가?
- primitive, pattern, template 중 어디에 속하는가?
- 기존 component의 variant로 해결할 수 있는가?
- 제품 전용 의미를 공통 디자인 시스템으로 잘못 올리는 것은 아닌가?
- native HTML로 충분한가?
### Step 2. 상태 행렬을 작성한다
최소 상태:
```text
default
hover
focus-visible
active/pressed
disabled
pending
invalid
light
dark
compact
long-content
```
선택/overlay component는 open, selected, indeterminate, empty, loading도 포함한다.
### Step 3. 접근성 명세를 작성한다
- element/role
- accessible name
- keyboard table
- focus entry/exit/restore
- announcement
- error association
- reduced motion
- touch와 screen reader
명세가 불명확하면 구현보다 먼저 native, React Aria, Radix prototype으로 검증한다.
### Step 4. API를 닫는다
- TypeScript public props를 정의한다.
- semantic `variant`를 사용하고 raw color prop을 노출하지 않는다.
- callback 이름과 payload를 domain-neutral하게 정한다.
- controlled/uncontrolled 정책을 정한다.
- `className` escape hatch의 범위를 정한다.
- vendor prop과 type을 public API에 노출하지 않는다.
### Step 5. 필요한 토큰을 추가한다
- 기존 semantic token으로 표현 가능한지 확인한다.
- raw 값 반복을 추가하지 않는다.
- light/dark/forced-colors 값을 함께 정의한다.
- token contract test를 갱신한다.
### Step 6. 구현한다
- native semantics 우선
- local vendor facade만 import
- static variant map
- ref/focus 지원
- DOM 구조 최소화
- render 중 전역 상태 변경 금지
- 제품 API나 application service import 금지
### Step 7. Story를 작성한다
- 상태 행렬을 모두 story로 표현한다.
- interaction test를 작성한다.
- dark, compact, long-content를 포함한다.
- a11y test를 error mode로 실행한다.
### Step 8. 자동 테스트를 작성한다
- role/name 중심 query
- keyboard와 focus
- disabled/pending
- callback
- controlled state
- 오류와 live region
- 필요한 visual baseline
구현 class나 내부 DOM 순서만 확인하는 brittle test는 피한다.
### Step 9. 통합 예제를 추가한다
- `/examples/ui`에는 primitive를 추가한다.
- `/examples/states`에는 새로운 공통 상태를 추가한다.
- pattern/template은 domain-neutral fixture로 실제 조합을 보여 준다.
- production profile에서 예제 route를 제외할 수 있는 계약을 유지한다.
### Step 10. 문서와 public export를 갱신한다
- 사용 목적
- 사용하지 말아야 할 경우
- props와 variant
- keyboard
- 접근성 책임
- i18n
- 예제
- public `index.ts` export
- 변경 로그
### Step 11. 게이트를 실행한다
최소 실행 범위:
```bash
corepack pnpm check:types
corepack pnpm lint
corepack pnpm check:architecture
corepack pnpm test:component
corepack pnpm test:e2e
corepack pnpm test:a11y
corepack pnpm build
corepack pnpm check:bundle
```
Storybook과 visual gate가 도입된 뒤에는 story build, interaction, story a11y,
visual comparison도 필수로 추가한다.
## 12. Definition of Done
컴포넌트는 아래 항목 중 자신의 state/capability matrix에 해당하는 항목을
충족해야 `완료`다. 적용되지 않는 항목은 체크를 생략하지 말고 `N/A`와 이유를
component contract에 기록한다. 예를 들어 `Separator`에는 pending/error/IME가,
읽기 전용 `Card`에는 controlled state와 field error가 적용되지 않는다.
### API와 아키텍처
- [ ] primitive/pattern/template 소유권이 명확하다.
- [ ] public TypeScript API가 closed union으로 정의되었다.
- [ ] 제품 코드가 vendor package를 직접 import하지 않는다.
- [ ] domain/application dependency가 없다.
- [ ] public barrel을 통해 소비할 수 있다.
- [ ] 상태를 소유하는 경우 controlled/uncontrolled 계약이 문서화되었고, focus
target이 있는 경우 ref 계약이 문서화되었다.
### Styling
- [ ] raw 색상과 반복되는 임의 값이 없다.
- [ ] semantic token을 사용한다.
- [ ] light/dark가 완성되었다.
- [ ] compact/wide reflow가 된다.
- [ ] long text와 번역 확장을 견딘다.
- [ ] forced-colors에서도 의미가 유지된다.
- [ ] bundle budget을 통과한다.
### 접근성
- [ ] native semantics와 accessible name이 있다.
- [ ] keyboard 동작이 명세와 일치한다.
- [ ] focus indicator, 이동, 복원이 올바르다.
- [ ] 상태가 색상에만 의존하지 않는다.
- [ ] 오류나 설명을 제공하는 component는 이들을 programmatically 연결한다.
- [ ] reduced-motion을 존중한다.
- [ ] 200%/400% zoom과 320px reflow를 확인했다.
- [ ] screen reader 수동 검토 항목이 정의되었다.
### 국제화
- [ ] 사용자 문구를 소유하는 component는 이를 내부 literal로 고정하지 않는다.
- [ ] 날짜·숫자·목록을 표시하는 component는 locale formatter를 사용한다.
- [ ] pseudo-locale에서 잘리지 않는다.
- [ ] RTL에서 배치와 키보드가 올바르다.
- [ ] 문자 입력을 받는 component는 IME 입력을 깨뜨리지 않는다.
### 테스트와 문서
- [ ] component behavior test가 있다.
- [ ] 모든 주요 상태 story가 있다.
- [ ] story interaction test가 있다.
- [ ] automated a11y가 통과한다.
- [ ] 필요한 visual baseline이 있다.
- [ ] runtime gallery 또는 pattern fixture가 있다.
- [ ] 사용법, 금지 사례, 접근성 책임이 문서화되었다.
- [ ] 전체 품질·아키텍처·bundle gate가 통과한다.
## 13. 금지 패턴
- 제품 페이지에서 `lucide-react` 직접 import
- 제품 페이지에서 React Aria/Radix 직접 import
- `div`와 keyboard handler로 native button을 재구현
- icon-only button에 accessible name 누락
- 문자열 icon name으로 전체 icon package를 동적 로딩
- 사용자 값으로 Tailwind/CSS class 조립
- raw hex/OKLCH 값을 JSX나 component CSS에 반복
- `as="div"`처럼 semantics를 무제한 변경하는 public API
- primitive에서 API/query/auth/storage를 직접 호출
- 모든 상태를 하나의 전역 store에 저장
- 오류 raw body, URL, header, token, stack 표시
- locale 문장을 문자열 덧셈으로 구성
- axe 통과만으로 접근성 완료 선언
- failure screenshot만으로 시각 회귀 완료 선언
- Storybook story 없이 runtime gallery 하나로 모든 variant를 대표
## 14. 단계별 도입 순서
1. TypeScript public API와 디자인 시스템 public barrel을 만든다.
2. token을 primitive/semantic/component 계층으로 분리한다.
3. 기존 Button, TextField, Card, Alert, Badge, Dialog를 새 계약으로 이동한다.
4. Lucide local facade와 `IconButton`을 추가하고 문자 glyph를 제거한다.
5. Field foundation과 필수 form primitives를 추가한다.
6. React Aria와 Radix prototype을 비교하고 headless ADR을 확정한다.
7. Drawer, Select, Menu, Popover, Tooltip을 local facade로 구현한다.
8. pattern과 page template을 추가한다.
9. locale provider, pseudo-locale, RTL 검증을 추가한다.
10. Storybook, story a11y, interaction test를 추가한다.
11. pinned browser 환경의 visual regression gate를 추가한다.
12. `/examples/ui``/examples/states`를 통합 smoke gallery로 유지한다.
이 순서는 라이브러리 수를 늘리는 것이 목표가 아니다. 각 단계가 제품 개발자가
중복 구현할 문제를 하나씩 제거하고, 외부 공급자를 교체할 수 있는 로컬 계약과
검증 증거를 남기는 것이 목표다.
+7
View File
@@ -39,3 +39,10 @@ Arbitrary-value policy:
The removable sample may demonstrate tokens, but production starter modules do
not import from `src/sample/contract-fixture`.
This file documents the currently implemented token and primitive baseline.
The [design-system platform contract](./design-system-platform.md) defines the
target token layers, component catalog, local Lucide/headless-library
facades, page patterns, accessibility and internationalization rules, isolated
workshop, and component-authoring recipe. Items in that target document are not
treated as implemented until their acceptance tests pass.