sh-ui-cli 0.34.0 → 0.38.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.
@@ -2,6 +2,25 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
4
4
  "versions": [
5
+ {
6
+ "version": "0.38.0",
7
+ "date": "2026-04-29",
8
+ "title": "테마 시스템 풀스택 — 5 프리셋 + 풀 토큰 편집기 + 스캐폴드 threading + CSS↔Flutter 변환기",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**프리셋 5종 (CLI + 플레이그라운드)** — neutral · slate · rose · emerald · violet. `sh-ui create --theme rose` 처럼 짧게 받거나 playground 에서 만든 base64 그대로. 플레이그라운드 토큰 편집기 위쪽 '프리셋' 행 클릭 한 번으로 light/dark/radius 동시 적용. sh-ui-cli/api 가 단일 소스",
12
+ "**쉬운 / 고급 토글** — 쉬운 모드는 베이스 톤 4(neutral/slate/warm/cool) + 포인트 컬러 1개 픽커(primary-foreground/primary-hover 자동 파생). 고급 모드는 8 카테고리 collapsible 섹션 — 색(15) · 크기 · 테두리 · 그림자 · 타이포 · 여백 · 폰트굵기 · 모션. 모든 슬라이더는 sh-ui Slider 사용",
13
+ "**그라데이션 빌더 + 슬롯 3개** (primary/surface/overlay) — 각도 슬라이더 + 2 컬러스톱 + 위치 슬라이더 + 라이브 프리뷰. CSS 변수 `--gradient-*` 로 캔버스 흘러감",
14
+ "**스캐폴드 threading 풀스택** — 색 15 + radius + 9 옵셔널 카테고리 (spacing 11 / typography 8 / weights 4 / controls 3 / borders 2 / durations 3 / shadows 4 / eases 2 / gradients 3). theme JSON 에 키가 있으면 tokens.css / sh_ui_tokens.dart 의 마커 섹션 교체, 없으면 템플릿 디폴트 유지. 디폴트와 다른 카테고리만 base64 에 포함돼 페이로드 최소화",
15
+ "**CSS↔Flutter 변환기** — shadow `0 1px 2px rgba(0,0,0,0.08)` → `BoxShadow(offset, blurRadius, spreadRadius, color: Color(0x14000000))` (alpha = 0.08 × 255). ease `cubic-bezier(0.4,0,0.2,1)` → `Cubic(0.4,0,0.2,1)` (named easing 도 정확한 4-인자로). gradient `linear-gradient(135deg, ...)` → `LinearGradient(begin: Alignment(-0.707,-0.707), end: Alignment(0.707,0.707), colors, stops)`. CSS deg → Flutter Alignment (0deg=topCenter, 시계방향)",
16
+ "**확장 색 직접 편집** — background-inverse / foreground-inverse / foreground-subtle 3개를 사용자가 직접 잡음. 기존 Dart 자동 inverse 거울 / 고정 foregroundSubtle 디폴트 폐기. 5 프리셋 light/dark 모두 15키 풀세트",
17
+ "**Flutter 템플릿** — ShUiGradientTokens 클래스 신설 + ShUiTheme 에 gradient 필드 통합. tokens.css / sh_ui_tokens.dart 에 카테고리별 마커 9쌍 (theme-space/text/weight/shadow/duration/ease/control/border-width/gradient)",
18
+ "**DatePicker 회귀 픽스 2개** — (1) container prop 누락 → 추가 (Select·Combobox·Popover 와 동일 패턴). 다크 모드를 page subtree 에만 입힌 환경에서 캘린더 popover 가 토큰 스코프 벗어나 흰 배경으로 뜨던 문제 해결. (2) 선택일 색을 --foreground 에서 --primary / --primary-foreground 로 전환 — rose/emerald 같은 primary 변경 프리셋이 캘린더에도 일관되게 반영. **호환성 주의**: primary 를 안 바꾸고 쓰던 사용자는 선택일 색이 검정 → 자기 primary 색으로 변경됨",
19
+ "**MCP** sh_ui_create_project 의 theme 파라미터가 프리셋 이름 수용 — IDE 에이전트가 `theme: \"slate\"` 한 줄로 호출. 짧은 오타는 base64 디코드 에러 대신 '알 수 없는 테마 프리셋: 지원 목록 …' 친절 안내",
20
+ "**테스트 104** (전 68 → +36). 옵셔널 카테고리 검증, 빌더 출력 형식, 파서 (rgba alpha / hex 8자리 / spread / multi-shadow / named ease / Alignment 각도 0·45·90·135·180), 잘못된 형식 throw, end-to-end Next + Flutter 스캐폴드 주입. localStorage 마이그레이션 (v0.34 까지 저장된 데이터는 새 키 디폴트 보강)"
21
+ ],
22
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.38.0"
23
+ },
5
24
  {
6
25
  "version": "0.34.0",
7
26
  "date": "2026-04-29",
@@ -329,6 +329,11 @@ export interface DatePickerProps {
329
329
  * @default true
330
330
  */
331
331
  closeOnSelect?: boolean;
332
+ /**
333
+ * Portal이 마운트될 DOM 노드. 토큰 스코프(다크 모드 등) 안에 popover를 띄우려면 해당 컨테이너 ref 전달.
334
+ * @default document.body
335
+ */
336
+ container?: React.ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
332
337
  /**
333
338
  * compound 모드. 미지정 시 기본 레이아웃(Trigger + Content + Calendar)이 자동 렌더된다.
334
339
  * 직접 조립하려면 `DatePickerTrigger`/`DatePickerContent`/`DatePickerCalendar`/`DatePickerFooter`를 자식으로 넘긴다.
@@ -353,6 +358,7 @@ export function DatePicker({
353
358
  "aria-invalid": ariaInvalid,
354
359
  className,
355
360
  closeOnSelect = true,
361
+ container,
356
362
  children,
357
363
  }: DatePickerProps) {
358
364
  const isControlled = value !== undefined;
@@ -417,7 +423,7 @@ export function DatePicker({
417
423
  {children ?? (
418
424
  <>
419
425
  <DatePickerTrigger className={className} />
420
- <DatePickerContent>
426
+ <DatePickerContent container={container}>
421
427
  <DatePickerCalendar />
422
428
  </DatePickerContent>
423
429
  </>
@@ -525,19 +531,24 @@ export interface DatePickerContentProps
525
531
  * @default "start"
526
532
  */
527
533
  align?: React.ComponentPropsWithoutRef<typeof BasePopover.Positioner>["align"];
534
+ /**
535
+ * Portal이 마운트될 DOM 노드. 토큰 스코프(다크 모드 등) 안에 popover를 띄우려면 해당 컨테이너 ref 전달.
536
+ * @default document.body
537
+ */
538
+ container?: React.ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
528
539
  }
529
540
 
530
541
  /** 캘린더 popover 본문. portal로 마운트되며 `disabled`/`readOnly`이면 렌더되지 않는다. */
531
542
  export const DatePickerContent = React.forwardRef<HTMLDivElement, DatePickerContentProps>(
532
543
  function DatePickerContent(
533
- { className, children, sideOffset = 4, side = "bottom", align = "start", ...props },
544
+ { className, children, sideOffset = 4, side = "bottom", align = "start", container, ...props },
534
545
  ref,
535
546
  ) {
536
547
  const ctx = useDatePickerContext("DatePickerContent");
537
548
  if (ctx.disabled || ctx.readOnly) return null;
538
549
 
539
550
  return (
540
- <BasePopover.Portal>
551
+ <BasePopover.Portal container={container}>
541
552
  <BasePopover.Positioner
542
553
  className="sh-ui-date-picker__positioner"
543
554
  sideOffset={sideOffset}
@@ -639,6 +650,11 @@ export interface DateRangePickerProps {
639
650
  /** invalid 상태. */
640
651
  "aria-invalid"?: boolean | "true";
641
652
  className?: string;
653
+ /**
654
+ * Portal이 마운트될 DOM 노드. 토큰 스코프(다크 모드 등) 안에 popover를 띄우려면 해당 컨테이너 ref 전달.
655
+ * @default document.body
656
+ */
657
+ container?: React.ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
642
658
  }
643
659
 
644
660
  /**
@@ -659,6 +675,7 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
659
675
  readOnly,
660
676
  "aria-invalid": ariaInvalid,
661
677
  className,
678
+ container,
662
679
  },
663
680
  ref,
664
681
  ) {
@@ -724,7 +741,7 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
724
741
  </BasePopover.Trigger>
725
742
 
726
743
  {!disabled && !readOnly && (
727
- <BasePopover.Portal>
744
+ <BasePopover.Portal container={container}>
728
745
  <BasePopover.Positioner
729
746
  className="sh-ui-date-picker__positioner"
730
747
  sideOffset={4}
@@ -207,37 +207,37 @@
207
207
  }
208
208
 
209
209
  .sh-ui-calendar__day--selected {
210
- background: var(--foreground);
211
- color: var(--background);
210
+ background: var(--primary);
211
+ color: var(--primary-foreground);
212
212
  font-weight: var(--weight-semibold);
213
213
  }
214
214
 
215
215
  .sh-ui-calendar__day--selected:hover:not(:disabled) {
216
- background: var(--foreground);
217
- opacity: 0.9;
216
+ background: var(--primary-hover);
217
+ color: var(--primary-foreground);
218
218
  }
219
219
 
220
220
  /* ── Range ── */
221
221
 
222
222
  .sh-ui-calendar__day--in-range {
223
- background: color-mix(in srgb, var(--foreground) 10%, transparent);
223
+ background: color-mix(in srgb, var(--primary) 12%, transparent);
224
224
  border-radius: 0;
225
225
  }
226
226
 
227
227
  .sh-ui-calendar__day--in-range:hover:not(:disabled) {
228
- background: color-mix(in srgb, var(--foreground) 18%, transparent);
228
+ background: color-mix(in srgb, var(--primary) 22%, transparent);
229
229
  }
230
230
 
231
231
  .sh-ui-calendar__day--range-start {
232
- background: var(--foreground);
233
- color: var(--background);
232
+ background: var(--primary);
233
+ color: var(--primary-foreground);
234
234
  font-weight: var(--weight-semibold);
235
235
  border-radius: calc(var(--radius) - 2px) 0 0 calc(var(--radius) - 2px);
236
236
  }
237
237
 
238
238
  .sh-ui-calendar__day--range-end {
239
- background: var(--foreground);
240
- color: var(--background);
239
+ background: var(--primary);
240
+ color: var(--primary-foreground);
241
241
  font-weight: var(--weight-semibold);
242
242
  border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0;
243
243
  }
@@ -248,8 +248,8 @@
248
248
 
249
249
  .sh-ui-calendar__day--range-start:hover:not(:disabled),
250
250
  .sh-ui-calendar__day--range-end:hover:not(:disabled) {
251
- background: var(--foreground);
252
- opacity: 0.9;
251
+ background: var(--primary-hover);
252
+ color: var(--primary-foreground);
253
253
  }
254
254
 
255
255
  /* ── Hint (range picker) ── */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.34.0",
3
+ "version": "0.38.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/api.d.ts CHANGED
@@ -40,3 +40,27 @@ export type PluginManifest = {
40
40
  };
41
41
 
42
42
  export const allPlugins: readonly PluginManifest[];
43
+
44
+ /* ─────── 테마 프리셋 ─────── */
45
+
46
+ export type ThemePresetName = 'neutral' | 'slate' | 'rose' | 'emerald' | 'violet';
47
+
48
+ /** decode.js 의 TOKEN_KEYS — light/dark 양쪽이 가져야 하는 키 12개. */
49
+ export type ThemeTokenKey =
50
+ | 'background' | 'background-subtle' | 'background-muted'
51
+ | 'foreground' | 'foreground-muted'
52
+ | 'border' | 'border-strong'
53
+ | 'primary' | 'primary-foreground' | 'primary-hover'
54
+ | 'danger' | 'danger-foreground';
55
+
56
+ export interface ThemePreset {
57
+ /** UI 에 표시할 사람이 읽는 라벨. */
58
+ label: string;
59
+ light: Record<ThemeTokenKey, string>;
60
+ dark: Record<ThemeTokenKey, string>;
61
+ /** rem 단위 (0~1.5). */
62
+ radius: number;
63
+ }
64
+
65
+ export const THEME_PRESETS: Record<ThemePresetName, ThemePreset>;
66
+ export const THEME_PRESET_NAMES: readonly ThemePresetName[];
package/src/api.js CHANGED
@@ -19,3 +19,4 @@ export {
19
19
  } from './constants.js';
20
20
 
21
21
  export { allPlugins } from './create/plugins/index.js';
22
+ export { THEME_PRESETS, THEME_PRESET_NAMES } from './create/theme/presets.js';
@@ -4,13 +4,32 @@ import fs from 'fs-extra';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { getPluginChoices, getPluginsByNames } from './plugins/index.js';
7
- import { decodeTheme } from './theme/decode.js';
7
+ import { resolveTheme } from './theme/decode.js';
8
+ import { THEME_PRESETS, getThemePreset } from './theme/presets.js';
8
9
  import {
9
10
  replaceSection,
10
11
  buildCssColorsBlock,
11
12
  buildCssRadiusBlock,
13
+ buildCssSpacingBlock,
14
+ buildCssTypographyBlock,
15
+ buildCssWeightsBlock,
16
+ buildCssControlsBlock,
17
+ buildCssBordersBlock,
18
+ buildCssDurationsBlock,
19
+ buildCssShadowsBlock,
20
+ buildCssEasesBlock,
21
+ buildCssGradientsBlock,
12
22
  buildDartColorsBlock,
13
23
  buildDartRadiusBlock,
24
+ buildDartSpacingBlock,
25
+ buildDartTypographyBlock,
26
+ buildDartWeightsBlock,
27
+ buildDartControlsBlock,
28
+ buildDartBordersBlock,
29
+ buildDartDurationsBlock,
30
+ buildDartShadowsBlock,
31
+ buildDartEasesBlock,
32
+ buildDartGradientsBlock,
14
33
  } from './theme/inject.js';
15
34
  import { getTemplatesRoot } from '../paths.mjs';
16
35
 
@@ -52,7 +71,25 @@ export async function createProject(options = {}) {
52
71
  ],
53
72
  });
54
73
 
55
- const theme = options.theme ? decodeTheme(options.theme) : null;
74
+ let theme = null;
75
+ if (options.theme) {
76
+ theme = resolveTheme(options.theme);
77
+ } else if (process.stdin.isTTY && !options.yes) {
78
+ // --yes 는 "선택 옵션은 기본값으로" 의미. 테마는 옵션이므로 prompt 우회.
79
+ const NONE = '__none__';
80
+ const choice = await select({
81
+ message: '테마:',
82
+ default: NONE,
83
+ choices: [
84
+ { name: '기본 (테마 없음 — sh-ui 기본 토큰 그대로)', value: NONE },
85
+ ...Object.entries(THEME_PRESETS).map(([name, preset]) => ({
86
+ name: preset.label,
87
+ value: name,
88
+ })),
89
+ ],
90
+ });
91
+ if (choice !== NONE) theme = getThemePreset(choice);
92
+ }
56
93
 
57
94
  // dry-run 은 tmpdir 에 그대로 생성한 뒤 파일 목록 출력 + 정리.
58
95
  // 사용자 cwd 를 건드리지 않으면서 실제 generation 흐름을 그대로 검증한다.
@@ -601,6 +638,34 @@ async function applyTransforms(targetDir, plugins) {
601
638
  // ─── Theme 주입 ───
602
639
 
603
640
  /** 여러 후보 경로 중 존재하는 첫 tokens.css 에 theme 주입 */
641
+ /**
642
+ * 옵셔널 카테고리 → CSS 마커 / 빌더 매핑.
643
+ * theme 에 카테고리 키가 있을 때만 해당 마커 섹션 교체.
644
+ */
645
+ const OPTIONAL_CSS_INJECTORS = [
646
+ ['spacing', 'theme-space', buildCssSpacingBlock],
647
+ ['typography', 'theme-text', buildCssTypographyBlock],
648
+ ['weights', 'theme-weight', buildCssWeightsBlock],
649
+ ['controls', 'theme-control', buildCssControlsBlock],
650
+ ['borders', 'theme-border-width', buildCssBordersBlock],
651
+ ['durations', 'theme-duration', buildCssDurationsBlock],
652
+ ['shadows', 'theme-shadow', buildCssShadowsBlock],
653
+ ['eases', 'theme-ease', buildCssEasesBlock],
654
+ ['gradients', 'theme-gradient', buildCssGradientsBlock],
655
+ ];
656
+
657
+ const OPTIONAL_DART_INJECTORS = [
658
+ ['spacing', 'theme-space', buildDartSpacingBlock],
659
+ ['typography', 'theme-text', buildDartTypographyBlock],
660
+ ['weights', 'theme-weight', buildDartWeightsBlock],
661
+ ['controls', 'theme-control', buildDartControlsBlock],
662
+ ['borders', 'theme-border-width', buildDartBordersBlock],
663
+ ['durations', 'theme-duration', buildDartDurationsBlock],
664
+ ['shadows', 'theme-shadow', buildDartShadowsBlock],
665
+ ['eases', 'theme-ease', buildDartEasesBlock],
666
+ ['gradients', 'theme-gradient', buildDartGradientsBlock],
667
+ ];
668
+
604
669
  async function injectCssTheme(projectDir, theme) {
605
670
  if (!theme) return;
606
671
  const candidates = [
@@ -613,6 +678,11 @@ async function injectCssTheme(projectDir, theme) {
613
678
  let css = await fs.readFile(abs, 'utf-8');
614
679
  css = replaceSection(css, 'theme-colors', '/*', '*/', buildCssColorsBlock(theme));
615
680
  css = replaceSection(css, 'theme-radius', '/*', '*/', buildCssRadiusBlock(theme));
681
+ for (const [key, marker, builder] of OPTIONAL_CSS_INJECTORS) {
682
+ if (theme[key]) {
683
+ css = replaceSection(css, marker, '/*', '*/', builder(theme[key]));
684
+ }
685
+ }
616
686
  await fs.writeFile(abs, css);
617
687
  return;
618
688
  }
@@ -629,5 +699,10 @@ async function injectDartTheme(projectDir, theme) {
629
699
  let dart = await fs.readFile(abs, 'utf-8');
630
700
  dart = replaceSection(dart, 'theme-colors', '//', '', buildDartColorsBlock(theme));
631
701
  dart = replaceSection(dart, 'theme-radius', '//', '', buildDartRadiusBlock(theme));
702
+ for (const [key, marker, builder] of OPTIONAL_DART_INJECTORS) {
703
+ if (theme[key]) {
704
+ dart = replaceSection(dart, marker, '//', '', builder(theme[key]));
705
+ }
706
+ }
632
707
  await fs.writeFile(abs, dart);
633
708
  }
@@ -4,9 +4,11 @@ import { parseArgs } from './cli-args.js';
4
4
  import { createProject, addApp, addComponent } from './generator.js';
5
5
  import { allPlugins } from './plugins/index.js';
6
6
  import { CREATE_PLATFORMS, CREATE_STRUCTURES } from '../constants.js';
7
+ import { THEME_PRESET_NAMES } from './theme/presets.js';
7
8
 
8
9
  const PLUGIN_NAMES = allPlugins.map((p) => p.name);
9
10
  const PLUGINS_LIST = PLUGIN_NAMES.join(', ');
11
+ const THEME_PRESETS_LIST = THEME_PRESET_NAMES.join('|');
10
12
 
11
13
  export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next.js / Flutter)
12
14
 
@@ -19,7 +21,7 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
19
21
  --platform <${CREATE_PLATFORMS.join('|')}> 타겟 플랫폼
20
22
  --structure <${CREATE_STRUCTURES.join('|')}> Next.js 프로젝트 구조 (next 일 때)
21
23
  --plugins <a,b> 플러그인 (${PLUGINS_LIST}). 미지정/"" → 없음
22
- --theme <base64> 테마 JSON (base64). 선택
24
+ --theme <preset|base64> 프리셋 이름(${THEME_PRESETS_LIST}) 또는 playground base64. 선택
23
25
  --yes 디렉토리 덮어쓰기 + 모노레포 기본값 자동 채택
24
26
  --dry-run 파일을 쓰지 않고 작성될 파일 목록만 출력
25
27
  -h, --help 이 도움말
@@ -30,6 +32,7 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
30
32
  예 (비대화형 / 에이전트 / CI):
31
33
  sh-ui create my-app --platform next --structure standalone --yes
32
34
  sh-ui create my-app --platform next --structure monorepo --plugins ${PLUGIN_NAMES.slice(0, 3).join(',')} --yes
35
+ sh-ui create my-app --platform next --structure standalone --theme rose --yes
33
36
  sh-ui create my-app --platform flutter --yes
34
37
 
35
38
  비대화형 환경(TTY 없음)에서는 누락된 필수 인자가 있으면 prompt 대신 에러로 종료한다.
@@ -1,11 +1,81 @@
1
+ import { getThemePreset, THEME_PRESET_NAMES } from './presets.js';
2
+
1
3
  const TOKEN_KEYS = [
2
- 'background', 'background-subtle', 'background-muted',
3
- 'foreground', 'foreground-muted',
4
+ 'background', 'background-subtle', 'background-muted', 'background-inverse',
5
+ 'foreground', 'foreground-muted', 'foreground-subtle', 'foreground-inverse',
4
6
  'border', 'border-strong',
5
7
  'primary', 'primary-foreground', 'primary-hover',
6
8
  'danger', 'danger-foreground',
7
9
  ];
8
10
 
11
+ /**
12
+ * 옵셔널 카테고리 검증 — 모두 Record<string, number>.
13
+ * 카테고리 누락은 OK (스캐폴드 시 해당 블록은 디폴트 유지). 형식만 위배되면 에러.
14
+ *
15
+ * 각 카테고리의 expectedKeys 가 정확히 일치해야 함 — 나머지/누락 키 모두 거부.
16
+ */
17
+ const SCALAR_CATEGORIES = {
18
+ spacing: ['0', '1', '2', '3', '4', '5', '6', '8', '10', '12', '16'],
19
+ typography: ['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl'],
20
+ weights: ['regular', 'medium', 'semibold', 'bold'],
21
+ controls: ['sm', 'md', 'lg'],
22
+ borders: ['width', 'widthStrong'],
23
+ durations: ['fast', 'base', 'slow'],
24
+ };
25
+
26
+ /** Phase 3 — 문자열 카테고리. 형식 자체 검증은 inject 빌더에서 (CSS 그대로 통과 + Dart 변환 시 throw). */
27
+ const STRING_CATEGORIES = {
28
+ shadows: ['sm', 'md', 'lg', 'xl'],
29
+ eases: ['standard', 'emphasized'],
30
+ gradients: ['primary', 'surface', 'overlay'],
31
+ };
32
+
33
+ const validateStringCategory = (name, expectedKeys, value) => {
34
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
35
+ throw new Error(`theme 디코드 실패: ${name} 가 객체가 아님`);
36
+ }
37
+ for (const key of expectedKeys) {
38
+ if (!(key in value)) {
39
+ throw new Error(`theme 디코드 실패: ${name}.${key} 누락`);
40
+ }
41
+ const v = value[key];
42
+ if (typeof v !== 'string' || v.length === 0) {
43
+ throw new Error(`theme 디코드 실패: ${name}.${key} 가 빈 문자열 또는 비-문자열 (받은 값: ${JSON.stringify(v)})`);
44
+ }
45
+ if (v.length > 512) {
46
+ throw new Error(`theme 디코드 실패: ${name}.${key} 가 너무 김 (>512자)`);
47
+ }
48
+ }
49
+ for (const key of Object.keys(value)) {
50
+ if (!expectedKeys.includes(key)) {
51
+ throw new Error(`theme 디코드 실패: ${name}.${key} 는 알 수 없는 키`);
52
+ }
53
+ }
54
+ };
55
+
56
+ const validateScalarCategory = (name, expectedKeys, value) => {
57
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
58
+ throw new Error(`theme 디코드 실패: ${name} 가 객체가 아님`);
59
+ }
60
+ for (const key of expectedKeys) {
61
+ if (!(key in value)) {
62
+ throw new Error(`theme 디코드 실패: ${name}.${key} 누락`);
63
+ }
64
+ const v = value[key];
65
+ if (typeof v !== 'number' || Number.isNaN(v) || !Number.isFinite(v)) {
66
+ throw new Error(`theme 디코드 실패: ${name}.${key} 가 유한 숫자가 아님 (받은 값: ${JSON.stringify(v)})`);
67
+ }
68
+ if (v < 0) {
69
+ throw new Error(`theme 디코드 실패: ${name}.${key} 가 음수 (받은 값: ${v})`);
70
+ }
71
+ }
72
+ for (const key of Object.keys(value)) {
73
+ if (!expectedKeys.includes(key)) {
74
+ throw new Error(`theme 디코드 실패: ${name}.${key} 는 알 수 없는 키`);
75
+ }
76
+ }
77
+ };
78
+
9
79
  const HEX_REGEX = /^#[0-9A-Fa-f]{6}$/;
10
80
  const BASE64_REGEX = /^[A-Za-z0-9+/]*={0,2}$/;
11
81
  const MAX_THEME_BYTES = 10 * 1024; // 정상 테마 ~860 바이트. 10KB 면 10× 여유.
@@ -60,7 +130,40 @@ export const decodeTheme = (b64) => {
60
130
  if (parsed.radius < 0 || parsed.radius > 1.5) {
61
131
  throw new Error(`theme 디코드 실패: radius 가 허용 범위(0~1.5)를 벗어남 (${parsed.radius})`);
62
132
  }
133
+ // 옵셔널 카테고리 — 누락은 OK, 들어 있으면 형식 검증.
134
+ for (const [category, expectedKeys] of Object.entries(SCALAR_CATEGORIES)) {
135
+ if (category in parsed) {
136
+ validateScalarCategory(category, expectedKeys, parsed[category]);
137
+ }
138
+ }
139
+ for (const [category, expectedKeys] of Object.entries(STRING_CATEGORIES)) {
140
+ if (category in parsed) {
141
+ validateStringCategory(category, expectedKeys, parsed[category]);
142
+ }
143
+ }
63
144
  return parsed;
64
145
  };
65
146
 
66
- export { TOKEN_KEYS };
147
+ /**
148
+ * --theme 입력 1개 슬롯에 프리셋 이름과 base64 둘 다 받기 위한 리졸버.
149
+ * 프리셋 이름이면 미리 정의된 ThemeConfig 를, 그 외엔 base64 로 간주해 decodeTheme 위임.
150
+ * THEME_PRESET_NAMES 와 base64 알파벳은 둘 다 [a-z]+ 와 충돌하지만 프리셋 사전 매칭이 우선이라
151
+ * 'rose' 같이 우연히 base64 로도 파싱 가능한 짧은 토큰은 프리셋으로 해석된다 (의도).
152
+ */
153
+ export const resolveTheme = (input) => {
154
+ if (typeof input !== 'string') {
155
+ throw new Error(`theme 입력 실패: 문자열이 아님`);
156
+ }
157
+ const preset = getThemePreset(input);
158
+ if (preset) return preset;
159
+ // 정상 base64 테마는 200자 이상. 그보다 짧으면 프리셋 오타로 보고 친절한 에러를.
160
+ if (input.length < 50) {
161
+ throw new Error(
162
+ `알 수 없는 테마 프리셋: '${input}'. 지원: ${THEME_PRESET_NAMES.join(', ')} ` +
163
+ `(또는 playground 에서 생성한 base64).`
164
+ );
165
+ }
166
+ return decodeTheme(input);
167
+ };
168
+
169
+ export { TOKEN_KEYS, THEME_PRESET_NAMES };
@@ -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,147 @@
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
+ },
90
+ rose: {
91
+ label: '로즈 — 핑크 강조 + 둥근 모서리',
92
+ light: {
93
+ ...NEUTRAL_LIGHT,
94
+ 'primary': '#E11D48',
95
+ 'primary-foreground': '#FFF1F2',
96
+ 'primary-hover': '#BE123C',
97
+ },
98
+ dark: {
99
+ ...NEUTRAL_DARK,
100
+ 'primary': '#FB7185',
101
+ 'primary-foreground': '#4C0519',
102
+ 'primary-hover': '#FDA4AF',
103
+ },
104
+ radius: 0.75,
105
+ },
106
+ emerald: {
107
+ label: '에메랄드 — 그린 강조',
108
+ light: {
109
+ ...NEUTRAL_LIGHT,
110
+ 'primary': '#059669',
111
+ 'primary-foreground': '#ECFDF5',
112
+ 'primary-hover': '#047857',
113
+ },
114
+ dark: {
115
+ ...NEUTRAL_DARK,
116
+ 'primary': '#34D399',
117
+ 'primary-foreground': '#022C22',
118
+ 'primary-hover': '#6EE7B7',
119
+ },
120
+ radius: 0.5,
121
+ },
122
+ violet: {
123
+ label: '바이올렛 — 퍼플 강조 + 살짝 라운드',
124
+ light: {
125
+ ...NEUTRAL_LIGHT,
126
+ 'primary': '#7C3AED',
127
+ 'primary-foreground': '#F5F3FF',
128
+ 'primary-hover': '#6D28D9',
129
+ },
130
+ dark: {
131
+ ...NEUTRAL_DARK,
132
+ 'primary': '#A78BFA',
133
+ 'primary-foreground': '#1E1B4B',
134
+ 'primary-hover': '#C4B5FD',
135
+ },
136
+ radius: 0.625,
137
+ },
138
+ };
139
+
140
+ export const THEME_PRESET_NAMES = Object.keys(THEME_PRESETS);
141
+
142
+ /** 프리셋 객체에서 inject 가 기대하는 ThemeConfig 형태(light/dark/radius)만 추출 */
143
+ export const getThemePreset = (name) => {
144
+ const preset = THEME_PRESETS[name];
145
+ if (!preset) return null;
146
+ return { light: preset.light, dark: preset.dark, radius: preset.radius };
147
+ };
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()
@@ -122,6 +122,7 @@ class ShUiSpacingTokens {
122
122
  });
123
123
 
124
124
  static const tokens = ShUiSpacingTokens(
125
+ // sh-ui:theme-space-start
125
126
  s0: 0.0,
126
127
  s1: 4.0,
127
128
  s2: 8.0,
@@ -133,6 +134,7 @@ class ShUiSpacingTokens {
133
134
  s10: 40.0,
134
135
  s12: 48.0,
135
136
  s16: 64.0,
137
+ // sh-ui:theme-space-end
136
138
  );
137
139
  }
138
140
 
@@ -159,6 +161,7 @@ class ShUiTextTokens {
159
161
  });
160
162
 
161
163
  static const tokens = ShUiTextTokens(
164
+ // sh-ui:theme-text-start
162
165
  xs: 12.0,
163
166
  sm: 14.0,
164
167
  base: 16.0,
@@ -167,6 +170,7 @@ class ShUiTextTokens {
167
170
  xl2: 24.0,
168
171
  xl3: 30.0,
169
172
  xl4: 36.0,
173
+ // sh-ui:theme-text-end
170
174
  );
171
175
  }
172
176
 
@@ -185,10 +189,12 @@ class ShUiWeightTokens {
185
189
  });
186
190
 
187
191
  static const tokens = ShUiWeightTokens(
192
+ // sh-ui:theme-weight-start
188
193
  regular: FontWeight.w400,
189
194
  medium: FontWeight.w500,
190
195
  semibold: FontWeight.w600,
191
196
  bold: FontWeight.w700,
197
+ // sh-ui:theme-weight-end
192
198
  );
193
199
  }
194
200
 
@@ -207,10 +213,12 @@ class ShUiShadowTokens {
207
213
  });
208
214
 
209
215
  static const tokens = ShUiShadowTokens(
216
+ // sh-ui:theme-shadow-start
210
217
  sm: <BoxShadow>[BoxShadow(offset: Offset(0.0, 1.0), blurRadius: 2.0, spreadRadius: 0.0, color: Color(0x14000000))],
211
218
  md: <BoxShadow>[BoxShadow(offset: Offset(0.0, 4.0), blurRadius: 12.0, spreadRadius: 0.0, color: Color(0x1F000000))],
212
219
  lg: <BoxShadow>[BoxShadow(offset: Offset(0.0, 8.0), blurRadius: 24.0, spreadRadius: 0.0, color: Color(0x26000000))],
213
220
  xl: <BoxShadow>[BoxShadow(offset: Offset(0.0, 16.0), blurRadius: 48.0, spreadRadius: 0.0, color: Color(0x2E000000))],
221
+ // sh-ui:theme-shadow-end
214
222
  );
215
223
  }
216
224
 
@@ -227,9 +235,11 @@ class ShUiDurationTokens {
227
235
  });
228
236
 
229
237
  static const tokens = ShUiDurationTokens(
238
+ // sh-ui:theme-duration-start
230
239
  fast: Duration(milliseconds: 120),
231
240
  base: Duration(milliseconds: 160),
232
241
  slow: Duration(milliseconds: 200),
242
+ // sh-ui:theme-duration-end
233
243
  );
234
244
  }
235
245
 
@@ -244,8 +254,10 @@ class ShUiEaseTokens {
244
254
  });
245
255
 
246
256
  static const tokens = ShUiEaseTokens(
257
+ // sh-ui:theme-ease-start
247
258
  standard: Cubic(0.4, 0, 0.2, 1),
248
259
  emphasized: Cubic(0.2, 0, 0, 1),
260
+ // sh-ui:theme-ease-end
249
261
  );
250
262
  }
251
263
 
@@ -262,9 +274,11 @@ class ShUiControlTokens {
262
274
  });
263
275
 
264
276
  static const tokens = ShUiControlTokens(
277
+ // sh-ui:theme-control-start
265
278
  sm: 32.0,
266
279
  md: 40.0,
267
280
  lg: 48.0,
281
+ // sh-ui:theme-control-end
268
282
  );
269
283
  }
270
284
 
@@ -279,8 +293,10 @@ class ShUiBorderWidthTokens {
279
293
  });
280
294
 
281
295
  static const tokens = ShUiBorderWidthTokens(
296
+ // sh-ui:theme-border-width-start
282
297
  normal: 1.0,
283
298
  strong: 2.0,
299
+ // sh-ui:theme-border-width-end
284
300
  );
285
301
  }
286
302
 
@@ -297,6 +313,27 @@ class ShUiOpacityTokens {
297
313
  );
298
314
  }
299
315
 
316
+ @immutable
317
+ class ShUiGradientTokens {
318
+ final LinearGradient primary;
319
+ final LinearGradient surface;
320
+ final LinearGradient overlay;
321
+
322
+ const ShUiGradientTokens({
323
+ required this.primary,
324
+ required this.surface,
325
+ required this.overlay,
326
+ });
327
+
328
+ static const tokens = ShUiGradientTokens(
329
+ // sh-ui:theme-gradient-start
330
+ primary: LinearGradient(begin: Alignment(-0.707, 0.707), end: Alignment(0.707, -0.707), colors: <Color>[Color(0xFF171717), Color(0xFF525252)], stops: <double>[0.00, 1.00]),
331
+ surface: LinearGradient(begin: Alignment(0.0, -1.0), end: Alignment(0.0, 1.0), colors: <Color>[Color(0xFFFFFFFF), Color(0xFFF5F5F5)], stops: <double>[0.00, 1.00]),
332
+ overlay: LinearGradient(begin: Alignment(0.0, -1.0), end: Alignment(0.0, 1.0), colors: <Color>[Color(0xFF000000), Color(0xFF1F1F1F)], stops: <double>[0.00, 1.00]),
333
+ // sh-ui:theme-gradient-end
334
+ );
335
+ }
336
+
300
337
  @immutable
301
338
  class ShUiBreakpointTokens {
302
339
  final double sm;
@@ -330,6 +367,7 @@ class ShUiTheme extends ThemeExtension<ShUiTheme> {
330
367
  final ShUiEaseTokens ease;
331
368
  final ShUiControlTokens control;
332
369
  final ShUiBorderWidthTokens borderWidth;
370
+ final ShUiGradientTokens gradient;
333
371
  final ShUiOpacityTokens opacity;
334
372
  final ShUiBreakpointTokens breakpoint;
335
373
 
@@ -344,6 +382,7 @@ class ShUiTheme extends ThemeExtension<ShUiTheme> {
344
382
  required this.ease,
345
383
  required this.control,
346
384
  required this.borderWidth,
385
+ required this.gradient,
347
386
  required this.opacity,
348
387
  required this.breakpoint,
349
388
  });
@@ -359,6 +398,7 @@ class ShUiTheme extends ThemeExtension<ShUiTheme> {
359
398
  ease: ShUiEaseTokens.tokens,
360
399
  control: ShUiControlTokens.tokens,
361
400
  borderWidth: ShUiBorderWidthTokens.tokens,
401
+ gradient: ShUiGradientTokens.tokens,
362
402
  opacity: ShUiOpacityTokens.tokens,
363
403
  breakpoint: ShUiBreakpointTokens.tokens,
364
404
  );
@@ -373,13 +413,14 @@ class ShUiTheme extends ThemeExtension<ShUiTheme> {
373
413
  ease: ShUiEaseTokens.tokens,
374
414
  control: ShUiControlTokens.tokens,
375
415
  borderWidth: ShUiBorderWidthTokens.tokens,
416
+ gradient: ShUiGradientTokens.tokens,
376
417
  opacity: ShUiOpacityTokens.tokens,
377
418
  breakpoint: ShUiBreakpointTokens.tokens,
378
419
  );
379
420
 
380
421
  @override
381
- ShUiTheme copyWith({ShUiColorTokens? colors, ShUiRadiusTokens? radius, ShUiSpacingTokens? spacing, ShUiTextTokens? text, ShUiWeightTokens? weight, ShUiShadowTokens? shadow, ShUiDurationTokens? duration, ShUiEaseTokens? ease, ShUiControlTokens? control, ShUiBorderWidthTokens? borderWidth, ShUiOpacityTokens? opacity, ShUiBreakpointTokens? breakpoint}) =>
382
- ShUiTheme(colors: colors ?? this.colors, radius: radius ?? this.radius, spacing: spacing ?? this.spacing, text: text ?? this.text, weight: weight ?? this.weight, shadow: shadow ?? this.shadow, duration: duration ?? this.duration, ease: ease ?? this.ease, control: control ?? this.control, borderWidth: borderWidth ?? this.borderWidth, opacity: opacity ?? this.opacity, breakpoint: breakpoint ?? this.breakpoint);
422
+ ShUiTheme copyWith({ShUiColorTokens? colors, ShUiRadiusTokens? radius, ShUiSpacingTokens? spacing, ShUiTextTokens? text, ShUiWeightTokens? weight, ShUiShadowTokens? shadow, ShUiDurationTokens? duration, ShUiEaseTokens? ease, ShUiControlTokens? control, ShUiBorderWidthTokens? borderWidth, ShUiGradientTokens? gradient, ShUiOpacityTokens? opacity, ShUiBreakpointTokens? breakpoint}) =>
423
+ ShUiTheme(colors: colors ?? this.colors, radius: radius ?? this.radius, spacing: spacing ?? this.spacing, text: text ?? this.text, weight: weight ?? this.weight, shadow: shadow ?? this.shadow, duration: duration ?? this.duration, ease: ease ?? this.ease, control: control ?? this.control, borderWidth: borderWidth ?? this.borderWidth, gradient: gradient ?? this.gradient, opacity: opacity ?? this.opacity, breakpoint: breakpoint ?? this.breakpoint);
383
424
 
384
425
  @override
385
426
  ShUiTheme lerp(ThemeExtension<ShUiTheme>? other, double t) {
@@ -42,6 +42,7 @@
42
42
  /* sh-ui:theme-radius-start */
43
43
  --radius: 0.5rem;
44
44
  /* sh-ui:theme-radius-end */
45
+ /* sh-ui:theme-space-start */
45
46
  --space-0: 0px;
46
47
  --space-1: 4px;
47
48
  --space-2: 8px;
@@ -53,6 +54,8 @@
53
54
  --space-10: 40px;
54
55
  --space-12: 48px;
55
56
  --space-16: 64px;
57
+ /* sh-ui:theme-space-end */
58
+ /* sh-ui:theme-text-start */
56
59
  --text-xs: 12px;
57
60
  --text-sm: 14px;
58
61
  --text-base: 16px;
@@ -61,24 +64,42 @@
61
64
  --text-2xl: 24px;
62
65
  --text-3xl: 30px;
63
66
  --text-4xl: 36px;
67
+ /* sh-ui:theme-text-end */
68
+ /* sh-ui:theme-weight-start */
64
69
  --weight-regular: 400;
65
70
  --weight-medium: 500;
66
71
  --weight-semibold: 600;
67
72
  --weight-bold: 700;
73
+ /* sh-ui:theme-weight-end */
74
+ /* sh-ui:theme-shadow-start */
68
75
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
69
76
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
70
77
  --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
71
78
  --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.18);
79
+ /* sh-ui:theme-shadow-end */
80
+ /* sh-ui:theme-duration-start */
72
81
  --duration-fast: 120ms;
73
82
  --duration-base: 160ms;
74
83
  --duration-slow: 200ms;
84
+ /* sh-ui:theme-duration-end */
85
+ /* sh-ui:theme-ease-start */
75
86
  --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
76
87
  --ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
88
+ /* sh-ui:theme-ease-end */
89
+ /* sh-ui:theme-control-start */
77
90
  --control-sm: 32px;
78
91
  --control-md: 40px;
79
92
  --control-lg: 48px;
93
+ /* sh-ui:theme-control-end */
94
+ /* sh-ui:theme-border-width-start */
80
95
  --border-width: 1px;
81
96
  --border-width-strong: 2px;
97
+ /* sh-ui:theme-border-width-end */
98
+ /* sh-ui:theme-gradient-start */
99
+ --gradient-primary: linear-gradient(135deg, #171717 0%, #525252 100%);
100
+ --gradient-surface: linear-gradient(180deg, #FFFFFF 0%, #F5F5F5 100%);
101
+ --gradient-overlay: linear-gradient(180deg, #000000 0%, #1F1F1F 100%);
102
+ /* sh-ui:theme-gradient-end */
82
103
  --opacity-disabled: 0.5;
83
104
  --z-base: 0;
84
105
  --z-sticky: 100;
@@ -42,6 +42,7 @@
42
42
  /* sh-ui:theme-radius-start */
43
43
  --radius: 0.5rem;
44
44
  /* sh-ui:theme-radius-end */
45
+ /* sh-ui:theme-space-start */
45
46
  --space-0: 0px;
46
47
  --space-1: 4px;
47
48
  --space-2: 8px;
@@ -53,6 +54,8 @@
53
54
  --space-10: 40px;
54
55
  --space-12: 48px;
55
56
  --space-16: 64px;
57
+ /* sh-ui:theme-space-end */
58
+ /* sh-ui:theme-text-start */
56
59
  --text-xs: 12px;
57
60
  --text-sm: 14px;
58
61
  --text-base: 16px;
@@ -61,24 +64,42 @@
61
64
  --text-2xl: 24px;
62
65
  --text-3xl: 30px;
63
66
  --text-4xl: 36px;
67
+ /* sh-ui:theme-text-end */
68
+ /* sh-ui:theme-weight-start */
64
69
  --weight-regular: 400;
65
70
  --weight-medium: 500;
66
71
  --weight-semibold: 600;
67
72
  --weight-bold: 700;
73
+ /* sh-ui:theme-weight-end */
74
+ /* sh-ui:theme-shadow-start */
68
75
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
69
76
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
70
77
  --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
71
78
  --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.18);
79
+ /* sh-ui:theme-shadow-end */
80
+ /* sh-ui:theme-duration-start */
72
81
  --duration-fast: 120ms;
73
82
  --duration-base: 160ms;
74
83
  --duration-slow: 200ms;
84
+ /* sh-ui:theme-duration-end */
85
+ /* sh-ui:theme-ease-start */
75
86
  --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
76
87
  --ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
88
+ /* sh-ui:theme-ease-end */
89
+ /* sh-ui:theme-control-start */
77
90
  --control-sm: 32px;
78
91
  --control-md: 40px;
79
92
  --control-lg: 48px;
93
+ /* sh-ui:theme-control-end */
94
+ /* sh-ui:theme-border-width-start */
80
95
  --border-width: 1px;
81
96
  --border-width-strong: 2px;
97
+ /* sh-ui:theme-border-width-end */
98
+ /* sh-ui:theme-gradient-start */
99
+ --gradient-primary: linear-gradient(135deg, #171717 0%, #525252 100%);
100
+ --gradient-surface: linear-gradient(180deg, #FFFFFF 0%, #F5F5F5 100%);
101
+ --gradient-overlay: linear-gradient(180deg, #000000 0%, #1F1F1F 100%);
102
+ /* sh-ui:theme-gradient-end */
82
103
  --opacity-disabled: 0.5;
83
104
  --z-base: 0;
84
105
  --z-sticky: 100;