@gaonjs/cli 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** 소스에서 레이아웃 브레이크포인트 사용 위치(토큰·줄)를 모은다(단위 테스트 진입점). */
3
+ export declare function usesLayoutBreakpoint(source: string): {
4
+ token: string;
5
+ line: number;
6
+ }[];
7
+ /** apps/<앱>/pages 를 훑어 페이지 레이아웃 브레이크포인트 사용을 경고로 낸다. */
8
+ export declare function checkPageLayoutBreakpoint(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,94 @@
1
+ // @gaonjs/cli · doctor · 페이지 레이아웃 브레이크포인트 안내 (결정 107)
2
+ //
3
+ // 폭·여백·열 수 같은 레이아웃 반응형은 UI 킷 블록(PageShell·PageHeader …)이
4
+ // 소유한다(결정 105·106·107). 페이지 파일이 레이아웃 브레이크포인트를 루트에서
5
+ // 직접 쓰면 반응형 규칙이 여러 곳에 흩어져, "정답이 하나" 가 무너진다. 이 검사는
6
+ // 그 사용을 **안내 경고(warning)** 로만 낸다 — 강제(error)가 아니다. 킷에 없는
7
+ // 표현이면 Tailwind 유틸을 직접 써도 되므로(탈출구), 오탐을 줄이려 신호가 확실한
8
+ // 레이아웃 유틸(반응형 flex-direction·grid 열 수)만 좁게 잡는다.
9
+ //
10
+ // 대상: apps/<앱>/pages/**/*.vue (페이지 파일만). shared/components/ui 의 킷 블록은
11
+ // 반응형을 소유하므로 검사하지 않는다(그게 이 규칙의 목적).
12
+ import { readdir, readFile } from 'node:fs/promises';
13
+ import { join, relative } from 'node:path';
14
+ /**
15
+ * 좁게 잡는 레이아웃 반응형 패턴: 반응형 접두사 + (flex-row/col · grid-cols-N/[).
16
+ * `sm:hidden`·`sm:text-lg`·`sm:px-6` 같은 흔한 반응형(표시·타이포·여백)은 잡지
17
+ * 않는다 — 오탐을 줄이려 페이지 레이아웃을 뒤집는 확실한 신호만 본다.
18
+ */
19
+ const LAYOUT_BREAKPOINT = /\b(sm|md|lg|xl|2xl):(flex-(?:row|col)(?:-reverse)?|grid-cols-(?:\d+|\[))/;
20
+ /** 소스에서 레이아웃 브레이크포인트 사용 위치(토큰·줄)를 모은다(단위 테스트 진입점). */
21
+ export function usesLayoutBreakpoint(source) {
22
+ const out = [];
23
+ const lines = source.split('\n');
24
+ for (let i = 0; i < lines.length; i++) {
25
+ const m = LAYOUT_BREAKPOINT.exec(lines[i]);
26
+ if (m)
27
+ out.push({ token: m[0], line: i + 1 });
28
+ }
29
+ return out;
30
+ }
31
+ /** apps/<앱>/pages 를 훑어 페이지 레이아웃 브레이크포인트 사용을 경고로 낸다. */
32
+ export async function checkPageLayoutBreakpoint(cwd) {
33
+ const appsDir = join(cwd, 'apps');
34
+ const issues = [];
35
+ for (const app of await safeListDirs(appsDir)) {
36
+ const pagesDir = join(appsDir, app, 'pages');
37
+ for (const abs of await walkVueFiles(pagesDir)) {
38
+ const source = await readFile(abs, 'utf8').catch(() => '');
39
+ const hits = usesLayoutBreakpoint(source);
40
+ if (hits.length === 0)
41
+ continue;
42
+ const rel = relative(cwd, abs);
43
+ const first = hits[0];
44
+ issues.push({
45
+ rule: 'page-layout-breakpoint',
46
+ level: 'warning',
47
+ file: rel,
48
+ line: first.line,
49
+ message: `${rel} (line ${first.line})\n` +
50
+ ` 페이지가 레이아웃 브레이크포인트('${first.token}'${hits.length > 1 ? ` 외 ${hits.length - 1}건` : ''})를 직접 씁니다.\n` +
51
+ ` 반응형(폭·여백·열 수)은 UI 킷 블록이 책임집니다(결정 107).\n` +
52
+ `→ shared/components/ui 의 블록(PageShell·PageHeader 등)으로 감싸 반응형을 킷에 두거나,\n` +
53
+ ` 킷에 없는 표현이면 그대로 둬도 됩니다 — 이 경고는 강제가 아닙니다(탈출구 유지).`,
54
+ detail: { token: first.token, count: hits.length },
55
+ });
56
+ }
57
+ }
58
+ return { rule: 'page-layout-breakpoint', issues };
59
+ }
60
+ /** 디렉터리 트리에서 .vue 파일 절대경로를 모은다(테스트·선언 제외 불필요 — .vue 만). */
61
+ async function walkVueFiles(dir) {
62
+ const out = [];
63
+ const walk = async (d) => {
64
+ let entries;
65
+ try {
66
+ entries = (await readdir(d, { withFileTypes: true }));
67
+ }
68
+ catch {
69
+ return;
70
+ }
71
+ for (const e of entries) {
72
+ const abs = join(d, e.name);
73
+ if (e.isDirectory()) {
74
+ if (e.name === 'node_modules' || e.name === '.gaon' || e.name === 'dist')
75
+ continue;
76
+ await walk(abs);
77
+ }
78
+ else if (e.isFile() && e.name.endsWith('.vue')) {
79
+ out.push(abs);
80
+ }
81
+ }
82
+ };
83
+ await walk(dir);
84
+ return out.sort();
85
+ }
86
+ async function safeListDirs(dir) {
87
+ try {
88
+ const entries = (await readdir(dir, { withFileTypes: true }));
89
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ }
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
@@ -1,10 +1,12 @@
1
- // @gaonjs/cli · doctor · UI 킷 배선 검사 (결정 76)
1
+ // @gaonjs/cli · doctor · UI 킷 배선 검사 (결정 76 · 결정 105)
2
2
  //
3
- // 앱이 UI 킷 컴포넌트(apps/<앱>/components/ui/*)를 import 하는데 앱에
4
- // Tailwind 배선(apps/<앱>/style.css 의 @tailwind + main.ts 의 style.css import)이
5
- // 없으면, UI 킷이 쓰는 유틸 클래스(bg-primary·rounded-lg …)가 펼쳐지지 않아
6
- // 컴포넌트가 스타일 없이 렌더된다 — 컴파일은 통과하므로 check 로는 안 잡히는
7
- // 조용한 런타임 파손이다(결정 74·75·76). 이 검사가 그 상태를 경고로 낸다.
3
+ // 앱이 UI 킷 컴포넌트(@shared/components/ui/* 결정 105 로 shared 이전)를
4
+ // import 하는데 그 앱에 Tailwind 배선(apps/<앱>/style.css 의 @tailwind +
5
+ // main.ts 의 style.css import)이 없으면, UI 킷이 쓰는 유틸 클래스(bg-primary·
6
+ // rounded-lg …)가 펼쳐지지 않아 컴포넌트가 스타일 없이 렌더된다 — 컴파일은
7
+ // 통과하므로 check 로는 안 잡히는 조용한 런타임 파손이다(결정 74·75·76·105).
8
+ // 이 검사가 그 상태를 경고로 낸다. 킷 위치와 무관하게 import 경로에 남는
9
+ // 'components/ui/' 로 사용처를 잡으므로 @shared alias 도 그대로 감지된다.
8
10
  //
9
11
  // `gaon new`(web)·`gaon g app` 은 이제 배선을 함께 심으므로 정상 경로에서는
10
12
  // 걸리지 않는다. 이 규칙은 배선을 지웠거나 구버전 스캐폴드에서 만든 앱을 잡는
@@ -44,7 +46,9 @@ export async function checkUiKitWiring(cwd) {
44
46
  /** 앱 디렉터리를 재귀 스캔해 UI 킷을 import 하는 첫 .vue/.ts 파일(cwd 상대)을 찾는다. */
45
47
  async function findUiKitImporter(appDir, cwd) {
46
48
  for (const abs of await walkSources(appDir)) {
47
- // components/ui 안의 컴포넌트 자체는 서로를 import 하므로 제외한다.
49
+ // 킷은 이제 shared/ apps/ 스캔엔 들어오지만, 결정 105 이전의 앱별
50
+ // 사본(apps/<앱>/components/ui)이 남아 있으면 그 컴포넌트끼리의 상호 import 를
51
+ // 사용처로 오인하지 않도록 제외한다(마이그레이션 과도기 방어).
48
52
  if (abs.includes(`${join(appDir, 'components', 'ui')}`))
49
53
  continue;
50
54
  const source = await readFile(abs, 'utf8').catch(() => '');
package/dist/doctor.d.ts CHANGED
@@ -19,6 +19,7 @@ export { hasStateChangingRoutes, checkCsrfWiring } from './doctor/csrf-wiring.js
19
19
  export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './doctor/internal-anchor.js';
20
20
  export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/pageprops-destructure.js';
21
21
  export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
22
+ export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
22
23
  export { renderHuman, renderJson } from './doctor/reporter.js';
23
24
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
24
25
  export interface DoctorCommandOptions {
package/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
3
3
  *
4
- * 21 검사를 조립한다:
4
+ * 22 검사를 조립한다:
5
5
  * 1) response-mixing (errata E-3 §C · 라이브)
6
6
  * 2) n-plus-one (errata E-4 (e))
7
7
  * 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
@@ -23,6 +23,7 @@
23
23
  * 19) internal-anchor (결정 96 · 앱 내부 이동 일반 <a> = 풀 리로드 경고)
24
24
  * 20) pageprops-destructure (결정 99 · pageProps() 구조분해 = 반응성 끊김 경고)
25
25
  * 21) async-offload (결정 102·103 · 컨트롤러 인라인 메일·이미지·외부 HTTP = 응답 지연 경고)
26
+ * 22) page-layout-breakpoint (결정 107 · 페이지 레이아웃 브레이크포인트 직접 사용 = 안내 경고)
26
27
  *
27
28
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
28
29
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -56,6 +57,7 @@ import { checkCsrfWiring } from './doctor/csrf-wiring.js';
56
57
  import { checkInternalAnchor } from './doctor/internal-anchor.js';
57
58
  import { checkPagePropsDestructure } from './doctor/pageprops-destructure.js';
58
59
  import { checkAsyncOffload } from './doctor/async-offload.js';
60
+ import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
59
61
  import { renderHuman, renderJson } from './doctor/reporter.js';
60
62
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
61
63
  import { makeResult, } from './doctor/types.js';
@@ -78,6 +80,7 @@ export { hasStateChangingRoutes, checkCsrfWiring } from './doctor/csrf-wiring.js
78
80
  export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './doctor/internal-anchor.js';
79
81
  export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/pageprops-destructure.js';
80
82
  export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
83
+ export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
81
84
  export { renderHuman, renderJson } from './doctor/reporter.js';
82
85
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
83
86
  /**
@@ -105,6 +108,7 @@ const ALL_RULES = [
105
108
  'internal-anchor',
106
109
  'pageprops-destructure',
107
110
  'async-offload',
111
+ 'page-layout-breakpoint',
108
112
  ];
109
113
  const CHECKERS = {
110
114
  'response-mixing': checkResponseMixing,
@@ -128,6 +132,7 @@ const CHECKERS = {
128
132
  'internal-anchor': checkInternalAnchor,
129
133
  'pageprops-destructure': checkPagePropsDestructure,
130
134
  'async-offload': checkAsyncOffload,
135
+ 'page-layout-breakpoint': checkPageLayoutBreakpoint,
131
136
  };
132
137
  /**
133
138
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
package/dist/index.js CHANGED
@@ -101,7 +101,7 @@ function renderHelp(version = VERSION) {
101
101
  " gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
102
102
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
103
103
  " gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
104
- " gaon doctor 정적 검사 (21 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드)",
104
+ " gaon doctor 정적 검사 (22 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트)",
105
105
  " gaon doctor --json 자동화용 JSON 출력",
106
106
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
107
107
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -1,12 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { pageProps, router } from 'gaonjs/vue'
3
- import Card from '../components/ui/Card.vue'
4
- import CardHeader from '../components/ui/CardHeader.vue'
5
- import CardTitle from '../components/ui/CardTitle.vue'
6
- import CardDescription from '../components/ui/CardDescription.vue'
7
- import CardContent from '../components/ui/CardContent.vue'
8
- import CardFooter from '../components/ui/CardFooter.vue'
9
- import Button from '../components/ui/Button.vue'
3
+ import Card from '@shared/components/ui/Card.vue'
4
+ import CardHeader from '@shared/components/ui/CardHeader.vue'
5
+ import CardTitle from '@shared/components/ui/CardTitle.vue'
6
+ import CardDescription from '@shared/components/ui/CardDescription.vue'
7
+ import CardContent from '@shared/components/ui/CardContent.vue'
8
+ import CardFooter from '@shared/components/ui/CardFooter.vue'
9
+ import Button from '@shared/components/ui/Button.vue'
10
10
 
11
11
  // dashboard#show 의 render props — user 는 직렬화되며 passwordDigest 는 없다(§4.2).
12
12
  // pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
@@ -1,16 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  import { pageProps, useForm, Link } from 'gaonjs/vue'
3
- import Card from '../../components/ui/Card.vue'
4
- import CardHeader from '../../components/ui/CardHeader.vue'
5
- import CardTitle from '../../components/ui/CardTitle.vue'
6
- import CardDescription from '../../components/ui/CardDescription.vue'
7
- import CardContent from '../../components/ui/CardContent.vue'
8
- import Form from '../../components/ui/Form.vue'
9
- import FormField from '../../components/ui/FormField.vue'
10
- import Input from '../../components/ui/Input.vue'
11
- import Button from '../../components/ui/Button.vue'
12
- import Alert from '../../components/ui/Alert.vue'
13
- import AlertDescription from '../../components/ui/AlertDescription.vue'
3
+ import Card from '@shared/components/ui/Card.vue'
4
+ import CardHeader from '@shared/components/ui/CardHeader.vue'
5
+ import CardTitle from '@shared/components/ui/CardTitle.vue'
6
+ import CardDescription from '@shared/components/ui/CardDescription.vue'
7
+ import CardContent from '@shared/components/ui/CardContent.vue'
8
+ import Form from '@shared/components/ui/Form.vue'
9
+ import FormField from '@shared/components/ui/FormField.vue'
10
+ import Input from '@shared/components/ui/Input.vue'
11
+ import Button from '@shared/components/ui/Button.vue'
12
+ import Alert from '@shared/components/ui/Alert.vue'
13
+ import AlertDescription from '@shared/components/ui/AlertDescription.vue'
14
14
 
15
15
  // 컨트롤러 session#new 의 render props 타입이 그대로 흐른다(§6.2). pageProps 는
16
16
  // 반응형이라 변수로 받아 props.x 로 접근한다 — 구조분해 금지(결정 99). 로그인
@@ -1,16 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  import { pageProps, useForm, Link } from 'gaonjs/vue'
3
- import Card from '../../components/ui/Card.vue'
4
- import CardHeader from '../../components/ui/CardHeader.vue'
5
- import CardTitle from '../../components/ui/CardTitle.vue'
6
- import CardDescription from '../../components/ui/CardDescription.vue'
7
- import CardContent from '../../components/ui/CardContent.vue'
8
- import Form from '../../components/ui/Form.vue'
9
- import FormField from '../../components/ui/FormField.vue'
10
- import Input from '../../components/ui/Input.vue'
11
- import Button from '../../components/ui/Button.vue'
12
- import Alert from '../../components/ui/Alert.vue'
13
- import AlertDescription from '../../components/ui/AlertDescription.vue'
3
+ import Card from '@shared/components/ui/Card.vue'
4
+ import CardHeader from '@shared/components/ui/CardHeader.vue'
5
+ import CardTitle from '@shared/components/ui/CardTitle.vue'
6
+ import CardDescription from '@shared/components/ui/CardDescription.vue'
7
+ import CardContent from '@shared/components/ui/CardContent.vue'
8
+ import Form from '@shared/components/ui/Form.vue'
9
+ import FormField from '@shared/components/ui/FormField.vue'
10
+ import Input from '@shared/components/ui/Input.vue'
11
+ import Button from '@shared/components/ui/Button.vue'
12
+ import Alert from '@shared/components/ui/Alert.vue'
13
+ import AlertDescription from '@shared/components/ui/AlertDescription.vue'
14
14
 
15
15
  // pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
16
16
  const props = pageProps<'{{APP_NAME}}:registration#new'>()
@@ -104,7 +104,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
104
104
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
105
105
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
106
106
 
107
- ### 2.2 `gaon doctor` 검사 21
107
+ ### 2.2 `gaon doctor` 검사 22
108
108
 
109
109
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
110
110
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -127,6 +127,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
127
127
  19. `internal-anchor` — 앱 내부 경로 일반 `<a href="/...">`(풀 리로드로 SPA 파손 · `Link`/`router.visit` 를 쓰라 · 외부 URL·`target="_blank"` 는 제외) (결정 96 · 경고)
128
128
  20. `pageprops-destructure` — `const { x } = pageProps(…)` 구조분해(반응성 끊김 · 리다이렉트/리로드 후 갱신 안 됨 · `const props = pageProps(…)` 후 `props.x` 로 접근하라) (결정 99 · 경고)
129
129
  21. `async-offload` — 컨트롤러 액션 인라인의 무거운/외부 작업(메일 SDK·이미지 처리 sharp/jimp·외부 HTTP)이 응답을 지연 (`domain/jobs/` 잡 + `.later()` 로 빼라 · JSON/API 앱 외부 호출·빠른 내부 호출은 오탐 방지로 제외) (결정 102·103 · 경고)
130
+ 22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
130
131
 
131
132
  ## 3. 로직 배치 One Way 판단표
132
133
 
@@ -187,7 +188,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
187
188
  ```bash
188
189
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
189
190
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
190
- gaon doctor # 정적 검사 21종 (§2.2)
191
+ gaon doctor # 정적 검사 22종 (§2.2)
191
192
  ```
192
193
 
193
194
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -395,6 +395,33 @@ export const Post = model(posts, {
395
395
  })
396
396
  ```
397
397
 
398
+ ### 8.1 스키마 파생 폼 — `Model.form` · `Model.form.pick()` (결정 104)
399
+
400
+ 모든 모델은 `Model.form` 으로 **스키마 파생 폼**을 노출한다 — 컨트롤러의
401
+ `this.params(Model.form)` 에 넘기면 컬럼 타입으로 런타임 강제 변환·검증
402
+ (필수 강제 · 스키마 밖 키 제거 = 대량 할당 차단)까지 한다. 컨트롤러 쪽
403
+ 사용법(폼 모양 판단·라우트 파라미터 병합)은 `agents/web.md` §3 이 정본이다.
404
+
405
+ **일부 컬럼만 검증해서 받으려면 `pick()`** — 지정한 컬럼만 담은 **새 폼**을
406
+ 돌려준다(원 폼 불변). 컬럼 타입·검증·기본값 정보가 그대로 따라오므로,
407
+ "검증되는 부분 폼"이 필요할 때 애드혹 `{ _row: {} as T }`(검증 없음) 대신 쓴다.
408
+
409
+ ```ts
410
+ // routes: r.post('/posts/:postId/comments', 'comments#create')
411
+ // comments 스키마에서 postId·author·body 만 — :postId 는 라우트에서 자동 병합(결정 95).
412
+ async create() {
413
+ const data = this.params(Comment.form.pick('postId', 'author', 'body'))
414
+ // data: { postId: bigint; author: string; body: string } — 안 고른 컬럼은 요구하지 않는다.
415
+ const comment = await Comment.create(data)
416
+ return this.redirect(`/posts/${String(data.postId)}`)
417
+ }
418
+ ```
419
+
420
+ - `pick()` 결과도 폼이다 — 부분집합으로 다시 `pick()` 할 수 있다.
421
+ - 스키마에 없는 컬럼을 지정하면 즉시 throw(수리 안내 포함) · 빈
422
+ `pick()` 도 throw · 중복 지정은 조용히 하나로 합친다.
423
+ - `omit`·`extend`·`merge` 는 **없다** — 폼 변형은 `pick()` 하나가 The One Way.
424
+
398
425
  ### 9. 서비스 (`service()`) — 트랜잭션 작업 흐름 (정본 §5.3 · `packages/data/src/service.ts`)
399
426
 
400
427
  로직 배치의 One Way 규칙은 루트 `AGENTS.md` 판단표가 정본이다 (정본 §5.3):
@@ -601,6 +628,9 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
601
628
  doctor **model-filename** 이 잡는다(`--fix` 지원 · 결정 32·46).
602
629
  - **잡·이벤트 발행을 트랜잭션과 정합시키려면** `afterCommit()`(service 안)
603
630
  또는 아웃박스(`agents/async.md`) — 커밋 전 발행은 롤백 시 유령 부수효과.
631
+ - **폼 변형은 `pick()` 뿐** (결정 104) — `Model.form.omit/extend/merge` 는
632
+ 없다. 검증되는 부분 폼 = `pick()`, 스키마와 무관한 입력만 애드혹
633
+ `{ _row: {} as T }`(검증 없음 · `agents/web.md` §3).
604
634
 
605
635
  ## 관련 결정 번호
606
636
 
@@ -615,4 +645,5 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
615
645
  | 결정 43 | 네이밍 정본화 · DB 네이밍 SSOT(§1.2 · 테이블 snake · 컬럼 camel) |
616
646
  | 결정 46 | doctor 컬럼(column-casing)·모델/페이지 파일명 검사 3종 |
617
647
  | 결정 47 | `gaon g model` 다단어 테이블명 snake_case(마지막 단어 복수) |
648
+ | 결정 104 | 스키마 파생 폼에 name·defs 탑재(실검증) · `Model.form.pick()` 검증되는 부분 폼(§8.1) |
618
649
  | E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
@@ -149,6 +149,11 @@ shared/composables/useDebounce.ts # 앱 간 공용 컴포저블 (순수 로
149
149
  `gaon doctor` 의 **shared-composable-purity** 검사가 shared 안에서
150
150
  `gaonjs/vue` 의 `api`/`pageProps` import 를 잡는다.
151
151
 
152
+ **shared 컴포넌트(UI 킷 §8)의 허용/금지도 같은 기준(결정 25·105):** 허용 = 라우트 키와
153
+ 무관한 범용 API(`useForm`·`Link`·`router`) · 금지 = 앱 라우트 지식(`api()`·`pageProps`).
154
+ 그래서 킷의 `Form` 은 `useForm` 을 써도 되고, `Pagination` 은 라우트를 모른 채
155
+ `v-model:page` 로 현재 페이지만 올려보낸다.
156
+
152
157
  ### 5. 레이아웃 관례 (errata E-5 §2.3)
153
158
 
154
159
  ```
@@ -195,37 +200,55 @@ import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
195
200
  ·`Auth/Signup.vue` 를 내고 컨트롤러는 `this.render('Auth/Login')` 로 부른다.
196
201
  소문자 `auth/` 는 doctor page-filename 이 잡는다(결정 32·46).
197
202
 
198
- ### 8. UI 킷 (결정 74·75 · shadcn 참조 · 복사-소유)
203
+ ### 8. UI 킷 (결정 74·75 · 결정 105 shared 이전 · shadcn 참조 · 복사-소유)
199
204
 
200
- `gaon new` 기본 앱에 UI 킷을 심고(배터리 포함), `gaon g ui-kit [--app <앱>]`
201
- 다른 앱에도 추가한다. 컴포넌트는 **프로젝트에 복사된 여러분 코드**다 npm
202
- 의존이 아니라 파일이므로 자유롭게 고친다(shadcn 방식). 외부 런타임 의존이 없다
203
- (clsx·tailwind-merge·cva·reka-ui 미도입 · `cn` 은 자작 · Dialog/Sheet 는 Teleport+
204
- Transition 자작).
205
+ UI 킷은 프로젝트당 **한 벌**만 `shared/components/ui/` 둔다(결정 105 결정 75
206
+ "앱마다 복사" 개정). `gaon new` 심고 `gaon g ui-kit` 로 재보장한다(멱등). 컴포넌트는
207
+ **프로젝트에 복사된 여러분 코드**다 — npm 의존이 아니라 파일이므로 자유롭게 고친다
208
+ (shadcn 방식). 외부 런타임 의존이 없다(clsx·tailwind-merge·cva·reka-ui 미도입 · `cn` 은
209
+ 자작 · Dialog/Sheet 는 Teleport+Transition 자작).
205
210
 
206
- 배치·import:
211
+ 배치·import — 앱 페이지는 `@shared` alias 로 참조한다:
207
212
 
208
213
  ```
209
- apps/<앱>/lib/utils.ts # cn() — 조건부 클래스 병합
210
- apps/<앱>/components/ui/*.vue # Button·Input·Label·Badge·Card…·Alert…·Form…·Dialog·Sheet
214
+ shared/lib/utils.ts # cn() — 조건부 클래스 병합
215
+ shared/components/ui/*.vue # 원자 + 블록 (전 앱 공용 순수 UI)
211
216
  ```
212
217
 
213
218
  ```vue
214
219
  <script setup lang="ts">
215
- import Button from '../../components/ui/Button.vue' // 로컬 · 상대 경로
216
- import Card from '../../components/ui/Card.vue'
217
- import CardContent from '../../components/ui/CardContent.vue'
220
+ import Button from '@shared/components/ui/Button.vue' // @shared = 프로젝트 shared/
221
+ import Card from '@shared/components/ui/Card.vue'
222
+ import PageShell from '@shared/components/ui/PageShell.vue'
218
223
  </script>
219
224
 
220
225
  <template>
221
- <Card>
222
- <CardContent>
223
- <Button variant="default">저장</Button> <!-- variant·size 로 모양 선택 -->
224
- </CardContent>
225
- </Card>
226
+ <PageShell>
227
+ <Card><Button variant="default">저장</Button></Card> <!-- variant·size 로 모양 선택 -->
228
+ </PageShell>
226
229
  </template>
227
230
  ```
228
231
 
232
+ `@shared` 는 `vite.config.ts`(resolve.alias)·`tsconfig.json`(paths)에 배선돼 있다 —
233
+ `../../../shared/...` 같은 깊은 상대경로 대신 alias 하나로 통일한다(결정 105). 앱 전용
234
+ 컴포넌트(예 `apps/<앱>/components/PostCard.vue`)는 이 킷을 **조합·확장**해 만든다.
235
+
236
+ **킷 카탈로그 (원자 18 + 블록 4):**
237
+
238
+ | 구분 | 컴포넌트 |
239
+ |---|---|
240
+ | 원자 (18) | Button · Input · Label · Badge · Card · CardHeader · CardTitle · CardDescription · CardContent · CardFooter · Alert · AlertTitle · AlertDescription · Form · FormField · FormMessage · Dialog · Sheet |
241
+ | 블록 (4 · 결정 106) | PageShell · PageHeader · EmptyState · Pagination |
242
+
243
+ - **블록은 성격 중립(결정 106)** — 관리자/프론트를 나누지 않고 전 앱에서 쓴다.
244
+ `PageShell`(최대폭·여백·세로 리듬) · `PageHeader`(제목+설명+액션) · `EmptyState`
245
+ (빈 목록) · `Pagination`(페이지 이동 · `v-model:page`). 이외 블록(DataTable·StatCard·
246
+ Tabs 등)은 아직 만들지 않는다(예약 · 실물 도그푸딩 후).
247
+ - **반응형은 킷 책임(결정 107)** — 폭·여백·열 수 같은 레이아웃 반응형은 `PageShell`
248
+ 등 킷 블록이 소유한다. **페이지 코드에 레이아웃 브레이크포인트(`sm:flex-row`·
249
+ `md:grid-cols-2` 등)를 직접 쓰지 않는다** — 킷에 그 표현이 있으면 킷을 쓴다.
250
+ (탈출구: 킷에 없는 표현이면 Tailwind 유틸을 직접 써도 된다 — doctor
251
+ **page-layout-breakpoint** 는 강제가 아닌 **안내 경고**다.)
229
252
  - **폼은 UI 킷 Form + gaonjs `useForm`(결정 64)** — `Form` 은 얇은 `<form>` 래퍼로
230
253
  `@submit` 을 `useForm` 의 `post/put/delete` 로 넘긴다. vee-validate 를 끌어오지
231
254
  않는다(검증·상태는 `useForm`). `FormField label error` + `FormMessage` 로 라벨·
@@ -236,13 +259,23 @@ import CardContent from '../../components/ui/CardContent.vue'
236
259
  - **디자인 토큰은 `style.css` 한 곳(결정 74)** — 컴포넌트는 `bg-primary`·
237
260
  `text-muted-foreground` 같은 의미 토큰만 쓰고, 실색은 `apps/<앱>/style.css` 의
238
261
  `:root`/`.dark` CSS 변수에서 바꾼다(다크 모드 = `<html class="dark">`).
239
- - **멀티앱은 앱마다 배선이 따로다(결정 76)** — `gaon g app admin` 앱에
240
- 프론트 배선(`apps/admin/{style.css,main.ts,index.html}`) web 앱과 동등하게
241
- 함께 심는다. 그래서 `gaon g ui-kit --app admin` 으로 심은 UI 킷이 Tailwind
242
- 유틸을 그대로 받는다(`g ui-kit --app` 배선이 없으면 멱등으로 보정한다).
243
- `tailwind.config.ts`·`postcss.config.js` 프로젝트 루트 공유(앱마다 두지
244
- 않는다). 앱이 UI 킷을 import 하는데 그 앱에 `style.css` 배선이 없으면 doctor
245
- **ui-kit-wiring** 경고한다.
262
+ - **shared 킷의 허용/금지 API(결정 25·105)** — 킷은 `shared/` 라우트를 몰라야
263
+ 한다: 허용 = 라우트 키와 무관한 범용 API(`useForm`·`Link`·`router`) · 금지 =
264
+ 라우트 지식(`api()`·`pageProps`). 데이터는 props 받는다(예 `Pagination`
265
+ `v-model:page` 현재 페이지만 올려보내고 실제 이동은 페이지가 정한다).
266
+ - **멀티앱은 앱마다 Tailwind 배선이 따로다(결정 76)** — 킷은 shared 한 벌이지만,
267
+ 앱이 Tailwind 유틸을 받으려면 그 앱에 `style.css` 배선이 있어야 한다.
268
+ `gaon g app admin` 이 배선을 동봉하고, `gaon g ui-kit --app admin` 은 배선이
269
+ 없으면 멱등 보정한다(`--app` 은 이제 킷 위치가 아니라 배선만 정한다).
270
+ `tailwind.config.ts`·`postcss.config.js` 는 프로젝트 루트 공유이고 `content` 는
271
+ `apps/**` 와 `shared/**` 를 함께 훑는다. 앱이 킷을 import 하는데 배선이 없으면
272
+ doctor **ui-kit-wiring** 이 경고한다.
273
+
274
+ **기존 프로젝트 마이그레이션(결정 105 이전 → 이후):** 앱별 사본(`apps/<앱>/components/ui`
275
+ ·`apps/<앱>/lib/utils.ts`)이 있으면 `gaon g ui-kit` 를 다시 실행해 `shared/` 에 킷을
276
+ 만든 뒤, 앱 사본을 지우고 import 를 `@shared/components/ui/…` 로 바꾼다.
277
+ `tailwind.config.ts` 의 `content` 에 `./shared/**/*.{vue,ts}` 가 있는지도 확인한다
278
+ (스캐폴드 기본값엔 이미 포함).
246
279
 
247
280
  ## 정본 예시
248
281
 
@@ -297,7 +330,15 @@ async function runSearch(q: string) {
297
330
  `useChannel(name, opts)` 가 정본(결정 87). `new WebSocket` 을 손으로 짜면
298
331
  URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클을 재구현하다
299
332
  틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에.
300
- - **레이아웃을 shared 에 두지 않는다** — 앱별이 정상.
333
+ - **레이아웃을 shared 에 두지 않는다** — 앱별이 정상(UI 킷 §8 은 예외 — 성격
334
+ 중립 순수 UI 라 `shared/components/ui` 프로젝트당 한 벌 · 결정 105).
335
+ - **UI 킷은 `@shared/components/ui/…` 로 import** — `../../../shared/...` 같은 깊은
336
+ 상대경로 대신 `@shared` alias 로 통일한다(결정 105 · `vite.config.ts`·`tsconfig.json`
337
+ 배선). 킷을 `apps/<앱>/components/ui` 에 복제하지 않는다.
338
+ - **페이지에 레이아웃 브레이크포인트 직접 사용 지양** — 폭·여백·열 수 반응형은
339
+ 킷 블록(`PageShell` 등)이 책임진다(결정 107). `sm:flex-row`·`md:grid-cols-2` 를
340
+ 페이지 루트에 직접 쓰면 doctor **page-layout-breakpoint** 가 **안내 경고**를 낸다
341
+ (강제 아님 · 킷에 없는 표현이면 그대로 둬도 된다 — 탈출구 유지).
301
342
  - **페이지 파일명은 PascalCase** — `pages/Posts/Index.vue`(폴더 세그먼트도
302
343
  Route 이름). 소문자(`posts/index.vue`)는 doctor **page-filename** 이 잡는다
303
344
  (결정 32·46). rename 후 컨트롤러 `this.render('...')` 키도 맞춘다.
@@ -320,4 +361,7 @@ async function runSearch(q: string) {
320
361
  | 결정 75 | shadcn 식 UI 킷(`gaon g ui-kit` · 복사-소유 · Vue 3 신작 · 외부 런타임 의존 0) |
321
362
  | 결정 76 | 멀티앱 UI 킷 배선 자동화(`g app`·`g ui-kit --app` 이 앱별 Tailwind 배선 동봉·멱등 보정 · doctor ui-kit-wiring) |
322
363
  | 결정 96 | 앱 내부 이동 = `Link`(선언적)/`router.visit`(프로그램적) · 내부 경로 일반 `<a>` 금지(풀 리로드) · `Link` 재수출 · doctor internal-anchor |
364
+ | 결정 105 | UI 킷 shared 이전(`shared/components/ui` 프로젝트당 한 벌 · `@shared` alias · 결정 75 개정) |
365
+ | 결정 106 | 최소 4블록(PageShell·PageHeader·EmptyState·Pagination · 성격 중립) |
366
+ | 결정 107 | 반응형은 킷 책임(페이지 레이아웃 브레이크포인트 지양 · doctor page-layout-breakpoint 안내 경고 · 터치 44px·폰트 최소 크기 토큰) |
323
367
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
@@ -122,10 +122,21 @@ export default controller({
122
122
  }
123
123
  ```
124
124
 
125
- - **② 애드혹 `this.params({ _row: {} as { ... } })`** — 전용 모델이 없거나
126
- 받을 필드를 **정확히** 고를 때. 라우트 파라미터·폼 필드를 자유롭게 섞어 타입을
125
+ 스키마 컬럼 **일부만** 받으면서 검증을 유지하려면
126
+ `Model.form.pick('a', 'b')` 지정 컬럼만 검증·통과하는 폼을
127
+ 돌려준다(안 고른 필수 컬럼은 요구하지 않음 · 원 폼 불변 · 결정 104 ·
128
+ 정본 `agents/data.md` §8.1). 폼 변형은 `pick()` 하나뿐이다
129
+ (omit/extend/merge 없음):
130
+
131
+ ```ts
132
+ const data = this.params(Comment.form.pick('postId', 'author', 'body'))
133
+ ```
134
+
135
+ - **② 애드혹 폼 `this.params({ _row: {} as { ... } })`** — 전용 모델(스키마)이
136
+ 없는 입력일 때. 라우트 파라미터·폼 필드를 자유롭게 섞어 타입을
127
137
  못박는다. 단 **런타임 스키마 검증은 없다**(타입만 · 컬럼 정의가 없어 coerce
128
- 스킵) — 필요하면 값 검사를 직접 하거나 ①로 간다:
138
+ 스킵) — 필요하면 값 검사를 직접 하거나 ①로 간다. 스키마가 있는데 필드만
139
+ 좁히고 싶은 거라면 ②가 아니라 ①의 `pick()` 이다:
129
140
 
130
141
  ```ts
131
142
  // routes: r.post('/posts/:id/comments', 'comments#create')
@@ -333,4 +344,5 @@ export default controller({
333
344
  | 결정 37 | bigint PK 컨트롤러 `String()` 정규화 (`agents/frontend.md`) |
334
345
  | 결정 59 | 인증 배선 = `app.config.ts` 의 `session`+`auth(loadUser)` — 없으면 currentUser 영구 null |
335
346
  | 결정 95 (W4) | 폼 모양 2종 — 스키마 파생 `Model.form`(검증) vs 애드혹 `{ _row }`(타입만) · 라우트 파라미터는 둘 다 자동 병합 |
347
+ | 결정 104 | `Model.form.pick('a','b')` = 검증되는 부분 폼(결정 95 회부 종결) · 폼 변형은 pick 하나(omit/extend/merge 없음) |
336
348
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
@@ -2,8 +2,8 @@
2
2
  import { computed } from 'vue'
3
3
  import { pageProps } from 'gaonjs/vue'
4
4
  import { useGaonHealth, type HealthDoctor } from '../../composables/useGaonHealth.js'
5
- import Card from '../../components/ui/Card.vue'
6
- import Badge from '../../components/ui/Badge.vue'
5
+ import Card from '@shared/components/ui/Card.vue'
6
+ import Badge from '@shared/components/ui/Badge.vue'
7
7
 
8
8
  // home#index 의 render props — Serialized<> 로 넘어온다(§6.2).
9
9
  // 라우트 키는 .gaon/routes.d.ts 가 유효한 값을 알려준다.
@@ -230,7 +230,7 @@ const routeLines = computed<CodeLine[]>(() => {
230
230
 
231
231
  <p class="mt-8 text-center text-xs text-muted-foreground">
232
232
  <Badge variant="secondary">UI 킷</Badge>
233
- 이 화면·카드·다음 단계는 gaon g ui-kit 로 심은 컴포넌트로 그렸습니다 — apps/web/components/ui/ 에서 소유·수정하세요.
233
+ 이 화면·카드·다음 단계는 gaon g ui-kit 로 심은 컴포넌트로 그렸습니다 — shared/components/ui/ 에서 소유·수정하세요.
234
234
  </p>
235
235
  </div>
236
236
  </template>
@@ -6,53 +6,63 @@
6
6
  * :root/.dark 의 CSS 변수 = 디자인 토큰(shadcn 관례). UI 킷 컴포넌트는 이
7
7
  * 토큰(bg-primary·text-muted-foreground …)만 참조하므로, 브랜드 색을 바꾸려면
8
8
  * 여기 한 곳만 고친다. hsl 채널 값으로 두는 이유는 tailwind.config.ts 가
9
- * hsl(var(--token)) 로 감싸 투명도 유틸(bg-primary/50)까지 동작하게 하기 위함이다. */
9
+ * hsl(var(--token)) 로 감싸 투명도 유틸(bg-primary/50)까지 동작하게 하기 위함이다.
10
+ *
11
+ * 결정 107: 토큰(:root·.dark)은 @layer base **밖**에 둔다 — @layer 안의 클래스
12
+ * 선택자 규칙(.dark)은 content 에 'dark' 문자열이 없으면 Tailwind 가 purge 해
13
+ * 다크 모드가 조용히 안 됐다(:root 등 요소 선택자는 남지만 .dark 는 사라짐).
14
+ * 토큰은 CSS 변수만 정의하므로 layer 밖이어도 유틸 속성과 충돌하지 않는다. */
10
15
  @tailwind base;
11
16
  @tailwind components;
12
17
  @tailwind utilities;
13
18
 
14
- @layer base {
15
- :root {
16
- --background: 0 0% 100%;
17
- --foreground: 240 10% 3.9%;
18
- --card: 0 0% 100%;
19
- --card-foreground: 240 10% 3.9%;
20
- --primary: 240 5.9% 10%;
21
- --primary-foreground: 0 0% 98%;
22
- --secondary: 240 4.8% 95.9%;
23
- --secondary-foreground: 240 5.9% 10%;
24
- --muted: 240 4.8% 95.9%;
25
- --muted-foreground: 240 3.8% 46.1%;
26
- --accent: 240 4.8% 95.9%;
27
- --accent-foreground: 240 5.9% 10%;
28
- --destructive: 0 84.2% 60.2%;
29
- --destructive-foreground: 0 0% 98%;
30
- --border: 240 5.9% 90%;
31
- --input: 240 5.9% 90%;
32
- --ring: 240 5.9% 10%;
33
- --radius: 0.5rem;
34
- }
19
+ :root {
20
+ --background: 0 0% 100%;
21
+ --foreground: 240 10% 3.9%;
22
+ --card: 0 0% 100%;
23
+ --card-foreground: 240 10% 3.9%;
24
+ --primary: 240 5.9% 10%;
25
+ --primary-foreground: 0 0% 98%;
26
+ --secondary: 240 4.8% 95.9%;
27
+ --secondary-foreground: 240 5.9% 10%;
28
+ --muted: 240 4.8% 95.9%;
29
+ --muted-foreground: 240 3.8% 46.1%;
30
+ --accent: 240 4.8% 95.9%;
31
+ --accent-foreground: 240 5.9% 10%;
32
+ --destructive: 0 84.2% 60.2%;
33
+ --destructive-foreground: 0 0% 98%;
34
+ --border: 240 5.9% 90%;
35
+ --input: 240 5.9% 90%;
36
+ --ring: 240 5.9% 10%;
37
+ --radius: 0.5rem;
38
+ /* 모바일·반응형 토큰(결정 107). tap-target-min = 터치 최소 타깃(권장 44px) ·
39
+ font-size-min = 입력·본문 최소 글꼴(iOS 가 16px 미만 입력에 포커스하면
40
+ 화면을 자동 확대하는 것을 막는 값). 라이트/다크 공통이라 :root 한 곳에 둔다. */
41
+ --tap-target-min: 44px;
42
+ --font-size-min: 16px;
43
+ }
35
44
 
36
- .dark {
37
- --background: 240 10% 3.9%;
38
- --foreground: 0 0% 98%;
39
- --card: 240 10% 3.9%;
40
- --card-foreground: 0 0% 98%;
41
- --primary: 0 0% 98%;
42
- --primary-foreground: 240 5.9% 10%;
43
- --secondary: 240 3.7% 15.9%;
44
- --secondary-foreground: 0 0% 98%;
45
- --muted: 240 3.7% 15.9%;
46
- --muted-foreground: 240 5% 64.9%;
47
- --accent: 240 3.7% 15.9%;
48
- --accent-foreground: 0 0% 98%;
49
- --destructive: 0 62.8% 30.6%;
50
- --destructive-foreground: 0 0% 98%;
51
- --border: 240 3.7% 15.9%;
52
- --input: 240 3.7% 15.9%;
53
- --ring: 240 4.9% 83.9%;
54
- }
45
+ .dark {
46
+ --background: 240 10% 3.9%;
47
+ --foreground: 0 0% 98%;
48
+ --card: 240 10% 3.9%;
49
+ --card-foreground: 0 0% 98%;
50
+ --primary: 0 0% 98%;
51
+ --primary-foreground: 240 5.9% 10%;
52
+ --secondary: 240 3.7% 15.9%;
53
+ --secondary-foreground: 0 0% 98%;
54
+ --muted: 240 3.7% 15.9%;
55
+ --muted-foreground: 240 5% 64.9%;
56
+ --accent: 240 3.7% 15.9%;
57
+ --accent-foreground: 0 0% 98%;
58
+ --destructive: 0 62.8% 30.6%;
59
+ --destructive-foreground: 0 0% 98%;
60
+ --border: 240 3.7% 15.9%;
61
+ --input: 240 3.7% 15.9%;
62
+ --ring: 240 4.9% 83.9%;
63
+ }
55
64
 
65
+ @layer base {
56
66
  * {
57
67
  border-color: hsl(var(--border));
58
68
  }
@@ -63,4 +73,15 @@
63
73
  font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
64
74
  -webkit-font-smoothing: antialiased;
65
75
  }
76
+
77
+ /* 모바일(≤640px): 입력 글꼴이 16px 미만이면 iOS 가 포커스 시 화면을 확대한다.
78
+ UI 킷 Input 은 text-sm(14px)이라 좁은 화면에서만 최소 글꼴을 보장한다(결정 107).
79
+ 데스크톱은 원래 text-sm 을 그대로 둔다. */
80
+ @media (max-width: 640px) {
81
+ input,
82
+ textarea,
83
+ select {
84
+ font-size: var(--font-size-min);
85
+ }
86
+ }
66
87
  }
@@ -11,7 +11,11 @@
11
11
  "resolveJsonModule": true,
12
12
  "noEmit": true,
13
13
  "jsx": "preserve",
14
- "types": ["node", "vite/client"]
14
+ "types": ["node", "vite/client"],
15
+ "baseUrl": ".",
16
+ "paths": {
17
+ "@shared/*": ["./shared/*"]
18
+ }
15
19
  },
16
20
  "include": [
17
21
  "apps/**/*.ts",
@@ -7,13 +7,29 @@
7
7
  // 앱이 하나 이상이면 각 앱마다 vite.config.ts 를 두는 것이 아니라, 이 루트
8
8
  // 파일 하나가 root 를 apps/<앱> 으로 잡고 여러 번 실행된다(gaon dev 가 앱별
9
9
  // Vite 서버를 띄운다). 관례가 곧 배치.
10
+ import { fileURLToPath } from 'node:url'
10
11
  import { defineConfig } from 'vite'
11
12
  import vue from '@vitejs/plugin-vue'
12
13
 
14
+ // @shared → 프로젝트 루트의 shared/ (결정 105). UI 킷은 shared/components/ui 에
15
+ // 프로젝트당 한 벌만 있고, 앱 페이지는 @shared 로 참조한다. root 가 apps/web 이라
16
+ // shared 는 root 밖이므로, dev 서버가 읽을 수 있게 fs.allow 에 프로젝트 루트를 넣는다.
17
+ const projectRoot = fileURLToPath(new URL('.', import.meta.url))
18
+ const sharedDir = fileURLToPath(new URL('./shared', import.meta.url))
19
+
13
20
  export default defineConfig({
14
21
  // 기본 앱은 apps/web · gaon dev 가 다른 앱에 대해 root 를 재정의한다.
15
22
  root: 'apps/web',
16
23
  plugins: [vue()],
24
+ resolve: {
25
+ // 앱 페이지에서 shared UI 킷을 @shared 로 참조한다(결정 105).
26
+ // 예) import Button from '@shared/components/ui/Button.vue'
27
+ alias: { '@shared': sharedDir },
28
+ },
29
+ server: {
30
+ // root(apps/web) 밖의 shared 를 dev 서버가 읽도록 허용한다.
31
+ fs: { allow: [projectRoot] },
32
+ },
17
33
  build: {
18
34
  outDir: '../../dist/web',
19
35
  emptyOutDir: true,
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ // UI 킷 블록 · EmptyState (결정 106). 빈 목록·무결과 안내. 아이콘(슬롯)·제목·
3
+ // 설명·액션(슬롯)으로 조합한다. 성격 중립 — 관리자/프론트 구분 없이 전 앱에서 쓴다.
4
+ import { useSlots } from 'vue'
5
+
6
+ defineProps<{ title?: string; description?: string }>()
7
+ const slots = useSlots()
8
+ </script>
9
+
10
+ <template>
11
+ <div
12
+ class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 py-12 text-center"
13
+ >
14
+ <div v-if="slots.icon" class="text-muted-foreground"><slot name="icon" /></div>
15
+ <div class="space-y-1">
16
+ <p class="text-sm font-medium"><slot name="title">{{ title }}</slot></p>
17
+ <p v-if="description || slots.description" class="text-sm text-muted-foreground">
18
+ <slot name="description">{{ description }}</slot>
19
+ </p>
20
+ </div>
21
+ <div v-if="slots.action" class="mt-1"><slot name="action" /></div>
22
+ </div>
23
+ </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ // UI 킷 블록 · PageHeader (결정 106). 페이지 상단 — 제목 + 설명 + 액션(슬롯).
3
+ // 모바일에선 액션이 제목 아래로 줄바꿈되고 넓은 화면에선 오른쪽에 붙는다
4
+ // (반응형은 블록 책임 · 결정 107). 액션 슬롯이 없으면 액션 영역은 렌더되지 않는다.
5
+ import { useSlots } from 'vue'
6
+
7
+ defineProps<{ title?: string; description?: string }>()
8
+ const slots = useSlots()
9
+ </script>
10
+
11
+ <template>
12
+ <div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
13
+ <div class="min-w-0 space-y-1">
14
+ <h1 class="truncate text-2xl font-bold tracking-tight">
15
+ <slot name="title">{{ title }}</slot>
16
+ </h1>
17
+ <p v-if="description || slots.description" class="text-sm text-muted-foreground">
18
+ <slot name="description">{{ description }}</slot>
19
+ </p>
20
+ </div>
21
+ <div v-if="slots.actions" class="flex shrink-0 flex-wrap items-center gap-2">
22
+ <slot name="actions" />
23
+ </div>
24
+ </div>
25
+ </template>
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ // UI 킷 블록 · PageShell (결정 106). 페이지 바깥 골격 — 최대폭·좌우 여백·세로
3
+ // 리듬을 한 곳에서 소유한다. 반응형(폭·여백)은 이 블록이 책임지므로(결정 107)
4
+ // 페이지 코드는 레이아웃 브레이크포인트를 직접 쓰지 않는다. size 로 최대폭을 고른다.
5
+ import { computed } from 'vue'
6
+ import { cn } from '../../lib/utils.js'
7
+
8
+ type Size = 'default' | 'narrow' | 'wide' | 'full'
9
+
10
+ const props = withDefaults(defineProps<{ size?: Size }>(), { size: 'default' })
11
+
12
+ const MAX: Record<Size, string> = {
13
+ narrow: 'max-w-2xl',
14
+ default: 'max-w-4xl',
15
+ wide: 'max-w-6xl',
16
+ full: 'max-w-none',
17
+ }
18
+
19
+ // 좌우 여백·세로 리듬을 브레이크포인트로 한 곳에서 정한다 — 페이지는 이 값을 모른다.
20
+ const classes = computed(() =>
21
+ cn('mx-auto w-full px-4 py-6 sm:px-6 sm:py-8 lg:px-8 lg:py-10', MAX[props.size]),
22
+ )
23
+ </script>
24
+
25
+ <template>
26
+ <div :class="classes"><slot /></div>
27
+ </template>
@@ -0,0 +1,60 @@
1
+ <script setup lang="ts">
2
+ // UI 킷 블록 · Pagination (결정 106). 페이지 이동 컨트롤. 라우트를 모르는 순수 UI —
3
+ // 현재 페이지를 v-model(update:page)로 올려보내고, 실제 이동(Link·router)은 페이지가
4
+ // 정한다(shared 순수성 · 결정 25). 모바일은 압축형(이전/다음 + "n / m"), sm 이상에선
5
+ // 현재 주변 번호를 함께 보인다(반응형은 블록 책임 · 결정 107). 버튼은 모바일 44px·
6
+ // 데스크톱 40px 로 터치 타깃을 확보한다(결정 107).
7
+ import { computed } from 'vue'
8
+ import { cn } from '../../lib/utils.js'
9
+
10
+ const props = withDefaults(
11
+ defineProps<{ page: number; pageCount: number; siblings?: number }>(),
12
+ { siblings: 1 },
13
+ )
14
+ const emit = defineEmits<{ (e: 'update:page', page: number): void }>()
15
+
16
+ const total = computed(() => Math.max(1, props.pageCount))
17
+ const clamped = computed(() => Math.min(Math.max(1, props.page), total.value))
18
+ const canPrev = computed(() => clamped.value > 1)
19
+ const canNext = computed(() => clamped.value < total.value)
20
+
21
+ // 현재 주변 번호 창(sm 이상): [현재-siblings, 현재+siblings]를 1..total 로 자른다.
22
+ const windowPages = computed<number[]>(() => {
23
+ const from = Math.max(1, clamped.value - props.siblings)
24
+ const to = Math.min(total.value, clamped.value + props.siblings)
25
+ const out: number[] = []
26
+ for (let p = from; p <= to; p++) out.push(p)
27
+ return out
28
+ })
29
+
30
+ function go(p: number): void {
31
+ const next = Math.min(Math.max(1, p), total.value)
32
+ if (next !== clamped.value) emit('update:page', next)
33
+ }
34
+
35
+ const BTN =
36
+ 'inline-flex h-11 min-w-11 sm:h-10 sm:min-w-10 items-center justify-center rounded-md border ' +
37
+ 'px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground ' +
38
+ 'disabled:pointer-events-none disabled:opacity-50'
39
+ </script>
40
+
41
+ <template>
42
+ <nav class="flex items-center justify-between gap-2 sm:justify-center" aria-label="페이지 이동">
43
+ <button type="button" :class="BTN" :disabled="!canPrev" @click="go(clamped - 1)">이전</button>
44
+
45
+ <span class="text-sm text-muted-foreground sm:hidden">{{ clamped }} / {{ total }}</span>
46
+
47
+ <span class="hidden items-center gap-1 sm:inline-flex">
48
+ <button
49
+ v-for="p in windowPages"
50
+ :key="p"
51
+ type="button"
52
+ :class="cn(BTN, p === clamped && 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground')"
53
+ :aria-current="p === clamped ? 'page' : undefined"
54
+ @click="go(p)"
55
+ >{{ p }}</button>
56
+ </span>
57
+
58
+ <button type="button" :class="BTN" :disabled="!canNext" @click="go(clamped + 1)">다음</button>
59
+ </nav>
60
+ </template>
@@ -1,4 +1,4 @@
1
- // apps/<app>/lib/utils.ts — UI 킷 공용 헬퍼 (결정 75 · shadcn 참조 · 복사-소유).
1
+ // shared/lib/utils.ts — UI 킷 공용 헬퍼 (결정 75 · 결정 105 · shadcn 참조 · 복사-소유).
2
2
  //
3
3
  // UI 킷은 외부 런타임 의존 없이 자기완결이다(gaon g ui-kit 로 프로젝트에
4
4
  // 복사된 뒤엔 이 파일도 여러분 코드다 — 자유롭게 고친다).
package/dist/uikit.d.ts CHANGED
@@ -11,10 +11,10 @@ export interface UiKitScaffoldResult {
11
11
  readonly created: string[];
12
12
  readonly skipped: string[];
13
13
  }
14
- /** UI 킷 전체가 생성하는 파일 목록. */
15
- export declare function uiKitScaffoldFiles(opts?: UiKitScaffoldOptions): UiKitScaffoldFile[];
16
- /** `gaon g auth` 가 필요로 하는 UI 킷 최소 세트 파일 목록. */
17
- export declare function authUiKitFiles(app?: string): UiKitScaffoldFile[];
14
+ /** UI 킷 전체가 생성하는 파일 목록(shared/ · 프로젝트당 한 벌). */
15
+ export declare function uiKitScaffoldFiles(_opts?: UiKitScaffoldOptions): UiKitScaffoldFile[];
16
+ /** `gaon g auth` 가 필요로 하는 UI 킷 최소 세트 파일 목록(shared/). */
17
+ export declare function authUiKitFiles(_app?: string): UiKitScaffoldFile[];
18
18
  /** 파일 목록을 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip(멱등 · 복사-소유). */
19
19
  export declare function writeUiKitFiles(cwd: string, files: readonly UiKitScaffoldFile[]): UiKitScaffoldResult;
20
20
  /** `gaon g ui-kit` — UI 킷 전체를 쓴다. */
package/dist/uikit.js CHANGED
@@ -1,20 +1,25 @@
1
1
  /**
2
- * @gaonjs/cli · `gaon g ui-kit` — shadcn 식 UI 킷 제너레이터 (결정 75)
2
+ * @gaonjs/cli · `gaon g ui-kit` — shadcn 식 UI 킷 제너레이터 (결정 75 · 결정 105 개정)
3
3
  *
4
4
  * shadcn 을 참조해 Vue 3 로 새로 쓴 UI 컴포넌트를 프로젝트에 **복사**한다
5
5
  * (복사-소유 — 생성된 뒤엔 사용자 코드다). npm 의존이 아니라 파일로 심으므로
6
6
  * 자유롭게 고칠 수 있고, 외부 런타임 의존이 전혀 없다(cn 은 자작 · reka-ui·
7
7
  * clsx·cva 미도입 · 결정 75). Tailwind(결정 74)를 전제로 한다.
8
8
  *
9
- * 배치:
10
- * apps/<app>/lib/utils.ts — cn() 헬퍼
11
- * apps/<app>/components/ui/*.vue — Button·Input·Card·Form·Dialog … (앱 전용 순수 UI)
9
+ * 배치(결정 105 — 결정 75 의 "앱마다 복사" 개정):
10
+ * shared/lib/utils.ts — cn() 헬퍼 (프로젝트당 한 벌)
11
+ * shared/components/ui/*.vue — Button·Input·Card·Form·Dialog … (공용 순수 UI)
12
12
  *
13
- * 전용에 두는 이유: 컴포넌트에서 lib/utils 가는 import 얕게 유지해
14
- * (`../../lib/utils.js`) AI·사람 모두 실수 없이 참조하게 하고, `gaon g auth`
15
- * 처럼 단위(--app)생성한다. 여러 앱이 각자 소유한다(복사-소유 관례).
13
+ * shared 두는 이유(결정 105): UI 킷은 성격 중립 순수 UI 라 앱마다 복제하지
14
+ * 않고 프로젝트당 벌만 둔다 컴포넌트는 킷을 조합·확장한다. 앱 페이지는
15
+ * `@shared/components/ui/…` alias참조한다(vite.config.ts·tsconfig.json 배선).
16
+ * 킷 내부 import(cn·하위 컴포넌트)는 상대 경로(`../../lib/utils.js`·`./Label.vue`)라
17
+ * 위치가 apps/<app> 에서 shared 로 옮겨져도 그대로 유효하다.
18
+ *
19
+ * --app 은 이제 킷 위치가 아니라 대상 앱의 Tailwind 배선(style.css·main.ts)을
20
+ * 멱등 보정하는 데만 쓰인다 — 킷은 앱과 무관하게 shared 로 간다.
16
21
  */
17
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
18
23
  import { dirname, join, resolve } from 'node:path';
19
24
  import { fileURLToPath } from 'node:url';
20
25
  import { appWiringFiles, readProjectName } from './scaffold/app-wiring.js';
@@ -39,6 +44,11 @@ const COMPONENTS = [
39
44
  'FormMessage',
40
45
  'Dialog',
41
46
  'Sheet',
47
+ // 블록(결정 106) — 성격 중립 페이지 조각. 원자를 조합해 페이지 골격을 만든다.
48
+ 'PageShell',
49
+ 'PageHeader',
50
+ 'EmptyState',
51
+ 'Pagination',
42
52
  ];
43
53
  /** `gaon g auth` 가 스캐폴드하는 로그인·가입·대시보드 페이지가 쓰는 최소 세트.
44
54
  * auth 생성 시 이 컴포넌트가 없으면 페이지가 컴파일되지 않으므로 함께 보장한다. */
@@ -62,26 +72,26 @@ const AUTH_SUBSET = [
62
72
  function read(name) {
63
73
  return readFileSync(join(TEMPLATE_DIR, name), 'utf8');
64
74
  }
65
- /** 컴포넌트명 목록 → 생성 파일 목록(utils 포함). */
66
- function filesFor(components, app) {
75
+ /** 컴포넌트명 목록 → 생성 파일 목록(utils 포함). 위치는 shared/ 고정(결정 105). */
76
+ function filesFor(components) {
67
77
  const files = [
68
- { path: `apps/${app}/lib/utils.ts`, contents: read('utils.ts.tpl') },
78
+ { path: `shared/lib/utils.ts`, contents: read('utils.ts.tpl') },
69
79
  ];
70
80
  for (const name of components) {
71
81
  files.push({
72
- path: `apps/${app}/components/ui/${name}.vue`,
82
+ path: `shared/components/ui/${name}.vue`,
73
83
  contents: read(`${name}.vue.tpl`),
74
84
  });
75
85
  }
76
86
  return files;
77
87
  }
78
- /** UI 킷 전체가 생성하는 파일 목록. */
79
- export function uiKitScaffoldFiles(opts = {}) {
80
- return filesFor(COMPONENTS, opts.app ?? 'web');
88
+ /** UI 킷 전체가 생성하는 파일 목록(shared/ · 프로젝트당 한 벌). */
89
+ export function uiKitScaffoldFiles(_opts = {}) {
90
+ return filesFor(COMPONENTS);
81
91
  }
82
- /** `gaon g auth` 가 필요로 하는 UI 킷 최소 세트 파일 목록. */
83
- export function authUiKitFiles(app = 'web') {
84
- return filesFor(AUTH_SUBSET, app);
92
+ /** `gaon g auth` 가 필요로 하는 UI 킷 최소 세트 파일 목록(shared/). */
93
+ export function authUiKitFiles(_app = 'web') {
94
+ return filesFor(AUTH_SUBSET);
85
95
  }
86
96
  /** 파일 목록을 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip(멱등 · 복사-소유). */
87
97
  export function writeUiKitFiles(cwd, files) {
@@ -118,11 +128,12 @@ export function runGenerateUiKitCommand(opts = {}) {
118
128
  skipped: [...wiring.skipped, ...kit.skipped].sort(),
119
129
  };
120
130
  if (opts.json) {
121
- process.stdout.write(JSON.stringify({ command: 'g ui-kit', app, ...result }, null, 2) + '\n');
131
+ const legacy = legacyUiKitApps(cwd);
132
+ process.stdout.write(JSON.stringify({ command: 'g ui-kit', app, ...result, legacyUiKitApps: legacy }, null, 2) + '\n');
122
133
  return 0;
123
134
  }
124
135
  const lines = [''];
125
- lines.push(` gaon g ui-kit — UI 킷 (${app} · shadcn 참조 · 복사-소유)`);
136
+ lines.push(` gaon g ui-kit — UI 킷 (shared/ · shadcn 참조 · 복사-소유)`);
126
137
  lines.push('');
127
138
  for (const f of result.created)
128
139
  lines.push(` + ${f}`);
@@ -130,9 +141,32 @@ export function runGenerateUiKitCommand(opts = {}) {
130
141
  lines.push(` · ${f} (이미 있음 — 건너뜀)`);
131
142
  lines.push('');
132
143
  lines.push(' Tailwind 배선(tailwind.config.ts·postcss.config.js·apps/' + app + '/style.css)이 함께 보정됩니다.');
133
- lines.push(' 컴포넌트는 이제 여러분 코드입니다 — apps/' + app + '/components/ui/ 에서 자유롭게 고치세요.');
134
- lines.push(' 예) import Button from \'../../components/ui/Button.vue\'');
144
+ lines.push(' 컴포넌트는 이제 여러분 코드입니다 — shared/components/ui/ 에서 자유롭게 고치세요.');
145
+ lines.push(' 예) import Button from \'@shared/components/ui/Button.vue\'');
146
+ const legacy = legacyUiKitApps(cwd);
147
+ if (legacy.length > 0) {
148
+ lines.push('');
149
+ lines.push(' ⚠ 구버전 UI 킷 사본이 남아 있습니다(결정 105 이전 · 앱별 배치):');
150
+ for (const app of legacy)
151
+ lines.push(` apps/${app}/components/ui/ · apps/${app}/lib/utils.ts`);
152
+ lines.push(' → shared/ 로 옮겼으니 앱 사본을 지우고 import 를 @shared 로 바꾸세요:');
153
+ lines.push(" import X from '../../components/ui/X.vue' → import X from '@shared/components/ui/X.vue'");
154
+ }
135
155
  lines.push('');
136
156
  process.stdout.write(lines.join('\n') + '\n');
137
157
  return 0;
138
158
  }
159
+ /** 결정 105 이전 배치(apps/<app>/components/ui)를 아직 가진 앱 목록 — 마이그레이션 안내용. */
160
+ function legacyUiKitApps(cwd) {
161
+ const appsDir = join(cwd, 'apps');
162
+ let apps;
163
+ try {
164
+ apps = readdirSync(appsDir, { withFileTypes: true })
165
+ .filter((e) => e.isDirectory())
166
+ .map((e) => e.name);
167
+ }
168
+ catch {
169
+ return [];
170
+ }
171
+ return apps.filter((app) => existsSync(join(appsDir, app, 'components', 'ui'))).sort();
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,12 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.6.1",
31
- "@gaonjs/web": "0.7.2",
32
- "@gaonjs/config": "0.5.3",
30
+ "@gaonjs/data": "0.10.0",
31
+ "@gaonjs/config": "0.5.4",
33
32
  "@gaonjs/core": "0.2.1",
34
33
  "@gaonjs/mail": "0.1.3",
35
- "@gaonjs/data": "0.9.2"
34
+ "@gaonjs/web": "0.7.2",
35
+ "@gaonjs/async": "0.6.1"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""