sh-ui-cli 0.42.0 → 0.43.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.
@@ -0,0 +1,405 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix"> {
6
+ /** input 우측에 부착할 보조 노드(아이콘·단위·버튼 등). 더 많은 슬롯이 필요하면 InputGroup 사용. */
7
+ suffix?: React.ReactNode;
8
+ /** input 좌측에 부착할 보조 노드. */
9
+ prefix?: React.ReactNode;
10
+ }
11
+
12
+ function cx(...args: (string | undefined | null | false)[]) {
13
+ return args.filter(Boolean).join(" ");
14
+ }
15
+
16
+ /* ───────── Base utility 묶음 (반복 줄이기) ───────── */
17
+
18
+ const baseInputClasses =
19
+ "block w-full h-[var(--control-md)] px-[var(--space-3)] bg-background text-foreground border border-border rounded-[var(--radius)] font-[inherit] text-[length:var(--text-sm)] leading-none transition-[border-color,box-shadow] duration-[var(--duration-fast)] placeholder:text-foreground-subtle hover:not-disabled:not-focus:border-border-strong focus:outline-none focus:border-foreground focus:shadow-[0_0_0_1px_var(--foreground)] disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed disabled:bg-background-subtle read-only:bg-background-subtle aria-[invalid=true]:border-danger aria-[invalid=true]:focus:shadow-[0_0_0_1px_var(--danger)]";
20
+
21
+ const inGroupOverrides =
22
+ "data-[in-group]:flex-1 data-[in-group]:min-w-0 data-[in-group]:h-auto data-[in-group]:p-0 data-[in-group]:bg-transparent data-[in-group]:border-none data-[in-group]:rounded-none data-[in-group]:shadow-none data-[in-group]:hover:border-none data-[in-group]:focus:border-none data-[in-group]:focus:outline-none data-[in-group]:focus:shadow-none data-[in-group]:disabled:bg-transparent";
23
+
24
+ const baseGroupClasses =
25
+ "flex items-center w-full min-h-[var(--control-md)] px-[var(--space-2)] gap-[var(--space-2)] bg-background text-foreground border border-border rounded-[var(--radius)] transition-[border-color,box-shadow] duration-[var(--duration-fast)] cursor-text hover:not-data-[disabled]:not-focus-within:border-border-strong focus-within:border-foreground focus-within:shadow-[0_0_0_1px_var(--foreground)] aria-[invalid=true]:border-danger aria-[invalid=true]:focus-within:shadow-[0_0_0_1px_var(--danger)] data-[disabled]:opacity-[var(--opacity-disabled)] data-[disabled]:cursor-not-allowed data-[disabled]:bg-background-subtle";
26
+
27
+ /* ───────── InputGroup + InputAdornment (compound) ───────── */
28
+
29
+ interface InputGroupContextValue {
30
+ inGroup: true;
31
+ }
32
+
33
+ const InputGroupContext = React.createContext<InputGroupContextValue | null>(null);
34
+
35
+ function useInputGroup() {
36
+ return React.useContext(InputGroupContext);
37
+ }
38
+
39
+ export interface InputGroupProps extends React.HTMLAttributes<HTMLDivElement> {
40
+ "aria-invalid"?: boolean | "true" | "false";
41
+ disabled?: boolean;
42
+ }
43
+
44
+ export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
45
+ (
46
+ { className, children, "aria-invalid": ariaInvalid, disabled, onClick, ...props },
47
+ ref,
48
+ ) => {
49
+ const innerRef = React.useRef<HTMLDivElement | null>(null);
50
+ const mergedRef = React.useCallback(
51
+ (el: HTMLDivElement | null) => {
52
+ innerRef.current = el;
53
+ if (typeof ref === "function") ref(el);
54
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
55
+ },
56
+ [ref],
57
+ );
58
+
59
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
60
+ onClick?.(e);
61
+ if (e.defaultPrevented) return;
62
+ const target = e.target as HTMLElement;
63
+ if (target.closest("button, input, textarea, select, a")) return;
64
+ const input = innerRef.current?.querySelector<HTMLInputElement>("input");
65
+ input?.focus();
66
+ };
67
+
68
+ return (
69
+ <InputGroupContext.Provider value={{ inGroup: true }}>
70
+ <div
71
+ ref={mergedRef}
72
+ className={cx(baseGroupClasses, className)}
73
+ data-disabled={disabled || undefined}
74
+ aria-invalid={ariaInvalid}
75
+ onClick={handleClick}
76
+ {...props}
77
+ >
78
+ {children}
79
+ </div>
80
+ </InputGroupContext.Provider>
81
+ );
82
+ },
83
+ );
84
+ InputGroup.displayName = "InputGroup";
85
+
86
+ export interface InputAdornmentProps extends React.HTMLAttributes<HTMLSpanElement> {
87
+ interactive?: boolean;
88
+ }
89
+
90
+ export const InputAdornment = React.forwardRef<HTMLSpanElement, InputAdornmentProps>(
91
+ ({ className, interactive, ...props }, ref) => (
92
+ <span
93
+ ref={ref}
94
+ className={cx(
95
+ "inline-flex items-center justify-center flex-none text-foreground-muted px-[var(--space-1)] data-[interactive]:p-0",
96
+ className,
97
+ )}
98
+ data-interactive={interactive || undefined}
99
+ {...props}
100
+ />
101
+ ),
102
+ );
103
+ InputAdornment.displayName = "InputAdornment";
104
+
105
+ /* ───────── Input (Tailwind utility 변종) ───────── */
106
+
107
+ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
108
+ ({ className, type = "text", prefix, suffix, ...props }, ref) => {
109
+ const group = useInputGroup();
110
+ const hasAffix = Boolean(prefix || suffix);
111
+ const input = (
112
+ <input
113
+ ref={ref}
114
+ type={type}
115
+ className={cx(
116
+ baseInputClasses,
117
+ inGroupOverrides,
118
+ !!prefix && "pl-[var(--space-10)]",
119
+ !!suffix && "pr-[var(--space-10)]",
120
+ className,
121
+ )}
122
+ data-in-group={group ? "" : undefined}
123
+ {...props}
124
+ />
125
+ );
126
+
127
+ if (!hasAffix) return input;
128
+
129
+ return (
130
+ <div
131
+ className="relative w-full block data-[in-group]:flex-1 data-[in-group]:min-w-0"
132
+ data-in-group={group ? "" : undefined}
133
+ >
134
+ {prefix && (
135
+ <span className="absolute top-1/2 -translate-y-1/2 left-[var(--space-3)] inline-flex items-center justify-center text-foreground-muted pointer-events-none [&>*]:pointer-events-auto">
136
+ {prefix}
137
+ </span>
138
+ )}
139
+ {input}
140
+ {suffix && (
141
+ <span className="absolute top-1/2 -translate-y-1/2 right-[var(--space-1)] inline-flex items-center justify-center text-foreground-muted pointer-events-none [&>*]:pointer-events-auto">
142
+ {suffix}
143
+ </span>
144
+ )}
145
+ </div>
146
+ );
147
+ },
148
+ );
149
+ Input.displayName = "Input";
150
+
151
+ /* ───────── PasswordInput ───────── */
152
+
153
+ const passwordToggleClasses =
154
+ "inline-flex items-center justify-center w-8 h-8 p-0 bg-transparent border-none rounded-[calc(var(--radius)-2px)] text-foreground-muted cursor-pointer transition-[color,background-color] duration-[var(--duration-fast)] hover:text-foreground hover:bg-background-muted focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2";
155
+
156
+ function EyeIcon() {
157
+ return (
158
+ <svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
159
+ <path d="M2 10s3-5.5 8-5.5S18 10 18 10s-3 5.5-8 5.5S2 10 2 10Z" stroke="currentColor" strokeWidth="1.5" />
160
+ <circle cx="10" cy="10" r="2.25" stroke="currentColor" strokeWidth="1.5" />
161
+ </svg>
162
+ );
163
+ }
164
+
165
+ function EyeOffIcon() {
166
+ return (
167
+ <svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
168
+ <path
169
+ d="M3 3l14 14M8 5a8 8 0 0 1 2-.3c5 0 8 5.3 8 5.3a13 13 0 0 1-2.3 2.9M12 12a2.5 2.5 0 0 1-3.4-3.4m-2.3-2.5A13 13 0 0 0 2 10s3 5.5 8 5.5a8 8 0 0 0 3.3-.7"
170
+ stroke="currentColor"
171
+ strokeWidth="1.5"
172
+ strokeLinecap="round"
173
+ />
174
+ </svg>
175
+ );
176
+ }
177
+
178
+ export interface PasswordInputProps extends Omit<InputProps, "type" | "suffix"> {
179
+ hideToggle?: boolean;
180
+ }
181
+
182
+ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
183
+ ({ hideToggle, ...props }, ref) => {
184
+ const [visible, setVisible] = React.useState(false);
185
+
186
+ const toggle = hideToggle ? undefined : (
187
+ <button
188
+ type="button"
189
+ className={passwordToggleClasses}
190
+ onClick={() => setVisible((v) => !v)}
191
+ aria-label={visible ? "비밀번호 숨기기" : "비밀번호 표시"}
192
+ aria-pressed={visible}
193
+ tabIndex={-1}
194
+ >
195
+ {visible ? <EyeOffIcon /> : <EyeIcon />}
196
+ </button>
197
+ );
198
+
199
+ return <Input ref={ref} type={visible ? "text" : "password"} suffix={toggle} {...props} />;
200
+ },
201
+ );
202
+ PasswordInput.displayName = "PasswordInput";
203
+
204
+ /* ───────── NumberInput / PhoneInput / BusinessNumberInput
205
+ * 이 세 컴포넌트는 Input 을 wrap 만 — 자체 className 사용 안 함.
206
+ * plain 변종과 동일한 로직을 그대로 노출. */
207
+
208
+ export interface NumberInputProps
209
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
210
+ value?: number;
211
+ defaultValue?: number;
212
+ onValueChange?: (value: number | undefined) => void;
213
+ thousandsSeparator?: boolean;
214
+ min?: number;
215
+ max?: number;
216
+ allowNegative?: boolean;
217
+ }
218
+
219
+ const formatNumber = (digits: string, thousandsSeparator: boolean): string => {
220
+ if (digits === "" || digits === "-") return digits;
221
+ const negative = digits.startsWith("-");
222
+ const body = negative ? digits.slice(1) : digits;
223
+ if (!body) return negative ? "-" : "";
224
+ const formatted = thousandsSeparator ? body.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : body;
225
+ return negative ? `-${formatted}` : formatted;
226
+ };
227
+
228
+ const parseNumber = (s: string): number | undefined => {
229
+ const cleaned = s.replace(/[^\d-]/g, "");
230
+ if (!cleaned || cleaned === "-") return undefined;
231
+ const n = Number(cleaned);
232
+ return Number.isFinite(n) ? n : undefined;
233
+ };
234
+
235
+ export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
236
+ (
237
+ { value, defaultValue, onValueChange, thousandsSeparator = true, min, max, allowNegative = true, onBlur, ...rest },
238
+ ref,
239
+ ) => {
240
+ const isControlled = value !== undefined;
241
+ const initial =
242
+ defaultValue !== undefined ? formatNumber(String(defaultValue), thousandsSeparator) : "";
243
+ const [internal, setInternal] = React.useState(initial);
244
+
245
+ const display = isControlled
246
+ ? value === undefined
247
+ ? ""
248
+ : formatNumber(String(value), thousandsSeparator)
249
+ : internal;
250
+
251
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
252
+ const raw = e.target.value;
253
+ const allowedRe = allowNegative ? /[^\d-]/g : /[^\d]/g;
254
+ let cleaned = raw.replace(allowedRe, "");
255
+ if (allowNegative) cleaned = cleaned.replace(/(?!^)-/g, "");
256
+ const formatted = formatNumber(cleaned, thousandsSeparator);
257
+ if (!isControlled) setInternal(formatted);
258
+ onValueChange?.(parseNumber(cleaned));
259
+ };
260
+
261
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
262
+ const n = parseNumber(display);
263
+ if (n !== undefined) {
264
+ let clamped = n;
265
+ if (min !== undefined && clamped < min) clamped = min;
266
+ if (max !== undefined && clamped > max) clamped = max;
267
+ if (clamped !== n) {
268
+ const f = formatNumber(String(clamped), thousandsSeparator);
269
+ if (!isControlled) setInternal(f);
270
+ onValueChange?.(clamped);
271
+ }
272
+ }
273
+ onBlur?.(e);
274
+ };
275
+
276
+ return (
277
+ <Input
278
+ ref={ref}
279
+ type="text"
280
+ inputMode="numeric"
281
+ value={display}
282
+ onChange={handleChange}
283
+ onBlur={handleBlur}
284
+ {...rest}
285
+ />
286
+ );
287
+ },
288
+ );
289
+ NumberInput.displayName = "NumberInput";
290
+
291
+ const formatPhoneKR = (digits: string): string => {
292
+ const d = digits.replace(/\D/g, "").slice(0, 11);
293
+ if (d.length === 0) return "";
294
+ if (d.startsWith("02")) {
295
+ if (d.length <= 2) return d;
296
+ if (d.length <= 5) return `${d.slice(0, 2)}-${d.slice(2)}`;
297
+ if (d.length <= 9) return `${d.slice(0, 2)}-${d.slice(2, 5)}-${d.slice(5)}`;
298
+ return `${d.slice(0, 2)}-${d.slice(2, 6)}-${d.slice(6, 10)}`;
299
+ }
300
+ if (d.length <= 3) return d;
301
+ if (d.length <= 6) return `${d.slice(0, 3)}-${d.slice(3)}`;
302
+ if (d.length <= 10) return `${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}`;
303
+ return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7, 11)}`;
304
+ };
305
+
306
+ export interface PhoneInputProps
307
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
308
+ value?: string;
309
+ defaultValue?: string;
310
+ onValueChange?: (digits: string) => void;
311
+ }
312
+
313
+ export const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
314
+ ({ value, defaultValue, onValueChange, onBlur, ...rest }, ref) => {
315
+ const isControlled = value !== undefined;
316
+ const initial = formatPhoneKR(defaultValue ?? "");
317
+ const [internal, setInternal] = React.useState(initial);
318
+
319
+ const display = isControlled ? formatPhoneKR(value ?? "") : internal;
320
+
321
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
322
+ const digits = e.target.value.replace(/\D/g, "").slice(0, 11);
323
+ const formatted = formatPhoneKR(digits);
324
+ if (!isControlled) setInternal(formatted);
325
+ onValueChange?.(digits);
326
+ };
327
+
328
+ return (
329
+ <Input
330
+ ref={ref}
331
+ type="tel"
332
+ inputMode="tel"
333
+ autoComplete="tel"
334
+ value={display}
335
+ onChange={handleChange}
336
+ onBlur={onBlur}
337
+ {...rest}
338
+ />
339
+ );
340
+ },
341
+ );
342
+ PhoneInput.displayName = "PhoneInput";
343
+
344
+ const formatBRN = (digits: string): string => {
345
+ const d = digits.replace(/\D/g, "").slice(0, 10);
346
+ if (d.length <= 3) return d;
347
+ if (d.length <= 5) return `${d.slice(0, 3)}-${d.slice(3)}`;
348
+ return `${d.slice(0, 3)}-${d.slice(3, 5)}-${d.slice(5)}`;
349
+ };
350
+
351
+ export function isValidBRN(digits: string): boolean {
352
+ const d = digits.replace(/\D/g, "");
353
+ if (d.length !== 10) return false;
354
+ const w = [1, 3, 7, 1, 3, 7, 1, 3, 5];
355
+ let sum = 0;
356
+ for (let i = 0; i < 9; i++) sum += parseInt(d[i], 10) * w[i];
357
+ sum += Math.floor((parseInt(d[8], 10) * 5) / 10);
358
+ const check = (10 - (sum % 10)) % 10;
359
+ return check === parseInt(d[9], 10);
360
+ }
361
+
362
+ export interface BusinessNumberInputProps
363
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
364
+ value?: string;
365
+ defaultValue?: string;
366
+ onValueChange?: (digits: string) => void;
367
+ validateChecksum?: boolean;
368
+ }
369
+
370
+ export const BusinessNumberInput = React.forwardRef<HTMLInputElement, BusinessNumberInputProps>(
371
+ ({ value, defaultValue, onValueChange, validateChecksum, onBlur, "aria-invalid": ariaInvalidProp, ...rest }, ref) => {
372
+ const isControlled = value !== undefined;
373
+ const initial = formatBRN(defaultValue ?? "");
374
+ const [internal, setInternal] = React.useState(initial);
375
+
376
+ const display = isControlled ? formatBRN(value ?? "") : internal;
377
+ const digits = display.replace(/\D/g, "");
378
+
379
+ const invalid =
380
+ ariaInvalidProp !== undefined
381
+ ? ariaInvalidProp
382
+ : validateChecksum && digits.length === 10 && !isValidBRN(digits);
383
+
384
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
385
+ const next = e.target.value.replace(/\D/g, "").slice(0, 10);
386
+ const formatted = formatBRN(next);
387
+ if (!isControlled) setInternal(formatted);
388
+ onValueChange?.(next);
389
+ };
390
+
391
+ return (
392
+ <Input
393
+ ref={ref}
394
+ type="text"
395
+ inputMode="numeric"
396
+ value={display}
397
+ onChange={handleChange}
398
+ onBlur={onBlur}
399
+ aria-invalid={invalid || undefined}
400
+ {...rest}
401
+ />
402
+ );
403
+ },
404
+ );
405
+ BusinessNumberInput.displayName = "BusinessNumberInput";
@@ -3,6 +3,7 @@
3
3
  "versions": {
4
4
  "@base-ui/react": "^1.4.1",
5
5
  "@tanstack/react-form": "^1.29.1",
6
+ "class-variance-authority": "^0.7.1",
6
7
  "lucide-react": "^1.11.0",
7
8
  "react-hook-form": "^7.74.0",
8
9
  "shiki": "^4.0.2"
@@ -1,5 +1,5 @@
1
1
  {
2
- "$description": "React 레지스트리 매니페스트 — CLI가 각 컴포넌트 name으로 조회해 files를 사용자 프로젝트로 복사한다.",
2
+ "$description": "React 레지스트리 매니페스트 — CLI가 각 컴포넌트 name으로 조회해 files를 사용자 프로젝트로 복사한다. 컴포넌트 또는 file 엔트리에 frameworks?: string[] 옵션 — 미지정시 모든 cssFramework 에 적용, 지정시 해당 배열에 포함된 경우만 복사.",
3
3
  "components": {
4
4
  "button": {
5
5
  "name": "button",
@@ -7,14 +7,23 @@
7
7
  "files": [
8
8
  {
9
9
  "src": "components/button/index.tsx",
10
- "dest": "{components}/button/index.tsx"
10
+ "dest": "{components}/button/index.tsx",
11
+ "frameworks": ["plain"]
11
12
  },
12
13
  {
13
14
  "src": "components/button/styles.css",
14
- "dest": "{components}/button/styles.css"
15
+ "dest": "{components}/button/styles.css",
16
+ "frameworks": ["plain"]
17
+ },
18
+ {
19
+ "src": "components/button/index.tailwind.tsx",
20
+ "dest": "{components}/button/index.tsx",
21
+ "frameworks": ["tailwind"]
15
22
  }
16
23
  ],
17
- "dependencies": [],
24
+ "dependencies": [
25
+ { "name": "class-variance-authority", "frameworks": ["tailwind"] }
26
+ ],
18
27
  "registryDependencies": []
19
28
  },
20
29
  "card": {
@@ -23,11 +32,18 @@
23
32
  "files": [
24
33
  {
25
34
  "src": "components/card/index.tsx",
26
- "dest": "{components}/card/index.tsx"
35
+ "dest": "{components}/card/index.tsx",
36
+ "frameworks": ["plain"]
27
37
  },
28
38
  {
29
39
  "src": "components/card/styles.css",
30
- "dest": "{components}/card/styles.css"
40
+ "dest": "{components}/card/styles.css",
41
+ "frameworks": ["plain"]
42
+ },
43
+ {
44
+ "src": "components/card/index.tailwind.tsx",
45
+ "dest": "{components}/card/index.tsx",
46
+ "frameworks": ["tailwind"]
31
47
  }
32
48
  ],
33
49
  "dependencies": [],
@@ -39,11 +55,18 @@
39
55
  "files": [
40
56
  {
41
57
  "src": "components/input/index.tsx",
42
- "dest": "{components}/input/index.tsx"
58
+ "dest": "{components}/input/index.tsx",
59
+ "frameworks": ["plain"]
43
60
  },
44
61
  {
45
62
  "src": "components/input/styles.css",
46
- "dest": "{components}/input/styles.css"
63
+ "dest": "{components}/input/styles.css",
64
+ "frameworks": ["plain"]
65
+ },
66
+ {
67
+ "src": "components/input/index.tailwind.tsx",
68
+ "dest": "{components}/input/index.tsx",
69
+ "frameworks": ["tailwind"]
47
70
  }
48
71
  ],
49
72
  "dependencies": [],
@@ -102,6 +102,29 @@ function mergeThemeIndependent(tokens) {
102
102
  return out;
103
103
  }
104
104
 
105
+ /**
106
+ * Tailwind v4 의 @theme inline 블록에 sh-ui 토큰을 매핑해
107
+ * `bg-primary`, `border-border`, `rounded-md` 같은 Tailwind utility 가
108
+ * 동작하도록 한다. 매핑은 light 토큰 맵에서 파생 — 모든 색 키를
109
+ * Tailwind 의 --color-* 네임스페이스로 옮김.
110
+ *
111
+ * radius 는 단일 토큰 (--radius) 이지만 Tailwind 의 rounded-{sm,md,lg,xl}
112
+ * 4 단계로 expand. 템플릿이 손으로 박아두던 패턴을 단일 소스로 옮긴 것.
113
+ */
114
+ function buildTailwindThemeBlock(lightTokens) {
115
+ const lines = [];
116
+ for (const path of Object.keys(lightTokens)) {
117
+ const cssVar = toCssVar(path);
118
+ const themeKey = cssVar.replace(/^--/, "--color-");
119
+ lines.push(` ${themeKey}: var(${cssVar});`);
120
+ }
121
+ lines.push(` --radius-sm: calc(var(--radius) - 2px);`);
122
+ lines.push(` --radius-md: var(--radius);`);
123
+ lines.push(` --radius-lg: calc(var(--radius) + 2px);`);
124
+ lines.push(` --radius-xl: calc(var(--radius) + 4px);`);
125
+ return `@theme inline {\n${lines.join("\n")}\n}`;
126
+ }
127
+
105
128
  export async function buildTokensCss(config) {
106
129
  const tokens = await resolveTokens(config);
107
130
  const mode = config.theme.mode;
@@ -116,6 +139,7 @@ export async function buildTokensCss(config) {
116
139
  if (mode === "light-dark") {
117
140
  blocks.push(emitCssBlock(".dark", tokens.dark));
118
141
  }
142
+ blocks.push(buildTailwindThemeBlock(tokens.light));
119
143
  const header = `/* Generated by @sh-ui/tokens — do not edit directly */\n/* base=${config.theme.base} radius=${config.theme.radius} mode=${config.theme.mode} */\n`;
120
144
  return header + "\n" + blocks.join("\n\n") + "\n";
121
145
  }
@@ -533,6 +557,48 @@ import 'package:flutter/material.dart';
533
557
  return `${header}\n${classes.join("\n\n")}\n\n${themeClass}\n`;
534
558
  }
535
559
 
560
+ /* ───────── Emitter 디스패처 ─────────
561
+ *
562
+ * (platform × cssFramework) → 토큰 emitter.
563
+ * 향후 Tailwind theme config / CSS Modules / vanilla-extract 추가 시
564
+ * 이 테이블에 한 줄만 등록하면 끝나도록 구조를 미리 잡아 둠.
565
+ *
566
+ * Flutter 는 CSS 프레임워크 개념이 무의미하지만, 일관된 디스패치를 위해
567
+ * "plain" 키로 통일 — 호출부가 platform 별 분기 없이 같은 진입점을 쓸 수 있게.
568
+ */
569
+ const tokenEmitters = {
570
+ react: {
571
+ plain: buildTokensCss,
572
+ // Tailwind 변종은 같은 tokens.css 를 공유. 컴포넌트의 utility class 가
573
+ // var(--primary) 를 @theme inline 매핑을 통해 참조하므로 — 토큰 자체는
574
+ // 동일. 향후 Tailwind v3 theme.config.ts 를 별도 emit 하고 싶으면
575
+ // 여기에 다른 함수를 등록.
576
+ tailwind: buildTokensCss,
577
+ },
578
+ flutter: {
579
+ plain: buildTokensDart,
580
+ },
581
+ };
582
+
583
+ export async function buildTokens(config) {
584
+ const platform = config.platform;
585
+ const fw = config.cssFramework ?? "plain";
586
+ const platformEmitters = tokenEmitters[platform];
587
+ if (!platformEmitters) {
588
+ throw new Error(
589
+ `tokens: 알 수 없는 platform '${platform}'. 지원: ${Object.keys(tokenEmitters).join(", ")}`,
590
+ );
591
+ }
592
+ const emit = platformEmitters[fw];
593
+ if (!emit) {
594
+ const supported = Object.keys(platformEmitters).join(", ");
595
+ throw new Error(
596
+ `tokens emitter 미구현: ${platform}/${fw}. 현재 지원: ${platform}/{${supported}}`,
597
+ );
598
+ }
599
+ return emit(config);
600
+ }
601
+
536
602
  /* ───────── CLI ───────── */
537
603
 
538
604
  if (import.meta.url === `file://${process.argv[1]}`) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/add.mjs CHANGED
@@ -149,11 +149,8 @@ async function addTokens(config, cwd, diffMode, summary, conflictResolver) {
149
149
  if (!destRel) throw new Error("paths.tokens 가 설정에 없습니다.");
150
150
  const dest = resolve(cwd, destRel);
151
151
 
152
- const { buildTokensCss, buildTokensDart } = await loadTokensBuilder();
153
- const content =
154
- config.platform === "react"
155
- ? await buildTokensCss(config)
156
- : await buildTokensDart(config);
152
+ const { buildTokens } = await loadTokensBuilder();
153
+ const content = await buildTokens(config);
157
154
 
158
155
  const result = await writeOrDiff({ dest, content, cwd, diffMode, summary, conflictResolver });
159
156
  if (!diffMode && result !== "unchanged") {
@@ -163,6 +160,31 @@ async function addTokens(config, cwd, diffMode, summary, conflictResolver) {
163
160
  }
164
161
  }
165
162
 
163
+ /**
164
+ * registry 엔트리의 frameworks 필드와 현재 cssFramework 가 호환되는지.
165
+ * 필드가 없으면 "모든 프레임워크에 적용" — 기본 케이스.
166
+ */
167
+ function frameworkMatches(entry, cssFramework) {
168
+ if (!entry.frameworks) return true;
169
+ return entry.frameworks.includes(cssFramework);
170
+ }
171
+
172
+ /**
173
+ * cssFramework="tailwind" 인데 컴포넌트에 tailwind 전용 변종 파일이 없으면
174
+ * plain 으로 fallback. plain CSS 컴포넌트도 @theme inline 브리지 덕분에
175
+ * Tailwind v4 프로젝트에서 그대로 동작하므로 깨지지 않음.
176
+ *
177
+ * 점진적 rollout 전략 — 모든 컴포넌트가 한 번에 tailwind 변종을 갖출 필요 없이
178
+ * 가능한 것부터 utility-class 변종을 제공하고, 나머지는 plain 으로 자연 처리.
179
+ */
180
+ function effectiveFramework(entry, cssFramework) {
181
+ if (cssFramework !== "tailwind") return cssFramework;
182
+ const hasTailwindVariant = (entry.files ?? []).some(
183
+ (f) => f.frameworks && f.frameworks.includes("tailwind"),
184
+ );
185
+ return hasTailwindVariant ? "tailwind" : "plain";
186
+ }
187
+
166
188
  async function addComponent(name, config, cwd, installed, pendingDeps, diffMode, summary, conflictResolver) {
167
189
  const registryRoot = getRegistryRoot(config.platform);
168
190
  const registry = JSON.parse(
@@ -175,11 +197,30 @@ async function addComponent(name, config, cwd, installed, pendingDeps, diffMode,
175
197
  );
176
198
  }
177
199
 
200
+ const requestedFw = config.cssFramework ?? "plain";
201
+ const cssFramework = effectiveFramework(entry, requestedFw);
202
+
203
+ // 사용자가 tailwind 를 골랐는데 이 컴포넌트는 plain 으로 fallback 된 경우 한 줄 알림.
204
+ // 동작에 문제는 없지만 일관성에 대한 기대를 정확히 셋업하기 위함.
205
+ if (requestedFw === "tailwind" && cssFramework === "plain" && !diffMode) {
206
+ console.log(
207
+ `ℹ ${name} — Tailwind 변종 미제공, plain 변종으로 설치 (Tailwind v4 환경에서 그대로 동작)`,
208
+ );
209
+ }
210
+
211
+ if (!frameworkMatches(entry, cssFramework)) {
212
+ console.log(
213
+ `↷ ${name} skipped — cssFramework=${cssFramework} 미지원 (지원: ${entry.frameworks.join(", ")})`,
214
+ );
215
+ return;
216
+ }
217
+
178
218
  for (const dep of entry.registryDependencies ?? []) {
179
219
  await addOne(dep, config, cwd, installed, pendingDeps, diffMode, summary, conflictResolver);
180
220
  }
181
221
 
182
222
  for (const file of entry.files) {
223
+ if (!frameworkMatches(file, cssFramework)) continue;
183
224
  const src = resolve(registryRoot, file.src);
184
225
  const dest = resolve(cwd, resolveDest(file.dest, config));
185
226
  const content = await readFile(src, "utf8");
@@ -192,7 +233,14 @@ async function addComponent(name, config, cwd, installed, pendingDeps, diffMode,
192
233
  }
193
234
 
194
235
  for (const dep of entry.dependencies ?? []) {
195
- pendingDeps.add(dep);
236
+ // dep 은 string ("react-hook-form") 또는 object ({name, frameworks?: string[]}).
237
+ // 후자는 cssFramework 에 따라 의존성을 분기 (예: cva 는 tailwind 변종에만 필요).
238
+ if (typeof dep === "string") {
239
+ pendingDeps.add(dep);
240
+ } else if (dep && typeof dep === "object" && dep.name) {
241
+ if (dep.frameworks && !dep.frameworks.includes(cssFramework)) continue;
242
+ pendingDeps.add(dep.name);
243
+ }
196
244
  }
197
245
  }
198
246