@makitt.io/mds-mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -0
- package/dist/data/catalog.json +10567 -0
- package/dist/data/playbook/ai-fill.md +175 -0
- package/dist/data/playbook/anti-patterns.md +188 -0
- package/dist/data/playbook/array-input.md +249 -0
- package/dist/data/playbook/async-states.md +137 -0
- package/dist/data/playbook/data-grid.md +192 -0
- package/dist/data/playbook/feedback.md +238 -0
- package/dist/data/playbook/form.md +259 -0
- package/dist/data/playbook/overlay.md +222 -0
- package/dist/data/playbook/page-layout.md +222 -0
- package/dist/data/playbook/responsive-tokens.md +191 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +329 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# MDS Playbook — AI Fill (ai-drawer 통합)
|
|
2
|
+
|
|
3
|
+
AI 가 form 자동 채우기 (autofill) 의 약속.
|
|
4
|
+
|
|
5
|
+
> **단일 통로 강제**: `useSkillAutofill` + `FormFillApprovalBanner` + `skill-registry`.
|
|
6
|
+
> 도메인별 hook / Banner / Prompt 작성 **금지**.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. AI Fill 의 의도
|
|
11
|
+
|
|
12
|
+
| 시나리오 | AI 의 역할 |
|
|
13
|
+
|---|---|
|
|
14
|
+
| 사용자가 ai-drawer 에 자연어 입력 ("새 상품 만들어줘, 캐시미어 니트") | AI 가 schema 따라 form value 생성 → eventBus 발행 |
|
|
15
|
+
| 페이지에 이미 form 열림 (예: `/products/new`) | `useSkillAutofill(agentType)` 수신 → banner 표시 → 사용자 approve/reject |
|
|
16
|
+
| 페이지 외부 (다른 page) | `useSkillAutofillNavigation` 가 prompt → 사용자 navigation → sessionStorage 통해 데이터 전달 |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 2. 단일 통로 — 4 layer 강제
|
|
21
|
+
|
|
22
|
+
| Layer | 컴포넌트 / 위치 | 책임 |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| **1. ai-drawer 패키지** | `packages/ai-drawer/` | AI 결과를 eventBus.emit 으로 발행 (도메인 무관) |
|
|
25
|
+
| **2. skill-registry** | `apps/web/src/shared/lib/skill-registry/` | agentType → { eventName, storageKey, schema, mutator } single source 등록 |
|
|
26
|
+
| **3. useSkillAutofill** | `apps/web/src/shared/lib/skill-registry/useSkillAutofill.ts` | generic hook — 모든 form page 가 1 줄 호출 |
|
|
27
|
+
| **4. FormFillApprovalBanner** | `apps/web/src/shared/ui/FormFillApprovalBanner/` | 사용자에게 approve/reject UI |
|
|
28
|
+
|
|
29
|
+
→ **도메인별 hook / Banner / Prompt 작성 금지**. ESLint rule 자동 강제 (Step 1).
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 3. 새 form 의 AI fill 통합 — 30 줄 등록
|
|
34
|
+
|
|
35
|
+
### Step 1 — skill 등록
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// apps/web/src/shared/lib/skill-registry/skills.ts
|
|
39
|
+
import { ProductAutofillSchema } from '@/shared/lib/validations';
|
|
40
|
+
|
|
41
|
+
registerSkill({
|
|
42
|
+
agentType: 'product',
|
|
43
|
+
storageKey: 'ai:product:pendingAutofill',
|
|
44
|
+
eventName: AI_EVENTS.PRODUCT_AUTOFILL,
|
|
45
|
+
schema: ProductAutofillSchema,
|
|
46
|
+
mutator: (data, store) => {
|
|
47
|
+
store.setName(data.productName);
|
|
48
|
+
store.setDescription(data.description ?? '');
|
|
49
|
+
store.setTags(data.features ?? []);
|
|
50
|
+
if (data.price) store.setFeaturedPrice(data.price);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Step 2 — form page 에서 hook 사용
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { useSkillAutofill } from '@/shared/lib/skill-registry/useSkillAutofill';
|
|
59
|
+
import { FormFillApprovalBanner } from '@/shared/ui/FormFillApprovalBanner';
|
|
60
|
+
|
|
61
|
+
function ProductFormPage() {
|
|
62
|
+
const { hasPendingFill, pendingData, applyFill, rejectFill } = useSkillAutofill<ProductAutofillData>('product');
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
{hasPendingFill && (
|
|
67
|
+
<FormFillApprovalBanner
|
|
68
|
+
data={pendingData}
|
|
69
|
+
onApply={applyFill}
|
|
70
|
+
onReject={rejectFill}
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
<ProductForm />
|
|
74
|
+
</>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 4. 결정 표 (lookup)
|
|
82
|
+
|
|
83
|
+
| 케이스 | 답 |
|
|
84
|
+
|---|---|
|
|
85
|
+
| "새 도메인 AI fill 통합" | `registerSkill({ agentType, eventName, storageKey, schema, mutator })` 30줄 |
|
|
86
|
+
| "form 안에서 AI 결과 수신" | `useSkillAutofill(agentType)` |
|
|
87
|
+
| "AI 가 채운 후 사용자 확인" | `<FormFillApprovalBanner data={pendingData} onApply onReject>` |
|
|
88
|
+
| "다른 page 에서 AI 가 채움 → form page 이동 prompt" | `useSkillAutofillNavigation` (TBD generic 화 — 현재 도메인별 useProductAutofillNavigation 6 hook) |
|
|
89
|
+
| "사용자가 reject 후 데이터 보존" | sessionStorage 그대로, 사용자 다른 page 가서 다시 trigger |
|
|
90
|
+
| "AI 가 schema 안 맞는 데이터 발행" | `useSkillAutofill` 의 schema validation (Zod) 자동 — invalid 시 toast.error |
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 5. 안티 패턴
|
|
95
|
+
|
|
96
|
+
- ❌ **도메인별 fill hook 작성** (`useProductFormFill` 등) — `useSkillAutofill` 만 사용. ESLint rule 자동 강제
|
|
97
|
+
- ❌ **도메인별 FormFillBanner** (`ProductFormFillBanner` 등) — shared `FormFillApprovalBanner` 만
|
|
98
|
+
- ❌ **eventBus name hardcode** — skill registry 가 자동 발행
|
|
99
|
+
- ❌ **schema 검증 없는 mutator** — skill 등록 시 schema 필수 + Zod 자동 validation
|
|
100
|
+
- ❌ **AI 결과 직접 store 변경** — banner 의 approve 거치는 게 표준 (사용자 의식 보장)
|
|
101
|
+
- ❌ **다른 도메인 의 skill 직접 trigger** — agentType 으로 격리
|
|
102
|
+
- ❌ **mutator 안 navigate** — mutator = 순수 함수 (data → store mutation). navigation 은 별도
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 6. AI 친화 — Catalog + MCP
|
|
107
|
+
|
|
108
|
+
| Layer | 효과 |
|
|
109
|
+
|---|---|
|
|
110
|
+
| **skill-registry 자체가 catalog** | AI 가 어떤 form 에 어떤 schema fill 가능 자동 lookup |
|
|
111
|
+
| **MCP server (Step 6 예정)** | AI 가 `mds.skills.list()` 또는 `mds.skills.fillable(routePath)` query 자동 |
|
|
112
|
+
| **schema export** | Zod schema → JSON Schema 변환 → AI 가 정확한 데이터 생성 |
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 7. 마이그레이션 — 6 도메인 의 hand-rolled → generic
|
|
117
|
+
|
|
118
|
+
현재 apps/web 에 6 도메인 hand-rolled (Step 8 마이그레이션 대상):
|
|
119
|
+
- `useProductFormFill` (199줄)
|
|
120
|
+
- `useSkuFormFill` (182줄)
|
|
121
|
+
- `useSellableUnitFormFill` (162줄)
|
|
122
|
+
- `useCollectionFormFill`
|
|
123
|
+
- `useFilterFormFill`
|
|
124
|
+
- `useBrandProfileFormFill`
|
|
125
|
+
|
|
126
|
+
각 ~30줄 의 `registerSkill` 호출로 마이그레이션. + 도메인별 Banner / Prompt → shared.
|
|
127
|
+
|
|
128
|
+
총 마이그레이션 ~1.5h Claude time (Step 8 web 마이그레이션 의 일부).
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 8. Cross-cutting
|
|
133
|
+
|
|
134
|
+
| Axis | 적용 |
|
|
135
|
+
|---|---|
|
|
136
|
+
| **Responsive** | FormFillApprovalBanner 의 mobile = vertical layout (action 버튼 stacked) |
|
|
137
|
+
| **A11y** | banner 의 `role="status"` + 사용자 approve/reject button focusable |
|
|
138
|
+
| **i18n** | "AI 가 form 을 채웠어요" + approve/reject 라벨 모두 `t()` |
|
|
139
|
+
| **AI Integration** | 본 layer 자체. 모든 form 의 AI fill 통일 |
|
|
140
|
+
| **Telemetry** | approve/reject ratio 추적 (AI 정확도 측정) |
|
|
141
|
+
| **Portability** | 외부 admin 프로젝트도 skill-registry import 만으로 동일 (Step 10) |
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 9. TBD
|
|
146
|
+
|
|
147
|
+
1. **AutofillNavigationPrompt 의 generic 화** — 현재 도메인별 prompt 5 component. generic + agentType prop 로 통합
|
|
148
|
+
2. **Approve/Reject 후 navigation** — apply 후 자동 form submit vs 사용자 confirm
|
|
149
|
+
3. **Schema → JSON Schema** — AI 가 catalog query 시 사용. converter 추가
|
|
150
|
+
4. **Multi-step skill** — 단순 form fill 외 multi-page wizard (TBD)
|
|
151
|
+
5. **AI 가 자체 검증** — Zod validation 외 의미 검증 (예: SKU 가 unique 한지)
|
|
152
|
+
6. **Skill 별 권한** (admin vs merchant) — fill 권한 분리
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Related Playbooks
|
|
157
|
+
|
|
158
|
+
- [form.md](./form.md) — Form 의 AI fill 사용 시점 (Step 4.1)
|
|
159
|
+
- [feedback.md](./feedback.md) — FormFillApprovalBanner 는 banner-style (Step 4.2)
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Future — Step 6 MCP server
|
|
164
|
+
|
|
165
|
+
AI 가 mds 의 사용 약속 + props spec + 안티패턴 자동 query 가능 — `mds-mcp-server` (Step 6).
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
// AI 가 query 가능
|
|
169
|
+
const fillable = await mds.skills.list(); // ['product', 'sku', 'sellable-unit', ...]
|
|
170
|
+
const schema = await mds.skills.get('product').schema; // Zod schema → JSON Schema
|
|
171
|
+
const pattern = await mds.playbook.search('form anti-patterns');
|
|
172
|
+
const violation = await mds.lint.check(code); // ESLint + audit 자동
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
→ AI fabrication 0 의 최종 layer. skill-registry + Playbook + Catalog 자동 lookup.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# MDS Playbook — Anti-Patterns 종합
|
|
2
|
+
|
|
3
|
+
9 영역의 모든 안티 패턴 모음 — **이거 보고 commit 막힘 / 코드리뷰 reject** 기준.
|
|
4
|
+
|
|
5
|
+
> 각 항목 = `금지 패턴` + `이유` + `대안`. ESLint rule 으로 자동 강제하는 항목 표시.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Component 작성 (Step 1 ESLint 자동 강제 ✓)
|
|
10
|
+
|
|
11
|
+
| 안티 패턴 | 이유 | 대안 | ESLint |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| `export const X = (props) => ...` (forwardRef 없음) | ref 전달 불가 — 일관성 깨짐 | `forwardRef<...>(function X(...))` | `mds/forward-ref-required` ✓ |
|
|
14
|
+
| Props interface 에 `BaseComponentProps` 미extend | className/style/data-testid 누락 | `extends BaseComponentProps` | `mds/base-component-props-extend` ✓ |
|
|
15
|
+
| Root element 에 `data-mds-component` 미명시 | Catalog lookup + Playwright + debug 불가 | `<div data-mds-component="X">` | `mds/data-mds-component-required` ✓ |
|
|
16
|
+
| `cn(className, styles.x)` 순서 (className 앞) | consumer override 불가 | `cn(styles.x, className)` (마지막) | `mds/classname-merge-last` ✓ |
|
|
17
|
+
| Root element 에 `{...rest}` 누락 | HTML attribute passthrough X (aria/data/event) | `<div {...rest}>` | `mds/spread-rest-props` ✓ |
|
|
18
|
+
| 컴포넌트 default export | named import 일관성 깨짐 | `export const X` | `mds/no-default-export-components` ✓ |
|
|
19
|
+
| `import styled from 'styled-components'` | runtime CSS-in-JS — bundle 부담 + token 시스템 우회 | SCSS module 만 | `mds/no-runtime-css-in-js` ✓ |
|
|
20
|
+
| 같은 layer cross-import (primitives/A → primitives/B) | layer 격리 깨짐 | 공통 코드는 foundations/hooks/utils | `mds/no-cross-layer-import` ✓ |
|
|
21
|
+
| file/folder 명명 (PascalCase / useCamelCase / *.types / *.store / *.module.scss) | 명명 표준 깨짐 | CLAUDE.md #6 | `mds/file-naming-conventions` ✓ |
|
|
22
|
+
| inline hex / px / rem / em literal | token 시스템 우회 | `var(--token)` | `mds/no-hardcoded-visual-values` ✓ |
|
|
23
|
+
|
|
24
|
+
→ **10 rule 자동 강제**. commit 시 차단.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 2. Form (form.md)
|
|
29
|
+
|
|
30
|
+
| 안티 패턴 | 이유 | 대안 |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `useState<string>('')` 으로 form value 관리 | 검증 / 의도 / RHF integration 없음 | RHF `useForm()` + Zod schema |
|
|
33
|
+
| raw `<input>` / `<textarea>` 직접 | Field compound 의 a11y wire 누락 | `<Field>` + `<TextField>` |
|
|
34
|
+
| Field compound 외부 input | label / error / hint 의 aria 안 wire | `<Field.Root>` 안 input |
|
|
35
|
+
| Modal 안 Modal 안 Form | 3단 mental stack | drawer 분리 또는 페이지 이동 |
|
|
36
|
+
| inline error + toast.error 동시 | 중복 — 사용자 혼란 | 한 곳만 |
|
|
37
|
+
| 가로 정렬 input 두 개가 의미 무관 | mental scan 깨짐 | vertical |
|
|
38
|
+
| Notification 으로 "저장됨" | 영속 의도 아님 | Toast |
|
|
39
|
+
| Toast 로 "라이선스 만료" | 사용자 놓침 | Notification (영속) |
|
|
40
|
+
| 도메인별 fill hook 작성 (useXxxFormFill) | 6 도메인 ~900줄 hand-rolled | `useSkillAutofill(agentType)` 30줄 |
|
|
41
|
+
| Inline 편집 의 blur 자동 저장 | 실수 위험 | Enter / 체크 명시 저장 |
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 3. Feedback (feedback.md)
|
|
46
|
+
|
|
47
|
+
| 안티 패턴 | 이유 | 대안 |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| Toast 로 "정말 삭제?" | dismissible — 응답 못 받음 | `Modal.confirm` (Dialog) |
|
|
50
|
+
| Notification 으로 "저장됨" | 영속 의도 아님 | Toast |
|
|
51
|
+
| Banner + Toast 동일 메시지 동시 | 중복 | 한 곳 |
|
|
52
|
+
| Toast 5+ 동시 stacking | 사용자 의식 한계 | stacking 제한 |
|
|
53
|
+
| Dialog 안 multiple input | Dialog 의 의도 깨짐 | `Modal.prompt` 또는 form Modal |
|
|
54
|
+
| Banner 2줄+ 긴 텍스트 | 시각 부담 | collapse 또는 Modal |
|
|
55
|
+
| 영속 Notification 의 dismiss 없음 | a11y 위반 | 명시 닫기 |
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 4. Overlay (overlay.md)
|
|
60
|
+
|
|
61
|
+
| 안티 패턴 | 이유 | 대안 |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| Modal 안 Modal | 3단 stack | drawer 분리 |
|
|
64
|
+
| Drawer 안 Drawer | 동일 | nested drawer 외 금지 |
|
|
65
|
+
| Modal ↔ Drawer 혼합 (Modal 안 Drawer 등) | focus / layer 충돌 | 한 종류 만 |
|
|
66
|
+
| Popover 안 Popover | auto-position 충돌 | nested popover 금지 |
|
|
67
|
+
| Dialog 안 form (input multiple) | Dialog 의도 깨짐 | `Modal.prompt` |
|
|
68
|
+
| Popover 안 큰 form | Popover 의도 (작은 inline) 아님 | Modal |
|
|
69
|
+
| isDirty close 시 force confirm 없음 | 사용자 변경 사항 손실 | `<Modal isDirty={isDirty}>` |
|
|
70
|
+
| Drawer 의 width 변경 (사용자 resize) | layout 깨짐 | caller fix |
|
|
71
|
+
| Sheet 의 height 가 viewport 90%+ | full-screen modal 이 차라리 명확 | Sheet 의 의도 모호 |
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 5. DataGrid + Table (data-grid.md)
|
|
76
|
+
|
|
77
|
+
| 안티 패턴 | 이유 | 대안 |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| DataGrid 안 form (모든 row 의 input) | 큰 form 은 별도 페이지 | page-form 분리 |
|
|
80
|
+
| Table 으로 DataGrid 흉내 (filter/pagination caller 구현) | 중복 + 일관성 X | DataGrid |
|
|
81
|
+
| column 50+ | 사용자 scan 불가 | hidden + caller 선택 |
|
|
82
|
+
| loading 시 ErrorState / EmptyState 표시 | async-states.md §2 위반 | loading 만 |
|
|
83
|
+
| row 클릭 + checkbox 동시 작동 | 의도 충돌 | selection 모드 시 row click 막힘 |
|
|
84
|
+
| pagination size 50+ | render 부담 + a11y | 최대 20-30 |
|
|
85
|
+
| selection 이 page 이동 후 사라짐 | cross-page 의도 깨짐 | DataGrid 의 selectedIds Set 유지 |
|
|
86
|
+
| column header 가 너무 길거나 wrap | fixed height 깨짐 | ellipsis |
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 6. Page Layout (page-layout.md)
|
|
91
|
+
|
|
92
|
+
| 안티 패턴 | 이유 | 대안 |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| 페이지마다 다른 layout | 사용자 mental model 깨짐 | AppShell + PageHeader 표준 |
|
|
95
|
+
| DataGrid 페이지 에 max-width | content 답답 (1920px 모니터) | full-bleed |
|
|
96
|
+
| form 페이지에 max-width 없음 | 가로 input 한 줄 어색 | 640~800px |
|
|
97
|
+
| PageHeader 의 actions 가 row-level | DataGrid column action 의 의도 | PageHeader 는 page-level |
|
|
98
|
+
| Sidebar 안 sub-Item 3 단계+ | mental load | 2 단계 max |
|
|
99
|
+
| section gap / padding hardcoded | token 시스템 우회 | `var(--section-gap)` / `var(--space-*)` |
|
|
100
|
+
| mobile 에서 Sidebar 그대로 left sticky | UX 깨짐 | drawer 변환 |
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 7. Array Input (array-input.md)
|
|
105
|
+
|
|
106
|
+
| 안티 패턴 | 이유 | 대안 |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| 무한 깊은 nested (drawer 안 drawer 안 drawer) | mental stack 한계 | 2 단 max |
|
|
109
|
+
| inline edit + drawer 편집 동시 | 의도 충돌 | 한쪽만 |
|
|
110
|
+
| add 버튼이 row 사이 | mental scan 깨짐 | row 끝 |
|
|
111
|
+
| remove 의 confirm 없음 (이미 저장 entity) | 실수 위험 | `Modal.confirm` |
|
|
112
|
+
| 0 row 의 empty state 없음 | placeholder 부족 | `<EmptyState size="sm">` |
|
|
113
|
+
| dnd 만 (keyboard reorder 없음) | a11y 위반 | dnd-kit keyboard sensor |
|
|
114
|
+
| submit 시 빈 row valid | 데이터 quality 깨짐 | Zod `.min(1)` |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 8. Async States (async-states.md)
|
|
119
|
+
|
|
120
|
+
| 안티 패턴 | 이유 | 대안 |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| Spinner 우선 사용 | 의도 모호 / placeholder X | Skeleton |
|
|
123
|
+
| Loading 의 깜빡임 (< 200ms) | UX 불안정 | min display time 200ms |
|
|
124
|
+
| Action button loading + 별도 progress bar | 중복 | button loading 만 |
|
|
125
|
+
| Background 의 modal loading | 사용자 인터럽트 | silent 또는 corner toast |
|
|
126
|
+
| Empty 의 "다시 시도" 만 | empty != error | EmptyState 의 의도 따라 |
|
|
127
|
+
| Error 시 toast 만 (page-level) | content 자리 missing | ErrorState content area |
|
|
128
|
+
| Skeleton 의 layout mismatch | 깜빡임 효과 | 실 content shape |
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 9. AI Fill (ai-fill.md)
|
|
133
|
+
|
|
134
|
+
| 안티 패턴 | 이유 | 대안 |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| 도메인별 fill hook (`useXxxFormFill`) | 6 도메인 ~900줄 hand-rolled | `useSkillAutofill(agentType)` |
|
|
137
|
+
| 도메인별 FormFillBanner | 6 component 중복 | shared `FormFillApprovalBanner` |
|
|
138
|
+
| eventBus name hardcode | 도메인별 name typo 위험 | skill-registry 자동 발행 |
|
|
139
|
+
| schema 검증 없는 mutator | invalid 데이터 store 진입 | Zod schema 필수 |
|
|
140
|
+
| AI 결과 직접 store 변경 (banner skip) | 사용자 의식 없음 | banner approve 거치기 |
|
|
141
|
+
| 다른 도메인 skill trigger | agent 격리 깨짐 | agentType 격리 |
|
|
142
|
+
| mutator 안 navigate | 순수성 깨짐 | navigation 별도 |
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 10. Cross-cutting 안티 패턴
|
|
147
|
+
|
|
148
|
+
| 안티 패턴 | 이유 | 대안 |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| **i18n raw string** ("저장" 직접) | locale 변경 시 깨짐 | `t('common.save')` |
|
|
151
|
+
| **a11y aria-label / role 없음** | screen reader 누락 | Radix 기반 + 명시 aria |
|
|
152
|
+
| **token 외 hardcoded color / spacing** | theme 안 자동 변환 | `var(--token)` 만 |
|
|
153
|
+
| **mobile breakpoint hardcoded** (`@media (max-width: 768px)`) | breakpoint 변경 시 mismatch | `var(--breakpoint-md)` (CSS @media 안 직접 사용 못 함 — SCSS @media + Tailwind) |
|
|
154
|
+
| **새 컴포넌트 추가 시 stories / test 누락** | audit 차단 + 사용 예 없음 | `pnpm mds:new` (Step 9 generator) |
|
|
155
|
+
| **CI 통과 안 한 채 commit** | drift 가능 | pre-commit + CI 강제 (작동 중) |
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 11. 강제 layer 종합
|
|
160
|
+
|
|
161
|
+
| Layer | 자동 강제 |
|
|
162
|
+
|---|---|
|
|
163
|
+
| **ESLint 10 rule (Step 1)** | §1 의 10 항목 + cross-layer + hardcoded |
|
|
164
|
+
| **audit-components (Step 2)** | file-structure / forwardRef / data-mds / use-client / BaseComponentProps / cn |
|
|
165
|
+
| **baseline lock (max-warnings 551)** | 새 ESLint warning 1개도 차단 |
|
|
166
|
+
| **verify:tokens** | hardcoded color / spacing |
|
|
167
|
+
| **verify:stories** | 정의 안 된 CSS var() |
|
|
168
|
+
| **a11y axe-playwright (Step 2.C)** | WCAG 2.1 AA 위반 |
|
|
169
|
+
| **visual regression** | snapshot diff 강제 confirm |
|
|
170
|
+
| **prefers-reduced-motion** | 운동 민감증 자동 |
|
|
171
|
+
| **style-dictionary** | theme 별 자동 token 적용 |
|
|
172
|
+
| **pre-commit + CI** | drift 차단 |
|
|
173
|
+
|
|
174
|
+
→ **새 컴포넌트 / 변경 시 위 10 layer 모두 자동 통과 필수**. 우회 시도해도 차단.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Related Playbooks
|
|
179
|
+
|
|
180
|
+
각 영역의 detail:
|
|
181
|
+
- [form.md](./form.md) (Step 4.1)
|
|
182
|
+
- [feedback.md](./feedback.md) (Step 4.2)
|
|
183
|
+
- [data-grid.md](./data-grid.md) (Step 4.3)
|
|
184
|
+
- [overlay.md](./overlay.md) (Step 4.4)
|
|
185
|
+
- [page-layout.md](./page-layout.md) (Step 4.5)
|
|
186
|
+
- [array-input.md](./array-input.md) (Step 4.6)
|
|
187
|
+
- [async-states.md](./async-states.md) (Step 4.7)
|
|
188
|
+
- [ai-fill.md](./ai-fill.md) (Step 4.8)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# MDS Playbook — Array Input + Nested Entity
|
|
2
|
+
|
|
3
|
+
배열형 input (반복 필드) + 메인 form 안 sub-entity 편집 의 약속.
|
|
4
|
+
|
|
5
|
+
> RHF `useFieldArray` 표준 + drawer 편집 패턴.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. 4 패턴 분리
|
|
10
|
+
|
|
11
|
+
| 패턴 | 사용 시점 | 컴포넌트 |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| **짧은 동질 배열** (태그 / 카테고리) | item = 단순 string, ≤ 20 개 | `<ChipToggle>` (compounds) |
|
|
14
|
+
| **가변 row sub-form** | item 이 small form (예: variant 가격) | `useFieldArray` + `<Stack>` row + add/remove |
|
|
15
|
+
| **nested entity 다수** (정책 / 결제 옵션) | item 이 큰 form, 별 entity | `useFieldArray` row 요약 + click → **Drawer 편집** |
|
|
16
|
+
| **reorder 가능** | drag handle 으로 순서 변경 | `<Tree>` (compounds) 또는 `useFieldArray` + dnd-kit |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 2. 사용 시점 결정표
|
|
21
|
+
|
|
22
|
+
| 케이스 | 답 |
|
|
23
|
+
|---|---|
|
|
24
|
+
| "상품 태그 (text chip 다수)" | `<ChipToggle>` (다중 토글) |
|
|
25
|
+
| "주문 항목 (상품 + 수량 + 가격 3 필드 row)" | `useFieldArray` + Stack row + IconButton remove |
|
|
26
|
+
| "변형 (variant: 색 / size / 가격 / 재고 4 필드, 각 row 가 큼)" | `useFieldArray` + 행 요약 + click → Drawer 의 sub-form |
|
|
27
|
+
| "정책 (각 정책이 큰 entity)" | nested drawer-form (overlay.md §7) |
|
|
28
|
+
| "image 다수 + reorder" | `<Tree>` (compounds) 또는 dnd-kit + useFieldArray |
|
|
29
|
+
| "FAQ list (질문/답 row, 다수)" | useFieldArray + Stack + Drawer 의 큰 답변 input |
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 3. 짧은 동질 배열 — ChipToggle
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
<Field.Root>
|
|
37
|
+
<Field.Label>태그</Field.Label>
|
|
38
|
+
<Controller
|
|
39
|
+
name="tags"
|
|
40
|
+
control={control}
|
|
41
|
+
render={({ field }) => (
|
|
42
|
+
<ChipToggle.Group value={field.value} onValueChange={field.onChange} multiple>
|
|
43
|
+
<ChipToggle.Item value="new">신상</ChipToggle.Item>
|
|
44
|
+
<ChipToggle.Item value="sale">세일</ChipToggle.Item>
|
|
45
|
+
<ChipToggle.Item value="featured">추천</ChipToggle.Item>
|
|
46
|
+
</ChipToggle.Group>
|
|
47
|
+
)}
|
|
48
|
+
/>
|
|
49
|
+
</Field.Root>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- 다중 선택 = `multiple` prop
|
|
53
|
+
- 단일 선택 = 그 prop 없음 (default)
|
|
54
|
+
- caller 가 새 chip 추가 불가 (predefined list 만) — 새 추가 = `Combobox` 또는 별도 input
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 4. 가변 row sub-form — useFieldArray + Stack
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import { useFieldArray } from 'react-hook-form';
|
|
62
|
+
|
|
63
|
+
function VariantPriceForm() {
|
|
64
|
+
const { fields, append, remove } = useFieldArray({
|
|
65
|
+
control,
|
|
66
|
+
name: 'variants',
|
|
67
|
+
});
|
|
68
|
+
return (
|
|
69
|
+
<Field.Root>
|
|
70
|
+
<Field.Label>변형 가격</Field.Label>
|
|
71
|
+
<Stack gap="2">
|
|
72
|
+
{fields.map((field, idx) => (
|
|
73
|
+
<Stack key={field.id} direction="row" gap="2" align="center">
|
|
74
|
+
<Field.Root>
|
|
75
|
+
<Field.Label visuallyHidden>이름</Field.Label>
|
|
76
|
+
<TextField {...register(`variants.${idx}.name`)} placeholder="이름" />
|
|
77
|
+
</Field.Root>
|
|
78
|
+
<Field.Root>
|
|
79
|
+
<Field.Label visuallyHidden>가격</Field.Label>
|
|
80
|
+
<NumberInput {...register(`variants.${idx}.price`)} step={100} suffix="원" />
|
|
81
|
+
</Field.Root>
|
|
82
|
+
<IconButton
|
|
83
|
+
aria-label={`${field.name} 삭제`}
|
|
84
|
+
onClick={() => remove(idx)}
|
|
85
|
+
variant="ghost"
|
|
86
|
+
>
|
|
87
|
+
<TrashIcon />
|
|
88
|
+
</IconButton>
|
|
89
|
+
</Stack>
|
|
90
|
+
))}
|
|
91
|
+
</Stack>
|
|
92
|
+
<Button
|
|
93
|
+
variant="ghost"
|
|
94
|
+
leadingIcon={<PlusIcon />}
|
|
95
|
+
onClick={() => append({ name: '', price: 0 })}
|
|
96
|
+
>
|
|
97
|
+
변형 추가
|
|
98
|
+
</Button>
|
|
99
|
+
</Field.Root>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Add / Remove UI 표준
|
|
105
|
+
|
|
106
|
+
| 위치 | 컴포넌트 | 라벨 |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| Add 버튼 | row 마지막 아래 (또는 위) | `Button variant="ghost" leadingIcon=<PlusIcon>{...}추가` |
|
|
109
|
+
| Remove 버튼 | row 끝 우측 | `IconButton variant="ghost"` + aria-label |
|
|
110
|
+
| 위험 (이미 저장된 entity 삭제) | `Modal.confirm` 확인 후 remove | — |
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## 5. nested entity 다수 — drawer 편집
|
|
115
|
+
|
|
116
|
+
큰 sub-entity (variant / 정책 / 결제 옵션) — 행 요약 + click → Drawer 의 full form.
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
function PolicyList() {
|
|
120
|
+
const { fields, append, remove, update } = useFieldArray({ control, name: 'policies' });
|
|
121
|
+
const [editIdx, setEditIdx] = useState<number | null>(null);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<Stack gap="2">
|
|
126
|
+
{fields.map((field, idx) => (
|
|
127
|
+
<Card.Root
|
|
128
|
+
key={field.id}
|
|
129
|
+
onClick={() => setEditIdx(idx)}
|
|
130
|
+
data-mds-component="PolicyRow"
|
|
131
|
+
>
|
|
132
|
+
<Card.Body>
|
|
133
|
+
<PersonCell name={field.name} sub={field.description} />
|
|
134
|
+
</Card.Body>
|
|
135
|
+
</Card.Root>
|
|
136
|
+
))}
|
|
137
|
+
</Stack>
|
|
138
|
+
<Button variant="ghost" leadingIcon={<PlusIcon />} onClick={() => setEditIdx(fields.length)}>
|
|
139
|
+
정책 추가
|
|
140
|
+
</Button>
|
|
141
|
+
<Drawer.Root open={editIdx !== null} onOpenChange={(o) => !o && setEditIdx(null)}>
|
|
142
|
+
<Drawer.Content side="right">
|
|
143
|
+
<PolicyForm
|
|
144
|
+
initialValue={editIdx !== null && editIdx < fields.length ? fields[editIdx] : undefined}
|
|
145
|
+
onSubmit={(data) => {
|
|
146
|
+
if (editIdx !== null && editIdx < fields.length) update(editIdx, data);
|
|
147
|
+
else append(data);
|
|
148
|
+
setEditIdx(null);
|
|
149
|
+
}}
|
|
150
|
+
onDelete={editIdx !== null && editIdx < fields.length
|
|
151
|
+
? () => { remove(editIdx); setEditIdx(null); }
|
|
152
|
+
: undefined}
|
|
153
|
+
/>
|
|
154
|
+
</Drawer.Content>
|
|
155
|
+
</Drawer.Root>
|
|
156
|
+
</>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- 메인 form 의 isDirty 유지 — Drawer 닫으면 변경 사항 commit
|
|
162
|
+
- Drawer 안 sub-form 의 isDirty close 시 confirm (overlay.md §11 TBD)
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 6. Reorder
|
|
167
|
+
|
|
168
|
+
mds 의 자체 dnd 없음. 옵션:
|
|
169
|
+
- **Tree (compounds)** — Radix 기반 nested reorder
|
|
170
|
+
- **useFieldArray + dnd-kit** — drag handle 의 manual integration
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
import { DndContext, closestCenter } from '@dnd-kit/core';
|
|
174
|
+
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
175
|
+
|
|
176
|
+
function SortableArray() {
|
|
177
|
+
const { fields, move } = useFieldArray({ control, name: 'items' });
|
|
178
|
+
return (
|
|
179
|
+
<DndContext
|
|
180
|
+
collisionDetection={closestCenter}
|
|
181
|
+
onDragEnd={(e) => {
|
|
182
|
+
const oldIndex = fields.findIndex((f) => f.id === e.active.id);
|
|
183
|
+
const newIndex = fields.findIndex((f) => f.id === e.over?.id);
|
|
184
|
+
if (oldIndex !== -1 && newIndex !== -1) move(oldIndex, newIndex);
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
|
|
188
|
+
{fields.map((field, idx) => (
|
|
189
|
+
<SortableRow key={field.id} id={field.id} {...field} />
|
|
190
|
+
))}
|
|
191
|
+
</SortableContext>
|
|
192
|
+
</DndContext>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## 7. Mobile 변형
|
|
200
|
+
|
|
201
|
+
| Pattern | Mobile |
|
|
202
|
+
|---|---|
|
|
203
|
+
| ChipToggle | wrap full-width, scroll horizontal if many |
|
|
204
|
+
| useFieldArray + Stack (row) | `direction="column"` (vertical) + add / remove 그대로 |
|
|
205
|
+
| nested entity (Drawer) | Drawer → bottom Sheet (overlay.md §8) |
|
|
206
|
+
| Reorder (dnd-kit) | touch-friendly drag handle |
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## 8. 안티 패턴
|
|
211
|
+
|
|
212
|
+
- ❌ **무한 깊은 nested** (drawer 안 drawer 안 drawer) — 2 단 max (메인 form + sub-entity drawer)
|
|
213
|
+
- ❌ **inline edit + drawer 편집 동시** — 한쪽만
|
|
214
|
+
- ❌ **add 버튼이 row 사이** — row 끝에만 (mental scan 깨짐)
|
|
215
|
+
- ❌ **remove 의 confirm 없음** (이미 저장된 entity) — `Modal.confirm`
|
|
216
|
+
- ❌ **0 row 의 empty state 없음** — `<EmptyState size="sm">` 또는 placeholder
|
|
217
|
+
- ❌ **row 의 a11y 없는 reorder** (drag only) — keyboard 도 가능 (dnd-kit 자체 keyboard sensor)
|
|
218
|
+
- ❌ **submit 시 useFieldArray 의 빈 row 도 valid** — Zod schema 의 .min(1) 검증
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## 9. Cross-cutting
|
|
223
|
+
|
|
224
|
+
| Axis | 적용 |
|
|
225
|
+
|---|---|
|
|
226
|
+
| **Responsive** | row → column (mobile) |
|
|
227
|
+
| **A11y** | each row 의 aria-label / remove button aria-label / dnd-kit keyboard support |
|
|
228
|
+
| **i18n** | "추가" / "삭제" / "변형" 등 모두 `t()` |
|
|
229
|
+
| **Catalog** | useFieldArray 의 standard pattern 자동 catalog |
|
|
230
|
+
| **Validation** | Zod `array().min(N).max(N)` |
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## 10. TBD
|
|
235
|
+
|
|
236
|
+
1. **dnd-kit mds 통합** — `<SortableArray>` 같은 mds wrapper 컴포넌트 추가
|
|
237
|
+
2. **drawer 안 isDirty confirm** — 메인 form 의 isDirty 와 drawer 의 isDirty 의 통합
|
|
238
|
+
3. **add 위치** — row 끝 vs row 사이 (행간 + 버튼) 둘 다 허용 vs 표준화
|
|
239
|
+
4. **empty state 표준** — useFieldArray 의 0 row 시 표시 패턴
|
|
240
|
+
5. **Bulk add** — CSV / paste 로 다 row 한 번에 (변형 가격 list 같은)
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Related Playbooks
|
|
245
|
+
|
|
246
|
+
- [form.md](./form.md) — useFieldArray 의 form 안 통합 (Step 4.1)
|
|
247
|
+
- [overlay.md](./overlay.md) — nested drawer 합성 규칙 (Step 4.4)
|
|
248
|
+
- [async-states.md](./async-states.md) — 0 row 의 empty state (Step 4.7)
|
|
249
|
+
- [data-grid.md](./data-grid.md) — DataGrid 의 row 자체가 다른 entity 의 array (Step 4.3)
|