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.
@@ -2,6 +2,42 @@
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.39.0",
7
+ "date": "2026-04-29",
8
+ "title": "프리셋 정체성 강화 — slate / rose / violet 이 typography·controls·borders 도 차별화",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "0.38.0 까지 5 프리셋이 색·radius 만 차별화 → 이제 typography·controls·borders 까지 프리셋 별 인상이 다름. neutral / emerald 는 디폴트 baseline (변화 없음), 나머지 셋은 정체성 부여",
12
+ "**slate** (정보 밀도) — text-base 14px (xs 11/sm 12/lg 16/xl 18/2xl 21/3xl 26/4xl 32), control-md 36px (sm 28/lg 44). 대시보드/관리자 인상",
13
+ "**rose** (친근·여유) — control-md 44px (sm 36/lg 52). 큼직한 터치 타겟 + 0.75rem 라운드 → 소비자 앱 인상",
14
+ "**violet** (모던·또렷) — control-md 42px (sm 34/lg 50), border-width-strong 3px (디폴트 2px). 약간 두꺼운 강조 보더로 창의·디자인 도구 인상",
15
+ "getThemePreset 시그니처 — 이전 `{light, dark, radius}` 만 → 이제 label 빼고 모든 카테고리 forward. CLI inject 가 자동으로 마커 섹션 교체",
16
+ "api.d.ts ThemePreset 인터페이스에 옵셔널 카테고리 6종 추가 (typography/controls/borders/spacing/weights/durations) — apps/docs 등 외부 사용자가 타입 안전",
17
+ "테스트 111개 (전 104 → +7). end-to-end 검증 — `--theme rose` 가 control-md 40 → 44 로 덮어쓰는지, `--theme slate` 가 text-base 16 → 14 로, `--theme violet` 이 border-width-strong 2 → 3 으로",
18
+ "참고: 기존 사용자가 0.38.0 까지 `--theme rose` 로 만든 프로젝트는 영향 없음 (이미 생성된 tokens.css 는 그대로). 새로 `--theme rose` 하면 0.39.0 정체성 그대로 들어감"
19
+ ],
20
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.39.0"
21
+ },
22
+ {
23
+ "version": "0.38.0",
24
+ "date": "2026-04-29",
25
+ "title": "테마 시스템 풀스택 — 5 프리셋 + 풀 토큰 편집기 + 스캐폴드 threading + CSS↔Flutter 변환기",
26
+ "type": "minor",
27
+ "highlights": [
28
+ "**프리셋 5종 (CLI + 플레이그라운드)** — neutral · slate · rose · emerald · violet. `sh-ui create --theme rose` 처럼 짧게 받거나 playground 에서 만든 base64 그대로. 플레이그라운드 토큰 편집기 위쪽 '프리셋' 행 클릭 한 번으로 light/dark/radius 동시 적용. sh-ui-cli/api 가 단일 소스",
29
+ "**쉬운 / 고급 토글** — 쉬운 모드는 베이스 톤 4(neutral/slate/warm/cool) + 포인트 컬러 1개 픽커(primary-foreground/primary-hover 자동 파생). 고급 모드는 8 카테고리 collapsible 섹션 — 색(15) · 크기 · 테두리 · 그림자 · 타이포 · 여백 · 폰트굵기 · 모션. 모든 슬라이더는 sh-ui Slider 사용",
30
+ "**그라데이션 빌더 + 슬롯 3개** (primary/surface/overlay) — 각도 슬라이더 + 2 컬러스톱 + 위치 슬라이더 + 라이브 프리뷰. CSS 변수 `--gradient-*` 로 캔버스 흘러감",
31
+ "**스캐폴드 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 에 포함돼 페이로드 최소화",
32
+ "**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, 시계방향)",
33
+ "**확장 색 직접 편집** — background-inverse / foreground-inverse / foreground-subtle 3개를 사용자가 직접 잡음. 기존 Dart 자동 inverse 거울 / 고정 foregroundSubtle 디폴트 폐기. 5 프리셋 light/dark 모두 15키 풀세트",
34
+ "**Flutter 템플릿** — ShUiGradientTokens 클래스 신설 + ShUiTheme 에 gradient 필드 통합. tokens.css / sh_ui_tokens.dart 에 카테고리별 마커 9쌍 (theme-space/text/weight/shadow/duration/ease/control/border-width/gradient)",
35
+ "**DatePicker 회귀 픽스 2개** — (1) container prop 누락 → 추가 (Select·Combobox·Popover 와 동일 패턴). 다크 모드를 page subtree 에만 입힌 환경에서 캘린더 popover 가 토큰 스코프 벗어나 흰 배경으로 뜨던 문제 해결. (2) 선택일 색을 --foreground 에서 --primary / --primary-foreground 로 전환 — rose/emerald 같은 primary 변경 프리셋이 캘린더에도 일관되게 반영. **호환성 주의**: primary 를 안 바꾸고 쓰던 사용자는 선택일 색이 검정 → 자기 primary 색으로 변경됨",
36
+ "**MCP** sh_ui_create_project 의 theme 파라미터가 프리셋 이름 수용 — IDE 에이전트가 `theme: \"slate\"` 한 줄로 호출. 짧은 오타는 base64 디코드 에러 대신 '알 수 없는 테마 프리셋: 지원 목록 …' 친절 안내",
37
+ "**테스트 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 까지 저장된 데이터는 새 키 디폴트 보강)"
38
+ ],
39
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.38.0"
40
+ },
5
41
  {
6
42
  "version": "0.34.0",
7
43
  "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.39.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,34 @@ 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
+ /** v0.39.0+ — 프리셋 별 정체성 차별화 (옵셔널). decode.js SCALAR_CATEGORIES 와 동일 키. */
64
+ typography?: Record<string, number>;
65
+ controls?: Record<string, number>;
66
+ borders?: Record<string, number>;
67
+ spacing?: Record<string, number>;
68
+ weights?: Record<string, number>;
69
+ durations?: Record<string, number>;
70
+ }
71
+
72
+ export const THEME_PRESETS: Record<ThemePresetName, ThemePreset>;
73
+ 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 };