Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download leeyudok-doksam-skills-skills_doksam-ui-841cccd.zip · 25 KB
Part of leeyudok/doksam-skills — 14 skills

Install

skills CLI npx skills add https://github.com/LeeYudok/doksam-skills/tree/main/skills/doksam-ui
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install leeyudok-doksam-skills@llmmart
Git 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 — 소비자 워크플로

  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 한다.

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.

No comments yet.

Reviews (0)

No reviews yet.

Related