@gaonjs/cli 0.64.0 → 0.65.2

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 (44) hide show
  1. package/dist/commands/check.js +34 -18
  2. package/dist/commands/g.d.ts +1 -0
  3. package/dist/commands/g.js +11 -2
  4. package/dist/commands/gen.js +1 -1
  5. package/dist/dev.d.ts +13 -7
  6. package/dist/dev.js +30 -22
  7. package/dist/doctor/fixers/i18n-layout.d.ts +2 -2
  8. package/dist/doctor/fixers/i18n-layout.js +119 -25
  9. package/dist/doctor/fixers/index.d.ts +1 -1
  10. package/dist/doctor/fixers/index.js +13 -3
  11. package/dist/doctor/fixers/types.d.ts +17 -2
  12. package/dist/doctor/i18n-app-scope.d.ts +3 -0
  13. package/dist/doctor/i18n-app-scope.js +94 -65
  14. package/dist/doctor/i18n-layout.d.ts +1 -1
  15. package/dist/doctor/i18n-layout.js +157 -32
  16. package/dist/doctor/i18n-server-scope.d.ts +2 -0
  17. package/dist/doctor/i18n-server-scope.js +124 -0
  18. package/dist/doctor/locale-parity.js +29 -68
  19. package/dist/doctor/redundant-index.d.ts +7 -0
  20. package/dist/doctor/redundant-index.js +67 -0
  21. package/dist/doctor/schema-relations.d.ts +13 -0
  22. package/dist/doctor/schema-relations.js +5 -1
  23. package/dist/doctor/types.d.ts +1 -1
  24. package/dist/doctor.d.ts +3 -1
  25. package/dist/doctor.js +42 -10
  26. package/dist/i18n-config.d.ts +9 -9
  27. package/dist/i18n-config.js +8 -15
  28. package/dist/messages-gen.d.ts +10 -10
  29. package/dist/messages-gen.js +42 -54
  30. package/dist/scaffold/app.d.ts +10 -1
  31. package/dist/scaffold/app.js +12 -2
  32. package/dist/templates/project/AGENTS.md.tpl +8 -6
  33. package/dist/templates/project/agents/data.md.tpl +6 -0
  34. package/dist/templates/project/agents/frontend.md.tpl +218 -75
  35. package/dist/templates/project/agents/i18n.md.tpl +165 -70
  36. package/dist/templates/project/agents/web.md.tpl +4 -3
  37. package/dist/templates/project/{locales → apps/web/locales}/en/frontend.json.tpl +1 -0
  38. package/dist/templates/project/{locales → apps/web/locales}/ko/frontend.json.tpl +1 -0
  39. package/dist/templates/project/gaon.config.ts.tpl +6 -5
  40. package/package.json +7 -7
  41. package/dist/templates/project/apps/web/locales/en.json.tpl +0 -3
  42. package/dist/templates/project/apps/web/locales/ko.json.tpl +0 -3
  43. /package/dist/templates/project/{locales → domain/locales}/en/backend.json.tpl +0 -0
  44. /package/dist/templates/project/{locales → domain/locales}/ko/backend.json.tpl +0 -0
@@ -340,83 +340,204 @@ import PageShell from '@shared/components/ui/PageShell.vue'
340
340
  | 원자 (18) | Button · Input · Label · Badge · Card · CardHeader · CardTitle · CardDescription · CardContent · CardFooter · Alert · AlertTitle · AlertDescription · Form · FormField · FormMessage · Dialog · Sheet |
341
341
  | 블록 (4 · 결정 106) | PageShell · PageHeader · EmptyState · Pagination |
342
342
 
343
- **슬롯·props 요약 (첫 시도용 · 소스 안 읽어도 되게 · O-2):** 카탈로그는 이름만이라 슬롯/prop 을
344
- 소스에서 찾아야 했다자주 쓰는 표면을 여기 못박는다(전체·정확한 타입은 컴포넌트 소스가 정본).
345
- **주의: named slot 이름이 비대칭이다** `PageHeader` 는 `#actions`(**복수**), `EmptyState`
346
- `#action`(**단수**). 기본 슬롯을 잘못 쓰면 조용히 그려진다.
343
+ **컴포넌트 사용법 — 킷 21종 전수 (첫 시도용 · 소스 안 읽어도 되게 · O-2).** 카탈로그가
344
+ 이름만이면 슬롯/prop 을 소스에서 찾아야 한다표면 전부를 여기 못박는다. 아래 값은
345
+ `gaon g ui-kit` 심는 **템플릿 소스의 `defineProps`·`defineEmits`·`<slot>` 에서 확인한
346
+ 것**이다(그래도 정본은 프로젝트에 복사된 여러분의 파일이다 고쳤다면 고친 쪽이 맞다).
347
+ **주의: named slot 이름이 비대칭이다** — `PageHeader` 는 `#actions`(**복수**), `EmptyState`
348
+ 는 `#action`(**단수**). 슬롯 이름을 틀리면 오류 없이 **조용히 안 그려진다**.
347
349
 
348
- | 컴포넌트 | props | slots | emits |
350
+ *블록 (4 · 결정 106) 화면 골격.*
351
+
352
+ | 컴포넌트 | props (기본값) | slots | emits |
349
353
  |---|---|---|---|
350
- | **PageShell** | `size?: 'default'\|'narrow'\|'wide'\|'full'` | 기본 | — |
351
- | **PageHeader** | `title?` · `description?` | `#title` · `#description` · **`#actions`**(복수) | — |
352
- | **EmptyState** | `title?` · **`description?`(prop)** | `#icon` · `#title` · `#description` · **`#action`**(단수) | — |
353
- | **Pagination** | `page`(필수) · `pageCount`(필수) · `siblings?=1` | — | `update:page` (= `v-model:page`) |
354
- | Button | `variant?='default'` · `size?='default'` · `type?='button'` · `href?` · `external?` · `target?` | 기본 | 네이티브(예 `@click`) |
355
- | FormField | `label?` · `error?` | 기본(컨트롤) | |
356
- | FormMessage | `message?` | — | — |
357
- | Alert | `variant?: 'default'\|'destructive'` | 기본 | |
358
- | Badge | `variant?: 'default'\|'secondary'\|'destructive'\|'outline'` | 기본 | — |
359
- | Card / CardHeader / CardTitle / CardDescription / CardContent / CardFooter | | 기본(조합) | |
360
-
361
- - **블록은 성격 중립(결정 106)** 관리자/프론트를 나누지 않고 앱에서 쓴다.
362
- `PageShell`(최대폭·여백·세로 리듬) · `PageHeader`(제목+설명+액션) · `EmptyState`
363
- (빈 목록) · `Pagination`(페이지 이동 · `v-model:page`). 이외 블록(DataTable·StatCard·
364
- Tabs 등)은 아직 만들지 않는다(예약 · 실물 도그푸딩 후).
365
- - **반응형은 책임(결정 107)** — 폭·여백·열 같은 레이아웃 반응형은 `PageShell`
366
- 블록이 소유한다. **페이지 코드에 레이아웃 브레이크포인트(`sm:flex-row`·
367
- `md:grid-cols-2` 등)를 직접 쓰지 않는다** 킷에 표현이 있으면 킷을 쓴다.
368
- (탈출구: 킷에 없는 표현이면 Tailwind 유틸을 직접 써도 된다 doctor
369
- **page-layout-breakpoint** 강제가 아닌 **안내 경고**다.)
370
- - **폼은 UI Form + gaonjs `useForm`(결정 64)** `Form` 얇은 `<form>` 래퍼로
371
- `@submit` 을 `useForm` 의 `post/put/delete` 로 넘긴다. vee-validate 를 끌어오지
372
- 않는다(검증·상태는 `useForm`). `FormField label error` + `FormMessage` 로 라벨·
373
- 오류를 붙이고, `:error="form.errors.<field>"` 로 서버 검증을 표시한다 —
374
- 서버 스키마 검증 실패는 `form.errors.<field>` **자동 반영**된다(결정 109 ·
375
- 컨트롤러가 손으로 다시 렌더하지 않는다 · `agents/web.md` §4.1).
376
- - **`class` 는 폴스루로 병합**단일 루트 컴포넌트는 `<Button class="w-full">` 처럼
377
- 넘긴 클래스가 루트로 흘러간다(별도 `class` prop 선언 없음). `cn` 은 충돌 클래스
378
- 자동 해소를 하지 않는다 오버라이드가 잦으면 tailwind-merge 를 설치해 `cn` 만 교체.
379
- - **버튼 모양 링크 = `<Button href>`(결정 113) — `Link` 로 `Button` 을 감싸지 않는다.**
380
- `<Link href="/x"><Button>…</Button></Link>``<a><button>` 중첩(HTML 비준수·접근성
381
- 결함)이다. `Button` `href` 주면 내부에서 SPA 이동 링크(`Link`=`<a>`)로 렌더한다:
382
- `<Button href="/posts/new">새 글</Button>`. 외부 URL `<Button href="https://…" external
383
- target="_blank">`. 순수 버튼은 `href` 없이 `<Button @click="…">`. doctor **link-button-nesting**
384
- Link>Button 중첩을 경고한다.
385
- - **디자인 토큰은 `style.css` 곳(결정 74)** — 컴포넌트는 `bg-primary`·
386
- `text-muted-foreground` 같은 의미 토큰만 쓰고, 실색은 `apps/<앱>/style.css` 의
387
- `:root`/`.dark` CSS 변수에서 바꾼다(다크 모드 = `<html class="dark">`).
388
- - **shared 킷의 허용/금지 API(결정 25·105)** — 킷은 `shared/` 라 라우트를 몰라야
389
- 한다: 허용 = 라우트 키와 무관한 범용 API(`useForm`·`Link`·`router`) · 금지 =
390
- 라우트 지식(`api()`·`pageProps`). 데이터는 props 받는다(예 `Pagination`
391
- `v-model:page` 현재 페이지만 올려보내고 실제 이동은 페이지가 정한다).
392
- - **`Pagination` 블록은 `paginate()` 결과에 바로 맞는다(결정 119·106)** — 컨트롤러가
393
- `chain.paginate(page, perPage)` 만든 `{ rows, total, page, pageCount, perPage }`
394
- 통째로 넘기면, 블록의 `:page`·`:pageCount` 필드명 그대로 붙는다(매핑 보일러플레이트 0).
395
- ```vue
396
- <script setup lang="ts">
397
- import Pagination from '@shared/components/ui/Pagination.vue'
398
- import { pageProps, router } from 'gaonjs/vue'
399
- const props = pageProps<'web:posts#index'>() // props.page = paginate 결과
400
- function goto(p: number) { router.get('/posts', { page: p }, { preserveState: true }) }
401
- </script>
402
- <template>
403
- <article v-for="post in props.page.rows" :key="post.id">…</article>
404
- <Pagination :page="props.page.page" :page-count="props.page.pageCount" @update:page="goto" />
405
- </template>
406
- ```
407
- - **멀티앱은 앱마다 Tailwind 배선이 따로다(결정 76)** — 킷은 shared 한 벌이지만,
408
- 앱이 Tailwind 유틸을 받으려면 앱에 `style.css` 배선이 있어야 한다.
409
- `gaon g app admin` 배선을 동봉하고, `gaon g ui-kit --app admin` 은 배선이
410
- 없으면 멱등 보정한다(`--app` 이제 위치가 아니라 배선만 정한다).
411
- `tailwind.config.ts`·`postcss.config.js` 프로젝트 루트 공유이고 `content`
412
- `apps/**` `shared/**` 함께 훑는다. 앱이 킷을 import 하는데 배선이 없으면
413
- doctor **ui-kit-wiring** 경고한다.
414
-
415
- **기존 프로젝트 마이그레이션(결정 105 이전 이후):** 앱별 사본(`apps/<앱>/components/ui`
416
- ·`apps/<앱>/lib/utils.ts`)이 있으면 `gaon g ui-kit` 다시 실행해 `shared/` 킷을
417
- 만든 뒤, 앱 사본을 지우고 import 를 `@shared/components/ui/…` 로 바꾼다.
418
- `tailwind.config.ts` `content` `./shared/**/*.{vue,ts}` 있는지도 확인한다
419
- (스캐폴드 기본값엔 이미 포함).
354
+ | **PageShell** | `size?: 'default'\|'narrow'\|'wide'\|'full'` (`'default'`) | 기본 | — |
355
+ | **PageHeader** | `title?: string` · `description?: string` | 기본 없음 · `#title` · `#description` · **`#actions`**(복수) | — |
356
+ | **EmptyState** | `title?: string` · `description?: string` | 기본 없음 · `#icon` · `#title` · `#description` · **`#action`**(단수) | — |
357
+ | **Pagination** | `page: number`(필수) · `pageCount: number`(필수) · `siblings?: number` (`1`) | — | `update:page` (= `v-model:page`) |
358
+
359
+ *원자 (18) — 표면·컨트롤.*
360
+
361
+ | 컴포넌트 | props (기본값) | slots | emits |
362
+ |---|---|---|---|
363
+ | **Button** | `variant?: 'default'\|'secondary'\|'destructive'\|'outline'\|'ghost'\|'link'` (`'default'`) · `size?: 'default'\|'sm'\|'lg'\|'icon'` (`'default'`) · `type?: 'button'\|'submit'\|'reset'` (`'button'`) · `href?: string` · `external?: boolean` · `target?: string` | 기본 | 네이티브(예 `@click`) |
364
+ | **Input** | `modelValue?: string \| number` | — | `update:modelValue` (= `v-model`) |
365
+ | **Label** || 기본 | |
366
+ | **Badge** | `variant?: 'default'\|'secondary'\|'destructive'\|'outline'` (`'default'`) | 기본 | — |
367
+ | **Alert** | `variant?: 'default'\|'destructive'` (`'default'`) | 기본 | — |
368
+ | **AlertTitle** / **AlertDescription** | | 기본 | — |
369
+ | **Card** / **CardHeader** / **CardTitle** / **CardDescription** / **CardContent** / **CardFooter** | | 기본(조합) | |
370
+ | **Form** | | 기본 | `submit` (`@submit` 를 `useForm` 의 post/put/delete 로 넘긴다) |
371
+ | **FormField** | `label?: string` · `error?: string` | 기본(컨트롤) | — |
372
+ | **FormMessage** | `message?: string` | ||
373
+ | **Dialog** | `open: boolean`(필수) | 기본 | `update:open` (= `v-model:open`) |
374
+ | **Sheet** | `open: boolean`(필수) · `side?: 'left'\|'right'` (`'right'`) | 기본 | `update:open` (= `v-model:open`) |
375
+
376
+ **쓸 자주 틀리는 것:**
377
+
378
+ - **`PageHeader`·`EmptyState` 기본 슬롯이 없다** 제목/설명은 prop 이거나 named slot
379
+ 이다. `<PageHeader>제목</PageHeader>` 아무것도 그린다(오류도 난다).
380
+ - **`Dialog`·`Sheet` 는 `v-model:open` 이다**`:open` 주면 열리기만 하고 안 닫힌다
381
+ (닫기 요청이 `update:open` 으로 나가는데 받는 쪽이 없다).
382
+ - **`Pagination` `v-model:page` 이거나 `@update:page`** 실제 이동(`router.get`)은
383
+ **페이지가** 한다. 킷은 라우트를 모른다(결정 25·105).
384
+ - **`Input` 의 `update:modelValue` `string`** 이다. 숫자를 받아야 하면 페이지에서
385
+ 변환한다(`Number(...)`) 킷이 타입을 추측하지 않는다.
386
+ - **`Button` variant 6종뿐이다** `dangerSoft`·`plain` 같은 이름은 이 킷에 없다.
387
+ 필요하면 여러분의 `Button.vue` 직접 더한다(복사-소유).
388
+ - **`class` 는 그냥 넘기면 루트로 폴스루된다** — `<Button class="w-full">`. 별도
389
+ `class` prop 없다.
390
+
391
+ > **왜 여기 코드 예시가 없나:** 문서 예시 컴파일 게이트(결정 458)의 픽스처
392
+ > 워크스페이스에는 킷이 `Button`·`Card`·`PageShell` 있고 나머지(`PageHeader`·
393
+ > `EmptyState`·`Pagination`·`Form`·`FormField`·`Input`·`Dialog`·`Badge`) 없어,
394
+ > 이들을 쓰는 예시는 검증할 없다. 검증 못 하는 예시는 싣지 않는다 — 위 표가
395
+ > 표면의 정본이고, 조합 예시는 `gaon new` 스캐폴드의 실제 페이지가 정본이다.
396
+
397
+ **킷을 넓힐 관리 화면 블록 관례.** 카탈로그(원자 18 + 블록 4)는 `gaon new` 가
398
+ 심는 **최소 벌**이다. 관리 화면을 만들면 목록·표·트리 같은 블록이 곧 필요해지는데
399
+ 이것들은 **프레임웍이 주지 않는다** — 여러분 프로젝트의 `shared/components/ui/` 에
400
+ 직접 만들어 소유한다(위 "예약" 항목). 그때의 관례는 아래와 같다. 이름은 예시일 뿐
401
+ 프레임웍 API 아니다 — 규칙만 가져가고 이름은 프로젝트가 정한다.
402
+
403
+ - **목록 화면은 순서를 고정한다** — 헤더 → 필터 바 → 목록 카드(편집 + 본문)
404
+ 페이지네이션. 화면마다 순서가 다르면 같은 콘솔 안에서 손이 헤맨다. 블록 하나가
405
+ 이 순서를 소유하고 각 자리를 named slot 으로 연다. **빈 슬롯은 그 줄째 사라지게**
406
+ 한다 — 자리를 남기려고 공백을 넣지 않는다.
407
+ - **목록의 표는 CSS 그리드로 짠다** — `display:grid` + `grid-template-columns`.
408
+ 컬럼 폭·정렬·좁은 화면 숨김이 **컬럼 정의 한 곳**에 모여, 열을 하나 넣고 뺄 때
409
+ `colgroup`·`th`·`td` 세 군데를 고칠 일이 없다. 체크 열·액션 열을 앞에 붙이는
410
+ 계산도 문자열 하나로 끝난다. **대신 `role` 을 직접 붙인다** — `<table>` 이 공짜로
411
+ 주던 행·열 관계가 그리드엔 없어서, `role="grid"/"row"/"columnheader"/"gridcell"`
412
+ `aria-sort` 없으면 스크린리더에는 표가 아니라 글자 더미로 읽힌다. `<table>`
413
+ 본문 안에 끼우는 짧은 표에만 남긴다.
414
+ - **정렬 헤더는 목록 전체가 클라이언트에 있을 때만 연다.** 서버 페이지네이션
415
+ (`paginate()`) 목록에서 헤더 정렬을 열면 **현재 쪽만** 정렬돼 "가격 높은 순" 이
416
+ 전체가 아니라 안에서만 맞는 거짓말이 된다. 서버 정렬을 붙이기 전까지는 그
417
+ 컬럼의 정렬을 막아 둔다.
418
+ - **상태 문자열 → 색 매핑은 한 곳에 둔다** — `shared/lib/` 에 `statusTone(status)`
419
+ 같은 함수 하나를 두고 배지가 그것만 쓴다. 화면마다 삼항 연산으로 색을 고르면 같은
420
+ "미처리" 화면에 따라 다른 색이 된다. 모르는 값은 중립색으로 떨어뜨려 화면이
421
+ 깨지지 않게 한다.
422
+ - **떠 있는 패널(드롭다운·달력·빠른 동작)은 `Teleport` 여부를 "자르는 조상" 으로
423
+ 판단한다.** 카드처럼 `overflow` 로 자르는 조상 안에서 열리면 → `Teleport to="body"`
424
+ + 좌표 계산(안 그러면 패널이 잘린다). ② 헤더·사이드바처럼 자르는 조상이 없고 앵커에
425
+ 붙어 스크롤을 따라가야 하면 → Teleport 하지 않는다. **②의 경우 그 패널을 감싸는
426
+ 상자에 `overflow-hidden` 을 주지 않는다** — 모서리를 둥글리려고 무심코 준
427
+ `overflow-hidden` 하나가 패널을 통째로 안 보이게 만든다(모서리는 안쪽 면에 직접
428
+ 둥글리기를 준다).
429
+ - **트리거의 여닫기는 쓰는 쪽이 붙인다.** 드롭다운 블록은 열림 상태와 "바깥 클릭·Esc
430
+ 로 닫기" 만 갖고 `@click` 은 쓰는 쪽이 준다 — 상단 바처럼 여럿이 나란히 있을 때
431
+ "여는 쪽이 나머지를 닫는" 규칙을 블록이 대신 정해 버리면 그 규칙을 바꿀 수 없다.
432
+ - **셸(사이드바·상단 바)이 든 상태는 슬롯 프롭으로 내린다.** 레이아웃은 셸의 **부모**라
433
+ `provide`/`inject` 로는 셸이 든 상태(접힘·콘텐츠 넓이)를 받지 못한다 — `inject` 가
434
+ 조용히 `undefined` 가 되고 "코드는 맞는데 화면만 안 바뀐다" 로 나타난다.
435
+ - **사이드바 활성 판정은 "가장 긴 접두 하나" 다.** 정확 일치만 보면 상세 화면
436
+ (`/admin/members/1`)에서 아무 메뉴도 안 켜지고, 그냥 `startsWith` 면 `/admin/posts`
437
+ 와 `/admin/posts/trash` 가 **함께** 켜진다. 후보 중 현재 경로의 접두이면서 가장 긴
438
+ 것 하나만 켠다(경계는 세그먼트 단위 — `/admin/postscript` 가 `/admin/posts` 를 먹지
439
+ 않게). 루트 항목(`/admin`)은 접두로 이기지 않게 정확 일치로 둔다. 이 판정은 **앱이**
440
+ 한다 — 킷은 라우트를 모른다(결정 25·105).
441
+ - **shared 블록이 문구를 직접 번역하면 그 키는 전 앱 카탈로그에 있어야 한다** —
442
+ `shared/` 는 어느 앱 번들에도 실릴 수 있어서다. 한 앱에만 있으면 다른 앱 화면에서만
443
+ 키 문자열이 뜨는 조용한 실패가 된다(doctor **i18n-app-scope** 가 잡는다 ·
444
+ `agents/i18n.md`). 문구를 props 로 끌어올려 회피하지 말고 키를 복제한다.
445
+
446
+ **콘솔 블록 표면 정본 (프로젝트가 만들어 쓰는 확장 킷).** 아래는 관리 콘솔을 실제로
447
+ 만들며 굳은 표면이다. **`gaon new` 는 이것들을 심지 않는다** — 여러분 프로젝트의
448
+ `shared/components/ui/` 에 만들어야 있고, 만들 때 이 표면을 그대로 쓰면 화면 코드가
449
+ 프로젝트 사이에서 옮겨 다닌다. 이미 만들어져 있다면 **여러분 파일이 정본**이다(고쳤다면
450
+ 고친 쪽이 맞다). 값은 실제 `defineProps`·`defineEmits`·`<slot>` 에서 확인한 것이다.
451
+
452
+ *목록 화면.*
453
+
454
+ | 컴포넌트 | props (기본값) | slots | emits |
455
+ |---|---|---|---|
456
+ | **ListPage** | `title?` · `description?` · `total?: number`(편집 바 "N건") · `listTitle?`(목록 카드 제목) · `listHint?` · `asideWidth?: number` (`260`) | 기본(목록 본문) · `#actions` · `#aside` · `#filters` · `#filter-bulk` · `#filter-actions` · `#list-actions` · `#pagination` | `search` |
457
+ | **DataGrid** | `columns: GridColumn[]`(필수) · `rows: Record<string,unknown>[]`(필수) · `rowKey: string`(필수) · `selectable?` (`false`) · `selected?: string[]` (`[]`) · `actions?: 'none'\|'detail'\|'full'` (`'none'`) · `labelKey?` · `sortKey?` · `sortDir?: 'asc'\|'desc'` (`'asc'`) · `minWidth?: number` (`640`) | `#cell-<컬럼키>`(셀마다) | `sort(key)` · `toggleAll(on)` · `toggleOne(id,on)` · `detail(row)` · `edit(row)` · `remove(row)` |
458
+ | **FilterBar** | `as?: string` (`'form'`) | 기본(필터 격자) · `#bulk`(아래 줄 좌) · `#actions`(아래 줄 우) | `submit` |
459
+ | **ListBulk** | `sizeLabel: string`(필수) · `selectedCount: number`(필수) · `statuses?: string[]`(없으면 셀렉트 자체가 안 나옴) | — | `update:sizeLabel` · `status(value)` · `remove` |
460
+ | **RowActions** | `detailOnly?` (`false`) · `label?`(스크린리더용 대상 이름) | — | `detail` · `edit` · `remove` |
461
+ | **UserCell** | `user: UserCellUser`(필수) · `interactive?` (`true`) · `statusOptions?` · `gradeOptions?` | — | `action(key, user, value?)` |
462
+ | **TitleCell** | `title: string`(필수) · `prefix?`(앞 배지) · `prefixTone?: 'accent'\|'danger'\|'warning'\|'neutral'` (`'accent'`) · `count?`·`likes?`·`files?` (`0` · 0이면 안 나옴) · `depth?` (`0`) · `secret?` (`false`) | — | — |
463
+
464
+ `GridColumn` = `{ key, label, w?, num?, mono?, hideMobile?, noSort? }` — `w` 는
465
+ `grid-template-columns` 조각(없으면 `minmax(0,1fr)`) · `num` 은 우측 정렬 + `tabular-nums`
466
+ · `hideMobile` 은 좁은 화면 숨김 · `noSort` 는 정렬 헤더를 막는다(서버 페이지네이션).
467
+
468
+ *설정·상세 화면.*
469
+
470
+ | 컴포넌트 | props (기본값) | slots | emits |
471
+ |---|---|---|---|
472
+ | **Splitter** | `width?: number` (`230`) · `minLeft?` (`230`) · `minRight?` (`220`) · `height?: string`(비우면 화면 높이) · `stackAt?: number` (`768`) | `#left` · `#right` | `update:width` |
473
+ | **TreeView** | `nodes: TreeNode[]`(필수) · `title?` · `selected?` · `addLabel?: string\|null`(null 이면 추가 버튼 숨김) · `searchPlaceholder?` · `totalLabel?` · `editable?` (`false`) · `reorderable?` (`false`) | — | `select(node, path)` · `add` · `edit(node, depth)` · `reorder(from, to, 'before'\|'after')` |
474
+ | **DetailFilter** | `selects: DetailSelect[]`(필수) · `values: string[]`(필수) · `range: DateRange`(필수) · `query: string`(필수) · `searchPlaceholder: string`(필수) | — | `update:values` · `update:range` · `update:query` · `reset` · `search` |
475
+ | **PermissionMatrix** | `modelValue: Record<string,string>`(필수) · `rows: MatrixRow[]`(필수 · `{key,label,desc}` — **`desc` 도 필수**) · `targets: string[]`(필수) · `disabled?` | — | `update:modelValue` |
476
+ | **SettingRow** | `setting: Setting`(필수) · `typeLabel: string`(필수) · `editMode?` (`false`) | — | `change` · `revert` · `remove` · `copyKey` · `toggleScope` |
477
+
478
+ *컨트롤·표시.*
479
+
480
+ | 컴포넌트 | props (기본값) | slots | emits |
481
+ |---|---|---|---|
482
+ | **FloatingField** | `label: string`(필수) · `modelValue?: string\|number` · `control?: 'input'\|'textarea'\|'select'` (`'input'`) · `type?` (`'text'`) · `rows?` (`4`) · `error?` · `hint?` · `surface?: 'background'\|'card'\|'popover'` (`'background'`) | 기본(select 의 `<option>`) | `update:modelValue` |
483
+ | **DateRangeField** | `modelValue?: DateRange` · `label?` · `surface?` (`'card'`) | — | `update:modelValue` |
484
+ | **Segmented** | `modelValue: string`(필수) · `options: {value,label}[]`(필수) · `label?` · `disabled?` | — | `update:modelValue` |
485
+ | **Switch** | `modelValue?: boolean` · `label?` · `disabled?` | — | `update:modelValue` |
486
+ | **Checkbox** | `modelValue?: boolean` · `label?` | 기본 | `update:modelValue` |
487
+ | **Select** / **Textarea** | `modelValue?` (Textarea 는 `rows?` 추가) | Select: 기본(`<option>`) | `update:modelValue` |
488
+ | **Icon** | `name: string`(필수 · 레지스트리 키) · `size?: number` (`15`) · `strokeWidth?: number` (`1.75`) | — | — |
489
+ | **Avatar** | `name: string`(필수) · `src?` · `size?: 'sm'\|'default'\|'lg'` (`'default'`) | — | — |
490
+ | **StatCard** | `label: string`·`value: string`(필수) · `delta?` · `trend?: 'up'\|'down'\|'flat'` (`'flat'`) · `icon?` · `clickable?` (`false`) | — | — |
491
+ | **Tooltip** | `text: string`(필수) · `side?: 'top'\|'bottom'` (`'top'`) | 기본 | — |
492
+ | **Skeleton** | `variant?: 'text'\|'block'\|'circle'` (`'block'`) | — | — |
493
+ | **Spinner** | `size?: 'sm'\|'default'\|'lg'` (`'default'`) · `label?` | — | — |
494
+
495
+ *떠 있는 것 · 셸.*
496
+
497
+ | 컴포넌트 | props (기본값) | slots | emits |
498
+ |---|---|---|---|
499
+ | **Dropdown** | `open: boolean`(필수) · `align?: 'left'\|'right'` (`'right'`) · `up?` (`false`) · `width?` (`'192px'`) · `label?` | `#trigger`(**여닫기 `@click` 은 여기에 직접 붙인다**) · 기본(패널 내용) | `update:open` |
500
+ | **ConfirmDialog** | `open: boolean`·`title: string`(필수) · `description?` · `confirmLabel?` · `cancelLabel?` | — | `update:open` · `confirm` |
501
+ | **Toast** | `title: string`(필수) · `description?` · `variant?: 'default'\|'success'\|'warning'\|'destructive'` (`'default'`) | `#action` | — |
502
+ | **Banner** | `title: string`(필수) · `description?` · `tone?: 'neutral'\|'accent'\|'success'\|'warning'\|'danger'\|'info'` (`'info'`) · `action?`(없으면 링크 자체가 안 나옴) · `dismissible?` (`false`) | — | `action` · `dismiss` |
503
+ | **Editor** | `modelValue?: string`(HTML) · `placeholder?` · `minHeight?: number` (`220`) | — | `update:modelValue` |
504
+ | **Tabs** | `modelValue: string`(필수) | 기본(TabList·TabPanel 조합) | `update:modelValue` |
505
+ | **Collapse** | `open: boolean`(필수) | 기본 | — |
506
+ | **AdminShell** | `size?: 'default'\|'wide'\|'shell'\|'full'` (`'shell'`) · `align?: 'center'\|'start'` (`'start'`) · `sidebar?: 'expanded'\|'mini'\|'off'` (`'expanded'`) · `collapsible?` (`true`) | 기본 · `#brand`(슬롯 프롭 `{mini}`) · `#nav` · `#topbar` · `#topbar-actions`(슬롯 프롭 `{fullWidth,setFullWidth}`) · `#account` · `#sidebar-footer` · `#footer` | — |
507
+
508
+ *레이아웃 원자(간격을 값이 아니라 이름으로 준다).*
509
+
510
+ | 컴포넌트 | props (기본값) |
511
+ |---|---|
512
+ | **Container** | `size?: 'narrow'\|'default'\|'wide'\|'shell'\|'full'` (`'default'`) · `gutter?` (`true`) · `align?: 'center'\|'start'` (`'center'`) |
513
+ | **Stack** | `gap?: 'none'\|'inline'\|'stack'\|'group'\|'section'` (`'stack'`) · `as?` (`'div'`) · `align?` (`'stretch'`) |
514
+ | **Cluster** | `gap?: 'none'\|'inline'\|'stack'\|'group'` (`'inline'`) · `justify?: 'start'\|'center'\|'end'\|'between'` (`'start'`) · `align?` (`'center'`) · `wrap?` (`true`) |
515
+ | **Grid** | `cols?: 'auto'\|1\|2\|3\|4` (`'auto'`) · `gap?` (`'group'`) |
516
+ | **Section** | `gap?: 'stack'\|'group'\|'section'` (`'group'`) · `label?` |
517
+ | **Separator** | `orientation?: 'horizontal'\|'vertical'` (`'horizontal'`) · `decorative?` (`true`) |
518
+
519
+ *함께 쓰는 컴포저블 (`shared/lib/`).*
520
+
521
+ | 이름 | 인자 | 반환 |
522
+ |---|---|---|
523
+ | **`useSort(rows)`** | `ComputedRef<T[]>`(필터까지 끝난 행) | `{ sortKey, sortDir, sorted, toggle(key) }` — 같은 키 재클릭이면 방향만 뒤집는다 |
524
+ | **`useListPage({ rows, key, size? })`** | `rows`=정렬까지 끝난 행 · `key(row)=>string` · `size`=초기 표시 개수 | `{ page, sizeLabel, pageSize, pageCount, paged, selected, allChecked, selectionNote, toggleAll, toggleOne, clear }` |
525
+ | **`statusTone(status)`** | 상태 문자열 | `Tone` — 모르는 값은 `neutral` 로 떨어진다 |
526
+ | **`useToast()`** | — | `{ toasts, toast, success, warning, error, dismiss }` — 렌더는 레이아웃에 한 번 둔 `ToastRegion` 이 한다 |
527
+
528
+ **조립 순서(외우기).** `ListPage`(골격) → `#filters` 에 `FloatingField`·`DateRangeField` →
529
+ `#filter-bulk` 에 `ListBulk` → 본문에 `DataGrid`(셀은 `#cell-<키>` 로 `UserCell`·
530
+ `TitleCell`·`Badge`) → `#pagination` 에 `Pagination`. 상태는
531
+ `useSort` → `useListPage` 순서로 감는다(정렬한 뒤 쪽을 자른다 · 반대로 하면 현재 쪽
532
+ 안에서만 정렬된다).
533
+
534
+ **킷 사용법 문서는 데이터로 두고 화면이 렌더한다.** 블록이 열 개를 넘어가면 "이게 무슨
535
+ 컴포넌트인지" 를 매번 소스에서 읽게 된다. 항목(설명·props·사용 예·규칙)을 **모듈 하나**에
536
+ 모으고 그것을 렌더하는 페이지를 하나 두면, 컴포넌트를 더할 때 항목만 더하면 된다.
537
+ 그 페이지에는 **실물 미리보기**를 함께 낸다 — 살아 있는 컴포넌트를 그대로 렌더하면
538
+ 컴포넌트가 바뀔 때 미리보기도 같이 바뀌어 문서가 늙지 않는다(스크린샷은 늙는다).
539
+ props 표에는 **실제 `defineProps` 에서 확인한 것만** 적는다 — 없는 prop 을 그럴듯하게
540
+ 적어 두느니 행을 비우는 편이 낫다.
420
541
 
421
542
  ### 9. 클라이언트 환경변수 — `env` (결정 198 · F-9 옵션 ②)
422
543
 
@@ -539,6 +660,28 @@ async function runSearch(q: string) {
539
660
  (결정 37).
540
661
  - **`v-html` 은 XSS 탈출구** — 사용자 입력을 넣지 않는다
541
662
  (`agents/security.md`).
663
+ - **`gaon dev` 에 Vue HMR 은 없다 — 저장 후 브라우저를 새로고침한다.** dev 는 Vite
664
+ dev 서버를 띄우지 않고 **`vite build --watch`** 로 번들을 다시 만들며(앱마다 ·
665
+ 결정 146), 서버는 `dist/<앱>` 을 서빙한다. `.vue`·`.ts`·`theme/*.css` 를 고치면
666
+ 재빌드가 돌지만(실측 1.1~1.3초) **화면은 새로고침해야 바뀐다** — Inertia 탓이
667
+ 아니라 v1 어댑터에 Vite 통합이 없어서다(§6.4). "고쳤는데 화면이 그대로" 를 코드
668
+ 문제로 오진하지 말 것. 콘솔의 `[vite] built in …` 이 재빌드 완료 신호다.
669
+ - **`tailwind.config.ts` 를 고치면 `gaon dev` 를 재시작한다** — watch 프로세스가
670
+ Tailwind 설정을 물고 있어 **재빌드가 돌아도 새 유틸이 생성되지 않는다**(실측
671
+ 2026-08-07: `theme.extend.fontSize` 에 계단을 더했는데 `.text-<이름>` 이 CSS 에
672
+ 아예 안 나옴 → 클래스가 조용히 무효). 증상이 "클래스를 썼는데 스타일이 하나도
673
+ 안 먹는다" 라 오진하기 쉽다. **토큰 값(`theme/*.css`)만 고칠 때는 새로고침으로
674
+ 충분하고, 토큰↔유틸 매핑(설정)을 고칠 때만 재시작**한다.
675
+ - **웹폰트·정적 자산은 CSS 에서 상대경로로 참조한다** — `theme/fonts/*.woff2` 처럼
676
+ 두고 `@font-face { src: url('./fonts/…') }` 로 가리키면 Vite 가 해시해
677
+ `dist/<앱>/assets/` 로 내보내 코어의 `/assets/*` 서빙과 CSP `font-src 'self'` 를
678
+ 그대로 통과한다. **Vite `public/` 은 쓸 수 없고**(코어는 `dist/<앱>/assets/*` 만
679
+ 서빙 — dist 루트 산출물은 라우트가 없어 404), **`apps/<앱>/static/` 도
680
+ `index.html` 에서 참조하면 안 된다**(런타임은 서빙하지만 `gaon build` 산출
681
+ 검증(결정 146)이 "index.html 이 참조하는 에셋이 dist 에 없다" 로 막는다).
682
+ `index.html` 에서 스크립트를 하나 더 실어야 하면 **`<script type="module"
683
+ src="./x.js">`**(번들 대상)로 쓴다 — **인라인 `<script>` 는 기본 CSP
684
+ `script-src 'self'` 가 차단한다**(규칙 8 · 콘솔 위반).
542
685
 
543
686
  ## 관련 결정 번호
544
687