sellmate-design-system-react 3.1.0 → 3.3.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/AGENTS.md CHANGED
@@ -162,6 +162,38 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
162
162
 
163
163
  정보 밀도가 높은 서비스라 블록 간격을 넓게 벌리지 않는다. `gap-16` / `gap-24` 를 페이지 골격의 기본값으로 쓰지 않는다.
164
164
 
165
+ #### 같은 컴포넌트를 여러 개 늘어놓을 때 (그룹 간격)
166
+
167
+ 위 "요소 ↔ 요소 `gap-8`" 은 **서로 다른 요소** 사이의 기본값이다.
168
+ **같은 컴포넌트를 여러 개 나열할 때는 컴포넌트마다 정해진 그룹 간격**이 따로 있다.
169
+
170
+ | 컴포넌트 | 수평 배열 | 수직 배열 |
171
+ | --- | --- | --- |
172
+ | `SCheckbox` | **`gap-24`** | `gap-8` |
173
+ | `SRadio` | **`gap-24`** | `gap-8` |
174
+ | `STextLink` | **`gap-16`**(sm) / **`gap-24`**(md·lg) | `gap-4` |
175
+ | `SGhostButton` | `gap-4` | `gap-4` |
176
+ | `SButton` | `gap-8` (xs·sm·md) / **`gap-12`**(lg) | 〃 |
177
+ | `STag` | `gap-8` | `gap-8` |
178
+ | `SToggle` | `gap-8` | `gap-8` |
179
+ | `SListItem` (bordered) | — | `gap-8` (+ 컨테이너 `p-16`) |
180
+
181
+ **수평·수직이 다른 것에 주의한다** — 체크박스·라디오는 가로로 놓으면 `gap-24`, 세로로 놓으면 `gap-8` 로 3배 차이다. 가로 배열에 `gap-8` 을 쓰면 항목이 붙어 보인다.
182
+
183
+ ```tsx
184
+ ✅ <div className="flex gap-24"> {/* 체크박스 가로 */}
185
+ <SCheckbox label="전체" … /><SCheckbox label="판매중" … />
186
+ </div>
187
+ ✅ <div className="flex flex-col gap-8"> {/* 체크박스 세로 */}
188
+ <SCheckbox label="전체" … /><SCheckbox label="판매중" … />
189
+ </div>
190
+ ❌ <div className="flex gap-8"> {/* 가로인데 8 — 붙어 보인다 */}
191
+ ```
192
+
193
+ - **라디오는 `SRadioGroup` 을 쓴다.** `direction="horizontal" | "vertical"` 만 주면 간격을 알아서 맞춘다 — 직접 `flex` 로 감싸지 않는다.
194
+ - `SRadioButton` 그룹의 간격은 `-1px`(테두리 겹침 처리)이라 손으로 만들지 않는다.
195
+ - 정확한 값이 필요하면 토큰을 직접 참조해도 된다: `gap-[var(--cmp-checkbox-group-gap-horizontal)]`
196
+
165
197
  ### 2-3. 색상
166
198
 
167
199
  **시맨틱 유틸리티를 우선 사용한다** — 의미가 이름에 담긴 토큰이 이미 유틸리티로 존재한다.
@@ -626,6 +658,7 @@ export default function ProductDetailPage() {
626
658
  - [ ] 간격이 전부 토큰 스케일 값인가 (`gap-13` ❌ → `gap-12` ✅)
627
659
  - [ ] 본문이 12px(`typo-body-sm-default`)이고 보조 설명이 `text-fg-tertiary` 인가 (14px 본문 ❌)
628
660
  - [ ] 페이지 패딩이 `p-20`, 블록·섹션 간격이 `gap-12` 인가 (`gap-16`/`gap-24` ❌)
661
+ - [ ] 같은 컴포넌트를 나열할 때 §2-2 그룹 간격을 썼는가 (체크박스 가로 `gap-24` 등)
629
662
  - [ ] 페이지가 §4의 표준 골격에서 시작했는가
630
663
  - [ ] 필터·폼·상세 정보를 `SKeyValueTable` 로 만들었는가 (컨트롤을 `div` 로 나열하지 않았는가)
631
664
  - [ ] 섹션 구분에 `SSectionHeaderCard` 를 썼는가 (직접 만든 카드가 아니라)
@@ -653,6 +686,7 @@ export default function ProductDetailPage() {
653
686
  | `sellmate/no-off-scale-spacing` | §2-2 스케일 밖 간격 (`gap-13`) |
654
687
  | `sellmate/table-numeric-align` | §3-4 숫자 컬럼의 `align: 'right'` 누락 (`--fix` 지원) |
655
688
  | `sellmate/require-locale-number` | §1-4 숫자 컬럼의 `toLocaleString()` 누락 |
689
+ | `sellmate/component-group-gap` | §2-2 컴포넌트 그룹 간격 (체크박스 가로 24 / 세로 8 등) |
656
690
  | `sellmate/require-locale-number` | §1-4 금액·수량 등의 `toLocaleString()` 누락 |
657
691
  | `sellmate/prefer-typo-preset` | §1-3 낱개 폰트 조합 (`text-14 font-bold`) |
658
692
 
package/README.md CHANGED
@@ -13,8 +13,23 @@ Sellmate 디자인 시스템의 React 컴포넌트 라이브러리 (React + Type
13
13
 
14
14
  ```bash
15
15
  npm install sellmate-design-system-react
16
+ npx sellmate-ds init
16
17
  ```
17
18
 
19
+ `init` 이 소비 앱 설정을 자동으로 연결합니다.
20
+
21
+ | 대상 | 하는 일 |
22
+ | --- | --- |
23
+ | `CLAUDE.md` / `AGENTS.md` | AI 에이전트가 규칙(`llms.txt`)을 먼저 읽도록 지침 추가 |
24
+ | `eslint.config.mjs` | 디자인 시스템 ESLint 프리셋 연결 |
25
+ | 전역 CSS | `theme.css` import 와 `@source` 경로(파일 기준 상대경로) 추가 |
26
+
27
+ - **이미 되어 있는 항목은 건너뜁니다** — 여러 번 실행해도 안전합니다.
28
+ - 무엇이 바뀌는지 먼저 보려면 `npx sellmate-ds init --dry-run`.
29
+ - 자동으로 고치기 어려운 형태(예: `defineConfig(...)` 로 감싼 ESLint 설정)는 **파일을 건드리지 않고** 붙여넣을 스니펫을 출력합니다.
30
+
31
+ 수동으로 설정하려면 아래 절을 따르세요.
32
+
18
33
  ## 설정 (방식 1 — 권장, Tailwind v4)
19
34
 
20
35
  소비 앱의 전역 CSS에 다음을 추가합니다.
@@ -112,8 +127,13 @@ export function Example() {
112
127
  | 파일 | 용도 |
113
128
  | --- | --- |
114
129
  | `node_modules/sellmate-design-system-react/AGENTS.md` | 사람이 읽는 사용 규칙서 |
115
- | `node_modules/sellmate-design-system-react/dist/llms.txt` | 규칙 + 토큰 어휘 + 전체 컴포넌트 Props 합친 AI 참조용 단일 문서 |
116
- | `node_modules/sellmate-design-system-react/dist/components/<이름>/README.md` | 컴포넌트별 Props/Events |
130
+ | `node_modules/sellmate-design-system-react/dist/llms.txt` | **AI 가 항상 읽는 문서** — 규칙 + 토큰 어휘 + 컴포넌트 인덱스 (약 32KB) |
131
+ | `node_modules/sellmate-design-system-react/dist/components/<이름>/README.md` | 컴포넌트별 Props/Events — 쓸 컴포넌트만 골라서 읽습니다 |
132
+ | `node_modules/sellmate-design-system-react/dist/llms-full.txt` | 위 둘을 한 파일에 합친 판본 (약 106KB) — 단일 파일만 물릴 수 있는 도구용 |
133
+
134
+ Props 를 `llms.txt` 에서 뺀 이유: 전체 컴포넌트 Props 가 분량의 70% 를 차지하는데,
135
+ prop 오류는 TypeScript 가 잡아주지만 **디자인 규칙은 아무도 잡아주지 않습니다.**
136
+ 한정된 컨텍스트를 규칙에 쓰고, Props 는 필요한 것만 정확히 읽게 하는 편이 낫습니다.
117
137
 
118
138
  Claude 등 AI 에이전트를 쓴다면 소비 앱의 `CLAUDE.md` 에 다음 한 줄을 넣어두면 됩니다.
119
139
 
@@ -142,6 +162,7 @@ export default [
142
162
  | `sellmate/no-off-scale-spacing` | error | 스케일 밖 간격 (`gap-13`, `p-15`) — Tailwind v4 에서 **조용히 무시되는** 값이라 눈으로 찾기 어렵다 |
143
163
  | `sellmate/table-numeric-align` | error | 숫자 컬럼(금액·수량 등)에 `align: 'right'` 누락 — **`--fix` 로 자동 교정** |
144
164
  | `sellmate/require-locale-number` | error | 숫자 컬럼의 `toLocaleString()` 누락 — 세 자리 콤마는 필수 |
165
+ | `sellmate/component-group-gap` | error | 같은 컴포넌트를 나열할 때의 그룹 간격 — 배열 방향에 따라 값이 다르다(체크박스 가로 24 / 세로 8) |
145
166
  | `sellmate/prefer-typo-preset` | warn | `text-14 font-bold` 같은 낱개 조합 → `typo-*` 프리셋 |
146
167
 
147
168
  `className` 뿐 아니라 `cn()`/`clsx()` 인자, 템플릿 리터럴, 객체 키 안까지 검사합니다.
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sellmate-ds — 소비 앱 설정 자동화 CLI.
4
+ *
5
+ * npx sellmate-ds init [--dry-run]
6
+ *
7
+ * 라이브러리를 설치해도 규칙(AGENTS.md·llms.txt)과 ESLint 프리셋은 소비 앱이
8
+ * 직접 연결해야 동작한다. 이 CLI 가 그 연결을 대신 해준다.
9
+ *
10
+ * 원칙
11
+ * - 덮어쓰지 않는다. 추가/삽입만 하고, 이미 있으면 건너뛴다 (몇 번 실행해도 안전).
12
+ * - 안전하게 고칠 수 없는 파일은 손대지 않고 붙여넣을 스니펫을 출력한다.
13
+ * - 무엇을 바꿨는지 전부 보고한다.
14
+ */
15
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
16
+ import { join, relative, dirname, sep } from "node:path";
17
+
18
+ const PKG = "sellmate-design-system-react";
19
+ const LLMS_PATH = `node_modules/${PKG}/dist/llms.txt`;
20
+
21
+ const args = process.argv.slice(2);
22
+ const command = args.find((a) => !a.startsWith("-")) ?? "init";
23
+ const dryRun = args.includes("--dry-run") || args.includes("-n");
24
+
25
+ const cwd = process.cwd();
26
+
27
+ /* ─────────────────────────── 출력 ─────────────────────────── */
28
+
29
+ const c = {
30
+ reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
31
+ green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m", red: "\x1b[31m",
32
+ };
33
+ const paint = (color, s) => (process.stdout.isTTY ? `${c[color]}${s}${c.reset}` : s);
34
+
35
+ /** @type {Array<{status: 'added'|'skipped'|'manual'|'error', file: string, detail: string, snippet?: string}>} */
36
+ const results = [];
37
+ const add = (status, file, detail, snippet) => results.push({ status, file, detail, snippet });
38
+
39
+ /* ─────────────────────────── 유틸 ─────────────────────────── */
40
+
41
+ const read = (p) => readFileSync(p, "utf8");
42
+
43
+ function write(path, content) {
44
+ if (dryRun) return;
45
+ writeFileSync(path, content);
46
+ }
47
+
48
+ /** node_modules 가 있는 프로젝트 루트인지 확인 */
49
+ function assertProjectRoot() {
50
+ if (!existsSync(join(cwd, "package.json"))) {
51
+ console.error(paint("red", "package.json 이 없습니다. 프로젝트 루트에서 실행하세요."));
52
+ process.exit(1);
53
+ }
54
+ }
55
+
56
+ /** 후보 경로 중 처음 존재하는 것 */
57
+ const firstExisting = (candidates) => candidates.find((p) => existsSync(join(cwd, p)));
58
+
59
+ /* ────────────────── 1. 에이전트 지침 파일 ────────────────── */
60
+
61
+ const AGENT_SECTION = `
62
+ ## 디자인 시스템 (${PKG})
63
+
64
+ UI 작업 전 \`${LLMS_PATH}\` 를 **반드시 먼저 읽는다.**
65
+ 컴포넌트 선택 규칙 · 토큰 어휘 · 페이지 표준 골격 · 컴포넌트 인덱스가 들어 있다.
66
+
67
+ **컴포넌트 Props 는 위 문서에 없다.** 쓸 컴포넌트를 정했으면 그 컴포넌트의 README 를 읽는다 —
68
+ \`node_modules/${PKG}/dist/components/<이름>/README.md\`. prop 을 추측해서 쓰지 않는다.
69
+
70
+ - 화면 요소를 만들기 전에 대응 컴포넌트가 있는지 먼저 확인한다 (생 \`<button>\`·\`<table>\` 금지).
71
+ - 색·타이포·간격·모서리는 토큰 유틸리티만 쓴다 (\`text-[14px]\`·\`bg-[#eee]\` 금지).
72
+ - 숫자는 \`toLocaleString()\` 으로 세 자리 콤마를 넣는다.
73
+ `;
74
+
75
+ function stepAgentInstructions() {
76
+ const existing = ["CLAUDE.md", "AGENTS.md"].filter((f) => existsSync(join(cwd, f)));
77
+ const targets = existing.length ? existing : ["CLAUDE.md"];
78
+
79
+ for (const file of targets) {
80
+ const path = join(cwd, file);
81
+ const current = existsSync(path) ? read(path) : "";
82
+
83
+ if (current.includes(LLMS_PATH)) {
84
+ add("skipped", file, "이미 llms.txt 참조가 있습니다");
85
+ continue;
86
+ }
87
+
88
+ const next = current
89
+ ? `${current.replace(/\s*$/, "")}\n${AGENT_SECTION}`
90
+ : `# 프로젝트 지침\n${AGENT_SECTION}`;
91
+
92
+ write(path, next);
93
+ add("added", file, existsSync(path) ? "디자인 시스템 지침 섹션 추가" : "생성 후 지침 추가");
94
+ }
95
+ }
96
+
97
+ /* ────────────────── 2. ESLint 설정 ────────────────── */
98
+
99
+ const ESLINT_IMPORT = `import sellmate from '${PKG}/eslint';`;
100
+ const ESLINT_SPREAD = ` ...sellmate.configs.recommended,`;
101
+ const ESLINT_SNIPPET = `${ESLINT_IMPORT}\n\nexport default [\n // ... 기존 설정\n${ESLINT_SPREAD}\n];`;
102
+
103
+ /** 배열 리터럴의 닫는 대괄호 위치를 균형 계산으로 찾는다 */
104
+ function matchingBracket(src, openIndex) {
105
+ let depth = 0;
106
+ for (let i = openIndex; i < src.length; i++) {
107
+ const ch = src[i];
108
+ if (ch === "[") depth++;
109
+ else if (ch === "]") {
110
+ depth--;
111
+ if (depth === 0) return i;
112
+ }
113
+ }
114
+ return -1;
115
+ }
116
+
117
+ function stepEslint() {
118
+ const file = firstExisting([
119
+ "eslint.config.mjs", "eslint.config.js", "eslint.config.ts", "eslint.config.cjs",
120
+ ]);
121
+
122
+ if (!file) {
123
+ add("manual", "eslint.config.mjs", "설정 파일이 없습니다 — 아래 내용으로 만드세요", ESLINT_SNIPPET);
124
+ return;
125
+ }
126
+
127
+ const path = join(cwd, file);
128
+ const src = read(path);
129
+
130
+ if (src.includes(`${PKG}/eslint`)) {
131
+ add("skipped", file, "이미 ESLint 프리셋이 연결되어 있습니다");
132
+ return;
133
+ }
134
+
135
+ // `export default [` 형태만 자동 삽입한다. 그 외(defineConfig(...), tseslint.config(...) 등)는
136
+ // 배열 경계를 확신할 수 없으므로 손대지 않고 스니펫을 안내한다.
137
+ const exportMatch = src.match(/export\s+default\s*\[/);
138
+ if (!exportMatch) {
139
+ add("manual", file, "자동 삽입이 어려운 형태입니다 — 아래를 직접 추가하세요", ESLINT_SNIPPET);
140
+ return;
141
+ }
142
+
143
+ const openIndex = src.indexOf("[", exportMatch.index);
144
+ const closeIndex = matchingBracket(src, openIndex);
145
+ if (closeIndex === -1) {
146
+ add("manual", file, "배열 끝을 찾지 못했습니다 — 아래를 직접 추가하세요", ESLINT_SNIPPET);
147
+ return;
148
+ }
149
+
150
+ // import 는 마지막 최상위 import 뒤에 붙인다
151
+ const importMatches = [...src.matchAll(/^import .*?;?\s*$/gm)];
152
+ const lastImport = importMatches.at(-1);
153
+ const withImport = lastImport
154
+ ? src.slice(0, lastImport.index + lastImport[0].length) +
155
+ `\n${ESLINT_IMPORT}` +
156
+ src.slice(lastImport.index + lastImport[0].length)
157
+ : `${ESLINT_IMPORT}\n${src}`;
158
+
159
+ // import 삽입으로 밀린 만큼 닫는 괄호 위치를 보정
160
+ const shift = withImport.length - src.length;
161
+ const close = closeIndex + shift;
162
+
163
+ // 배열 마지막 항목 뒤에 삽입한다 (DS 프리셋이 뒤에 와야 앞선 설정을 덮는다)
164
+ const before = withImport.slice(0, close);
165
+ const needsComma = /[^[\s,]\s*$/.test(before);
166
+ const next =
167
+ before.replace(/\s*$/, "") + (needsComma ? "," : "") + `\n${ESLINT_SPREAD}\n` +
168
+ withImport.slice(close);
169
+
170
+ write(path, next);
171
+ add("added", file, "프리셋(configs.recommended) 연결");
172
+ }
173
+
174
+ /* ────────────────── 3. 전역 CSS ────────────────── */
175
+
176
+ /** 앱의 전역 CSS 를 찾는다 */
177
+ function findGlobalCss() {
178
+ const candidates = [
179
+ "app/globals.css", "src/app/globals.css", "src/styles/globals.css",
180
+ "styles/globals.css", "src/index.css", "src/main.css", "src/global.css",
181
+ ];
182
+ const found = firstExisting(candidates);
183
+ if (found) return found;
184
+
185
+ // 후보에 없으면 @import "tailwindcss" 가 있는 css 를 얕게 탐색
186
+ const roots = ["src", "app", "styles"].filter((d) => existsSync(join(cwd, d)));
187
+ for (const root of roots) {
188
+ const hit = walkCss(join(cwd, root), 3);
189
+ if (hit) return relative(cwd, hit);
190
+ }
191
+ return null;
192
+ }
193
+
194
+ function walkCss(dir, depth) {
195
+ if (depth < 0) return null;
196
+ let entries;
197
+ try {
198
+ entries = readdirSync(dir);
199
+ } catch {
200
+ return null;
201
+ }
202
+ for (const name of entries) {
203
+ if (name === "node_modules" || name.startsWith(".")) continue;
204
+ const p = join(dir, name);
205
+ let st;
206
+ try {
207
+ st = statSync(p);
208
+ } catch {
209
+ continue;
210
+ }
211
+ if (st.isDirectory()) {
212
+ const hit = walkCss(p, depth - 1);
213
+ if (hit) return hit;
214
+ } else if (name.endsWith(".css")) {
215
+ try {
216
+ if (read(p).includes("tailwindcss")) return p;
217
+ } catch {
218
+ /* 읽기 실패는 무시 */
219
+ }
220
+ }
221
+ }
222
+ return null;
223
+ }
224
+
225
+ function stepGlobalCss() {
226
+ const file = findGlobalCss();
227
+
228
+ if (!file) {
229
+ add(
230
+ "manual",
231
+ "전역 CSS",
232
+ "전역 CSS 를 찾지 못했습니다 — 앱의 전역 CSS 에 아래를 추가하세요",
233
+ `@import 'tailwindcss';\n@import '${PKG}/theme.css';\n@source "<상대경로>/node_modules/${PKG}/dist";`,
234
+ );
235
+ return;
236
+ }
237
+
238
+ const path = join(cwd, file);
239
+ const src = read(path);
240
+
241
+ // CSS 파일 기준 상대경로로 @source 를 계산한다 (README 주의사항)
242
+ const toNodeModules = relative(dirname(path), join(cwd, "node_modules", PKG, "dist"));
243
+ const sourcePath = toNodeModules.split(sep).join("/");
244
+
245
+ const hasTheme = src.includes(`${PKG}/theme.css`);
246
+ const hasSource = src.includes(`node_modules/${PKG}/dist`);
247
+
248
+ if (hasTheme && hasSource) {
249
+ add("skipped", file, "theme.css · @source 가 이미 설정되어 있습니다");
250
+ return;
251
+ }
252
+
253
+ const lines = [];
254
+ if (!hasTheme) lines.push(`@import '${PKG}/theme.css';`);
255
+ if (!hasSource) lines.push(`@source "${sourcePath}";`);
256
+
257
+ // @import 'tailwindcss' 바로 뒤에 넣는다 (theme.css 는 tailwindcss 를 import 하지 않는다)
258
+ const tw = src.match(/@import\s+["']tailwindcss["'];?/);
259
+ if (!tw) {
260
+ add(
261
+ "manual",
262
+ file,
263
+ "@import 'tailwindcss' 를 찾지 못했습니다 — 아래를 직접 추가하세요",
264
+ lines.join("\n"),
265
+ );
266
+ return;
267
+ }
268
+
269
+ const insertAt = tw.index + tw[0].length;
270
+ const next = src.slice(0, insertAt) + "\n" + lines.join("\n") + src.slice(insertAt);
271
+ write(path, next);
272
+ add("added", file, lines.length === 2 ? "theme.css import · @source 추가" : "누락분 추가");
273
+ }
274
+
275
+ /* ─────────────────────────── 실행 ─────────────────────────── */
276
+
277
+ function printHelp() {
278
+ console.log(`
279
+ ${paint("bold", "sellmate-ds")} — ${PKG} 소비 앱 설정
280
+
281
+ ${paint("cyan", "npx sellmate-ds init")} 설정을 자동으로 연결합니다
282
+ ${paint("cyan", "npx sellmate-ds init --dry-run")} 무엇이 바뀔지만 보여줍니다
283
+
284
+ 연결하는 것
285
+ · CLAUDE.md / AGENTS.md AI 에이전트가 규칙(llms.txt)을 읽도록 지침 추가
286
+ · eslint.config.mjs 디자인 시스템 ESLint 프리셋 연결
287
+ · 전역 CSS theme.css import 와 @source 경로 추가
288
+
289
+ 이미 되어 있는 항목은 건너뛰므로 여러 번 실행해도 안전합니다.
290
+ `);
291
+ }
292
+
293
+ if (command === "help" || args.includes("--help") || args.includes("-h")) {
294
+ printHelp();
295
+ process.exit(0);
296
+ }
297
+
298
+ if (command !== "init") {
299
+ console.error(paint("red", `알 수 없는 명령: ${command}`));
300
+ printHelp();
301
+ process.exit(1);
302
+ }
303
+
304
+ assertProjectRoot();
305
+
306
+ console.log(
307
+ `\n${paint("bold", `${PKG} 설정`)}${dryRun ? paint("yellow", " (dry-run — 파일을 바꾸지 않습니다)") : ""}\n`,
308
+ );
309
+
310
+ stepAgentInstructions();
311
+ stepEslint();
312
+ stepGlobalCss();
313
+
314
+ const icon = { added: paint("green", "✓"), skipped: paint("dim", "·"), manual: paint("yellow", "!"), error: paint("red", "✗") };
315
+
316
+ for (const r of results) {
317
+ console.log(` ${icon[r.status]} ${paint("bold", r.file)} ${paint("dim", r.detail)}`);
318
+ if (r.snippet) {
319
+ console.log(r.snippet.split("\n").map((l) => ` ${paint("cyan", l)}`).join("\n"));
320
+ }
321
+ }
322
+
323
+ const added = results.filter((r) => r.status === "added").length;
324
+ const manual = results.filter((r) => r.status === "manual").length;
325
+
326
+ console.log();
327
+ if (dryRun) {
328
+ console.log(paint("yellow", ` ${added}개 항목이 변경됩니다. --dry-run 을 빼고 다시 실행하세요.`));
329
+ } else if (added) {
330
+ console.log(paint("green", ` ${added}개 항목을 설정했습니다.`));
331
+ } else if (!manual) {
332
+ console.log(paint("dim", " 이미 모두 설정되어 있습니다."));
333
+ }
334
+ if (manual) {
335
+ console.log(paint("yellow", ` ${manual}개 항목은 위 내용을 직접 추가해야 합니다.`));
336
+ }
337
+ console.log();
@@ -11,6 +11,7 @@
11
11
  | `open?` | `boolean` | — | |
12
12
  | `persistent?` | `boolean` | — | true면 백드롭·ESC로 안 닫히고 흔들림 (sd-modal-container persistent) |
13
13
  | `modalTitle?` | `string` | `''` | |
14
+ | `description?` | `ReactNode` | — | 제목 오른쪽에 붙는 보조 설명 (sd-action-modal 의 header-sub-title 슬롯). 문자열이면 서브텍스트 스타일로 렌더하고, 노드를 넘기면 그대로 배치한다. |
14
15
  | `button?` | `SActionModalButton` | — | 하단 액션 버튼 (주 액션 1개). 의도적으로 단수다 — 보조 버튼(취소·삭제 등)은 `footerLeft` 슬롯에 직접 배치한다. |
15
16
  | `footerLeft?` | `ReactNode` | — | footer 좌측 영역 (sd-action-modal 의 bottom-sub-content 슬롯). 보조 버튼이나 안내 문구를 넣는다. 버튼을 하나 더 쓰고 싶을 때 여기에 SButton 을 넣는다. |
16
17
  | `width?` | `number \| string` | — | |
@@ -3350,6 +3350,7 @@ function SActionModal({
3350
3350
  onClose,
3351
3351
  persistent,
3352
3352
  modalTitle = "",
3353
+ description,
3353
3354
  button,
3354
3355
  footerLeft,
3355
3356
  width,
@@ -3369,7 +3370,10 @@ function SActionModal({
3369
3370
  height,
3370
3371
  className: "w-fit min-w-[480px] min-h-[min(320px,calc(100dvh-48px))]",
3371
3372
  children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-h-0 flex-auto flex-col", children: [
3372
- /* @__PURE__ */ jsxRuntime.jsx("header", { className: "flex flex-shrink-0 items-center gap-[var(--cmp-overlay-header-gap)] py-[var(--cmp-overlay-header-paddingY)] pl-[var(--cmp-overlay-header-paddingX)] pr-[calc(var(--cmp-overlay-header-paddingX)+20px)]", children: /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "text-[16px] font-bold leading-[26px] text-[color:var(--cmp-overlay-header-title-color)]", children: modalTitle }) }),
3373
+ /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex flex-shrink-0 items-center gap-[var(--cmp-overlay-header-gap)] py-[var(--cmp-overlay-header-paddingY)] pl-[var(--cmp-overlay-header-paddingX)] pr-[calc(var(--cmp-overlay-header-paddingX)+20px)]", children: [
3374
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "whitespace-nowrap text-[16px] font-bold leading-[26px] text-[color:var(--cmp-overlay-header-title-color)]", children: modalTitle }),
3375
+ description != null && description !== "" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex min-w-0 flex-1 items-center gap-2 text-[12px] font-normal leading-[20px] text-[color:var(--color-grey-65)]", children: description })
3376
+ ] }),
3373
3377
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1 overflow-auto px-[var(--cmp-modal-action-body-paddingX)] pb-[var(--cmp-modal-action-body-paddingBottom)]", children }),
3374
3378
  (button || footerLeft) && /* @__PURE__ */ jsxRuntime.jsxs(
3375
3379
  "footer",