sh-ui-cli 0.75.0 → 0.77.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 (91) hide show
  1. package/bin/sh-ui.mjs +1 -1
  2. package/data/changelog/versions.json +39 -0
  3. package/data/registry/react/components/switch/index.tailwind.tsx +1 -1
  4. package/data/registry/react/components/switch/styles.css +6 -0
  5. package/data/registry/react/components/switch/styles.module.css +6 -0
  6. package/data/registry/react/tokens-used.json +3 -1
  7. package/package.json +3 -3
  8. package/src/create/architectures/index.js +2 -1
  9. package/src/create/architectures/mes.js +53 -0
  10. package/src/mcp-init.mjs +161 -20
  11. package/templates/monorepo/packages/eslint-config/mes.js +82 -0
  12. package/templates/monorepo/packages/eslint-config/package.json +2 -1
  13. package/templates/nextjs-app/_arch/mes/app/api/proxy/[...path]/route.ts +112 -0
  14. package/templates/nextjs-app/_arch/mes/app/layout.tsx +16 -0
  15. package/templates/nextjs-app/_arch/mes/app/sign-in/page.tsx +1 -0
  16. package/templates/nextjs-app/_arch/mes/eslint.config.js +10 -0
  17. package/templates/nextjs-app/_arch/mes/src/components/common/.gitkeep +0 -0
  18. package/templates/nextjs-app/_arch/mes/src/components/common/FallbackBoundary/index.tsx +89 -0
  19. package/templates/nextjs-app/_arch/mes/src/components/common/PrefetchBoundary/index.tsx +35 -0
  20. package/templates/nextjs-app/_arch/mes/src/components/layouts/RootLayout.tsx +13 -0
  21. package/templates/nextjs-app/_arch/mes/src/components/providers/GlobalProvider/index.tsx +23 -0
  22. package/templates/nextjs-app/_arch/mes/src/components/providers/index.tsx +1 -0
  23. package/templates/nextjs-app/_arch/mes/src/components/providers/tanstack/QueryClientProvider.tsx +14 -0
  24. package/templates/nextjs-app/_arch/mes/src/components/providers/tanstack/TanstackDevtoolsProvider.tsx +13 -0
  25. package/templates/nextjs-app/_arch/mes/src/components/providers/theme/ThemeProvider.tsx +27 -0
  26. package/templates/nextjs-app/_arch/mes/src/hooks/.gitkeep +0 -0
  27. package/templates/nextjs-app/_arch/mes/src/hooks/useAppMutation.ts +59 -0
  28. package/templates/nextjs-app/_arch/mes/src/lib/api/.gitkeep +0 -0
  29. package/templates/nextjs-app/_arch/mes/src/lib/api/apiTypes.ts +21 -0
  30. package/templates/nextjs-app/_arch/mes/src/lib/api/clientFetch.ts +40 -0
  31. package/templates/nextjs-app/_arch/mes/src/lib/api/error.ts +12 -0
  32. package/templates/nextjs-app/_arch/mes/src/lib/api/errorMessages.ts +37 -0
  33. package/templates/nextjs-app/_arch/mes/src/lib/api/http.ts +13 -0
  34. package/templates/nextjs-app/_arch/mes/src/lib/api/observability.ts +20 -0
  35. package/templates/nextjs-app/_arch/mes/src/lib/api/queryClient.ts +30 -0
  36. package/templates/nextjs-app/_arch/mes/src/lib/api/serverFetch.ts +59 -0
  37. package/templates/nextjs-app/_arch/mes/src/lib/config/.gitkeep +0 -0
  38. package/templates/nextjs-app/_arch/mes/src/lib/test/createTestQueryClient.ts +18 -0
  39. package/templates/nextjs-app/_arch/mes/src/lib/test/index.ts +2 -0
  40. package/templates/nextjs-app/_arch/mes/src/lib/test/renderWithProviders.tsx +65 -0
  41. package/templates/nextjs-app/_arch/mes/src/lib/utils/.gitkeep +0 -0
  42. package/templates/nextjs-app/_arch/mes/src/lib/utils/formatDate.ts +26 -0
  43. package/templates/nextjs-app/_arch/mes/src/lib/utils/formatPrice.ts +18 -0
  44. package/templates/nextjs-app/_arch/mes/src/lib/utils/getQueryClient.ts +14 -0
  45. package/templates/nextjs-app/_arch/mes/src/pages/sign-in/api.ts +3 -0
  46. package/templates/nextjs-app/_arch/mes/src/pages/sign-in/components/.gitkeep +0 -0
  47. package/templates/nextjs-app/_arch/mes/src/pages/sign-in/hooks.ts +3 -0
  48. package/templates/nextjs-app/_arch/mes/src/pages/sign-in/index.tsx +14 -0
  49. package/templates/nextjs-app/_arch/mes/src/pages/sign-in/schema.ts +2 -0
  50. package/templates/nextjs-app/_arch/mes/tsconfig.json +24 -0
  51. package/templates/nextjs-standalone/_arch/mes/app/api/proxy/[...path]/route.ts +112 -0
  52. package/templates/nextjs-standalone/_arch/mes/app/globals.css +49 -0
  53. package/templates/nextjs-standalone/_arch/mes/app/layout.tsx +16 -0
  54. package/templates/nextjs-standalone/_arch/mes/app/sign-in/page.tsx +1 -0
  55. package/templates/nextjs-standalone/_arch/mes/eslint.config.js +137 -0
  56. package/templates/nextjs-standalone/_arch/mes/sh-ui.config.json +22 -0
  57. package/templates/nextjs-standalone/_arch/mes/src/components/common/.gitkeep +0 -0
  58. package/templates/nextjs-standalone/_arch/mes/src/components/common/FallbackBoundary/index.tsx +89 -0
  59. package/templates/nextjs-standalone/_arch/mes/src/components/common/PrefetchBoundary/index.tsx +35 -0
  60. package/templates/nextjs-standalone/_arch/mes/src/components/layouts/RootLayout.tsx +13 -0
  61. package/templates/nextjs-standalone/_arch/mes/src/components/providers/GlobalProvider/index.tsx +23 -0
  62. package/templates/nextjs-standalone/_arch/mes/src/components/providers/index.tsx +1 -0
  63. package/templates/nextjs-standalone/_arch/mes/src/components/providers/tanstack/QueryClientProvider.tsx +14 -0
  64. package/templates/nextjs-standalone/_arch/mes/src/components/providers/tanstack/TanstackDevtoolsProvider.tsx +13 -0
  65. package/templates/nextjs-standalone/_arch/mes/src/components/providers/theme/ThemeProvider.tsx +27 -0
  66. package/templates/nextjs-standalone/_arch/mes/src/hooks/.gitkeep +0 -0
  67. package/templates/nextjs-standalone/_arch/mes/src/hooks/useAppMutation.ts +59 -0
  68. package/templates/nextjs-standalone/_arch/mes/src/lib/api/.gitkeep +0 -0
  69. package/templates/nextjs-standalone/_arch/mes/src/lib/api/apiTypes.ts +21 -0
  70. package/templates/nextjs-standalone/_arch/mes/src/lib/api/clientFetch.ts +40 -0
  71. package/templates/nextjs-standalone/_arch/mes/src/lib/api/error.ts +12 -0
  72. package/templates/nextjs-standalone/_arch/mes/src/lib/api/errorMessages.ts +37 -0
  73. package/templates/nextjs-standalone/_arch/mes/src/lib/api/http.ts +13 -0
  74. package/templates/nextjs-standalone/_arch/mes/src/lib/api/observability.ts +20 -0
  75. package/templates/nextjs-standalone/_arch/mes/src/lib/api/queryClient.ts +30 -0
  76. package/templates/nextjs-standalone/_arch/mes/src/lib/api/serverFetch.ts +59 -0
  77. package/templates/nextjs-standalone/_arch/mes/src/lib/config/.gitkeep +0 -0
  78. package/templates/nextjs-standalone/_arch/mes/src/lib/styles/tokens.css +170 -0
  79. package/templates/nextjs-standalone/_arch/mes/src/lib/test/createTestQueryClient.ts +18 -0
  80. package/templates/nextjs-standalone/_arch/mes/src/lib/test/index.ts +2 -0
  81. package/templates/nextjs-standalone/_arch/mes/src/lib/test/renderWithProviders.tsx +65 -0
  82. package/templates/nextjs-standalone/_arch/mes/src/lib/utils/formatDate.ts +26 -0
  83. package/templates/nextjs-standalone/_arch/mes/src/lib/utils/formatPrice.ts +18 -0
  84. package/templates/nextjs-standalone/_arch/mes/src/lib/utils/getQueryClient.ts +14 -0
  85. package/templates/nextjs-standalone/_arch/mes/src/lib/utils/utils.ts +6 -0
  86. package/templates/nextjs-standalone/_arch/mes/src/pages/sign-in/api.ts +3 -0
  87. package/templates/nextjs-standalone/_arch/mes/src/pages/sign-in/components/.gitkeep +0 -0
  88. package/templates/nextjs-standalone/_arch/mes/src/pages/sign-in/hooks.ts +3 -0
  89. package/templates/nextjs-standalone/_arch/mes/src/pages/sign-in/index.tsx +14 -0
  90. package/templates/nextjs-standalone/_arch/mes/src/pages/sign-in/schema.ts +2 -0
  91. package/templates/nextjs-standalone/_arch/mes/tsconfig.json +39 -0
@@ -0,0 +1,59 @@
1
+ import {
2
+ useMutation,
3
+ type UseMutationOptions,
4
+ type DefaultError,
5
+ } from '@tanstack/react-query';
6
+ import { toast } from 'sonner';
7
+
8
+ import { ApiError } from '@/lib/api/error';
9
+ import { resolveErrorMessage } from '@/lib/api/errorMessages';
10
+
11
+ type AppMutationOptions<
12
+ TData = unknown,
13
+ TError = DefaultError,
14
+ TVariables = void,
15
+ TContext = unknown,
16
+ > = UseMutationOptions<TData, TError, TVariables, TContext> & {
17
+ /** ApiError 가 아니거나 mapping 에 없을 때의 fallback 메시지. */
18
+ errorMessage?: string;
19
+ /** false 면 toast 띄우지 않음. */
20
+ showErrorToast?: boolean;
21
+ };
22
+
23
+ /**
24
+ * useMutation 래퍼 — 에러 발생 시 `resolveErrorMessage` 를 통해 안전한
25
+ * 사용자 facing 메시지를 toast 로 띄운다. backend 가 보낸 raw 메시지를 그대로
26
+ * 띄우지 않고 `errorMessages.ts` 의 mapping 을 우선 사용해 일관된 사용자
27
+ * 경험과 i18n 친화성을 확보.
28
+ *
29
+ * showErrorToast: false 로 자동 toast 끌 수 있고, errorMessage 로 fallback 지정.
30
+ */
31
+ export const useAppMutation = <
32
+ TData = unknown,
33
+ TError = DefaultError,
34
+ TVariables = void,
35
+ TContext = unknown,
36
+ >(
37
+ options: AppMutationOptions<TData, TError, TVariables, TContext>,
38
+ ) => {
39
+ const { errorMessage, showErrorToast = true, onError, ...rest } = options;
40
+
41
+ return useMutation({
42
+ ...rest,
43
+ onError: (...args) => {
44
+ onError?.(...args);
45
+
46
+ if (!showErrorToast) return;
47
+
48
+ const [error] = args;
49
+ const message = resolveErrorMessage(error, errorMessage);
50
+
51
+ if (message) {
52
+ toast.error(message);
53
+ }
54
+ },
55
+ });
56
+ };
57
+
58
+ // re-export ApiError 타입을 쓰는 사용처 편의용 (선택).
59
+ export type { ApiError };
@@ -0,0 +1,21 @@
1
+ /** 공통 API 에러 형식 */
2
+ export type ApiErrorBody = {
3
+ message: string;
4
+ code: string;
5
+ };
6
+
7
+ /** 백엔드 공통 응답 래퍼 */
8
+ export type ApiResponse<TData = unknown, TError = ApiErrorBody> = {
9
+ result: 'SUCCESS' | 'ERROR';
10
+ data: TData;
11
+ error: TError | null;
12
+ };
13
+
14
+ /** 페이지네이션 응답 래퍼 */
15
+ export type PaginatedData<T> = {
16
+ content: T[];
17
+ totalItems: number;
18
+ offset: number;
19
+ limit: number;
20
+ hasNext: boolean;
21
+ };
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+
3
+ import type { ApiResponse } from './apiTypes';
4
+ import { ApiError } from './error';
5
+
6
+ const PROXY_BASE = '/api/proxy';
7
+
8
+ export async function clientFetch<T>(
9
+ path: string,
10
+ init: RequestInit = {},
11
+ ): Promise<T> {
12
+ const url = `${PROXY_BASE}${path.startsWith('/') ? path : `/${path}`}`;
13
+
14
+ const res = await fetch(url, {
15
+ ...init,
16
+ headers: {
17
+ 'Content-Type': 'application/json',
18
+ ...(init.headers as Record<string, string> | undefined),
19
+ },
20
+ credentials: 'include',
21
+ });
22
+
23
+ const body = (await res.json()) as ApiResponse<T>;
24
+
25
+ if (res.status === 401) {
26
+ // 이미 sign-in 페이지면 redirect 루프 방지.
27
+ // next-intl 활성 시 path 가 `/ko/sign-in` 형태가 되므로 regex 로 match.
28
+ if (typeof window !== 'undefined' && !/\/sign-in(\/|$)/.test(window.location.pathname)) {
29
+ // /sign-in 으로만 보내면 middleware (next-intl 활성 시) 가 default locale 을 prefix 추가.
30
+ window.location.href = '/sign-in';
31
+ }
32
+ throw new ApiError(401, body.error?.code ?? 'UNAUTHORIZED', body.error);
33
+ }
34
+
35
+ if (!res.ok || body.result === 'ERROR') {
36
+ throw new ApiError(res.status, body.error?.code ?? '', body.error);
37
+ }
38
+
39
+ return body.data;
40
+ }
@@ -0,0 +1,12 @@
1
+ import type { ApiErrorBody } from './apiTypes';
2
+
3
+ export class ApiError extends Error {
4
+ constructor(
5
+ public readonly status: number,
6
+ public readonly code: string,
7
+ public readonly data: ApiErrorBody | null,
8
+ ) {
9
+ super(data?.message ?? `API 요청 실패 (${status})`);
10
+ this.name = 'ApiError';
11
+ }
12
+ }
@@ -0,0 +1,37 @@
1
+ import { ApiError } from './error';
2
+
3
+ /**
4
+ * 에러 코드 → 사용자 facing 메시지 mapping.
5
+ *
6
+ * 백엔드가 보내는 raw `error.message` 를 그대로 toast 로 띄우면 i18n 어긋나거나
7
+ * 내부 노출이 될 수 있어, frontend 가 정의한 안전한 메시지로 변환하는 게 권장.
8
+ * 코드별로 명시 안 된 경우 `error.data.message` (backend 메시지) → fallback.
9
+ *
10
+ * 사용자 프로젝트에서 자유롭게 추가/수정. next-intl 활성 시엔 `useTranslations`
11
+ * 와 결합해 hook 형태로 변환 가능 (이 파일은 RSC/CSR 양쪽 호환을 위해 module).
12
+ */
13
+ export const ERROR_MESSAGES: Record<string, string> = {
14
+ UNAUTHORIZED: '로그인이 필요합니다.',
15
+ FORBIDDEN: '접근 권한이 없습니다.',
16
+ NOT_FOUND: '요청한 리소스를 찾을 수 없습니다.',
17
+ NETWORK_ERROR: '서버에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
18
+ // 사용자 정의 코드를 여기에 추가하세요.
19
+ };
20
+
21
+ const DEFAULT_FALLBACK = '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
22
+
23
+ /**
24
+ * 에러 → 사용자 facing 메시지 결정.
25
+ * 1. ApiError + 코드가 ERROR_MESSAGES 에 있으면 그 메시지
26
+ * 2. ApiError 의 backend `data.message` (있으면)
27
+ * 3. 호출자의 fallback
28
+ * 4. 글로벌 DEFAULT_FALLBACK
29
+ */
30
+ export function resolveErrorMessage(error: unknown, fallback?: string): string {
31
+ if (error instanceof ApiError) {
32
+ const mapped = ERROR_MESSAGES[error.code];
33
+ if (mapped) return mapped;
34
+ if (error.data?.message) return error.data.message;
35
+ }
36
+ return fallback ?? DEFAULT_FALLBACK;
37
+ }
@@ -0,0 +1,13 @@
1
+ import { clientFetch } from './clientFetch';
2
+ import { serverFetch } from './serverFetch';
3
+
4
+ /**
5
+ * isomorphic 진입점.
6
+ * RSC/서버에서는 백엔드로 직통, 브라우저에서는 /api/proxy 경유.
7
+ * API 함수는 한 번만 작성하고 환경 분기는 여기서 처리한다.
8
+ */
9
+ export function http<T>(path: string, init?: RequestInit): Promise<T> {
10
+ return typeof window === 'undefined'
11
+ ? serverFetch<T>(path, init)
12
+ : clientFetch<T>(path, init);
13
+ }
@@ -0,0 +1,20 @@
1
+ type ApiCaptureParams = {
2
+ url: string;
3
+ apiPath: string;
4
+ method: string;
5
+ status: number | undefined;
6
+ responseBody?: unknown;
7
+ };
8
+
9
+ type ApiLogParams = {
10
+ url: string;
11
+ method: string;
12
+ status: number | undefined;
13
+ requestHeaders?: Record<string, string | undefined>;
14
+ requestBody?: unknown;
15
+ responseBody?: unknown;
16
+ };
17
+
18
+ export const captureApiError = (_params: ApiCaptureParams): void => {};
19
+
20
+ export const logApiError = (_prefix: string, _params: ApiLogParams): void => {};
@@ -0,0 +1,30 @@
1
+ import {
2
+ QueryClient,
3
+ defaultShouldDehydrateQuery,
4
+ isServer,
5
+ } from '@tanstack/react-query';
6
+ import { cache } from 'react';
7
+
8
+ function makeQueryClient(): QueryClient {
9
+ return new QueryClient({
10
+ defaultOptions: {
11
+ queries: {
12
+ staleTime: 60 * 1000,
13
+ retry: 1,
14
+ },
15
+ dehydrate: {
16
+ shouldDehydrateQuery: (q) =>
17
+ defaultShouldDehydrateQuery(q) || q.state.status === 'pending',
18
+ },
19
+ },
20
+ });
21
+ }
22
+
23
+ export const getServerQueryClient = cache(makeQueryClient);
24
+
25
+ let browserQueryClient: QueryClient | undefined;
26
+
27
+ export function getBrowserQueryClient(): QueryClient {
28
+ if (isServer) return makeQueryClient();
29
+ return (browserQueryClient ??= makeQueryClient());
30
+ }
@@ -0,0 +1,59 @@
1
+ import { cookies } from 'next/headers';
2
+
3
+ import type { ApiResponse } from './apiTypes';
4
+ import { ApiError } from './error';
5
+ import { captureApiError, logApiError } from './observability';
6
+
7
+ const API_URL = process.env.API_URL ?? 'http://localhost:8080/api';
8
+
9
+ const ACCESS_TOKEN_COOKIE = 'accessToken';
10
+ const LOCALE_COOKIE = 'NEXT_LOCALE';
11
+
12
+ export async function serverFetch<T>(
13
+ path: string,
14
+ init: RequestInit = {},
15
+ ): Promise<T> {
16
+ const jar = await cookies();
17
+ const accessToken = jar.get(ACCESS_TOKEN_COOKIE)?.value;
18
+ const locale = jar.get(LOCALE_COOKIE)?.value;
19
+
20
+ const url = `${API_URL}${path.startsWith('/') ? path : `/${path}`}`;
21
+ const method = (init.method ?? 'GET').toUpperCase();
22
+
23
+ const headers: Record<string, string> = {
24
+ 'Content-Type': 'application/json',
25
+ ...(accessToken && { Authorization: `Bearer ${accessToken}` }),
26
+ ...(locale && { 'Accept-Language': locale }),
27
+ ...(init.headers as Record<string, string> | undefined),
28
+ };
29
+
30
+ const res = await fetch(url, {
31
+ ...init,
32
+ headers,
33
+ cache: init.cache ?? 'no-store',
34
+ });
35
+
36
+ const body = (await res.json()) as ApiResponse<T>;
37
+
38
+ if (!res.ok || body.result === 'ERROR') {
39
+ logApiError('SERVER_FETCH', {
40
+ url,
41
+ method,
42
+ status: res.status,
43
+ requestBody: typeof init.body === 'string' ? init.body : undefined,
44
+ responseBody: body,
45
+ });
46
+
47
+ captureApiError({
48
+ url,
49
+ apiPath: path,
50
+ method,
51
+ status: res.status,
52
+ responseBody: body,
53
+ });
54
+
55
+ throw new ApiError(res.status, body.error?.code ?? '', body.error);
56
+ }
57
+
58
+ return body.data;
59
+ }
@@ -0,0 +1,170 @@
1
+ /* Generated by @sh-ui/tokens — do not edit directly */
2
+ /* base=neutral radius=md mode=light-dark */
3
+
4
+ /* sh-ui:theme-colors-start */
5
+ :root {
6
+ --background: #FFFFFF;
7
+ --background-subtle: #FAFAFA;
8
+ --background-muted: #F5F5F5;
9
+ --background-inverse: #0A0A0A;
10
+ --foreground: #0A0A0A;
11
+ --foreground-muted: #525252;
12
+ --foreground-subtle: #A3A3A3;
13
+ --foreground-inverse: #FFFFFF;
14
+ --border: #E5E5E5;
15
+ --border-strong: #D4D4D4;
16
+ --primary: #171717;
17
+ --primary-foreground: #FAFAFA;
18
+ --primary-hover: #262626;
19
+ --ring: color-mix(in srgb, var(--primary) 50%, transparent);
20
+ --danger: #DC2626;
21
+ --danger-hover: color-mix(in srgb, var(--danger) 90%, black);
22
+ --danger-foreground: #FFFFFF;
23
+ }
24
+ @media (prefers-color-scheme: dark) {
25
+ :root:not(.light):not(.dark) {
26
+ --background: #0A0A0A;
27
+ --background-subtle: #171717;
28
+ --background-muted: #262626;
29
+ --background-inverse: #FFFFFF;
30
+ --foreground: #FAFAFA;
31
+ --foreground-muted: #A3A3A3;
32
+ --foreground-subtle: #737373;
33
+ --foreground-inverse: #0A0A0A;
34
+ --border: #262626;
35
+ --border-strong: #404040;
36
+ --primary: #FAFAFA;
37
+ --primary-foreground: #171717;
38
+ --primary-hover: #E5E5E5;
39
+ --danger: #DC2626;
40
+ --danger-hover: color-mix(in srgb, var(--danger) 90%, black);
41
+ --danger-foreground: #FFFFFF;
42
+ }
43
+ }
44
+ .dark {
45
+ --background: #0A0A0A;
46
+ --background-subtle: #171717;
47
+ --background-muted: #262626;
48
+ --background-inverse: #FFFFFF;
49
+ --foreground: #FAFAFA;
50
+ --foreground-muted: #A3A3A3;
51
+ --foreground-subtle: #737373;
52
+ --foreground-inverse: #0A0A0A;
53
+ --border: #262626;
54
+ --border-strong: #404040;
55
+ --primary: #FAFAFA;
56
+ --primary-foreground: #171717;
57
+ --primary-hover: #E5E5E5;
58
+ --danger: #DC2626;
59
+ --danger-hover: color-mix(in srgb, var(--danger) 90%, black);
60
+ --danger-foreground: #FFFFFF;
61
+ }
62
+ /* sh-ui:theme-colors-end */
63
+
64
+ :root {
65
+ /* sh-ui:theme-radius-start */
66
+ --radius: 0.5rem;
67
+ /* sh-ui:theme-radius-end */
68
+ /* sh-ui:theme-space-start */
69
+ --space-0: 0;
70
+ --space-1: 0.25rem;
71
+ --space-2: 0.5rem;
72
+ --space-3: 0.75rem;
73
+ --space-4: 1rem;
74
+ --space-5: 1.25rem;
75
+ --space-6: 1.5rem;
76
+ --space-8: 2rem;
77
+ --space-10: 2.5rem;
78
+ --space-12: 3rem;
79
+ --space-16: 4rem;
80
+ /* sh-ui:theme-space-end */
81
+ /* sh-ui:theme-text-start */
82
+ --text-xs: 0.75rem;
83
+ --text-sm: 0.875rem;
84
+ --text-base: 1rem;
85
+ --text-lg: 1.125rem;
86
+ --text-xl: 1.25rem;
87
+ --text-2xl: 1.5rem;
88
+ --text-3xl: 1.875rem;
89
+ --text-4xl: 2.25rem;
90
+ /* sh-ui:theme-text-end */
91
+ /* sh-ui:theme-weight-start */
92
+ --weight-regular: 400;
93
+ --weight-medium: 500;
94
+ --weight-semibold: 600;
95
+ --weight-bold: 700;
96
+ /* sh-ui:theme-weight-end */
97
+ /* sh-ui:theme-shadow-start */
98
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
99
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
100
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
101
+ --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.18);
102
+ --shadow-menu: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
103
+ /* sh-ui:theme-shadow-end */
104
+ /* sh-ui:theme-duration-start */
105
+ --duration-fast: 120ms;
106
+ --duration-base: 160ms;
107
+ --duration-slow: 200ms;
108
+ /* sh-ui:theme-duration-end */
109
+ /* sh-ui:theme-ease-start */
110
+ --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
111
+ --ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
112
+ /* sh-ui:theme-ease-end */
113
+ /* sh-ui:theme-control-start */
114
+ --control-sm: 2rem;
115
+ --control-md: 2.5rem;
116
+ --control-lg: 3rem;
117
+ /* sh-ui:theme-control-end */
118
+ /* sh-ui:theme-border-width-start */
119
+ --border-width: 1px;
120
+ --border-width-strong: 2px;
121
+ /* sh-ui:theme-border-width-end */
122
+ /* sh-ui:theme-gradient-start */
123
+ --gradient-primary: linear-gradient(135deg, #171717 0%, #525252 100%);
124
+ --gradient-surface: linear-gradient(180deg, #FFFFFF 0%, #F5F5F5 100%);
125
+ --gradient-overlay: linear-gradient(180deg, #000000 0%, #1F1F1F 100%);
126
+ /* sh-ui:theme-gradient-end */
127
+ --opacity-disabled: 0.5;
128
+ --z-base: 0;
129
+ --z-sticky: 100;
130
+ --z-dropdown: 200;
131
+ --z-overlay: 300;
132
+ --z-modal: 400;
133
+ --z-popover: 500;
134
+ --z-toast: 600;
135
+ --z-tooltip: 700;
136
+ --bp-sm: 640px;
137
+ --bp-md: 768px;
138
+ --bp-lg: 1024px;
139
+ --bp-xl: 1280px;
140
+ }
141
+
142
+ /* WCAG 2.5.5 — 터치 타겟 최소 44×44px. 마우스/스타일러스 대신 손가락 입력 시 control 높이를 보정. */
143
+ @media (hover: none) and (pointer: coarse) {
144
+ :root {
145
+ --control-sm: 2.75rem;
146
+ --control-md: 2.75rem;
147
+ }
148
+ }
149
+
150
+ /* WCAG AA 보장 — 사용자가 OS/브라우저에서 "고대비" 접근성 옵션 켰을 때.
151
+ * foreground-subtle, border, border-strong 만 강화 (다른 토큰은 이미 AA 통과). */
152
+ @media (prefers-contrast: high) {
153
+ :root {
154
+ --foreground-subtle: #767676;
155
+ --border: #767676;
156
+ --border-strong: #595959;
157
+ }
158
+ .dark {
159
+ --foreground-subtle: #B3B3B3;
160
+ --border: #757575;
161
+ --border-strong: #999999;
162
+ }
163
+ @media (prefers-color-scheme: dark) {
164
+ :root:not(.light):not(.dark) {
165
+ --foreground-subtle: #B3B3B3;
166
+ --border: #757575;
167
+ --border-strong: #999999;
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,18 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+
3
+ /**
4
+ * 테스트 전용 QueryClient — retry/refetch 끄고 gcTime 0 으로 격리.
5
+ */
6
+ export const createTestQueryClient = (): QueryClient =>
7
+ new QueryClient({
8
+ defaultOptions: {
9
+ queries: {
10
+ retry: false,
11
+ refetchOnWindowFocus: false,
12
+ gcTime: 0,
13
+ },
14
+ mutations: {
15
+ retry: false,
16
+ },
17
+ },
18
+ });
@@ -0,0 +1,2 @@
1
+ export { createTestQueryClient } from './createTestQueryClient';
2
+ export { renderWithProviders } from './renderWithProviders';
@@ -0,0 +1,65 @@
1
+ import type { ComponentType, ReactElement, ReactNode } from 'react';
2
+
3
+ import {
4
+ render,
5
+ type RenderOptions,
6
+ type RenderResult,
7
+ } from '@testing-library/react';
8
+ import userEvent from '@testing-library/user-event';
9
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
10
+ import { ThemeProvider } from 'next-themes';
11
+
12
+ import { createTestQueryClient } from './createTestQueryClient';
13
+
14
+ type Options = Omit<RenderOptions, 'wrapper'> & {
15
+ queryClient?: QueryClient;
16
+ /**
17
+ * 외부 Provider 가 필요할 때 (예: next-intl 활성 프로젝트에서
18
+ * `NextIntlClientProvider`) 사용. ThemeProvider 안쪽 / QueryClientProvider 바깥에 wrap.
19
+ */
20
+ extraWrapper?: ComponentType<{ children: ReactNode }>;
21
+ };
22
+
23
+ type Result = RenderResult & {
24
+ user: ReturnType<typeof userEvent.setup>;
25
+ queryClient: QueryClient;
26
+ };
27
+
28
+ /**
29
+ * RTL render + ThemeProvider + QueryClientProvider + userEvent setup 한 번에.
30
+ *
31
+ * next-intl 프로젝트에서 `useTranslations` 사용 컴포넌트 테스트 시:
32
+ *
33
+ * const Intl = ({ children }) => (
34
+ * <NextIntlClientProvider locale='ko' messages={ko}>
35
+ * {children}
36
+ * </NextIntlClientProvider>
37
+ * );
38
+ * renderWithProviders(<MyComponent />, { extraWrapper: Intl });
39
+ */
40
+ export const renderWithProviders = (
41
+ ui: ReactElement,
42
+ options: Options = {},
43
+ ): Result => {
44
+ const {
45
+ queryClient = createTestQueryClient(),
46
+ extraWrapper: Extra,
47
+ ...rtlOptions
48
+ } = options;
49
+
50
+ const Wrapper = ({ children }: { children: ReactNode }) => {
51
+ const inner = (
52
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
53
+ );
54
+ return (
55
+ <ThemeProvider attribute='class' defaultTheme='light' enableSystem={false}>
56
+ {Extra ? <Extra>{inner}</Extra> : inner}
57
+ </ThemeProvider>
58
+ );
59
+ };
60
+
61
+ const result = render(ui, { wrapper: Wrapper, ...rtlOptions });
62
+ const user = userEvent.setup();
63
+
64
+ return { ...result, user, queryClient };
65
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Date → 로케일 기반 날짜 포맷 (시간 없음).
3
+ *
4
+ * 비-i18n 프로젝트면 default 'ko-KR' 사용. next-intl 활성 시엔 이 util 을 직접
5
+ * 부르지 말고 같은 모듈의 hook (`useFormatDate`) 을 사용하세요 — 현재 locale 을
6
+ * 자동으로 따릅니다 (next-intl 플러그인이 emit).
7
+ */
8
+ export const formatDate = (date: Date, locale = 'ko-KR'): string =>
9
+ new Intl.DateTimeFormat(locale, {
10
+ year: 'numeric',
11
+ month: '2-digit',
12
+ day: '2-digit',
13
+ }).format(date);
14
+
15
+ /**
16
+ * Date → 로케일 기반 날짜 + 시간 포맷 (24h).
17
+ */
18
+ export const formatDateTime = (date: Date, locale = 'ko-KR'): string =>
19
+ new Intl.DateTimeFormat(locale, {
20
+ year: 'numeric',
21
+ month: '2-digit',
22
+ day: '2-digit',
23
+ hour: '2-digit',
24
+ minute: '2-digit',
25
+ hour12: false,
26
+ }).format(date);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 숫자 → 통화 포맷. default ko-KR + KRW 이지만 두 인자 모두 override 가능.
3
+ * 예: formatPrice(12000) → "₩12,000"
4
+ * formatPrice(99.5, 'en-US', 'USD') → "$99.50"
5
+ *
6
+ * next-intl 활성 시엔 같은 모듈의 hook (`useFormatPrice`) 을 사용하면 현재
7
+ * locale 을 자동으로 따릅니다 (next-intl 플러그인이 emit).
8
+ */
9
+ export const formatPrice = (
10
+ amount: number,
11
+ locale = 'ko-KR',
12
+ currency = 'KRW',
13
+ ): string =>
14
+ new Intl.NumberFormat(locale, {
15
+ style: 'currency',
16
+ currency,
17
+ maximumFractionDigits: currency === 'KRW' ? 0 : 2,
18
+ }).format(amount);
@@ -0,0 +1,14 @@
1
+ import { isServer } from '@tanstack/react-query';
2
+
3
+ import {
4
+ getBrowserQueryClient,
5
+ getServerQueryClient,
6
+ } from '@/lib/api/queryClient';
7
+
8
+ /**
9
+ * RSC 와 클라이언트 양쪽에서 안전하게 호출 가능한 QueryClient 핸들.
10
+ * 서버에서는 React `cache()` 로 요청 단위 싱글턴, 브라우저에서는 모듈 싱글턴.
11
+ */
12
+ export function getQueryClient() {
13
+ return isServer ? getServerQueryClient() : getBrowserQueryClient();
14
+ }
@@ -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,3 @@
1
+ // 순수 fetch 레이어 — React 모름. hooks.ts 가 useQuery/useMutation 으로 감싼다.
2
+ // 공용 fetcher 는 `@/lib/api/clientFetch` 등 src/lib/api/ 에서 import.
3
+ export {};
@@ -0,0 +1,3 @@
1
+ // 페이지 전용 React 어댑터 — TanStack Query useQuery/useMutation 래퍼.
2
+ // query key 와 invalidation 을 이 파일에 모아 다른 mutation 추가 시 빠뜨리지 않게.
3
+ export {};
@@ -0,0 +1,14 @@
1
+ // MES 프리셋의 페이지 본체 슬롯 예시.
2
+ //
3
+ // 페이지 폴더 구조 (관용):
4
+ // index.tsx ─ 페이지 컴포넌트 (이 파일)
5
+ // components/ ─ 페이지 전용 UI (폼, 다이얼로그, 테이블 등)
6
+ // api.ts ─ 순수 fetch 레이어
7
+ // hooks.ts ─ TanStack Query 어댑터 (useQuery / useMutation)
8
+ // schema.ts ─ zod 스키마
9
+ // columns.ts ─ (옵션) 리스트 페이지의 테이블 컬럼 정의
10
+ //
11
+ // 새 페이지를 추가할 때 이 폴더를 복사해 이름만 바꾸세요.
12
+ export default function SignInPage() {
13
+ return null;
14
+ }
@@ -0,0 +1,2 @@
1
+ // zod 스키마 — entity 와 form 이 다르면 두 개 (예: createXxxSchema 는 id/createdAt 제외).
2
+ export {};