@gaonjs/cli 0.13.2 → 0.15.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.
@@ -151,6 +151,24 @@ Nuxt 식 자동 import 는 넣지 않는다. 모든 컴포넌트·컴포저블
151
151
  import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
152
152
  설정을 잡는다.
153
153
 
154
+ ### 7. 랜딩·라이브 헬스 (결정 69 · 70)
155
+
156
+ `gaon new` 첫 화면(`apps/web/pages/Home/Index.vue`)은 라이브 상태 랜딩이다 — 다크
157
+ 헤더 레이아웃(`layouts/Default.vue`) + 실 상태 카드(WEB·DATABASE·HUB·DOCTOR) + 동적
158
+ 다음 단계 + 실 `routes.ts` 코드 블록. 상태는 **하드코딩하지 않는다** — dev 전용
159
+ 엔드포인트 `/_gaon/health` 를 컴포저블(`composables/useGaonHealth.ts`)로 읽어 바인딩한다.
160
+ 이 엔드포인트는 `gaon dev` 에서만 등록되고(운영 빌드엔 없음), 404 면 컴포저블이
161
+ `available=false` 로 우아하게 degrade 한다.
162
+
163
+ - **스타일은 SFC `<style scoped>` + CSS 변수** — 스캐폴드에 Tailwind 를 넣지
164
+ 않는다(의존성 경량 · 결정 69). 유틸 클래스 대신 scoped CSS 로 캡슐화.
165
+ - **auth 링크는 수동(결정 70)** — `gaon g auth` 는 auth 페이지·라우트만 신설하고
166
+ 랜딩·레이아웃 nav 를 편집하지 않는다. 헤더에 로그인 링크를 두려면 `Default.vue`
167
+ 의 nav 에 `<a href="/session/new">로그인</a>` 을 직접 추가한다(Rails 관례).
168
+ - **auth 페이지 경로 = `pages/Auth/`(PascalCase)** — `gaon g auth` 는 `Auth/Login.vue`
169
+ ·`Auth/Signup.vue` 를 내고 컨트롤러는 `this.render('Auth/Login')` 로 부른다.
170
+ 소문자 `auth/` 는 doctor page-filename 이 잡는다(결정 32·46).
171
+
154
172
  ## 정본 예시
155
173
 
156
174
  ```vue
@@ -203,4 +221,6 @@ async function runSearch(q: string) {
203
221
  | 결정 25 (E-5) | 컴포저블·레이아웃 관례 · 프론트 로직 배치 3규칙 · 자동 import 금지 |
204
222
  | 결정 37 | bigint PK 컨트롤러 `String()` 정규화 |
205
223
  | 결정 46 | doctor page-filename(페이지 PascalCase)·model/column 검사 3종 |
224
+ | 결정 69 | 랜딩 정본(라이브 헬스 카드 · 다크 헤더 레이아웃 · Tailwind 미편입 · scoped CSS) |
225
+ | 결정 70 | auth 통합 = 수동(`gaon g auth` 는 랜딩·nav 를 안 건드림 · Rails 관례) |
206
226
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
@@ -0,0 +1,79 @@
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
+ streams?: { stream: string; waiting: number | null }[]
32
+ error?: string
33
+ }
34
+ export interface HealthDoctor {
35
+ checks: number
36
+ passed: number
37
+ warnings: number
38
+ errors: number
39
+ level: 'pass' | 'warn' | 'error'
40
+ fatal?: string
41
+ }
42
+ export interface GaonHealth {
43
+ ok: boolean
44
+ env: string
45
+ web: HealthWeb
46
+ database: HealthDatabase
47
+ hub: HealthHub
48
+ doctor: HealthDoctor
49
+ routes: { path: string; source: string } | null
50
+ }
51
+
52
+ export function useGaonHealth() {
53
+ const health = ref<GaonHealth | null>(null)
54
+ const loading = ref(true)
55
+ // 엔드포인트가 있는가(= dev 서버인가). 운영 빌드에선 false 로 남는다.
56
+ const available = ref(false)
57
+
58
+ async function load(): Promise<void> {
59
+ loading.value = true
60
+ try {
61
+ const res = await fetch('/_gaon/health', { headers: { Accept: 'application/json' } })
62
+ if (!res.ok) {
63
+ available.value = false
64
+ return
65
+ }
66
+ health.value = (await res.json()) as GaonHealth
67
+ available.value = true
68
+ } catch {
69
+ // 네트워크·파싱 실패 = 진단 불가. 랜딩은 정적 안내로 degrade.
70
+ available.value = false
71
+ } finally {
72
+ loading.value = false
73
+ }
74
+ }
75
+
76
+ onMounted(load)
77
+
78
+ return { health, loading, available, reload: load }
79
+ }
@@ -5,39 +5,115 @@
5
5
  //
6
6
  // 레이아웃은 shared 에 두지 않는다(E-5 §2.3) — 앱마다 레이아웃이 다른 것이
7
7
  // 정상이고, 공용 조각(로고·푸터 등) 만 shared/components 로 뽑는다.
8
+ //
9
+ // 다크 헤더 + 라이트 본문(결정 69). 버전은 스캐폴드 시점 gaonjs 버전이 박힌다.
10
+ //
11
+ // 결정 70(auth = 수동): gaon g auth 는 이 nav 를 건드리지 않는다. 로그인 링크를
12
+ // 헤더에 두려면 아래 nav 에 <a href="/session/new">로그인</a> 을 직접 추가한다.
13
+
14
+ // package.json 의 gaonjs 의존 범위(예: ^0.9.2)에서 캐럿·틸드를 벗겨 표기.
15
+ const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
8
16
  </script>
9
17
 
10
18
  <template>
11
- <div class="layout">
12
- <header class="layout-header">
13
- <strong>{{PROJECT_NAME}}</strong>
19
+ <div class="gaon-layout">
20
+ <header class="gaon-header">
21
+ <a class="gaon-brand" href="/">
22
+ <span class="gaon-brand-mark">가온</span>
23
+ <span class="gaon-brand-word">GAONJS</span>
24
+ <span class="gaon-brand-ver">v{{ version }}</span>
25
+ </a>
26
+ <nav class="gaon-nav">
27
+ <a href="https://gaonjs.dev" target="_blank" rel="noreferrer">문서</a>
28
+ <a href="https://github.com/gaonjs" target="_blank" rel="noreferrer">GitHub</a>
29
+ </nav>
14
30
  </header>
15
- <slot />
16
- <footer class="layout-footer">
17
- <small>Powered by <a href="https://gaonjs.dev" target="_blank">Gaon</a></small>
31
+
32
+ <main class="gaon-main">
33
+ <slot />
34
+ </main>
35
+
36
+ <footer class="gaon-footer">
37
+ <small>{{PROJECT_NAME}} · Powered by
38
+ <a href="https://gaonjs.dev" target="_blank" rel="noreferrer">Gaon</a></small>
18
39
  </footer>
19
40
  </div>
20
41
  </template>
21
42
 
22
43
  <style scoped>
23
- .layout {
44
+ .gaon-layout {
24
45
  min-height: 100vh;
25
46
  display: flex;
26
47
  flex-direction: column;
48
+ background: #ffffff;
49
+ color: #1b1f24;
50
+ font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
51
+ }
52
+
53
+ /* 다크 헤더 — 본문 테마와 무관하게 브랜드 바는 어둡게. */
54
+ .gaon-header {
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: space-between;
58
+ gap: 1rem;
59
+ padding: 0.85rem 1.5rem;
60
+ background: #0d1117;
61
+ color: #e6edf3;
62
+ border-bottom: 1px solid #21262d;
63
+ }
64
+ .gaon-brand {
65
+ display: inline-flex;
66
+ align-items: baseline;
67
+ gap: 0.55rem;
68
+ text-decoration: none;
69
+ color: inherit;
27
70
  }
28
- .layout-header,
29
- .layout-footer {
30
- padding: 1rem;
31
- background: #f6f8fa;
32
- border-color: #e1e4e8;
71
+ .gaon-brand-mark {
72
+ display: inline-block;
73
+ padding: 0.15rem 0.45rem;
74
+ border-radius: 6px;
75
+ background: linear-gradient(135deg, #4f8cff, #7c5cff);
76
+ color: #fff;
77
+ font-weight: 700;
78
+ font-size: 0.8rem;
79
+ letter-spacing: 0.02em;
33
80
  }
34
- .layout-header {
35
- border-bottom: 1px solid #e1e4e8;
81
+ .gaon-brand-word {
82
+ font-weight: 700;
83
+ letter-spacing: 0.08em;
84
+ font-size: 0.95rem;
36
85
  }
37
- .layout-footer {
38
- border-top: 1px solid #e1e4e8;
39
- margin-top: auto;
86
+ .gaon-brand-ver {
87
+ font-size: 0.72rem;
88
+ color: #8b949e;
89
+ font-variant-numeric: tabular-nums;
90
+ }
91
+ .gaon-nav {
92
+ display: flex;
93
+ gap: 1.1rem;
94
+ font-size: 0.85rem;
95
+ }
96
+ .gaon-nav a {
97
+ color: #c9d1d9;
98
+ text-decoration: none;
99
+ }
100
+ .gaon-nav a:hover {
101
+ color: #fff;
102
+ }
103
+
104
+ .gaon-main {
105
+ flex: 1;
106
+ width: 100%;
107
+ }
108
+
109
+ .gaon-footer {
110
+ padding: 1.25rem 1.5rem;
40
111
  text-align: center;
41
112
  color: #6a737d;
113
+ border-top: 1px solid #eaecef;
114
+ }
115
+ .gaon-footer a {
116
+ color: #4f8cff;
117
+ text-decoration: none;
42
118
  }
43
119
  </style>
@@ -1,36 +1,419 @@
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'
3
5
 
4
6
  // home#index 의 render props — Serialized<> 로 넘어온다(§6.2).
5
7
  // 라우트 키는 .gaon/routes.d.ts 가 유효한 값을 알려준다.
6
8
  const props = pageProps<'web:home#index'>()
9
+
10
+ // 라이브 상태(dev 전용 /_gaon/health). 운영 빌드에선 available=false 로 degrade.
11
+ const { health, loading, available } = useGaonHealth()
12
+
13
+ type CardStatus = 'ok' | 'warn' | 'error' | 'idle'
14
+ interface Card {
15
+ readonly key: string
16
+ readonly title: string
17
+ readonly status: CardStatus
18
+ readonly headline: string
19
+ readonly lines: readonly string[]
20
+ }
21
+
22
+ function doctorNote(d: HealthDoctor | undefined): string {
23
+ if (!d) return '—'
24
+ if (d.fatal) return `검사 불가 (${d.fatal})`
25
+ return `${d.passed} 통과 · ${d.warnings} 경고 · ${d.errors} 오류`
26
+ }
27
+
28
+ const cards = computed<Card[]>(() => {
29
+ const h = health.value
30
+ const web = h?.web
31
+ const db = h?.database
32
+ const hub = h?.hub
33
+ const doc = h?.doctor
34
+
35
+ const webCard: Card = {
36
+ key: 'web',
37
+ title: 'WEB',
38
+ status: available.value ? 'ok' : 'idle',
39
+ headline: web ? `:${web.port}` : '대기',
40
+ lines: web
41
+ ? [`Node ${web.node}`, `gaonjs v${web.gaonjs}`, `앱 ${web.apps.map((a) => a.name).join(', ')}`]
42
+ : ['개발 서버에서만'],
43
+ }
44
+
45
+ const dbCard: Card = {
46
+ key: 'database',
47
+ title: 'DATABASE',
48
+ status: !db || !db.configured ? 'idle' : db.connected ? 'ok' : 'error',
49
+ headline: !db || !db.configured ? '미설정' : db.connected ? '연결됨' : '미연결',
50
+ lines:
51
+ db && db.configured
52
+ ? db.connected
53
+ ? [`${db.adapter ?? 'postgres'}`, `테이블 ${db.tables ?? 0}개`, `마이그레이션 ${db.migrations ?? 0}건`]
54
+ : [db.error ?? '연결 실패', '.env DATABASE_URL 확인']
55
+ : ['.env 에 DATABASE_URL', 'gaon.config.ts 에서 켜기'],
56
+ }
57
+
58
+ const hubCard: Card = {
59
+ key: 'hub',
60
+ title: 'HUB',
61
+ status: !hub || !hub.configured ? 'idle' : hub.connected ? 'ok' : 'error',
62
+ headline: !hub || !hub.configured ? '미설정' : hub.connected ? '연결됨' : '미연결',
63
+ lines:
64
+ hub && hub.configured
65
+ ? hub.connected
66
+ ? (hub.streams ?? []).map((s) => `${s.stream}: ${s.waiting === null ? '대기 없음' : `${s.waiting} 대기`}`)
67
+ : [hub.error ?? 'NATS 미연결']
68
+ : ['.env 에 NATS_URL', '실시간·비동기 백본'],
69
+ }
70
+
71
+ const doctorCard: Card = {
72
+ key: 'doctor',
73
+ title: 'DOCTOR',
74
+ status: !doc ? 'idle' : doc.level === 'pass' ? 'ok' : doc.level === 'warn' ? 'warn' : 'error',
75
+ headline: !doc
76
+ ? '대기'
77
+ : doc.fatal
78
+ ? '검사 불가'
79
+ : doc.errors > 0
80
+ ? `오류 ${doc.errors}`
81
+ : doc.warnings > 0
82
+ ? `경고 ${doc.warnings}`
83
+ : '통과',
84
+ lines: [doctorNote(doc), 'gaon check 로 재실행'],
85
+ }
86
+
87
+ return [webCard, dbCard, hubCard, doctorCard]
88
+ })
89
+
90
+ // 다음 단계 — 라이브 상태에서 완료 여부를 파생한다.
91
+ interface Step {
92
+ readonly label: string
93
+ readonly cmd: string
94
+ readonly done: boolean
95
+ readonly note: string
96
+ }
97
+ const steps = computed<Step[]>(() => {
98
+ const h = health.value
99
+ const db = h?.database
100
+ const configured = !!(db && db.configured)
101
+ const connected = !!(db && db.configured && db.connected)
102
+ const tables = configured ? db!.tables ?? 0 : 0
103
+ return [
104
+ {
105
+ label: '개발 스택 부팅',
106
+ cmd: 'gaon dev',
107
+ done: available.value,
108
+ note: 'Docker · .gaon 타입 브리지 · 서버 · watch',
109
+ },
110
+ {
111
+ label: '데이터베이스 연결',
112
+ cmd: configured ? 'gaon db migrate' : '.env: DATABASE_URL',
113
+ done: connected,
114
+ note: configured ? (connected ? '연결됨' : '설정됨 · 미연결') : '미설정',
115
+ },
116
+ {
117
+ label: '첫 모델 만들기',
118
+ cmd: 'gaon g model Post',
119
+ done: tables > 0,
120
+ note: tables > 0 ? `테이블 ${tables}개` : '스키마 + 모델 (E-4)',
121
+ },
122
+ {
123
+ label: '인증 배터리',
124
+ cmd: 'gaon g auth',
125
+ done: false,
126
+ note: '가입 · 로그인 · 세션 (수동 통합 · 결정 70)',
127
+ },
128
+ {
129
+ label: '검사 통과',
130
+ cmd: 'gaon check',
131
+ done: h?.doctor.level === 'pass',
132
+ note: doctorNote(h?.doctor),
133
+ },
134
+ ]
135
+ })
136
+
137
+ // routes.ts 코드 블록 — 라이브 원문을 줄 단위로 나누고 주석만 가볍게 색을 준다.
138
+ // v-html 없이(텍스트 보간만) 안전하게 그린다. 의존성 없는 최소 하이라이트.
139
+ interface CodeLine {
140
+ readonly code: string
141
+ readonly comment: string
142
+ }
143
+ function splitComment(line: string): CodeLine {
144
+ const m = line.match(/\s\/\/.*$/)
145
+ if (m && m.index !== undefined) return { code: line.slice(0, m.index), comment: line.slice(m.index) }
146
+ if (line.trimStart().startsWith('//')) return { code: '', comment: line }
147
+ return { code: line, comment: '' }
148
+ }
149
+ const routesPath = computed(() => health.value?.routes?.path ?? 'apps/web/routes.ts')
150
+ const routeLines = computed<CodeLine[]>(() => {
151
+ const src = health.value?.routes?.source
152
+ if (!src) return []
153
+ return src.replace(/\n+$/, '').split('\n').map(splitComment)
154
+ })
7
155
  </script>
8
156
 
9
157
  <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>
158
+ <div class="landing">
159
+ <section class="hero">
160
+ <p class="eyebrow">{{ props.title }}</p>
161
+ <h1>가온에 올라탔습니다.</h1>
162
+ <p class="lede">
163
+ 이 화면은 <code>apps/web/pages/Home/Index.vue</code> 입니다.
164
+ <code>routes.ts</code> 의 <code>r.get('/', 'home#index')</code> 가 여기로 연결했습니다.
165
+ </p>
166
+ <p class="lede sub">
167
+ 아래 카드는 지금 이 서버의 <strong>실제 상태</strong>입니다 —
168
+ 하드코딩이 아니라 <code>/_gaon/health</code> 를 읽어 그립니다.
169
+ <a :href="props.docs" target="_blank" rel="noreferrer">{{ props.docs }}</a>
170
+ </p>
171
+ </section>
172
+
173
+ <section class="cards" aria-label="프레임웍 상태">
174
+ <article v-for="c in cards" :key="c.key" class="card" :class="`is-${c.status}`">
175
+ <header class="card-top">
176
+ <span class="dot" :class="`dot-${c.status}`" aria-hidden="true"></span>
177
+ <span class="card-title">{{ c.title }}</span>
178
+ </header>
179
+ <p class="card-headline">{{ c.headline }}</p>
180
+ <ul class="card-lines">
181
+ <li v-for="(ln, i) in c.lines" :key="i">{{ ln }}</li>
182
+ </ul>
183
+ </article>
184
+ </section>
185
+
186
+ <p v-if="loading" class="hint">상태 확인 중…</p>
187
+ <p v-else-if="!available" class="hint">
188
+ 라이브 상태는 <code>gaon dev</code> 개발 서버에서만 보입니다(운영 빌드엔 진단 엔드포인트가 없습니다).
15
189
  </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>
190
+
191
+ <section class="next">
192
+ <h2>다음 단계</h2>
193
+ <ul class="steps">
194
+ <li v-for="s in steps" :key="s.label" :class="{ done: s.done }">
195
+ <span class="check" aria-hidden="true">{{ s.done ? '✓' : '○' }}</span>
196
+ <div class="step-body">
197
+ <div class="step-head">
198
+ <span class="step-label">{{ s.label }}</span>
199
+ <code class="step-cmd">{{ s.cmd }}</code>
200
+ </div>
201
+ <span class="step-note">{{ s.note }}</span>
202
+ </div>
203
+ </li>
204
+ </ul>
205
+ </section>
206
+
207
+ <section v-if="routeLines.length" class="code">
208
+ <div class="code-head">{{ routesPath }}</div>
209
+ <pre class="code-body"><code><span v-for="(ln, i) in routeLines" :key="i" class="code-line"><span class="c-code">{{ ln.code }}</span><span class="c-comment">{{ ln.comment }}</span>{{ '\n' }}</span></code></pre>
210
+ </section>
211
+ </div>
26
212
  </template>
27
213
 
28
214
  <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;
215
+ .landing {
216
+ max-width: 880px;
217
+ margin: 0 auto;
218
+ padding: 3rem 1.25rem 4rem;
219
+ color: #1b1f24;
34
220
  line-height: 1.6;
35
221
  }
222
+
223
+ .hero {
224
+ margin-bottom: 2.5rem;
225
+ }
226
+ .eyebrow {
227
+ margin: 0 0 0.4rem;
228
+ font-size: 0.8rem;
229
+ font-weight: 600;
230
+ letter-spacing: 0.08em;
231
+ text-transform: uppercase;
232
+ color: #7c5cff;
233
+ }
234
+ .hero h1 {
235
+ margin: 0 0 0.9rem;
236
+ font-size: clamp(2rem, 5vw, 2.9rem);
237
+ line-height: 1.15;
238
+ letter-spacing: -0.02em;
239
+ }
240
+ .lede {
241
+ margin: 0.35rem 0;
242
+ color: #3a424c;
243
+ font-size: 1.02rem;
244
+ }
245
+ .lede.sub {
246
+ color: #57606a;
247
+ font-size: 0.95rem;
248
+ }
249
+ .lede a {
250
+ color: #4f8cff;
251
+ }
252
+ code {
253
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
254
+ font-size: 0.86em;
255
+ background: #f2f4f7;
256
+ padding: 0.1rem 0.35rem;
257
+ border-radius: 5px;
258
+ }
259
+
260
+ .cards {
261
+ display: grid;
262
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
263
+ gap: 1rem;
264
+ margin-bottom: 1rem;
265
+ }
266
+ .card {
267
+ border: 1px solid #e4e7eb;
268
+ border-radius: 12px;
269
+ padding: 1.1rem 1.15rem;
270
+ background: #fff;
271
+ box-shadow: 0 1px 2px rgba(16, 22, 26, 0.04);
272
+ }
273
+ .card-top {
274
+ display: flex;
275
+ align-items: center;
276
+ gap: 0.5rem;
277
+ margin-bottom: 0.6rem;
278
+ }
279
+ .card-title {
280
+ font-size: 0.74rem;
281
+ font-weight: 700;
282
+ letter-spacing: 0.08em;
283
+ color: #6a737d;
284
+ }
285
+ .dot {
286
+ width: 9px;
287
+ height: 9px;
288
+ border-radius: 50%;
289
+ background: #c2c8cf;
290
+ }
291
+ .dot-ok {
292
+ background: #2da44e;
293
+ box-shadow: 0 0 0 3px rgba(45, 164, 78, 0.15);
294
+ }
295
+ .dot-warn {
296
+ background: #d4a72c;
297
+ box-shadow: 0 0 0 3px rgba(212, 167, 44, 0.15);
298
+ }
299
+ .dot-error {
300
+ background: #cf222e;
301
+ box-shadow: 0 0 0 3px rgba(207, 34, 46, 0.15);
302
+ }
303
+ .card-headline {
304
+ margin: 0 0 0.5rem;
305
+ font-size: 1.35rem;
306
+ font-weight: 700;
307
+ letter-spacing: -0.01em;
308
+ font-variant-numeric: tabular-nums;
309
+ }
310
+ .card-lines {
311
+ margin: 0;
312
+ padding: 0;
313
+ list-style: none;
314
+ font-size: 0.82rem;
315
+ color: #57606a;
316
+ }
317
+ .card-lines li {
318
+ padding: 0.08rem 0;
319
+ overflow-wrap: anywhere;
320
+ }
321
+
322
+ .hint {
323
+ margin: 0.25rem 0 1.5rem;
324
+ font-size: 0.85rem;
325
+ color: #6a737d;
326
+ }
327
+
328
+ .next {
329
+ margin: 2.5rem 0;
330
+ }
331
+ .next h2 {
332
+ font-size: 1.15rem;
333
+ margin: 0 0 1rem;
334
+ }
335
+ .steps {
336
+ list-style: none;
337
+ margin: 0;
338
+ padding: 0;
339
+ display: flex;
340
+ flex-direction: column;
341
+ gap: 0.35rem;
342
+ }
343
+ .steps li {
344
+ display: flex;
345
+ gap: 0.75rem;
346
+ align-items: flex-start;
347
+ padding: 0.65rem 0.85rem;
348
+ border: 1px solid #eaecef;
349
+ border-radius: 10px;
350
+ background: #fbfcfd;
351
+ }
352
+ .steps li.done {
353
+ border-color: #cbe7d3;
354
+ background: #f3faf5;
355
+ }
356
+ .check {
357
+ color: #b0b7bf;
358
+ font-weight: 700;
359
+ line-height: 1.5;
360
+ }
361
+ .steps li.done .check {
362
+ color: #2da44e;
363
+ }
364
+ .step-body {
365
+ flex: 1;
366
+ min-width: 0;
367
+ }
368
+ .step-head {
369
+ display: flex;
370
+ align-items: center;
371
+ gap: 0.6rem;
372
+ flex-wrap: wrap;
373
+ }
374
+ .step-label {
375
+ font-weight: 600;
376
+ }
377
+ .step-cmd {
378
+ background: #0d1117;
379
+ color: #e6edf3;
380
+ }
381
+ .step-note {
382
+ display: block;
383
+ font-size: 0.8rem;
384
+ color: #6a737d;
385
+ margin-top: 0.15rem;
386
+ }
387
+
388
+ .code {
389
+ border: 1px solid #e4e7eb;
390
+ border-radius: 12px;
391
+ overflow: hidden;
392
+ }
393
+ .code-head {
394
+ padding: 0.55rem 1rem;
395
+ background: #f6f8fa;
396
+ border-bottom: 1px solid #e4e7eb;
397
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
398
+ font-size: 0.78rem;
399
+ color: #57606a;
400
+ }
401
+ .code-body {
402
+ margin: 0;
403
+ padding: 1rem 1.15rem;
404
+ overflow-x: auto;
405
+ background: #0d1117;
406
+ color: #c9d1d9;
407
+ font-size: 0.82rem;
408
+ line-height: 1.65;
409
+ }
410
+ .code-body code {
411
+ background: none;
412
+ padding: 0;
413
+ font-size: inherit;
414
+ color: inherit;
415
+ }
416
+ .c-comment {
417
+ color: #8b949e;
418
+ }
36
419
  </style>
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "dev": "gaon dev",
11
+ "build": "vite build",
11
12
  "serve": "gaon serve",
12
13
  "work": "gaon work",
13
14
  "hub": "gaon hub",