sh-ui-cli 0.52.3 → 0.55.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,7 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { Popover as BasePopover } from "@base-ui/react/popover";
5
- import { Calendar, type DateRange } from "../calendar";
5
+ import { Calendar, DEFAULT_LOCALE, type CalendarMessages, type DateRange } from "../calendar";
6
6
  import "./styles.css";
7
7
 
8
8
  import { cn } from "@SH_UI_UTILS@";
@@ -17,6 +17,18 @@ const formatDefault = (d: Date) =>
17
17
  const startOfMonth = (d: Date) =>
18
18
  new Date(d.getFullYear(), d.getMonth(), 1);
19
19
 
20
+ /** locale 기반 단일 날짜 placeholder. 한국어 외에는 영어 fallback. */
21
+ function defaultDatePlaceholder(locale: string): string {
22
+ const lang = locale.toLowerCase().split(/[-_]/)[0];
23
+ return lang === "ko" ? "날짜 선택" : "Select date";
24
+ }
25
+
26
+ /** locale 기반 범위 placeholder. */
27
+ function defaultRangePlaceholder(locale: string): string {
28
+ const lang = locale.toLowerCase().split(/[-_]/)[0];
29
+ return lang === "ko" ? "시작일 ~ 종료일" : "Start date – end date";
30
+ }
31
+
20
32
  /* ───────── Icons ───────── */
21
33
 
22
34
  function CalendarIcon() {
@@ -39,6 +51,8 @@ interface DatePickerContextValue {
39
51
  setFocusedDate: (date: Date) => void;
40
52
  formatDate: (date: Date) => string;
41
53
  placeholder: string;
54
+ locale: string;
55
+ messages?: CalendarMessages;
42
56
  min?: Date;
43
57
  max?: Date;
44
58
  disabled?: boolean;
@@ -76,10 +90,16 @@ export interface DatePickerProps {
76
90
  /** 선택 가능 최대 날짜 (포함). 이후 날짜는 비활성. */
77
91
  max?: Date;
78
92
  /**
79
- * 미선택 상태의 트리거 텍스트.
80
- * @default "날짜 선택"
93
+ * 미선택 상태의 트리거 텍스트. 미지정 시 `locale` 기반 자동 생성("날짜 선택" / "Select date").
81
94
  */
82
95
  placeholder?: string;
96
+ /**
97
+ * BCP47 로케일. 내부 Calendar 와 placeholder 기본값에 모두 적용된다.
98
+ * @default "ko-KR"
99
+ */
100
+ locale?: string;
101
+ /** 내부 Calendar 의 nav/select aria-label override. */
102
+ messages?: CalendarMessages;
83
103
  /** 비활성. 트리거 클릭·키보드 모두 차단. */
84
104
  disabled?: boolean;
85
105
  /** 읽기 전용. 트리거 표시는 유지하되 popover가 열리지 않는다. */
@@ -119,7 +139,9 @@ export function DatePicker({
119
139
  formatDate = formatDefault,
120
140
  min,
121
141
  max,
122
- placeholder = "날짜 선택",
142
+ placeholder,
143
+ locale = DEFAULT_LOCALE,
144
+ messages,
123
145
  disabled,
124
146
  readOnly,
125
147
  "aria-invalid": ariaInvalid,
@@ -128,6 +150,7 @@ export function DatePicker({
128
150
  container,
129
151
  children,
130
152
  }: DatePickerProps) {
153
+ const resolvedPlaceholder = placeholder ?? defaultDatePlaceholder(locale);
131
154
  const isControlled = value !== undefined;
132
155
  const [internal, setInternal] = React.useState<Date | undefined>(defaultValue);
133
156
  const selected = isControlled ? value : internal;
@@ -160,7 +183,9 @@ export function DatePicker({
160
183
  focusedDate,
161
184
  setFocusedDate,
162
185
  formatDate,
163
- placeholder,
186
+ placeholder: resolvedPlaceholder,
187
+ locale,
188
+ messages,
164
189
  min,
165
190
  max,
166
191
  disabled,
@@ -174,7 +199,9 @@ export function DatePicker({
174
199
  open,
175
200
  focusedDate,
176
201
  formatDate,
177
- placeholder,
202
+ resolvedPlaceholder,
203
+ locale,
204
+ messages,
178
205
  min,
179
206
  max,
180
207
  disabled,
@@ -355,6 +382,8 @@ export function DatePickerCalendar() {
355
382
  onMonthChange={ctx.setFocusedDate}
356
383
  min={ctx.min}
357
384
  max={ctx.max}
385
+ locale={ctx.locale}
386
+ messages={ctx.messages}
358
387
  />
359
388
  );
360
389
  }
@@ -407,10 +436,16 @@ export interface DateRangePickerProps {
407
436
  /** 선택 가능 최대 날짜. */
408
437
  max?: Date;
409
438
  /**
410
- * 미선택 상태의 트리거 텍스트.
411
- * @default "시작일 ~ 종료일"
439
+ * 미선택 상태의 트리거 텍스트. 미지정 시 `locale` 기반 자동 생성.
412
440
  */
413
441
  placeholder?: string;
442
+ /**
443
+ * BCP47 로케일. 내부 Calendar 와 placeholder 기본값에 모두 적용.
444
+ * @default "ko-KR"
445
+ */
446
+ locale?: string;
447
+ /** 내부 Calendar 의 nav/select aria-label override. */
448
+ messages?: CalendarMessages;
414
449
  /** 비활성. */
415
450
  disabled?: boolean;
416
451
  /** 읽기 전용. popover가 열리지 않는다. */
@@ -438,7 +473,9 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
438
473
  formatDate = formatDefault,
439
474
  min,
440
475
  max,
441
- placeholder = "시작일 ~ 종료일",
476
+ placeholder,
477
+ locale = DEFAULT_LOCALE,
478
+ messages,
442
479
  disabled,
443
480
  readOnly,
444
481
  "aria-invalid": ariaInvalid,
@@ -447,6 +484,7 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
447
484
  },
448
485
  ref,
449
486
  ) {
487
+ const resolvedPlaceholder = placeholder ?? defaultRangePlaceholder(locale);
450
488
  const isControlled = value !== undefined;
451
489
  const [internal, setInternal] = React.useState<DateRange | undefined>(defaultValue);
452
490
  const selected = isControlled ? value : internal;
@@ -485,7 +523,7 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
485
523
  }}
486
524
  >
487
525
  <span className={cn("sh-ui-date-picker__value", !displayText && "sh-ui-date-picker__placeholder")}>
488
- {displayText ?? placeholder}
526
+ {displayText ?? resolvedPlaceholder}
489
527
  </span>
490
528
  <span className="sh-ui-date-picker__icon" aria-hidden>
491
529
  <CalendarIcon />
@@ -509,6 +547,8 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
509
547
  onMonthChange={setCalendarMonth}
510
548
  min={min}
511
549
  max={max}
550
+ locale={locale}
551
+ messages={messages}
512
552
  />
513
553
  </BasePopover.Popup>
514
554
  </BasePopover.Positioner>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.52.3",
3
+ "version": "0.55.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,6 +8,15 @@ const TOKEN_KEYS = [
8
8
  'danger', 'danger-foreground',
9
9
  ];
10
10
 
11
+ // 옵셔널 색 토큰 — 누락 OK. 입력에 들어 있으면 hex 검증 + inject 시 CSS 변수로 emit.
12
+ // 사용자가 success/warning/info 상태 컬러를 커스텀하고 싶을 때 사용.
13
+ // Dart 측 ShUiColorTokens 는 아직 미반영(웹 한정).
14
+ const OPTIONAL_TOKEN_KEYS = [
15
+ 'success', 'success-foreground',
16
+ 'warning', 'warning-foreground',
17
+ 'info', 'info-foreground',
18
+ ];
19
+
11
20
  /**
12
21
  * 옵셔널 카테고리 검증 — 모두 Record<string, number>.
13
22
  * 카테고리 누락은 OK (스캐폴드 시 해당 블록은 디폴트 유지). 형식만 위배되면 에러.
@@ -95,6 +104,16 @@ const validateTokenMap = (name, map) => {
95
104
  );
96
105
  }
97
106
  }
107
+ // 옵셔널 키는 들어 있을 때만 hex 검증.
108
+ for (const key of OPTIONAL_TOKEN_KEYS) {
109
+ if (!(key in map)) continue;
110
+ const value = map[key];
111
+ if (typeof value !== 'string' || !HEX_REGEX.test(value)) {
112
+ throw new Error(
113
+ `theme 디코드 실패: ${name}.${key} 가 hex 포맷이 아님 (받은 값: ${JSON.stringify(value)})`,
114
+ );
115
+ }
116
+ }
98
117
  };
99
118
 
100
119
  export const decodeTheme = (b64) => {
@@ -166,4 +185,4 @@ export const resolveTheme = (input) => {
166
185
  return decodeTheme(input);
167
186
  };
168
187
 
169
- export { TOKEN_KEYS, THEME_PRESET_NAMES };
188
+ export { TOKEN_KEYS, OPTIONAL_TOKEN_KEYS, THEME_PRESET_NAMES };
@@ -0,0 +1,24 @@
1
+ import { decodeTheme } from './decode.js';
2
+
3
+ /**
4
+ * 사용자/AI 가 손본 토큰 객체를 sh-ui base64 테마 문자열로 인코딩.
5
+ * `sh_ui_create_project` 의 `theme` 인자에 그대로 넘겨 영구 보관 가능.
6
+ *
7
+ * 입력 형태: `{ light, dark, radius, ...옵셔널 카테고리 }`
8
+ * - light/dark: 15개 필수 토큰 (background, foreground, primary, danger 계열) + 옵셔널 6개 (success/warning/info)
9
+ * - radius: 0~1.5 (rem 단위)
10
+ * - 옵셔널: spacing/typography/weights/controls/borders/durations/shadows/eases/gradients
11
+ *
12
+ * 인코드 직후 round-trip 검증으로 디코더가 거부할 입력은 즉시 throw.
13
+ */
14
+ export const encodeTheme = (theme) => {
15
+ if (!theme || typeof theme !== 'object' || Array.isArray(theme)) {
16
+ throw new Error('theme 인코드 실패: 객체가 아님');
17
+ }
18
+ const json = JSON.stringify(theme);
19
+ const b64 = Buffer.from(json, 'utf-8').toString('base64');
20
+ // 같은 검증 로직을 한 곳에 두기 위해 round-trip — 인코드 산출물을 즉시 디코드해 본다.
21
+ // throw 면 입력이 스키마에 안 맞다는 뜻이라 그대로 호출자에게 전파.
22
+ decodeTheme(b64);
23
+ return b64;
24
+ };
@@ -1,4 +1,4 @@
1
- import { TOKEN_KEYS } from './decode.js';
1
+ import { TOKEN_KEYS, OPTIONAL_TOKEN_KEYS } from './decode.js';
2
2
 
3
3
  /**
4
4
  * 파일 내용에서 sh-ui:<section>-start / -end 마커 사이 내용을 교체.
@@ -30,12 +30,28 @@ export const replaceSection = (content, section, commentOpen, commentClose, repl
30
30
  const cssColorLine = (key, value) => ` --${key}: ${value};`;
31
31
 
32
32
  export const buildCssColorsBlock = (theme) => {
33
- const lightLines = TOKEN_KEYS.map((k) => cssColorLine(k, theme.light[k])).join('\n');
34
- const darkLines = TOKEN_KEYS.map((k) => cssColorLine(k, theme.dark[k])).join('\n');
33
+ // 옵셔널 토큰 — light/dark 둘 다에 정의되어 있을 때만 emit. 한쪽만 있으면 누락된 쪽은 fallback 으로
34
+ // 디자인이 깨질 있어서 양쪽 정의가 일치할 때만 안전하게 내보낸다.
35
+ const optionalKeys = OPTIONAL_TOKEN_KEYS.filter(
36
+ (k) => k in theme.light && k in theme.dark,
37
+ );
38
+ const allKeys = [...TOKEN_KEYS, ...optionalKeys];
39
+
40
+ const lightLines = allKeys.map((k) => cssColorLine(k, theme.light[k])).join('\n');
41
+ const darkLines = allKeys.map((k) => cssColorLine(k, theme.dark[k])).join('\n');
42
+ // 미디어쿼리 안의 다크 라인은 한 단계 더 들여쓰기 (`:root:not(...)` 안쪽).
43
+ const darkLinesIndented = allKeys
44
+ .map((k) => ` ${cssColorLine(k, theme.dark[k])}`)
45
+ .join('\n');
35
46
  return [
36
47
  ':root {',
37
48
  lightLines,
38
49
  '}',
50
+ '@media (prefers-color-scheme: dark) {',
51
+ ' :root:not(.light):not(.dark) {',
52
+ darkLinesIndented,
53
+ ' }',
54
+ '}',
39
55
  '.dark {',
40
56
  darkLines,
41
57
  '}',
package/src/mcp.mjs CHANGED
@@ -6,11 +6,15 @@
6
6
  //
7
7
  // 노출 툴:
8
8
  // sh_ui_describe_init - init 4개 축(platform/base/radius/mode) enum + 한글 설명
9
+ // sh_ui_create_project - 빈 폴더에 Next.js/Flutter 프로젝트 스캐폴드
9
10
  // sh_ui_init - sh-ui.config.json 생성 (비대화형)
10
11
  // sh_ui_list_components - 플랫폼 전체 컴포넌트 + 요약
11
12
  // sh_ui_get_component - 단일 컴포넌트의 메타·소스·deps
12
13
  // sh_ui_add_component - 컴포넌트 설치 (외부 패키지 자동 설치 포함)
13
14
  // sh_ui_remove_component - 컴포넌트 삭제
15
+ // sh_ui_get_changelog - 변경 내역(versions.json) 반환
16
+ // sh_ui_encode_theme - 토큰 객체 → base64 (사용자가 손본 톤을 영구 보관)
17
+ // sh_ui_decode_theme - base64 → 토큰 객체 (기존 테마 일부만 수정 후 재인코딩)
14
18
 
15
19
  import { readFile } from "node:fs/promises";
16
20
  import { existsSync } from "node:fs";
@@ -40,6 +44,8 @@ import {
40
44
  } from "./constants.js";
41
45
  import { allPlugins } from "./create/plugins/index.js";
42
46
  import { THEME_PRESET_NAMES } from "./create/theme/presets.js";
47
+ import { decodeTheme } from "./create/theme/decode.js";
48
+ import { encodeTheme } from "./create/theme/encode.js";
43
49
 
44
50
  const PLATFORMS = INIT_PLATFORMS;
45
51
  const BASES = THEME_BASES;
@@ -164,6 +170,18 @@ function buildServerInstructions(cliName) {
164
170
  - \`sh_ui_get_component\` — props/소스 확인 (코드 작성 전)
165
171
  - \`sh_ui_add_component\` / \`sh_ui_remove_component\` — 설치/삭제
166
172
  - \`sh_ui_get_changelog\` — 최근 변경 내역
173
+
174
+ ## 테마 커스터마이징 (스캐폴드 결과 톤이 마음에 안 들 때)
175
+
176
+ 스캐폴드 후 사용자가 "눈 아프다" / "Linear 톤으로" 같이 톤 조정을 요청하면, **\`tokens.css\` 직접 편집** + **편집 결과를 base64 로 백업** 두 단계를 같이 한다 — 그래야 다음에 같은 프로젝트를 재생성해도 톤이 보존된다.
177
+
178
+ 1. \`tokens.css\` 의 \`:root\` / \`.dark\` 블록 색만 손봄 (마커는 건드리지 않음).
179
+ 2. \`sh_ui_encode_theme\` 으로 \`{ light, dark, radius }\` 객체를 base64 로 인코딩.
180
+ - 옵셔널 색 토큰(\`success\`/\`warning\`/\`info\` + \`-foreground\`)도 같이 넣을 수 있음.
181
+ 3. 그 base64 를 사용자에게 보여주고 (또는 메모리에 저장), **다음 \`sh_ui_create_project\` 호출 시 \`theme\` 인자에 그대로** 넣어 영구 보관.
182
+ 4. 기존 base64 를 일부만 고치고 싶으면 \`sh_ui_decode_theme\` → 객체 수정 → \`sh_ui_encode_theme\` round-trip.
183
+
184
+ > 프리셋(\`neutral\`/\`slate\`/...) 이름과 base64 둘 다 \`theme\` 인자에 넣을 수 있다 — 길이로 자동 판별.
167
185
  `;
168
186
  }
169
187
 
@@ -204,7 +222,7 @@ export async function startMcpServer() {
204
222
  plugins: z.array(z.enum(PLUGIN_NAMES)).optional()
205
223
  .describe(`Next.js 플러그인 (${PLUGIN_NAMES.join(', ')}). 미지정시 빈 배열`),
206
224
  theme: z.string().optional()
207
- .describe(`프리셋 이름 (${THEME_PRESETS_LIST}) 또는 playground 에서 생성한 base64 (선택)`),
225
+ .describe(`프리셋 이름 (${THEME_PRESETS_LIST}) 또는 base64 테마 코드. 사용자가 톤을 직접 손본 결과를 영구 보관하려면 sh_ui_encode_theme 으로 base64 를 만들어 여기에 넘긴다.`),
208
226
  cssFramework: z.enum(CSS_FRAMEWORKS).optional()
209
227
  .describe(`CSS 프레임워크. 기본 plain. 현재 ${CSS_FRAMEWORKS.join('/')} 지원 — 변종 미보유 컴포넌트는 add 시 plain 으로 자동 fallback`),
210
228
  cwd: z.string().optional()
@@ -432,6 +450,80 @@ export async function startMcpServer() {
432
450
  },
433
451
  );
434
452
 
453
+ // 테마 round-trip — 사용자가 손본 토큰을 다음 스캐폴드까지 보존.
454
+ // 입력 hex 토큰은 z.object 로 명시 — 누락/오타가 즉시 잡히고 클라이언트(IDE-내 AI)가 schema 만 봐도
455
+ // 어떤 키가 필요한지 자동으로 알 수 있다. round-trip 검증은 encodeTheme 내부의 decodeTheme 호출에 위임.
456
+ const HEX = z.string().regex(/^#[0-9A-Fa-f]{6}$/, "hex 컬러 (#RRGGBB)");
457
+ const tokenMapSchema = z
458
+ .object({
459
+ background: HEX, "background-subtle": HEX, "background-muted": HEX, "background-inverse": HEX,
460
+ foreground: HEX, "foreground-muted": HEX, "foreground-subtle": HEX, "foreground-inverse": HEX,
461
+ border: HEX, "border-strong": HEX,
462
+ primary: HEX, "primary-foreground": HEX, "primary-hover": HEX,
463
+ danger: HEX, "danger-foreground": HEX,
464
+ success: HEX.optional(), "success-foreground": HEX.optional(),
465
+ warning: HEX.optional(), "warning-foreground": HEX.optional(),
466
+ info: HEX.optional(), "info-foreground": HEX.optional(),
467
+ })
468
+ .describe("15개 필수 색 토큰 + 옵셔널 6개(success/warning/info × -foreground). 각 값은 #RRGGBB hex");
469
+
470
+ server.registerTool(
471
+ "sh_ui_encode_theme",
472
+ {
473
+ description:
474
+ "사용자가 손본 색 토큰을 sh-ui base64 테마 코드로 인코딩. " +
475
+ "산출물을 sh_ui_create_project 의 theme 인자에 그대로 넣으면 다음 스캐폴드에서 톤이 보존된다. " +
476
+ "스캐폴드 후 tokens.css 를 직접 편집한 케이스에서 그 결과를 영구 보관할 때 사용.",
477
+ inputSchema: {
478
+ light: tokenMapSchema.describe("라이트 모드 토큰"),
479
+ dark: tokenMapSchema.describe("다크 모드 토큰"),
480
+ radius: z.number().min(0).max(1.5)
481
+ .describe("기본 radius (rem 단위, 0~1.5). 일반 권장값 0.5~0.75"),
482
+ },
483
+ },
484
+ async (input) => {
485
+ try {
486
+ const b64 = encodeTheme({
487
+ light: input.light,
488
+ dark: input.dark,
489
+ radius: input.radius,
490
+ });
491
+ return jsonResult({
492
+ theme: b64,
493
+ length: b64.length,
494
+ hint: "sh_ui_create_project 의 theme 인자에 위 문자열을 그대로 넣으면 됩니다.",
495
+ });
496
+ } catch (e) {
497
+ return {
498
+ isError: true,
499
+ content: [{ type: "text", text: e.message }],
500
+ };
501
+ }
502
+ },
503
+ );
504
+
505
+ server.registerTool(
506
+ "sh_ui_decode_theme",
507
+ {
508
+ description:
509
+ "sh-ui base64 테마 코드를 토큰 객체로 복원. " +
510
+ "기존 base64 테마의 일부만 수정해 다시 인코딩하고 싶을 때 사용 (decode → 수정 → encode).",
511
+ inputSchema: {
512
+ theme: z.string().describe("sh-ui base64 테마 코드"),
513
+ },
514
+ },
515
+ async (input) => {
516
+ try {
517
+ return jsonResult(decodeTheme(input.theme));
518
+ } catch (e) {
519
+ return {
520
+ isError: true,
521
+ content: [{ type: "text", text: e.message }],
522
+ };
523
+ }
524
+ },
525
+ );
526
+
435
527
  // 변경 내역 조회 — 보너스: 사용자가 "최근 변경 알려줘" 류 요청 시
436
528
  server.registerTool(
437
529
  "sh_ui_get_changelog",