sh-ui-cli 0.96.2 → 0.97.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 (34) hide show
  1. package/data/changelog/versions.json +26 -0
  2. package/package.json +1 -1
  3. package/src/api.d.ts +0 -2
  4. package/src/create/cli-args.js +1 -1
  5. package/src/create/describeTemplate.js +1 -20
  6. package/src/create/generator.js +20 -156
  7. package/src/create/index.mjs +1 -3
  8. package/src/create/templateManifest.js +0 -12
  9. package/src/mcp.mjs +1 -34
  10. package/templates/vite-app/_arch/flat/src/components/providers/theme/ThemeProvider.tsx +29 -23
  11. package/templates/vite-app/_arch/flat/tsconfig.app.json +1 -0
  12. package/templates/vite-app/_arch/fsd/src/app/providers/theme/ThemeProvider.tsx +29 -23
  13. package/templates/vite-app/_arch/fsd/tsconfig.app.json +1 -0
  14. package/templates/vite-app/gitignore +1 -0
  15. package/templates/vite-app/package.json +1 -1
  16. package/templates/vite-app/tsconfig.node.json +2 -0
  17. package/templates/vite-standalone/_arch/flat/index.html +2 -2
  18. package/templates/vite-standalone/_arch/flat/src/components/providers/theme/ThemeProvider.tsx +29 -23
  19. package/templates/vite-standalone/_arch/flat/src/lib/styles/globals.css +2 -2
  20. package/templates/vite-standalone/_arch/flat/tsconfig.app.json +1 -0
  21. package/templates/vite-standalone/_arch/fsd/index.html +2 -2
  22. package/templates/vite-standalone/_arch/fsd/src/app/providers/theme/ThemeProvider.tsx +29 -23
  23. package/templates/vite-standalone/_arch/fsd/src/shared/styles/globals.css +2 -2
  24. package/templates/vite-standalone/_arch/fsd/tsconfig.app.json +1 -0
  25. package/templates/vite-standalone/gitignore +1 -0
  26. package/templates/vite-standalone/package.json +1 -1
  27. package/templates/vite-standalone/tsconfig.node.json +2 -0
  28. package/templates/tauri-shell/Cargo.toml +0 -21
  29. package/templates/tauri-shell/README.md +0 -49
  30. package/templates/tauri-shell/build.rs +0 -3
  31. package/templates/tauri-shell/capabilities/default.json +0 -12
  32. package/templates/tauri-shell/src/lib.rs +0 -8
  33. package/templates/tauri-shell/src/main.rs +0 -6
  34. package/templates/tauri-shell/tauri.conf.json +0 -29
@@ -2,6 +2,32 @@
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.97.0",
7
+ "date": "2026-05-16",
8
+ "title": "Tauri 통합 제거 — 스캐폴더에서 src-tauri/ shell 및 --tauri 옵션 삭제 (breaking)",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**`--tauri` / MCP `tauri` 옵션 제거 (breaking)** — `sh-ui-cli create` / `add-app` 의 `--tauri` 플래그, `sh_ui_create_project` · `sh_ui_add_app` · `sh_ui_describe_template` 의 `tauri` 인자, `DescribeTemplateOptions.tauri` 타입을 모두 삭제. 이제 `--tauri` 는 '알 수 없는 플래그' 에러로 거부된다.",
12
+ "**`tauri-shell` 템플릿 + `emitTauri`/`patchViteForTauri` 삭제** — `src-tauri/` (Cargo.toml·tauri.conf.json·Rust 소스) 를 더 이상 emit 하지 않는다. `@tauri-apps/*` 의존성·`tauri:dev`/`tauri:build` scripts·`src-tauri/target/` gitignore 주입도 제거. Tauri 가 필요하면 공식 `@tauri-apps/cli init` 을 직접 쓰면 된다 (vite 의 `dist/` 표준 산출물과 그대로 호환).",
13
+ "**docs `/create` UI 정리** — Tauri 데스크탑 셸 토글 제거, `/api/template-content` 의 src-tauri resolve 분기 삭제. vite 의 mobile/PWA 친화 디폴트(`viewport-fit=cover`·`theme-color`·webview reset)는 그대로 유지 — Tauri 전용이 아니라 일반 모바일 하드닝이라 보존.",
14
+ "**smoke 정리** — tauri 전용 시나리오(V7a–f, V12–14) 삭제, all-options 가드(V20a)는 i18n+sentry 조합으로 축소 유지. 마이그레이션: `--tauri` 쓰던 사용자는 플래그를 빼고 생성 후 `@tauri-apps/cli init` 으로 desktop shell 을 별도 추가."
15
+ ],
16
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.97.0"
17
+ },
18
+ {
19
+ "version": "0.96.3",
20
+ "date": "2026-05-16",
21
+ "title": "vite 스캐폴드 fix — vite 7 전환 + turbo outputs/env + tsconfig 누수 + ThemeProvider",
22
+ "type": "patch",
23
+ "highlights": [
24
+ "**vite `^5` → `^7` (+ `vite-plugin-static-copy` `^3.2.0`)** — vitest 4 와 vite 5 의 메이저 불일치로 `pnpm test` 가 테스트 0개에서도 startup 에서 죽던 문제 해소. i18n 이 끌어오는 static-copy peer 도 vite 7 라인으로 동반 상향.",
25
+ "**monorepo `turbo.json` 플랫폼 인지** — vite 앱은 `build.outputs` 가 `dist/**`, `globalEnv` 의 `API_URL` → `VITE_API_URL`. 그동안 `.next/**` 잔재로 turbo build 캐시가 전혀 안 잡히던 것 교정 (이제 FULL TURBO).",
26
+ "**`observability=sentry` + monorepo** — `MODE`·`SENTRY_ORG`·`SENTRY_PROJECT`·`SENTRY_AUTH_TOKEN` 를 turbo `globalEnv` 에 자동 선언해 깨끗한 scaffold 가 `turbo/no-undeclared-env-vars` 경고 없이 lint clean.",
27
+ "**tsconfig 누수 + ThemeProvider** — `tsc -b` 가 `vite.config.js`/`*.tsbuildinfo` 를 소스 옆에 흘리던 것을 `noEmit` + `node_modules/.tmp` 로 차단하고 `*.tsbuildinfo` gitignore. ThemeProvider 는 `useSyncExternalStore` 로 재작성해 React 19 set-state-in-effect 경고 제거."
28
+ ],
29
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.96.3"
30
+ },
5
31
  {
6
32
  "version": "0.96.2",
7
33
  "date": "2026-05-15",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.96.2",
3
+ "version": "0.97.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
@@ -139,8 +139,6 @@ export interface DescribeTemplateOptions {
139
139
  projectName?: string;
140
140
  /** monorepo 첫 앱 이름. 기본 'web'. */
141
141
  appName?: string;
142
- /** platform=vite + structure=standalone 일 때 Tauri 2.x 셸(`src-tauri/`) 동시 emit. */
143
- tauri?: boolean;
144
142
  /** vite 전용 — react-i18next opt-in. v0.92.0+ */
145
143
  i18n?: 'none' | 'react-i18next';
146
144
  /** i18n 활성화 시 생성할 locale 코드 (comma-separated). 기본 'ko,en' */
@@ -15,7 +15,7 @@ const VALID_PLUGINS = allPlugins.map((p) => p.name);
15
15
  const VALID_ARCHES = allArchitectures.map((a) => a.name);
16
16
 
17
17
  const VALUE_FLAGS = ['platform', 'structure', 'plugins', 'theme', 'app', 'css', 'arch', 'port', 'i18n', 'locales', 'observability'];
18
- const BOOL_FLAGS = ['yes', 'help', 'dry-run', 'tauri'];
18
+ const BOOL_FLAGS = ['yes', 'help', 'dry-run'];
19
19
 
20
20
  const SUBCOMMANDS = ['add-app', 'add-component'];
21
21
 
@@ -35,7 +35,6 @@ import { CSS_FRAMEWORK_DEFAULT } from '../constants.js';
35
35
  * @property {'tailwind'|'plain'|'css-modules'} [cssFramework]
36
36
  * @property {string} [projectName]
37
37
  * @property {string} [appName] monorepo 첫 앱 이름. 기본 'web'
38
- * @property {boolean} [tauri] platform=vite (standalone/monorepo 둘 다) 일 때 Tauri 2.x 셸 같이 emit
39
38
  * @property {'none'|'react-i18next'} [i18n] vite 전용 — react-i18next 셋업
40
39
  * @property {string} [locales] i18n 활성화 시 생성할 locale 코드 (comma-separated, default 'ko,en')
41
40
  * @property {'none'|'sentry'} [observability] vite 전용 — Sentry 셋업 (v0.93.0+)
@@ -66,7 +65,6 @@ export function describeTemplate(opts = {}) {
66
65
  plugins: pluginNames = [],
67
66
  cssFramework = CSS_FRAMEWORK_DEFAULT,
68
67
  appName: rawAppName = 'web',
69
- tauri = false,
70
68
  i18n = 'none',
71
69
  locales = 'ko,en',
72
70
  observability = 'none',
@@ -97,14 +95,6 @@ export function describeTemplate(opts = {}) {
97
95
  makeGroup('base', '베이스 (vite-standalone)', baseFiles),
98
96
  makeGroup('arch', `Arch (${safeArchName})`, archFiles),
99
97
  ];
100
- if (tauri) {
101
- const tauriTpl = TEMPLATE_MANIFEST['tauri-shell'];
102
- if (!tauriTpl) {
103
- throw new Error("Template manifest missing entry for 'tauri-shell'.");
104
- }
105
- const tauriFiles = tauriTpl.base.map((p) => `src-tauri/${p}`);
106
- groups.push(makeGroup('tauri', 'Tauri 셸 (src-tauri/)', tauriFiles));
107
- }
108
98
  if (i18n === 'react-i18next') {
109
99
  const isFsd = safeArchName === 'fsd';
110
100
  const i18nBase = isFsd ? 'src/shared/i18n' : 'src/lib/i18n';
@@ -170,15 +160,6 @@ export function describeTemplate(opts = {}) {
170
160
  ),
171
161
  ));
172
162
 
173
- if (tauri) {
174
- const tauriTpl = TEMPLATE_MANIFEST['tauri-shell'];
175
- if (!tauriTpl) {
176
- throw new Error("Template manifest missing entry for 'tauri-shell'.");
177
- }
178
- const tauriFiles = tauriTpl.base.map((p) => `apps/${appName}/src-tauri/${p}`);
179
- groups.push(makeGroup('tauri', `Tauri 셸 (apps/${appName}/src-tauri/)`, tauriFiles));
180
- }
181
-
182
163
  if (i18n === 'react-i18next') {
183
164
  const isFsd = safeArchName === 'fsd';
184
165
  const i18nBase = isFsd ? 'src/shared/i18n' : 'src/lib/i18n';
@@ -426,7 +407,7 @@ function finalize(groups) {
426
407
  * 현재 규칙:
427
408
  * - basename 이 정확히 'gitignore' 인 경로 → '.gitignore' 로 prefix dot 추가.
428
409
  * (npm publish 가 .gitignore 를 strip 하므로 템플릿엔 점 없이 두고 emit 후 dot-prefix.)
429
- * - 이미 '.gitignore' 인 경로 (예: tauri-shell 의 src-tauri/.gitignore) 는 그대로.
410
+ * - 이미 '.gitignore' 인 경로는 그대로.
430
411
  *
431
412
  * 미래에 다른 fs-level rename 이 추가되면 여기에 같이 등록 (describeTemplate 의
432
413
  * file-plan ↔ create_project 의 실제 emit 1:1 정합성 유지).
@@ -213,17 +213,6 @@ export async function createProject(options = {}) {
213
213
  ],
214
214
  });
215
215
 
216
- // tauri 옵션은 platform=vite + structure=standalone 일 때만 의미. 다른 조합은 명시적 에러.
217
- // (MCP 진입점은 mcp.mjs 가 이미 동일 가드 — CLI 직접 호출에도 동일 안전망.)
218
- if (options.tauri) {
219
- if (platform !== 'vite') {
220
- throw new Error(
221
- `tauri: true 는 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). ` +
222
- `--tauri 옵션 제거 또는 --platform vite 사용.`,
223
- );
224
- }
225
- }
226
-
227
216
  // i18n 옵션도 vite preset 전용. v0.92.0+.
228
217
  if (options.i18n && options.i18n !== 'none' && platform !== 'vite') {
229
218
  throw new Error(
@@ -359,7 +348,6 @@ export async function createProject(options = {}) {
359
348
 
360
349
  if (projectType === 'standalone') {
361
350
  await generateViteStandalone(targetDir, projectName, theme, cssFramework, arch, themeBase, {
362
- tauri: !!options.tauri,
363
351
  i18n: options.i18n ?? 'none',
364
352
  locales: options.locales ?? 'ko,en',
365
353
  observability: options.observability ?? 'none',
@@ -368,7 +356,6 @@ export async function createProject(options = {}) {
368
356
  await generateMonorepo(targetDir, projectName, [], {
369
357
  yes: options.yes, theme, css: cssFramework, arch, themeBase,
370
358
  platform: 'vite',
371
- tauri: options.tauri,
372
359
  i18n: options.i18n ?? 'none',
373
360
  locales: options.locales ?? 'ko,en',
374
361
  observability: options.observability ?? 'none',
@@ -392,14 +379,6 @@ export async function createProject(options = {}) {
392
379
  console.log(`\n cd ${projectName}`);
393
380
  console.log(' pnpm install');
394
381
  console.log(' pnpm dev\n');
395
- if (options.tauri) {
396
- console.log('Tauri 데스크탑 셸:');
397
- if (projectType === 'monorepo') {
398
- console.log(' cd apps/web # 또는 첫 앱 이름');
399
- }
400
- console.log(' pnpm tauri dev # Rust 처음 빌드는 5~10분 — 캐시 후 5~10초');
401
- console.log(' (Rust 미설치 시 https://rustup.rs/ 참고)\n');
402
- }
403
382
  console.log('다음 단계 — 베이스 컴포넌트 추가 (예시):');
404
383
  console.log(' npx sh-ui-cli add button card input dialog\n');
405
384
  return;
@@ -534,11 +513,6 @@ export async function addApp(options = {}) {
534
513
  `add-app 는 platform=next 또는 vite 만 지원 (받은 값: ${platform}). flutter 는 standalone 만 지원.`,
535
514
  );
536
515
  }
537
- if (options.tauri && platform !== 'vite') {
538
- throw new Error(
539
- `tauri 는 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). --platform vite 사용 또는 tauri 옵션 제거.`,
540
- );
541
- }
542
516
  if (options.i18n && options.i18n !== 'none' && platform !== 'vite') {
543
517
  throw new Error(
544
518
  `i18n='${options.i18n}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}).`,
@@ -600,7 +574,6 @@ export async function addApp(options = {}) {
600
574
 
601
575
  if (platform === 'vite') {
602
576
  await generateViteApp(appsDir, appName, port, arch, css, {
603
- tauri: !!options.tauri,
604
577
  i18n: options.i18n ?? 'none',
605
578
  locales: options.locales ?? 'ko,en',
606
579
  observability: options.observability ?? 'none',
@@ -620,13 +593,6 @@ export async function addApp(options = {}) {
620
593
  console.log(`\n✅ apps/${appName} 이 추가되었습니다!`);
621
594
  console.log('\n pnpm install');
622
595
  console.log(` pnpm --filter ${appName} dev\n`);
623
-
624
- if (platform === 'vite' && options.tauri) {
625
- console.log('Tauri 데스크탑 셸:');
626
- console.log(` cd apps/${appName}`);
627
- console.log(' pnpm tauri dev # Rust 처음 빌드는 5~10분 — 캐시 후 5~10초');
628
- console.log(' (Rust 미설치 시 https://rustup.rs/ 참고)\n');
629
- }
630
596
  }
631
597
 
632
598
  /**
@@ -867,7 +833,7 @@ async function generateStandalone(targetDir, projectName, plugins, theme, css, a
867
833
  await patchShUiConfig(path.join(targetDir, 'sh-ui.config.json'), css, themeBase);
868
834
  }
869
835
 
870
- async function generateViteStandalone(targetDir, projectName, theme, css, arch, themeBase, { tauri = false, i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
836
+ async function generateViteStandalone(targetDir, projectName, theme, css, arch, themeBase, { i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
871
837
  // 베이스 (arch-neutral) + arch 오버레이 — generateStandalone 과 같은 패턴.
872
838
  await fs.copy(path.join(TEMPLATES_DIR, 'vite-standalone'), targetDir, {
873
839
  filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
@@ -903,12 +869,6 @@ async function generateViteStandalone(targetDir, projectName, theme, css, arch,
903
869
  await injectCssTheme(targetDir, theme);
904
870
  await patchShUiConfig(path.join(targetDir, 'sh-ui.config.json'), css, themeBase);
905
871
 
906
- // Tauri 셸 emit (옵션) — vite SPA + native window. standalone 만 v1 지원.
907
- if (tauri) {
908
- await emitTauri(targetDir, projectName, { devPort: 5173 });
909
- await patchViteForTauri(targetDir, { port: 5173 });
910
- }
911
-
912
872
  if (i18n === 'react-i18next') {
913
873
  const localesArr = parseLocales(locales);
914
874
  await emitI18n(targetDir, { arch, locales: localesArr });
@@ -919,101 +879,6 @@ async function generateViteStandalone(targetDir, projectName, theme, css, arch,
919
879
  }
920
880
  }
921
881
 
922
- /**
923
- * tauri-shell 템플릿을 `<targetDir>/src-tauri/` 로 복사하고 placeholder 치환.
924
- *
925
- * - `{{project_name}}` → projectName (kebab-case 유지, npm 패키지명과 동일)
926
- * - `{{tauri_crate_name}}` → snake_case 변환 (Rust crate name 규칙: 영숫자+언더스코어).
927
- * 하이픈/점/대문자가 들어 있으면 모두 안전한 형태로 정규화.
928
- *
929
- * generateViteStandalone 에서 tauri: true 인 경우 호출. monorepo+tauri 는 v0.89 후속.
930
- */
931
- async function emitTauri(targetDir, projectName, { devPort = 5173 } = {}) {
932
- const srcTauriDir = path.join(targetDir, 'src-tauri');
933
- await fs.copy(path.join(TEMPLATES_DIR, 'tauri-shell'), srcTauriDir);
934
-
935
- // crate name: snake_case 강제 — Rust 식별자는 영숫자+'_' 만 허용
936
- const tauriCrateName = projectName
937
- .toLowerCase()
938
- .replace(/[^a-z0-9]+/g, '_')
939
- .replace(/^_+|_+$/g, '');
940
-
941
- await replaceInAllFiles(srcTauriDir, '{{tauri_crate_name}}', tauriCrateName);
942
- await replaceInAllFiles(srcTauriDir, '{{project_name}}', projectName);
943
- await replaceInAllFiles(srcTauriDir, '{{tauri_dev_url}}', `http://localhost:${devPort}`);
944
- }
945
-
946
- /**
947
- * vite 앱의 package.json + vite.config.ts 를 Tauri 친화적으로 패치.
948
- *
949
- * - package.json: `@tauri-apps/cli` (devDep), `@tauri-apps/api` (dep), `tauri`/`tauri:dev`/`tauri:build` scripts 추가
950
- * - vite.config.ts: Tauri 공식 권장값 추가 — `clearScreen: false`, `server.strictPort: true`,
951
- * `server.host: false`, `server.port: 5173`. 그래야 Tauri 가 dev server 를 안정적으로 wrap.
952
- *
953
- * NOTE: vite.config.ts 를 전부 재작성한다. 현재 base template 의 vite.config.ts 는 arch-neutral
954
- * 이라 안전. 후속 task 에서 arch-specific vite.config.ts overlay 가 생기면 이 자리에서 머지 전략
955
- * 필요 (현재는 단순 overwrite).
956
- */
957
- async function patchViteForTauri(targetDir, { port = 5173 } = {}) {
958
- const pkgPath = path.join(targetDir, 'package.json');
959
- const pkg = await fs.readJson(pkgPath);
960
-
961
- pkg.dependencies = pkg.dependencies ?? {};
962
- pkg.devDependencies = pkg.devDependencies ?? {};
963
- pkg.scripts = pkg.scripts ?? {};
964
-
965
- pkg.dependencies['@tauri-apps/api'] = '^2.0.0';
966
- pkg.dependencies['@tauri-apps/plugin-opener'] = '^2.0.0';
967
- pkg.devDependencies['@tauri-apps/cli'] = '^2.0.0';
968
-
969
- pkg.scripts.tauri = 'tauri';
970
- pkg.scripts['tauri:dev'] = 'tauri dev';
971
- pkg.scripts['tauri:build'] = 'tauri build';
972
-
973
- pkg.dependencies = sortObjectKeys(pkg.dependencies);
974
- pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
975
- await fs.writeJson(pkgPath, pkg, { spaces: 2 });
976
-
977
- // vite.config.ts 재작성 — Tauri 공식 권장 설정 추가.
978
- const viteCfgPath = path.join(targetDir, 'vite.config.ts');
979
- const viteCfg = `import { defineConfig } from 'vite';
980
- import react from '@vitejs/plugin-react';
981
- import tailwindcss from '@tailwindcss/vite';
982
- import tsconfigPaths from 'vite-tsconfig-paths';
983
-
984
- // Tauri 권장 설정 (https://v2.tauri.app/start/frontend/vite/)
985
- // - clearScreen: false — Rust 컴파일 에러가 터미널을 가리지 않게
986
- // - server.strictPort — Tauri 가 사용할 포트를 고정 (충돌 시 에러)
987
- // - server.host: false — Tauri dev 가 host network 안 열어도 됨
988
- export default defineConfig({
989
- plugins: [react(), tailwindcss(), tsconfigPaths()],
990
- clearScreen: false,
991
- server: {
992
- port: ${port},
993
- strictPort: true,
994
- host: false,
995
- },
996
- });
997
- `;
998
- await fs.writeFile(viteCfgPath, viteCfg);
999
-
1000
- // .gitignore 에 src-tauri/target 추가 — Rust 빌드 산출물 (수 GB 가능).
1001
- // 스캐폴드 단계에서는 파일명이 `gitignore` (점 없음); finalizeProject 가 나중에 `.gitignore` 로 rename.
1002
- // 양쪽 이름 모두 시도해서 호출 순서가 달라져도 안전하게 적용.
1003
- const gitignoreCandidates = ['.gitignore', 'gitignore'];
1004
- for (const name of gitignoreCandidates) {
1005
- const p = path.join(targetDir, name);
1006
- if (await fs.pathExists(p)) {
1007
- let ignore = await fs.readFile(p, 'utf-8');
1008
- if (!ignore.includes('src-tauri/target')) {
1009
- ignore += `\n# Tauri build artifacts\nsrc-tauri/target/\n`;
1010
- await fs.writeFile(p, ignore);
1011
- }
1012
- break;
1013
- }
1014
- }
1015
- }
1016
-
1017
882
  /**
1018
883
  * react-i18next 셋업을 emit. arch 에 따라 경로가 달라짐:
1019
884
  * - fsd: src/shared/i18n/* + src/app/providers/I18nProvider.tsx
@@ -1054,7 +919,6 @@ import { initReactI18next } from 'react-i18next';
1054
919
  // 클라이언트 측 lazy-load — 빌드 산출물에서 public/locales/{lng}/{ns}.json 경로로 fetch.
1055
920
  // 원본 locale 파일은 ${i18nDirRel}/locales/* 에 두고, vite-plugin-static-copy 가
1056
921
  // dev/build 양쪽에서 public/locales/* 로 자동 미러링한다 (vite.config.ts 참고).
1057
- // Tauri 빌드의 경우도 dist/locales 에 그대로 포함된다.
1058
922
 
1059
923
  i18n
1060
924
  .use(HttpBackend)
@@ -1144,7 +1008,7 @@ export function GlobalProvider({ children }: { children: ReactNode }) {
1144
1008
  pkg.devDependencies = pkg.devDependencies ?? {};
1145
1009
  // dev/build 양쪽에서 src/shared/i18n/locales/* (또는 src/lib/i18n/locales/*) 를
1146
1010
  // public/locales/* 로 자동 미러 → i18next-http-backend 의 /locales/{{lng}}/{{ns}}.json 이 동작.
1147
- pkg.devDependencies['vite-plugin-static-copy'] = '^2.2.0';
1011
+ pkg.devDependencies['vite-plugin-static-copy'] = '^3.2.0';
1148
1012
  pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
1149
1013
  await fs.writeJson(pkgPath, pkg, { spaces: 2 });
1150
1014
 
@@ -1532,7 +1396,7 @@ async function patchViteConfigForSentry(targetDir) {
1532
1396
  await fs.writeFile(viteCfgPath, cfg);
1533
1397
  }
1534
1398
 
1535
- async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase, platform = 'next', tauri = false, i18n = 'none', locales = 'ko,en', observability = 'none', appName: appNameOpt = null, port: portOpt = null } = {}) {
1399
+ async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase, platform = 'next', i18n = 'none', locales = 'ko,en', observability = 'none', appName: appNameOpt = null, port: portOpt = null } = {}) {
1536
1400
  await fs.copy(path.join(TEMPLATES_DIR, 'monorepo'), targetDir);
1537
1401
 
1538
1402
  // Update root package.json
@@ -1543,7 +1407,7 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
1543
1407
 
1544
1408
  // CLAUDE.md 의 platform 분기 placeholder 치환. AI 에이전트가 Next.js 가정으로
1545
1409
  // 잘못된 컨벤션을 적용하지 않도록 (v0.94.0+).
1546
- const platformAppDesc = describeAppPlatform(platform, { tauri });
1410
+ const platformAppDesc = describeAppPlatform(platform);
1547
1411
  await replaceInAllFiles(targetDir, '{{PLATFORM_APP_DESCRIPTION}}', platformAppDesc);
1548
1412
 
1549
1413
  // Update turbo.json
@@ -1554,6 +1418,18 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
1554
1418
  turbo.globalEnv.push(...plugin.turboEnvVars);
1555
1419
  }
1556
1420
  }
1421
+ // 템플릿 turbo.json 은 .next/** 기준이라 vite 앱에선 turbo 가 산출물을
1422
+ // 못 찾아 build 캐시가 전혀 안 잡힌다 — 플랫폼별 outputs 로 교정.
1423
+ if (platform === 'vite') {
1424
+ turbo.tasks.build.outputs = ['dist/**'];
1425
+ // vite 는 클라이언트 노출 env 가 VITE_ 접두사 관례.
1426
+ turbo.globalEnv = turbo.globalEnv.map((e) => (e === 'API_URL' ? 'VITE_API_URL' : e));
1427
+ // sentry observability 는 플러그인 turboEnvVars 훅을 안 타므로 직접 선언.
1428
+ if (observability === 'sentry') {
1429
+ turbo.globalEnv.push('MODE', 'SENTRY_ORG', 'SENTRY_PROJECT', 'SENTRY_AUTH_TOKEN');
1430
+ }
1431
+ }
1432
+ turbo.globalEnv = [...new Set(turbo.globalEnv)];
1557
1433
  await fs.writeJson(turboPath, turbo, { spaces: 2 });
1558
1434
 
1559
1435
  // Create first app — `appName` 인자가 주어지면 그대로, 아니면 yes 모드 default 'web' / 대화모드 prompt.
@@ -1572,7 +1448,7 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
1572
1448
 
1573
1449
  const appsDir = path.join(targetDir, 'apps', appName);
1574
1450
  if (platform === 'vite') {
1575
- await generateViteApp(appsDir, appName, port, arch, css, { tauri, i18n, locales, observability });
1451
+ await generateViteApp(appsDir, appName, port, arch, css, { i18n, locales, observability });
1576
1452
  } else {
1577
1453
  await generateApp(appsDir, appName, port, plugins, arch, css);
1578
1454
  }
@@ -1673,7 +1549,7 @@ async function generateApp(targetDir, appName, port, plugins, arch, css = 'tailw
1673
1549
  }
1674
1550
  }
1675
1551
 
1676
- async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind', { tauri = false, i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
1552
+ async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind', { i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
1677
1553
  // 베이스 (arch-neutral) + arch 오버레이 — generateApp 과 동일 패턴.
1678
1554
  await fs.copy(path.join(TEMPLATES_DIR, 'vite-app'), targetDir, {
1679
1555
  filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
@@ -1733,15 +1609,6 @@ async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind',
1733
1609
  await applyCssFrameworkVariant(uiPkgDir, css, { isMonorepo: true, plugins: [], arch, isUiPackage: true });
1734
1610
  }
1735
1611
 
1736
- // tauri 가 켜져 있으면 이 app 안에 src-tauri/ shell 을 떨어뜨린다 (v0.90.0+).
1737
- // standalone 과 달리 monorepo 에서는 app 단위로 별도 dev port (default 3000) 가 있고,
1738
- // tauri 의 devUrl 이 그 port 와 일치해야 한다. frontendDist 는 src-tauri 기준 `../dist`.
1739
- if (tauri) {
1740
- const devPort = Number(port) || 3000;
1741
- await emitTauri(targetDir, appName, { devPort });
1742
- await patchViteForTauri(targetDir, { port: devPort });
1743
- }
1744
-
1745
1612
  if (i18n === 'react-i18next') {
1746
1613
  const localesArr = parseLocales(locales);
1747
1614
  await emitI18n(targetDir, { arch, locales: localesArr });
@@ -2384,12 +2251,9 @@ async function finalizeProject(targetDir, { dryRun = false } = {}) {
2384
2251
  * CLAUDE.md 의 `{{PLATFORM_APP_DESCRIPTION}}` 치환용 문장 — AI 에이전트에게 어떤 플랫폼인지
2385
2252
  * 정확히 전달해서 잘못된 컨벤션 (예: vite 프로젝트에 App Router 가정) 적용 방지.
2386
2253
  */
2387
- function describeAppPlatform(platform, { tauri = false } = {}) {
2254
+ function describeAppPlatform(platform) {
2388
2255
  if (platform === 'vite') {
2389
- const tauriSuffix = tauri
2390
- ? ' Tauri 데스크탑 셸이 동봉되어 있어 `src-tauri/` 가 native 진입점이다.'
2391
- : '';
2392
- return `Vite SPA (React + TypeScript). 라우트 + 비즈니스 로직. RSC / App Router 없음 — 모든 코드가 클라이언트 사이드 실행이다.${tauriSuffix}`;
2256
+ return `Vite SPA (React + TypeScript). 라우트 + 비즈니스 로직. RSC / App Router 없음 — 모든 코드가 클라이언트 사이드 실행이다.`;
2393
2257
  }
2394
2258
  // 디폴트: Next.js. 향후 platform 추가 시 분기 늘릴 것.
2395
2259
  return 'Next.js 앱 (App Router + Server Components). 라우트 + 비즈니스 로직.';
@@ -18,7 +18,7 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
18
18
 
19
19
  사용법:
20
20
  sh-ui create [name] [options] [--observability <none|sentry>]
21
- sh-ui create add-app [name] [--port <n>] [--platform <next|vite>] [--plugins ..] [--theme ..] [--css ..] [--tauri] [--i18n <react-i18next|none>] [--locales ko,en] [--observability <none|sentry>]
21
+ sh-ui create add-app [name] [--port <n>] [--platform <next|vite>] [--plugins ..] [--theme ..] [--css ..] [--i18n <react-i18next|none>] [--locales ko,en] [--observability <none|sentry>]
22
22
  sh-ui create add-component <name> [--app <name>]
23
23
 
24
24
  옵션:
@@ -77,7 +77,6 @@ export async function runCreate(rest) {
77
77
  theme: flags.theme,
78
78
  css: flags.css,
79
79
  platform: flags.platform,
80
- tauri: flags.tauri,
81
80
  i18n: flags.i18n,
82
81
  locales: flags.locales,
83
82
  observability: flags.observability,
@@ -97,7 +96,6 @@ export async function runCreate(rest) {
97
96
  arch: flags.arch,
98
97
  theme: flags.theme,
99
98
  css: flags.css,
100
- tauri: flags.tauri,
101
99
  i18n: flags.i18n,
102
100
  locales: flags.locales,
103
101
  observability: flags.observability,
@@ -314,18 +314,6 @@ export const TEMPLATE_MANIFEST = {
314
314
  ]
315
315
  }
316
316
  },
317
- "tauri-shell": {
318
- "base": [
319
- ".gitignore",
320
- "Cargo.toml",
321
- "README.md",
322
- "build.rs",
323
- "capabilities/default.json",
324
- "src/lib.rs",
325
- "src/main.rs",
326
- "tauri.conf.json"
327
- ]
328
- },
329
317
  "ui-app-template": {
330
318
  "base": [
331
319
  "eslint.config.js",
package/src/mcp.mjs CHANGED
@@ -432,7 +432,6 @@ export async function startMcpServer() {
432
432
  {
433
433
  description:
434
434
  "빈 폴더에 sh-ui 프로젝트 스캐폴드 — Next.js (standalone/monorepo) | Vite (standalone/monorepo) | Flutter. " +
435
- "+ vite 인 경우 `tauri: true` 로 Tauri 2.x 데스크탑 셸 (`src-tauri/`) 까지 한 번에 emit (Rust toolchain 필요). " +
436
435
  `FSD 폴더 구조 + 토큰 + sh-ui.config.json 일괄 생성. 사용자가 '새 프로젝트' / '빈 폴더' / '스캐폴드부터' 류 요청을 하면 이 툴 사용 (Bash 로 npx ${cliName} create 직접 호출보다 우선). ` +
437
436
  "**단일 진입점** — theme/plugins/cssFramework/structure 모두 호출 시점에 정해서 한 번에 박는다. 호출 후 sh-ui.config.json/tokens.css 를 손으로 패치하지 말 것 (다음 재스캐폴드 시 유실). " +
438
437
  "산출물: theme 인자가 프리셋이면 sh-ui.config.json 의 theme.base 가 그 이름, base64 면 'custom'. paths.styles · paths.tokens 도 자동 박혀서 sh_ui_add_component 가 사후 패치 없이 동작.",
@@ -461,13 +460,6 @@ export async function startMcpServer() {
461
460
  .describe("부모 디렉토리. 기본 process.cwd()"),
462
461
  force: z.boolean().optional()
463
462
  .describe("기존 디렉토리 덮어쓰기. 기본 false (안전)"),
464
- tauri: z.boolean().optional()
465
- .describe(
466
- "Tauri 2.x 데스크탑 셸 (`src-tauri/`) 함께 emit. platform=vite (standalone/monorepo 둘 다) 일 때만 지원. " +
467
- "monorepo 의 경우 src-tauri/ 는 apps/{appName}/ 안에 emit, devUrl 은 해당 app 의 dev port 와 자동 동기화. " +
468
- "Rust toolchain (`cargo`/`rustc`) 가 시스템에 설치되어 있어야 첫 `pnpm tauri dev` 가 동작. " +
469
- "기본 false.",
470
- ),
471
463
  i18n: z.enum(I18N_LIBRARIES).optional()
472
464
  .describe(
473
465
  "i18n 라이브러리 — platform=vite 일 때만 의미. 'react-i18next' 로 설정 시 i18next + react-i18next + browser-languagedetector + http-backend 셋업 + " +
@@ -513,15 +505,6 @@ export async function startMcpServer() {
513
505
  } catch (e) {
514
506
  return { isError: true, content: [{ type: "text", text: e.message }] };
515
507
  }
516
- if (input.tauri && input.platform !== "vite") {
517
- return {
518
- isError: true,
519
- content: [{
520
- type: "text",
521
- text: "tauri: true 는 platform=vite 일 때만 지원합니다 (현재 platform=" + input.platform + ").",
522
- }],
523
- };
524
- }
525
508
  if (input.i18n && input.i18n !== "none" && input.platform !== "vite") {
526
509
  return {
527
510
  isError: true,
@@ -565,7 +548,6 @@ export async function startMcpServer() {
565
548
  arch: input.arch,
566
549
  theme: input.theme,
567
550
  css: input.cssFramework,
568
- tauri: input.tauri,
569
551
  i18n: input.i18n,
570
552
  locales: input.locales,
571
553
  observability: input.observability,
@@ -589,7 +571,7 @@ export async function startMcpServer() {
589
571
  "사용자가 '앱 추가' / 'monorepo 에 새 앱' / 'add admin app' 류 요청을 하면 이 툴 사용 (Bash 로 npx " + cliName + " add-app 직접 호출보다 우선). " +
590
572
  "v0.65+ 레이아웃 준수 — ui-{name} 은 tokens-only role, 컴포넌트는 sibling ui-core 가 SoT. " +
591
573
  "theme/css 는 새 ui-app 에만 적용 (다른 앱 영향 없음). monorepo 가 아니면 (pnpm-workspace.yaml 없음) 에러. " +
592
- "platform 미지정 시 기존 apps/* 스캔해 추론 (모든 앱이 같은 플랫폼이면 그것으로). vite + tauri:true 면 apps/{name}/src-tauri/ 도 함께 emit.",
574
+ "platform 미지정 시 기존 apps/* 스캔해 추론 (모든 앱이 같은 플랫폼이면 그것으로).",
593
575
  inputSchema: {
594
576
  name: z.string().min(1)
595
577
  .describe("앱 이름 — apps/{name}/ + packages/ui/ui-apps/ui-{name}/ 디렉토리명. 영숫자 + 하이픈."),
@@ -603,8 +585,6 @@ export async function startMcpServer() {
603
585
  .describe("CSS 프레임워크. 기본 plain. 새 앱의 컴포넌트 변종 결정 — 같은 모노레포 내 다른 앱과 다른 값 가능."),
604
586
  platform: z.enum(["next", "vite"]).optional()
605
587
  .describe("플랫폼 — next | vite. 미지정 시 기존 apps/* 의 deps 로 추론 (모든 앱이 같은 플랫폼이면 그것으로, 혼재면 명시 필요)."),
606
- tauri: z.boolean().optional()
607
- .describe("Tauri 2.x 데스크탑 셸 — platform=vite 일 때만 의미. apps/{name}/src-tauri/ 에 emit. 기본 false."),
608
588
  i18n: z.enum(I18N_LIBRARIES).optional()
609
589
  .describe(
610
590
  "i18n 라이브러리 — platform=vite 일 때만 의미. 'react-i18next' 로 설정 시 i18next + react-i18next + " +
@@ -629,15 +609,6 @@ export async function startMcpServer() {
629
609
  } catch (e) {
630
610
  return { isError: true, content: [{ type: "text", text: e.message }] };
631
611
  }
632
- if (input.tauri && input.platform === "next") {
633
- return {
634
- isError: true,
635
- content: [{
636
- type: "text",
637
- text: "tauri 는 platform=vite 일 때만 지원합니다. --platform vite 사용 또는 tauri 옵션 제거.",
638
- }],
639
- };
640
- }
641
612
  if (input.i18n && input.i18n !== "none" && input.platform && input.platform !== "vite") {
642
613
  return {
643
614
  isError: true,
@@ -664,7 +635,6 @@ export async function startMcpServer() {
664
635
  theme: input.theme,
665
636
  css: input.cssFramework,
666
637
  platform: input.platform,
667
- tauri: input.tauri,
668
638
  i18n: input.i18n,
669
639
  locales: input.locales,
670
640
  observability: input.observability,
@@ -1017,8 +987,6 @@ export async function startMcpServer() {
1017
987
  .describe("monorepo 첫 앱 이름. 기본 web"),
1018
988
  // vite 전용 — sh_ui_create_project 의 동일 옵션과 1:1 대응. describe ↔ create 가 같은
1019
989
  // file-plan 을 보장하려면 여기서 받아 describeTemplate 에 전달해야 한다 (v0.95.0+).
1020
- tauri: z.boolean().optional()
1021
- .describe("Tauri 2.x 데스크탑 셸 emit (platform=vite 전용). 기본 false"),
1022
990
  i18n: z.enum(I18N_LIBRARIES).optional()
1023
991
  .describe(`i18n 라이브러리 (platform=vite 전용). 옵션: ${I18N_LIBRARIES.join(', ')}. 기본 none`),
1024
992
  locales: z.string().optional()
@@ -1036,7 +1004,6 @@ export async function startMcpServer() {
1036
1004
  plugins: input.plugins,
1037
1005
  cssFramework: input.cssFramework,
1038
1006
  appName: input.appName,
1039
- tauri: input.tauri,
1040
1007
  i18n: input.i18n,
1041
1008
  locales: input.locales,
1042
1009
  observability: input.observability,
@@ -1,4 +1,12 @@
1
- import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ useSyncExternalStore,
8
+ type ReactNode,
9
+ } from 'react';
2
10
 
3
11
  export type Theme = 'light' | 'dark' | 'system';
4
12
 
@@ -12,13 +20,14 @@ export const ThemeContext = createContext<ThemeContextValue | null>(null);
12
20
 
13
21
  const STORAGE_KEY = 'theme';
14
22
 
15
- function getSystemTheme(): 'light' | 'dark' {
16
- if (typeof window === 'undefined') return 'light';
17
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
23
+ function subscribeSystemTheme(onChange: () => void): () => void {
24
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
25
+ mq.addEventListener('change', onChange);
26
+ return () => mq.removeEventListener('change', onChange);
18
27
  }
19
28
 
20
- function resolveTheme(theme: Theme): 'light' | 'dark' {
21
- return theme === 'system' ? getSystemTheme() : theme;
29
+ function getSystemTheme(): 'light' | 'dark' {
30
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
22
31
  }
23
32
 
24
33
  export function ThemeProvider({ children }: { children: ReactNode }) {
@@ -27,25 +36,19 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
27
36
  return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
28
37
  });
29
38
 
30
- const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
39
+ // 시스템 테마는 외부 스토어(matchMedia) useSyncExternalStore 로 구독해
40
+ // effect 안에서 setState 하지 않는다 (React 19 set-state-in-effect 회피).
41
+ const systemTheme = useSyncExternalStore(
42
+ subscribeSystemTheme,
43
+ getSystemTheme,
44
+ () => 'light' as const,
45
+ );
31
46
 
32
- useEffect(() => {
33
- const resolved = resolveTheme(theme);
34
- setResolvedTheme(resolved);
35
- document.documentElement.classList.toggle('dark', resolved === 'dark');
36
- }, [theme]);
47
+ const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemTheme : theme;
37
48
 
38
49
  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]);
50
+ document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
51
+ }, [resolvedTheme]);
49
52
 
50
53
  const setTheme = useCallback((next: Theme) => {
51
54
  setThemeState(next);
@@ -53,7 +56,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
53
56
  else localStorage.setItem(STORAGE_KEY, next);
54
57
  }, []);
55
58
 
56
- const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
59
+ const value = useMemo(
60
+ () => ({ theme, resolvedTheme, setTheme }),
61
+ [theme, resolvedTheme, setTheme],
62
+ );
57
63
 
58
64
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
59
65
  }
@@ -7,6 +7,7 @@
7
7
  "moduleResolution": "Bundler",
8
8
  "jsx": "react-jsx",
9
9
  "noEmit": true,
10
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
10
11
  "baseUrl": ".",
11
12
  "paths": {
12
13
  "@/lib/*": ["./src/lib/*"],
@@ -1,4 +1,12 @@
1
- import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ useSyncExternalStore,
8
+ type ReactNode,
9
+ } from 'react';
2
10
 
3
11
  export type Theme = 'light' | 'dark' | 'system';
4
12
 
@@ -12,13 +20,14 @@ export const ThemeContext = createContext<ThemeContextValue | null>(null);
12
20
 
13
21
  const STORAGE_KEY = 'theme';
14
22
 
15
- function getSystemTheme(): 'light' | 'dark' {
16
- if (typeof window === 'undefined') return 'light';
17
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
23
+ function subscribeSystemTheme(onChange: () => void): () => void {
24
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
25
+ mq.addEventListener('change', onChange);
26
+ return () => mq.removeEventListener('change', onChange);
18
27
  }
19
28
 
20
- function resolveTheme(theme: Theme): 'light' | 'dark' {
21
- return theme === 'system' ? getSystemTheme() : theme;
29
+ function getSystemTheme(): 'light' | 'dark' {
30
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
22
31
  }
23
32
 
24
33
  export function ThemeProvider({ children }: { children: ReactNode }) {
@@ -27,25 +36,19 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
27
36
  return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
28
37
  });
29
38
 
30
- const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
39
+ // 시스템 테마는 외부 스토어(matchMedia) useSyncExternalStore 로 구독해
40
+ // effect 안에서 setState 하지 않는다 (React 19 set-state-in-effect 회피).
41
+ const systemTheme = useSyncExternalStore(
42
+ subscribeSystemTheme,
43
+ getSystemTheme,
44
+ () => 'light' as const,
45
+ );
31
46
 
32
- useEffect(() => {
33
- const resolved = resolveTheme(theme);
34
- setResolvedTheme(resolved);
35
- document.documentElement.classList.toggle('dark', resolved === 'dark');
36
- }, [theme]);
47
+ const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemTheme : theme;
37
48
 
38
49
  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]);
50
+ document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
51
+ }, [resolvedTheme]);
49
52
 
50
53
  const setTheme = useCallback((next: Theme) => {
51
54
  setThemeState(next);
@@ -53,7 +56,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
53
56
  else localStorage.setItem(STORAGE_KEY, next);
54
57
  }, []);
55
58
 
56
- const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
59
+ const value = useMemo(
60
+ () => ({ theme, resolvedTheme, setTheme }),
61
+ [theme, resolvedTheme, setTheme],
62
+ );
57
63
 
58
64
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
59
65
  }
@@ -7,6 +7,7 @@
7
7
  "moduleResolution": "Bundler",
8
8
  "jsx": "react-jsx",
9
9
  "noEmit": true,
10
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
10
11
  "baseUrl": ".",
11
12
  "paths": {
12
13
  "@/*": ["./src/*"],
@@ -6,3 +6,4 @@ dist/
6
6
  .env.*.local
7
7
  .vite/
8
8
  coverage/
9
+ *.tsbuildinfo
@@ -41,7 +41,7 @@
41
41
  "jsdom": "^29.0.0",
42
42
  "tailwindcss": "^4.1.18",
43
43
  "typescript": "^5.9.3",
44
- "vite": "^5.4.0",
44
+ "vite": "^7.0.0",
45
45
  "vite-tsconfig-paths": "^5.1.4",
46
46
  "vitest": "^4.1.0"
47
47
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "composite": true,
4
+ "noEmit": true,
5
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
6
  "skipLibCheck": true,
5
7
  "module": "ESNext",
6
8
  "moduleResolution": "Bundler",
@@ -3,9 +3,9 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
- <!-- viewport-fit=cover: iOS safe-area inset / Android edge-to-edge / Tauri mobile 호환 -->
6
+ <!-- viewport-fit=cover: iOS safe-area inset / Android edge-to-edge -->
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
8
- <!-- theme-color: 모바일 브라우저 / PWA / Tauri WebView 의 status bar 톤. dark default 에 맞춰 어두운 톤. -->
8
+ <!-- theme-color: 모바일 브라우저 / PWA 의 status bar 톤. dark default 에 맞춰 어두운 톤. -->
9
9
  <meta name="theme-color" content="#0A0A0A" media="(prefers-color-scheme: dark)" />
10
10
  <meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
11
11
  <title>sh-ui app</title>
@@ -1,4 +1,12 @@
1
- import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ useSyncExternalStore,
8
+ type ReactNode,
9
+ } from 'react';
2
10
 
3
11
  export type Theme = 'light' | 'dark' | 'system';
4
12
 
@@ -12,13 +20,14 @@ export const ThemeContext = createContext<ThemeContextValue | null>(null);
12
20
 
13
21
  const STORAGE_KEY = 'theme';
14
22
 
15
- function getSystemTheme(): 'light' | 'dark' {
16
- if (typeof window === 'undefined') return 'light';
17
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
23
+ function subscribeSystemTheme(onChange: () => void): () => void {
24
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
25
+ mq.addEventListener('change', onChange);
26
+ return () => mq.removeEventListener('change', onChange);
18
27
  }
19
28
 
20
- function resolveTheme(theme: Theme): 'light' | 'dark' {
21
- return theme === 'system' ? getSystemTheme() : theme;
29
+ function getSystemTheme(): 'light' | 'dark' {
30
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
22
31
  }
23
32
 
24
33
  export function ThemeProvider({ children }: { children: ReactNode }) {
@@ -27,25 +36,19 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
27
36
  return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
28
37
  });
29
38
 
30
- const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
39
+ // 시스템 테마는 외부 스토어(matchMedia) useSyncExternalStore 로 구독해
40
+ // effect 안에서 setState 하지 않는다 (React 19 set-state-in-effect 회피).
41
+ const systemTheme = useSyncExternalStore(
42
+ subscribeSystemTheme,
43
+ getSystemTheme,
44
+ () => 'light' as const,
45
+ );
31
46
 
32
- useEffect(() => {
33
- const resolved = resolveTheme(theme);
34
- setResolvedTheme(resolved);
35
- document.documentElement.classList.toggle('dark', resolved === 'dark');
36
- }, [theme]);
47
+ const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemTheme : theme;
37
48
 
38
49
  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]);
50
+ document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
51
+ }, [resolvedTheme]);
49
52
 
50
53
  const setTheme = useCallback((next: Theme) => {
51
54
  setThemeState(next);
@@ -53,7 +56,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
53
56
  else localStorage.setItem(STORAGE_KEY, next);
54
57
  }, []);
55
58
 
56
- const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
59
+ const value = useMemo(
60
+ () => ({ theme, resolvedTheme, setTheme }),
61
+ [theme, resolvedTheme, setTheme],
62
+ );
57
63
 
58
64
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
59
65
  }
@@ -34,8 +34,8 @@
34
34
  --color-sidebar-bg: var(--sidebar-bg);
35
35
  }
36
36
 
37
- /* sh-ui:webview-base-start — 모바일 브라우저 / Tauri WebView / PWA 공통 기본값.
38
- * v0.88.1+: Tauri 통합 + iOS/Android 친화. 사용자가 텍스트 셀렉트 필요한 곳은 input/textarea/
37
+ /* sh-ui:webview-base-start — 모바일 브라우저 / PWA 공통 기본값.
38
+ * iOS/Android 친화. 사용자가 텍스트 셀렉트 필요한 곳은 input/textarea/
39
39
  * contenteditable 에 명시적으로 user-select: text 로 복원. 토큰이 아니라 reset 성 규칙이라
40
40
  * @theme inline 밖에 둠. */
41
41
  @layer base {
@@ -13,6 +13,7 @@
13
13
  "resolveJsonModule": true,
14
14
  "allowSyntheticDefaultImports": true,
15
15
  "noEmit": true,
16
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
16
17
  "baseUrl": ".",
17
18
  "paths": {
18
19
  "@/lib/*": ["./src/lib/*"],
@@ -3,9 +3,9 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
- <!-- viewport-fit=cover: iOS safe-area inset / Android edge-to-edge / Tauri mobile 호환 -->
6
+ <!-- viewport-fit=cover: iOS safe-area inset / Android edge-to-edge -->
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
8
- <!-- theme-color: 모바일 브라우저 / PWA / Tauri WebView 의 status bar 톤. dark default 에 맞춰 어두운 톤. -->
8
+ <!-- theme-color: 모바일 브라우저 / PWA 의 status bar 톤. dark default 에 맞춰 어두운 톤. -->
9
9
  <meta name="theme-color" content="#0A0A0A" media="(prefers-color-scheme: dark)" />
10
10
  <meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
11
11
  <title>sh-ui app</title>
@@ -1,4 +1,12 @@
1
- import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ useSyncExternalStore,
8
+ type ReactNode,
9
+ } from 'react';
2
10
 
3
11
  export type Theme = 'light' | 'dark' | 'system';
4
12
 
@@ -12,13 +20,14 @@ export const ThemeContext = createContext<ThemeContextValue | null>(null);
12
20
 
13
21
  const STORAGE_KEY = 'theme';
14
22
 
15
- function getSystemTheme(): 'light' | 'dark' {
16
- if (typeof window === 'undefined') return 'light';
17
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
23
+ function subscribeSystemTheme(onChange: () => void): () => void {
24
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
25
+ mq.addEventListener('change', onChange);
26
+ return () => mq.removeEventListener('change', onChange);
18
27
  }
19
28
 
20
- function resolveTheme(theme: Theme): 'light' | 'dark' {
21
- return theme === 'system' ? getSystemTheme() : theme;
29
+ function getSystemTheme(): 'light' | 'dark' {
30
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
22
31
  }
23
32
 
24
33
  export function ThemeProvider({ children }: { children: ReactNode }) {
@@ -27,25 +36,19 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
27
36
  return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
28
37
  });
29
38
 
30
- const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
39
+ // 시스템 테마는 외부 스토어(matchMedia) useSyncExternalStore 로 구독해
40
+ // effect 안에서 setState 하지 않는다 (React 19 set-state-in-effect 회피).
41
+ const systemTheme = useSyncExternalStore(
42
+ subscribeSystemTheme,
43
+ getSystemTheme,
44
+ () => 'light' as const,
45
+ );
31
46
 
32
- useEffect(() => {
33
- const resolved = resolveTheme(theme);
34
- setResolvedTheme(resolved);
35
- document.documentElement.classList.toggle('dark', resolved === 'dark');
36
- }, [theme]);
47
+ const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemTheme : theme;
37
48
 
38
49
  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]);
50
+ document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
51
+ }, [resolvedTheme]);
49
52
 
50
53
  const setTheme = useCallback((next: Theme) => {
51
54
  setThemeState(next);
@@ -53,7 +56,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
53
56
  else localStorage.setItem(STORAGE_KEY, next);
54
57
  }, []);
55
58
 
56
- const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
59
+ const value = useMemo(
60
+ () => ({ theme, resolvedTheme, setTheme }),
61
+ [theme, resolvedTheme, setTheme],
62
+ );
57
63
 
58
64
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
59
65
  }
@@ -34,8 +34,8 @@
34
34
  --color-sidebar-bg: var(--sidebar-bg);
35
35
  }
36
36
 
37
- /* sh-ui:webview-base-start — 모바일 브라우저 / Tauri WebView / PWA 공통 기본값.
38
- * v0.88.1+: Tauri 통합 + iOS/Android 친화. 사용자가 텍스트 셀렉트 필요한 곳은 input/textarea/
37
+ /* sh-ui:webview-base-start — 모바일 브라우저 / PWA 공통 기본값.
38
+ * iOS/Android 친화. 사용자가 텍스트 셀렉트 필요한 곳은 input/textarea/
39
39
  * contenteditable 에 명시적으로 user-select: text 로 복원. 토큰이 아니라 reset 성 규칙이라
40
40
  * @theme inline 밖에 둠. */
41
41
  @layer base {
@@ -13,6 +13,7 @@
13
13
  "resolveJsonModule": true,
14
14
  "allowSyntheticDefaultImports": true,
15
15
  "noEmit": true,
16
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
16
17
  "baseUrl": ".",
17
18
  "paths": {
18
19
  "@/*": ["./src/*"]
@@ -6,3 +6,4 @@ dist/
6
6
  .env.*.local
7
7
  .vite/
8
8
  coverage/
9
+ *.tsbuildinfo
@@ -53,7 +53,7 @@
53
53
  "tailwindcss": "^4.1.18",
54
54
  "typescript": "^5.9.3",
55
55
  "typescript-eslint": "^8.54.0",
56
- "vite": "^5.4.0",
56
+ "vite": "^7.0.0",
57
57
  "vite-tsconfig-paths": "^5.1.4",
58
58
  "vitest": "^4.1.0"
59
59
  },
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "composite": true,
4
+ "noEmit": true,
5
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
6
  "skipLibCheck": true,
5
7
  "module": "ESNext",
6
8
  "moduleResolution": "Bundler",
@@ -1,21 +0,0 @@
1
- [package]
2
- name = "{{tauri_crate_name}}"
3
- version = "0.1.0"
4
- description = "{{project_name}} desktop shell"
5
- authors = ["you"]
6
- edition = "2021"
7
-
8
- # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
9
-
10
- [lib]
11
- name = "{{tauri_crate_name}}_lib"
12
- crate-type = ["staticlib", "cdylib", "rlib"]
13
-
14
- [build-dependencies]
15
- tauri-build = { version = "2", features = [] }
16
-
17
- [dependencies]
18
- tauri = { version = "2", features = [] }
19
- tauri-plugin-opener = "2"
20
- serde = { version = "1", features = ["derive"] }
21
- serde_json = "1"
@@ -1,49 +0,0 @@
1
- # {{project_name}} — Tauri 데스크탑 셸
2
-
3
- 이 디렉토리는 Tauri 2.x 의 Rust 코드입니다. 부모 디렉토리의 vite SPA 가
4
- 프론트엔드, 이 디렉토리가 native 윈도우 셸.
5
-
6
- ## 첫 실행
7
-
8
- ```bash
9
- # 부모 디렉토리에서 (vite 앱 루트)
10
- pnpm install
11
- pnpm tauri dev # Rust 처음 빌드는 5~10분 — 캐시되면 이후 5~10초
12
- ```
13
-
14
- Rust toolchain (`cargo`, `rustc`) 이 시스템에 설치되어 있어야 합니다. 없으면
15
- https://rustup.rs/ 참고.
16
-
17
- ## 프로덕션 빌드 전 체크리스트
18
-
19
- 1. **Bundle identifier** — `tauri.conf.json` 의 `identifier: "app.{{tauri_crate_name}}.dev"` 를
20
- 실제 도메인 기반 unique ID 로 교체 (예: `com.yourcompany.{{tauri_crate_name}}`).
21
- 동일 ID 로 publish 된 다른 앱과 충돌 시 OS install 이 깨질 수 있음.
22
- 2. **Icons** — `tauri.conf.json` 의 `bundle.icon: []` 가 비어 있습니다. 프로덕션 빌드 시:
23
- - 1024x1024 PNG 준비 (square, 투명 배경 권장)
24
- - 부모 디렉토리에서 `pnpm tauri icon path/to/source.png` 실행 — `src-tauri/icons/` 에
25
- 플랫폼별 variants 자동 emit + `bundle.icon` 자동 채워짐
26
- 3. **Capabilities** — `capabilities/default.json` 은 최소 권한. fs / dialog / shell 등
27
- 확장 API 가 필요하면 https://v2.tauri.app/security/ 참고.
28
- 4. **Window 옵션** — `tauri.conf.json` 의 `app.windows[0]` 에 `decorations`, `transparent`,
29
- `alwaysOnTop` 등 추가 가능.
30
-
31
- ## Rust 코드 추가
32
-
33
- `src/lib.rs` 의 `invoke_handler![]` 안에 새 command 등록:
34
-
35
- ```rust
36
- #[tauri::command]
37
- fn my_command(name: &str) -> String {
38
- format!("Hello, {}!", name)
39
- }
40
-
41
- // run() 안의 .invoke_handler 에:
42
- .invoke_handler(tauri::generate_handler![my_command])
43
- ```
44
-
45
- 프론트엔드에서:
46
- ```ts
47
- import { invoke } from '@tauri-apps/api/core';
48
- const greeting = await invoke<string>('my_command', { name: 'World' });
49
- ```
@@ -1,3 +0,0 @@
1
- fn main() {
2
- tauri_build::build()
3
- }
@@ -1,12 +0,0 @@
1
- {
2
- "$schema": "../gen/schemas/desktop-schema.json",
3
- "identifier": "default",
4
- "description": "기본 capability — 핵심 Tauri API 만 허용. 추가 권한 (fs / dialog / shell 등) 은 https://v2.tauri.app/security/ 참고해서 명시적 추가.",
5
- "windows": [
6
- "main"
7
- ],
8
- "permissions": [
9
- "core:default",
10
- "opener:default"
11
- ]
12
- }
@@ -1,8 +0,0 @@
1
- #[cfg_attr(mobile, tauri::mobile_entry_point)]
2
- pub fn run() {
3
- tauri::Builder::default()
4
- .plugin(tauri_plugin_opener::init())
5
- .invoke_handler(tauri::generate_handler![])
6
- .run(tauri::generate_context!())
7
- .expect("error while running tauri application");
8
- }
@@ -1,6 +0,0 @@
1
- // Prevents additional console window on Windows in release.
2
- #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
3
-
4
- fn main() {
5
- {{tauri_crate_name}}_lib::run()
6
- }
@@ -1,29 +0,0 @@
1
- {
2
- "$schema": "https://schema.tauri.app/config/2",
3
- "productName": "{{project_name}}",
4
- "version": "0.1.0",
5
- "identifier": "app.{{tauri_crate_name}}.dev",
6
- "build": {
7
- "beforeDevCommand": "pnpm dev",
8
- "devUrl": "{{tauri_dev_url}}",
9
- "beforeBuildCommand": "pnpm build",
10
- "frontendDist": "../dist"
11
- },
12
- "app": {
13
- "windows": [
14
- {
15
- "title": "{{project_name}}",
16
- "width": 1200,
17
- "height": 800
18
- }
19
- ],
20
- "security": {
21
- "csp": null
22
- }
23
- },
24
- "bundle": {
25
- "active": true,
26
- "targets": "all",
27
- "icon": []
28
- }
29
- }