@gaonjs/cli 0.14.0 → 0.18.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 (57) hide show
  1. package/dist/commands/dev.js +3 -1
  2. package/dist/commands/g.js +8 -0
  3. package/dist/commands/new.js +5 -0
  4. package/dist/dev/health.d.ts +75 -0
  5. package/dist/dev/health.js +156 -0
  6. package/dist/doctor/types.d.ts +1 -1
  7. package/dist/doctor/types.js +3 -3
  8. package/dist/doctor/ui-kit-wiring.d.ts +5 -0
  9. package/dist/doctor/ui-kit-wiring.js +93 -0
  10. package/dist/doctor.d.ts +13 -0
  11. package/dist/doctor.js +57 -26
  12. package/dist/generate.js +13 -2
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.js +20 -6
  15. package/dist/scaffold/app-wiring.d.ts +12 -0
  16. package/dist/scaffold/app-wiring.js +68 -0
  17. package/dist/serve.d.ts +6 -0
  18. package/dist/serve.js +9 -0
  19. package/dist/templates/auth/Dashboard.vue.tpl +21 -5
  20. package/dist/templates/auth/Login.vue.tpl +37 -8
  21. package/dist/templates/auth/Signup.vue.tpl +40 -9
  22. package/dist/templates/auth/registration.controller.ts.tpl +1 -1
  23. package/dist/templates/auth/session.controller.ts.tpl +2 -2
  24. package/dist/templates/project/AGENTS.md.tpl +3 -2
  25. package/dist/templates/project/CLAUDE.md.tpl +3 -2
  26. package/dist/templates/project/agents/async.md.tpl +15 -7
  27. package/dist/templates/project/agents/frontend.md.tpl +79 -1
  28. package/dist/templates/project/apps/web/composables/useGaonHealth.ts.tpl +81 -0
  29. package/dist/templates/project/apps/web/layouts/Default.vue.tpl +30 -29
  30. package/dist/templates/project/apps/web/main.ts.tpl +4 -0
  31. package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +225 -25
  32. package/dist/templates/project/apps/web/style.css.tpl +66 -0
  33. package/dist/templates/project/package.json.tpl +3 -0
  34. package/dist/templates/project/postcss.config.js.tpl +15 -0
  35. package/dist/templates/project/tailwind.config.ts.tpl +56 -0
  36. package/dist/templates/ui-kit/Alert.vue.tpl +23 -0
  37. package/dist/templates/ui-kit/AlertDescription.vue.tpl +9 -0
  38. package/dist/templates/ui-kit/AlertTitle.vue.tpl +9 -0
  39. package/dist/templates/ui-kit/Badge.vue.tpl +25 -0
  40. package/dist/templates/ui-kit/Button.vue.tpl +39 -0
  41. package/dist/templates/ui-kit/Card.vue.tpl +10 -0
  42. package/dist/templates/ui-kit/CardContent.vue.tpl +9 -0
  43. package/dist/templates/ui-kit/CardDescription.vue.tpl +9 -0
  44. package/dist/templates/ui-kit/CardFooter.vue.tpl +9 -0
  45. package/dist/templates/ui-kit/CardHeader.vue.tpl +9 -0
  46. package/dist/templates/ui-kit/CardTitle.vue.tpl +9 -0
  47. package/dist/templates/ui-kit/Dialog.vue.tpl +68 -0
  48. package/dist/templates/ui-kit/Form.vue.tpl +13 -0
  49. package/dist/templates/ui-kit/FormField.vue.tpl +16 -0
  50. package/dist/templates/ui-kit/FormMessage.vue.tpl +9 -0
  51. package/dist/templates/ui-kit/Input.vue.tpl +22 -0
  52. package/dist/templates/ui-kit/Label.vue.tpl +9 -0
  53. package/dist/templates/ui-kit/Sheet.vue.tpl +73 -0
  54. package/dist/templates/ui-kit/utils.ts.tpl +26 -0
  55. package/dist/uikit.d.ts +28 -0
  56. package/dist/uikit.js +138 -0
  57. package/package.json +6 -6
@@ -0,0 +1,81 @@
1
+ // useGaonHealth — 앱 전용 컴포저블(errata E-5 §2.1).
2
+ // gaon dev 가 띄운 서버의 dev 전용 진단 엔드포인트(/_gaon/health)를 읽어
3
+ // 랜딩(Home/Index.vue)의 라이브 상태 카드에 바인딩한다.
4
+ //
5
+ // dev-only: 이 엔드포인트는 gaon dev(= gaon serve --dev)에서만 등록된다.
6
+ // 운영 빌드에는 없다 — 404 면 available=false 로 우아하게 degrade하고(카드가
7
+ // "개발 서버에서만" 로 표시), 에러를 던지지 않는다.
8
+ import { ref, onMounted } from 'vue'
9
+
10
+ // 서버 응답(GaonHealth) 의 앱 쪽 미러. 서버 타입을 import 하지 않고(앱은 프레임웍
11
+ // 내부 타입에 의존하지 않는다) 랜딩이 쓰는 필드만 선언한다.
12
+ export interface HealthWeb {
13
+ host: string
14
+ port: number
15
+ node: string
16
+ gaonjs: string
17
+ apps: { name: string; prefix: string }[]
18
+ }
19
+ export interface HealthDatabase {
20
+ configured: boolean
21
+ connected?: boolean
22
+ adapter?: string
23
+ tables?: number
24
+ tableNames?: string[]
25
+ migrations?: number
26
+ error?: string
27
+ }
28
+ export interface HealthHub {
29
+ configured: boolean
30
+ connected?: boolean
31
+ // jobs: JetStream 스트림 대기 수 · channels: 채널별 실 접속자 수(결정 71).
32
+ streams?: { stream: string; waiting: number | null }[]
33
+ channels?: { channel: string; members: number }[] | null
34
+ error?: string
35
+ }
36
+ export interface HealthDoctor {
37
+ checks: number
38
+ passed: number
39
+ warnings: number
40
+ errors: number
41
+ level: 'pass' | 'warn' | 'error'
42
+ fatal?: string
43
+ }
44
+ export interface GaonHealth {
45
+ ok: boolean
46
+ env: string
47
+ web: HealthWeb
48
+ database: HealthDatabase
49
+ hub: HealthHub
50
+ doctor: HealthDoctor
51
+ routes: { path: string; source: string } | null
52
+ }
53
+
54
+ export function useGaonHealth() {
55
+ const health = ref<GaonHealth | null>(null)
56
+ const loading = ref(true)
57
+ // 엔드포인트가 있는가(= dev 서버인가). 운영 빌드에선 false 로 남는다.
58
+ const available = ref(false)
59
+
60
+ async function load(): Promise<void> {
61
+ loading.value = true
62
+ try {
63
+ const res = await fetch('/_gaon/health', { headers: { Accept: 'application/json' } })
64
+ if (!res.ok) {
65
+ available.value = false
66
+ return
67
+ }
68
+ health.value = (await res.json()) as GaonHealth
69
+ available.value = true
70
+ } catch {
71
+ // 네트워크·파싱 실패 = 진단 불가. 랜딩은 정적 안내로 degrade.
72
+ available.value = false
73
+ } finally {
74
+ loading.value = false
75
+ }
76
+ }
77
+
78
+ onMounted(load)
79
+
80
+ return { health, loading, available, reload: load }
81
+ }
@@ -5,39 +5,40 @@
5
5
  //
6
6
  // 레이아웃은 shared 에 두지 않는다(E-5 §2.3) — 앱마다 레이아웃이 다른 것이
7
7
  // 정상이고, 공용 조각(로고·푸터 등) 만 shared/components 로 뽑는다.
8
+ //
9
+ // 다크 헤더 + 라이트 본문(결정 69). 스타일은 Tailwind 유틸(결정 74) — 다크
10
+ // 헤더는 본문 테마와 무관하게 항상 어둡게 두려고 브랜드 색을 명시값으로 박는다.
11
+ // 버전은 스캐폴드 시점 gaonjs 버전이 박힌다.
12
+
13
+ // package.json 의 gaonjs 의존 범위(예: ^0.9.2)에서 캐럿·틸드를 벗겨 표기.
14
+ const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
8
15
  </script>
9
16
 
10
17
  <template>
11
- <div class="layout">
12
- <header class="layout-header">
13
- <strong>{{PROJECT_NAME}}</strong>
18
+ <div class="flex min-h-screen flex-col bg-background text-foreground">
19
+ <header
20
+ class="flex items-center justify-between gap-4 border-b border-[#21262d] bg-[#0d1117] px-6 py-3.5 text-[#e6edf3]"
21
+ >
22
+ <a href="/" class="inline-flex items-baseline gap-2.5 text-inherit no-underline">
23
+ <span
24
+ class="inline-block rounded-md bg-gradient-to-br from-[#4f8cff] to-[#7c5cff] px-1.5 py-0.5 text-xs font-bold tracking-wide text-white"
25
+ >가온</span>
26
+ <span class="text-[0.95rem] font-bold tracking-[0.08em]">GAONJS</span>
27
+ <span class="text-xs tabular-nums text-[#8b949e]">v{{ version }}</span>
28
+ </a>
29
+ <nav class="flex gap-[1.1rem] text-sm">
30
+ <a href="https://gaonjs.dev" target="_blank" rel="noreferrer" class="text-[#c9d1d9] no-underline hover:text-white">문서</a>
31
+ <a href="https://github.com/gaonjs" target="_blank" rel="noreferrer" class="text-[#c9d1d9] no-underline hover:text-white">GitHub</a>
32
+ </nav>
14
33
  </header>
15
- <slot />
16
- <footer class="layout-footer">
17
- <small>Powered by <a href="https://gaonjs.dev" target="_blank">Gaon</a></small>
34
+
35
+ <main class="w-full flex-1">
36
+ <slot />
37
+ </main>
38
+
39
+ <footer class="border-t border-border px-6 py-5 text-center text-muted-foreground">
40
+ <small>{{PROJECT_NAME}} · Powered by
41
+ <a href="https://gaonjs.dev" target="_blank" rel="noreferrer" class="text-[#4f8cff] no-underline">Gaon</a></small>
18
42
  </footer>
19
43
  </div>
20
44
  </template>
21
-
22
- <style scoped>
23
- .layout {
24
- min-height: 100vh;
25
- display: flex;
26
- flex-direction: column;
27
- }
28
- .layout-header,
29
- .layout-footer {
30
- padding: 1rem;
31
- background: #f6f8fa;
32
- border-color: #e1e4e8;
33
- }
34
- .layout-header {
35
- border-bottom: 1px solid #e1e4e8;
36
- }
37
- .layout-footer {
38
- border-top: 1px solid #e1e4e8;
39
- margin-top: auto;
40
- text-align: center;
41
- color: #6a737d;
42
- }
43
- </style>
@@ -9,6 +9,10 @@
9
9
  // import.meta.glob 으로 만든다. 심볼 출처가 코드에 그대로 보인다.
10
10
  import { createGaonApp } from 'gaonjs/vue'
11
11
 
12
+ // 전역 스타일 — Tailwind 레이어 + 디자인 토큰(결정 74). 부수효과 import 라
13
+ // 번들에 CSS 가 실린다. 앱마다 하나(관례 = 배치).
14
+ import './style.css'
15
+
12
16
  // 페이지는 지연 로드(코드 스플리팅) — 큰 앱에서도 첫 페이지 로딩이 빠르다.
13
17
  // eager 로 바꿔도 되지만, One Way 의 기본은 지연 로드다.
14
18
  const pages = import.meta.glob('./pages/**/*.vue')
@@ -1,36 +1,236 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue'
2
3
  import { pageProps } from 'gaonjs/vue'
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'
3
7
 
4
8
  // home#index 의 render props — Serialized<> 로 넘어온다(§6.2).
5
9
  // 라우트 키는 .gaon/routes.d.ts 가 유효한 값을 알려준다.
6
10
  const props = pageProps<'web:home#index'>()
11
+
12
+ // 라이브 상태(dev 전용 /_gaon/health). 운영 빌드에선 available=false 로 degrade.
13
+ const { health, loading, available } = useGaonHealth()
14
+
15
+ type CardStatus = 'ok' | 'warn' | 'error' | 'idle'
16
+ interface StatusCard {
17
+ readonly key: string
18
+ readonly title: string
19
+ readonly status: CardStatus
20
+ readonly headline: string
21
+ readonly lines: readonly string[]
22
+ }
23
+
24
+ function doctorNote(d: HealthDoctor | undefined): string {
25
+ if (!d) return '—'
26
+ if (d.fatal) return `검사 불가 (${d.fatal})`
27
+ return `${d.passed} 통과 · ${d.warnings} 경고 · ${d.errors} 오류`
28
+ }
29
+
30
+ const cards = computed<StatusCard[]>(() => {
31
+ const h = health.value
32
+ const web = h?.web
33
+ const db = h?.database
34
+ const hub = h?.hub
35
+ const doc = h?.doctor
36
+
37
+ const webCard: StatusCard = {
38
+ key: 'web',
39
+ title: 'WEB',
40
+ status: available.value ? 'ok' : 'idle',
41
+ headline: web ? `:${web.port}` : '대기',
42
+ lines: web
43
+ ? [`Node ${web.node}`, `gaonjs v${web.gaonjs}`, `앱 ${web.apps.map((a) => a.name).join(', ')}`]
44
+ : ['개발 서버에서만'],
45
+ }
46
+
47
+ const dbCard: StatusCard = {
48
+ key: 'database',
49
+ title: 'DATABASE',
50
+ status: !db || !db.configured ? 'idle' : db.connected ? 'ok' : 'error',
51
+ headline: !db || !db.configured ? '미설정' : db.connected ? '연결됨' : '미연결',
52
+ lines:
53
+ db && db.configured
54
+ ? db.connected
55
+ ? [`${db.adapter ?? 'postgres'}`, `테이블 ${db.tables ?? 0}개`, `마이그레이션 ${db.migrations ?? 0}건`]
56
+ : [db.error ?? '연결 실패', '.env DATABASE_URL 확인']
57
+ : ['.env 에 DATABASE_URL', 'gaon.config.ts 에서 켜기'],
58
+ }
59
+
60
+ const hubCard: StatusCard = {
61
+ key: 'hub',
62
+ title: 'HUB',
63
+ status: !hub || !hub.configured ? 'idle' : hub.connected ? 'ok' : 'error',
64
+ headline: !hub || !hub.configured ? '미설정' : hub.connected ? '연결됨' : '미연결',
65
+ lines:
66
+ hub && hub.configured
67
+ ? hub.connected
68
+ ? [
69
+ // jobs = 스트림 대기 수 · channels = 프레즌스 실 접속자 수(결정 71).
70
+ ...(hub.streams ?? []).map((s) => `${s.stream}: ${s.waiting === null ? '대기 없음' : `${s.waiting} 대기`}`),
71
+ `채널 접속 ${(hub.channels ?? []).reduce((n, c) => n + c.members, 0)}명`,
72
+ ]
73
+ : [hub.error ?? 'NATS 미연결']
74
+ : ['.env 에 NATS_URL', '실시간·비동기 백본'],
75
+ }
76
+
77
+ const doctorCard: StatusCard = {
78
+ key: 'doctor',
79
+ title: 'DOCTOR',
80
+ status: !doc ? 'idle' : doc.level === 'pass' ? 'ok' : doc.level === 'warn' ? 'warn' : 'error',
81
+ headline: !doc
82
+ ? '대기'
83
+ : doc.fatal
84
+ ? '검사 불가'
85
+ : doc.errors > 0
86
+ ? `오류 ${doc.errors}`
87
+ : doc.warnings > 0
88
+ ? `경고 ${doc.warnings}`
89
+ : '통과',
90
+ lines: [doctorNote(doc), 'gaon check 로 재실행'],
91
+ }
92
+
93
+ return [webCard, dbCard, hubCard, doctorCard]
94
+ })
95
+
96
+ // 상태 → 점 색(Tailwind). idle 은 은은하게, 나머지는 신호색.
97
+ const DOT: Record<CardStatus, string> = {
98
+ ok: 'bg-green-500',
99
+ warn: 'bg-yellow-500',
100
+ error: 'bg-red-500',
101
+ idle: 'bg-muted-foreground/40',
102
+ }
103
+
104
+ // 다음 단계 — 라이브 상태에서 완료 여부를 파생한다.
105
+ interface Step {
106
+ readonly label: string
107
+ readonly cmd: string
108
+ readonly done: boolean
109
+ readonly note: string
110
+ }
111
+ const steps = computed<Step[]>(() => {
112
+ const h = health.value
113
+ const db = h?.database
114
+ const configured = !!(db && db.configured)
115
+ const connected = !!(db && db.configured && db.connected)
116
+ const tables = configured ? db!.tables ?? 0 : 0
117
+ return [
118
+ {
119
+ label: '개발 스택 부팅',
120
+ cmd: 'gaon dev',
121
+ done: available.value,
122
+ note: 'Docker · .gaon 타입 브리지 · 서버 · watch',
123
+ },
124
+ {
125
+ label: '데이터베이스 연결',
126
+ cmd: configured ? 'gaon db migrate' : '.env: DATABASE_URL',
127
+ done: connected,
128
+ note: configured ? (connected ? '연결됨' : '설정됨 · 미연결') : '미설정',
129
+ },
130
+ {
131
+ label: '첫 모델 만들기',
132
+ cmd: 'gaon g model Post',
133
+ done: tables > 0,
134
+ note: tables > 0 ? `테이블 ${tables}개` : '스키마 + 모델 (E-4)',
135
+ },
136
+ {
137
+ label: '인증 배터리',
138
+ cmd: 'gaon g auth',
139
+ done: false,
140
+ note: '가입 · 로그인 · 세션 (수동 통합 · 결정 70)',
141
+ },
142
+ {
143
+ label: '검사 통과',
144
+ cmd: 'gaon check',
145
+ done: h?.doctor.level === 'pass',
146
+ note: doctorNote(h?.doctor),
147
+ },
148
+ ]
149
+ })
150
+
151
+ // routes.ts 코드 블록 — 라이브 원문을 줄 단위로 나누고 주석만 가볍게 색을 준다.
152
+ // v-html 없이(텍스트 보간만) 안전하게 그린다. 의존성 없는 최소 하이라이트.
153
+ interface CodeLine {
154
+ readonly code: string
155
+ readonly comment: string
156
+ }
157
+ function splitComment(line: string): CodeLine {
158
+ const m = line.match(/\s\/\/.*$/)
159
+ if (m && m.index !== undefined) return { code: line.slice(0, m.index), comment: line.slice(m.index) }
160
+ if (line.trimStart().startsWith('//')) return { code: '', comment: line }
161
+ return { code: line, comment: '' }
162
+ }
163
+ const routesPath = computed(() => health.value?.routes?.path ?? 'apps/web/routes.ts')
164
+ const routeLines = computed<CodeLine[]>(() => {
165
+ const src = health.value?.routes?.source
166
+ if (!src) return []
167
+ return src.replace(/\n+$/, '').split('\n').map(splitComment)
168
+ })
7
169
  </script>
8
170
 
9
171
  <template>
10
- <main class="home">
11
- <h1>{{ props.title }}</h1>
12
- <p>Gaon 프레임웍이 방금 앱을 만들었습니다.</p>
13
- <p>
14
- 문서: <a :href="props.docs" target="_blank">{{ props.docs }}</a>
172
+ <div class="mx-auto max-w-[880px] px-5 pb-16 pt-12 leading-relaxed">
173
+ <section class="mb-10">
174
+ <p class="mb-1.5 text-xs font-semibold uppercase tracking-[0.08em] text-[#7c5cff]">{{ props.title }}</p>
175
+ <h1 class="mb-3.5 text-[clamp(2rem,5vw,2.9rem)] font-bold leading-[1.15] tracking-tight">가온에 올라탔습니다.</h1>
176
+ <p class="my-1.5 text-[1.02rem] text-foreground/80">
177
+ 이 화면은 <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">apps/web/pages/Home/Index.vue</code> 입니다.
178
+ <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">routes.ts</code> 의 <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">r.get('/', 'home#index')</code> 가 여기로 연결했습니다.
179
+ </p>
180
+ <p class="my-1.5 text-[0.95rem] text-muted-foreground">
181
+ 아래 카드는 지금 이 서버의 <strong class="font-semibold text-foreground">실제 상태</strong>입니다 —
182
+ 하드코딩이 아니라 <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">/_gaon/health</code> 를 읽어 그립니다.
183
+ <a :href="props.docs" target="_blank" rel="noreferrer" class="text-[#4f8cff] hover:underline">{{ props.docs }}</a>
184
+ </p>
185
+ </section>
186
+
187
+ <section class="mb-4 grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-4" aria-label="프레임웍 상태">
188
+ <Card v-for="c in cards" :key="c.key" class="card p-[1.15rem]">
189
+ <div class="mb-2.5 flex items-center gap-2">
190
+ <span class="h-2.5 w-2.5 rounded-full" :class="DOT[c.status]" aria-hidden="true"></span>
191
+ <span class="text-[0.74rem] font-bold tracking-[0.08em] text-muted-foreground">{{ c.title }}</span>
192
+ </div>
193
+ <p class="mb-2 text-[1.35rem] font-bold tabular-nums tracking-tight">{{ c.headline }}</p>
194
+ <ul class="m-0 list-none p-0 text-[0.82rem] text-muted-foreground">
195
+ <li v-for="(ln, i) in c.lines" :key="i" class="break-words py-[0.08rem]">{{ ln }}</li>
196
+ </ul>
197
+ </Card>
198
+ </section>
199
+
200
+ <p v-if="loading" class="my-1 mb-6 text-sm text-muted-foreground">상태 확인 중…</p>
201
+ <p v-else-if="!available" class="my-1 mb-6 text-sm text-muted-foreground">
202
+ 라이브 상태는 <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">gaon dev</code> 개발 서버에서만 보입니다(운영 빌드엔 진단 엔드포인트가 없습니다).
15
203
  </p>
16
- <hr />
17
- <h2>다음 단계</h2>
18
- <ol>
19
- <li><code>gaon g auth</code> — 인증 스캐폴드 생성 (회원가입·로그인·세션)</li>
20
- <li><code>gaon g model Post</code> — 모델 스캐폴드 (스키마 + 모델 · E-4)</li>
21
- <li><code>gaon g controller posts</code> — 컨트롤러 스캐폴드</li>
22
- <li><code>gaon g page Posts/Index</code> — Vue 페이지 (Inertia SPA)</li>
23
- <li><code>gaon dev</code> — Docker 자동 기동 · 타입 브리지 · watch</li>
24
- </ol>
25
- </main>
26
- </template>
27
204
 
28
- <style scoped>
29
- .home {
30
- max-width: 640px;
31
- margin: 4rem auto;
32
- padding: 0 1rem;
33
- font-family: system-ui, -apple-system, sans-serif;
34
- line-height: 1.6;
35
- }
36
- </style>
205
+ <section class="my-10">
206
+ <h2 class="mb-4 text-[1.15rem] font-semibold">다음 단계</h2>
207
+ <ul class="flex list-none flex-col gap-1.5 p-0">
208
+ <li
209
+ v-for="s in steps"
210
+ :key="s.label"
211
+ class="flex items-start gap-3 rounded-[10px] border px-3.5 py-2.5"
212
+ :class="s.done ? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/30' : 'border-border bg-muted/30'"
213
+ >
214
+ <span class="font-bold leading-6" :class="s.done ? 'text-green-600' : 'text-muted-foreground/60'" aria-hidden="true">{{ s.done ? '✓' : '○' }}</span>
215
+ <div class="min-w-0 flex-1">
216
+ <div class="flex flex-wrap items-center gap-2.5">
217
+ <span class="font-semibold">{{ s.label }}</span>
218
+ <code class="rounded bg-[#0d1117] px-1.5 py-0.5 font-mono text-[0.86em] text-[#e6edf3]">{{ s.cmd }}</code>
219
+ </div>
220
+ <span class="mt-0.5 block text-xs text-muted-foreground">{{ s.note }}</span>
221
+ </div>
222
+ </li>
223
+ </ul>
224
+ </section>
225
+
226
+ <section v-if="routeLines.length" class="overflow-hidden rounded-xl border">
227
+ <div class="border-b bg-muted px-4 py-2 font-mono text-xs text-muted-foreground">{{ routesPath }}</div>
228
+ <pre class="m-0 overflow-x-auto bg-[#0d1117] px-[1.15rem] py-4 text-[0.82rem] leading-[1.65] text-[#c9d1d9]"><code><span v-for="(ln, i) in routeLines" :key="i" class="block"><span>{{ ln.code }}</span><span class="text-[#8b949e]">{{ ln.comment }}</span>{{ '\n' }}</span></code></pre>
229
+ </section>
230
+
231
+ <p class="mt-8 text-center text-xs text-muted-foreground">
232
+ <Badge variant="secondary">UI 킷</Badge>
233
+ 이 화면·카드·다음 단계는 gaon g ui-kit 로 심은 컴포넌트로 그렸습니다 — apps/web/components/ui/ 에서 소유·수정하세요.
234
+ </p>
235
+ </div>
236
+ </template>
@@ -0,0 +1,66 @@
1
+ /* apps/web/style.css — 앱 전역 스타일 (결정 74).
2
+ *
3
+ * main.ts 가 이 파일을 import 해 번들에 싣는다. @tailwind 3줄이 Tailwind 의
4
+ * base/components/utilities 레이어를 펼친다(PostCSS · postcss.config.js).
5
+ *
6
+ * :root/.dark 의 CSS 변수 = 디자인 토큰(shadcn 관례). UI 킷 컴포넌트는 이
7
+ * 토큰(bg-primary·text-muted-foreground …)만 참조하므로, 브랜드 색을 바꾸려면
8
+ * 여기 한 곳만 고친다. hsl 채널 값으로 두는 이유는 tailwind.config.ts 가
9
+ * hsl(var(--token)) 로 감싸 투명도 유틸(bg-primary/50)까지 동작하게 하기 위함이다. */
10
+ @tailwind base;
11
+ @tailwind components;
12
+ @tailwind utilities;
13
+
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
+ }
35
+
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
+ }
55
+
56
+ * {
57
+ border-color: hsl(var(--border));
58
+ }
59
+
60
+ body {
61
+ background-color: hsl(var(--background));
62
+ color: hsl(var(--foreground));
63
+ font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
64
+ -webkit-font-smoothing: antialiased;
65
+ }
66
+ }
@@ -24,6 +24,9 @@
24
24
  "devDependencies": {
25
25
  "@types/node": "^22.0.0",
26
26
  "@vitejs/plugin-vue": "^6.0.0",
27
+ "autoprefixer": "^10.4.0",
28
+ "postcss": "^8.4.0",
29
+ "tailwindcss": "^3.4.0",
27
30
  "typescript": "^5.9.0",
28
31
  "vite": "^7.0.0",
29
32
  "vue-tsc": "^3.3.0",
@@ -0,0 +1,15 @@
1
+ // postcss.config.js — Vite 가 CSS 를 처리할 때 타는 PostCSS 파이프라인 (결정 74).
2
+ //
3
+ // Vite 는 프로젝트 루트에서 이 파일을 자동으로 찾아 style.css 의 @tailwind
4
+ // 지시문을 실 유틸 클래스로 펼치고(tailwindcss), 벤더 프리픽스를 붙인다
5
+ // (autoprefixer). gaon dev(Vite 미들웨어)·vite build 가 같은 설정을 공유한다.
6
+ //
7
+ // PostCSS 설정은 .js 가 표준이다 — Vite 의 postcss-load-config 가 .ts 를 바로
8
+ // 로드하지 못한다. 프레임웍의 "TS 전용" 관례는 앱/도메인 소스에 대한 것이고,
9
+ // 툴 설정 파일은 각 도구가 요구하는 형식을 따른다.
10
+ export default {
11
+ plugins: {
12
+ tailwindcss: {},
13
+ autoprefixer: {},
14
+ },
15
+ }
@@ -0,0 +1,56 @@
1
+ // tailwind.config.ts — Tailwind CSS 설정 (결정 74).
2
+ //
3
+ // content 는 유틸 클래스가 등장하는 모든 소스를 가리킨다. 앱 페이지·컴포넌트와
4
+ // shared UI 를 훑는다(자동 import 금지 관례와 무관 — 여기선 클래스 문자열 스캔).
5
+ // darkMode: 'class' — <html class="dark"> 로 다크 테마를 켠다(토큰은 style.css).
6
+ //
7
+ // 색 토큰은 style.css 의 CSS 변수(hsl 채널)를 가리킨다(shadcn 관례). 컴포넌트는
8
+ // bg-primary·text-muted-foreground 처럼 의미 토큰만 쓰고, 실제 색은 style.css 의
9
+ // :root/.dark 한 곳에서 바꾼다 — 라이트/다크가 한 지점에서 갈린다.
10
+ import type { Config } from 'tailwindcss'
11
+
12
+ export default {
13
+ darkMode: 'class',
14
+ content: ['./apps/**/*.{vue,ts}', './shared/**/*.{vue,ts}', './apps/**/index.html'],
15
+ theme: {
16
+ extend: {
17
+ colors: {
18
+ border: 'hsl(var(--border))',
19
+ input: 'hsl(var(--input))',
20
+ ring: 'hsl(var(--ring))',
21
+ background: 'hsl(var(--background))',
22
+ foreground: 'hsl(var(--foreground))',
23
+ primary: {
24
+ DEFAULT: 'hsl(var(--primary))',
25
+ foreground: 'hsl(var(--primary-foreground))',
26
+ },
27
+ secondary: {
28
+ DEFAULT: 'hsl(var(--secondary))',
29
+ foreground: 'hsl(var(--secondary-foreground))',
30
+ },
31
+ destructive: {
32
+ DEFAULT: 'hsl(var(--destructive))',
33
+ foreground: 'hsl(var(--destructive-foreground))',
34
+ },
35
+ muted: {
36
+ DEFAULT: 'hsl(var(--muted))',
37
+ foreground: 'hsl(var(--muted-foreground))',
38
+ },
39
+ accent: {
40
+ DEFAULT: 'hsl(var(--accent))',
41
+ foreground: 'hsl(var(--accent-foreground))',
42
+ },
43
+ card: {
44
+ DEFAULT: 'hsl(var(--card))',
45
+ foreground: 'hsl(var(--card-foreground))',
46
+ },
47
+ },
48
+ borderRadius: {
49
+ lg: 'var(--radius)',
50
+ md: 'calc(var(--radius) - 2px)',
51
+ sm: 'calc(var(--radius) - 4px)',
52
+ },
53
+ },
54
+ },
55
+ plugins: [],
56
+ } satisfies Config
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ // UI 킷 · Alert (결정 75). 인라인 알림. variant='destructive' 로 오류 톤.
3
+ import { computed } from 'vue'
4
+ import { cn } from '../../lib/utils.js'
5
+
6
+ type Variant = 'default' | 'destructive'
7
+
8
+ const props = withDefaults(defineProps<{ variant?: Variant }>(), { variant: 'default' })
9
+
10
+ const VARIANTS: Record<Variant, string> = {
11
+ default: 'bg-background text-foreground',
12
+ destructive: 'border-destructive/50 text-destructive',
13
+ }
14
+ const classes = computed(() =>
15
+ cn('relative w-full rounded-lg border p-4', VARIANTS[props.variant]),
16
+ )
17
+ </script>
18
+
19
+ <template>
20
+ <div role="alert" :class="classes">
21
+ <slot />
22
+ </div>
23
+ </template>
@@ -0,0 +1,9 @@
1
+ <script setup lang="ts">
2
+ // UI 킷 · AlertDescription (결정 75). 알림 본문.
3
+ </script>
4
+
5
+ <template>
6
+ <div class="text-sm [&_p]:leading-relaxed">
7
+ <slot />
8
+ </div>
9
+ </template>
@@ -0,0 +1,9 @@
1
+ <script setup lang="ts">
2
+ // UI 킷 · AlertTitle (결정 75). 알림 제목.
3
+ </script>
4
+
5
+ <template>
6
+ <h5 class="mb-1 font-medium leading-none tracking-tight">
7
+ <slot />
8
+ </h5>
9
+ </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ // UI 킷 · Badge (결정 75). 작은 상태 라벨. variant 로 색을 고른다.
3
+ import { computed } from 'vue'
4
+ import { cn } from '../../lib/utils.js'
5
+
6
+ type Variant = 'default' | 'secondary' | 'destructive' | 'outline'
7
+
8
+ const props = withDefaults(defineProps<{ variant?: Variant }>(), { variant: 'default' })
9
+
10
+ const VARIANTS: Record<Variant, string> = {
11
+ default: 'border-transparent bg-primary text-primary-foreground',
12
+ secondary: 'border-transparent bg-secondary text-secondary-foreground',
13
+ destructive: 'border-transparent bg-destructive text-destructive-foreground',
14
+ outline: 'text-foreground',
15
+ }
16
+ const BASE =
17
+ 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold ' +
18
+ 'transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
19
+
20
+ const classes = computed(() => cn(BASE, VARIANTS[props.variant]))
21
+ </script>
22
+
23
+ <template>
24
+ <span :class="classes"><slot /></span>
25
+ </template>