sh-ui-cli 0.34.0 → 0.39.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.
@@ -56,40 +56,32 @@ const toDartColor = (hex) => `Color(0xFF${hex.replace('#', '').toUpperCase()})`;
56
56
  * inverse — 반대 모드의 편집값
57
57
  * default — playground 가 노출하지 않음, 고정 기본값 사용
58
58
  */
59
+ /**
60
+ * Dart ShUiColorTokens 필드 매핑. v0.37.0 부터 background-inverse / foreground-inverse /
61
+ * foreground-subtle 도 사용자가 직접 잡으므로 모두 self 룩업.
62
+ */
59
63
  const DART_FIELD_SOURCES = [
60
- { field: 'background', source: { kind: 'self', key: 'background' } },
61
- { field: 'backgroundSubtle', source: { kind: 'self', key: 'background-subtle' } },
62
- { field: 'backgroundMuted', source: { kind: 'self', key: 'background-muted' } },
63
- { field: 'backgroundInverse', source: { kind: 'inverse', key: 'background' } },
64
- { field: 'foreground', source: { kind: 'self', key: 'foreground' } },
65
- { field: 'foregroundMuted', source: { kind: 'self', key: 'foreground-muted' } },
66
- { field: 'foregroundSubtle', source: { kind: 'default' } },
67
- { field: 'foregroundInverse', source: { kind: 'inverse', key: 'foreground' } },
68
- { field: 'border', source: { kind: 'self', key: 'border' } },
69
- { field: 'borderStrong', source: { kind: 'self', key: 'border-strong' } },
70
- { field: 'primary', source: { kind: 'self', key: 'primary' } },
71
- { field: 'primaryForeground', source: { kind: 'self', key: 'primary-foreground' } },
72
- { field: 'primaryHover', source: { kind: 'self', key: 'primary-hover' } },
73
- { field: 'danger', source: { kind: 'self', key: 'danger' } },
74
- { field: 'dangerForeground', source: { kind: 'self', key: 'danger-foreground' } },
64
+ { field: 'background', key: 'background' },
65
+ { field: 'backgroundSubtle', key: 'background-subtle' },
66
+ { field: 'backgroundMuted', key: 'background-muted' },
67
+ { field: 'backgroundInverse', key: 'background-inverse' },
68
+ { field: 'foreground', key: 'foreground' },
69
+ { field: 'foregroundMuted', key: 'foreground-muted' },
70
+ { field: 'foregroundSubtle', key: 'foreground-subtle' },
71
+ { field: 'foregroundInverse', key: 'foreground-inverse' },
72
+ { field: 'border', key: 'border' },
73
+ { field: 'borderStrong', key: 'border-strong' },
74
+ { field: 'primary', key: 'primary' },
75
+ { field: 'primaryForeground', key: 'primary-foreground' },
76
+ { field: 'primaryHover', key: 'primary-hover' },
77
+ { field: 'danger', key: 'danger' },
78
+ { field: 'dangerForeground', key: 'danger-foreground' },
75
79
  ];
76
80
 
77
- const DART_DEFAULTS = {
78
- light: { foregroundSubtle: '0xFFA3A3A3' },
79
- dark: { foregroundSubtle: '0xFF737373' },
80
- };
81
-
82
- const buildDartStaticConst = (mode, self, opposite) => {
83
- const lines = DART_FIELD_SOURCES.map(({ field, source }) => {
84
- switch (source.kind) {
85
- case 'self':
86
- return ` ${field}: ${toDartColor(self[source.key])},`;
87
- case 'inverse':
88
- return ` ${field}: ${toDartColor(opposite[source.key])},`;
89
- case 'default':
90
- return ` ${field}: Color(${DART_DEFAULTS[mode][field]}),`;
91
- }
92
- }).join('\n');
81
+ const buildDartStaticConst = (mode, self) => {
82
+ const lines = DART_FIELD_SOURCES.map(({ field, key }) =>
83
+ ` ${field}: ${toDartColor(self[key])},`,
84
+ ).join('\n');
93
85
  return [
94
86
  ` static const ${mode} = ShUiColorTokens(`,
95
87
  lines,
@@ -99,9 +91,9 @@ const buildDartStaticConst = (mode, self, opposite) => {
99
91
 
100
92
  export const buildDartColorsBlock = (theme) => {
101
93
  return [
102
- buildDartStaticConst('light', theme.light, theme.dark),
94
+ buildDartStaticConst('light', theme.light),
103
95
  '',
104
- buildDartStaticConst('dark', theme.dark, theme.light),
96
+ buildDartStaticConst('dark', theme.dark),
105
97
  ].join('\n');
106
98
  };
107
99
 
@@ -109,3 +101,295 @@ export const buildDartRadiusBlock = (theme) => {
109
101
  const px = (theme.radius * 16).toFixed(1);
110
102
  return ` defaultRadius: ${px},`;
111
103
  };
104
+
105
+ // ─── 옵셔널 카테고리 빌더 (Phase 2) ───
106
+ //
107
+ // 모두 마커 사이에 들어갈 본문만 만든다. CSS 는 변수 선언 N줄, Dart 는 필드 N줄.
108
+
109
+ /* --- spacing --- */
110
+
111
+ const SPACING_KEYS = ['0', '1', '2', '3', '4', '5', '6', '8', '10', '12', '16'];
112
+
113
+ export const buildCssSpacingBlock = (spacing) =>
114
+ SPACING_KEYS.map((k) => ` --space-${k}: ${spacing[k]}px;`).join('\n');
115
+
116
+ export const buildDartSpacingBlock = (spacing) =>
117
+ SPACING_KEYS.map((k) => ` s${k}: ${spacing[k].toFixed(1)},`).join('\n');
118
+
119
+ /* --- typography --- */
120
+
121
+ const TYPOGRAPHY_KEYS = ['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl'];
122
+
123
+ export const buildCssTypographyBlock = (typography) =>
124
+ TYPOGRAPHY_KEYS.map((k) => ` --text-${k}: ${typography[k]}px;`).join('\n');
125
+
126
+ // Dart 클래스는 'xl2'/'xl3'/'xl4' 명명 규칙 사용 (숫자 prefix 회피).
127
+ const DART_TEXT_FIELD_NAMES = {
128
+ xs: 'xs', sm: 'sm', base: 'base', lg: 'lg', xl: 'xl',
129
+ '2xl': 'xl2', '3xl': 'xl3', '4xl': 'xl4',
130
+ };
131
+
132
+ export const buildDartTypographyBlock = (typography) =>
133
+ TYPOGRAPHY_KEYS
134
+ .map((k) => ` ${DART_TEXT_FIELD_NAMES[k]}: ${typography[k].toFixed(1)},`)
135
+ .join('\n');
136
+
137
+ /* --- weights --- */
138
+
139
+ const WEIGHT_KEYS = ['regular', 'medium', 'semibold', 'bold'];
140
+
141
+ export const buildCssWeightsBlock = (weights) =>
142
+ WEIGHT_KEYS.map((k) => ` --weight-${k}: ${weights[k]};`).join('\n');
143
+
144
+ export const buildDartWeightsBlock = (weights) =>
145
+ WEIGHT_KEYS.map((k) => ` ${k}: FontWeight.w${weights[k]},`).join('\n');
146
+
147
+ /* --- controls --- */
148
+
149
+ const CONTROL_KEYS = ['sm', 'md', 'lg'];
150
+
151
+ export const buildCssControlsBlock = (controls) =>
152
+ CONTROL_KEYS.map((k) => ` --control-${k}: ${controls[k]}px;`).join('\n');
153
+
154
+ export const buildDartControlsBlock = (controls) =>
155
+ CONTROL_KEYS.map((k) => ` ${k}: ${controls[k].toFixed(1)},`).join('\n');
156
+
157
+ /* --- borders --- */
158
+
159
+ export const buildCssBordersBlock = (borders) => [
160
+ ` --border-width: ${borders.width}px;`,
161
+ ` --border-width-strong: ${borders.widthStrong}px;`,
162
+ ].join('\n');
163
+
164
+ export const buildDartBordersBlock = (borders) => [
165
+ ` normal: ${borders.width.toFixed(1)},`,
166
+ ` strong: ${borders.widthStrong.toFixed(1)},`,
167
+ ].join('\n');
168
+
169
+ /* --- durations --- */
170
+
171
+ const DURATION_KEYS = ['fast', 'base', 'slow'];
172
+
173
+ export const buildCssDurationsBlock = (durations) =>
174
+ DURATION_KEYS.map((k) => ` --duration-${k}: ${durations[k]}ms;`).join('\n');
175
+
176
+ export const buildDartDurationsBlock = (durations) =>
177
+ DURATION_KEYS
178
+ .map((k) => ` ${k}: Duration(milliseconds: ${Math.round(durations[k])}),`)
179
+ .join('\n');
180
+
181
+ // ─── Phase 3 — string 카테고리: shadow / ease / gradient ───
182
+ //
183
+ // CSS 측은 사용자가 입력한 문자열을 거의 그대로 흘려보내고, Dart 측은 파서를 거쳐
184
+ // BoxShadow / Cubic / LinearGradient 로 변환. 파서는 형식 위배 시 throw.
185
+
186
+ /* --- 공통 유틸 --- */
187
+
188
+ /** 괄호 깊이 0 인 위치에서만 콤마로 split. rgba(0,0,0,0.5) 같은 것을 보호. */
189
+ const splitTopLevelByComma = (str) => {
190
+ const parts = [];
191
+ let depth = 0, start = 0;
192
+ for (let i = 0; i < str.length; i++) {
193
+ const c = str[i];
194
+ if (c === '(') depth++;
195
+ else if (c === ')') depth--;
196
+ else if (c === ',' && depth === 0) {
197
+ parts.push(str.slice(start, i).trim());
198
+ start = i + 1;
199
+ }
200
+ }
201
+ parts.push(str.slice(start).trim());
202
+ return parts.filter(Boolean);
203
+ };
204
+
205
+ /** 공백 split — 단 괄호 안은 그룹 유지. */
206
+ const tokenizeSpaceAware = (str) => {
207
+ const tokens = [];
208
+ let depth = 0, start = 0;
209
+ const flush = (end) => {
210
+ const t = str.slice(start, end).trim();
211
+ if (t) tokens.push(t);
212
+ };
213
+ for (let i = 0; i < str.length; i++) {
214
+ const c = str[i];
215
+ if (c === '(') depth++;
216
+ else if (c === ')') depth--;
217
+ else if (/\s/.test(c) && depth === 0) {
218
+ flush(i);
219
+ start = i + 1;
220
+ }
221
+ }
222
+ flush(str.length);
223
+ return tokens;
224
+ };
225
+
226
+ const cssLengthToPx = (s, ctx) => {
227
+ const m = s.match(/^(-?[\d.]+)(?:px)?$/);
228
+ if (!m) throw new Error(`${ctx}: 길이 단위 오류 — '${s}'`);
229
+ return parseFloat(m[1]);
230
+ };
231
+
232
+ /** CSS color → { r, g, b, a } (a ∈ 0..255). #RRGGBB / #RRGGBBAA / rgb() / rgba() 지원. */
233
+ const parseCssColor = (s, ctx) => {
234
+ if (s.startsWith('#')) {
235
+ const hex = s.slice(1);
236
+ if (hex.length === 6) {
237
+ return {
238
+ r: parseInt(hex.slice(0, 2), 16),
239
+ g: parseInt(hex.slice(2, 4), 16),
240
+ b: parseInt(hex.slice(4, 6), 16),
241
+ a: 0xff,
242
+ };
243
+ }
244
+ if (hex.length === 8) {
245
+ return {
246
+ r: parseInt(hex.slice(0, 2), 16),
247
+ g: parseInt(hex.slice(2, 4), 16),
248
+ b: parseInt(hex.slice(4, 6), 16),
249
+ a: parseInt(hex.slice(6, 8), 16),
250
+ };
251
+ }
252
+ throw new Error(`${ctx}: hex 색은 #RRGGBB 또는 #RRGGBBAA 만 지원 — '${s}'`);
253
+ }
254
+ const m = s.match(/^rgba?\(\s*([^)]+)\)$/);
255
+ if (m) {
256
+ const parts = m[1].split(',').map((p) => p.trim());
257
+ if (parts.length < 3 || parts.length > 4) {
258
+ throw new Error(`${ctx}: rgb/rgba 인자 수 오류 — '${s}'`);
259
+ }
260
+ const r = parseInt(parts[0], 10);
261
+ const g = parseInt(parts[1], 10);
262
+ const b = parseInt(parts[2], 10);
263
+ const a = parts[3] !== undefined ? Math.round(parseFloat(parts[3]) * 255) : 0xff;
264
+ if ([r, g, b, a].some((n) => Number.isNaN(n))) {
265
+ throw new Error(`${ctx}: rgb/rgba 숫자 파싱 실패 — '${s}'`);
266
+ }
267
+ return { r, g, b, a };
268
+ }
269
+ throw new Error(`${ctx}: 색 형식 미지원 (#hex / rgb() / rgba() 만) — '${s}'`);
270
+ };
271
+
272
+ const colorToARGBHex = ({ a, r, g, b }) => {
273
+ const v = (((a & 0xff) << 24) | ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff)) >>> 0;
274
+ return v.toString(16).toUpperCase().padStart(8, '0');
275
+ };
276
+
277
+ /* --- shadow --- */
278
+
279
+ const SHADOW_KEYS = ['sm', 'md', 'lg', 'xl'];
280
+
281
+ export const buildCssShadowsBlock = (shadows) =>
282
+ SHADOW_KEYS.map((k) => ` --shadow-${k}: ${shadows[k]};`).join('\n');
283
+
284
+ const parseSingleBoxShadow = (s) => {
285
+ const tokens = tokenizeSpaceAware(s);
286
+ if (tokens.length < 4 || tokens.length > 5) {
287
+ throw new Error(`shadow: 토큰 수 4~5 (offset-x offset-y blur [spread] color) — '${s}'`);
288
+ }
289
+ const offsetX = cssLengthToPx(tokens[0], 'shadow.offset-x');
290
+ const offsetY = cssLengthToPx(tokens[1], 'shadow.offset-y');
291
+ const blur = cssLengthToPx(tokens[2], 'shadow.blur');
292
+ let spread = 0;
293
+ let colorStr;
294
+ if (tokens.length === 4) {
295
+ colorStr = tokens[3];
296
+ } else {
297
+ spread = cssLengthToPx(tokens[3], 'shadow.spread');
298
+ colorStr = tokens[4];
299
+ }
300
+ const color = parseCssColor(colorStr, 'shadow.color');
301
+ return { offsetX, offsetY, blur, spread, color };
302
+ };
303
+
304
+ export const cssShadowToDartList = (cssValue) => {
305
+ const items = splitTopLevelByComma(cssValue).map(parseSingleBoxShadow);
306
+ const dartItems = items.map((it) => {
307
+ const hex = colorToARGBHex(it.color);
308
+ return `BoxShadow(offset: Offset(${it.offsetX.toFixed(1)}, ${it.offsetY.toFixed(1)}), blurRadius: ${it.blur.toFixed(1)}, spreadRadius: ${it.spread.toFixed(1)}, color: Color(0x${hex}))`;
309
+ });
310
+ return `<BoxShadow>[${dartItems.join(', ')}]`;
311
+ };
312
+
313
+ export const buildDartShadowsBlock = (shadows) =>
314
+ SHADOW_KEYS.map((k) => ` ${k}: ${cssShadowToDartList(shadows[k])},`).join('\n');
315
+
316
+ /* --- ease --- */
317
+
318
+ const EASE_KEYS = ['standard', 'emphasized'];
319
+
320
+ const NAMED_EASES = {
321
+ linear: [0, 0, 1, 1],
322
+ ease: [0.25, 0.1, 0.25, 1],
323
+ 'ease-in': [0.42, 0, 1, 1],
324
+ 'ease-out': [0, 0, 0.58, 1],
325
+ 'ease-in-out': [0.42, 0, 0.58, 1],
326
+ };
327
+
328
+ export const buildCssEasesBlock = (eases) =>
329
+ EASE_KEYS.map((k) => ` --ease-${k}: ${eases[k]};`).join('\n');
330
+
331
+ export const cssEaseToDartCubic = (cssValue) => {
332
+ const trimmed = cssValue.trim();
333
+ if (NAMED_EASES[trimmed]) {
334
+ const [x1, y1, x2, y2] = NAMED_EASES[trimmed];
335
+ return `Cubic(${x1}, ${y1}, ${x2}, ${y2})`;
336
+ }
337
+ const m = trimmed.match(/^cubic-bezier\(([^)]+)\)$/);
338
+ if (!m) throw new Error(`ease: cubic-bezier(...) 또는 named easing 만 — '${cssValue}'`);
339
+ const nums = m[1].split(',').map((s) => parseFloat(s.trim()));
340
+ if (nums.length !== 4 || nums.some((n) => Number.isNaN(n))) {
341
+ throw new Error(`ease: cubic-bezier 인자 4개 숫자 필요 — '${cssValue}'`);
342
+ }
343
+ return `Cubic(${nums.join(', ')})`;
344
+ };
345
+
346
+ export const buildDartEasesBlock = (eases) =>
347
+ EASE_KEYS.map((k) => ` ${k}: ${cssEaseToDartCubic(eases[k])},`).join('\n');
348
+
349
+ /* --- gradient --- */
350
+
351
+ const GRADIENT_KEYS = ['primary', 'surface', 'overlay'];
352
+
353
+ export const buildCssGradientsBlock = (gradients) =>
354
+ GRADIENT_KEYS.map((k) => ` --gradient-${k}: ${gradients[k]};`).join('\n');
355
+
356
+ /**
357
+ * CSS gradient 의 deg → Flutter Alignment(begin, end).
358
+ * CSS 0deg=위쪽, 시계방향. Flutter Alignment 의 +y 는 아래.
359
+ */
360
+ const angleToBeginEnd = (deg) => {
361
+ const rad = (deg * Math.PI) / 180;
362
+ const ex = Math.sin(rad);
363
+ const ey = -Math.cos(rad);
364
+ const round3 = (v) => parseFloat(v.toFixed(3));
365
+ return {
366
+ begin: [round3(-ex), round3(-ey)],
367
+ end: [round3(ex), round3(ey)],
368
+ };
369
+ };
370
+
371
+ export const cssGradientToDartLinear = (cssValue) => {
372
+ const trimmed = cssValue.trim();
373
+ const m = trimmed.match(/^linear-gradient\(([\s\S]+)\)$/);
374
+ if (!m) throw new Error(`gradient: linear-gradient(...) 만 — '${cssValue}'`);
375
+ const parts = splitTopLevelByComma(m[1]);
376
+ if (parts.length < 3) throw new Error(`gradient: 인자 부족 (각도 + 스톱 ≥ 2) — '${cssValue}'`);
377
+ const angleM = parts[0].match(/^(-?[\d.]+)deg$/);
378
+ if (!angleM) throw new Error(`gradient: 첫 인자가 <angle>deg 아님 — '${parts[0]}'`);
379
+ const angle = parseFloat(angleM[1]);
380
+ const stops = parts.slice(1).map((s) => {
381
+ const tokens = tokenizeSpaceAware(s);
382
+ if (tokens.length !== 2) throw new Error(`gradient: 스톱 형식 '<color> <pos>%' — '${s}'`);
383
+ const color = parseCssColor(tokens[0], 'gradient.stop.color');
384
+ const pm = tokens[1].match(/^([\d.]+)%$/);
385
+ if (!pm) throw new Error(`gradient: 스톱 위치 % 형식 — '${tokens[1]}'`);
386
+ return { color, position: parseFloat(pm[1]) };
387
+ });
388
+ const { begin, end } = angleToBeginEnd(angle);
389
+ const colorList = stops.map((st) => `Color(0x${colorToARGBHex(st.color)})`).join(', ');
390
+ const stopList = stops.map((st) => (st.position / 100).toFixed(2)).join(', ');
391
+ return `LinearGradient(begin: Alignment(${begin[0]}, ${begin[1]}), end: Alignment(${end[0]}, ${end[1]}), colors: <Color>[${colorList}], stops: <double>[${stopList}])`;
392
+ };
393
+
394
+ export const buildDartGradientsBlock = (gradients) =>
395
+ GRADIENT_KEYS.map((k) => ` ${k}: ${cssGradientToDartLinear(gradients[k])},`).join('\n');
@@ -0,0 +1,160 @@
1
+ // 프로젝트 스캐폴드용 테마 프리셋. 사용자가 --theme <name> 또는 대화형 프롬프트에서
2
+ // 고르면 generator 가 tokens.css / sh_ui_tokens.dart 에 그대로 주입한다.
3
+ //
4
+ // 색은 shadcn/ui · Tailwind 팔레트를 참고해 light/dark 모두 충분한 명도 대비를 갖도록 잡았다.
5
+ // radius 만 살짝 변주해 프리셋 별 인상 차이를 더했다 (slate=0.375 sharp, rose=0.75 round, …).
6
+ //
7
+ // 새 프리셋을 추가하면 cli-args.js 의 VALID_THEME_PRESETS 에도 자동 반영된다 (이 파일에서 derive).
8
+
9
+ const NEUTRAL_LIGHT = {
10
+ 'background': '#FFFFFF',
11
+ 'background-subtle': '#FAFAFA',
12
+ 'background-muted': '#F5F5F5',
13
+ 'background-inverse': '#0A0A0A',
14
+ 'foreground': '#0A0A0A',
15
+ 'foreground-muted': '#525252',
16
+ 'foreground-subtle': '#A3A3A3',
17
+ 'foreground-inverse': '#FFFFFF',
18
+ 'border': '#E5E5E5',
19
+ 'border-strong': '#D4D4D4',
20
+ 'primary': '#171717',
21
+ 'primary-foreground': '#FAFAFA',
22
+ 'primary-hover': '#262626',
23
+ 'danger': '#DC2626',
24
+ 'danger-foreground': '#FFFFFF',
25
+ };
26
+
27
+ const NEUTRAL_DARK = {
28
+ 'background': '#0A0A0A',
29
+ 'background-subtle': '#171717',
30
+ 'background-muted': '#262626',
31
+ 'background-inverse': '#FFFFFF',
32
+ 'foreground': '#FAFAFA',
33
+ 'foreground-muted': '#A3A3A3',
34
+ 'foreground-subtle': '#737373',
35
+ 'foreground-inverse': '#0A0A0A',
36
+ 'border': '#262626',
37
+ 'border-strong': '#404040',
38
+ 'primary': '#FAFAFA',
39
+ 'primary-foreground': '#171717',
40
+ 'primary-hover': '#E5E5E5',
41
+ 'danger': '#DC2626',
42
+ 'danger-foreground': '#FFFFFF',
43
+ };
44
+
45
+ export const THEME_PRESETS = {
46
+ neutral: {
47
+ label: '뉴트럴 — 흑백 강조 (sh-ui 기본)',
48
+ light: NEUTRAL_LIGHT,
49
+ dark: NEUTRAL_DARK,
50
+ radius: 0.5,
51
+ },
52
+ slate: {
53
+ label: '슬레이트 — 차분한 슬레이트 + 인디고 (정보 밀도)',
54
+ light: {
55
+ 'background': '#FFFFFF',
56
+ 'background-subtle': '#F8FAFC',
57
+ 'background-muted': '#F1F5F9',
58
+ 'background-inverse': '#0F172A',
59
+ 'foreground': '#0F172A',
60
+ 'foreground-muted': '#475569',
61
+ 'foreground-subtle': '#94A3B8',
62
+ 'foreground-inverse': '#F1F5F9',
63
+ 'border': '#E2E8F0',
64
+ 'border-strong': '#CBD5E1',
65
+ 'primary': '#4F46E5',
66
+ 'primary-foreground': '#FFFFFF',
67
+ 'primary-hover': '#4338CA',
68
+ 'danger': '#DC2626',
69
+ 'danger-foreground': '#FFFFFF',
70
+ },
71
+ dark: {
72
+ 'background': '#0F172A',
73
+ 'background-subtle': '#1E293B',
74
+ 'background-muted': '#334155',
75
+ 'background-inverse': '#FFFFFF',
76
+ 'foreground': '#F1F5F9',
77
+ 'foreground-muted': '#94A3B8',
78
+ 'foreground-subtle': '#64748B',
79
+ 'foreground-inverse': '#0F172A',
80
+ 'border': '#334155',
81
+ 'border-strong': '#475569',
82
+ 'primary': '#818CF8',
83
+ 'primary-foreground': '#1E1B4B',
84
+ 'primary-hover': '#A5B4FC',
85
+ 'danger': '#F87171',
86
+ 'danger-foreground': '#450A0A',
87
+ },
88
+ radius: 0.375,
89
+ // 정보 밀도 ↑ — 본문 14px 부터, 컨트롤 36px (대시보드/관리자 인상)
90
+ typography: { xs: 11, sm: 12, base: 14, lg: 16, xl: 18, '2xl': 21, '3xl': 26, '4xl': 32 },
91
+ controls: { sm: 28, md: 36, lg: 44 },
92
+ },
93
+ rose: {
94
+ label: '로즈 — 핑크 강조 + 둥근 모서리 (친근·여유)',
95
+ light: {
96
+ ...NEUTRAL_LIGHT,
97
+ 'primary': '#E11D48',
98
+ 'primary-foreground': '#FFF1F2',
99
+ 'primary-hover': '#BE123C',
100
+ },
101
+ dark: {
102
+ ...NEUTRAL_DARK,
103
+ 'primary': '#FB7185',
104
+ 'primary-foreground': '#4C0519',
105
+ 'primary-hover': '#FDA4AF',
106
+ },
107
+ radius: 0.75,
108
+ // 큰 모서리 + 큼직한 컨트롤 — 소비자 앱 / 캐주얼 인상
109
+ controls: { sm: 36, md: 44, lg: 52 },
110
+ },
111
+ emerald: {
112
+ label: '에메랄드 — 그린 강조',
113
+ light: {
114
+ ...NEUTRAL_LIGHT,
115
+ 'primary': '#059669',
116
+ 'primary-foreground': '#ECFDF5',
117
+ 'primary-hover': '#047857',
118
+ },
119
+ dark: {
120
+ ...NEUTRAL_DARK,
121
+ 'primary': '#34D399',
122
+ 'primary-foreground': '#022C22',
123
+ 'primary-hover': '#6EE7B7',
124
+ },
125
+ radius: 0.5,
126
+ },
127
+ violet: {
128
+ label: '바이올렛 — 퍼플 강조 + 살짝 라운드 (모던·또렷)',
129
+ light: {
130
+ ...NEUTRAL_LIGHT,
131
+ 'primary': '#7C3AED',
132
+ 'primary-foreground': '#F5F3FF',
133
+ 'primary-hover': '#6D28D9',
134
+ },
135
+ dark: {
136
+ ...NEUTRAL_DARK,
137
+ 'primary': '#A78BFA',
138
+ 'primary-foreground': '#1E1B4B',
139
+ 'primary-hover': '#C4B5FD',
140
+ },
141
+ radius: 0.625,
142
+ // 살짝 큰 컨트롤 + 진한 강조 보더 (creative/디자인 도구 인상)
143
+ controls: { sm: 34, md: 42, lg: 50 },
144
+ borders: { width: 1, widthStrong: 3 },
145
+ },
146
+ };
147
+
148
+ export const THEME_PRESET_NAMES = Object.keys(THEME_PRESETS);
149
+
150
+ /**
151
+ * 프리셋 객체에서 ThemeConfig 형태로 변환 — 사용자에게 보이는 label 빼고 모두 forward.
152
+ * v0.39.0 부터 light/dark/radius 외에 옵셔널 카테고리(typography/controls/borders/...)
153
+ * 도 그대로 전달돼 CLI inject 가 카테고리별 마커 섹션을 교체한다.
154
+ */
155
+ export const getThemePreset = (name) => {
156
+ const preset = THEME_PRESETS[name];
157
+ if (!preset) return null;
158
+ const { label: _label, ...themeConfig } = preset;
159
+ return themeConfig;
160
+ };
package/src/mcp.mjs CHANGED
@@ -38,12 +38,14 @@ import {
38
38
  THEME_MODES,
39
39
  } from "./constants.js";
40
40
  import { allPlugins } from "./create/plugins/index.js";
41
+ import { THEME_PRESET_NAMES } from "./create/theme/presets.js";
41
42
 
42
43
  const PLATFORMS = INIT_PLATFORMS;
43
44
  const BASES = THEME_BASES;
44
45
  const RADII = THEME_RADII;
45
46
  const MODES = THEME_MODES;
46
47
  const PLUGIN_NAMES = allPlugins.map((p) => p.name);
48
+ const THEME_PRESETS_LIST = THEME_PRESET_NAMES.join(", ");
47
49
 
48
50
  const INIT_DESCRIPTIONS = {
49
51
  platform: {
@@ -178,7 +180,7 @@ export async function startMcpServer() {
178
180
  plugins: z.array(z.enum(PLUGIN_NAMES)).optional()
179
181
  .describe(`Next.js 플러그인 (${PLUGIN_NAMES.join(', ')}). 미지정시 빈 배열`),
180
182
  theme: z.string().optional()
181
- .describe("base64 인코딩된 테마 JSON (선택)"),
183
+ .describe(`프리셋 이름 (${THEME_PRESETS_LIST}) 또는 playground 에서 생성한 base64 (선택)`),
182
184
  cwd: z.string().optional()
183
185
  .describe("부모 디렉토리. 기본 process.cwd()"),
184
186
  force: z.boolean().optional()