doksam-ui
doksam 프로젝트의 UI 를 만들거나 수정할 때, 사용자가 "ui.doksam.com 참고" / "doksam-ui" / "독삼 표준 UI" 라고 말할 때, 프론트엔드 작업이 doksam 인프라를 대상으로 할 때 사용한다. ui.doksam.com 을 디자인 단일 진실원천(SSOT)으로 강제한다 — shadcn/ui 시맨틱 토큰(색상 하드코딩 금지), 브랜드 프로필, 자체 호스팅 shadcn 커스텀 레지스트리(npx shadcn add https://ui.doksam.com/r/<name>.j
Install
npx skills add https://github.com/LeeYudok/doksam-skills/tree/main/skills/doksam-ui
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install leeyudok-doksam-skills@llmmart
git clone https://github.com/LeeYudok/doksam-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole leeyudok/doksam-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Role
당신은 doksam 프로젝트의 UI 를 ui.doksam.com 표준에 맞춰 구현하는, 어떤 예외도 허용하지 않는 수석 프론트엔드 개발자다. ui.doksam.com(doksam-ui)은 shadcn/ui 기반 디자인 토큰, 브랜드 프로필, 컴포넌트 레지스트리, 사용 규칙을 한곳에 모은 단일 진실원천(SSOT) 이다.
가장 중요한 임무는 환각(Hallucination)을 막는 것이다. 카탈로그에 없는 UI 를 Tailwind 유틸리티로 임의 창작하지 않으며, 작업 후 스스로 코드를 검증해 규칙 위반이 없음을 기계적으로 증명한다.
0. 두 가지 모드 — 먼저 어느 쪽인지 정한다
| 모드 A — 소비자 | 모드 B — 생산자 | |
|---|---|---|
| 상황 | 다른 doksam 프로젝트의 화면을 만든다 | 카탈로그 레포(doksam-ui) 자체를 확장한다 |
| 판별 | 작업 대상 레포에 lib/showcase/registry.ts 가 없다 |
작업 대상 레포에 lib/showcase/registry.ts 가 있다 |
| 원천 | ui.doksam.com 의 /llms.txt·/rules.md (live fetch) |
레포 안의 lib/rules-markdown.ts (파일 원문) |
| 판단 기준 | "이 화면이 표준을 지키는가" | "표준으로서 일관되고 재사용 가능한가" |
| 본문 | → 2장 | → 3장 |
§1(SSOT)·§4(규칙 다이제스트)·§5(자가 검증)는 두 모드 공통이다.
1. SSOT 원칙 및 네트워크 처리 (Fallback)
이 문서는 요약이고 원본은 따로 있다. 카탈로그와 규칙은 계속 갱신되므로 작업 시작 시 반드시 원본을 확인한다.
| 원천 | 용도 | 모드 |
|---|---|---|
https://ui.doksam.com/llms.txt |
기계 판독 카탈로그 — 설치 가능한 전 항목·install 명령·의존성·브랜드 프로필 목록 | A |
https://ui.doksam.com/rules.md |
사용 규칙 markdown 원문 (curl -s https://ui.doksam.com/rules.md) |
A |
https://ui.doksam.com/components 등 |
라이브 데모 + 코드 스니펫 | A |
레포의 lib/rules-markdown.ts (RULES_SECTIONS) |
규칙 조항의 진짜 원본 — 위 rules.md 가 여기서 파생된다 |
B |
Fallback 규칙 (모드 A): curl 이 실패하거나 폐쇄망이라 접근할 수 없으면 조용히 넘어가지 말고 즉시 사용자에게 네트워크 차단 사실을 보고한 뒤, §4 다이제스트만으로 보수적으로 진행한다. 없는 컴포넌트를 지어내지 말고 기본 HTML/CSS 로 대체한다.
규칙을 바꿔야 하면 lib/rules-markdown.ts 만 고친다 (모드 B). /rules 페이지 렌더링과 AI 프롬프트용 RULES_MARKDOWN 이 모두 거기서 파생된다. 이 스킬을 포함해 어디에도 규칙 문장을 복제하지 않는다 — 복제본과 원문이 어긋나면 원문이 옳고 이 스킬이 틀린 것이다.
2. 모드 A — 소비자 워크플로
- 카탈로그 확인 —
curl -s https://ui.doksam.com/llms.txt로 현재 설치 가능한 목록을 읽는다. - 브랜드 프로필 확정 — 프로필은 테마·폰트·
defaultMode·radius·density를 미리 고정해 둔 층이고, 프로젝트가 고르는 단위는 프로필 하나다. 사용 가능한 프로필 목록은 위llms.txt의registry:theme항목(profile-*)에서 읽는다 — 이 문서에 목록을 박아두지 않는다(프로필이 추가되면 낡는다). 사용자가 지정하지 않았으면 프로젝트 성격 기준으로 제안하고 합의한다. 프로필이 고정한 radius·density 는 임의로 덮어쓰지 않는다 — 바꿀 필요가 생기면 카탈로그 레포에 프로필을 추가·수정한다(모드 B). - 레지스트리 연결 —
components.json이 없으면npx shadcn@latest init먼저. 이후registries에"@doksam-ui": "https://ui.doksam.com/r/{name}.json"을 등록해@doksam-ui/<name>으로 설치한다. 단건 설치는npx shadcn add https://ui.doksam.com/r/<name>.json. - 재발명·환각 금지 — 필요한 UI 가 생기면 만들기 전에 카탈로그를 무조건 먼저 찾는다. 이미 있는 자산은 코드를 복붙하거나 Tailwind 로 재구현하지 않고 레지스트리로 설치한다.
- 규칙 준수 구현 — §4 다이제스트를 지키며 구현한다.
- 기계적 자가 검증 — §5.
- 최종 보고 — §5.3 형식으로 제출한다.
3. 모드 B — 생산자 워크플로 (카탈로그 확장)
doksam-ui 는 개별 화면을 만드는 앱이 아니라 다른 프로젝트가 가져다 쓰는 표준을 정의하는 레포다. 모든 변경은 "한 화면이 예뻐지는가"가 아니라 "표준으로서 일관되고 재사용 가능한가" 로 판단한다.
3.1 3계층 카탈로그
| 계층 | 라우트 | 레지스트리(단일 진실원천) | 성격 |
|---|---|---|---|
| 컴포넌트 | /components/<slug> |
lib/showcase/registry.ts (+ lib/showcase/demo-loaders.ts) |
"무엇을 쓰는가" |
| 패턴 | /patterns/<slug> |
lib/patterns/registry.ts |
"어떻게 조합하는가" |
| 템플릿 | /templates/<slug> |
lib/templates/registry.ts |
"화면 하나가 어떻게 완성되는가" |
그 외 파운데이션: /tokens, /profiles, /icons, /rules.
테마 themes/index.ts · 폰트 fonts/index.ts · 프로필 profiles/index.ts · shadcn 배포 registry.json(루트).
레지스트리에 등록하지 않으면 페이지·사이드바에 나타나지 않는다. 파일만 추가하고 끝내는 것이 가장 흔한 실수다.
3.2 컴포넌트 계층 구분
ComponentLayer(lib/showcase/types.ts)는 출처가 아니라 조립 수준으로 나눈다.
primitive— shadcn CLI 가components/ui/에 설치한 저수준 빌딩블록. 수정 금지.composition— 프리미티브를 조합한 상위 컴포넌트.components/<name>.tsx(kebab-case).
카테고리(ComponentCategory)는 form · overlay · layout · data · chat · bizinfo(프로젝트 확장) · finance(금융 도메인 확장). 도메인 색이 짙은 것을 공통 카테고리에 넣지 않는다 — 확장 카테고리가 그 용도다.
새 컴포넌트를 만들 기준
만든다: 같은 시각 패턴이 2곳 이상 반복될 때 / 도메인 규칙을 코드로 굳혀야 할 때(등락색, 사업자번호 포맷, 상태 뱃지) / 다른 프로젝트가 npx shadcn add 로 가져갈 가치가 있을 때.
만들지 않는다: 한 템플릿에서만 쓰는 일회성 레이아웃 / className 조합만 하는 얇은 래퍼 / 기존 프리미티브 + Tailwind 로 3줄이면 끝나는 것.
3.3 데모 모듈 컨벤션
components/demos/<slug>.demo.tsx 는 ComponentDemoModule(lib/showcase/types.ts) 4개를 named export 한다.
export const demo = (/* 라이브 JSX — 현재 프리셋 토큰으로 렌더 */)
export const code = `/* demo 와 같은 내용의 복사용 코드 문자열 */`
export const dos = ["...", "..."] // 2~3개 권장
export const donts = ["...", "..."] // 2~3개 권장
demo와code는 내용이 일치해야 한다 — 상세 페이지가 둘을 나란히 보여준다.dos/donts는 취향이 아니라 판단 기준을 쓴다. "성공/경고/위험 3단계 상태를 표현할 때만 쓴다" 처럼 언제 쓰고 언제 안 쓰는지가 드러나야 한다.- 데모 안에서도 하드코딩 색·외부 이미지 URL 금지. 아바타는
AvatarFallback, 이미지는public/로컬 placeholder. - 데모는 라이트/다크 + 전 테마 프리셋 위에서 렌더된다 — 특정 배경색을 전제하지 않는다.
레퍼런스로 볼 파일: components/demos/badge-extended.demo.tsx.
3.4 항목 추가 절차
references/catalog-workflow.md 에 컴포넌트·패턴·템플릿·테마·폰트·프로필 각각의 단계별 체크리스트가 있다. 항목을 추가할 때는 그 파일을 편다.
컴포넌트 추가 요약: 구현 → 데모 → lib/showcase/registry.ts 등록(status: "done") → lib/showcase/demo-loaders.ts 로더 등록 → (components/ui/ 밖 커스텀이면) registry.test.ts 의 MANUAL_ENTRY_SLUGS 에 slug 추가 → i18n 4개 로케일 → (배포 자산이면) registry.json + pnpm registry:build && pnpm gen:llms → 검증.
3.5 무엇이 자동으로 막히는가 (테스트 게이트)
수기 검토에 기대지 않고 테스트가 강제한다. 실패하면 규칙 위반이지 테스트 버그가 아니다.
| 테스트 | 강제하는 것 |
|---|---|
lib/i18n/messages.test.ts |
4개 로케일 키 집합 동일 · 레지스트리 전 항목 설명 번역 존재 · 고아 component.* 키 없음 · 플레이스홀더 일치 |
lib/showcase/registry.test.ts |
components/ui/ 스캔 결과와 레지스트리 정합 · 수동 등록 slug 화이트리스트 |
lib/showcase/demo-loaders.test.ts |
status: "done" 항목만 로더 등록 |
test/closed-network.test.ts |
프로덕션 산출물에 외부 <script src>/<link href>/CSS url()/CDN 힌트 0건 |
test/sourcemap.test.ts |
프로덕션 청크에 sourcemap 부재 |
profiles/index.test.ts |
프로필이 참조하는 theme/font 가 실재하는지 |
lib/profile-css.test.ts |
프로필 CSS 방출(data-theme/data-font/data-density/--radius) 형태 |
pnpm test:vision 은 CI 에 없는 수동 게이트 — Playwright 스크린샷을 Claude 비전으로 채점한다(텍스트 겹침·레이아웃 깨짐·대비). 시각 변화가 큰 작업 뒤에만 돌린다.
3.6 파운데이션 층
토큰 — app/globals.css 가 소유한다. 색은 OKLCH, --radius 기본 6px, 파생값은 --radius-sm ~ --radius-4xl 이 calc() 로 만든다. 임의 radius 신설 금지.
시맨틱 색 토큰: background/foreground, card, popover, primary, secondary, muted, accent, destructive, success, warning, gain/loss, border, input, ring, chart-1~5, sidebar-*.
테마 — themes/<name>.ts 추가 시 themes/index.ts 에 등록. 기존 프리셋 파일이나 globals.css 의 다른 프리셋 블록은 건드리지 않는다.
폰트 — fonts/index.ts 에 등록, 실 파일은 assets/fonts/<name>/ 에 woff2 + LICENSE 커밋.
프로필 — profiles/index.ts. 프로젝트가 고르는 단위이므로 여기서 테마·폰트·defaultMode·radius·density 를 확정한다. 소비 프로젝트가 프로필의 radius·density 를 임의 재정의하면 표준이 발산한다.
밀도 — <html data-density="compact|comfortable"> 을 프로필이 지정하고 app/globals.css 의 밀도 층이 소비한다. 속성이 없으면 아무 규칙도 걸리지 않는다(하위호환).
테마 초기화 — hydration 이전에 끝낸다. app/layout.tsx <head> 의 인라인 THEME_INIT_SCRIPT 가 localStorage 를 읽어 <html> 에 data-theme/data-font/dark 를 직접 세팅한다. useEffect 만으로 적용하면 FOUC(테마 깜빡임)가 난다.
3.7 다국어
카탈로그 설명문은 한국어가 기본, en·ja·zh·es 번역을 lib/i18n/messages/ 에 둔다.
- 컴포넌트 안 문구:
<TranslatedText k="..." ko="..." />또는t("<ns>.<key>", "<ko원문>"). t()는 처음 두 인자가 문자열 리터럴이어야 추출기가 잡는다 — 변수 조립 금지.- 키 추가 후
node scripts/i18n/extract.mjs로scripts/i18n/ko-catalog.json갱신. - 4개 로케일 키 집합이 어긋나면 테스트가 깨진다. 번역을 나중에 하겠다고
en만 넣지 않는다.
3.8 배포 산출물 동기화
registry.json(루트) 이 shadcn 레지스트리의 단일 진실원천이다.
pnpm registry:build # registry.json → public/r/*.json
pnpm gen:llms # registry.json → public/llms.txt (AI 발견용 카탈로그)
public/r, public/llms.txt 는 빌드 생성물 — 손으로 편집하지 않는다. 수기 하드코딩은 다음 생성에서 날아간다.
3.9 파일 컨벤션
| 종류 | 위치 | 표기 |
|---|---|---|
| shadcn 프리미티브 | components/ui/<name>.tsx |
kebab-case, 수정 금지 |
| 조합 컴포넌트 | components/<name>.tsx |
kebab-case |
| 패턴 컴포넌트 | components/patterns/<name>.tsx |
kebab-case |
| 쇼케이스 셸 | components/showcase/<name>.tsx |
kebab-case |
| 데모 | components/demos/<slug>.demo.tsx |
slug 는 레지스트리 slug 와 동일 |
| 라우트 | app/<segment>/page.tsx (+ loading.tsx, error.tsx) |
|
| 레지스트리·유틸 | lib/<domain>/registry.ts, lib/<name>.ts |
|
| 훅 | hooks/use-<name>.ts |
|
| 테스트 | 대상 파일 옆 <name>.test.ts(x) |
vitest |
className 병합은 항상 cn()(@/lib/utils). variant 가 여럿이면 CVA.
3.10 자주 나오는 실수
- 컴포넌트 파일만 만들고 레지스트리·데모 로더 등록을 빼먹어 카탈로그에 안 뜸
status: "done"인데 데모 파일이 없음 (또는 그 반대)- i18n 을
en에만 추가해서 로케일 키 집합 테스트가 깨짐 - 데모에 하드코딩 색·외부 이미지 URL 사용 → 폐쇄망 테스트에서 막힘
components/ui/원본을 직접 수정하거나components/ui/customs/같은 하위 폴더를 끼워 넣음- 페이지 컴포넌트에서
max-w-[1300px]를 직접 선언(컨테이너는 layout 소유) - 새 라우트에
loading.tsx/error.tsx누락 public/r·public/llms.txt를 손으로 수정- 등락 표시에 Tailwind 팔레트 색을 직접 사용 (→
--gain/--loss,lib/finance/rate.ts) - 캔버스·차트 렌더러에 CSS 변수 문자열을 그대로 전달 (→
lib/finance/normalize-color.ts) - 새 UI 라이브러리를 먼저 설치하고 나중에 정당화 (의존성 규율 선검토가 순서)
4. 규칙 다이제스트 (두 모드 공통 · 위반 빈발 항목)
전체 조항은 §1 의 원천을 읽는다. 아래는 예외 없이 적용되는 것만 추린 것이다.
컬러 · 토큰
- 하드코딩 색 금지(hex·rgb/hsl/oklch 리터럴·Tailwind 팔레트 클래스): 항상 시맨틱 토큰(
bg-background,text-destructive,text-chart-1)만 쓴다. (검증 대상) - 시세 등락은 팔레트 색 직접 지정 금지 →
--gain/--loss토큰(lib/finance/rate.ts). 한국식 관례로 상승=빨강, 하락=파랑. - canvas 류 렌더러에는 CSS 변수 문자열을 그대로 주지 않고
normalizeColor로 해소한 뒤 전달한다.
컴포넌트
components/ui/의 shadcn 원본은 수정하지 않는다. 커스텀은components/또는components/patterns/에서 조합한다.
아이콘
- 이모지를 아이콘 대용으로 쓰지 않는다. (검증 대상)
- Phosphor(
@phosphor-icons/react) 기본. 강조는 duotone/fill. 서버 컴포넌트는/dist/ssr경로 import.
레이아웃 · 라우팅
- 콘텐츠 컨테이너
max-w-[1300px] mx-auto, 소유자는 세그먼트layout.tsx— 페이지 컴포넌트에서 max-width 하드코딩 금지. main랜드마크는 layout 이 렌더한다. 페이지·loading.tsx·error.tsx에서 중복 렌더 금지(중첩은 invalid HTML). 에러 UI 는div role="alert".- 모바일 우선 3모드(기본 /
sm:·md:/lg:↑). 역방향 접두 금지. - 넓은 콘텐츠(테이블·코드블록·차트)는 자체
overflow-x-auto래퍼. body 가로 스크롤 0. - 새 라우트에는
loading.tsx와error.tsx를 함께 만든다. - 상태 UI(로딩·빈·에러)는
/patterns/state표준을 따른다.
폐쇄망 · 의존성
- 모든 리소스 self-host — 외부 CDN·외부 URL fetch 0건. 폰트는
next/font/local+ 벤더링, 아이콘은 npm 번들, 데모 이미지도 로컬 placeholder. (검증 대상) - TypeScript strict 유지,
any금지. (검증 대상)
5. 자가 검증 및 완료 보고 (필수 수행)
구현 후 반드시 기계적으로 증명한다. 순서가 있다.
5.1 레포에 테스트가 있으면 그쪽이 1차 게이트다
카탈로그 레포(모드 B)나 테스트를 갖춘 소비자 레포에서는 아래가 먼저다. 이 게이트가 §4 의 상당 부분을 이미 강제한다.
pnpm typecheck && pnpm lint && pnpm test && pnpm build
5.2 표준 준수 스캐너
레포 테스트가 없거나(신규 프로젝트) 추가 확인이 필요하면 이 스킬의 스캐너를 돌린다.
python3 <스킬경로>/scripts/check_standards.py app components lib
검사 항목은 하드코딩 색 · 이모지 아이콘 · 외부 URL · TypeScript any 4종이고, 위반이 있으면 파일:줄 과 함께 exit 1 이다.
정당한 예외(색 선택기의 스와치 팔레트처럼 hex 가 곧 데이터인 경우)는 이유와 함께 그 줄에 표기해 면제한다. 면제 수단이 없으면 사람은 스캐너 전체를 무시하게 되고, 그 순간 검증이 죽는다.
const SWATCHES = ["#ef4444", "#3b82f6"] // doksam-ui:allow-color 색 선택기 팔레트 원본
doksam-ui:allow 는 그 줄의 모든 검사를, doksam-ui:allow-color|emoji|url|any 는 해당 검사만 면제한다. 이유 없이 다는 것은 위반을 숨기는 것이다.
맨손 grep 으로 대체하지 않는다. grep -r ' any' src/ 는 company·many 를 잡고, grep -r '[^\x00-\x7F]' src/ 는 한글 텍스트를 전부 잡는다. 노이즈에 묻히면 "통과"가 아무것도 증명하지 못한다. 스캐너는 단어 경계·이모지 코드포인트·소스 확장자로 범위를 좁혀 그 오탐을 제거한다.
5.3 완료 보고
검증이 모두 통과하면 아래 형식으로 보고한다. 실패하면 코드를 고치고 다시 검증한다.
# doksam-ui 적용 완료 보고
- 모드: [A 소비자 | B 생산자]
- 적용된 프로필: [예: profile-admin]
- 새로 설치·추가된 자산: [예: @doksam-ui/badge-extended]
## 기계적 검증 결과
- [Pass] pnpm typecheck / lint / test / build
- [Pass] 하드코딩 색 0건 (시맨틱 토큰 사용)
- [Pass] 이모지 아이콘 0건 (Phosphor 사용)
- [Pass] 외부 CDN / URL fetch 0건 (self-host 준수)
- [Pass] TypeScript any 0건
보고서에도 이모지를 쓰지 않는다 — 이모지 0건을 보고하는 문서가 이모지를 달고 있으면 그 보고는 스스로를 반증한다.
Files (doksam-skills)
-
agents
-
antigravity.md 1 KB
--- name: doksam-ui description: doksam 프로젝트 UI 를 ui.doksam.com 표준(디자인 토큰·컴포넌트 레지스트리·규칙)에 맞춰 만들고, 그 표준 카탈로그 자체를 확장하는 프론트엔드 개발자 --- # doksam-ui doksam 프로젝트 UI 를 ui.doksam.com 표준(디자인 토큰·컴포넌트 레지스트리·규칙)에 맞춰 만들고, 그 표준 카탈로그 자체를 확장하는 프론트엔드 개발자. `doksam-ui` Skill 을 작업 계약의 단일 원본으로 사용한다. Antigravity Managed Agent 등록 시 이 파일의 내용을 역할 정의로 넣는다. 모드 A(소비자) — ui.doksam.com 이 SSOT: /llms.txt 카탈로그 live 확인, 브랜드 프로필 확정, 레지스트리 설치 우선(재발명 금지), 규칙 준수 구현. 모드 B(생산자) — 카탈로그 레포 확장: lib/rules-markdown.ts 가 규칙 원문이고, 레지스트리 등록·데모·i18n 4개 로케일·배포 산출물 재생성까지 한 묶음으로 끝낸다. 자가 검증 결과와 함께 전달한다. -
claude.md 1.1 KB
--- name: doksam-ui description: doksam 프로젝트 UI 를 ui.doksam.com 표준(디자인 토큰·컴포넌트 레지스트리·규칙)에 맞춰 만들고, 그 표준 카탈로그 자체를 확장하는 프론트엔드 개발자 skills: - doksam-ui --- `doksam-ui` Skill 을 작업 계약의 단일 원본으로 사용한다. 작업 시작 시 **모드를 먼저 판별한다** — 대상 레포에 `lib/showcase/registry.ts` 가 없으면 모드 A(소비자), 있으면 모드 B(생산자·카탈로그 확장)다. - 모드 A: ui.doksam.com 이 SSOT 다. `/llms.txt` 로 카탈로그를 live 확인하고, 브랜드 프로필 확정 → 레지스트리 설치(재발명 금지) → 규칙 준수 구현 순서를 따른다. - 모드 B: 레포의 `lib/rules-markdown.ts` 가 규칙 원문이다. 레지스트리 등록·데모· i18n 4개 로케일·배포 산출물 재생성까지 한 묶음으로 끝낸다. 규칙 세부는 Skill 에 있는 것을 따르고 이 파일에 복제하지 않는다. 자가 검증(`scripts/check_standards.py` 또는 레포 테스트)을 통과한 결과와 함께 산출물을 전달한다. -
codex.toml 1.1 KB
name = "doksam_ui" description = "doksam 프로젝트 UI 를 ui.doksam.com 표준(디자인 토큰·컴포넌트 레지스트리·규칙)에 맞춰 만들고, 그 표준 카탈로그 자체를 확장하는 프론트엔드 개발자" developer_instructions = """ doksam-ui 스킬을 작업 계약의 단일 원본으로 사용한다. 먼저 모드를 판별한다: 대상 레포에 lib/showcase/registry.ts 가 없으면 모드 A(소비자), 있으면 모드 B(생산자 - 카탈로그 확장). 모드 A 에서는 ui.doksam.com 이 SSOT 다. /llms.txt 로 live 카탈로그를 확인하고, 브랜드 프로필을 확정한 뒤, 기존 자산은 재구현하지 말고 shadcn 커스텀 레지스트리에서 설치한다. 프로필 목록은 문서가 아니라 llms.txt 에서 읽는다. 모드 B 에서는 레포의 lib/rules-markdown.ts 가 규칙 원문이다. 파일 추가만으로 끝내지 말고 레지스트리 등록, 데모, i18n 4개 로케일, 배포 산출물 재생성까지 한 묶음으로 처리한다. 자가 검증을 통과한 결과만 전달하고, 검증 결과를 보고에 포함한다. """ -
openai.yaml 284 B
interface: display_name: "doksam UI" short_description: "ui.doksam.com 표준을 적용하고 카탈로그를 확장" default_prompt: "$doksam-ui 로 이 화면을 ui.doksam.com 표준(시맨틱 토큰·레지스트리 컴포넌트)에 맞추고 준수 여부를 검증해줘."
-
-
references
-
catalog-workflow.md 8.1 KB
# 카탈로그 항목 추가 워크플로 (모드 B 전용) doksam-ui 카탈로그 레포 안에서만 쓰는 계층별 체크리스트다. 각 절차의 마지막은 항상 검증 커맨드다. 공통 검증 (CI 와 같은 순서): ```bash pnpm typecheck && pnpm lint && pnpm test && pnpm build # 필요 시: pnpm test:e2e # 시각 변화가 크면(수동, CI 미포함): pnpm test:vision ``` --- ## A. 컴포넌트 추가 1. **구현** — `components/<slug>.tsx` - `components/ui/` 의 shadcn 프리미티브를 조합한다. 원본은 건드리지 않는다. - className 병합은 `cn()`(`@/lib/utils`), variant 가 여럿이면 CVA. - 색은 시맨틱 토큰만. 아이콘은 Phosphor(서버 컴포넌트는 `.../dist/ssr`). 2. **데모** — `components/demos/<slug>.demo.tsx` - `demo` / `code` / `dos` / `donts` 4개 export. `demo` 와 `code` 내용 일치. - 레퍼런스: `components/demos/badge-extended.demo.tsx` 3. **레지스트리 등록** — `lib/showcase/registry.ts` ```ts { slug: "<slug>", title: "<Title>", category: "form" | "overlay" | "layout" | "data" | "chat" | "bizinfo" | "finance", layer: "primitive" | "composition", description: "한 줄 설명(한국어).", status: "done", } ``` 카테고리 블록의 기존 정렬을 유지한다. 4. **데모 로더 등록** — `lib/showcase/demo-loaders.ts` ```ts "<slug>": () => import("@/components/demos/<slug>.demo"), ``` `status: "done"` 인 항목만 여기 등록되어야 한다(`demo-loaders.test.ts` 가 검사). 5. **수동 항목 화이트리스트** — `components/ui/` 밖의 커스텀이면 `lib/showcase/registry.test.ts` 의 `MANUAL_ENTRY_SLUGS` 에 slug 추가. (이걸 빼면 "레지스트리에만 있고 파일 스캔에 없다"로 테스트가 깨진다.) 6. **i18n** — `lib/i18n/messages/{en,ja,zh,es}.json` **4개 전부** - `component.<slug>.description` 필수. - 컴포넌트 내부 문구가 있으면 해당 키도 4개 로케일에 추가. - `node scripts/i18n/extract.mjs` 로 `scripts/i18n/ko-catalog.json` 재생성. 7. **배포 레지스트리** — 다른 프로젝트가 `npx shadcn add` 로 가져갈 자산이면 `registry.json` 에 item 추가 (`name`/`type`/`title`/`description`/ `dependencies`/`registryDependencies`/`files`) 후: ```bash pnpm registry:build && pnpm gen:llms ``` `public/r`·`public/llms.txt` 는 생성물이므로 직접 편집 금지. 8. **테스트** — 로직이 있는 컴포넌트는 `components/<slug>.test.tsx`, 데모에 상호작용이 있으면 `components/demos/<slug>.demo.test.tsx`. --- ## B. 패턴 추가 패턴 = 화면 단위 조합 규칙. 컴포넌트 단품이 아니라 "이 상황엔 이렇게 조립한다"를 보여준다. 1. **라우트** — `app/patterns/<slug>/page.tsx` + `loading.tsx` + `error.tsx` - `main` 은 상위 layout 이 렌더한다 — 페이지에서 중복 렌더 금지. - 에러 UI 는 `div role="alert"`. 2. **레지스트리 등록** — `lib/patterns/registry.ts` ```ts { slug: "<slug>", title: "...", description: "...", icon: SomeIcon, scope: "common" | "finance" | "srope" } ``` - `scope: "common"` 은 어떤 doksam 프로젝트에서도 재사용 가능한 것만. 도메인 색이 짙으면 `finance` 또는 `srope`. - `icon` 은 `@phosphor-icons/react/dist/ssr` 에서 import. 3. **샘플 데이터** — 목 데이터가 필요하면 `lib/patterns/<name>-data.ts` + `<name>-data.test.ts` (기존 `mobile-banking-data`, `stock-order-data` 참고). 4. **i18n** — `pattern.<slug>.title` 과 `pattern.<slug>.description` 을 4개 로케일에. 5. 검증. --- ## C. 템플릿 추가 템플릿 = 완성된 화면 하나. 프로필(테마+폰트)을 지정해 "이 조합이면 이렇게 보인다"를 증명한다. 1. **라우트 디렉터리** — `app/templates/<slug>/` - `layout.tsx` 가 컨테이너(`max-w-[1300px] mx-auto`)와 `main` 을 소유한다. - `page.tsx` + `loading.tsx` + `error.tsx`. - 하위 화면이 있으면 `app/templates/<slug>/<sub>/page.tsx`. - 내부 전용 컴포넌트·목 데이터는 `_components/`, `_data/` (언더스코어 = 라우트 제외). - 뼈대는 `app/templates/_blueprint/blueprint.tsx` 참고. 2. **레지스트리 등록** — `lib/templates/registry.ts` ```ts { href: "/templates/<slug>", title: "...", profile: "admin 프로필 · Slate · Geist", // 사람이 읽는 조합 설명 description: "...", stack: ["app-shell", "table-sortable"], // 사용한 패턴/자산 icon: SomeIcon, } ``` 3. **i18n** — `template.<slug>.description` 을 4개 로케일에 (slug 는 `href` 의 마지막 세그먼트). 4. **폐쇄망** — 템플릿의 데모 데이터도 외부 이미지 URL 금지. 아바타는 `AvatarFallback`. 5. 검증 + 시각 확인(`pnpm dev` 로 라이트/다크 양쪽). --- ## D. 테마 프리셋 추가 1. `themes/<name>.ts` 새 파일 — `ThemePreset` 형태로 토큰 정의. **기존 프리셋 파일은 건드리지 않는다.** 2. `themes/index.ts` 의 `THEME_PRESETS` 배열에 등록. 3. `app/globals.css` 에 해당 프리셋 CSS 변수 블록 추가 — **다른 프리셋 블록은 수정하지 않는다.** 4. `themes/index.test.ts` 의 프리셋 이름 목록을 갱신한다 — 이 테스트는 `THEME_PRESETS` 가 **정확히 그 목록과 일치**하는지 단언하므로, 프리셋을 추가·제거하면 반드시 같이 고쳐야 한다. 5. 같은 테스트가 `THEME_TOKEN_KEYS` 전 키의 light/dark 값 존재도 검사한다 — 라이트/다크 양쪽 값이 모두 있어야 한다. ## E. 폰트 프리셋 추가 1. woff2 파일을 `assets/fonts/<name>/` 에 커밋 + 해당 폰트 LICENSE 파일 동봉. 2. `fonts/index.ts` 의 `FONT_PRESETS` 에 등록, `next/font/local` 로 연결. 3. `THIRD_PARTY_LICENSES.md` 에 라이선스 항목 추가. 4. npm 폰트 패키지(@fontsource 등)는 파일 취득 도구로만 쓰고 런타임 의존성으로 남기지 않는다. ## F. 브랜드 프로필 추가 1. `profiles/index.ts` 의 `BRAND_PROFILES` 에 항목 추가: `{ name, label, description, theme, font, defaultMode, radius, density, shell?, examples }` 2. `theme` 은 `themes/index.ts` 의, `font` 는 `fonts/index.ts` 의 실재하는 `name` 이어야 한다 — `profiles/index.test.ts` 가 참조 무결성을 강제한다. 3. `density` 는 `compact`(관리·데이터 화면) 또는 `comfortable`(대외 화면). 4. shadcn 레지스트리로 배포하려면 `registry.json` 에 `registry:theme` item 추가 (`profile-<name>`) 후 `pnpm registry:build && pnpm gen:llms`. **폰트는 registry item 으로 자동 설치되지 않는다** — cssVars 는 색·radius 만 담고, 폰트는 수동 복사 + `next/font/local` 연결이라고 안내 문구에 남긴다. 5. 프로필을 추가·제거해도 소비자 쪽 문서를 고칠 필요는 없다 — 목록의 원천은 생성물인 `llms.txt` 이고 SKILL.md 는 거기서 읽으라고만 지시한다. --- ## G. 사용 규칙(/rules) 자체를 바꿀 때 `lib/rules-markdown.ts` 의 `RULES_SECTIONS` 만 수정한다. `/rules` 페이지 렌더링과 AI 프롬프트용 `RULES_MARKDOWN` 이 여기서 파생되므로 다른 곳에 규칙 문장을 복제하지 않는다. 규칙을 추가·변경했으면 `doksam-ui` 스킬(`SKILL.md` §4 다이제스트와 이 파일)이 그와 모순되지 않는지 훑는다 — 모순이 있으면 `lib/rules-markdown.ts` 가 옳고 스킬을 고친다. --- ## 자주 빠뜨리는 것 (제출 전 훑기) - [ ] 레지스트리 등록했는가 (파일만 만들고 끝내지 않았는가) - [ ] `status: "done"` ↔ 데모 파일 ↔ 데모 로더 3자가 일치하는가 - [ ] i18n 4개 로케일 전부 넣었는가 (`en` 만 넣지 않았는가) - [ ] 새 라우트에 `loading.tsx` / `error.tsx` 가 있는가 - [ ] 하드코딩 색·외부 URL 0건인가 - [ ] `registry.json` 을 고쳤으면 `pnpm registry:build && pnpm gen:llms` 를 돌렸는가 - [ ] 라이트/다크 + 다른 테마 프리셋에서 깨지지 않는가 - [ ] 모바일·태블릿·데스크톱 3모드에서 가로 스크롤이 안 생기는가
-
-
scripts
-
check_standards.py 10.5 KB
#!/usr/bin/env python3 """doksam-ui 표준 준수 스캐너 (stdlib only). SKILL.md §5.2 가 부르는 자가 검증기. 맨손 grep 이 내던 오탐을 제거하는 것이 존재 이유다. grep -r ' any' src/ → company, many, Germany 를 잡는다 grep -r '[^\\x00-\\x7F]' src/ → 한글 텍스트를 전부 잡는다 grep -r 'https://' src/ → 주석·문서 링크까지 잡는다 노이즈에 묻힌 "통과"는 아무것도 증명하지 못하므로, 여기서는 단어 경계·이모지 코드포인트·소스 확장자·문자열 리터럴로 범위를 좁힌다. 사용: python3 check_standards.py [경로...] # 기본 경로: app components lib src python3 check_standards.py --only color,any . python3 check_standards.py --json . 위반이 하나라도 있으면 exit 1. """ import argparse import json import re import sys from pathlib import Path SOURCE_SUFFIXES = {".ts", ".tsx", ".js", ".jsx", ".css", ".scss"} STYLE_SUFFIXES = {".css", ".scss"} SKIP_DIRS = { "node_modules", ".next", ".git", "dist", "build", "out", "coverage", "__pycache__", ".turbo", ".vercel", } DEFAULT_TARGETS = ("app", "components", "lib", "src") # --- 하드코딩 색 ----------------------------------------------------------- # CSS 변수를 정의하는 토큰 원본 파일(globals.css 등)은 hex 를 쓸 수밖에 없으므로 # 검사 대상에서 뺀다. 그 파일이 곧 토큰의 단일 진실원천이다. TOKEN_SOURCE_NAMES = {"globals.css", "theme.css", "tokens.css"} # 6·8자리는 언제나 색이다. 3·4자리는 이슈 참조(`#233`)·앵커(`href="#feed"`)와 # 구분이 안 되므로, 문자열이나 CSS 값으로 **단독으로** 놓였을 때만 색으로 본다. HEX = re.compile(r"#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?\b(?![0-9a-fA-F])") HEX_SHORT = re.compile( r"""(?: (['"])\#[0-9a-fA-F]{3,4}\1 # "#f00" | :\s*\#[0-9a-fA-F]{3,4}\s*[;}\n] # color: #f00; )""", re.VERBOSE, ) COLOR_FN = re.compile(r"\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\s*\(") TAILWIND_PALETTE = re.compile( r"\b(?:text|bg|border|ring|fill|stroke|from|via|to|decoration|outline|shadow|accent|caret|divide|placeholder)" r"-(?:slate|gray|grey|zinc|neutral|stone|red|orange|amber|yellow|lime|green|" r"emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b" ) # --- 이모지 --------------------------------------------------------------- # 한글(가-힣)·CJK 는 건드리지 않는다. 이모지 블록과 VS16 만 본다. # # 화살표 블록(U+2190~U+21FF, U+2B00~U+2BFF)은 의도적으로 제외한다. # 산문의 "A → B" 까지 위반으로 잡으면 오탐이 본문을 덮어 스캐너가 무시당한다. # 이 검사가 막는 것은 "아이콘 자리에 쓰인 그림문자"다. EMOJI = re.compile( "[" "\U0001F000-\U0001FAFF" # 이모티콘·픽토그램·보조 기호·확장 "☀-➿" # 기타 기호(☀★☑) + 딩뱃(✅✨❌) "⭐⭕" # ⭐⭕ "️" # variation selector-16 (이모지 표현 강제) "]" ) # --- 외부 URL ------------------------------------------------------------- # 폐쇄망 규칙이 막는 것은 **리소스를 실제로 가져오는 것**이다. 바깥으로 나가는 # 앵커 링크(`<a href>`)나 화면에 글자로 보여주는 URL, 목 데이터의 baseUrl 은 # 네트워크 요청이 아니므로 위반이 아니다. 로드 위치에 있는 것만 잡는다. URL_LOADING = re.compile( r"""(?: \b(?:src|srcSet|poster|imageSrcSet)\s*=\s*['"{`]?\s*(https?://[^'"`}\s]+) | <link\b[^>]*?\bhref\s*=\s*['"{`]?\s*(https?://[^'"`}\s]+) | \b(?:fetch|importScripts)\s*\(\s*['"`](https?://[^'"`]+) | \b(?:axios|ky)\s*(?:\.\s*\w+\s*)?\(\s*['"`](https?://[^'"`]+) | @import\s+(?:url\()?\s*['"](https?://[^'"]+) )""", re.VERBOSE, ) URL_IN_CSS = re.compile(r"url\(\s*['\"]?(https?://[^)'\"\s]+)") LOCALHOST = re.compile(r"^https?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?:[:/]|$)") # --- TypeScript any ------------------------------------------------------- # `: any`, `<any>`, `as any`, `any[]`, `Array<any>` 만. company·many 는 아니다. ANY_TYPE = re.compile( r"(?::\s*any\b)" r"|(?:\bas\s+any\b)" r"|(?:<\s*any\s*[,>])" r"|(?:\bany\s*\[\])" ) LINE_COMMENT = re.compile(r"^\s*(?://|\*|/\*)") # --- 예외 표기 ------------------------------------------------------------- # 정당한 예외가 실제로 있다(색 선택기의 스와치 팔레트, 색 입력의 placeholder). # 그런 줄은 이유를 적어 명시적으로 면제한다. 면제가 없으면 사람은 스캐너 전체를 # 무시하게 되고, 그 순간 검증은 죽는다. # # const SWATCHES = ["#ef4444", ...] // doksam-ui:allow-color 색 선택기 팔레트 원본 # # `doksam-ui:allow` 는 그 줄의 모든 검사를, `doksam-ui:allow-<검사>` 는 해당 # 검사만 면제한다. ALLOW = re.compile(r"doksam-ui:allow(?:-(color|emoji|url|any))?\b") def suppressed(line, key): for match in ALLOW.finditer(line): if match.group(1) in (None, key): return True return False def iter_files(targets): for target in targets: path = Path(target) if path.is_file(): if path.suffix in SOURCE_SUFFIXES: yield path continue if not path.is_dir(): continue for child in sorted(path.rglob("*")): if not child.is_file() or child.suffix not in SOURCE_SUFFIXES: continue if SKIP_DIRS.intersection(child.parts): continue yield child def is_test_file(path): return bool(re.search(r"\.(?:test|spec)\.[jt]sx?$", path.name)) def is_shadcn_primitive(path): """`components/ui/` 는 shadcn CLI 원본이고 수정 금지 대상이다. 고칠 수 없는 파일을 위반으로 세면 스캐너 출력이 영구 적자가 되고, 사람은 그 순간부터 결과를 보지 않는다. """ parts = path.parts return "ui" in parts and "components" in parts and \ parts.index("ui") == parts.index("components") + 1 def check_color(path, lineno, line): if path.name in TOKEN_SOURCE_NAMES or is_shadcn_primitive(path): return None if is_test_file(path) or LINE_COMMENT.match(line): return None if HEX.search(line): return "하드코딩 hex 색" for match in HEX_SHORT.finditer(line): # `href="#feed"`, `href: "#top"` 은 앵커지 색이 아니다. if "href" in line[max(0, match.start() - 12):match.start()]: continue return "하드코딩 hex 색" if path.suffix not in STYLE_SUFFIXES and COLOR_FN.search(line): return "색 함수 리터럴 (rgb/hsl/oklch)" if TAILWIND_PALETTE.search(line): return "Tailwind 팔레트 클래스 (시맨틱 토큰이 아님)" return None def check_emoji(path, lineno, line): return "이모지" if EMOJI.search(line) else None def check_url(path, lineno, line): if LINE_COMMENT.match(line): return None for match in URL_LOADING.finditer(line): url = next(g for g in match.groups() if g) if not LOCALHOST.match(url): return f"외부 리소스 로드 ({url})" if path.suffix in STYLE_SUFFIXES: for match in URL_IN_CSS.finditer(line): if not LOCALHOST.match(match.group(1)): return f"외부 URL (CSS url()) ({match.group(1)})" return None def check_any(path, lineno, line): if path.suffix not in {".ts", ".tsx"}: return None # 테스트 파일의 전역 스텁(`(globalThis as any).ResizeObserver ??= ...`)은 # 프로덕션 타입 안전성과 무관하다. strict 규율의 대상은 앱 코드다. if is_test_file(path): return None if LINE_COMMENT.match(line): return None return "TypeScript any" if ANY_TYPE.search(line) else None CHECKS = { "color": ("하드코딩 색", check_color), "emoji": ("이모지 아이콘", check_emoji), "url": ("외부 URL / CDN", check_url), "any": ("TypeScript any", check_any), } def scan(targets, only=None): selected = list(only) if only else list(CHECKS) findings = {key: [] for key in selected} for path in iter_files(targets): try: text = path.read_text(encoding="utf-8") except (UnicodeDecodeError, OSError): continue lines = text.splitlines() for lineno, line in enumerate(lines, 1): for key in selected: if suppressed(line, key): continue reason = CHECKS[key][1](path, lineno, line) if reason: findings[key].append( {"file": str(path), "line": lineno, "reason": reason, "text": line.strip()[:160]}) return findings def main(argv=None): parser = argparse.ArgumentParser(description="doksam-ui 표준 준수 스캐너") parser.add_argument("targets", nargs="*", default=None, help="검사할 경로 (기본: app components lib src 중 존재하는 것)") parser.add_argument("--only", default=None, help=f"검사 항목 쉼표 구분 ({', '.join(CHECKS)})") parser.add_argument("--json", action="store_true", help="JSON 으로 출력") args = parser.parse_args(argv) targets = args.targets or [t for t in DEFAULT_TARGETS if Path(t).exists()] if not targets: print("검사할 경로가 없다. 경로를 인자로 넘겨라.", file=sys.stderr) return 2 only = None if args.only: only = [k.strip() for k in args.only.split(",") if k.strip()] unknown = [k for k in only if k not in CHECKS] if unknown: parser.error(f"알 수 없는 검사 항목: {', '.join(unknown)}") findings = scan(targets, only) if args.json: print(json.dumps(findings, ensure_ascii=False, indent=2)) return 1 if any(findings.values()) else 0 total = 0 for key, hits in findings.items(): label = CHECKS[key][0] total += len(hits) if not hits: print(f"[Pass] {label} 0건") continue print(f"[Fail] {label} {len(hits)}건") for hit in hits: print(f" {hit['file']}:{hit['line']}: {hit['reason']}") print(f" {hit['text']}") print(f"\n검사 경로: {', '.join(str(t) for t in targets)}") print(f"위반 합계: {total}건") return 1 if total else 0 if __name__ == "__main__": sys.exit(main())
-
-
tests
-
test_check_standards.py 8.5 KB
"""check_standards.py 가 맨손 grep 의 오탐을 실제로 제거하는지 검증 (stdlib only). 이 스캐너의 존재 이유가 "grep -r ' any' 는 company 를 잡는다" 이므로, 탐지(true positive)만큼 **비탐지(false positive 부재)** 를 같은 무게로 단언한다. """ import importlib.util import tempfile import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent SCRIPT = SKILL_ROOT / "scripts" / "check_standards.py" spec = importlib.util.spec_from_file_location("check_standards", SCRIPT) check_standards = importlib.util.module_from_spec(spec) spec.loader.exec_module(check_standards) class ScanCase(unittest.TestCase): def scan(self, name, body, only=None): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / name path.write_text(body, encoding="utf-8") return check_standards.scan([tmp], only) def assertHit(self, name, body, key): findings = self.scan(name, body, [key]) self.assertTrue(findings[key], f"{key} 를 잡았어야 한다: {body!r}") def assertClean(self, name, body, key): findings = self.scan(name, body, [key]) self.assertFalse(findings[key], f"{key} 오탐: {findings[key]}") class TestColor(ScanCase): def test_hex_literal_is_flagged(self): self.assertHit("a.tsx", 'const c = "#ff0000"', "color") def test_color_function_literal_is_flagged(self): self.assertHit("a.tsx", "const c = rgb(255, 0, 0)", "color") def test_tailwind_palette_class_is_flagged(self): self.assertHit("a.tsx", '<p className="text-red-600" />', "color") def test_semantic_token_class_is_clean(self): self.assertClean( "a.tsx", '<p className="bg-background text-destructive text-chart-1" />', "color") def test_gain_loss_token_is_clean(self): self.assertClean("a.tsx", '<span className="text-[--gain]" />', "color") def test_token_source_css_may_define_hex(self): self.assertClean("globals.css", " --background: #ffffff;", "color") def test_issue_reference_is_clean(self): self.assertClean("a.tsx", "// 한국 시/도 분포 지도 (bizinfo #233 이식, #56).", "color") self.assertClean("a.tsx", '<span>배치 #241</span>', "color") def test_anchor_fragment_is_clean(self): self.assertClean("a.tsx", '{ href: "#feed", label: "피드" },', "color") def test_short_hex_literal_is_flagged(self): self.assertHit("a.tsx", 'const c = "#f00"', "color") def test_shadcn_primitive_is_skipped(self): with tempfile.TemporaryDirectory() as tmp: target = Path(tmp) / "components" / "ui" target.mkdir(parents=True) (target / "bubble.tsx").write_text( 'className="bg-[oklch(from_var(--primary)_0.93_c_h)]"', encoding="utf-8") self.assertFalse(check_standards.scan([tmp], ["color"])["color"]) def test_component_test_file_is_skipped(self): self.assertClean("relation-network.test.tsx", '{ key: "a", color: "#22d3ee" },', "color") def test_css_var_reference_is_clean(self): self.assertClean("a.tsx", "const c = 'var(--primary)'", "color") class TestEmoji(ScanCase): def test_emoji_is_flagged(self): self.assertHit("a.tsx", "<span>\U0001F4CB</span>", "emoji") def test_korean_text_is_clean(self): self.assertClean("a.tsx", '<p>주문 내역을 확인하세요</p>', "emoji") def test_korean_comment_is_clean(self): self.assertClean("a.tsx", "// 등락색은 --gain / --loss 를 쓴다", "emoji") def test_cjk_and_japanese_are_clean(self): self.assertClean("a.tsx", '<p>注文履歴 · 訂單 · 주문</p>', "emoji") def test_phosphor_import_is_clean(self): self.assertClean( "a.tsx", 'import { CaretRight } from "@phosphor-icons/react/dist/ssr"', "emoji") class TestUrl(ScanCase): """폐쇄망 규칙이 막는 것은 '리소스를 실제로 가져오는 것'이다.""" def test_fetch_is_flagged(self): self.assertHit("a.tsx", 'fetch("https://cdn.example.com/x.json")', "url") def test_script_src_is_flagged(self): self.assertHit( "a.tsx", '<Script src="https://www.googletagmanager.com/gtag/js?id=X" />', "url") def test_img_src_is_flagged(self): self.assertHit("a.tsx", '<img src="https://cdn.example.com/a.png" />', "url") def test_link_stylesheet_is_flagged(self): self.assertHit( "a.tsx", '<link rel="stylesheet" href="https://fonts.example.com/x.css" />', "url") def test_css_url_is_flagged(self): self.assertHit("a.css", "background: url(https://cdn.example.com/a.png);", "url") def test_anchor_href_is_clean(self): """바깥으로 나가는 링크는 네트워크 요청이 아니다.""" self.assertClean( "a.tsx", '<a href="https://www.doksam.com" target="_blank">doksam</a>', "url") def test_displayed_url_string_is_clean(self): self.assertClean( "a.tsx", '<Input readOnly defaultValue="https://ui.doksam.com/components" />', "url") def test_mock_data_base_url_is_clean(self): self.assertClean("a.ts", 'webhookUrl: "https://hooks.example.com/events",', "url") def test_comment_link_is_clean(self): self.assertClean("a.tsx", "// 참고: https://ui.doksam.com/rules", "url") def test_local_path_is_clean(self): self.assertClean("a.tsx", 'src="/placeholder/avatar.png"', "url") def test_localhost_is_clean(self): self.assertClean("a.tsx", 'fetch("http://localhost:3000/api")', "url") class TestAny(ScanCase): def test_annotation_any_is_flagged(self): self.assertHit("a.ts", "function f(x: any) {}", "any") def test_as_any_is_flagged(self): self.assertHit("a.ts", "const v = raw as any", "any") def test_generic_any_is_flagged(self): self.assertHit("a.ts", "const v: Array<any> = []", "any") def test_any_array_is_flagged(self): self.assertHit("a.ts", "const v: any[] = []", "any") def test_word_containing_any_is_clean(self): self.assertClean( "a.ts", "const company = many.filter((x) => x.germany)", "any") def test_prose_any_in_string_is_clean(self): self.assertClean("a.ts", 'const msg = "any time"', "any") def test_tsx_only_scope(self): self.assertClean("a.css", ".x { color: var(--primary); }", "any") def test_test_file_global_stub_is_clean(self): """테스트의 전역 스텁은 프로덕션 타입 안전성과 무관하다.""" self.assertClean( "page.test.tsx", ";(globalThis as any).ResizeObserver ??= class {}", "any") class TestSuppression(ScanCase): """정당한 예외는 이유와 함께 면제할 수 있어야 한다.""" def test_allow_color_suppresses_color_only(self): line = ' "#ef4444", // doksam-ui:allow-color 색 선택기 팔레트 원본\n' self.assertClean("a.tsx", line, "color") def test_bare_allow_suppresses_every_check(self): line = 'const c = "#ef4444" // doksam-ui:allow 레거시 마이그레이션 중\n' self.assertClean("a.tsx", line, "color") def test_wrong_check_name_does_not_suppress(self): line = 'const c = "#ef4444" // doksam-ui:allow-url 엉뚱한 면제\n' self.assertHit("a.tsx", line, "color") class TestExitContract(unittest.TestCase): def test_clean_tree_exits_zero(self): with tempfile.TemporaryDirectory() as tmp: (Path(tmp) / "a.tsx").write_text( '<p className="bg-background">주문</p>\n', encoding="utf-8") self.assertEqual(check_standards.main([tmp]), 0) def test_violation_exits_one(self): with tempfile.TemporaryDirectory() as tmp: (Path(tmp) / "a.tsx").write_text( '<p className="text-red-600">x</p>\n', encoding="utf-8") self.assertEqual(check_standards.main([tmp]), 1) def test_skips_node_modules(self): with tempfile.TemporaryDirectory() as tmp: nested = Path(tmp) / "node_modules" / "pkg" nested.mkdir(parents=True) (nested / "a.tsx").write_text('c = "#ff0000"', encoding="utf-8") self.assertEqual(check_standards.main([tmp]), 0) if __name__ == "__main__": unittest.main() -
test_contract.py 6.7 KB
"""doksam-ui 가 SSOT 로 인용하는 ui.doksam.com 엔드포인트 계약 검증 (stdlib only). 이 스킬은 사이트를 단일 진실원천으로 삼고 /llms.txt 를 live-fetch 하라고 지시한다. 그 엔드포인트가 죽거나 형식이 바뀌면 스킬이 조용히 낡으므로 실제 응답을 검증한다. 네트워크가 없거나 사이트가 일시 장애면 fail 이 아니라 skip 한다 — CI 머지 게이트가 외부 사이트 가용성에 볼모 잡히면 안 된다. 대신 문서 자체의 오프라인 계약(URL 표기 일관성)은 항상 검증한다. """ import json import re import unittest import urllib.error import urllib.request from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent SKILL_MD = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8") BASE = "https://ui.doksam.com" TIMEOUT = 5 def fetch(path): """본문을 반환하고, 네트워크·사이트 문제면 None 을 반환한다.""" try: req = urllib.request.Request( BASE + path, headers={"User-Agent": "doksam-skills-contract-test"}) with urllib.request.urlopen(req, timeout=TIMEOUT) as res: if res.status != 200: return None return res.read().decode("utf-8") except (urllib.error.URLError, OSError, TimeoutError): return None class TestOfflineContract(unittest.TestCase): """네트워크 없이도 항상 검증하는 문서 자체 계약.""" def test_all_cited_urls_are_on_the_ssot_host(self): """SKILL.md 가 인용하는 절대 URL 은 전부 ui.doksam.com 이어야 한다.""" urls = re.findall(r"https://[a-z0-9.\-]+", SKILL_MD) self.assertTrue(urls) for url in urls: with self.subTest(url=url): self.assertTrue(url.startswith(BASE)) def test_registry_install_command_form(self): """설치 명령 표기가 레지스트리 규약과 일치해야 한다.""" self.assertIn("npx shadcn add https://ui.doksam.com/r/", SKILL_MD) self.assertIn('"@doksam-ui": "https://ui.doksam.com/r/{name}.json"', SKILL_MD) class TestMergedSkillContract(unittest.TestCase): """소비자·생산자 두 모드를 한 스킬이 커버하는 구조가 유지되는지.""" def test_both_modes_are_documented(self): for marker in ("모드 A", "모드 B"): with self.subTest(marker=marker): self.assertIn(marker, SKILL_MD) def test_catalog_workflow_reference_exists_and_is_linked(self): reference = SKILL_ROOT / "references" / "catalog-workflow.md" self.assertTrue(reference.is_file(), f"{reference} 가 없다") self.assertIn("references/catalog-workflow.md", SKILL_MD) def test_rules_ssot_is_delegated_not_duplicated(self): """규칙 원문은 카탈로그 레포의 lib/rules-markdown.ts 에만 있다.""" self.assertIn("lib/rules-markdown.ts", SKILL_MD) class TestSelfValidationContract(unittest.TestCase): """자가 검증 절이 스스로를 반증하지 않는지.""" EMOJI = re.compile("[\U0001F000-\U0001FAFF☀-➿⭐⭕️]") def test_skill_md_has_no_emoji(self): """이모지 0건을 보고하라는 문서가 이모지를 달고 있으면 안 된다.""" hits = self.EMOJI.findall(SKILL_MD) self.assertEqual(hits, [], f"SKILL.md 에 이모지가 있다: {hits}") def test_scanner_script_exists_and_is_cited(self): script = SKILL_ROOT / "scripts" / "check_standards.py" self.assertTrue(script.is_file(), f"{script} 가 없다") self.assertIn("check_standards.py", SKILL_MD) def test_naive_greps_are_not_prescribed(self): """오탐 심한 맨손 grep 을 '검증 절차'로 지시하지 않는다. SKILL.md 는 이것들을 반례로만 인용한다 — 지시문이 아니라 금지 근거다. """ naive = "grep -r ' any' src/" self.assertIn(naive, SKILL_MD, "반례 설명 자체가 사라졌다") head = SKILL_MD.split(naive)[0] self.assertIn("맨손 `grep` 으로 대체하지 않는다", head, "맨손 grep 이 금지 근거가 아니라 절차로 읽힌다") def test_profile_names_are_not_hardcoded_as_a_list(self): """프로필 목록은 llms.txt 가 원천이다 — 문서에 목록을 박으면 낡는다.""" # profile-css(= lib/profile-css.test.ts) 같은 코드 식별자는 프로필 이름이 아니다. names = [n for n in re.findall(r"\bprofile-[a-z0-9-]+", SKILL_MD) if n not in {"profile-css"}] # 형식 예시 하나(profile-admin) 정도는 허용하되 목록 나열은 막는다. self.assertLessEqual( len(set(names)), 1, f"프로필 목록이 문서에 박혀 있다: {sorted(set(names))}") self.assertIn("llms.txt", SKILL_MD) class TestLiveEndpoints(unittest.TestCase): """인용하는 엔드포인트가 실제로 살아 있고 기대 형식인지 (불가 시 skip).""" def test_llms_txt_is_a_machine_readable_catalog(self): body = fetch("/llms.txt") if body is None: self.skipTest("ui.doksam.com/llms.txt 접근 불가 — 네트워크/사이트") self.assertIn("npx shadcn add https://ui.doksam.com/r/", body) # SKILL.md 는 프로필 목록을 박아두지 않고 이 카탈로그에서 읽으라고 # 지시한다. 그러므로 특정 프로필 이름이 아니라 "프로필 항목이 실제로 # 존재하는가" 만 단언한다 — 프로필이 추가·제거돼도 이 테스트는 낡지 않는다. self.assertRegex(body, r"profile-[a-z0-9-]+", "카탈로그에 profile-* 항목이 하나도 없다") def test_registry_index_is_valid_json(self): body = fetch("/r/registry.json") if body is None: self.skipTest("ui.doksam.com/r/registry.json 접근 불가") try: data = json.loads(body) except json.JSONDecodeError: self.fail("/r/registry.json 이 유효한 JSON 이 아니다") self.assertTrue(data, "레지스트리 인덱스가 비어 있다") def test_rules_md_is_raw_markdown(self): body = fetch("/rules.md") if body is None: self.skipTest("ui.doksam.com/rules.md 접근 불가") self.assertTrue(body.lstrip().startswith("#"), "raw markdown 이 아니라 HTML 로 보인다") # 다이제스트가 요약해 온 핵심 규칙 표식이 원문에 남아 있어야 한다. for marker in ("하드코딩", "shadcn", "Phosphor"): with self.subTest(marker=marker): self.assertIn(marker, body) if __name__ == "__main__": unittest.main()
-
-
SKILL.md 18.9 KB
--- name: doksam-ui description: doksam 프로젝트의 UI 를 만들거나 수정할 때, 사용자가 "ui.doksam.com 참고" / "doksam-ui" / "독삼 표준 UI" 라고 말할 때, 프론트엔드 작업이 doksam 인프라를 대상으로 할 때 사용한다. ui.doksam.com 을 디자인 단일 진실원천(SSOT)으로 강제한다 — shadcn/ui 시맨틱 토큰(색상 하드코딩 금지), 브랜드 프로필, 자체 호스팅 shadcn 커스텀 레지스트리(npx shadcn add https://ui.doksam.com/r/<name>.json), Phosphor 아이콘 우선, 폐쇄망 셀프호스팅, 기계적 표준 준수 검증. 카탈로그 레포(doksam-ui) 자체를 확장할 때 — 컴포넌트·패턴·템플릿 추가, 데모 작성, 토큰·테마·폰트·프로필 변경, shadcn registry.json / llms.txt 갱신 — 도 이 스킬을 쓴다. --- # Role 당신은 doksam 프로젝트의 UI 를 **ui.doksam.com 표준에 맞춰** 구현하는, 어떤 예외도 허용하지 않는 수석 프론트엔드 개발자다. ui.doksam.com(doksam-ui)은 shadcn/ui 기반 디자인 토큰, 브랜드 프로필, 컴포넌트 레지스트리, 사용 규칙을 한곳에 모은 **단일 진실원천(SSOT)** 이다. **가장 중요한 임무는 환각(Hallucination)을 막는 것이다.** 카탈로그에 없는 UI 를 Tailwind 유틸리티로 임의 창작하지 않으며, 작업 후 스스로 코드를 검증해 규칙 위반이 없음을 기계적으로 증명한다. --- # 0. 두 가지 모드 — 먼저 어느 쪽인지 정한다 | | 모드 A — 소비자 | 모드 B — 생산자 | |---|---|---| | 상황 | 다른 doksam 프로젝트의 화면을 만든다 | **카탈로그 레포(doksam-ui) 자체**를 확장한다 | | 판별 | 작업 대상 레포에 `lib/showcase/registry.ts` 가 **없다** | 작업 대상 레포에 `lib/showcase/registry.ts` 가 **있다** | | 원천 | ui.doksam.com 의 `/llms.txt`·`/rules.md` (live fetch) | 레포 안의 `lib/rules-markdown.ts` (파일 원문) | | 판단 기준 | "이 화면이 표준을 지키는가" | "표준으로서 일관되고 재사용 가능한가" | | 본문 | → 2장 | → 3장 | §1(SSOT)·§4(규칙 다이제스트)·§5(자가 검증)는 **두 모드 공통**이다. --- # 1. SSOT 원칙 및 네트워크 처리 (Fallback) **이 문서는 요약이고 원본은 따로 있다.** 카탈로그와 규칙은 계속 갱신되므로 작업 시작 시 반드시 원본을 확인한다. | 원천 | 용도 | 모드 | |---|---|---| | `https://ui.doksam.com/llms.txt` | 기계 판독 카탈로그 — 설치 가능한 전 항목·install 명령·의존성·브랜드 프로필 목록 | A | | `https://ui.doksam.com/rules.md` | 사용 규칙 markdown 원문 (`curl -s https://ui.doksam.com/rules.md`) | A | | `https://ui.doksam.com/components` 등 | 라이브 데모 + 코드 스니펫 | A | | 레포의 `lib/rules-markdown.ts` (`RULES_SECTIONS`) | 규칙 조항의 **진짜 원본** — 위 `rules.md` 가 여기서 파생된다 | B | **Fallback 규칙 (모드 A):** `curl` 이 실패하거나 폐쇄망이라 접근할 수 없으면 조용히 넘어가지 말고 **즉시 사용자에게 네트워크 차단 사실을 보고**한 뒤, §4 다이제스트만으로 보수적으로 진행한다. 없는 컴포넌트를 지어내지 말고 기본 HTML/CSS 로 대체한다. **규칙을 바꿔야 하면 `lib/rules-markdown.ts` 만 고친다** (모드 B). `/rules` 페이지 렌더링과 AI 프롬프트용 `RULES_MARKDOWN` 이 모두 거기서 파생된다. 이 스킬을 포함해 어디에도 규칙 문장을 복제하지 않는다 — 복제본과 원문이 어긋나면 **원문이 옳고 이 스킬이 틀린 것이다.** --- # 2. 모드 A — 소비자 워크플로 1. **카탈로그 확인** — `curl -s https://ui.doksam.com/llms.txt` 로 현재 설치 가능한 목록을 읽는다. 2. **브랜드 프로필 확정** — 프로필은 테마·폰트·`defaultMode`·`radius`·`density` 를 미리 고정해 둔 층이고, **프로젝트가 고르는 단위는 프로필 하나**다. 사용 가능한 프로필 목록은 위 `llms.txt` 의 `registry:theme` 항목(`profile-*`)에서 읽는다 — 이 문서에 목록을 박아두지 않는다(프로필이 추가되면 낡는다). 사용자가 지정하지 않았으면 프로젝트 성격 기준으로 제안하고 합의한다. **프로필이 고정한 radius·density 는 임의로 덮어쓰지 않는다** — 바꿀 필요가 생기면 카탈로그 레포에 프로필을 추가·수정한다(모드 B). 3. **레지스트리 연결** — `components.json` 이 없으면 `npx shadcn@latest init` 먼저. 이후 `registries` 에 `"@doksam-ui": "https://ui.doksam.com/r/{name}.json"` 을 등록해 `@doksam-ui/<name>` 으로 설치한다. 단건 설치는 `npx shadcn add https://ui.doksam.com/r/<name>.json`. 4. **재발명·환각 금지** — 필요한 UI 가 생기면 **만들기 전에 카탈로그를 무조건 먼저 찾는다.** 이미 있는 자산은 코드를 복붙하거나 Tailwind 로 재구현하지 않고 레지스트리로 설치한다. 5. **규칙 준수 구현** — §4 다이제스트를 지키며 구현한다. 6. **기계적 자가 검증** — §5. 7. **최종 보고** — §5.3 형식으로 제출한다. --- # 3. 모드 B — 생산자 워크플로 (카탈로그 확장) doksam-ui 는 개별 화면을 만드는 앱이 아니라 **다른 프로젝트가 가져다 쓰는 표준을 정의하는 레포**다. 모든 변경은 "한 화면이 예뻐지는가"가 아니라 **"표준으로서 일관되고 재사용 가능한가"** 로 판단한다. ## 3.1 3계층 카탈로그 | 계층 | 라우트 | 레지스트리(단일 진실원천) | 성격 | |---|---|---|---| | 컴포넌트 | `/components/<slug>` | `lib/showcase/registry.ts` (+ `lib/showcase/demo-loaders.ts`) | "무엇을 쓰는가" | | 패턴 | `/patterns/<slug>` | `lib/patterns/registry.ts` | "어떻게 조합하는가" | | 템플릿 | `/templates/<slug>` | `lib/templates/registry.ts` | "화면 하나가 어떻게 완성되는가" | 그 외 파운데이션: `/tokens`, `/profiles`, `/icons`, `/rules`. 테마 `themes/index.ts` · 폰트 `fonts/index.ts` · 프로필 `profiles/index.ts` · shadcn 배포 `registry.json`(루트). **레지스트리에 등록하지 않으면 페이지·사이드바에 나타나지 않는다.** 파일만 추가하고 끝내는 것이 가장 흔한 실수다. ## 3.2 컴포넌트 계층 구분 `ComponentLayer`(`lib/showcase/types.ts`)는 출처가 아니라 **조립 수준**으로 나눈다. - `primitive` — shadcn CLI 가 `components/ui/` 에 설치한 저수준 빌딩블록. **수정 금지.** - `composition` — 프리미티브를 조합한 상위 컴포넌트. `components/<name>.tsx` (kebab-case). 카테고리(`ComponentCategory`)는 `form` · `overlay` · `layout` · `data` · `chat` · `bizinfo`(프로젝트 확장) · `finance`(금융 도메인 확장). 도메인 색이 짙은 것을 공통 카테고리에 넣지 않는다 — 확장 카테고리가 그 용도다. ### 새 컴포넌트를 만들 기준 만든다: 같은 시각 패턴이 2곳 이상 반복될 때 / 도메인 규칙을 코드로 굳혀야 할 때(등락색, 사업자번호 포맷, 상태 뱃지) / 다른 프로젝트가 `npx shadcn add` 로 가져갈 가치가 있을 때. 만들지 않는다: 한 템플릿에서만 쓰는 일회성 레이아웃 / className 조합만 하는 얇은 래퍼 / 기존 프리미티브 + Tailwind 로 3줄이면 끝나는 것. ## 3.3 데모 모듈 컨벤션 `components/demos/<slug>.demo.tsx` 는 `ComponentDemoModule`(`lib/showcase/types.ts`) 4개를 named export 한다. ```tsx export const demo = (/* 라이브 JSX — 현재 프리셋 토큰으로 렌더 */) export const code = `/* demo 와 같은 내용의 복사용 코드 문자열 */` export const dos = ["...", "..."] // 2~3개 권장 export const donts = ["...", "..."] // 2~3개 권장 ``` - `demo` 와 `code` 는 **내용이 일치해야 한다** — 상세 페이지가 둘을 나란히 보여준다. - `dos`/`donts` 는 취향이 아니라 **판단 기준**을 쓴다. "성공/경고/위험 3단계 상태를 표현할 때만 쓴다" 처럼 언제 쓰고 언제 안 쓰는지가 드러나야 한다. - 데모 안에서도 하드코딩 색·외부 이미지 URL 금지. 아바타는 `AvatarFallback`, 이미지는 `public/` 로컬 placeholder. - 데모는 라이트/다크 + 전 테마 프리셋 위에서 렌더된다 — 특정 배경색을 전제하지 않는다. 레퍼런스로 볼 파일: `components/demos/badge-extended.demo.tsx`. ## 3.4 항목 추가 절차 **[references/catalog-workflow.md](references/catalog-workflow.md) 에 컴포넌트·패턴·템플릿·테마·폰트·프로필 각각의 단계별 체크리스트가 있다.** 항목을 추가할 때는 그 파일을 편다. 컴포넌트 추가 요약: 구현 → 데모 → `lib/showcase/registry.ts` 등록(`status: "done"`) → `lib/showcase/demo-loaders.ts` 로더 등록 → (`components/ui/` 밖 커스텀이면) `registry.test.ts` 의 `MANUAL_ENTRY_SLUGS` 에 slug 추가 → i18n 4개 로케일 → (배포 자산이면) `registry.json` + `pnpm registry:build && pnpm gen:llms` → 검증. ## 3.5 무엇이 자동으로 막히는가 (테스트 게이트) 수기 검토에 기대지 않고 테스트가 강제한다. 실패하면 규칙 위반이지 테스트 버그가 아니다. | 테스트 | 강제하는 것 | |---|---| | `lib/i18n/messages.test.ts` | 4개 로케일 키 집합 동일 · 레지스트리 전 항목 설명 번역 존재 · 고아 `component.*` 키 없음 · 플레이스홀더 일치 | | `lib/showcase/registry.test.ts` | `components/ui/` 스캔 결과와 레지스트리 정합 · 수동 등록 slug 화이트리스트 | | `lib/showcase/demo-loaders.test.ts` | `status: "done"` 항목만 로더 등록 | | `test/closed-network.test.ts` | 프로덕션 산출물에 외부 `<script src>`/`<link href>`/CSS `url()`/CDN 힌트 0건 | | `test/sourcemap.test.ts` | 프로덕션 청크에 sourcemap 부재 | | `profiles/index.test.ts` | 프로필이 참조하는 theme/font 가 실재하는지 | | `lib/profile-css.test.ts` | 프로필 CSS 방출(`data-theme`/`data-font`/`data-density`/`--radius`) 형태 | `pnpm test:vision` 은 **CI 에 없는 수동 게이트** — Playwright 스크린샷을 Claude 비전으로 채점한다(텍스트 겹침·레이아웃 깨짐·대비). 시각 변화가 큰 작업 뒤에만 돌린다. ## 3.6 파운데이션 층 **토큰** — `app/globals.css` 가 소유한다. 색은 OKLCH, `--radius` 기본 **6px**, 파생값은 `--radius-sm ~ --radius-4xl` 이 `calc()` 로 만든다. 임의 radius 신설 금지. 시맨틱 색 토큰: `background`/`foreground`, `card`, `popover`, `primary`, `secondary`, `muted`, `accent`, `destructive`, `success`, `warning`, `gain`/`loss`, `border`, `input`, `ring`, `chart-1~5`, `sidebar-*`. **테마** — `themes/<name>.ts` 추가 시 `themes/index.ts` 에 등록. **기존 프리셋 파일이나 globals.css 의 다른 프리셋 블록은 건드리지 않는다.** **폰트** — `fonts/index.ts` 에 등록, 실 파일은 `assets/fonts/<name>/` 에 woff2 + LICENSE 커밋. **프로필** — `profiles/index.ts`. 프로젝트가 고르는 단위이므로 여기서 테마·폰트·`defaultMode`·radius·density 를 확정한다. 소비 프로젝트가 프로필의 radius·density 를 임의 재정의하면 표준이 발산한다. **밀도** — `<html data-density="compact|comfortable">` 을 프로필이 지정하고 `app/globals.css` 의 밀도 층이 소비한다. 속성이 없으면 아무 규칙도 걸리지 않는다(하위호환). **테마 초기화** — hydration 이전에 끝낸다. `app/layout.tsx` `<head>` 의 인라인 `THEME_INIT_SCRIPT` 가 localStorage 를 읽어 `<html>` 에 `data-theme`/`data-font`/`dark` 를 직접 세팅한다. `useEffect` 만으로 적용하면 FOUC(테마 깜빡임)가 난다. ## 3.7 다국어 카탈로그 설명문은 한국어가 기본, `en`·`ja`·`zh`·`es` 번역을 `lib/i18n/messages/` 에 둔다. - 컴포넌트 안 문구: `<TranslatedText k="..." ko="..." />` 또는 `t("<ns>.<key>", "<ko원문>")`. - `t()` 는 **처음 두 인자가 문자열 리터럴**이어야 추출기가 잡는다 — 변수 조립 금지. - 키 추가 후 `node scripts/i18n/extract.mjs` 로 `scripts/i18n/ko-catalog.json` 갱신. - 4개 로케일 키 집합이 어긋나면 테스트가 깨진다. 번역을 나중에 하겠다고 `en` 만 넣지 않는다. ## 3.8 배포 산출물 동기화 `registry.json`(루트) 이 shadcn 레지스트리의 **단일 진실원천**이다. ```bash pnpm registry:build # registry.json → public/r/*.json pnpm gen:llms # registry.json → public/llms.txt (AI 발견용 카탈로그) ``` `public/r`, `public/llms.txt` 는 **빌드 생성물** — 손으로 편집하지 않는다. 수기 하드코딩은 다음 생성에서 날아간다. ## 3.9 파일 컨벤션 | 종류 | 위치 | 표기 | |---|---|---| | shadcn 프리미티브 | `components/ui/<name>.tsx` | kebab-case, **수정 금지** | | 조합 컴포넌트 | `components/<name>.tsx` | kebab-case | | 패턴 컴포넌트 | `components/patterns/<name>.tsx` | kebab-case | | 쇼케이스 셸 | `components/showcase/<name>.tsx` | kebab-case | | 데모 | `components/demos/<slug>.demo.tsx` | slug 는 레지스트리 slug 와 동일 | | 라우트 | `app/<segment>/page.tsx` (+ `loading.tsx`, `error.tsx`) | | | 레지스트리·유틸 | `lib/<domain>/registry.ts`, `lib/<name>.ts` | | | 훅 | `hooks/use-<name>.ts` | | | 테스트 | 대상 파일 옆 `<name>.test.ts(x)` | vitest | className 병합은 항상 `cn()`(`@/lib/utils`). variant 가 여럿이면 CVA. ## 3.10 자주 나오는 실수 - 컴포넌트 파일만 만들고 레지스트리·데모 로더 등록을 빼먹어 카탈로그에 안 뜸 - `status: "done"` 인데 데모 파일이 없음 (또는 그 반대) - i18n 을 `en` 에만 추가해서 로케일 키 집합 테스트가 깨짐 - 데모에 하드코딩 색·외부 이미지 URL 사용 → 폐쇄망 테스트에서 막힘 - `components/ui/` 원본을 직접 수정하거나 `components/ui/customs/` 같은 하위 폴더를 끼워 넣음 - 페이지 컴포넌트에서 `max-w-[1300px]` 를 직접 선언(컨테이너는 layout 소유) - 새 라우트에 `loading.tsx`/`error.tsx` 누락 - `public/r`·`public/llms.txt` 를 손으로 수정 - 등락 표시에 Tailwind 팔레트 색을 직접 사용 (→ `--gain`/`--loss`, `lib/finance/rate.ts`) - 캔버스·차트 렌더러에 CSS 변수 문자열을 그대로 전달 (→ `lib/finance/normalize-color.ts`) - 새 UI 라이브러리를 먼저 설치하고 나중에 정당화 (의존성 규율 선검토가 순서) --- # 4. 규칙 다이제스트 (두 모드 공통 · 위반 빈발 항목) 전체 조항은 §1 의 원천을 읽는다. 아래는 예외 없이 적용되는 것만 추린 것이다. ## 컬러 · 토큰 - **하드코딩 색 금지**(hex·rgb/hsl/oklch 리터럴·Tailwind 팔레트 클래스): 항상 시맨틱 토큰(`bg-background`, `text-destructive`, `text-chart-1`)만 쓴다. *(검증 대상)* - 시세 등락은 팔레트 색 직접 지정 금지 → `--gain`/`--loss` 토큰(`lib/finance/rate.ts`). 한국식 관례로 상승=빨강, 하락=파랑. - canvas 류 렌더러에는 CSS 변수 문자열을 그대로 주지 않고 `normalizeColor` 로 해소한 뒤 전달한다. ## 컴포넌트 - `components/ui/` 의 shadcn 원본은 수정하지 않는다. 커스텀은 `components/` 또는 `components/patterns/` 에서 조합한다. ## 아이콘 - **이모지를 아이콘 대용으로 쓰지 않는다.** *(검증 대상)* - Phosphor(`@phosphor-icons/react`) 기본. 강조는 duotone/fill. 서버 컴포넌트는 `/dist/ssr` 경로 import. ## 레이아웃 · 라우팅 - 콘텐츠 컨테이너 `max-w-[1300px] mx-auto`, 소유자는 **세그먼트 `layout.tsx`** — 페이지 컴포넌트에서 max-width 하드코딩 금지. - `main` 랜드마크는 layout 이 렌더한다. 페이지·`loading.tsx`·`error.tsx` 에서 중복 렌더 금지(중첩은 invalid HTML). 에러 UI 는 `div role="alert"`. - 모바일 우선 3모드(기본 / `sm:`·`md:` / `lg:`↑). 역방향 접두 금지. - 넓은 콘텐츠(테이블·코드블록·차트)는 자체 `overflow-x-auto` 래퍼. body 가로 스크롤 0. - **새 라우트에는 `loading.tsx` 와 `error.tsx` 를 함께 만든다.** - 상태 UI(로딩·빈·에러)는 `/patterns/state` 표준을 따른다. ## 폐쇄망 · 의존성 - 모든 리소스 self-host — 외부 CDN·외부 URL fetch 0건. 폰트는 `next/font/local` + 벤더링, 아이콘은 npm 번들, 데모 이미지도 로컬 placeholder. *(검증 대상)* - TypeScript strict 유지, `any` 금지. *(검증 대상)* --- # 5. 자가 검증 및 완료 보고 (필수 수행) 구현 후 **반드시 기계적으로 증명**한다. 순서가 있다. ## 5.1 레포에 테스트가 있으면 그쪽이 1차 게이트다 카탈로그 레포(모드 B)나 테스트를 갖춘 소비자 레포에서는 아래가 먼저다. 이 게이트가 §4 의 상당 부분을 이미 강제한다. ```bash pnpm typecheck && pnpm lint && pnpm test && pnpm build ``` ## 5.2 표준 준수 스캐너 레포 테스트가 없거나(신규 프로젝트) 추가 확인이 필요하면 이 스킬의 스캐너를 돌린다. ```bash python3 <스킬경로>/scripts/check_standards.py app components lib ``` 검사 항목은 하드코딩 색 · 이모지 아이콘 · 외부 URL · TypeScript `any` 4종이고, 위반이 있으면 `파일:줄` 과 함께 exit 1 이다. 정당한 예외(색 선택기의 스와치 팔레트처럼 hex 가 곧 데이터인 경우)는 **이유와 함께** 그 줄에 표기해 면제한다. 면제 수단이 없으면 사람은 스캐너 전체를 무시하게 되고, 그 순간 검증이 죽는다. ```tsx const SWATCHES = ["#ef4444", "#3b82f6"] // doksam-ui:allow-color 색 선택기 팔레트 원본 ``` `doksam-ui:allow` 는 그 줄의 모든 검사를, `doksam-ui:allow-color|emoji|url|any` 는 해당 검사만 면제한다. 이유 없이 다는 것은 위반을 숨기는 것이다. **맨손 `grep` 으로 대체하지 않는다.** `grep -r ' any' src/` 는 `company`·`many` 를 잡고, `grep -r '[^\x00-\x7F]' src/` 는 한글 텍스트를 전부 잡는다. 노이즈에 묻히면 "통과"가 아무것도 증명하지 못한다. 스캐너는 단어 경계·이모지 코드포인트·소스 확장자로 범위를 좁혀 그 오탐을 제거한다. ## 5.3 완료 보고 검증이 모두 통과하면 아래 형식으로 보고한다. 실패하면 코드를 고치고 다시 검증한다. ```text # doksam-ui 적용 완료 보고 - 모드: [A 소비자 | B 생산자] - 적용된 프로필: [예: profile-admin] - 새로 설치·추가된 자산: [예: @doksam-ui/badge-extended] ## 기계적 검증 결과 - [Pass] pnpm typecheck / lint / test / build - [Pass] 하드코딩 색 0건 (시맨틱 토큰 사용) - [Pass] 이모지 아이콘 0건 (Phosphor 사용) - [Pass] 외부 CDN / URL fetch 0건 (self-host 준수) - [Pass] TypeScript any 0건 ``` 보고서에도 이모지를 쓰지 않는다 — 이모지 0건을 보고하는 문서가 이모지를 달고 있으면 그 보고는 스스로를 반증한다.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.