From 4daca6d35757b5b5077b032c35b13649f403d5a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=88=98=ED=98=84?= Date: Thu, 8 Oct 2026 20:32:15 +0900 Subject: [PATCH] feat: improve lightweight AI generation guard --- AGENTS.md | 93 ++------------- docs/guidelines/ai-generation-guard.md | 16 +++ docs/guidelines/ai-implementation-details.md | 84 ++++++++++++++ .../guidelines/component-pattern-reference.md | 108 +++++++++--------- scripts/ai-generation-guard.mjs | 65 ++++++++--- scripts/ai-generation-guard.test.mjs | 102 +++++++++++++++-- scripts/create-ai-reference-package.mjs | 21 +++- 7 files changed, 327 insertions(+), 162 deletions(-) create mode 100644 docs/guidelines/ai-generation-guard.md create mode 100644 docs/guidelines/ai-implementation-details.md diff --git a/AGENTS.md b/AGENTS.md index 74919a9..6085b65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,84 +1,15 @@ -# HMDesignSystem 작업 지침 +# HMDesign AI 화면 구현 — 필수 규칙 -## AI Generation Guard FAST CORE +이 파일을 먼저 읽고 필요한 항목만 탐색한다. 전체 문서·전체 소스·전체 Storybook을 반복해서 읽지 않는다. -- AI 화면 생성 또는 부분 수정 완료 후 실제 재사용한 HMDesign 요소에 대해 `node scripts/ai-generation-guard.mjs --target <앱 경로> --scope FULL_SCREEN|PARTIAL`을 1회 실행한다. Navigation을 재사용하면 확정된 `--mode`를 전달하고, PARTIAL은 `--files <수정 파일>`로 범위를 지정한다. -- FAIL이 발생한 항목만 수정한다. -- 수정 후에는 수정한 범위만 PARTIAL로 다시 검사한다. -- 정상 결과에 대해 추가 검사를 반복하지 않는다. +1. 범위 `STORYBOOK_ONLY | PRODUCTION_UI | BOTH`와 Runtime을 확인한다. 신규 AI 업무 화면은 실제 HM React 컴포넌트를 사용한다. 명시적 다른 Runtime adapter는 [상세 계약](docs/guidelines/runtime-portability.md)을 따른다. +2. [기존 Component / Pattern 인덱스](docs/guidelines/component-pattern-reference.md)에서 후보를 찾는다. Import는 `@hm-design/ui` 공개 API가 기본이다. 선택한 문서·대표 All Story·필요한 Props만 확인한다. verification Story는 운영 기준이 아니다. +3. 기존 Component → Variant/Prop → Composition 순으로 재사용한다. 기본 HTML·임의 CSS로 HM 컴포넌트를 모방하거나 `data-slot`만 붙여 재사용했다고 보고하지 않는다. Import만으로 재사용을 인정하지 않는다. 실제 렌더링에 사용해야 한다. +4. 기존 Theme/Semantic Token을 사용한다. React CSS entry는 앱 `index.css → hm-theme.css` 및 필요 시 공개 status CSS를 연결한다. [Runtime Theme](docs/guidelines/runtime-theme.md), [Tailwind/Asset 계약](docs/guidelines/runtime-portability.md)은 관련 변경 때만 읽는다. +5. 페이지 Navigation은 GNB/LNB/DASHBOARD 중 하나다. 공식 LNB ONLY는 허용한다. 신규 GNB ONLY·DASHBOARD ONLY의 Sidebar는 금지한다. GNB는 GNBMobileMenu도 구성한다. Dashboard는 전용 48px Header와 최소 폭 960px·내부 스크롤을 유지한다. 미지정 시 [선택 근거](docs/guidelines/navigation-single-mode.md#선택-근거)를 기록한다. +6. Storybook은 참조 전용이다. 신규 업무 화면을 Story로 등록하거나 대표 Story를 교체하지 않는다. 독립 미리보기 HTML은 실제 React bootstrap을 실행해야 한다. 별도 HTML 모방 화면·이미지를 실제 React 결과로 제출하지 않는다. +7. 완료 후 Guard를 1회 실행한다: `node scripts/ai-generation-guard.mjs --target <앱> --scope FULL_SCREEN|PARTIAL`. Navigation 재사용 시 `--mode GNB_ONLY|LNB_ONLY|DASHBOARD_ONLY`, PARTIAL은 `--files <수정 파일,...>`을 전달한다. 최종 제출 경로 확인은 같은 실행에 `--result <실제 React HTML 또는 bootstrap>`을 추가한다. +8. FAIL만 수정하고 수정 범위만 PARTIAL 재검사한다. 정상 검사는 반복하지 않는다. Guard PASS는 정적 계약만 뜻한다. 화면별 빌드와 실제 React 브라우저 렌더링(utility 적용·레이아웃·핵심 동작)을 최소 1회 확인한다. 미확인은 `NOT VERIFIED`로 보고한다. [Guard 범위](docs/guidelines/ai-generation-guard.md). +9. Component·Pattern·Theme·대표 Story 디자인/동작·공개 API를 요청 없이 변경하지 않는다. 결과는 실제 사용 요소·Navigation·검증·미검증만 간결하게 보고한다. -## AI 화면 구현 Quick Start - -화면 구현 요청을 받으면 아래 순서를 그대로 따른다. 파일이나 API의 존재를 확인할 수 없으면 추측하지 말고 미확인으로 기록한다. - -1. **프로젝트 구조와 Runtime 확인**: 제공된 파일 범위를 살펴보고 `REACT_VITE | STATIC_HTML | VANILLA_WEB | OTHER_RUNTIME` 중 대상을 정한다. Component·Pattern 파일이 실제 범위에 있는지 확인한다. -2. **Component 검색**: `packages/ui/src/components/ui/`와 `packages/ui/src/index.ts`에서 공개 Component·Props를 찾고, `docs/components/.md`를 확인한다. 기존 공개 Component를 우선 사용한다. -3. **Pattern 검색**: `packages/ui/src/patterns/`, `src/patterns/`, `packages/ui/src/index.ts`와 `docs/patterns/`에서 맞는 Pattern·공개 API를 찾아 구조와 상태 규칙을 따른다. -4. **Storybook 확인**: `src/components/`, `src/patterns/`, `src/theme/`의 대표 `All` Story와 관련 문서를 확인한다. `verification` 전용 Story는 일반 화면의 기준으로 쓰지 않는다. Storybook을 실행할 수 없으면 Story 소스와 문서만 확인하고 시각 동작은 추측하지 않는다. -5. **Theme 연결**: `themes/default.json`, `packages/ui/src/theme.css`, Runtime 계약과 Token 가이드를 확인해 기존 CSS Custom Properties 구조를 사용한다. -6. **화면 구성**: Component와 Pattern을 조합하고 아래 단일 Navigation 정책을 지킨다. 신규 AI Production 업무 화면에서 LNB ONLY는 기존 공식 LNB 메뉴와 좌측 LNB Navigation을 정상 생성·사용한다. GNB ONLY는 기존 공식 GNB를 사용하되 Sidebar·GNB Sidebar Composition을 자동·수동 요청 모두 적용하지 않으며, DASHBOARD ONLY는 Sidebar/LNB를 생성하지 않는다. Navigation 미지정 시 기존 모드 선택 규칙을 따르며 임의 Sidebar·Drawer·대체 메뉴를 추가하지 않는다. 기존 구성으로 해결되지 않을 때만 새 Component를 검토한다. -7. **결과 확인 및 보고**: 실제 적용한 Component·Pattern, Theme 연결, Navigation Mode, 실행/화면 확인 결과와 미검증 항목을 보고한다. - -**Storybook / 화면 생성 분리**: Storybook은 기존 Component·Pattern·Theme·Variant·State·Interaction을 확인하는 Reference 환경이다. 일반 업무 화면 생성은 Storybook Story·사이드바에 등록하지 않고, 기존 대표 Story를 생성 화면으로 교체하지 않으며, Storybook 실행을 필수 확인 경로로 요구하지 않는다. 별도의 React 화면과 독립 미리보기를 만들고 화면별 빌드·필요한 검증을 수행한다. H-2(AI 생성 화면의 자동 빌드·캡처·검증, Storybook 배포와 별도이며 구현 완료 여부는 현재 파일로 확인)나 유사 자동화가 사용 가능한 경우에도 `preview.html`·desktop/mobile 이미지 같은 독립 미리보기 산출에만 사용하며 Storybook 등록과 혼동하지 않는다. Storybook 수정은 Component/Pattern 추가·수정, 대표/Verification Story 작성 또는 Storybook Reference 개선을 사용자가 명시한 경우에만 허용한다. Storybook 전용 helper/CSS는 운영 화면에 사용하지 않는다. - -상세 규칙은 이 문서의 [구현 기본값](#ai-implementation-defaults), [Runtime 계약](#runtime-portability-policy), [단일 Navigation 정책](#navigation-single-mode-policy)과 [Component / Pattern Reference Index](docs/guidelines/component-pattern-reference.md), [Component 구현 원칙](docs/guidelines/shadcn-기반-구현-원칙.md), [Runtime Theme](docs/guidelines/runtime-theme.md), [Navigation 정책](docs/guidelines/navigation-single-mode.md)을 따른다. - -**검색 원칙(도구 중립)**: 변경과 관련된 소스 경로부터 좁혀 검색하고, 생성물(`node_modules`, `dist`, `storybook-static`)과 로컬 작업본은 기본 제외한다. 필요한 최소 범위만 검증하며 실제로 확인하지 않은 기능을 PASS로 보고하지 않는다. 존재하지 않는 Component·Pattern·공개 API를 임의로 만들지 않고 없으면 없다고 보고한다. - -**Story 구분**: 운영 기준은 대표 `All` Story이고 `verification`·Compatibility·Fixture Story는 운영 예제가 아니다. Storybook 문서 화면은 `StoryPage` 공통 Inner를 사용하며, 이는 Storybook 표현용으로 `@hm-design/ui` 공개 API가 아니다. - -**화면 생성 시 필수 확인**: Navigation을 지정받으면 아래 모드별 정책에 따라 기존 공식 Component를 적용하고, 미지정이면 기존 모드 선택 규칙을 따르며 그 근거를 기록한다([선택 기준](docs/guidelines/navigation-single-mode.md#선택-근거)). LNB ONLY의 정상적인 공식 LNB 생성은 Sidebar 금지 대상에서 명시적으로 제외한다. GNB ONLY의 Sidebar·GNB Sidebar Composition 또는 DASHBOARD ONLY의 Sidebar/LNB가 요청되면 금지 규칙을 안내하고 해당 기능을 구현하지 않으며, 대체 Navigation을 임의로 추가하지 않는다. GNB는 Desktop 메뉴와 `GNBMobileMenu`를 함께 구성한다([GNB 모바일](docs/patterns/gnb.md#8-desktop--mobile)). Dashboard 운영 화면에는 Sidebar를 생성하지 않는다. `DashboardSidebar` 공개 Export와 선택적 `sidebar` slot은 기존 호환성·검증용이며 운영 허용을 뜻하지 않는다. Dashboard 상태는 Theme 다음에 공개 Status CSS를 로드하고 기존 Badge mapping을 소비한다([상태 예제](docs/patterns/dashboard-layout.md#dashboard-업무-상태-공통-계약)). FilterBar의 입력 조건과 조회 적용 조건·초기화 책임을 구분한다([조회 예제](docs/patterns/filter-bar.md#61-조회-버튼으로-적용하는-소비-예제)). `REACT_VITE` 독립 화면은 bootstrap에서 기존 앱 CSS entry(`@/index.css` → `@/hm-theme.css` → 필요 시 공개 상태 CSS)를 로드하며, `@hm-design/ui/tailwind.css`만 단독 import하지 않는다. 빌드 후 `.flex`, `.grid` 등 대표 utility의 생성·실제 적용과 캡처 레이아웃을 확인하고, 빌드 성공만으로 화면 검증을 PASS 처리하지 않는다. React 소비자는 Tailwind 검색 범위와 HM package source를 확인하고([Tailwind](docs/guidelines/runtime-portability.md#21-tailwind-v4-소비자-검색-범위)), 폰트·Asset 경로는 실행 위치별로 확인한다([Asset](docs/guidelines/runtime-portability.md#4-asset과-실행-위치)). - -## AI Implementation Defaults - -**사용자가 별도로 "기존 컴포넌트/디자인 시스템을 사용해줘", "테마를 적용해줘", "Semantic Token을 사용해줘", "Navigation 정책을 따라줘"라고 요청하지 않아도 AI는 이를 모든 신규·수정 UI의 기본 구현 조건으로 자동 적용한다.** - -- 구현 전에 `STORYBOOK_ONLY | PRODUCTION_UI | BOTH` 중 변경 범위를 정한다. -- 기존 Component와 유사 대표 Story를 먼저 검색한다. `packages/ui`, `src/components`, `src/patterns`, `src/center`, 기존 Storybook stories와 shared helpers를 확인하고 도메인에 맞는 구현을 재사용한다. -- 재사용 순서: 기존 HM Component → 기존 Variant / Prop → 기존 Composition → shadcn → Radix Primitive → 최소 Custom. 새 Component는 기존 Component·Composition으로 표현 불가능하고 Variant 확장이 부적절하며 shadcn/Radix 조합도 어려운 경우에만 만든다. 재사용 불가 사유와 최소 추가 범위를 설명한다. -- Storybook은 구현 Reference Catalog다. 대표/PRIMARY Story의 시각 언어와 공통 상태 mapping을 재사용하고 검증 전용 Story를 운영 예제로 삼지 않는다. 신규 업무 화면의 등록 대상이 아니며, 생성 결과는 별도 React 화면·독립 미리보기로 제공한다. -- Theme은 구현 시작부터 적용한다. 기존 Semantic Token → 해당 Component Token → 기존 CSS Variable을 먼저 확인하고, 없을 때만 필요한 최소 Semantic 확장을 검토한다. 기존 Token이 있는 색상·spacing·값을 하드코딩하거나 같은 Token을 여러 파일에 중복 정의하지 않는다. -- Theme 사용은 별도 승인 없이 기본 적용한다. Theme Source/값 수정은 명시적 필요성과 영향 범위를 확인한다. `themes/*.json` → Runtime Loader → CSS Custom Properties → Semantic → Component Mapping과 Primary / On Accent의 독립 fallback을 유지한다. -- 한 페이지에 `GNB | LNB | DASHBOARD` 중 하나만 사용한다. 신규 AI Production 업무 화면에서 LNB ONLY의 공식 LNB는 허용하고 GNB ONLY·DASHBOARD ONLY의 Sidebar는 금지한다. 반응형도 같은 Navigation Mode를 유지한다. -- 화면 구현 순서는 Quick Start를 따른다. 변경 작업 범위(STORYBOOK_ONLY | PRODUCTION_UI | BOTH)는 구현 전에 정한다. -- package exports, public props/types, package entry, Story ID, `meta.title`을 불필요하게 변경하지 않는다. 필요한 변경은 소비처·호환성 영향을 먼저 확인한다. Storybook 표현 문제와 Production 동작 문제는 해당 범위에서 해결한다. -- Badge/Button/FilterBar를 재사용하더라도 동일한 상태 ID·라벨·필터 매칭이 반복되면 화면별 helper로 남기지 말고 Runtime-neutral 상태 계약을 먼저 검토한다. 계약은 React 구현과 분리하고 `data-status`, 독립 flag attribute, `aria-pressed` 등 다른 Runtime이 재현할 수 있는 상태 vocabulary를 정의한다. -- 명시적 실험·Theme 미적용 prototype·외부 디자인 완전 재현·독립 POC만 별도 격리 범위에서 예외를 검토한다. Navigation 혼합은 아래 단일 모드 정책의 별도 기준을 따른다. -- Button은 `brand | solid | outline`의 사용 위치와 세트 규칙을 `docs/components/button.md` 기준으로 따른다: 실제 화면 Brand 최대 1개·팝업 Brand 금지, 일반 확인/작성은 Solid 세트, 삭제 확인·행/반복 작업은 Outline 세트이며 세트의 appearance와 size를 통일한다. Runtime 변환은 `docs/guidelines/runtime-portability.md`의 DOM/Theme/keyboard 계약을 함께 보존한다. -- Dashboard 운영 화면은 전용 Header(48px)·업무 상태 필터와 Content로 구성하며 Sidebar 사용을 금지한다. Dashboard 대표 `Patterns/Layout/Dashboard → Dashboard` Story와 `docs/patterns/dashboard-layout.md`를 기준으로 하며, Header를 GNB로 대체하거나 일반 LNB/GNB를 함께 넣지 않는다. Sidebar verification Story는 신규 운영 화면의 Reference가 아니다. -- Dashboard 및 DataTable은 기존 Pattern 구조를 우선한다. `Data Table` 대표 Story를 기준으로 하고 숨김 `With Filter Bar`는 verification 용도다. -- Story의 fixture나 장식은 Component API나 Production 규칙과 구분한다. 대표 Story와 공개 API·사용 문서를 함께 확인한다. - -상세 Source of Truth: [AI/Component 구현 원칙](docs/guidelines/shadcn-기반-구현-원칙.md), [Theme/Token 정책](docs/guidelines/테마-토큰-가이드.md), [Runtime Theme 계약](docs/guidelines/runtime-theme.md). 이 기본 정책은 과거 문서·예제의 상충 지침보다 우선한다. - -## Runtime Portability Policy - -- 설치 없는 테스트 배포의 현재 검증 기준은 `HMDesignTest-Portable-Test-r3.zip`이다(r2·원본 `HMDesignTest-Portable-Test.zip`은 과거 기록). 포터블 ZIP은 기본 포트 6007이며 충돌 시 자동으로 다른 포트를 쓰고, 개발 저장소 Storybook은 6024다. 이 ZIP은 `start-preview.bat`과 동봉된 Windows x64 Node 런타임으로 사전 빌드된 Storybook을 실행한다. 테스트 사용자는 ZIP 해제 → 배치 더블클릭 → 표시된 로컬 URL 열기만 수행하며 Node/npm 설치, npm 명령, Git 작업, CDN/운영 업로드가 필요 없다. 아래 의존성 복원·빌드 절차는 소스 개발자 전용이다. [테스트 배포 안내](docs/guidelines/portable-test-distribution.md). -- Runtime 구분: React 화면은 실제 `packages/ui` 공개 API와 workspace를 쓰는 `REACT_VITE`; 압축 ZIP에서 React 개발환경을 쓸 때는 먼저 `npm ci`로 의존성을 복원한다. `STATIC_HTML`은 파일을 `file://`로 직접 여는 HTML이며 Vite나 bare package import에 의존하지 않는다. `VANILLA_WEB`은 서버/정적 호스팅의 HTML·CSS·JS다. Storybook은 구현 기준을 확인하는 Reference 환경이며 Production Runtime을 뜻하지 않는다. -- `HMDesignTest.zip`에는 `.storybook/` 설정·Story 소스·패키지 소스와 dist가 있지만 `node_modules/`, `storybook-static/`은 없다. 실제 포함 파일과 `package.json` scripts를 먼저 확인한다. `HMDesignTest.zip`의 Storybook 개발 서버는 기본 포트 6007이며(현재 개발 저장소는 6024) 정적 빌드가 필요하면 의존성을 복원한 뒤 `npm run build-storybook`을 실행한다. 현재 개발 저장소와 ZIP의 설정 차이가 있으면 대상 `package.json`을 다시 확인한다. 생성되기 전의 `storybook-static/`을 필수 Reference로 요구하지 않는다. -- React는 구현 방식 중 하나일 뿐이다. 신규·변환 작업은 먼저 `REACT_VITE | STATIC_HTML | VANILLA_WEB | OTHER_RUNTIME` 중 Target Runtime을 정하고, HM Design/Theme/Component Contract를 유지한다. -- `themes/default.json`, `packages/ui/src/theme/contract.ts`, `packages/ui/src/theme/runtime.ts`, `packages/ui/src/theme.css`와 [Runtime 계약표](docs/guidelines/runtime-theme-contract.md)를 실제 Source of Truth로 사용한다. 색상·spacing·radius·Typography를 추측하거나 하드코딩하지 않는다. -- HTML/다른 Runtime 결과물은 기존 React를 대체하거나 별도 디자인으로 재작성하지 않고, 동일한 시각·상태·접근성·responsive 계약을 소비하는 adapter로 만든다. `STATIC_HTML`은 `file://` 직접 실행 검증을 포함한다. -- Dashboard 상태 색상은 `src/patterns/dashboard-status-contract.css`의 Runtime-neutral 변수(`--hm-dashboard-status-active|hold|done|issue`)를 우선 사용한다. 이 파일의 active/hold/issue 값은 기존 승인된 Story 색상을 보존하는 compatibility mapping이며 Public Theme token으로 승격하지 않는다. 실제 Production 공통 소비가 확인될 때만 Semantic Token 승격을 제안한다. -- 상세한 Target Runtime, asset 경로, 변환 규칙과 검증 기준은 [Runtime Portability Policy](docs/guidelines/runtime-portability.md)를 따른다. - -## Navigation Single Mode Policy - -**ONE PAGE = ONE NAVIGATION MODE = ONE HEADER/NAVIGATION EXPERIENCE** - -- 페이지 구현 전에 `navigationMode = GNB | LNB | DASHBOARD` 중 정확히 하나를 선택한다. 신규 AI Production 업무 화면에서 LNB ONLY의 공식 LNB는 허용하고 GNB ONLY·DASHBOARD ONLY의 Sidebar는 금지한다. -- 허용 모드: GNB ONLY, LNB ONLY, DASHBOARD ONLY. 신규 AI Production 업무 화면의 LNB ONLY는 기존 공식 LNB 메뉴와 좌측 LNB Navigation을 정상 생성·사용한다. GNB ONLY는 기존 공식 GNB와 Sidebar 없는 구성으로 사용하고, DASHBOARD ONLY는 전용 48px Header·기존 최소 폭·스크롤 계약을 유지하며 Sidebar/LNB를 생성하지 않는다. -- 기존 GNB ONLY 내부 Sidebar는 LNB 부품을 재사용하는 Composition이며 독립 LNB Mode가 아니다. 이 계약은 기존 Component·Pattern·Storybook 보존용이며 신규 AI Production 업무 화면에서는 사용을 금지한다. 기존 구성의 브랜드·사용자와 모바일 Header/Sheet는 GNB 하나가 담당한다([상세 범위](docs/guidelines/navigation-single-mode.md#gnb-only-내부-sidebar)). -- 금지: GNB + LNB, GNB + Dashboard, LNB + Dashboard, GNB + LNB + Dashboard. -- 이 정책은 AI 기반 코드 생성·제안, 신규 페이지 구현, 기존 화면 수정, 프로토타입, Storybook, 실제 Production 구현 전체에 동일하게 적용한다. -- AI는 별도 요청이 없는 한 Navigation 조합을 생성하거나 제안하지 않는다. 별도 요청이 있더라도 기존 단일 모드 정책과 충돌함을 알리고 정책 변경인지 기술 검증인지 구분한다. -- 신규 AI Production 업무 화면에서 GNB ONLY·DASHBOARD ONLY의 Sidebar는 사용자 요청 여부와 관계없이 생성·사용을 금지한다. GNB Sidebar Composition은 자동·수동 요청 모두 적용하지 않으며, DASHBOARD ONLY에는 LNB도 생성하지 않는다. LNB ONLY의 기존 공식 LNB 메뉴·좌측 LNB Navigation의 정상 생성·사용은 금지 대상에서 명시적으로 제외한다. 임의 Sidebar·Drawer·Collapsible Sidebar·대체 메뉴와 이를 위한 전용 Component·CSS·Width 규칙을 생성하지 않는다. 금지된 구성이 요청되면 금지 규칙을 안내하고 해당 기능을 구현하지 않으며, 다른 Navigation을 임의로 추가하거나 메뉴를 추측하지 않는다. Navigation 미지정 시 기존 모드 선택 규칙을 따른다. 이 정책은 AI의 신규 Production 업무 화면 생성에만 적용하고, 기존 HMDesign Component·Pattern·Storybook의 Sidebar/LNB 기능과 API·Variant는 삭제하거나 변경하지 않는다. 상세 기준은 [AI Sidebar 생성 정책](docs/guidelines/navigation-single-mode.md#ai-sidebar-생성-정책)을 따른다. -- 반응형에서도 선택한 체계를 유지한다. LNB Desktop(≥1024px) → LNB Mobile Header(≤1023px)는 같은 LNB Mode이며 GNB로 전환하지 않는다. -- Dashboard 운영 화면은 전용 Header/Menu와 Content만 사용하며 Sidebar는 금지한다. 외부 GNB나 일반 LNB를 slot에 넣지 않는다. 기존 `DashboardSidebar`와 `sidebar` slot은 호환성·검증용으로 유지하며 신규 운영 화면에 사용하지 않는다. -- Dashboard Header는 GNB가 아니다. Dashboard 화면은 Dashboard Pattern과 대표 Story를 따르고, Header의 역할을 GNB로 대체하지 않는다. -- GNB/LNB/Dashboard의 함께 쓰기는 위 단일 모드 허용 범위만 따른다. 과거 혼합 Navigation verification Story는 운영 화면 기준으로 사용하지 않는다. -- Dashboard 운영 최소 폭은 Sidebar 없는 960px이며 그 이하에서는 Dashboard 내부 가로 스크롤을 유지한다. Sidebar 포함 1232px은 기존 호환성·검증 구성의 값으로 현재 운영 기준이 아니다. -- Navigation 간 CSS 변수, height/width 계산, breakpoint, spacing override의 교차 의존성을 추가하지 않는다. -- 대표/PRIMARY Story는 단일 모드만 사용한다. 기존 혼합 Story를 기술 검증 목적으로 유지할 경우 `verification` 태그와 **VERIFICATION ONLY** 표시를 모두 적용하고 운영 예제로 추천하지 않는다. - -상세 기준: [Navigation Single Mode Policy](docs/guidelines/navigation-single-mode.md). 이 사용자 확정 정책은 이전 Navigation 조합 문서·예제보다 우선한다. +필요할 때만 [보존된 상세 지침](docs/guidelines/ai-implementation-details.md)을 읽는다. 신규 화면에는 위 필수 규칙이 우선한다. diff --git a/docs/guidelines/ai-generation-guard.md b/docs/guidelines/ai-generation-guard.md new file mode 100644 index 0000000..dc262bb --- /dev/null +++ b/docs/guidelines/ai-generation-guard.md @@ -0,0 +1,16 @@ +# AI Generation Guard — 경량 검증 + +`node scripts/ai-generation-guard.mjs --target <앱> --scope FULL_SCREEN|PARTIAL` + +- 기존 Navigation·Storybook 격리·CSS entry·공개 Import 검사를 유지한다. Navigation 재사용 시 확정한 `--mode`를 전달한다. +- PARTIAL은 `--files <수정 파일,...>`로 지정한다. 앱 bootstrap graph만 탐색하며 별도 전체 저장소 검색은 하지 않는다. 디자인 시스템 내부 구현은 소비 화면 검사에서 제외한다. +- 정상 텍스트 출력은 `PASS` 한 줄이다. 문제는 최대 5개 파일·행·원인만 표시한다. 기계 처리·전체 진단은 `--json`으로 확인한다. +- `REUSE-001` FAIL: 공개 이름에 대응하는 HM `data-slot`을 소비자의 기본 HTML에 부여함. 공식 여부가 불명확한 wrapper는 NOT VERIFIED다. slot은 컴포넌트 재사용 증거가 아니다. +- `REUSE-002` FAIL: 공개 컴포넌트를 import한 뒤 참조하지 않음. re-export·type import·별칭·namespace·props 전달은 단순 미사용으로 판정하지 않는다. +- `REUSE-003` NOT VERIFIED: HM slot/class를 겨냥한 CSS. 승인된 override와 복제 의도를 정적 코드만으로 구분할 수 없으므로 실제 사용을 확인한다. +- `REUSE-004` NOT VERIFIED: 기본 select/input/textarea/button 사용. 합법적인 기본 HTML과 기존 컴포넌트 대체 구현을 구분하여 검토한다. 일반 div·레이아웃 CSS는 복제로 단정하지 않는다. +- 최종 제출물이 있으면 같은 실행에 `--result `을 지정한다. 해당 artifact를 시작점으로 검사한다. React mount가 확인되지 않으면 `RENDER-001`, 확인되어도 실제 브라우저 결과는 `RENDER-002` NOT VERIFIED다. 경로 이름만으로 preview.html을 금지하지 않는다. 실제 React를 실행하는 HTML은 정상 entry다. + +PASS는 정적 계약 통과만 의미한다. 실제 React 화면을 빌드하고 브라우저에서 utility 적용·레이아웃·핵심 동작을 최소 1회 확인한다. 별도 HTML 모방 화면이나 이미지로 이를 대체하지 않는다. 화면에서 실행되지 않는 코드, 조건부 렌더, CSS 복제 의도, 동적 binding은 정적 검사만으로 완전히 판별할 수 없다. 미확인은 NOT VERIFIED로 기록한다. + +FAIL 수정 후 수정 파일만 PARTIAL 재검사한다. 성공한 검사는 반복하지 않는다. 최소 회귀 테스트: `node --test scripts/ai-generation-guard.test.mjs` (새 의존성 설치 없음). diff --git a/docs/guidelines/ai-implementation-details.md b/docs/guidelines/ai-implementation-details.md new file mode 100644 index 0000000..6fe3756 --- /dev/null +++ b/docs/guidelines/ai-implementation-details.md @@ -0,0 +1,84 @@ +# HMDesignSystem 작업 지침 + +## AI Generation Guard FAST CORE + +- AI 화면 생성 또는 부분 수정 완료 후 실제 재사용한 HMDesign 요소에 대해 `node scripts/ai-generation-guard.mjs --target <앱 경로> --scope FULL_SCREEN|PARTIAL`을 1회 실행한다. Navigation을 재사용하면 확정된 `--mode`를 전달하고, PARTIAL은 `--files <수정 파일>`로 범위를 지정한다. +- FAIL이 발생한 항목만 수정한다. +- 수정 후에는 수정한 범위만 PARTIAL로 다시 검사한다. +- 정상 결과에 대해 추가 검사를 반복하지 않는다. + +## AI 화면 구현 Quick Start + +화면 구현 요청을 받으면 아래 순서를 그대로 따른다. 파일이나 API의 존재를 확인할 수 없으면 추측하지 말고 미확인으로 기록한다. + +1. **프로젝트 구조와 Runtime 확인**: 제공된 파일 범위를 살펴보고 `REACT_VITE | STATIC_HTML | VANILLA_WEB | OTHER_RUNTIME` 중 대상을 정한다. Component·Pattern 파일이 실제 범위에 있는지 확인한다. +2. **Component 검색**: `packages/ui/src/components/ui/`와 `packages/ui/src/index.ts`에서 공개 Component·Props를 찾고, `docs/components/.md`를 확인한다. 기존 공개 Component를 우선 사용한다. +3. **Pattern 검색**: `packages/ui/src/patterns/`, `src/patterns/`, `packages/ui/src/index.ts`와 `docs/patterns/`에서 맞는 Pattern·공개 API를 찾아 구조와 상태 규칙을 따른다. +4. **Storybook 확인**: `src/components/`, `src/patterns/`, `src/theme/`의 대표 `All` Story와 관련 문서를 확인한다. `verification` 전용 Story는 일반 화면의 기준으로 쓰지 않는다. Storybook을 실행할 수 없으면 Story 소스와 문서만 확인하고 시각 동작은 추측하지 않는다. +5. **Theme 연결**: `themes/default.json`, `packages/ui/src/theme.css`, Runtime 계약과 Token 가이드를 확인해 기존 CSS Custom Properties 구조를 사용한다. +6. **화면 구성**: Component와 Pattern을 조합하고 아래 단일 Navigation 정책을 지킨다. 신규 AI Production 업무 화면에서 LNB ONLY는 기존 공식 LNB 메뉴와 좌측 LNB Navigation을 정상 생성·사용한다. GNB ONLY는 기존 공식 GNB를 사용하되 Sidebar·GNB Sidebar Composition을 자동·수동 요청 모두 적용하지 않으며, DASHBOARD ONLY는 Sidebar/LNB를 생성하지 않는다. Navigation 미지정 시 기존 모드 선택 규칙을 따르며 임의 Sidebar·Drawer·대체 메뉴를 추가하지 않는다. 기존 구성으로 해결되지 않을 때만 새 Component를 검토한다. +7. **결과 확인 및 보고**: 실제 적용한 Component·Pattern, Theme 연결, Navigation Mode, 실행/화면 확인 결과와 미검증 항목을 보고한다. + +**Storybook / 화면 생성 분리**: Storybook은 기존 Component·Pattern·Theme·Variant·State·Interaction을 확인하는 Reference 환경이다. 일반 업무 화면 생성은 Storybook Story·사이드바에 등록하지 않고, 기존 대표 Story를 생성 화면으로 교체하지 않으며, Storybook 실행을 필수 확인 경로로 요구하지 않는다. 별도의 React 화면과 독립 미리보기를 만들고 화면별 빌드·필요한 검증을 수행한다. H-2(AI 생성 화면의 자동 빌드·캡처·검증, Storybook 배포와 별도이며 구현 완료 여부는 현재 파일로 확인)나 유사 자동화가 사용 가능한 경우에도 `preview.html`·desktop/mobile 이미지 같은 독립 미리보기 산출에만 사용하며 Storybook 등록과 혼동하지 않는다. Storybook 수정은 Component/Pattern 추가·수정, 대표/Verification Story 작성 또는 Storybook Reference 개선을 사용자가 명시한 경우에만 허용한다. Storybook 전용 helper/CSS는 운영 화면에 사용하지 않는다. + +상세 규칙은 이 문서의 [구현 기본값](#ai-implementation-defaults), [Runtime 계약](#runtime-portability-policy), [단일 Navigation 정책](#navigation-single-mode-policy)과 [Component / Pattern Reference Index](./component-pattern-reference.md), [Component 구현 원칙](./shadcn-기반-구현-원칙.md), [Runtime Theme](./runtime-theme.md), [Navigation 정책](./navigation-single-mode.md)을 따른다. + +**검색 원칙(도구 중립)**: 변경과 관련된 소스 경로부터 좁혀 검색하고, 생성물(`node_modules`, `dist`, `storybook-static`)과 로컬 작업본은 기본 제외한다. 필요한 최소 범위만 검증하며 실제로 확인하지 않은 기능을 PASS로 보고하지 않는다. 존재하지 않는 Component·Pattern·공개 API를 임의로 만들지 않고 없으면 없다고 보고한다. + +**Story 구분**: 운영 기준은 대표 `All` Story이고 `verification`·Compatibility·Fixture Story는 운영 예제가 아니다. Storybook 문서 화면은 `StoryPage` 공통 Inner를 사용하며, 이는 Storybook 표현용으로 `@hm-design/ui` 공개 API가 아니다. + +**화면 생성 시 필수 확인**: Navigation을 지정받으면 아래 모드별 정책에 따라 기존 공식 Component를 적용하고, 미지정이면 기존 모드 선택 규칙을 따르며 그 근거를 기록한다([선택 기준](./navigation-single-mode.md#선택-근거)). LNB ONLY의 정상적인 공식 LNB 생성은 Sidebar 금지 대상에서 명시적으로 제외한다. GNB ONLY의 Sidebar·GNB Sidebar Composition 또는 DASHBOARD ONLY의 Sidebar/LNB가 요청되면 금지 규칙을 안내하고 해당 기능을 구현하지 않으며, 대체 Navigation을 임의로 추가하지 않는다. GNB는 Desktop 메뉴와 `GNBMobileMenu`를 함께 구성한다([GNB 모바일](../patterns/gnb.md#8-desktop--mobile)). Dashboard 운영 화면에는 Sidebar를 생성하지 않는다. `DashboardSidebar` 공개 Export와 선택적 `sidebar` slot은 기존 호환성·검증용이며 운영 허용을 뜻하지 않는다. Dashboard 상태는 Theme 다음에 공개 Status CSS를 로드하고 기존 Badge mapping을 소비한다([상태 예제](../patterns/dashboard-layout.md#dashboard-업무-상태-공통-계약)). FilterBar의 입력 조건과 조회 적용 조건·초기화 책임을 구분한다([조회 예제](../patterns/filter-bar.md#61-조회-버튼으로-적용하는-소비-예제)). `REACT_VITE` 독립 화면은 bootstrap에서 기존 앱 CSS entry(`@/index.css` → `@/hm-theme.css` → 필요 시 공개 상태 CSS)를 로드하며, `@hm-design/ui/tailwind.css`만 단독 import하지 않는다. 빌드 후 `.flex`, `.grid` 등 대표 utility의 생성·실제 적용과 캡처 레이아웃을 확인하고, 빌드 성공만으로 화면 검증을 PASS 처리하지 않는다. React 소비자는 Tailwind 검색 범위와 HM package source를 확인하고([Tailwind](./runtime-portability.md#21-tailwind-v4-소비자-검색-범위)), 폰트·Asset 경로는 실행 위치별로 확인한다([Asset](./runtime-portability.md#4-asset과-실행-위치)). + +## AI Implementation Defaults + +**사용자가 별도로 "기존 컴포넌트/디자인 시스템을 사용해줘", "테마를 적용해줘", "Semantic Token을 사용해줘", "Navigation 정책을 따라줘"라고 요청하지 않아도 AI는 이를 모든 신규·수정 UI의 기본 구현 조건으로 자동 적용한다.** + +- 구현 전에 `STORYBOOK_ONLY | PRODUCTION_UI | BOTH` 중 변경 범위를 정한다. +- 기존 Component와 유사 대표 Story를 먼저 검색한다. `packages/ui`, `src/components`, `src/patterns`, `src/center`, 기존 Storybook stories와 shared helpers를 확인하고 도메인에 맞는 구현을 재사용한다. +- 재사용 순서: 기존 HM Component → 기존 Variant / Prop → 기존 Composition → shadcn → Radix Primitive → 최소 Custom. 새 Component는 기존 Component·Composition으로 표현 불가능하고 Variant 확장이 부적절하며 shadcn/Radix 조합도 어려운 경우에만 만든다. 재사용 불가 사유와 최소 추가 범위를 설명한다. +- Storybook은 구현 Reference Catalog다. 대표/PRIMARY Story의 시각 언어와 공통 상태 mapping을 재사용하고 검증 전용 Story를 운영 예제로 삼지 않는다. 신규 업무 화면의 등록 대상이 아니며, 생성 결과는 별도 React 화면·독립 미리보기로 제공한다. +- Theme은 구현 시작부터 적용한다. 기존 Semantic Token → 해당 Component Token → 기존 CSS Variable을 먼저 확인하고, 없을 때만 필요한 최소 Semantic 확장을 검토한다. 기존 Token이 있는 색상·spacing·값을 하드코딩하거나 같은 Token을 여러 파일에 중복 정의하지 않는다. +- Theme 사용은 별도 승인 없이 기본 적용한다. Theme Source/값 수정은 명시적 필요성과 영향 범위를 확인한다. `themes/*.json` → Runtime Loader → CSS Custom Properties → Semantic → Component Mapping과 Primary / On Accent의 독립 fallback을 유지한다. +- 한 페이지에 `GNB | LNB | DASHBOARD` 중 하나만 사용한다. 신규 AI Production 업무 화면에서 LNB ONLY의 공식 LNB는 허용하고 GNB ONLY·DASHBOARD ONLY의 Sidebar는 금지한다. 반응형도 같은 Navigation Mode를 유지한다. +- 화면 구현 순서는 Quick Start를 따른다. 변경 작업 범위(STORYBOOK_ONLY | PRODUCTION_UI | BOTH)는 구현 전에 정한다. +- package exports, public props/types, package entry, Story ID, `meta.title`을 불필요하게 변경하지 않는다. 필요한 변경은 소비처·호환성 영향을 먼저 확인한다. Storybook 표현 문제와 Production 동작 문제는 해당 범위에서 해결한다. +- Badge/Button/FilterBar를 재사용하더라도 동일한 상태 ID·라벨·필터 매칭이 반복되면 화면별 helper로 남기지 말고 Runtime-neutral 상태 계약을 먼저 검토한다. 계약은 React 구현과 분리하고 `data-status`, 독립 flag attribute, `aria-pressed` 등 다른 Runtime이 재현할 수 있는 상태 vocabulary를 정의한다. +- 명시적 실험·Theme 미적용 prototype·외부 디자인 완전 재현·독립 POC만 별도 격리 범위에서 예외를 검토한다. Navigation 혼합은 아래 단일 모드 정책의 별도 기준을 따른다. +- Button은 `brand | solid | outline`의 사용 위치와 세트 규칙을 `docs/components/button.md` 기준으로 따른다: 실제 화면 Brand 최대 1개·팝업 Brand 금지, 일반 확인/작성은 Solid 세트, 삭제 확인·행/반복 작업은 Outline 세트이며 세트의 appearance와 size를 통일한다. Runtime 변환은 `docs/guidelines/runtime-portability.md`의 DOM/Theme/keyboard 계약을 함께 보존한다. +- Dashboard 운영 화면은 전용 Header(48px)·업무 상태 필터와 Content로 구성하며 Sidebar 사용을 금지한다. Dashboard 대표 `Patterns/Layout/Dashboard → Dashboard` Story와 `docs/patterns/dashboard-layout.md`를 기준으로 하며, Header를 GNB로 대체하거나 일반 LNB/GNB를 함께 넣지 않는다. Sidebar verification Story는 신규 운영 화면의 Reference가 아니다. +- Dashboard 및 DataTable은 기존 Pattern 구조를 우선한다. `Data Table` 대표 Story를 기준으로 하고 숨김 `With Filter Bar`는 verification 용도다. +- Story의 fixture나 장식은 Component API나 Production 규칙과 구분한다. 대표 Story와 공개 API·사용 문서를 함께 확인한다. + +상세 Source of Truth: [AI/Component 구현 원칙](./shadcn-기반-구현-원칙.md), [Theme/Token 정책](./테마-토큰-가이드.md), [Runtime Theme 계약](./runtime-theme.md). 이 기본 정책은 과거 문서·예제의 상충 지침보다 우선한다. + +## Runtime Portability Policy + +- 설치 없는 테스트 배포의 현재 검증 기준은 `HMDesignTest-Portable-Test-r3.zip`이다(r2·원본 `HMDesignTest-Portable-Test.zip`은 과거 기록). 포터블 ZIP은 기본 포트 6007이며 충돌 시 자동으로 다른 포트를 쓰고, 개발 저장소 Storybook은 6024다. 이 ZIP은 `start-preview.bat`과 동봉된 Windows x64 Node 런타임으로 사전 빌드된 Storybook을 실행한다. 테스트 사용자는 ZIP 해제 → 배치 더블클릭 → 표시된 로컬 URL 열기만 수행하며 Node/npm 설치, npm 명령, Git 작업, CDN/운영 업로드가 필요 없다. 아래 의존성 복원·빌드 절차는 소스 개발자 전용이다. [테스트 배포 안내](./portable-test-distribution.md). +- Runtime 구분: React 화면은 실제 `packages/ui` 공개 API와 workspace를 쓰는 `REACT_VITE`; 압축 ZIP에서 React 개발환경을 쓸 때는 먼저 `npm ci`로 의존성을 복원한다. `STATIC_HTML`은 파일을 `file://`로 직접 여는 HTML이며 Vite나 bare package import에 의존하지 않는다. `VANILLA_WEB`은 서버/정적 호스팅의 HTML·CSS·JS다. Storybook은 구현 기준을 확인하는 Reference 환경이며 Production Runtime을 뜻하지 않는다. +- `HMDesignTest.zip`에는 `.storybook/` 설정·Story 소스·패키지 소스와 dist가 있지만 `node_modules/`, `storybook-static/`은 없다. 실제 포함 파일과 `package.json` scripts를 먼저 확인한다. `HMDesignTest.zip`의 Storybook 개발 서버는 기본 포트 6007이며(현재 개발 저장소는 6024) 정적 빌드가 필요하면 의존성을 복원한 뒤 `npm run build-storybook`을 실행한다. 현재 개발 저장소와 ZIP의 설정 차이가 있으면 대상 `package.json`을 다시 확인한다. 생성되기 전의 `storybook-static/`을 필수 Reference로 요구하지 않는다. +- React는 구현 방식 중 하나일 뿐이다. 신규·변환 작업은 먼저 `REACT_VITE | STATIC_HTML | VANILLA_WEB | OTHER_RUNTIME` 중 Target Runtime을 정하고, HM Design/Theme/Component Contract를 유지한다. +- `themes/default.json`, `packages/ui/src/theme/contract.ts`, `packages/ui/src/theme/runtime.ts`, `packages/ui/src/theme.css`와 [Runtime 계약표](./runtime-theme-contract.md)를 실제 Source of Truth로 사용한다. 색상·spacing·radius·Typography를 추측하거나 하드코딩하지 않는다. +- HTML/다른 Runtime 결과물은 기존 React를 대체하거나 별도 디자인으로 재작성하지 않고, 동일한 시각·상태·접근성·responsive 계약을 소비하는 adapter로 만든다. `STATIC_HTML`은 `file://` 직접 실행 검증을 포함한다. +- Dashboard 상태 색상은 `src/patterns/dashboard-status-contract.css`의 Runtime-neutral 변수(`--hm-dashboard-status-active|hold|done|issue`)를 우선 사용한다. 이 파일의 active/hold/issue 값은 기존 승인된 Story 색상을 보존하는 compatibility mapping이며 Public Theme token으로 승격하지 않는다. 실제 Production 공통 소비가 확인될 때만 Semantic Token 승격을 제안한다. +- 상세한 Target Runtime, asset 경로, 변환 규칙과 검증 기준은 [Runtime Portability Policy](./runtime-portability.md)를 따른다. + +## Navigation Single Mode Policy + +**ONE PAGE = ONE NAVIGATION MODE = ONE HEADER/NAVIGATION EXPERIENCE** + +- 페이지 구현 전에 `navigationMode = GNB | LNB | DASHBOARD` 중 정확히 하나를 선택한다. 신규 AI Production 업무 화면에서 LNB ONLY의 공식 LNB는 허용하고 GNB ONLY·DASHBOARD ONLY의 Sidebar는 금지한다. +- 허용 모드: GNB ONLY, LNB ONLY, DASHBOARD ONLY. 신규 AI Production 업무 화면의 LNB ONLY는 기존 공식 LNB 메뉴와 좌측 LNB Navigation을 정상 생성·사용한다. GNB ONLY는 기존 공식 GNB와 Sidebar 없는 구성으로 사용하고, DASHBOARD ONLY는 전용 48px Header·기존 최소 폭·스크롤 계약을 유지하며 Sidebar/LNB를 생성하지 않는다. +- 기존 GNB ONLY 내부 Sidebar는 LNB 부품을 재사용하는 Composition이며 독립 LNB Mode가 아니다. 이 계약은 기존 Component·Pattern·Storybook 보존용이며 신규 AI Production 업무 화면에서는 사용을 금지한다. 기존 구성의 브랜드·사용자와 모바일 Header/Sheet는 GNB 하나가 담당한다([상세 범위](./navigation-single-mode.md#gnb-only-내부-sidebar)). +- 금지: GNB + LNB, GNB + Dashboard, LNB + Dashboard, GNB + LNB + Dashboard. +- 이 정책은 AI 기반 코드 생성·제안, 신규 페이지 구현, 기존 화면 수정, 프로토타입, Storybook, 실제 Production 구현 전체에 동일하게 적용한다. +- AI는 별도 요청이 없는 한 Navigation 조합을 생성하거나 제안하지 않는다. 별도 요청이 있더라도 기존 단일 모드 정책과 충돌함을 알리고 정책 변경인지 기술 검증인지 구분한다. +- 신규 AI Production 업무 화면에서 GNB ONLY·DASHBOARD ONLY의 Sidebar는 사용자 요청 여부와 관계없이 생성·사용을 금지한다. GNB Sidebar Composition은 자동·수동 요청 모두 적용하지 않으며, DASHBOARD ONLY에는 LNB도 생성하지 않는다. LNB ONLY의 기존 공식 LNB 메뉴·좌측 LNB Navigation의 정상 생성·사용은 금지 대상에서 명시적으로 제외한다. 임의 Sidebar·Drawer·Collapsible Sidebar·대체 메뉴와 이를 위한 전용 Component·CSS·Width 규칙을 생성하지 않는다. 금지된 구성이 요청되면 금지 규칙을 안내하고 해당 기능을 구현하지 않으며, 다른 Navigation을 임의로 추가하거나 메뉴를 추측하지 않는다. Navigation 미지정 시 기존 모드 선택 규칙을 따른다. 이 정책은 AI의 신규 Production 업무 화면 생성에만 적용하고, 기존 HMDesign Component·Pattern·Storybook의 Sidebar/LNB 기능과 API·Variant는 삭제하거나 변경하지 않는다. 상세 기준은 [AI Sidebar 생성 정책](./navigation-single-mode.md#ai-sidebar-생성-정책)을 따른다. +- 반응형에서도 선택한 체계를 유지한다. LNB Desktop(≥1024px) → LNB Mobile Header(≤1023px)는 같은 LNB Mode이며 GNB로 전환하지 않는다. +- Dashboard 운영 화면은 전용 Header/Menu와 Content만 사용하며 Sidebar는 금지한다. 외부 GNB나 일반 LNB를 slot에 넣지 않는다. 기존 `DashboardSidebar`와 `sidebar` slot은 호환성·검증용으로 유지하며 신규 운영 화면에 사용하지 않는다. +- Dashboard Header는 GNB가 아니다. Dashboard 화면은 Dashboard Pattern과 대표 Story를 따르고, Header의 역할을 GNB로 대체하지 않는다. +- GNB/LNB/Dashboard의 함께 쓰기는 위 단일 모드 허용 범위만 따른다. 과거 혼합 Navigation verification Story는 운영 화면 기준으로 사용하지 않는다. +- Dashboard 운영 최소 폭은 Sidebar 없는 960px이며 그 이하에서는 Dashboard 내부 가로 스크롤을 유지한다. Sidebar 포함 1232px은 기존 호환성·검증 구성의 값으로 현재 운영 기준이 아니다. +- Navigation 간 CSS 변수, height/width 계산, breakpoint, spacing override의 교차 의존성을 추가하지 않는다. +- 대표/PRIMARY Story는 단일 모드만 사용한다. 기존 혼합 Story를 기술 검증 목적으로 유지할 경우 `verification` 태그와 **VERIFICATION ONLY** 표시를 모두 적용하고 운영 예제로 추천하지 않는다. + +상세 기준: [Navigation Single Mode Policy](./navigation-single-mode.md). 이 사용자 확정 정책은 이전 Navigation 조합 문서·예제보다 우선한다. diff --git a/docs/guidelines/component-pattern-reference.md b/docs/guidelines/component-pattern-reference.md index 918ad7e..3f0f5f8 100644 --- a/docs/guidelines/component-pattern-reference.md +++ b/docs/guidelines/component-pattern-reference.md @@ -6,52 +6,52 @@ AI 화면 구현 Quick Start의 Component·Pattern 검색에 사용하는 파일 현재 `packages/ui/src/components/ui/`에는 구현 TSX 44개가 있다. 각 구현 파일은 `packages/ui/src/index.ts`의 공개 export를 통해 소비한다. 아래 Story는 실제 Story 파일의 대표 그룹이다. -| Component | 구현·공개 API (`@hm-design/ui`) | 사용 문서 | 대표 Story | +| Component (문서) | Import 경로 | 주요 용도 | 대표 Story | |---|---|---|---| -| Accordion | `Accordion`, `AccordionContent`, `AccordionItem`, `AccordionTrigger`
[구현](../../packages/ui/src/components/ui/accordion.tsx) | [문서](../components/accordion.md) | [Disclosure/Accordion](../../src/components/accordion.stories.tsx) | -| Alert | `Alert`, `AlertDescription`, `AlertTitle`
[구현](../../packages/ui/src/components/ui/alert.tsx) | [문서](../components/alert.md) | [Feedback/Alert](../../src/components/alert.stories.tsx) | -| Alert Dialog | `AlertDialog`, `AlertDialogAction`, `AlertDialogCancel`, `AlertDialogContent`, `AlertDialogDescription`, `AlertDialogFooter`, `AlertDialogHeader`, `AlertDialogOverlay`, `AlertDialogPortal`, `AlertDialogTitle`, `AlertDialogTrigger`
[구현](../../packages/ui/src/components/ui/alert-dialog.tsx) | [문서](../components/alert-dialog.md) | [Overlay/Alert Dialog](../../src/components/alert-dialog.stories.tsx) | -| Aspect Ratio | `AspectRatio`
[구현](../../packages/ui/src/components/ui/aspect-ratio.tsx) | [문서](../components/aspect-ratio.md) | [Data & Display/Aspect Ratio](../../src/components/aspect-ratio.stories.tsx) | -| Avatar | `Avatar`, `AvatarFallback`, `AvatarImage`
[구현](../../packages/ui/src/components/ui/avatar.tsx) | [문서](../components/avatar.md) | [Data & Display/Avatar](../../src/components/avatar.stories.tsx) | -| Badge | `Badge`, `badgeVariants`
[구현](../../packages/ui/src/components/ui/badge.tsx) | [문서](../components/badge.md) | [Data & Display/Badge](../../src/components/badge.stories.tsx) | -| Breadcrumb | `Breadcrumb`, `BreadcrumbEllipsis`, `BreadcrumbItem`, `BreadcrumbLink`, `BreadcrumbList`, `BreadcrumbPage`, `BreadcrumbSeparator`
[구현](../../packages/ui/src/components/ui/breadcrumb.tsx) | [문서](../components/breadcrumb.md) | [Navigation/Breadcrumb](../../src/components/breadcrumb.stories.tsx) | -| Button | `Button`, `buttonVariants`, `ButtonAppearance`
[구현](../../packages/ui/src/components/ui/button.tsx) | [문서](../components/button.md) | [Form/Button](../../src/components/button.stories.tsx) | -| Calendar | `Calendar`
[구현](../../packages/ui/src/components/ui/calendar.tsx) | [문서](../components/calendar.md) | [Search & Date/Calendar](../../src/components/calendar.stories.tsx) | -| Card | `Card`, `CardAction`, `CardContent`, `CardDescription`, `CardFooter`, `CardHeader`, `CardTitle`, `CardSurface`
[구현](../../packages/ui/src/components/ui/card.tsx) | [문서](../components/card.md) | [Data & Display/Card](../../src/components/card.stories.tsx) | -| Carousel | `Carousel`, `CarouselContent`, `CarouselItem`, `CarouselNext`, `CarouselPrevious`, `useCarousel`, `CarouselApi`
[구현](../../packages/ui/src/components/ui/carousel.tsx) | [문서](../components/carousel.md) | [Data & Display/Carousel](../../src/components/carousel.stories.tsx) | -| Checkbox | `Checkbox`
[구현](../../packages/ui/src/components/ui/checkbox.tsx) | [문서](../components/checkbox.md) | [Form/Checkbox](../../src/components/checkbox.stories.tsx) | -| Collapsible | `Collapsible`, `CollapsibleContent`, `CollapsibleTrigger`
[구현](../../packages/ui/src/components/ui/collapsible.tsx) | [문서](../components/collapsible.md) | [Disclosure/Collapsible](../../src/components/collapsible.stories.tsx) | -| Combobox | `Combobox`, `ComboboxOption`, `ComboboxSize`
[구현](../../packages/ui/src/components/ui/combobox.tsx) | [문서](../components/combobox.md) | [Search & Date/Combobox](../../src/components/combobox.stories.tsx) | -| Command | `Command`, `CommandEmpty`, `CommandGroup`, `CommandInput`, `CommandItem`, `CommandList`, `CommandSeparator`, `CommandShortcut`
[구현](../../packages/ui/src/components/ui/command.tsx) | [문서](../components/command.md) | [Search & Date/Command](../../src/components/command.stories.tsx) | -| Context Menu | `ContextMenu`, `ContextMenuCheckboxItem`, `ContextMenuContent`, `ContextMenuGroup`, `ContextMenuItem`, `ContextMenuLabel`, `ContextMenuPortal`, `ContextMenuRadioGroup`, `ContextMenuRadioItem`, `ContextMenuSeparator`, `ContextMenuShortcut`, `ContextMenuSub`, `ContextMenuSubContent`, `ContextMenuSubTrigger`, `ContextMenuTrigger`
[구현](../../packages/ui/src/components/ui/context-menu.tsx) | [문서](../components/context-menu.md) | [Overlay/Context Menu](../../src/components/context-menu.stories.tsx) | -| Date Picker | `DatePicker`, `DatePickerSize`
[구현](../../packages/ui/src/components/ui/date-picker.tsx) | [문서](../components/date-picker.md) | [Search & Date/Date Picker](../../src/components/date-picker.stories.tsx) | -| Dialog | `Dialog`, `DialogBody`, `DialogClose`, `DialogContent`, `DialogDescription`, `DialogFooter`, `DialogHeader`, `DialogOverlay`, `DialogPortal`, `DialogTitle`, `DialogTrigger`
[구현](../../packages/ui/src/components/ui/dialog.tsx) | [문서](../components/dialog.md) | [Overlay/Dialog](../../src/components/dialog.stories.tsx) | -| Drawer | `Drawer`, `DrawerBody`, `DrawerClose`, `DrawerContent`, `DrawerDescription`, `DrawerFooter`, `DrawerHeader`, `DrawerOverlay`, `DrawerPortal`, `DrawerTitle`, `DrawerTrigger`
[구현](../../packages/ui/src/components/ui/drawer.tsx) | [문서](../components/drawer.md) | [Overlay/Drawer](../../src/components/drawer.stories.tsx) | -| Dropdown Menu | `DropdownMenu`, `DropdownMenuCheckboxItem`, `DropdownMenuContent`, `DropdownMenuGroup`, `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuPortal`, `DropdownMenuRadioGroup`, `DropdownMenuRadioItem`, `DropdownMenuSeparator`, `DropdownMenuShortcut`, `DropdownMenuSub`, `DropdownMenuSubContent`, `DropdownMenuSubTrigger`, `DropdownMenuTrigger`
[구현](../../packages/ui/src/components/ui/dropdown-menu.tsx) | [문서](../components/dropdown-menu.md) | [Overlay/Dropdown Menu](../../src/components/dropdown-menu.stories.tsx) | +| [Accordion](../components/accordion.md) | `@hm-design/ui` | 접고 펼치는 항목 | [Disclosure/Accordion](../../src/components/accordion.stories.tsx) | +| [Alert](../components/alert.md) | `@hm-design/ui` | 안내·경고 | [Feedback/Alert](../../src/components/alert.stories.tsx) | +| [Alert Dialog](../components/alert-dialog.md) | `@hm-design/ui` | 작업 확인 팝업 | [Overlay/Alert Dialog](../../src/components/alert-dialog.stories.tsx) | +| [Aspect Ratio](../components/aspect-ratio.md) | `@hm-design/ui` | 콘텐츠 비율 | [Data & Display/Aspect Ratio](../../src/components/aspect-ratio.stories.tsx) | +| [Avatar](../components/avatar.md) | `@hm-design/ui` | 사용자 이미지 | [Data & Display/Avatar](../../src/components/avatar.stories.tsx) | +| [Badge](../components/badge.md) | `@hm-design/ui` | 상태·라벨 | [Data & Display/Badge](../../src/components/badge.stories.tsx) | +| [Breadcrumb](../components/breadcrumb.md) | `@hm-design/ui` | 현재 경로 | [Navigation/Breadcrumb](../../src/components/breadcrumb.stories.tsx) | +| [Button](../components/button.md) | `@hm-design/ui` | 작업 실행 | [Form/Button](../../src/components/button.stories.tsx) | +| [Calendar](../components/calendar.md) | `@hm-design/ui` | 날짜 선택 | [Search & Date/Calendar](../../src/components/calendar.stories.tsx) | +| [Card](../components/card.md) | `@hm-design/ui` | 콘텐츠 묶음 | [Data & Display/Card](../../src/components/card.stories.tsx) | +| [Carousel](../components/carousel.md) | `@hm-design/ui` | 슬라이드 콘텐츠 | [Data & Display/Carousel](../../src/components/carousel.stories.tsx) | +| [Checkbox](../components/checkbox.md) | `@hm-design/ui` | 복수 선택 | [Form/Checkbox](../../src/components/checkbox.stories.tsx) | +| [Collapsible](../components/collapsible.md) | `@hm-design/ui` | 영역 접기 | [Disclosure/Collapsible](../../src/components/collapsible.stories.tsx) | +| [Combobox](../components/combobox.md) | `@hm-design/ui` | 검색 가능한 선택 | [Search & Date/Combobox](../../src/components/combobox.stories.tsx) | +| [Command](../components/command.md) | `@hm-design/ui` | 명령 검색 | [Search & Date/Command](../../src/components/command.stories.tsx) | +| [Context Menu](../components/context-menu.md) | `@hm-design/ui` | 우클릭 메뉴 | [Overlay/Context Menu](../../src/components/context-menu.stories.tsx) | +| [Date Picker](../components/date-picker.md) | `@hm-design/ui` | 날짜 입력 | [Search & Date/Date Picker](../../src/components/date-picker.stories.tsx) | +| [Dialog](../components/dialog.md) | `@hm-design/ui` | 모달 | [Overlay/Dialog](../../src/components/dialog.stories.tsx) | +| [Drawer](../components/drawer.md) | `@hm-design/ui` | 슬라이드 패널 | [Overlay/Drawer](../../src/components/drawer.stories.tsx) | +| [Dropdown Menu](../components/dropdown-menu.md) | `@hm-design/ui` | 작업 메뉴 | [Overlay/Dropdown Menu](../../src/components/dropdown-menu.stories.tsx) | | Hover Card | `HoverCard`, `HoverCardContent`, `HoverCardTrigger`
[구현](../../packages/ui/src/components/ui/hover-card.tsx) | [문서](../components/hover-card.md) | [Overlay/Hover Card](../../src/components/hover-card.stories.tsx) | -| Input | `Input`, `InputSize`
[구현](../../packages/ui/src/components/ui/input.tsx) | [문서](../components/input.md) | [Form/Input](../../src/components/input.stories.tsx) | -| Label | `Label`
[구현](../../packages/ui/src/components/ui/label.tsx) | [문서](../components/label.md) | [Form/Label](../../src/components/label.stories.tsx) | -| Menubar | `Menubar`, `MenubarCheckboxItem`, `MenubarContent`, `MenubarGroup`, `MenubarItem`, `MenubarLabel`, `MenubarMenu`, `MenubarPortal`, `MenubarRadioGroup`, `MenubarRadioItem`, `MenubarSeparator`, `MenubarShortcut`, `MenubarSub`, `MenubarSubContent`, `MenubarSubTrigger`, `MenubarTrigger`
[구현](../../packages/ui/src/components/ui/menubar.tsx) | [문서](../components/menubar.md) | [Navigation/Menubar](../../src/components/menubar.stories.tsx) | -| Navigation Menu | `NavigationMenu`, `NavigationMenuContent`, `NavigationMenuIndicator`, `NavigationMenuItem`, `NavigationMenuLink`, `NavigationMenuList`, `NavigationMenuTrigger`, `NavigationMenuViewport`
[구현](../../packages/ui/src/components/ui/navigation-menu.tsx) | [문서](../components/navigation-menu.md) | [Navigation/Navigation Menu](../../src/components/navigation-menu.stories.tsx) | -| Pagination | `Pagination`, `PaginationContent`, `PaginationEllipsis`, `PaginationItem`, `PaginationLink`, `PaginationNext`, `PaginationPrevious`
[구현](../../packages/ui/src/components/ui/pagination.tsx) | [문서](../components/pagination.md) | [Navigation/Pagination](../../src/components/pagination.stories.tsx) | -| Popover | `Popover`, `PopoverAnchor`, `PopoverClose`, `PopoverContent`, `PopoverTrigger`
[구현](../../packages/ui/src/components/ui/popover.tsx) | [문서](../components/popover.md) | [Overlay/Popover](../../src/components/popover.stories.tsx) | -| Progress | `Progress`
[구현](../../packages/ui/src/components/ui/progress.tsx) | [문서](../components/progress.md) | [Data & Display/Progress](../../src/components/progress.stories.tsx) | -| Radio Group | `RadioGroup`, `RadioGroupItem`
[구현](../../packages/ui/src/components/ui/radio-group.tsx) | [문서](../components/radio-group.md) | [Form/Radio Group](../../src/components/radio-group.stories.tsx) | -| Resizable | `ResizableHandle`, `ResizablePanel`, `ResizablePanelGroup`
[구현](../../packages/ui/src/components/ui/resizable.tsx) | [문서](../components/resizable.md) | [Disclosure/Resizable](../../src/components/resizable.stories.tsx) | -| Scroll Area | `ScrollArea`, `ScrollBar`
[구현](../../packages/ui/src/components/ui/scroll-area.tsx) | [문서](../components/scroll-area.md) | [Data & Display/Scroll Area](../../src/components/scroll-area.stories.tsx) | -| Select | `Select`, `SelectContent`, `SelectGroup`, `SelectItem`, `SelectLabel`, `SelectScrollDownButton`, `SelectScrollUpButton`, `SelectSeparator`, `SelectTrigger`, `SelectValue`, `SelectSize`
[구현](../../packages/ui/src/components/ui/select.tsx) | [문서](../components/select.md) | [Form/Select](../../src/components/select.stories.tsx) | -| Separator | `Separator`
[구현](../../packages/ui/src/components/ui/separator.tsx) | [문서](../components/separator.md) | [Data & Display/Separator](../../src/components/separator.stories.tsx) | -| Sheet | `Sheet`, `SheetBody`, `SheetClose`, `SheetContent`, `SheetDescription`, `SheetFooter`, `SheetHeader`, `SheetOverlay`, `SheetPortal`, `SheetTitle`, `SheetTrigger`
[구현](../../packages/ui/src/components/ui/sheet.tsx) | [문서](../components/sheet.md) | [Overlay/Sheet](../../src/components/sheet.stories.tsx) | -| Skeleton | `Skeleton`
[구현](../../packages/ui/src/components/ui/skeleton.tsx) | [문서](../components/skeleton.md) | [Data & Display/Skeleton](../../src/components/skeleton.stories.tsx) | -| Slider | `Slider`
[구현](../../packages/ui/src/components/ui/slider.tsx) | [문서](../components/slider.md) | [Form/Slider](../../src/components/slider.stories.tsx) | -| Sonner | `Toaster`, `toast`, `ExternalToast`, `ToasterProps`
[구현](../../packages/ui/src/components/ui/sonner.tsx) | [문서](../components/sonner.md) | [Feedback/Sonner](../../src/components/sonner.stories.tsx) | -| Switch | `Switch`
[구현](../../packages/ui/src/components/ui/switch.tsx) | [문서](../components/switch.md) | [Form/Switch](../../src/components/switch.stories.tsx) | -| Table | `Table`, `TableBody`, `TableCaption`, `TableCell`, `TableFooter`, `TableHead`, `TableHeader`, `TableRow`
[구현](../../packages/ui/src/components/ui/table.tsx) | [문서](../components/table.md) | [Data & Display/Table](../../src/components/table.stories.tsx) | -| Tabs | `Tabs`, `TabsContent`, `TabsList`, `TabsTrigger`, `TabsPanelSurface`
[구현](../../packages/ui/src/components/ui/tabs.tsx)
기본 `` = Primary (`fill=true`, `panelSurface="content"`). Secondary: `fill panelSurface="surface"`. Legacy: `fill={false}` (panelSurface 시각 무시). 패널은 Primary `bg-background`, Secondary / Legacy `bg-card`로 조합한다 | [문서·기본값 변경 영향](../components/tabs.md) | [Navigation/Tabs](../../src/components/tabs.stories.tsx) | -| Textarea | `Textarea`, `TextareaResize`
[구현](../../packages/ui/src/components/ui/textarea.tsx) | [문서](../components/textarea.md) | [Form/Textarea](../../src/components/textarea.stories.tsx) | -| Toggle | `Toggle`, `toggleVariants`
[구현](../../packages/ui/src/components/ui/toggle.tsx) | [문서](../components/toggle.md) | [Form/Toggle](../../src/components/toggle.stories.tsx) | -| Toggle Group | `ToggleGroup`, `ToggleGroupItem`
[구현](../../packages/ui/src/components/ui/toggle-group.tsx) | [문서](../components/toggle-group.md) | [Form/Toggle Group](../../src/components/toggle-group.stories.tsx) | -| Tooltip | `Tooltip`, `TooltipContent`, `TooltipProvider`, `TooltipTrigger`
[구현](../../packages/ui/src/components/ui/tooltip.tsx) | [문서](../components/tooltip.md) | [Overlay/Tooltip](../../src/components/tooltip.stories.tsx) | +| [Input](../components/input.md) | `@hm-design/ui` | 단일 입력 | [Form/Input](../../src/components/input.stories.tsx) | +| [Label](../components/label.md) | `@hm-design/ui` | 입력 라벨 | [Form/Label](../../src/components/label.stories.tsx) | +| [Menubar](../components/menubar.md) | `@hm-design/ui` | 메뉴 묶음 | [Navigation/Menubar](../../src/components/menubar.stories.tsx) | +| [Navigation Menu](../components/navigation-menu.md) | `@hm-design/ui` | 페이지 이동 | [Navigation/Navigation Menu](../../src/components/navigation-menu.stories.tsx) | +| [Pagination](../components/pagination.md) | `@hm-design/ui` | 페이지 선택 | [Navigation/Pagination](../../src/components/pagination.stories.tsx) | +| [Popover](../components/popover.md) | `@hm-design/ui` | 보조 팝업 | [Overlay/Popover](../../src/components/popover.stories.tsx) | +| [Progress](../components/progress.md) | `@hm-design/ui` | 진행률 | [Data & Display/Progress](../../src/components/progress.stories.tsx) | +| [Radio Group](../components/radio-group.md) | `@hm-design/ui` | 단일 선택 | [Form/Radio Group](../../src/components/radio-group.stories.tsx) | +| [Resizable](../components/resizable.md) | `@hm-design/ui` | 영역 크기 조절 | [Disclosure/Resizable](../../src/components/resizable.stories.tsx) | +| [Scroll Area](../components/scroll-area.md) | `@hm-design/ui` | 스크롤 영역 | [Data & Display/Scroll Area](../../src/components/scroll-area.stories.tsx) | +| [Select](../components/select.md) | `@hm-design/ui` | 목록 선택 | [Form/Select](../../src/components/select.stories.tsx) | +| [Separator](../components/separator.md) | `@hm-design/ui` | 영역 구분 | [Data & Display/Separator](../../src/components/separator.stories.tsx) | +| [Sheet](../components/sheet.md) | `@hm-design/ui` | 측면 패널 | [Overlay/Sheet](../../src/components/sheet.stories.tsx) | +| [Skeleton](../components/skeleton.md) | `@hm-design/ui` | 로딩 자리 표시 | [Data & Display/Skeleton](../../src/components/skeleton.stories.tsx) | +| [Slider](../components/slider.md) | `@hm-design/ui` | 범위 값 선택 | [Form/Slider](../../src/components/slider.stories.tsx) | +| [Sonner](../components/sonner.md) | `@hm-design/ui` | 토스트 | [Feedback/Sonner](../../src/components/sonner.stories.tsx) | +| [Switch](../components/switch.md) | `@hm-design/ui` | 켜기·끄기 | [Form/Switch](../../src/components/switch.stories.tsx) | +| [Table](../components/table.md) | `@hm-design/ui` | 표 | [Data & Display/Table](../../src/components/table.stories.tsx) | +| [Tabs](../components/tabs.md) | `@hm-design/ui` | 탭 전환 | [Navigation/Tabs](../../src/components/tabs.stories.tsx) | +| [Textarea](../components/textarea.md) | `@hm-design/ui` | 여러 줄 입력 | [Form/Textarea](../../src/components/textarea.stories.tsx) | +| [Toggle](../components/toggle.md) | `@hm-design/ui` | 선택 상태 버튼 | [Form/Toggle](../../src/components/toggle.stories.tsx) | +| [Toggle Group](../components/toggle-group.md) | `@hm-design/ui` | 선택 버튼 묶음 | [Form/Toggle Group](../../src/components/toggle-group.stories.tsx) | +| [Tooltip](../components/tooltip.md) | `@hm-design/ui` | 보조 설명 | [Overlay/Tooltip](../../src/components/tooltip.stories.tsx) | `packages/ui/src/index.ts`에는 위 44개 Component 모듈의 named export 222개(값과 type 포함)가 연결되어 있다. 사용 시 API·Props·Variant·주요 상태·Theme 연결은 해당 Component 문서의 API/상태/Token/Storybook 절을 따른다. 문서 45개 중 `field.md`는 별도 `field.tsx` 공개 Component를 나타내지 않는 Field 소비 가이드다. @@ -59,16 +59,16 @@ AI 화면 구현 Quick Start의 Component·Pattern 검색에 사용하는 파일 현재 개발 저장소와 포터블 r3의 `packages/ui/src/patterns/`에는 8개 디렉터리와 62개 named export(값과 type 포함)가 있다. 역사적 오전 배포 기준 `HMDesignTest.zip`에는 7개 Pattern 모듈과 50개 named export가 있었다. Pattern 소스 디렉터리, Story 파일, Story 그룹 수는 별도 집계다. -| Pattern / 분류 | 공개 API 위치 | 사용 문서 | 대표 Reference / 계약 | +| Pattern (문서) | Import 경로 | 주요 용도 | 대표 Story | |---|---|---|---| -| Dashboard Layout — Layout Pattern | 운영: `DashboardLayout`, `DashboardHeader`, `DashboardMain`, `DashboardContent`, `DashboardLayoutProps`.
기존 호환성·검증용: `DashboardSidebar`, 선택적 `sidebar` slot. 공개 Export 존재는 운영 허용을 뜻하지 않음 | [dashboard-layout.md](../patterns/dashboard-layout.md) | [Patterns/Layout/Dashboard → Dashboard](../../src/patterns/dashboard-layout.stories.tsx) — Sidebar 없는 48px Header·업무 상태 필터·960px 최소 폭 | -| Dashboard Status — Runtime 상태 계약 | `countDashboardFilter`, `dashboardFilterDefinitions`, `dashboardFilterIds`, `dashboardStatusAttributes`, `dashboardStatusDefinitions`, `matchesDashboardFilter`, `DashboardFilterDefinition`, `DashboardFilterId`, `DashboardStatusDefinition`, `DashboardStatusId`, `DashboardStatusRecord`, `DashboardStatusTone` | [Dashboard 문서의 상태 계약 절](../patterns/dashboard-layout.md#3-storybook) | **최신 포터블 테스트 ZIP에 export·소스·CSS·Static HTML·빌드 결과 포함. 오전 ZIP에는 없음.** [현재 contract.ts](../../packages/ui/src/patterns/dashboard-status/contract.ts), [Runtime-neutral CSS](../../src/patterns/dashboard-status-contract.css), [React 대표 Story](../../src/patterns/dashboard-layout.stories.tsx), [Static HTML 예제](../patterns/dashboard-status-static.html) | -| Data Table — 여러 Component 조합 Pattern | `DataTable`, `DataTableColumnHeader`, `DataTablePagination`, `DataTableViewOptions`, `dataTableSelectColumn`, `useDataTable`, `DataTableColumnDef`, `DataTableInstance`, `DataTableOptions`, `DataTableProps` | [data-table.md](../patterns/data-table.md) | [Patterns/Data/Data Table → Data Table](../../src/patterns/data-table.stories.tsx) | -| Filter Bar — 여러 Component 조합 Pattern | `FilterBar`, `FilterBarActions`, `FilterBarSearch` 및 `FilterBarSearchProps` | [filter-bar.md](../patterns/filter-bar.md) | [Patterns/Data/Filter Bar → Filter Bar](../../src/patterns/filter-bar.stories.tsx) | -| GNB — Navigation Pattern | `GNB`, `GNBBrand`, `GNBMobileGroup`, `GNBMobileLink`, `GNBMobileMenu`, `GNBNav`, `GNBUser`, `GNBUtilities`, `GNBMobileMenuProps`, `GNBUserProps` | [gnb.md](../patterns/gnb.md) | [Patterns/Layout/GNB](../../src/patterns/gnb.stories.tsx) | -| LNB — Navigation Pattern | `LNB`, `LNBBrand`, `LNBGroup`, `LNBItem`, `LNBNav`, `LNBProfile`, `LNBMobileHeader`, `LNBMobileBrand`, `LNBMobileMenu`, `LNBMobileLink`, `LNBMobileGroup`, `LNBMobileMenuProps`, `LNBGroupProps`, `LNBProfileProps` | [lnb.md](../patterns/lnb.md) | [Patterns/Layout/LNB](../../src/patterns/lnb.stories.tsx) | -| Loading Screen — 화면 UI Pattern | `LoadingScreen` | [loading-screen.md](../patterns/loading-screen.md) | [Patterns/Layout/Loading Screen](../../src/patterns/loading-screen.stories.tsx) | -| Page Header — 화면 구성 Pattern | `PageHeader`, `PageHeaderActions`, `PageHeaderContent`, `PageHeaderDescription`, `PageHeaderTitle` | [page-header.md](../patterns/page-header.md) | [Patterns/Layout/Page Header → Page Header](../../src/patterns/page-header.stories.tsx) (`patterns-layout-page-header--all`). Breadcrumb + Title + Description + 단일 Brand Action. [Dashboard 조합](../../src/patterns/dashboard-layout.stories.tsx)은 Dashboard Layout 계약을 따름 | +| [Dashboard Layout](../patterns/dashboard-layout.md) | `@hm-design/ui` | 전용 Header·업무 상태·Content | [Patterns/Layout/Dashboard → Dashboard](../../src/patterns/dashboard-layout.stories.tsx) | +| [Dashboard Status](../patterns/dashboard-layout.md#3-storybook) | `@hm-design/ui` | 업무 상태·필터 계약 | [React 대표 Story](../../src/patterns/dashboard-layout.stories.tsx) | +| [Data Table](../patterns/data-table.md) | `@hm-design/ui` | 데이터 표·정렬·페이지 | [Patterns/Data/Data Table → Data Table](../../src/patterns/data-table.stories.tsx) | +| [Filter Bar](../patterns/filter-bar.md) | `@hm-design/ui` | 조회 조건·조회 액션 | [Patterns/Data/Filter Bar → Filter Bar](../../src/patterns/filter-bar.stories.tsx) | +| [GNB](../patterns/gnb.md) | `@hm-design/ui` | 상단 Navigation | [Patterns/Layout/GNB](../../src/patterns/gnb.stories.tsx) | +| [LNB](../patterns/lnb.md) | `@hm-design/ui` | 좌측 Navigation | [Patterns/Layout/LNB](../../src/patterns/lnb.stories.tsx) | +| [Loading Screen](../patterns/loading-screen.md) | `@hm-design/ui` | 전체 화면 로딩 | [Patterns/Layout/Loading Screen](../../src/patterns/loading-screen.stories.tsx) | +| [Page Header](../patterns/page-header.md) | `@hm-design/ui` | 제목·경로·주요 작업 | [Patterns/Layout/Page Header → Page Header](../../src/patterns/page-header.stories.tsx) | Data Table은 기존 Table·Pagination·Checkbox·Select·Dropdown Menu 등을 조합한다. 연동 예제가 필요하면 Data Table Story 안의 `With Filter Bar`를 참고하되 숨김 `verification` Story이므로 일반 화면의 단독 기본 Reference로 삼지 않는다. Filter Bar는 단독으로도 사용 가능하며 검색·Select·Combobox·Date Picker 등 조회 조건의 layout이다. Dashboard 빠른 상태 필터는 Filter Bar API가 아니라 Dashboard Status 계약을 따른다. @@ -79,7 +79,7 @@ Data Table은 기존 Table·Pagination·Checkbox·Select·Dropdown Menu 등을 ### 화면 생성 시 놓치기 쉬운 소비 계약 - Navigation 미지정: [선택 근거](navigation-single-mode.md#선택-근거)를 기록한다. GNB/LNB의 목적과 대표 화면 구조로 판단하며 일반 목록의 기본 모드를 임의 확정하지 않는다. -- GNB ONLY: [Desktop·Mobile 구성](../patterns/gnb.md#8-desktop--mobile)을 함께 구현한다. 대표 `patterns-layout-gnb--all`의 Overview·Usage · GNB Sidebar를 확인하고 [Sidebar 예제](../patterns/gnb.md#15-gnb-only-sidebar-composition-2026-10-08)도 Sheet 하나만 사용한다. +- GNB ONLY: [Desktop·Mobile 구성](../patterns/gnb.md#8-desktop--mobile)을 함께 구현한다. 대표 `patterns-layout-gnb--all`의 Overview·Usage를 확인한다. 신규 AI Production 화면에는 GNB Sidebar를 적용하지 않는다. - LNB ONLY: 기존 독립 LNB 구조와 [LNB Mobile Header/Menu](../patterns/lnb.md#3b-lnb-mobile-header-2026-10-07)를 유지한다. - DASHBOARD ONLY: Sidebar를 생성하지 않는다. 대표 `patterns-layout-dashboard-layout--all`의 전용 Header(48px)·업무 상태 필터 + Content, 최소 폭 960px·내부 가로 스크롤을 따른다. `DashboardSidebar`와 `sidebar` slot은 호환성·검증용이며 과거 Sidebar verification Story는 신규 운영 화면 구현 기준이 아니다. - Dashboard Status: 공개 `@hm-design/ui/dashboard-status.css`는 변수만 제공한다. [Theme → Status CSS → 기존 Outline Badge mapping·React 소비 예제](../patterns/dashboard-layout.md#dashboard-업무-상태-공통-계약)를 사용한다. `issue`는 독립 flag다. [Static HTML Reference](../patterns/dashboard-status-static.html)도 같은 계약을 소비한다. diff --git a/scripts/ai-generation-guard.mjs b/scripts/ai-generation-guard.mjs index cb2d141..76e2304 100644 --- a/scripts/ai-generation-guard.mjs +++ b/scripts/ai-generation-guard.mjs @@ -17,13 +17,13 @@ const within = (root, p) => { const r = path.relative(root, p); return r === '' const literal = n => n?.type === 'StringLiteral' ? n.value : n?.type === 'TemplateLiteral' && n.expressions.length === 0 ? n.quasis[0].value.cooked : undefined; const nameOf = n => n?.name ?? n?.value; const tagName = n => n?.type === 'JSXMemberExpression' || n?.type === 'MemberExpression' ? `${tagName(n.object)}.${nameOf(n.property)}` : nameOf(n); -function walk(node, fn) { +function walk(node, fn, parent = null, field = null) { if (!node || typeof node !== 'object') return; - if (node.type) fn(node); + if (node.type) fn(node, parent, field); for (const [key, value] of Object.entries(node)) { if (['loc', 'start', 'end', 'comments', 'tokens', 'extra'].includes(key)) continue; - if (Array.isArray(value)) value.forEach(x => walk(x, fn)); - else if (value && typeof value === 'object') walk(value, fn); + if (Array.isArray(value)) value.forEach(x => walk(x, fn, node, key)); + else if (value && typeof value === 'object') walk(value, fn, node, key); } } @@ -32,7 +32,7 @@ function argumentsFrom(argv) { for (let i = 0; i < argv.length; i++) { const key = argv[i]; if (key === '--json' || key === '--help') { options[key.slice(2)] = true; continue; } - if (!['--target', '--scope', '--mode', '--entry', '--files'].includes(key) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`Invalid option: ${key}`); + if (!['--target', '--scope', '--mode', '--entry', '--files', '--result'].includes(key) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`Invalid option: ${key}`); const value = argv[++i]; if (key === '--entry' || key === '--files') options[key.slice(2)].push(...value.split(',').filter(Boolean)); else options[key.slice(2)] = value; @@ -57,6 +57,7 @@ async function inspect(options) { const selected = options.files.map(p => path.resolve(target, p)); if (options.scope === 'PARTIAL' && isFile(targetPath) && !selected.length) selected.push(targetPath); const selectedFile = p => !selected.length || selectedDependencies?.has(p) || selected.some(s => s === p || (!isFile(s) && within(s, p))); + const changedFile = p => !selected.length || selected.some(s => s === p || (!isFile(s) && within(s, p))); const add = (severity, code, file, node, message, contextual = false) => { const line = node?.loc?.start?.line ?? node?.source?.start?.line ?? 1; const issue = { severity, code, file: file ? normalize(path.relative(target, file)) || path.basename(file) : '.', line, message }; @@ -100,6 +101,10 @@ async function inspect(options) { spec = spec.split(/[?#]/)[0]; for (const [pkg, contract] of contracts) if (spec === pkg || spec.startsWith(`${pkg}/`)) return { pkg, key: spec === pkg ? '.' : `.${spec.slice(pkg.length)}`, contract }; }; + // Public component names supply lightweight slot vocabulary, without reading implementations. + const componentNames = new Set([...contracts.values()].flatMap(c => [...c.entries.values()].flatMap(names => names ? [...names].filter(n => /^[A-Z]/.test(n)) : []))); + const slots = new Set([...componentNames].map(n => n.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase())); + const knownSlot = value => typeof value === 'string' && (slots.has(value) || /^(?:select|gnb|lnb|dashboard)-/.test(value)); const aliases = []; // JSONC paths are read as data. Config files are never executed. for (const file of ['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json']) { @@ -171,8 +176,8 @@ async function inspect(options) { } else if (edge.resolved && privatePath(edge.resolved)) add('FAIL', 'IMP-001', record.file, edge.node, 'Use the public HM package entry instead of an internal source path'); }; function visit(file) { - if (graph.has(file)) return; - const record = { file, edges: [], bindings: new Map(), exports: new Map(), stars: [], renders: [], members: [], classes: [], status: false, css: false, features: new Set() }; + if (graph.has(file) || privatePath(file)) return; + const record = { file, edges: [], bindings: new Map(), exports: new Map(), stars: [], renders: [], members: [], classes: [], references: new Set(), mounts: false, status: false, css: false, features: new Set() }; graph.set(file, record); let text; try { text = fs.readFileSync(file, 'utf8'); } catch { add('NOT VERIFIED', 'ENTRY-002', file, null, 'Cannot read production dependency', true); return; } @@ -199,6 +204,8 @@ async function inspect(options) { css.walkAtRules('tailwind', n => { if (n.params === 'utilities') record.features.add('utilities'); }); css.walkRules(n => { if (/\.sb-(?:page(?:-inner|-header)?|section|board)(?![\w-])/.test(n.selector)) add('FAIL', 'SB-003', file, n, 'Production uses Storybook-only CSS'); + if ([...n.selector.matchAll(/data-slot\s*=\s*["']?([\w-]+)/g)].some(m => knownSlot(m[1]))) add('NOT VERIFIED', 'REUSE-003', file, n, 'HM slot styling: confirm approved customization, not a CSS replica'); + if ([...n.selector.matchAll(/\.hm-([\w-]+)/g)].some(m => knownSlot(m[1]))) add('NOT VERIFIED', 'REUSE-003', file, n, 'HM-named custom CSS: confirm it is not a component replica'); }); css.walkDecls(n => { if (n.value.includes('--hm-dashboard-status-')) record.status = true; }); } catch { add('NOT VERIFIED', 'CSS-005', file, null, 'Cannot parse production CSS', true); } @@ -221,7 +228,10 @@ async function inspect(options) { stmt.declaration?.declarations?.forEach(d => { if (d.id.type === 'Identifier') record.exports.set(d.id.name, { local: d.id.name }); }); } else if (stmt.type === 'ExportDefaultDeclaration') record.exports.set('default', { local: stmt.declaration.id?.name ?? (stmt.declaration.type === 'Identifier' ? stmt.declaration.name : 'default') }); } - walk(ast, n => { + walk(ast, (n, parent, field) => { + if (n.type === 'Identifier' && !parent?.type.startsWith('Import') && !parent?.type.startsWith('TS') && !((parent?.type === 'MemberExpression' || parent?.type === 'ObjectProperty' || parent?.type === 'ObjectMethod') && field === (parent.type === 'MemberExpression' ? 'property' : 'key') && !parent.computed) && !(parent?.type === 'VariableDeclarator' && field === 'id')) record.references.add(n.name); + if (n.type === 'JSXIdentifier' && /^[A-Z]/.test(n.name)) record.references.add(n.name); + if (n.type === 'CallExpression' && /^(?:(?:ReactDOM|ReactDOMClient)\.)?(?:createRoot|hydrateRoot)$|^ReactDOM\.(?:render|hydrate)$/.test(tagName(n.callee))) record.mounts = true; if (n.type === 'ImportExpression' || n.type === 'CallExpression' && n.callee.type === 'Identifier' && n.callee.name === 'require') { const value = literal(n.source ?? n.arguments[0]); if (value) { edge(value, n); if (packageRef(value)) add('NOT VERIFIED', 'IMP-003', file, n, 'HM dynamic import / require bindings need runtime confirmation'); } @@ -245,12 +255,14 @@ async function inspect(options) { for (const e of record.edges) if (e.runtime && e.resolved && !privatePath(e.resolved) && !normalize(e.resolved).includes('/node_modules/')) visit(e.resolved); } const entries = []; - const htmlFiles = options.entry.length ? options.entry.map(e => path.resolve(target, e)) : isFile(targetPath) ? [targetPath] : [path.join(target, 'index.html')]; + const htmlFiles = options.result ? [path.resolve(target, options.result)] : options.entry.length ? options.entry.map(e => path.resolve(target, e)) : isFile(targetPath) ? [targetPath] : [path.join(target, 'index.html')]; for (const entry of htmlFiles) { if (!isFile(entry)) { add('NOT VERIFIED', 'ENTRY-001', entry, null, 'Production entry unavailable; specify --entry bootstrap-path', true); continue; } if (isStory(entry)) { add('NOT VERIFIED', 'ENTRY-001', entry, null, 'Storybook is not a production entry', true); continue; } if (!entry.endsWith('.html')) { entries.push({ file: entry, roots: [entry] }); continue; } const html = fs.readFileSync(entry, 'utf8').replace(//g, ''); + const markup = html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, s => s.replace(/[^\n]/g, ' ')); + for (const m of markup.matchAll(/<([a-z][\w-]*)\b[^>]*\bdata-slot\s*=\s*["']([^"']+)["'][^>]*>/gi)) if (knownSlot(m[2])) add('FAIL', 'REUSE-001', entry, { loc: { start: { line: markup.slice(0, m.index).split('\n').length } } }, `Native <${m[1]}> claims HM slot ${m[2]}; use the public React component`, true); const roots = []; for (const m of html.matchAll(/<(script|link)\b([^>]+)>/gi)) { const attrs = Object.fromEntries([...m[2].matchAll(/([\w-]+)\s*=\s*["']([^"']*)["']/g)].map(a => [a[1].toLowerCase(), a[2]])); @@ -266,6 +278,13 @@ async function inspect(options) { entries.push({ file: entry, roots }); } entries.forEach(e => e.roots.forEach(visit)); + if (options.result) { + const entry = entries[0]; + const records = entry ? closure(entry.roots) : []; + const react = records.some(r => r.mounts && r.edges.some(e => /^react-dom(?:\/client)?$/.test(e.spec))); + if (!react) add('NOT VERIFIED', 'RENDER-001', entry?.file ?? path.resolve(target, options.result), null, 'Final artifact has no proven React mount; HTML imitation is not React rendering evidence', true); + if (react) add('NOT VERIFIED', 'RENDER-002', entry.file, null, 'React bootstrap found; confirm this artifact in the browser once (layout / utilities / interaction)', true); + } for (const s of selected) { if (!fs.existsSync(s)) add('NOT VERIFIED', 'SCOPE-001', s, null, 'Selected change path unavailable', true); else if (![...graph.keys()].some(f => s === f || within(s, f))) add('NOT VERIFIED', 'SCOPE-001', s, null, 'Selected path is outside the proven production graph; supply --entry', true); @@ -303,6 +322,20 @@ async function inspect(options) { selectedDependencies = new Set(closure(changedRoots).map(r => r.file)); } const directlyReused = r => r.edges.some(e => e.runtime && (packageRef(e.spec) || e.resolved && privatePath(e.resolved))) || r.renders.some(x => origin(r.file, x.name)?.pkg); + for (const r of graph.values()) { + if (!changedFile(r.file) || privatePath(r.file) || isStory(r.file)) continue; + for (const render of r.renders) { + const slot = literal(render.attrs.get('data-slot')); + if (knownSlot(slot) && origin(r.file, render.name)?.pkg !== '@hm-design/ui' && origin(r.file, render.name)?.pkg !== '@hm-design/center-graphic') add(/^[a-z]/.test(render.name) ? 'FAIL' : 'NOT VERIFIED', 'REUSE-001', r.file, render.node, `${render.name} claims HM slot ${slot}; confirm public component provenance`); + if (render.attrs.has('data-slot') && slot === undefined) add('NOT VERIFIED', 'REUSE-005', r.file, render.node, 'Computed data-slot requires component provenance confirmation'); + if (/^(?:select|input|textarea|button)$/.test(render.name)) add('NOT VERIFIED', 'REUSE-004', r.file, render.node, `Native <${render.name}>: confirm it does not replace an indexed HM component`); + } + for (const e of r.edges) for (const b of e.names) { + const identity = origin(r.file, b.local); + if (e.node.type !== 'ImportDeclaration' || !e.runtime || b.typeOnly || !(identity?.pkg || packageRef(e.spec)) || !(componentNames.has(identity?.name ?? b.imported) || b.imported === '*')) continue; + if (!r.references.has(b.local)) add('FAIL', 'REUSE-002', r.file, e.node, `${identity?.name ?? b.imported} imported but unused; import alone is not component reuse`); + } + } // Related ancestors supply bootstrap / CSS evidence. A new sibling UI is not // made an HM element simply because the screen also renders an HM component. applicableFiles = new Set(); @@ -327,7 +360,7 @@ async function inspect(options) { let selectedDashboardLayout = null; let officialDashboardHeader = false; for (const r of reused) { - for (const e of r.edges.filter(e => e.runtime && packageRef(e.spec))) for (const b of e.names) if (!b.typeOnly && packageRef(e.spec).contract.entries.get(packageRef(e.spec).key)?.has(b.imported)) remember(b.imported); + for (const e of r.edges.filter(e => e.runtime && packageRef(e.spec))) for (const b of e.names) if (!b.typeOnly && r.references.has(b.local) && packageRef(e.spec).contract.entries.get(packageRef(e.spec).key)?.has(b.imported)) remember(b.imported); for (const render of r.renders) { const identity = origin(r.file, render.name); if (identity?.pkg) remember(identity.name); } } for (const r of records) for (const render of r.renders) { @@ -375,6 +408,7 @@ async function inspect(options) { for (let i = issues.length - 1; i >= 0; i--) { const item = issues[i]; const file = path.resolve(target, item.file); + if (item.code.startsWith('REUSE-') && selected.length && !changedFile(file) && !(options.result && file === path.resolve(target, options.result))) { issues.splice(i, 1); continue; } if (selected.length && !contextualIssues.has(item) && !selectedFile(file)) { issues.splice(i, 1); continue; } if (/^(?:SB|CSS|NAV)-/.test(item.code) && !applicableFiles.has(file) && !item.code.startsWith('NAV-003')) issues.splice(i, 1); } @@ -386,21 +420,20 @@ async function inspect(options) { const related = issues.filter(x => x.code.startsWith(['NAV', 'SB', 'CSS', 'IMP'][i]) || x.code.startsWith('ENTRY') || x.code.startsWith('CORE') || x.code.startsWith('SCOPE')); return { name, status: related.length ? statusFor(related) : groupApplicability[i] ? 'PASS' : 'NOT APPLICABLE' }; }); - return { status: issues.length ? statusFor(issues) : groupApplicability.some(Boolean) ? 'PASS' : 'NOT APPLICABLE', scope: options.scope, mode: options.mode ?? null, target, contractSource: workspace, checkedGroups: groupResults, checkedHMElements: [...checkedElements].sort(), issues, durationMs: Math.round((performance.now() - start) * 100) / 100, note: 'Reused HM elements only. New UI/layout design, browser rendering, generated utility output and runtime behavior are not verified.' }; + return { status: issues.length ? statusFor(issues) : groupApplicability.some(Boolean) ? 'PASS' : 'NOT APPLICABLE', scope: options.scope, mode: options.mode ?? null, target, contractSource: workspace, checkedGroups: groupResults, checkedHMElements: [...checkedElements].sort(), issues, durationMs: Math.round((performance.now() - start) * 100) / 100, note: 'Static contracts only. Native controls / CSS intent, actual rendered component usage, browser layout and behavior require verification. --result checks a final artifact bootstrap, not browser evidence.' }; } } try { const options = argumentsFrom(process.argv.slice(2)); - if (options.help) console.log('node scripts/ai-generation-guard.mjs --target PATH --scope FULL_SCREEN|PARTIAL [--mode GNB_ONLY|LNB_ONLY|DASHBOARD_ONLY]\n[--entry BOOTSTRAP] [--files CHANGED_FILE,... (PARTIAL)] [--json]\nPARTIAL checks reachable selected files; no Navigation creation required. PASS covers static contracts only.'); + if (options.help) console.log('node scripts/ai-generation-guard.mjs --target PATH --scope FULL_SCREEN|PARTIAL [--mode GNB_ONLY|LNB_ONLY|DASHBOARD_ONLY]\n[--entry BOOTSTRAP] [--files CHANGED_FILE,... (PARTIAL)] [--result FINAL_HTML_OR_BOOTSTRAP] [--json]\n--result selects the submitted React artifact. PARTIAL checks reachable selected files; PASS covers static contracts only.'); else { const report = await inspect(options); if (options.json) console.log(JSON.stringify(report, null, 2)); - else if (!report.issues.length) console.log(`HMDesign FAST CORE: ${report.status}\nHMDesign: ${report.checkedHMElements.join(', ') || 'none'}\nChecked: 4 groups; Issues: 0`); + else if (!report.issues.length) console.log(report.status); else { - console.log(`HMDesign FAST CORE: ${report.status}`); - console.log(`HMDesign: ${report.checkedHMElements.join(', ') || 'none'}`); - for (const issue of report.issues.slice(0, 5)) console.log(`${issue.severity} ${issue.code} ${issue.file}:${issue.line}\n${issue.message}`); + console.log(report.status); + for (const issue of report.issues.slice(0, 5)) console.log(`${issue.severity} ${issue.code} ${issue.file}:${issue.line} ${issue.message}`); if (report.issues.length > 5) console.log(`Issues: ${report.issues.length} (showing 5)`); } if (report.status === 'FAIL') process.exitCode = 1; diff --git a/scripts/ai-generation-guard.test.mjs b/scripts/ai-generation-guard.test.mjs index 96d4594..d4f764a 100644 --- a/scripts/ai-generation-guard.test.mjs +++ b/scripts/ai-generation-guard.test.mjs @@ -9,7 +9,6 @@ import { performance } from 'node:perf_hooks'; import test from 'node:test'; const guard = fileURLToPath(new URL('./ai-generation-guard.mjs', import.meta.url)); -const external = String.raw`D:\디자인시스템\HMDesign-main\hmdesign`; function fixture(files, callback) { const tempRoot = fs.realpathSync(os.tmpdir()); const root = fs.mkdtempSync(path.join(tempRoot, 'hm-fast-core-')); @@ -55,10 +54,7 @@ test('1. normal GNB_ONLY; indirect CSS, aliases, unrelated new UI and unused Sto const result = run(root, ['--scope', 'FULL_SCREEN', '--mode', 'GNB_ONLY'], false); assert.equal(result.status, 0); const lines = result.stdout.trim().split(/\r?\n/); - assert.equal(lines.length, 3); - assert.equal(lines[0], 'HMDesign FAST CORE: PASS'); - assert.equal(lines[1], 'HMDesign: Button, GNB, GNBMobileMenu'); - assert.match(lines[2], /Issues: 0/); + assert.deepEqual(lines, ['PASS']); console.log(`Normal GNB wall time: ${result.wallMs} ms`); }); }); @@ -113,9 +109,9 @@ test('5. private paths for both packages and a non-public named export are detec }); }); -test('6. external app PARTIAL is read-only and never infers a Navigation mode', () => { - assert.ok(fs.existsSync(external), 'External test project must exist'); - const names = ['src/App.tsx', 'src/main.tsx', 'src/index.css', 'src/hm-theme.css', 'package.json', 'AGENTS.md']; +test('6. PARTIAL is read-only and never infers a Navigation mode', () => { + fixture({ 'src/App.tsx': 'import { Button, GNB, GNBMobileMenu } from "@hm-design/ui"; export default () => ;' }, external => { + const names = ['src/App.tsx', 'src/main.tsx', 'src/styles.css', 'src/tokens.css', 'tsconfig.app.json', 'index.html']; const hashes = () => names.map(name => createHash('sha256').update(fs.readFileSync(path.join(external, name))).digest('hex')); const before = hashes(); const { status, report } = run(external, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']); @@ -127,4 +123,94 @@ test('6. external app PARTIAL is read-only and never infers a Navigation mode', assert.ok(report.checkedHMElements.includes('GNB')); assert.deepEqual(hashes(), before); console.log(`External PARTIAL: ${report.status}; ${report.checkedHMElements.length} HM elements; ${report.durationMs} ms`); + }); +}); + +test('7. native Select with fake data-slot and import-only reuse fails', () => { + fixture({ 'src/App.tsx': 'import { Select as Choice } from "@hm-design/ui";\nexport default () => ;' }, root => { + const { status, report } = run(root, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']); + assert.equal(status, 1); + for (const code of ['REUSE-001', 'REUSE-002']) assert.ok(report.issues.some(i => i.code === code && i.file === 'src/App.tsx')); + assert.ok(!report.checkedHMElements.includes('Select')); + }); +}); + +test('8. fake slots without an HM import, createElement and namespace unused', () => { + for (const source of [ + 'export default () =>
;', + 'import React from "react"; export default () => React.createElement("select", { "data-slot": "select" });', + 'import * as HM from "@hm-design/ui"; export default () =>
;', + ]) fixture({ 'src/App.tsx': source }, root => { + const { report } = run(root, ['--scope', 'FULL_SCREEN']); + assert.equal(report.status, 'FAIL'); + assert.ok(report.issues.some(i => /^REUSE-00[12]$/.test(i.code))); + }); +}); + +test('9. official aliases, namespace, forwarding, types and barrels are not unused', () => { + fixture({ + 'src/hm.ts': 'export { Select as Choice } from "@hm-design/ui";', + 'src/App.tsx': 'import { Choice } from "./hm"; import { Button } from "@hm-design/ui"; import type { ButtonAppearance } from "@hm-design/ui"; import * as HM from "@hm-design/ui"; const Forward = ({ component: Component }) => ; export default () => <>;', + }, root => { + const { report } = run(root, ['--scope', 'FULL_SCREEN']); + assert.equal(report.status, 'PASS'); + }); +}); + +test('10. native controls and CSS customization need review, not FAIL', () => { + fixture({ 'src/App.tsx': 'export default () => ;', + }, root => { + assert.equal(run(root, ['--scope', 'FULL_SCREEN']).report.status, 'FAIL'); + assert.equal(run(root, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']).report.status, 'PASS'); + // Selecting a leaf checks that leaf; its unchanged siblings stay outside scope. + assert.equal(run(root, ['--scope', 'PARTIAL', '--files', 'src/Old.tsx']).report.issues.some(i => i.file === 'src/App.tsx' && i.code.startsWith('REUSE')), false); + }); +}); + +test('12. final HTML imitation is flagged; React preview remains browser NOT VERIFIED', () => { + fixture({ + 'preview.html': '', + 'react-preview.html': '
', + 'src/main.tsx': 'import { createRoot } from "react-dom/client"; import App from "./App"; import "./styles.css"; createRoot(document.getElementById("root")).render();', + 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () =>