sh-ui-cli 0.86.1 → 0.87.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.
Files changed (37) hide show
  1. package/data/changelog/versions.json +14 -0
  2. package/package.json +1 -1
  3. package/src/create/describeTemplate.js +53 -11
  4. package/src/create/generator.js +68 -7
  5. package/src/create/templateManifest.js +41 -0
  6. package/src/mcp.mjs +1 -1
  7. package/templates/vite-app/_arch/flat/src/App.tsx +13 -0
  8. package/templates/vite-app/_arch/flat/src/components/layouts/RootLayout.tsx +5 -0
  9. package/templates/vite-app/_arch/flat/src/components/providers/GlobalProvider/index.tsx +13 -0
  10. package/templates/vite-app/_arch/flat/src/components/providers/index.tsx +1 -0
  11. package/templates/vite-app/_arch/flat/src/components/providers/theme/ThemeProvider.tsx +59 -0
  12. package/templates/vite-app/_arch/flat/src/lib/api/queryClient.ts +12 -0
  13. package/templates/vite-app/_arch/flat/src/lib/hooks/useTheme.ts +8 -0
  14. package/templates/vite-app/_arch/flat/src/lib/utils/utils.ts +6 -0
  15. package/templates/vite-app/_arch/flat/src/main.tsx +10 -0
  16. package/templates/vite-app/_arch/flat/tsconfig.app.json +19 -0
  17. package/templates/vite-app/_arch/fsd/src/App.tsx +13 -0
  18. package/templates/vite-app/_arch/fsd/src/app/layouts/RootLayout.tsx +5 -0
  19. package/templates/vite-app/_arch/fsd/src/app/providers/GlobalProvider/index.tsx +13 -0
  20. package/templates/vite-app/_arch/fsd/src/app/providers/theme/ThemeProvider.tsx +59 -0
  21. package/templates/vite-app/_arch/fsd/src/main.tsx +10 -0
  22. package/templates/vite-app/_arch/fsd/src/shared/api/queryClient.ts +12 -0
  23. package/templates/vite-app/_arch/fsd/src/shared/hooks/useTheme.ts +8 -0
  24. package/templates/vite-app/_arch/fsd/src/shared/lib/utils.ts +6 -0
  25. package/templates/vite-app/_arch/fsd/tsconfig.app.json +18 -0
  26. package/templates/vite-app/eslint.config.js +3 -0
  27. package/templates/vite-app/gitignore +8 -0
  28. package/templates/vite-app/index.html +22 -0
  29. package/templates/vite-app/package.json +48 -0
  30. package/templates/vite-app/src/App.tsx +5 -0
  31. package/templates/vite-app/src/Home.tsx +7 -0
  32. package/templates/vite-app/src/main.tsx +9 -0
  33. package/templates/vite-app/tsconfig.json +7 -0
  34. package/templates/vite-app/tsconfig.node.json +12 -0
  35. package/templates/vite-app/vite.config.ts +11 -0
  36. package/templates/vite-app/vitest.config.ts +13 -0
  37. package/templates/vite-app/vitest.setup.ts +1 -0
@@ -2,6 +2,20 @@
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.87.0",
7
+ "date": "2026-05-14",
8
+ "title": "Vite monorepo — apps/{name} + packages/ui/ui-core 공유 SoT",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**`sh-ui-cli create --platform vite --structure monorepo`** — vite 도 monorepo 진입점이 됨. ai-org 같은 단일 vite SPA 시나리오를 넘어, 여러 vite 앱이 packages/ui/ui-core 의 컴포넌트를 공유하는 워크스페이스 구조까지 1급 지원. apps/{name}/ (vite-app 템플릿) + packages/ui/ui-core/ (v0.65 단일 SoT) + packages/ui/ui-apps/ui-{name}/ (tokens-only role) 자동 emit.",
12
+ "**vite-app 템플릿 신설** — packages/cli/templates/vite-app/ 추가 (31 파일, base + flat + fsd overlays). nextjs-app 과 동일 패턴이지만 vite 5 + @tailwindcss/vite + vite-tsconfig-paths 기반. `@workspace/ui-core` / `@workspace/ui-{name}` workspace 별칭은 tsconfig.app.json 단일 SoT 로 핀고정, vite 빌드 타임에는 vite-tsconfig-paths 가 그대로 해석. workspace 별칭과 빌드 별칭이 어긋날 일 없음.",
13
+ "**generateMonorepo 가 platform 분기** — { platform: 'next' | 'vite' } 옵션 추가. vite 일 때 generateViteApp 으로 위임 (next 의 plugin pipeline / next.config 분기 없이 vite.config.ts 의 server.port 만 patch). ui-app-template + monorepo 베이스 + ui-core 는 두 플랫폼이 공유 — 코드 중복 없음.",
14
+ "**describeTemplate vite monorepo 분기** — monorepo 루트 + apps/{name} 베이스 + arch 오버레이 + ui-app 패키지 4 개 그룹으로 미리보기 산출. apps/docs CreateProjectDialog 와 MCP `sh_ui_describe_template` 둘 다 동일 결과 제공.",
15
+ "**스모크 회귀 가드** — `scenario V6` 가 vite 모노레포 스캐폴드의 파일 트리 + tsconfig workspace 별칭 치환 + ui-app role: tokens-only 확정. 빌드 사이클(`pnpm install + pnpm build`)은 Task E manual 에서 fsd/flat 양쪽 PASS — `composite: true` 와 base.json 의 `incremental: false` 충돌 + `clsx`/`tailwind-merge` 누락 두 가지를 사전 차단."
16
+ ],
17
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.87.0"
18
+ },
5
19
  {
6
20
  "version": "0.86.1",
7
21
  "date": "2026-05-14",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.86.1",
3
+ "version": "0.87.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -72,22 +72,64 @@ export function describeTemplate(opts = {}) {
72
72
  }
73
73
 
74
74
  if (platform === 'vite') {
75
- // standalone only in Phase 1. monorepo is added in Task 13.
76
- const tpl = TEMPLATE_MANIFEST['vite-standalone'];
77
- if (!tpl) {
78
- throw new Error("Template manifest missing entry for 'vite-standalone'.");
79
- }
80
75
  const safeArchName = isKnownArch(archName) ? archName : DEFAULT_ARCH;
81
76
  const archObj = getArchByName(safeArchName);
82
77
  if (!archObj.platforms.includes('vite')) {
83
78
  throw new Error(`Arch '${safeArchName}' is not compatible with vite.`);
84
79
  }
85
- const baseFiles = tpl.base.slice();
86
- const archFiles = (tpl.arches?.[safeArchName] ?? []).slice();
87
- return finalize([
88
- makeGroup('base', '베이스 (vite-standalone)', baseFiles),
89
- makeGroup('arch', `Arch (${safeArchName})`, archFiles),
90
- ]);
80
+
81
+ if (structure === 'standalone') {
82
+ const tpl = TEMPLATE_MANIFEST['vite-standalone'];
83
+ if (!tpl) {
84
+ throw new Error("Template manifest missing entry for 'vite-standalone'.");
85
+ }
86
+ const baseFiles = tpl.base.slice();
87
+ const archFiles = (tpl.arches?.[safeArchName] ?? []).slice();
88
+ return finalize([
89
+ makeGroup('base', '베이스 (vite-standalone)', baseFiles),
90
+ makeGroup('arch', `Arch (${safeArchName})`, archFiles),
91
+ ]);
92
+ }
93
+
94
+ // monorepo — vite app 변형. Next monorepo 브랜치와 동일 구조이지만 vite-app 템플릿
95
+ // 사용 + 플러그인 없음 (vite 는 아직 plugin 시스템 없음).
96
+ const appName = rawAppName || 'web';
97
+ const viteAppTpl = TEMPLATE_MANIFEST['vite-app'];
98
+ if (!viteAppTpl) {
99
+ throw new Error("Template manifest missing entry for 'vite-app'.");
100
+ }
101
+ const groups = [];
102
+
103
+ groups.push(makeGroup(
104
+ 'monorepo',
105
+ '모노레포 루트',
106
+ TEMPLATE_MANIFEST['monorepo'].base.slice(),
107
+ ));
108
+
109
+ const prefix = `apps/${appName}/`;
110
+ groups.push(makeGroup(
111
+ `app-base`,
112
+ `apps/${appName} — vite-app 베이스`,
113
+ viteAppTpl.base.map((p) => prefix + p),
114
+ ));
115
+ const appArchFiles = (viteAppTpl.arches?.[safeArchName] ?? []).map((p) => prefix + p);
116
+ if (appArchFiles.length > 0) {
117
+ groups.push(makeGroup(
118
+ `app-arch`,
119
+ `apps/${appName} — Arch (${safeArchName})`,
120
+ appArchFiles,
121
+ ));
122
+ }
123
+
124
+ groups.push(makeGroup(
125
+ 'ui-app',
126
+ `packages/ui/ui-apps/ui-${appName}`,
127
+ TEMPLATE_MANIFEST['ui-app-template'].base.map(
128
+ (p) => `packages/ui/ui-apps/ui-${appName}/${p}`,
129
+ ),
130
+ ));
131
+
132
+ return finalize(groups);
91
133
  }
92
134
 
93
135
  // platform === 'next'
@@ -335,11 +335,7 @@ export async function createProject(options = {}) {
335
335
  if (projectType === 'standalone') {
336
336
  await generateViteStandalone(targetDir, projectName, theme, cssFramework, arch, themeBase);
337
337
  } else {
338
- // monorepo path is added in Task 12. For now, fail loudly so users know.
339
- throw new Error(
340
- 'platform=vite + structure=monorepo 는 아직 구현되지 않았습니다 (Phase 2 — v0.87 예정). ' +
341
- 'standalone 을 사용하거나 platform=next 로 monorepo 를 만든 뒤 vite 앱을 수동으로 추가해주세요.',
342
- );
338
+ await generateMonorepo(targetDir, projectName, [], { yes: options.yes, theme, css: cssFramework, arch, themeBase, platform: 'vite' });
343
339
  }
344
340
 
345
341
  await finalizeProject(targetDir, { dryRun: options.dryRun });
@@ -772,7 +768,7 @@ async function generateViteStandalone(targetDir, projectName, theme, css, arch,
772
768
  await patchShUiConfig(path.join(targetDir, 'sh-ui.config.json'), css, themeBase);
773
769
  }
774
770
 
775
- async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase } = {}) {
771
+ async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase, platform = 'next' } = {}) {
776
772
  await fs.copy(path.join(TEMPLATES_DIR, 'monorepo'), targetDir);
777
773
 
778
774
  // Update root package.json
@@ -803,7 +799,11 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
803
799
  });
804
800
 
805
801
  const appsDir = path.join(targetDir, 'apps', appName);
806
- await generateApp(appsDir, appName, port, plugins, arch, css);
802
+ if (platform === 'vite') {
803
+ await generateViteApp(appsDir, appName, port, arch, css);
804
+ } else {
805
+ await generateApp(appsDir, appName, port, plugins, arch, css);
806
+ }
807
807
  // generateApp 이 ui-{app} 패키지의 cssFramework 변종까지 처리. 여기선 theme + sh-ui.config.json 만.
808
808
  const uiAppDir = path.join(targetDir, 'packages', 'ui', 'ui-apps', `ui-${appName}`);
809
809
  await injectCssTheme(uiAppDir, theme);
@@ -901,6 +901,67 @@ async function generateApp(targetDir, appName, port, plugins, arch, css = 'tailw
901
901
  }
902
902
  }
903
903
 
904
+ async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind') {
905
+ // 베이스 (arch-neutral) + arch 오버레이 — generateApp 과 동일 패턴.
906
+ await fs.copy(path.join(TEMPLATES_DIR, 'vite-app'), targetDir, {
907
+ filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
908
+ });
909
+ await ensureArchCleanup(targetDir);
910
+ await fs.copy(
911
+ path.join(TEMPLATES_DIR, 'vite-app', '_arch', arch.name),
912
+ targetDir,
913
+ { overwrite: true },
914
+ );
915
+ // vite-app 의 flat overlay 는 src/ 하위 — arch.paths.layouts(next 관용) 앞에 src/ 보정.
916
+ // (generateViteStandalone 의 동일 인라인 가드와 같은 이유 — fsd 는 이미 src/app/layouts.)
917
+ const layoutsPath = arch.paths.layouts.startsWith('src/')
918
+ ? arch.paths.layouts
919
+ : `src/${arch.paths.layouts}`;
920
+ const sentinelPath = path.join(targetDir, `${layoutsPath}/RootLayout.tsx`);
921
+ if (!(await fs.pathExists(sentinelPath))) {
922
+ throw new Error(
923
+ `arch 오버레이 누락: vite-app + ${arch.name} 의 sentinel 파일(${layoutsPath}/RootLayout.tsx) 이 ${targetDir} 에 없습니다.`,
924
+ );
925
+ }
926
+
927
+ // 워크스페이스 placeholder 치환 — `ui-app-name` → `ui-{appName}`, `app-name` → `{appName}`.
928
+ await replaceInAllFiles(targetDir, 'ui-app-name', `ui-${appName}`);
929
+ await replaceInAllFiles(targetDir, 'app-name', appName);
930
+
931
+ // package.json — name + dep sort
932
+ const pkgPath = path.join(targetDir, 'package.json');
933
+ const pkg = await fs.readJson(pkgPath);
934
+ pkg.name = appName;
935
+ if (pkg.dependencies) pkg.dependencies = sortObjectKeys(pkg.dependencies);
936
+ if (pkg.devDependencies) pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
937
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
938
+
939
+ // vite.config.ts 의 server.port 를 사용자 지정 port 로 patch.
940
+ // generateMonorepo 가 받은 port 가 next 의 --port 와 같은 의미로 흐른다.
941
+ const viteCfgPath = path.join(targetDir, 'vite.config.ts');
942
+ if (await fs.pathExists(viteCfgPath)) {
943
+ let viteCfg = await fs.readFile(viteCfgPath, 'utf-8');
944
+ viteCfg = viteCfg.replace(/port:\s*\d+/, `port: ${port}`);
945
+ await fs.writeFile(viteCfgPath, viteCfg);
946
+ }
947
+
948
+ // ui-{appName} 패키지 생성 — generateApp 과 동일 패턴 (ui-app-template 카피 후 placeholder 치환).
949
+ const monorepoRoot = path.resolve(targetDir, '..', '..');
950
+ const uiPkgDir = path.join(monorepoRoot, 'packages', 'ui', 'ui-apps', `ui-${appName}`);
951
+ if (!(await fs.pathExists(uiPkgDir))) {
952
+ await fs.copy(path.join(TEMPLATES_DIR, 'ui-app-template'), uiPkgDir);
953
+ await replaceInAllFiles(uiPkgDir, 'ui-app-name', `ui-${appName}`);
954
+ await replaceInAllFiles(uiPkgDir, 'app-name', appName);
955
+ }
956
+
957
+ // cssFramework 변종 — vite app 디렉토리 + ui-app 패키지 양쪽.
958
+ // 플러그인 없음 (vite 는 아직 플러그인 시스템 없음 — v0.87 스코프 밖).
959
+ await applyCssFrameworkVariant(targetDir, css, { isMonorepo: true, plugins: [], arch });
960
+ if (await fs.pathExists(uiPkgDir)) {
961
+ await applyCssFrameworkVariant(uiPkgDir, css, { isMonorepo: true, plugins: [], arch, isUiPackage: true });
962
+ }
963
+ }
964
+
904
965
  /**
905
966
  * 베이스 템플릿 카피 직후 `_arch/` 잔여 정리.
906
967
  *
@@ -325,6 +325,47 @@ export const TEMPLATE_MANIFEST = {
325
325
  "tsconfig.json"
326
326
  ]
327
327
  },
328
+ "vite-app": {
329
+ "base": [
330
+ "eslint.config.js",
331
+ "gitignore",
332
+ "index.html",
333
+ "package.json",
334
+ "src/App.tsx",
335
+ "src/Home.tsx",
336
+ "src/main.tsx",
337
+ "tsconfig.json",
338
+ "tsconfig.node.json",
339
+ "vite.config.ts",
340
+ "vitest.config.ts",
341
+ "vitest.setup.ts"
342
+ ],
343
+ "arches": {
344
+ "flat": [
345
+ "src/App.tsx",
346
+ "src/components/layouts/RootLayout.tsx",
347
+ "src/components/providers/GlobalProvider/index.tsx",
348
+ "src/components/providers/index.tsx",
349
+ "src/components/providers/theme/ThemeProvider.tsx",
350
+ "src/lib/api/queryClient.ts",
351
+ "src/lib/hooks/useTheme.ts",
352
+ "src/lib/utils/utils.ts",
353
+ "src/main.tsx",
354
+ "tsconfig.app.json"
355
+ ],
356
+ "fsd": [
357
+ "src/App.tsx",
358
+ "src/app/layouts/RootLayout.tsx",
359
+ "src/app/providers/GlobalProvider/index.tsx",
360
+ "src/app/providers/theme/ThemeProvider.tsx",
361
+ "src/main.tsx",
362
+ "src/shared/api/queryClient.ts",
363
+ "src/shared/hooks/useTheme.ts",
364
+ "src/shared/lib/utils.ts",
365
+ "tsconfig.app.json"
366
+ ]
367
+ }
368
+ },
328
369
  "vite-standalone": {
329
370
  "base": [
330
371
  "CLAUDE.md",
package/src/mcp.mjs CHANGED
@@ -366,7 +366,7 @@ export async function startMcpServer() {
366
366
  "sh_ui_create_project",
367
367
  {
368
368
  description:
369
- "빈 폴더에 sh-ui 프로젝트 스캐폴드 — Next.js (standalone/monorepo) | Vite (standalone) | Flutter. " +
369
+ "빈 폴더에 sh-ui 프로젝트 스캐폴드 — Next.js (standalone/monorepo) | Vite (standalone/monorepo) | Flutter. " +
370
370
  `FSD 폴더 구조 + 토큰 + sh-ui.config.json 일괄 생성. 사용자가 '새 프로젝트' / '빈 폴더' / '스캐폴드부터' 류 요청을 하면 이 툴 사용 (Bash 로 npx ${cliName} create 직접 호출보다 우선). ` +
371
371
  "**단일 진입점** — theme/plugins/cssFramework/structure 모두 호출 시점에 정해서 한 번에 박는다. 호출 후 sh-ui.config.json/tokens.css 를 손으로 패치하지 말 것 (다음 재스캐폴드 시 유실). " +
372
372
  "산출물: theme 인자가 프리셋이면 sh-ui.config.json 의 theme.base 가 그 이름, base64 면 'custom'. paths.styles · paths.tokens 도 자동 박혀서 sh_ui_add_component 가 사후 패치 없이 동작.",
@@ -0,0 +1,13 @@
1
+ import { RootLayout } from '@/components/layouts/RootLayout';
2
+ import { GlobalProvider } from '@/components/providers';
3
+ import Home from './Home';
4
+
5
+ export default function App() {
6
+ return (
7
+ <GlobalProvider>
8
+ <RootLayout>
9
+ <Home />
10
+ </RootLayout>
11
+ </GlobalProvider>
12
+ );
13
+ }
@@ -0,0 +1,5 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export function RootLayout({ children }: { children: ReactNode }) {
4
+ return <div className="min-h-screen bg-background text-foreground">{children}</div>;
5
+ }
@@ -0,0 +1,13 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query';
2
+ import { type ReactNode, useState } from 'react';
3
+ import { createQueryClient } from '@/lib/api/queryClient';
4
+ import { ThemeProvider } from '../theme/ThemeProvider';
5
+
6
+ export function GlobalProvider({ children }: { children: ReactNode }) {
7
+ const [queryClient] = useState(() => createQueryClient());
8
+ return (
9
+ <ThemeProvider>
10
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
11
+ </ThemeProvider>
12
+ );
13
+ }
@@ -0,0 +1 @@
1
+ export { GlobalProvider } from './GlobalProvider';
@@ -0,0 +1,59 @@
1
+ import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
2
+
3
+ export type Theme = 'light' | 'dark' | 'system';
4
+
5
+ type ThemeContextValue = {
6
+ theme: Theme;
7
+ resolvedTheme: 'light' | 'dark';
8
+ setTheme: (theme: Theme) => void;
9
+ };
10
+
11
+ export const ThemeContext = createContext<ThemeContextValue | null>(null);
12
+
13
+ const STORAGE_KEY = 'theme';
14
+
15
+ function getSystemTheme(): 'light' | 'dark' {
16
+ if (typeof window === 'undefined') return 'light';
17
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
18
+ }
19
+
20
+ function resolveTheme(theme: Theme): 'light' | 'dark' {
21
+ return theme === 'system' ? getSystemTheme() : theme;
22
+ }
23
+
24
+ export function ThemeProvider({ children }: { children: ReactNode }) {
25
+ const [theme, setThemeState] = useState<Theme>(() => {
26
+ if (typeof window === 'undefined') return 'system';
27
+ return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
28
+ });
29
+
30
+ const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
31
+
32
+ useEffect(() => {
33
+ const resolved = resolveTheme(theme);
34
+ setResolvedTheme(resolved);
35
+ document.documentElement.classList.toggle('dark', resolved === 'dark');
36
+ }, [theme]);
37
+
38
+ useEffect(() => {
39
+ if (theme !== 'system') return;
40
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
41
+ const handler = () => {
42
+ const resolved = getSystemTheme();
43
+ setResolvedTheme(resolved);
44
+ document.documentElement.classList.toggle('dark', resolved === 'dark');
45
+ };
46
+ mq.addEventListener('change', handler);
47
+ return () => mq.removeEventListener('change', handler);
48
+ }, [theme]);
49
+
50
+ const setTheme = useCallback((next: Theme) => {
51
+ setThemeState(next);
52
+ if (next === 'system') localStorage.removeItem(STORAGE_KEY);
53
+ else localStorage.setItem(STORAGE_KEY, next);
54
+ }, []);
55
+
56
+ const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
57
+
58
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
59
+ }
@@ -0,0 +1,12 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+
3
+ export function createQueryClient() {
4
+ return new QueryClient({
5
+ defaultOptions: {
6
+ queries: {
7
+ staleTime: 60 * 1000,
8
+ refetchOnWindowFocus: false,
9
+ },
10
+ },
11
+ });
12
+ }
@@ -0,0 +1,8 @@
1
+ import { useContext } from 'react';
2
+ import { ThemeContext } from '@/components/providers/theme/ThemeProvider';
3
+
4
+ export function useTheme() {
5
+ const ctx = useContext(ThemeContext);
6
+ if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
7
+ return ctx;
8
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import App from './App';
4
+ import '@workspace/ui-app-name/globals.css';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
@@ -0,0 +1,19 @@
1
+ {
2
+ "extends": "@workspace/typescript-config/react-library.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "incremental": true,
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "jsx": "react-jsx",
9
+ "noEmit": true,
10
+ "baseUrl": ".",
11
+ "paths": {
12
+ "@/lib/*": ["./src/lib/*"],
13
+ "@/components/*": ["./src/components/*"],
14
+ "@workspace/ui-app-name/*": ["../../packages/ui/ui-apps/ui-app-name/src/*"],
15
+ "@workspace/ui-core/*": ["../../packages/ui/ui-core/src/*"]
16
+ }
17
+ },
18
+ "include": ["src"]
19
+ }
@@ -0,0 +1,13 @@
1
+ import { GlobalProvider } from '@/app/providers/GlobalProvider';
2
+ import { RootLayout } from '@/app/layouts/RootLayout';
3
+ import Home from './Home';
4
+
5
+ export default function App() {
6
+ return (
7
+ <GlobalProvider>
8
+ <RootLayout>
9
+ <Home />
10
+ </RootLayout>
11
+ </GlobalProvider>
12
+ );
13
+ }
@@ -0,0 +1,5 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export function RootLayout({ children }: { children: ReactNode }) {
4
+ return <div className="min-h-screen bg-background text-foreground">{children}</div>;
5
+ }
@@ -0,0 +1,13 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query';
2
+ import { type ReactNode, useState } from 'react';
3
+ import { createQueryClient } from '@/shared/api/queryClient';
4
+ import { ThemeProvider } from '../theme/ThemeProvider';
5
+
6
+ export function GlobalProvider({ children }: { children: ReactNode }) {
7
+ const [queryClient] = useState(() => createQueryClient());
8
+ return (
9
+ <ThemeProvider>
10
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
11
+ </ThemeProvider>
12
+ );
13
+ }
@@ -0,0 +1,59 @@
1
+ import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
2
+
3
+ export type Theme = 'light' | 'dark' | 'system';
4
+
5
+ type ThemeContextValue = {
6
+ theme: Theme;
7
+ resolvedTheme: 'light' | 'dark';
8
+ setTheme: (theme: Theme) => void;
9
+ };
10
+
11
+ export const ThemeContext = createContext<ThemeContextValue | null>(null);
12
+
13
+ const STORAGE_KEY = 'theme';
14
+
15
+ function getSystemTheme(): 'light' | 'dark' {
16
+ if (typeof window === 'undefined') return 'light';
17
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
18
+ }
19
+
20
+ function resolveTheme(theme: Theme): 'light' | 'dark' {
21
+ return theme === 'system' ? getSystemTheme() : theme;
22
+ }
23
+
24
+ export function ThemeProvider({ children }: { children: ReactNode }) {
25
+ const [theme, setThemeState] = useState<Theme>(() => {
26
+ if (typeof window === 'undefined') return 'system';
27
+ return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
28
+ });
29
+
30
+ const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
31
+
32
+ useEffect(() => {
33
+ const resolved = resolveTheme(theme);
34
+ setResolvedTheme(resolved);
35
+ document.documentElement.classList.toggle('dark', resolved === 'dark');
36
+ }, [theme]);
37
+
38
+ useEffect(() => {
39
+ if (theme !== 'system') return;
40
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
41
+ const handler = () => {
42
+ const resolved = getSystemTheme();
43
+ setResolvedTheme(resolved);
44
+ document.documentElement.classList.toggle('dark', resolved === 'dark');
45
+ };
46
+ mq.addEventListener('change', handler);
47
+ return () => mq.removeEventListener('change', handler);
48
+ }, [theme]);
49
+
50
+ const setTheme = useCallback((next: Theme) => {
51
+ setThemeState(next);
52
+ if (next === 'system') localStorage.removeItem(STORAGE_KEY);
53
+ else localStorage.setItem(STORAGE_KEY, next);
54
+ }, []);
55
+
56
+ const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
57
+
58
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
59
+ }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import App from './App';
4
+ import '@workspace/ui-app-name/globals.css';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
@@ -0,0 +1,12 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+
3
+ export function createQueryClient() {
4
+ return new QueryClient({
5
+ defaultOptions: {
6
+ queries: {
7
+ staleTime: 60 * 1000,
8
+ refetchOnWindowFocus: false,
9
+ },
10
+ },
11
+ });
12
+ }
@@ -0,0 +1,8 @@
1
+ import { useContext } from 'react';
2
+ import { ThemeContext } from '@/app/providers/theme/ThemeProvider';
3
+
4
+ export function useTheme() {
5
+ const ctx = useContext(ThemeContext);
6
+ if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
7
+ return ctx;
8
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "@workspace/typescript-config/react-library.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "incremental": true,
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "jsx": "react-jsx",
9
+ "noEmit": true,
10
+ "baseUrl": ".",
11
+ "paths": {
12
+ "@/*": ["./src/*"],
13
+ "@workspace/ui-app-name/*": ["../../packages/ui/ui-apps/ui-app-name/src/*"],
14
+ "@workspace/ui-core/*": ["../../packages/ui/ui-core/src/*"]
15
+ }
16
+ },
17
+ "include": ["src"]
18
+ }
@@ -0,0 +1,3 @@
1
+ import { config } from "@workspace/eslint-config/react-internal"
2
+
3
+ export default config
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ dist/
3
+ .DS_Store
4
+ *.log
5
+ .env.local
6
+ .env.*.local
7
+ .vite/
8
+ coverage/
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="ko" suppressHydrationWarning>
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>sh-ui app</title>
8
+ <script>
9
+ // FOUC 차단 — ThemeProvider mount 전에 첫 paint 에 dark class 박기.
10
+ // matrix: 'dark' → .dark, 'light' → (none), 'system'/unset → system pref.
11
+ try {
12
+ var t = localStorage.getItem('theme');
13
+ var d = t === 'dark' || ((!t || t === 'system') && matchMedia('(prefers-color-scheme:dark)').matches);
14
+ if (d) document.documentElement.classList.add('dark');
15
+ } catch (e) {}
16
+ </script>
17
+ </head>
18
+ <body>
19
+ <div id="root"></div>
20
+ <script type="module" src="/src/main.tsx"></script>
21
+ </body>
22
+ </html>
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "app-name",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "private": true,
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview",
10
+ "lint": "eslint .",
11
+ "lint:fix": "eslint . --fix",
12
+ "typecheck": "tsc -b --noEmit",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest"
15
+ },
16
+ "dependencies": {
17
+ "@tanstack/react-query": "^5.90.21",
18
+ "@workspace/ui-app-name": "workspace:*",
19
+ "@workspace/ui-core": "workspace:*",
20
+ "clsx": "^2.1.1",
21
+ "lucide-react": "^0.563.0",
22
+ "react": "^19.2.4",
23
+ "react-dom": "^19.2.4",
24
+ "sonner": "^2.0.7",
25
+ "tailwind-merge": "^3.5.0",
26
+ "zod": "^4.3.6"
27
+ },
28
+ "devDependencies": {
29
+ "@tailwindcss/vite": "^4.1.18",
30
+ "@tanstack/react-query-devtools": "^5.91.3",
31
+ "@testing-library/jest-dom": "^6.9.1",
32
+ "@testing-library/react": "^16",
33
+ "@testing-library/user-event": "^14",
34
+ "@types/node": "^25.1.0",
35
+ "@types/react": "^19.2.10",
36
+ "@types/react-dom": "^19.2.3",
37
+ "@vitejs/plugin-react": "^5.0.0",
38
+ "@workspace/eslint-config": "workspace:^",
39
+ "@workspace/typescript-config": "workspace:*",
40
+ "eslint": "^9.39.2",
41
+ "jsdom": "^29.0.0",
42
+ "tailwindcss": "^4.1.18",
43
+ "typescript": "^5.9.3",
44
+ "vite": "^5.4.0",
45
+ "vite-tsconfig-paths": "^5.1.4",
46
+ "vitest": "^4.1.0"
47
+ }
48
+ }
@@ -0,0 +1,5 @@
1
+ import Home from './Home';
2
+
3
+ export default function App() {
4
+ return <Home />;
5
+ }
@@ -0,0 +1,7 @@
1
+ export default function Home() {
2
+ return (
3
+ <main className="flex min-h-screen flex-col items-center justify-center">
4
+ <h1 className="text-4xl font-bold">Hello World</h1>
5
+ </main>
6
+ );
7
+ }
@@ -0,0 +1,9 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import App from './App';
4
+
5
+ createRoot(document.getElementById('root')!).render(
6
+ <StrictMode>
7
+ <App />
8
+ </StrictMode>,
9
+ );
@@ -0,0 +1,7 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "allowSyntheticDefaultImports": true,
8
+ "strict": true,
9
+ "types": ["node"]
10
+ },
11
+ "include": ["vite.config.ts", "vitest.config.ts", "vitest.setup.ts"]
12
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import tailwindcss from '@tailwindcss/vite';
4
+ import tsconfigPaths from 'vite-tsconfig-paths';
5
+
6
+ export default defineConfig({
7
+ plugins: [react(), tailwindcss(), tsconfigPaths()],
8
+ server: {
9
+ port: 3000,
10
+ },
11
+ });
@@ -0,0 +1,13 @@
1
+ import { defineConfig, mergeConfig } from 'vitest/config';
2
+ import viteConfig from './vite.config';
3
+
4
+ export default mergeConfig(
5
+ viteConfig,
6
+ defineConfig({
7
+ test: {
8
+ environment: 'jsdom',
9
+ globals: true,
10
+ setupFiles: ['./vitest.setup.ts'],
11
+ },
12
+ }),
13
+ );
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom/vitest';