diff --git a/docs/sw/ai-template/README.md b/docs/sw/ai-template/README.md new file mode 100644 index 0000000..60b18a5 --- /dev/null +++ b/docs/sw/ai-template/README.md @@ -0,0 +1,116 @@ +# SW AI 재사용 템플릿 — 조사·구현·검증 기록 (2026-10-19) + +> 범위: `@hm-design/sw`만. `@hm-design/ui`·조직도·Center Graphic·DWG/DXF Viewer는 변경하지 않았습니다. 작업 Worktree `D:\디자인시스템\HMDesign-SW-AI-Template`, 브랜치 `feature/sw-ai-template`, 시작 HEAD `50bf5bda8e956a4973b923d64131f2f2285bd38c`(`feature/sw-package`). +> +> AI용 사용 규칙·카탈로그·디자인 규칙·조합 패턴의 **원본은 패키지 안 [`packages/sw/ai/`](../../../packages/sw/ai/AI_USAGE.md)** 이며 npm 패키지에 포함됩니다. 이 문서는 조사 결과와 검증 기록만 담고 원본 내용을 반복하지 않습니다. + +## 1. 결론 + +- 기존 공개 Export만으로 재사용(예제 A)·조합(예제 B·C)·확장(예제 D)이 모두 가능했습니다. **런타임 소스(`packages/sw/src`)와 공개 API는 변경하지 않았습니다.** 그래서 기존 SW Header의 디자인·기능·Props·Callback은 그대로입니다. +- 변경은 패키지 배포 범위(`files`에 `ai` 추가), AI 가이드 문서 4종, 공개 API만 쓰는 예제, README 안내 1절, SW 전용 검증 스크립트 3개입니다. +- 공개 API가 부족한 부분(More, 범용 필드 면, Header 면 레이아웃 등)은 내부 Import로 우회하지 않았습니다. §3 EXPORT CANDIDATE / MISSING으로 보고합니다. + +## 2. 기존 구성 분석 요약 + +- 진입점: `src/index.ts` → `components/sw-header/index.ts`, `SWTheme`. 배포는 `exports` `"."`(ESM `dist/index.js` + `dist/index.d.ts`)와 `"./styles.css"` 두 개입니다. `main`·`types`가 있고 `module` 필드는 없습니다(ESM 전용 `"type": "module"`, `exports`가 우선). Peer는 `react`/`react-dom` `^19.0.0`, 런타임 dependency는 없습니다. +- 자산: SVG·PNG 48개는 Vite library build에서 data URI로 JS에 포함됩니다(소비 프로젝트 33개 이미지 디코드 확인). 글꼴은 포함하지 않습니다. +- CSS: 토큰은 `.hm-sw-header`와 `.hm-sw-theme`에만 정의됩니다. 컴포넌트 규칙은 대부분 `hm-sw-header__*` class 단위라서 `SWTheme` 안에서 단독 렌더링됩니다. 반응형은 `@container hm-sw-header`(12개 규칙)라 `SWHeader` 안에서만 적용됩니다. 전역 선택자는 없습니다. +- Overlay: 필드 Dropdown은 absolute(필드 아래, `z-index` 30)이고 `closest(".hm-sw-header") ?? documentElement` 기준으로 좌우를 보정하므로 Header 밖에서도 동작합니다. 속성창·K 가이드는 `position: fixed`(`useSWHeaderOverlay`)이며 Portal·Dim은 없습니다. +- `dist`에는 내부 모듈 `.d.ts`도 생성되지만 `exports`가 deep import를 막습니다. + +## 3. 분류 + +### PUBLIC READY (현재 외부 재사용 가능) + +| 컴포넌트 | 근거 | +|---|---| +| `SWHeader` | 공식 기준 템플릿. 예제 A에서 1920 좌표가 Figma와 일치(0·80 / 65·310 / 377·1301 / 1680·240, 49px) | +| `SWTheme` | 단독 사용 토큰 범위. 소비 프로젝트에서 토큰·글꼴 확인 | +| `SWHeaderHome`, `SWHeaderDocumentTitle` | 예제 B·C 렌더링, 문서 Dropdown `+ New` Callback | +| `SWHeaderLayerMenu`, `SWHeaderColorMenu`, `SWHeaderLineStyleMenu`, `SWHeaderTextStyleMenu`, `SWHeaderDimensionStyleMenu` | 예제 B·C·D에서 열기·선택 Callback·Escape Focus 복귀 | +| `SWHeaderActions` | `showPrint` / `showPopupGuide`로 K만·인쇄만. K 가이드 Hover·Click | +| `SWHeaderInput`, `parseSWHeaderColor`, `parseSWHeaderNumber` | Input은 예제 D에서 Enter 확정·Escape 복원 확인. 파서는 이번 미검증 | +| `SWHeaderLayerControl` 외 필드 버튼 4종, `SWHeaderWindowControls`, `SWHeaderProperties`, `SWHeaderPropertyBar`, `SWHeaderPropertyPanel`, `SWHeaderLinePreview`, `swHeaderLinePatterns`, 공개 타입 | 공개·문서화 상태(기존 사용 가이드 §4·§8). 이번 소비 검증 범위 밖 | + +### EXPORT CANDIDATE (내부 구현 있음, 공개 API 정비 필요) + +| 내부 구현 | 재사용 가치 | 공개 전 필요한 정비 | +|---|---|---| +| `SWHeaderMore` | 조합 Header의 Overflow | tier·`data-visible`·container 규칙이 `SWHeader` 구조에 결합. 항목 visibility API 설계 필요 | +| `SWHeaderField` / `SWHeaderFieldText` | 새 필드형 컨트롤이 같은 면을 재사용(예제 D 표면 CSS 대체) | `variant`가 5개 고정 폭. 범용 폭·`children` 계약 필요 | +| `SWHeaderKGuide` | pill 없는 K 단독 버튼 | 현재 `SWHeaderActions showPrint={false}`로 대체 가능. 수요 확인 후 | +| `useSWHeaderDropdown`, `SWHeaderMenuDetail`, `SWHeaderColorChip` | 새 Dropdown 동작·`… 상세` pill·색상 칩 | 반환값 계약(ref·style·shifted)과 CSS 의존 정리 | +| Header 면(객체 수정 / 창 영역 / 막대) | 조합 Header 배치 | 현재 `SWHeader` 내부 DOM. 예제는 소비 CSS + 공개 토큰으로 구성 | + +### INTERNAL ONLY + +`SWHeaderLayerRows`, `SWHeaderColorPanelBody`, `SWHeaderLineStyleColumns`(헤더 Dropdown과 속성창 Picker의 공유 본문), `SWHeaderPropertyPickerField`, `propertyPicker`, `propertyLineTypes`, `SWHeaderLayerIconArt`, `SWHeaderLayerToggle`, `layerIconOn`, `SWHeaderCommitPresence`, `useSWHeaderOverlay`, `useSWHeaderColorMode`, `SWHeaderKGuidePreview`. 상위 공개 컴포넌트의 상태·Focus 계약에 묶여 있어 단독 공개 시 중복 상태 위험이 있습니다. + +### MISSING (SW 패키지에 없음) + +검색 컨트롤(예제 D에서 소비 프로젝트 확장으로 작성), 범용 SW Button / IconButton, 범용 Tooltip 컴포넌트(창 이동 Tooltip은 CSS 전용), 조합 Header용 반응형 More, Header 면 레이아웃 컴포넌트. + +### 발견한 재사용 제약 (코드 미변경, 문서화) + +1. `SWHeaderDocumentTitle`은 앞의 로고와 겹치는 전제로 왼쪽 테두리가 없습니다 → 첫 칸이면 소비 wrapper에 테두리(예제 C). +2. `SWHeaderDocumentTitle` 폭은 `100cqw` 기반이라 Header 밖에서는 뷰포트 폭을 기준으로 계산합니다. +3. 창 영역 배경·테두리는 `SWHeader` DOM에 있어 `SWHeaderActions`/`SWHeaderWindowControls` 단독 사용 시 소비 면이 필요합니다. +4. `body` margin 등 페이지 기본값은 소비 앱 책임입니다(첫 소비 실행에서 8px 오프셋으로 확인 → 테스트 앱에서 지정). + +## 4. 변경 파일 + +| 파일 | 내용 | +|---|---| +| `packages/sw/package.json` | `files`: `["dist"]` → `["dist", "ai"]` | +| `packages/sw/README.md` | 끝에 "AI 코딩 도구용 가이드" 절 추가(기존 본문 무변경) | +| `packages/sw/ai/AI_USAGE.md`, `COMPONENT_CATALOG.md`, `DESIGN_RULES.md`, `COMPOSITION_PATTERNS.md` | AI 가이드(배포 포함) | +| `packages/sw/ai/examples/*` | 예제 A~D, `SWSearchControl`(소비 확장), 소비 배치 CSS, 샘플 데이터 | +| `packages/sw/tests/ai-docs.check.mjs` | 문서·예제와 공개 Export·링크 일치 검사 | +| `packages/sw/tests/ai-template-consumer.mjs` | `npm pack` → 저장소 밖 독립 React 19 + TS 7 + Vite 8 프로젝트 생성, Tarball 설치, 설치된 `ai/examples` 복사, `tsc`·`vite build` | +| `packages/sw/tests/ai-template-consumer.browser.mjs` | 소비 빌드 브라우저 검증(Chrome, Playwright) | +| `docs/sw/ai-template/` | 이 기록과 `evidence/`(스크린샷 7장, `report.json`) | + +## 5. 이번에 실행한 검증 (2026-10-19) + +| 항목 | 결과 | +|---|---| +| `npm run typecheck -w @hm-design/sw` | PASS | +| `npm run build -w @hm-design/sw` | PASS(`dist/index.js` 712.25 kB, `styles.css` 102.25 kB) | +| `npm pack`(실제 tarball, Publish 없음) | PASS — 40개 파일, `ai/` 문서 4 + 예제 7, `dist`, `README.md`, `package.json` | +| `node packages/sw/tests/ai-docs.check.mjs` | 19/19 PASS — 문서에 쓴 SW 이름 전부 공개 Export, "공개하지 않은 것" 목록에 Export 없음, 예제 Import 전부 공개·deep import 없음, 패키지 상대 링크 정상 | +| 독립 소비 프로젝트 설치(`react@19.3.0`, `typescript@7.0.2`, `vite@8.3.0`, `@vitejs/plugin-react@6.1.1`) | PASS | +| 소비 프로젝트 `tsc`(strict, `noUnusedLocals`, `skipLibCheck: false`) | PASS — 설치된 패키지의 예제를 그대로 컴파일 | +| 소비 프로젝트 Production Build | PASS(번들 500 kB 경고만, 오류 없음) | +| 소비 빌드 브라우저(Chrome 1920 × 1080) | 27/27 PASS([report.json](./evidence/report.json)) | + +브라우저 27개 항목: 자산 33개 data URI 디코드 · CSS/토큰 로딩 · A 기본 Header 좌표 = Figma · A 레이어 선택 / 종료 Callback · B 순서(DOM) · B 크기(49 / 80 / 파일 x65 / 234·192 × 38) · B 문서 `+ New` · B 레이어 열림 · Escape 닫힘 + Focus 복귀 · B 색상 선택(열림 유지) · B K 가이드 Hover · B K Click · C 순서 · C 파일 왼쪽 테두리 · C 선·문자·치수 선택과 필드 값 반영 · C 인쇄 · D 검색 필드 표면 = 레이어 필드(높이·위치·테두리·radius·배경·그림자·라벨 12px) · D Hover 링(테두리만 노랑, 내부 픽셀 불변) · D Enter → 검색 · D 버튼 검색 · D Escape 복원 · D 색상 선택 · B Tab 순서 = 표시 순서 · 페이지/콘솔 오류 0. + +중간 실패와 수정: 첫 실행 24/27(소비 앱 `body` 8px margin, 예제 막대 `box-sizing`, favicon 404) → 소비 앱·예제 CSS 수정. 육안 확인 중 D의 Hover 링이 필드 전체를 덮는 문제(표준 `mask` 뒤에 `-webkit-mask` 선언) 발견 → 선언 순서 수정, 테스트에 픽셀 검사 추가 후 재실행 PASS. + +증거: [A](./evidence/A-default-1920.png) · [B](./evidence/B-viewer-1920.png) · [B 레이어](./evidence/B-layer-open.png) · [B K](./evidence/B-k-guide.png) · [C](./evidence/C-feature-1920.png) · [D](./evidence/D-search-1920.png) · [D Hover](./evidence/D-search-hover.png). + +재현: + +```bash +npm run build -w @hm-design/sw +node packages/sw/tests/ai-docs.check.mjs +node packages/sw/tests/ai-template-consumer.mjs <저장소 밖 빈 폴더> +node packages/sw/tests/ai-template-consumer.browser.mjs <같은 폴더> <증거 폴더> +``` + +## 6. NOT VERIFIED + +- Firefox·Safari에서 소비 빌드(이번에는 Chrome만). 예제 D의 mask는 표준 + prefix 둘 다 선언했으나 Firefox 미실행. +- 이번 소비 검증에서 직접 렌더링하지 않은 공개 컴포넌트: 필드 버튼 5종 단독, `SWHeaderWindowControls` 단독(창 이동 Motion), `SWHeaderProperties`, `SWHeaderPropertyBar`, `SWHeaderPropertyPanel`(13개 필드·4상태), `SWHeaderLinePreview`, 파서 함수. 기존 Storybook 검증 기록만 있습니다. +- `SWHeader`의 반응형 More·10개 폭, Colorize, 게이지바, 속성창 연결: 런타임 코드를 바꾸지 않았으므로 이번에 다시 실행하지 않았습니다(과거 PASS 기록은 기존 문서). +- Noto Sans KR 실제 로드(소비 테스트는 시스템 대체 글꼴). +- 저장소 루트 전체 `npm run typecheck`·Storybook build(SW 패키지 단위만 실행). +- `README.md`의 기존 `../../docs/...` 링크는 Tarball 안에서 열리지 않습니다(기존 상태, `ai/` 문서는 패키지 내부 링크만 사용). + +## 7. 추가 개선 후보 + +1. `SWHeaderField` 범용 폭 공개 → 새 필드형 컨트롤이 표면 CSS를 다시 쓰지 않도록. +2. 조합 Header용 More(또는 `SWHeaderMore` 공개 API) 설계. +3. `SWHeaderDocumentTitle`에 단독 첫 칸용 왼쪽 테두리 옵션 검토(기본 Header 픽셀 영향 확인 필요). +4. Header 면(막대·객체 수정·창 영역) 레이아웃 컴포넌트 검토. +5. 소비 검증을 Firefox와 속성창 단독 사용까지 확장. diff --git a/docs/sw/ai-template/evidence/A-default-1920.png b/docs/sw/ai-template/evidence/A-default-1920.png new file mode 100644 index 0000000..eb4a525 Binary files /dev/null and b/docs/sw/ai-template/evidence/A-default-1920.png differ diff --git a/docs/sw/ai-template/evidence/B-k-guide.png b/docs/sw/ai-template/evidence/B-k-guide.png new file mode 100644 index 0000000..a7a024b Binary files /dev/null and b/docs/sw/ai-template/evidence/B-k-guide.png differ diff --git a/docs/sw/ai-template/evidence/B-layer-open.png b/docs/sw/ai-template/evidence/B-layer-open.png new file mode 100644 index 0000000..fbc5dec Binary files /dev/null and b/docs/sw/ai-template/evidence/B-layer-open.png differ diff --git a/docs/sw/ai-template/evidence/B-viewer-1920.png b/docs/sw/ai-template/evidence/B-viewer-1920.png new file mode 100644 index 0000000..d2aaf22 Binary files /dev/null and b/docs/sw/ai-template/evidence/B-viewer-1920.png differ diff --git a/docs/sw/ai-template/evidence/C-feature-1920.png b/docs/sw/ai-template/evidence/C-feature-1920.png new file mode 100644 index 0000000..ebe7fc1 Binary files /dev/null and b/docs/sw/ai-template/evidence/C-feature-1920.png differ diff --git a/docs/sw/ai-template/evidence/D-search-1920.png b/docs/sw/ai-template/evidence/D-search-1920.png new file mode 100644 index 0000000..ba7f6f8 Binary files /dev/null and b/docs/sw/ai-template/evidence/D-search-1920.png differ diff --git a/docs/sw/ai-template/evidence/D-search-hover.png b/docs/sw/ai-template/evidence/D-search-hover.png new file mode 100644 index 0000000..3354dca Binary files /dev/null and b/docs/sw/ai-template/evidence/D-search-hover.png differ diff --git a/docs/sw/ai-template/evidence/report.json b/docs/sw/ai-template/evidence/report.json new file mode 100644 index 0000000..3f5a33d --- /dev/null +++ b/docs/sw/ai-template/evidence/report.json @@ -0,0 +1,276 @@ +{ + "checks": [ + { + "name": "1 all SVG/PNG assets decode (data URI, no external path)", + "status": "PASS", + "data": { + "count": 33, + "broken": [] + } + }, + { + "name": "1 styles.css loaded; SWTheme provides tokens", + "status": "PASS", + "data": { + "height": "49px", + "font": "\"Noto Sans KR\", \"Malgun Gothic\", \"Apple SD Gothic Neo\", sans-serif" + } + }, + { + "name": "2 A default header 1920 geometry = Figma (49 / 0·80 / 65·310 / 377·1301 / 1680·240)", + "status": "PASS", + "data": { + "header": { + "x": 0, + "y": 0, + "w": 1920, + "h": 49 + }, + "home": { + "x": 0, + "y": 0, + "w": 80, + "h": 48 + }, + "document": { + "x": 65, + "y": 0, + "w": 310, + "h": 48 + }, + "object": { + "x": 377, + "y": 0, + "w": 1301, + "h": 48 + }, + "window": { + "x": 1680, + "y": 0, + "w": 240, + "h": 48 + } + } + }, + { + "name": "2 A layer dropdown select callback", + "status": "PASS", + "data": "layer:center" + }, + { + "name": "2 A close callback", + "status": "PASS", + "data": "close" + }, + { + "name": "3 B order Logo → File → Layer → Color → K (DOM)", + "status": "PASS", + "data": [ + "뷰어 홈", + "hm-sw-header__document", + "레이어", + "색상", + "팝업 가이드" + ] + }, + { + "name": "3 B sizes keep SW rules (bar 49, home 80 overlapping file 15px, fields 234/192 x 38)", + "status": "PASS", + "data": { + "bar": { + "x": 0, + "y": 409, + "w": 1920, + "h": 49 + }, + "home": { + "x": 0, + "y": 409, + "w": 80, + "h": 48 + }, + "document": { + "x": 65, + "y": 409, + "w": 310, + "h": 48 + }, + "layer": { + "x": 382, + "y": 414, + "w": 234, + "h": 38 + }, + "color": { + "x": 620, + "y": 414, + "w": 192, + "h": 38 + } + } + }, + { + "name": "3 B document dropdown New callback", + "status": "PASS", + "data": "document:new" + }, + { + "name": "3 B layer dropdown opens under the field", + "status": "PASS", + "data": "true" + }, + { + "name": "3 B Escape closes and returns focus to the field", + "status": "PASS", + "data": null + }, + { + "name": "3 B color chip callback (dropdown stays open)", + "status": "PASS", + "data": "color:red" + }, + { + "name": "3 B K guide preview on hover (animation frame advances)", + "status": "PASS", + "data": { + "guide": true + } + }, + { + "name": "3 B K click callback", + "status": "PASS", + "data": "popupGuide" + }, + { + "name": "4 C order File → 선 → 문자 → 치수 → 인쇄", + "status": "PASS", + "data": [ + "file", + "선스타일", + "문자스타일", + "치수스타일", + "인쇄" + ] + }, + { + "name": "4 C first file well has a left edge (consumer wrapper)", + "status": "PASS", + "data": "1px" + }, + { + "name": "4 C line type callback + field value", + "status": "PASS", + "data": "lineType:center" + }, + { + "name": "4 C text style callback + field value", + "status": "PASS", + "data": "textStyle:dotum" + }, + { + "name": "4 C dimension style callback", + "status": "PASS", + "data": "dimensionStyle:iso" + }, + { + "name": "4 C print callback", + "status": "PASS", + "data": "print" + }, + { + "name": "5 D new search field matches the SW field surface (height/top/border/radius/background/shadow/label)", + "status": "PASS", + "data": { + "layer": { + "h": 38, + "top": 1232, + "border": "1px rgb(8, 37, 28)", + "radius": "3px", + "bg": "linear-gradient(0deg, rgba(0, 0, 0, 0.45", + "shadow": "rgba(9, 39, 30, 0.7) 0px 1px 4px 0px" + }, + "search": { + "h": 38, + "top": 1232, + "border": "1px rgb(8, 37, 28)", + "radius": "3px", + "bg": "linear-gradient(0deg, rgba(0, 0, 0, 0.45", + "shadow": "rgba(9, 39, 30, 0.7) 0px 1px 4px 0px" + }, + "label": "12px", + "fieldLabel": "12px" + } + }, + { + "name": "5 D hover ring like existing fields (edge turns yellow, inside unchanged)", + "status": "PASS", + "data": { + "ring": "1", + "before": { + "inside": [ + 18, + 50, + 39 + ], + "edge": [ + 8, + 37, + 28 + ] + }, + "after": { + "inside": [ + 18, + 50, + 39 + ], + "edge": [ + 172, + 131, + 3 + ] + } + } + }, + { + "name": "5 D Enter commits through SWHeaderInput → search callback", + "status": "PASS", + "data": "search:WALL-01" + }, + { + "name": "5 D search button callback uses committed value", + "status": "PASS", + "data": "search:WALL-01" + }, + { + "name": "5 D Escape restores the committed value", + "status": "PASS", + "data": "WALL-01" + }, + { + "name": "5 D existing color dropdown in the same bar", + "status": "PASS", + "data": "color:blue" + }, + { + "name": "6 B Tab order = display order", + "status": "PASS", + "data": [ + "문서: C0020101-006-평면및종단면도(06)", + "레이어", + "색상", + "팝업 가이드" + ] + }, + { + "name": "7 no page/console errors", + "status": "PASS", + "data": [] + } + ], + "errors": [], + "warnings": [ + "Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently", + "Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently" + ] +} \ No newline at end of file diff --git a/packages/sw/README.md b/packages/sw/README.md index abff9da..957a9bf 100644 --- a/packages/sw/README.md +++ b/packages/sw/README.md @@ -87,3 +87,7 @@ npm run typecheck -w @hm-design/sw - 속성창 공통 행은 Figma 26:12388 ~ 26:12649의 Default / Hover / On을 따릅니다. 숫자는 편집 On(`#0F0D0A`), 레이어·선스타일·색상은 Dropdown On(`#211D13`)이며 하이퍼링크·재료는 Default·Hover만 있습니다. - 추가 API(모두 선택): `SWHeaderProperty.picker`(`"layer" | "lineStyle" | "color" | false`), `SWHeaderProperty.decimals`, `SWHeaderPropertyPanelProps.menus`(`SWHeaderPropertyMenus`). 필드 Dropdown은 Header Dropdown과 같은 목록 컴포넌트를 씁니다. [사용법](../../docs/sw/sw-header-usage.md#속성창k-가이드-추가-연결-2026-10-16), [검증 기록](../../docs/sw/sw-property-fields-verification-2026-10-19.md). - 투명도 게이지는 빨강 채움 + 베이지 Track이며 Chrome·Firefox가 같은 그림을 씁니다([Firefox 게이지 기록](../../docs/sw/sw-header-firefox-range-close-verification-2026-10-19.md)). + +## AI 코딩 도구용 가이드 (2026-10-19) + +다른 프로젝트에서 AI 코딩 도구가 이 패키지를 재사용·조합·확장할 때는 설치된 패키지의 `ai/` 폴더를 먼저 읽습니다: [AI_USAGE.md](./ai/AI_USAGE.md)(작업 순서·Import 규칙) → [COMPONENT_CATALOG.md](./ai/COMPONENT_CATALOG.md)(공개 API) → [DESIGN_RULES.md](./ai/DESIGN_RULES.md)(토큰·상태·Motion) → [COMPOSITION_PATTERNS.md](./ai/COMPOSITION_PATTERNS.md)(조합 방법) → [examples/](./ai/examples)(공개 API만 쓰는 예제 A~D). `ai/`는 npm 패키지(`files`)에 포함됩니다. 런타임 API는 바뀌지 않았습니다. diff --git a/packages/sw/ai/AI_USAGE.md b/packages/sw/ai/AI_USAGE.md new file mode 100644 index 0000000..ee9d57e --- /dev/null +++ b/packages/sw/ai/AI_USAGE.md @@ -0,0 +1,54 @@ +# @hm-design/sw — AI 코딩 도구 사용 안내 + +이 폴더(`node_modules/@hm-design/sw/ai/`)는 다른 React 프로젝트에서 AI 코딩 도구가 SW 패키지를 **재사용·조합·확장**할 때 먼저 읽는 안내서입니다. 패키지 자체에는 AI 기능이 없습니다. 아래 문서는 실제 공개 API와 CSS에서 확인한 값만 적습니다. + +| 문서 | 내용 | +|---|---| +| [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트·Props·Callback·크기·CSS 의존성 | +| [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형 | +| [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header 조합 방법 | +| [examples/](./examples) | 공개 API만 쓰는 예제 A~D(독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증) | + +## 1. 작업 순서 (필수) + +1. **먼저 검색한다.** 요청 기능을 [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md)의 공개 컴포넌트에서 찾는다. +2. **있으면 재사용한다(1순위).** 완성형 Header가 필요하면 `SWHeader` 하나만 쓴다. 기존 Props(`hiddenControls`, `menuOrder`, `controls`, 각 데이터 배열·Callback)로 해결되는지 먼저 본다. +3. **없으면 조합한다(2순위).** 개별 공개 컴포넌트(`SWHeaderHome`, `SWHeaderDocumentTitle`, `SWHeaderLayerMenu` …)를 `` 안에 배치한다. 배치용 CSS만 소비 프로젝트에 작성한다([COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md)). +4. **조합으로도 안 될 때만 새로 만든다(3순위).** 새 컴포넌트는 **소비 프로젝트**에 작성하고, 공개 컴포넌트(예: `SWHeaderInput`)를 내부에 재사용하며, 외형은 공개 토큰 `--hm-sw-header-*`만 읽는다([DESIGN_RULES.md](./DESIGN_RULES.md), 예제 D). +5. **새 디자인 값을 만들지 않는다.** 크기·색·간격은 토큰과 DESIGN_RULES의 확인값만 쓴다. 값이 없으면 추측하지 말고 사용자에게 확인한다. +6. **존재하지 않는 API를 추측하지 않는다.** 카탈로그와 `dist/index.d.ts`에 없는 Export·Props는 없는 것이다. +7. **패키지 코드를 복사하지 않는다.** `node_modules/@hm-design/sw/dist`의 구현이나 `hm-sw-*` CSS 블록을 복사해 같은 컴포넌트를 다시 만들지 않는다. +8. **실제로 검증한다.** 소비 프로젝트에서 `tsc`, Production Build, 브라우저 렌더링(Dropdown 열기·Callback·Focus)을 확인한 뒤 완료로 보고한다. 실행하지 않은 검증은 PASS로 적지 않는다. + +## 2. 설치·Import 규칙 + +```tsx +import { SWHeader, SWTheme, SWHeaderLayerMenu } from "@hm-design/sw" +import "@hm-design/sw/styles.css" // 앱 진입점에서 한 번 +``` + +- 공식 진입점은 `@hm-design/sw`(JS·타입)와 `@hm-design/sw/styles.css` 두 개뿐이다. `package.json`의 `exports`가 다른 경로를 막는다. +- **금지:** `@hm-design/sw/src/...`, `@hm-design/sw/dist/components/...` 같은 내부 경로 Import. `dist`에 내부 모듈의 `.d.ts`가 보여도 공개 API가 아니다. +- Peer: `react`, `react-dom` `^19.0.0`. 런타임 dependency 없음. `@hm-design/ui`·조직도 패키지와 무관하다. +- SVG·PNG 자산은 JS에 data URI로 포함되어 별도 설정이 없다. +- 글꼴 파일은 포함하지 않는다. 앱에서 Noto Sans KR(400/500/700)을 로드한다(없으면 Malgun Gothic → Apple SD Gothic Neo). +- CSS는 전역(`html`, `body`, `*`)을 건드리지 않는다. `body` margin 등 페이지 기본값은 소비 앱이 정한다. +- TypeScript에서 CSS side-effect import 오류가 나면 Vite의 `vite/client` 타입을 포함한다. + +## 3. 스타일 규칙 + +- `SWHeader` 밖에서 하위 컴포넌트를 쓰면 반드시 ``로 감싼다. 토큰·글꼴·버튼 초기화가 이 범위에서만 적용된다. +- 소비 코드는 `hm-sw-*` class를 붙이거나 재정의하지 않는다. 배치는 자기 class(`className`)와 `style`로 한다. +- 외형 변경은 `--hm-sw-header-*` 토큰을 `SWTheme`/`SWHeader`의 `style`로 바꾸는 방법만 쓴다. 사용자 확정 면(파일명·객체 수정·속성 입력)은 기본값 유지를 권장한다. +- Light/Dark 분기는 없다. SW 디자인은 하나다. + +## 4. 판단 기록 남기기 + +새 UI를 만들 때 결과 보고에 다음을 남긴다. + +| 요청 항목 | 판단 | 근거 | +|---|---|---| +| 예: 레이어 | 재사용 | `SWHeaderLayerMenu` | +| 예: 검색 | 신규(소비 프로젝트) | 카탈로그 MISSING. `SWHeaderInput` 재사용 + 토큰 기반 표면 | + +새 컴포넌트를 SW 패키지 공식 컴포넌트로 승격할지는 별도 검토 대상이다. 임의로 패키지에 추가하지 않는다. diff --git a/packages/sw/ai/COMPONENT_CATALOG.md b/packages/sw/ai/COMPONENT_CATALOG.md new file mode 100644 index 0000000..999b020 --- /dev/null +++ b/packages/sw/ai/COMPONENT_CATALOG.md @@ -0,0 +1,75 @@ +# @hm-design/sw 컴포넌트 카탈로그 + +기준: `@hm-design/sw` 0.1.0, `dist/index.d.ts` 공개 Export(2026-10-19). 모든 Import는 `from "@hm-design/sw"`이며 CSS는 `@hm-design/sw/styles.css` 한 개입니다. 이 표에 없는 이름은 공개 API가 아닙니다. + +- **단독 사용:** `SWHeader` 밖에서는 `` 안에 둡니다(토큰·글꼴·버튼 초기화). +- **상태 소유:** 값은 Props, 사용자 행동은 Callback입니다. 컴포넌트는 CAD 데이터를 바꾸지 않습니다. 호스트가 Callback을 받아 Props를 갱신합니다. +- **열림 상태:** Dropdown은 `open` / `onOpenChange`(Controlled)를 주지 않으면 내부 상태로 동작합니다. +- **공통 키보드:** Click·Enter·Space 열기/닫기(Hover로는 열리지 않음), ArrowDown 열고 첫(현재) 항목 Focus, Arrow 이동, Escape·바깥 클릭·Tab 이탈 닫기(Escape는 Trigger로 Focus 복귀). +- **검증 표시:** ✅ = 2026-10-19 독립 소비 프로젝트(Tarball 설치)에서 브라우저 검증, ◻ = 이번 소비 검증 범위 밖(기존 Storybook 검증 기록만 있음). + +## 1. 완성형 템플릿 + +### `SWHeader` ✅ +SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체 수정 영역(그리기모드, 레이어/색상/선/문자/치수스타일, 상세보기) · 창 영역(인쇄·K, 창 이동, 최소화, 종료)과 반응형 More, 속성Bar, 속성창을 포함합니다. + +| 구분 | Props / Callback | +|---|---| +| 필수 | `documentTitle: string` | +| 구성 | `hiddenControls?: SWHeaderHideableControlId[]`(로고 외 부품 제거, `home` 제거 시 정적 로고), `menuOrder?: SWHeaderMenuId[]`(5개 메뉴끼리 순서), `controls?: SWHeaderControlConfig[]`(객체 영역 전체 allowlist·순서·label) | +| 로고 | `logo?: ReactNode`, `logoSrc?: string`, `logoAlt?: string`, `onHomeClick` | +| 파일 | `documents`, `activeDocumentId`, `documentMenuOpen`, `onDocumentMenuOpenChange`, `onDocumentSelect`, `onDocumentClose`, `onDocumentNew`, `onDocumentOpen`, `onDocumentClick`, `documentIcon`, `documentNewLabel`, `documentOpenLabel` | +| 레이어 | `layer`, `layers`, `activeLayerId`, `layerMenuOpen`, `onLayerMenuOpenChange`, `onLayerSelect`, `onLayerVisibilityChange`, `onLayerFreezeChange`, `onLayerLockChange`, `onLayerDetail`, `onLayerOpen` | +| 색상 | `color`, `colors`, `userColors`, `colorMode`, `colorMenuOpen`, `onColorMenuOpenChange`, `onColorSelect`, `onColorModeChange`, `onColorInput`, `onColorPick`, `onColorDetail`, `onColorOpen` | +| 선스타일 | `lineStyle`, `lineTypes`, `lineWeights`, `activeLineTypeId`, `activeLineWeightId`, `lineStyleMenuOpen`, `onLineStyleMenuOpenChange`, `onLineTypeSelect`, `onLineWeightSelect`, `onLineWeightValueChange`, `onLineScaleChange`, `onLineStyleDetail`, `onLineStyleOpen` | +| 문자/치수 | `textStyle`, `textStyles`, `activeTextStyleId`, `textStyleMenuOpen`, `onTextStyleMenuOpenChange`, `onTextStyleSelect`, `onTextStyleDetail`, `onTextStyleOpen` / `dimensionStyle`, `dimensionStyles`, `activeDimensionStyleId`, `dimensionStyleMenuOpen`, `onDimensionStyleMenuOpenChange`, `onDimensionStyleSelect`, `onDimensionStyleDetail`, `onDimensionStyleOpen` | +| 상세 | `propertyBar`, `propertyBarOpen`, `defaultPropertyBarOpen`, `onPropertyBarOpenChange`, `onPropertyBarToggle`, `propertyPanel`, `propertyPanelOpen`, `onPropertyPanelOpenChange`, `onPropertyPanelToggle` | +| 창 | `onPrint`, `onPopupGuide`, `onWindowMoveStart(PointerEvent)`, `onMinimize`, `onClose` | +| 기타 | `activeMenu`, `disabled`(창 버튼 제외), `drawingModeLabel`, `tools?: ReactNode`(객체 영역 메뉴 뒤), `
` HTML 속성·`ref` | + +- 데이터 배열(`layers`, `colors`, `lineTypes`/`lineWeights`, `textStyles`, `dimensionStyles`, `documents`)을 주지 않으면 해당 필드는 Dropdown 없는 버튼입니다. +- 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`). + +```tsx + setId(l.id)} onClose={closeWindow} /> +``` + +## 2. 개별 컨트롤 (조합용) + +| 컴포넌트 | 용도 | 주요 Props / Callback | 크기(Figma) | 상태 | 검증 | +|---|---|---|---|---|---| +| `SWTheme` | 단독 사용 범위(토큰·글꼴·초기화). 배치는 정하지 않음 | `div` HTML 속성, `className`, `style`(토큰 override) | — | — | ✅ | +| `SWHeaderHome` | 로고 슬롯 + 홈 버튼 | `logo`, `logoSrc`, `logoAlt`, `onClick`, `interactive`(false = 정적 로고) | 80 × 48, 로고 슬롯 32 × 17, 오른쪽 −17px로 다음 파일 면과 겹침 | Hover 배경 교체, Focus | ✅ | +| `SWHeaderDocumentTitle` | 파일명 + 문서 Dropdown(+ New / Open) | `title`(필수), `documents`, `activeDocumentId`, `menuOpen`, `onMenuOpenChange`, `onDocumentSelect`, `onDocumentClose`, `onNew`, `onOpen`, `onClick`, `documentIcon`, `newLabel`, `openLabel`, `label` | 높이 48, 폭 190–310(반응형 clamp), 메뉴 310 | 텍스트만 Hover #FFC600, 열림 화살표 회전 | ✅ | +| `SWHeaderLayerMenu` | 레이어 필드 + Dropdown | `value`(필수), `layers`(필수), `activeLayerId`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onLayerSelect`, `onVisibilityChange`, `onFreezeChange`, `onLockChange`, `onDetail`, `label` | 필드 234 × 38, 메뉴 234 | Hover 링, Selected, Expanded, 행 Hover | ✅ | +| `SWHeaderColorMenu` | 색상 필드 + Dropdown(ByLayer/ByBlock, 팔레트, 사용자 색, 추출, RGB 입력) | `value`, `colors`(필수), `userColors`, `mode`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onColorSelect`, `onModeChange`, `onColorInput`, `onPick`, `onDetail`, `label` | 필드 192 × 38 | 칩 선택 시 열림 유지 | ✅ | +| `SWHeaderLineStyleMenu` | 선스타일 필드 + 3열 Dropdown | `value`, `lineTypes`, `lineWeights`, `activeLineTypeId`, `activeLineWeightId`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onLineTypeSelect`, `onLineWeightSelect`, `onLineWeightValueChange`, `onScaleChange`, `onDetail`, `label` | 필드 284 × 38, 열 182·142·142(필드 중앙 정렬) | 열별 선택, 열림 유지 | ✅ | +| `SWHeaderTextStyleMenu` | 문자스타일 필드 + 목록 | `value`, `textStyles`(필수), `activeTextStyleId`, `onTextStyleSelect`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onDetail`, `label` | 필드 164 × 38 | 선택 시 닫힘 | ✅ | +| `SWHeaderDimensionStyleMenu` | 치수스타일 필드 + 목록 | `value`, `dimensionStyles`(필수), `activeDimensionStyleId`, `onDimensionStyleSelect`, 열림·`onDetail`·`label` 동일 | 필드 164 × 38 | 선택 시 닫힘 | ✅ | +| `SWHeaderLayerControl` · `SWHeaderColorControl` · `SWHeaderLineStyle` · `SWHeaderTextStyle` · `SWHeaderDimensionStyle` | Dropdown 없는 필드 버튼(호스트가 자체 팝업을 열 때) | `value`(필수), `label`, `selected`, `disabled`, `onClick`, `expanded`, `controls`, `buttonRef` / `SWHeaderLayerControl`은 `state`(표시·동결·잠금) | 위 필드 크기와 같음 | Hover 링, Selected | ◻ | +| `SWHeaderActions` | 인쇄·K 버튼 pill | `onPrint`, `onPopupGuide`, `disabled`, `showPrint`, `showPopupGuide`(하나만 남기면 pill이 축소) | pill 85 × 42(버튼 40 × 38) | 아이콘 Hover, Hover 링, K 가이드 미리보기(반복 Motion, Reduced Motion 정적) | ✅ | +| `SWHeaderWindowControls` | 창 이동·최소화·종료 | `onWindowMoveStart`, `onMinimize`, `onClose`, `showWindowMove`, `showMinimize`, `showClose` | 이동 88 × 48, 버튼 32 × 48 | 창 이동 Hover Motion 1.4s, 종료 Hover 자산 | ◻ | +| `SWHeaderProperties` | 상세보기 / 속성Bar · 속성창 토글 | `barOpen`, `panelOpen`, `panelId`, `disabled`, `onBarToggle`, `onPanelToggle`, `showCaption`, `showBar`, `showPanel` | 157 × 38 | 텍스트 Hover #FFC600 | ◻ | +| `SWHeaderPropertyBar` | 28px 속성 편집 띠 | `selection`, `properties`, `onPropertyChange`, `readOnly`, `disabled`, `div` 속성 | 높이 28, 콘텐츠 폭 | Input 상태 | ◻ | +| `SWHeaderPropertyPanel` | 속성창(13개 필드 타입, 그룹, 형상 Slot) | `selection`, `properties`, `groups`, `onPropertyChange`, `menus`, `expandedGroupIds`/`defaultExpandedGroupIds`/`onExpandedGroupsChange`, `toolActive`/`onToolActiveChange`, `toolLabel`, `onOverviewClick`, `shapeContent`, `onClose`, `id`, `anchorRef`, `style` | 고정 위치 Overlay(Portal·Dim 없음) | 4가지 선택 상태 | ◻ | +| `SWHeaderInput` | 수정 가능한 SW Input | `value`(필수), `onValueChange`, `onCommit`(Enter·Blur, 유효 + 변경 시), `onCancel`(Escape 복원), `valueType`(`text`/`number`), `parse`, `readOnly`, `disabled`, `commitOnUnmount`, `input` 속성 | 58 × 20 기준, Hover/편집 24px | Default/Hover/편집/ReadOnly/Disabled, IME 조합 중 Enter 무시 | ✅ | +| `parseSWHeaderColor` · `parseSWHeaderNumber` | Input 검증 도우미 | `r,g,b` / `#rrggbb`, 숫자(`min`, `max`) | — | — | ◻ | +| `SWHeaderLinePreview` · `swHeaderLinePatterns` | 70 × 2 선 미리보기, CENTER/HIDDEN 패턴 | `pattern?: number[]` | 70 × 2 | — | ◻(패턴 상수만 예제 데이터에서 사용) | + +## 3. 공개 타입 + +`SWHeaderProps`, 각 컴포넌트 `*Props`, `SWThemeProps`, `SWHeaderMenuId`, `SWHeaderControlId`, `SWHeaderControlConfig`, `SWHeaderFrameControlId`, `SWHeaderHideableControlId`, `SWHeaderDocument`, `SWHeaderLayer`, `SWHeaderLayerValue`, `SWHeaderColorValue`, `SWHeaderColorOption`, `SWHeaderColorMode`, `SWHeaderColorInputValue`, `SWHeaderLineStyleValue`, `SWHeaderLineType`, `SWHeaderLineWeight`, `SWHeaderTextStyleValue`, `SWHeaderTextStyleOption`, `SWHeaderDimensionStyleValue`, `SWHeaderDimensionStyleOption`, `SWHeaderInputValueType`, `SWHeaderProperty`, `SWHeaderPropertyType`, `SWHeaderPropertyPicker`, `SWHeaderPropertyMenus`, `SWHeaderPropertyGroup`, `SWHeaderPropertySelection`, `SWHeaderPropertyShapeContent`, `SWHeaderPropertyShapeContext`. + +## 4. 공개하지 않은 것 (사용 금지) + +`SWHeaderMore`, `SWHeaderField`/`SWHeaderFieldText`, `SWHeaderKGuide`, `useSWHeaderDropdown`, `SWHeaderMenuDetail`, `SWHeaderColorChip`, `SWHeaderLayerRows`, `SWHeaderColorPanelBody`, `SWHeaderLineStyleColumns`, 속성창 내부 Picker, `useSWHeaderOverlay` 등은 내부 구현입니다. 필요하면 공개 컴포넌트로 대체하거나, 부족한 API로 보고합니다. + +## 5. 알려진 단독 사용 제약 + +| 항목 | 내용 | 대응 | +|---|---|---| +| 반응형 More | `SWHeader` 전용. 조합 Header에서는 More·필드 이동이 없음 | 조합 Header의 폭은 소비 앱이 정함(필요 없는 컨트롤을 빼거나 가로 공간 확보) | +| `SWHeaderDocumentTitle` 왼쪽 테두리 | 앞의 로고와 겹치는 전제로 왼쪽 테두리가 없음 | 로고 없이 첫 칸이면 소비 레이아웃 wrapper에 1px 테두리(예제 C) | +| `SWHeaderDocumentTitle` 폭 | `clamp(190px, 100cqw − 1610px, 310px)`; Header 밖에서는 cqw가 뷰포트 기준 | flex 부모 안에 둔다. 1920 뷰포트에서 310px | +| 창 영역 배경 | 배경·테두리는 `SWHeader`의 창 영역에 있음 | 소비 레이아웃이 토큰으로 표면 작성(예제 B·C) | +| Dropdown 위치 | `SWHeader` 안이면 헤더, 밖이면 뷰포트 안으로 좌우 보정 | 부모에 `overflow: hidden`을 두지 않는다 | diff --git a/packages/sw/ai/COMPOSITION_PATTERNS.md b/packages/sw/ai/COMPOSITION_PATTERNS.md new file mode 100644 index 0000000..179a409 --- /dev/null +++ b/packages/sw/ai/COMPOSITION_PATTERNS.md @@ -0,0 +1,82 @@ +# SW 조합 패턴 + +모든 패턴은 공개 Export만 사용합니다. 전체 코드는 [examples/](./examples)에 있으며, 설치된 패키지에서 꺼낸 그대로 독립 React 19 + TypeScript + Vite 프로젝트의 `tsc`·Production Build·브라우저 검증을 통과했습니다(2026-10-19). + +## 0. 어떤 패턴을 고를까 + +| 요청 | 패턴 | +|---|---| +| 기존 SW Header 전체(일부 부품 제거·메뉴 순서 변경 포함) | 1. 완성형 | +| 기존 컨트롤 일부만, 다른 배치·순서 | 2. 조합 Header | +| 기존 Dropdown을 툴바·패널 등 다른 곳에 | 3. 개별 컨트롤 | +| 패키지에 없는 기능 | 4. 확장(소비 프로젝트 신규 컴포넌트) | + +## 1. 완성형 SW Header — [ExampleA-DefaultHeader.tsx](./examples/ExampleA-DefaultHeader.tsx) + +```tsx + +``` + +- 부품 제거: `hiddenControls={["print", "popupGuide"]}`(로고는 항상 표시, `"home"`은 정적 로고). +- 메뉴 순서: `menuOrder={["color", "layer"]}`(5개 메뉴끼리, 누락분은 기본 순서로 뒤에). +- 객체 영역 전체를 직접 나열: `controls={[{ id: "document" }, { id: "layer" }, { id: "print" }]}`. +- 추가 도구: `tools={}`(객체 영역 메뉴 뒤). 새 도구는 아래 4의 규칙을 따른다. +- 이 경우에만 반응형 More가 동작합니다. + +## 2. 조합 Header (간소화·기능 중심) + +``을 Header 막대로 쓰고, 막대·면 배치 CSS는 소비 프로젝트가 작성합니다([example-layout.css](./examples/example-layout.css)). 이 CSS는 `--hm-sw-header-*` 토큰만 읽고 `hm-sw-*` class를 건드리지 않습니다. + +```tsx + {/* 49px, #060606, flex, gap 2px */} + {/* 80px, 다음 파일 면과 겹침 */} + +
{/* 객체 수정 면 토큰 */} + + +
+
{/* 창 영역 면 토큰 */} + +
+
+``` + +- 간소화 Viewer: [ExampleB-ViewerHeader.tsx](./examples/ExampleB-ViewerHeader.tsx) — 로고 → 파일 → 레이어 → 색상 → K. +- 기능 중심: [ExampleC-FeatureHeader.tsx](./examples/ExampleC-FeatureHeader.tsx) — 파일 → 선 → 문자 → 치수스타일 → 인쇄. +- 순서는 JSX 순서입니다(DOM·Tab 순서 동일). CSS `order`로 바꾸지 않습니다. +- 로고 없이 파일 면이 처음이면 왼쪽 테두리가 없으므로 wrapper에 1px 테두리를 둡니다(예제 C `app-sw-file-edge`). +- K만/인쇄만: `SWHeaderActions`의 `showPrint={false}` / `showPopupGuide={false}`. +- 창 제어: ``(창 영역 면 안). +- 조합 Header에는 반응형 More가 없습니다. 좁은 화면이 필요하면 완성형을 쓰거나 표시할 컨트롤을 줄입니다. + +## 3. 기존 Dropdown을 다른 레이아웃에서 + +```tsx + + + +``` + +- Dropdown은 필드 바로 아래에 열리고 뷰포트 안으로 좌우 보정됩니다. 부모에 `overflow: hidden`을 두지 않습니다. +- Controlled가 필요하면 `open` / `onOpenChange`를 함께 줍니다. +- Dropdown이 필요 없고 호스트가 자체 팝업을 열면 `SWHeaderLayerControl` 등 필드 버튼만 씁니다. + +## 4. 확장 — 없는 컨트롤 새로 만들기 — [SWSearchControl.tsx](./examples/SWSearchControl.tsx), [ExampleD-SearchExtension.tsx](./examples/ExampleD-SearchExtension.tsx) + +요청 예: "레이어와 색상은 기존 SW 컴포넌트, 검색 버튼은 SW 디자인으로 새로." + +1. 카탈로그 검색: 검색 컴포넌트 없음(MISSING). +2. 조합 검토: 텍스트 입력·확정·취소·IME는 `SWHeaderInput`으로 충족 → 재사용. 필드 면과 검색 아이콘 버튼은 공개 컴포넌트가 없음 → 신규. +3. 신규 부분은 **소비 프로젝트**에 작성: 38px 속성 필드 면, 12px 라벨, gradient Hover 링, 아이콘 버튼은 글자색 Hover `#FFC600`. 모든 값은 토큰([sw-search-control.css](./examples/sw-search-control.css)). +4. 기존 컨트롤과 같은 면(`app-sw-surface`)에 나란히 배치. +5. 검증: 레이어 필드와 높이·위치·테두리·radius·배경·그림자·라벨 크기 일치, Hover 링, Enter 확정 → `onSearch`, Escape 복원. + +새 컨트롤은 패키지 공식 컴포넌트로 자동 등록하지 않습니다. 여러 프로젝트에서 반복되면 SW 패키지 승격을 별도로 검토합니다. + +## 5. 하지 말 것 + +- `hm-sw-*` class를 소비 코드에 쓰거나 CSS로 덮어쓰기 +- `dist` 구현·`styles.css` 블록 복사로 같은 컴포넌트 재작성 +- `@hm-design/sw/dist/...`·`src/...` 내부 경로 Import +- 문서에 없는 Props 추측, 새 색·크기 값 도입 +- 같은 화면에 `SWHeader`와 조합 Header를 겹쳐 두 개의 Header 만들기 diff --git a/packages/sw/ai/DESIGN_RULES.md b/packages/sw/ai/DESIGN_RULES.md new file mode 100644 index 0000000..b3323bc --- /dev/null +++ b/packages/sw/ai/DESIGN_RULES.md @@ -0,0 +1,94 @@ +# SW 디자인 규칙 + +출처: `@hm-design/sw` `styles.css`(원본 `src/theme/sw.css`)의 토큰과 규칙, Figma 이지빔 SW `rj6wpsfk24lOWvWiVRAANP` node `26:8944`와 사용자 확정값. 이 문서에 없는 값은 확정값이 아닙니다. 새 컴포넌트에 임의 값을 만들지 말고 아래 토큰을 `var(--…)`로 읽으세요. + +- 토큰은 `.hm-sw-header`와 `.hm-sw-theme`(``)에만 정의됩니다. 새 컴포넌트는 그 안에 렌더링합니다. +- SW는 `@hm-design/ui` 토큰을 쓰지 않습니다. UI 패키지 토큰을 복제하거나 섞지 않습니다. +- Light/Dark 분기 없음. 전역 선택자(`html`, `body`, `*`) 없음. + +## 1. 크기·간격 + +| 항목 | 값 | 토큰 | +|---|---|---| +| Header 높이 / 면 높이 | 49px / 48px | `--hm-sw-header-height` / `--hm-sw-header-bar-height` | +| 면 사이 간격 | 2px | (Header bar `gap`) | +| 필드 높이 | 38px(면 안 위아래 5px) | `--hm-sw-header-field-height` | +| 필드 사이 간격 | 4px | `--hm-sw-header-gap` | +| 로고 슬롯 | 32 × 17 (홈 80 × 48 안 12, 16) | `--hm-sw-header-logo-width` / `-height` | +| 필드 폭 | 레이어 234, 색상 192, 선스타일 284, 문자·치수스타일 164 | — | +| 상세보기 / 창 영역 | 157 × 38 / 240 × 48(pill 85 × 42, 창 이동 88, 버튼 32 × 2) | — | +| 필드 내부 | padding 0 10px 0 11px, 텍스트 사이 12px | — | + +## 2. 색·표면 + +| 역할 | 값 | 토큰 | +|---|---|---| +| Header 배경 | `#060606` | `--hm-sw-header-background` | +| 기본 글자 / 제목 | `#F6F4F2` / `#F0F7F5` | `--hm-sw-header-text-color` / `--hm-sw-header-title-color` | +| Selected·On·Hover 글자 | `#FFC600` | `--hm-sw-header-selected-color` | +| 화살표 | `#47AF91` | `--hm-sw-header-arrow-color` | +| 파일명 면 | 세로 gradient, 테두리 `#0E4C3A` | `--hm-sw-header-document-background`, `--hm-sw-header-document-border-color` | +| 객체 수정 면 | gradient, 테두리 `#135643`, radius 6px | `--hm-sw-header-object-background`, `-object-border-color`, `-object-radius` | +| 속성 필드 면 | 검정 45–40% gradient, 테두리 `#08251C`, radius 3px, shadow `0 1px 4px rgba(9,39,30,.7)` | `--hm-sw-header-property-background`, `-property-border-color`, `-property-radius`, `-property-shadow` | +| 인쇄·K pill | `rgba(0,0,0,.8)`, 테두리 `#092920` | `--hm-sw-header-pill-background`, `-pill-border-color` | +| 문서 Dropdown | `#103127`, 테두리 `#041611` | `--hm-sw-header-document-menu-*` | +| 필드 Dropdown(레이어·색상·선·문자·치수) | `#27241D`, 테두리 `#000`, radius 3px, 행 Hover `#62533D` + 글자 `#FF5C00` | `--hm-sw-header-layer-menu-*`, `--hm-sw-header-layer-row-hover-*` | +| Input | 글자 `#E60403`, 편집 `#0F0D0A` | `--hm-sw-header-input-*` | +| Tooltip | `rgba(0,0,0,.8)` / `#FFF` | `--hm-sw-header-tooltip-*` | + +사용자 확정 면 3개(파일명·객체 수정·속성 필드)는 기본값 유지를 권장합니다. + +## 3. Typography + +| 항목 | 값 | +|---|---| +| 글꼴 | Noto Sans KR → Malgun Gothic → Apple SD Gothic Neo (Figma Dotum 대체, 의도된 결정). 폰트 파일은 앱이 로드 | +| 기본 / 라벨 | 14px / 12px (`--hm-sw-header-font-size` / `--hm-sw-header-label-size`) | +| Weight | 400, 500, 700만 사용(600 없음). 값·제목·토글 700 | +| 자간 | 값 0.04em, 라벨 0.02em, 파일명 0.06em | +| Text Stroke | Figma Stroke가 있는 텍스트만 검정 외곽선: 14·16px `--hm-sw-header-text-outline`(1.3px), 12px `--hm-sw-header-label-outline`(1px). 자기 색 Stroke 없음. More·Tooltip·`+ New`/`Open` 제외 | +| 말줄임 | 값은 한 줄 `ellipsis`, `title`로 전체 표시 | + +## 4. 상태 + +| 상태 | 규칙 | +|---|---| +| Hover(테두리 있는 버튼·필드·More·인쇄·K) | 1px 테두리가 노란 세로 gradient로 바뀜(`--hm-sw-header-hover-border-gradient`, 폭 `--hm-sw-header-hover-border-width`). 크기·배경·그림자 변화 없음, 자기 Dropdown이 열린 필드는 Hover 없음 | +| Hover(파일명, 속성Bar/속성창 토글) | 글자만 `#FFC600`. 링·배경 없음 | +| Hover(필드 미리보기) | 텍스트·선/폰트/치수 미리보기 `#FFC600` | +| Selected / On | 글자·단색 아이콘 `#FFC600`(`data-selected="true"`), 배경·테두리 변화 없음 | +| Expanded(Dropdown 열림) | 필드 테두리 `#081813`, 아래 모서리 직각, 필드+목록을 감싸는 1px 노란 링 | +| Focus | 2px `#F0F7F5` outline, offset −2px(`:focus-visible`) | +| Disabled | opacity 0.4, 커서 기본. 창 버튼은 `disabled` 영향 없음 | +| 목록 행 | Hover와 Selected가 같은 강조(`#62533D` + `#FF5C00`) | + +## 5. Overlay·Dropdown + +- 필드 Dropdown은 필드 바로 아래(`top: 100%`)에 겹쳐 열립니다. Portal·Dim 없음, `z-index` 30. 화면(또는 Header) 밖으로 나가면 좌우만 보정합니다. 부모에 `overflow: hidden`을 두지 않습니다. +- 목록 최대 높이 240px(8행), 넘치면 내부 스크롤. 색상 팔레트 118px. +- 속성창·K 가이드는 `position: fixed` Overlay(뷰포트 안 보정, Portal·Dim 없음). +- 한 번에 하나의 Dropdown(SWHeader). 단독 사용 시 각 컴포넌트가 바깥 클릭으로 스스로 닫힙니다. + +## 6. Motion + +| 대상 | 규칙 | +|---|---| +| 창 이동 Hover | 1.4s 반복(커서 상승 → 약 444ms 문구 → 오른쪽 위 이동 → 약 888ms부터 유지). Drag(`:active`) 중 정지. Reduced Motion: 원본 아이콘과 정적 Tooltip | +| K 가이드 | 원본 이미지 Pan/Zoom 반복(400ms 대기 후 1022·1022·511·511ms 전환). Reduced Motion 정적 | +| 레이어 토글 | 배경·아이콘 300ms ease-out | +| 기타 | Hover·Selected는 즉시 전환(Transition 없음) | + +## 7. 반응형 (`SWHeader` 전용) + +Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타일 → More, **< 1180** 그리기모드 숨김, **< 1080** 상세보기 → More·창 이동 24px(창 영역 176), **< 900** 색상 → More, **< 640** 인쇄·K → More·창 이동 숨김(창 영역 64), **< 520** 레이어 상태 아이콘 숨김. More에서 고른 필드는 첫 칸(레이어 자리)에 표시되고 Dropdown이 바로 열립니다(UI 상태만, 데이터 Callback 없음). + +조합 Header(개별 컴포넌트)에는 이 규칙이 적용되지 않습니다. 필요한 컨트롤만 배치하고 폭 정책은 소비 앱이 정합니다. + +## 8. 새 컴포넌트 체크리스트 + +1. ``(또는 `SWHeader`의 `tools`) 안에서 렌더링한다. +2. 표면·크기·색은 위 토큰만 쓴다. 필드형이면 38px·속성 필드 면·12px 라벨 + 14px 700 값. +3. Hover/Focus/Disabled/Selected를 §4 규칙대로 구현한다(테두리형은 gradient 링, 텍스트형은 글자만 `#FFC600`). +4. 입력이 필요하면 `SWHeaderInput`을 재사용한다. +5. 값은 Props, 동작은 Callback. CAD 데이터를 내부에서 바꾸지 않는다. +6. `hm-sw-*` class를 새로 쓰거나 덮어쓰지 않는다(자기 접두사 사용, 예: `app-`). diff --git a/packages/sw/ai/examples/ExampleA-DefaultHeader.tsx b/packages/sw/ai/examples/ExampleA-DefaultHeader.tsx new file mode 100644 index 0000000..5ffdc9b --- /dev/null +++ b/packages/sw/ai/examples/ExampleA-DefaultHeader.tsx @@ -0,0 +1,40 @@ +// Example A — reuse (1st priority): the complete SW Header, unchanged. Existing projects keep this code. +import { useState } from "react" +import { SWHeader, type SWHeaderColorMode, type SWHeaderColorValue } from "@hm-design/sw" +import { colors, dimensionStyles, documents, layers, lineTypes, lineWeights, textStyles } from "./sample-data" + +export function ExampleADefaultHeader({ onEvent }: { onEvent?: (name: string) => void }) { + const [activeDocumentId, setActiveDocumentId] = useState(documents[0].id) + const [activeLayerId, setActiveLayerId] = useState("0") + const [color, setColor] = useState({ value: "ByLayer", swatch: "#ffffff" }) + const [colorMode, setColorMode] = useState("byLayer") + const log = (name: string) => () => onEvent?.(name) + return ( + item.id === activeDocumentId)?.name ?? ""} + logoAlt="예제 SW" + documents={documents} + activeDocumentId={activeDocumentId} + onDocumentSelect={document => { setActiveDocumentId(document.id); onEvent?.(`document:${document.id}`) }} + onDocumentNew={log("document:new")} + onDocumentOpen={log("document:open")} + layers={layers} + activeLayerId={activeLayerId} + onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }} + color={color} + colors={colors} + colorMode={colorMode} + onColorSelect={option => { setColor({ value: option.label ?? option.color, swatch: option.color }); setColorMode(null); onEvent?.(`color:${option.id}`) }} + onColorModeChange={mode => { setColorMode(mode); onEvent?.(`colorMode:${mode}`) }} + lineTypes={lineTypes} + lineWeights={lineWeights} + textStyles={textStyles} + dimensionStyles={dimensionStyles} + onHomeClick={log("home")} + onPrint={log("print")} + onPopupGuide={log("popupGuide")} + onMinimize={log("minimize")} + onClose={log("close")} + /> + ) +} diff --git a/packages/sw/ai/examples/ExampleB-ViewerHeader.tsx b/packages/sw/ai/examples/ExampleB-ViewerHeader.tsx new file mode 100644 index 0000000..ce40206 --- /dev/null +++ b/packages/sw/ai/examples/ExampleB-ViewerHeader.tsx @@ -0,0 +1,50 @@ +// Example B — composition (2nd priority): a simplified viewer header +// Logo → File → Layer → Color → K +// Only public components; the layout is the consumer's (example-layout.css) and provides tokens. +import { useState } from "react" +import { + SWHeaderActions, SWHeaderColorMenu, SWHeaderDocumentTitle, SWHeaderHome, SWHeaderLayerMenu, SWTheme, + type SWHeaderColorMode, type SWHeaderColorValue, +} from "@hm-design/sw" +import { colors, documents, layers } from "./sample-data" +import "./example-layout.css" + +export function ExampleBViewerHeader({ onEvent }: { onEvent?: (name: string) => void }) { + const [activeDocumentId, setActiveDocumentId] = useState(documents[0].id) + const [activeLayerId, setActiveLayerId] = useState("0") + const [color, setColor] = useState({ value: "ByLayer", swatch: "#ffffff" }) + const [colorMode, setColorMode] = useState("byLayer") + return ( + + onEvent?.("home")} /> + item.id === activeDocumentId)?.name ?? ""} + documents={documents} + activeDocumentId={activeDocumentId} + onDocumentSelect={document => { setActiveDocumentId(document.id); onEvent?.(`document:${document.id}`) }} + onNew={() => onEvent?.("document:new")} + onOpen={() => onEvent?.("document:open")} + /> +
+ { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }} + onVisibilityChange={(layer, next) => onEvent?.(`layer-visible:${layer.id}:${next}`)} + /> + { setColor({ value: option.label ?? option.color, swatch: option.color }); setColorMode(null); onEvent?.(`color:${option.id}`) }} + onModeChange={mode => { setColorMode(mode); onEvent?.(`colorMode:${mode}`) }} + /> +
+
+ {/* K only: the existing actions pill with print removed. */} + onEvent?.("popupGuide")} /> +
+
+ ) +} diff --git a/packages/sw/ai/examples/ExampleC-FeatureHeader.tsx b/packages/sw/ai/examples/ExampleC-FeatureHeader.tsx new file mode 100644 index 0000000..40d07e4 --- /dev/null +++ b/packages/sw/ai/examples/ExampleC-FeatureHeader.tsx @@ -0,0 +1,46 @@ +// Example C — composition (2nd priority): a feature-focused header in a different order +// File → Line style → Text style → Dimension style → Print +import { useState } from "react" +import { + SWHeaderActions, SWHeaderDimensionStyleMenu, SWHeaderDocumentTitle, SWHeaderLineStyleMenu, SWHeaderTextStyleMenu, SWTheme, + type SWHeaderDimensionStyleValue, type SWHeaderLineStyleValue, type SWHeaderTextStyleValue, +} from "@hm-design/sw" +import { dimensionStyles, lineTypes, lineWeights, textStyles } from "./sample-data" +import "./example-layout.css" + +export function ExampleCFeatureHeader({ onEvent }: { onEvent?: (name: string) => void }) { + const [lineStyle, setLineStyle] = useState({ name: "ByLayer", weight: "ByLayer", scale: "1.00" }) + const [textStyle, setTextStyle] = useState({ font: "Arial", name: "Standard" }) + const [dimensionStyle, setDimensionStyle] = useState({ name: "Standard" }) + return ( + + {/* No logo before the file well, so the consumer layout gives it its own left edge. */} +
+ onEvent?.("document")} /> +
+
+ { setLineStyle(current => ({ ...current, name: type.name })); onEvent?.(`lineType:${type.id}`) }} + onLineWeightSelect={weight => { setLineStyle(current => ({ ...current, weight: weight.name })); onEvent?.(`lineWeight:${weight.id}`) }} + onScaleChange={scale => { setLineStyle(current => ({ ...current, scale })); onEvent?.(`lineScale:${scale}`) }} + /> + { setTextStyle({ font: style.font, name: style.name }); onEvent?.(`textStyle:${style.id}`) }} + /> + { setDimensionStyle({ name: style.name }); onEvent?.(`dimensionStyle:${style.id}`) }} + /> +
+
+ onEvent?.("print")} /> +
+
+ ) +} diff --git a/packages/sw/ai/examples/ExampleD-SearchExtension.tsx b/packages/sw/ai/examples/ExampleD-SearchExtension.tsx new file mode 100644 index 0000000..2cdebbe --- /dev/null +++ b/packages/sw/ai/examples/ExampleD-SearchExtension.tsx @@ -0,0 +1,30 @@ +// Example D — existing Layer / Color components next to a NEW consumer control (SWSearchControl). +import { useState } from "react" +import { SWHeaderColorMenu, SWHeaderLayerMenu, SWTheme, type SWHeaderColorValue } from "@hm-design/sw" +import { colors, layers } from "./sample-data" +import { SWSearchControl } from "./SWSearchControl" +import "./example-layout.css" + +export function ExampleDSearchExtension({ onEvent }: { onEvent?: (name: string) => void }) { + const [activeLayerId, setActiveLayerId] = useState("0") + const [color, setColor] = useState({ value: "ByLayer", swatch: "#ffffff" }) + const [query, setQuery] = useState("") + return ( + +
+ { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }} + /> + { setColor({ value: option.label ?? option.color, swatch: option.color }); onEvent?.(`color:${option.id}`) }} + /> + onEvent?.(`search:${text}`)} /> +
+
+ ) +} diff --git a/packages/sw/ai/examples/SWSearchControl.tsx b/packages/sw/ai/examples/SWSearchControl.tsx new file mode 100644 index 0000000..77f96ee --- /dev/null +++ b/packages/sw/ai/examples/SWSearchControl.tsx @@ -0,0 +1,41 @@ +// Example D extension — a NEW control written in the consuming project (3rd priority). +// Decision record: the package has no search component (MISSING). Its editable SWHeaderInput covers the +// text entry (Enter / blur commit, Escape cancel, IME), so it is reused; only the field surface and the +// search icon button are new. They read the public SW tokens (sw-search-control.css) — no copied +// package code, no hm-sw-* class, no new design values. Not registered as a package component. +import { SWHeaderInput } from "@hm-design/sw" +import "./sw-search-control.css" + +export interface SWSearchControlProps { + /** Committed query (host state). */ + value: string + /** A new query was committed with Enter or by leaving the input. */ + onValueChange?: (value: string) => void + /** Search with the current query (icon button or a committed query). */ + onSearch?: (query: string) => void + label?: string + disabled?: boolean +} + +export function SWSearchControl({ value, onValueChange, onSearch, label = "검색", disabled }: SWSearchControlProps) { + return ( +
+ + + { onValueChange?.(next); onSearch?.(next) }} + /> + + +
+ ) +} diff --git a/packages/sw/ai/examples/example-layout.css b/packages/sw/ai/examples/example-layout.css new file mode 100644 index 0000000..b55633b --- /dev/null +++ b/packages/sw/ai/examples/example-layout.css @@ -0,0 +1,36 @@ +/* Consumer layout for the composed examples (B, C, D). It belongs to the consuming project: it only + arranges public SW components and reads the public --hm-sw-header-* tokens provided by . + It never styles hm-sw-* classes and adds no new design values. */ +.app-sw-bar { + display: flex; + align-items: stretch; + gap: 2px; + box-sizing: border-box; + height: var(--hm-sw-header-height); + padding-bottom: 1px; /* 48px surfaces in the 49px header, like SWHeader */ + background: var(--hm-sw-header-background); +} +/* Object (editing) surface, the same tokens as the SWHeader object area. */ +.app-sw-surface { + display: flex; + align-items: center; + gap: var(--hm-sw-header-gap); + flex: 1 1 0; + min-width: 0; + padding: 4px; + border: 1px solid var(--hm-sw-header-object-border-color); + border-radius: var(--hm-sw-header-object-radius); + background: var(--hm-sw-header-object-background); +} +/* Window-area surface for print / K / window buttons. */ +.app-sw-window { + display: flex; + flex: 0 0 auto; + background: var(--hm-sw-header-document-background); + box-shadow: inset 0 0 0 1px var(--hm-sw-header-document-border-color); /* 1px edge without moving the parts */ +} +/* File well placed first (no home overlap): its own left edge. */ +.app-sw-file-edge { + display: flex; + border-left: 1px solid var(--hm-sw-header-document-border-color); +} diff --git a/packages/sw/ai/examples/sample-data.ts b/packages/sw/ai/examples/sample-data.ts new file mode 100644 index 0000000..c0261fe --- /dev/null +++ b/packages/sw/ai/examples/sample-data.ts @@ -0,0 +1,52 @@ +// Sample host data for the examples. Every type comes from the public package API. +import { + swHeaderLinePatterns, + type SWHeaderColorOption, type SWHeaderDimensionStyleOption, type SWHeaderDocument, type SWHeaderLayer, + type SWHeaderLineType, type SWHeaderLineWeight, type SWHeaderTextStyleOption, +} from "@hm-design/sw" + +export const documents: SWHeaderDocument[] = [ + { id: "doc-1", name: "C0020101-006-평면및종단면도(06)" }, + { id: "doc-2", name: "C0020101-007-횡단면도(07)" }, +] + +export const layers: SWHeaderLayer[] = [ + { id: "0", name: "0", color: "#ffffff" }, + { id: "center", name: "CENTER", color: "#ff0000" }, + { id: "hidden", name: "HIDDEN", color: "#ffe500", visible: false }, + { id: "dim", name: "DIM", color: "#00ff00", locked: true }, +] + +export const colors: SWHeaderColorOption[] = [ + { id: "white", color: "#ffffff", label: "흰색" }, + { id: "red", color: "#ff0000", label: "빨강" }, + { id: "green", color: "#00ff00", label: "초록" }, + { id: "yellow", color: "#ffff00", label: "노랑" }, + { id: "blue", color: "#0000ff", label: "파랑" }, + { id: "magenta", color: "#ff00ff", label: "자홍" }, + { id: "cyan", color: "#00ffff", label: "청록" }, +] + +export const lineTypes: SWHeaderLineType[] = [ + { id: "bylayer", name: "ByLayer" }, + { id: "continuous", name: "Continuous" }, + { id: "center", name: "CENTER", pattern: swHeaderLinePatterns.center }, + { id: "hidden", name: "HIDDEN", pattern: swHeaderLinePatterns.hidden }, +] + +export const lineWeights: SWHeaderLineWeight[] = [ + { id: "bylayer", name: "ByLayer" }, + { id: "byblock", name: "ByBlock" }, + { id: "default", name: "ByDefault", value: "0.25" }, + { id: "user", name: "User", value: "0.50", editable: true }, +] + +export const textStyles: SWHeaderTextStyleOption[] = [ + { id: "standard", name: "Standard", font: "Arial" }, + { id: "dotum", name: "Dotum", font: "돋움" }, +] + +export const dimensionStyles: SWHeaderDimensionStyleOption[] = [ + { id: "standard", name: "Standard" }, + { id: "iso", name: "ISO-25" }, +] diff --git a/packages/sw/ai/examples/sw-search-control.css b/packages/sw/ai/examples/sw-search-control.css new file mode 100644 index 0000000..fc1e4a3 --- /dev/null +++ b/packages/sw/ai/examples/sw-search-control.css @@ -0,0 +1,48 @@ +/* Consumer extension styles. Every value is a public SW token (see ../DESIGN_RULES.md); the + 38px field height, 12px label / 14px bold value and 1px gradient hover ring follow the SW field rules. */ +.app-sw-search { + position: relative; + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + height: var(--hm-sw-header-field-height); + padding: 0 6px 0 11px; + border: 1px solid var(--hm-sw-header-property-border-color); + border-radius: var(--hm-sw-header-property-radius); + background: var(--hm-sw-header-property-background); + box-shadow: var(--hm-sw-header-property-shadow); +} +/* Hover: the bordered-control rule — the 1px border becomes the yellow gradient; nothing moves. */ +.app-sw-search::after { + content: ""; + position: absolute; + inset: calc(-1 * var(--hm-sw-header-hover-border-width)); + padding: var(--hm-sw-header-hover-border-width); + border-radius: inherit; + background: var(--hm-sw-header-hover-border-gradient); + /* Ring only: the content box is cut out (prefixed first, the standard declaration last wins). */ + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); + opacity: 0; + pointer-events: none; +} +.app-sw-search:hover:not([data-disabled])::after { opacity: 1; } +.app-sw-search__text { display: flex; flex-direction: column; min-width: 0; } +.app-sw-search__label { + height: 14px; + font-size: var(--hm-sw-header-label-size); + line-height: 14px; + letter-spacing: 0.02em; + text-shadow: var(--hm-sw-header-label-outline); +} +.app-sw-search__button { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: var(--hm-sw-header-property-radius); +} +/* Text-only hover / selected color, like the SW toggles. */ +.app-sw-search__button:hover:not(:disabled) { color: var(--hm-sw-header-selected-color); } diff --git a/packages/sw/package.json b/packages/sw/package.json index f27169a..84801af 100644 --- a/packages/sw/package.json +++ b/packages/sw/package.json @@ -15,7 +15,8 @@ "./styles.css": "./dist/styles.css" }, "files": [ - "dist" + "dist", + "ai" ], "scripts": { "typecheck": "tsc -p tsconfig.json", diff --git a/packages/sw/tests/ai-docs.check.mjs b/packages/sw/tests/ai-docs.check.mjs new file mode 100644 index 0000000..e91e172 --- /dev/null +++ b/packages/sw/tests/ai-docs.check.mjs @@ -0,0 +1,53 @@ +// Checks that the shipped AI guide matches the built public API (2026-10-19). +// Usage: node packages/sw/tests/ai-docs.check.mjs (build first: npm run build -w @hm-design/sw) +// 1. Every SW identifier named in ai/*.md is a real export, unless the doc lists it as not public. +// 2. Every import from "@hm-design/sw" in ai/examples is a real export; no deep package import. +// 3. Every relative link in ai/*.md and README.md resolves inside the package (tarball-safe). +import { readdir, readFile } from "node:fs/promises" +import { existsSync } from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const root = fileURLToPath(new URL("../", import.meta.url)) +const declarations = await readFile(path.join(root, "dist/index.d.ts"), "utf8") +const nested = [...declarations.matchAll(/from\s+["'](\.[^"']+)["']/g)].map(match => match[1]) +let exported = declarations +for (const file of nested) exported += await readFile(path.join(root, "dist", file.replace(/\.js$/, ".d.ts")), "utf8") +const names = new Set() +for (const block of exported.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) for (const part of block[1].split(",")) { const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop(); if (name) names.add(name) } +for (const match of exported.matchAll(/export\s+(?:declare\s+)?(?:const|function|interface|type)\s+(\w+)/g)) names.add(match[1]) +// Only names re-exported by the entry are public: collect the entry's re-export lists. +const publicNames = new Set() +for (const block of declarations.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) for (const part of block[1].split(",")) { const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop(); if (name) publicNames.add(name) } +for (const match of declarations.matchAll(/export\s+\*\s+from\s+["']([^"']+)["']/g)) { + const text = await readFile(path.join(root, "dist", match[1].replace(/\.js$/, ".d.ts")), "utf8") + for (const block of text.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) for (const part of block[1].split(",")) { const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop(); if (name) publicNames.add(name) } +} +void names + +const results = [] +const check = (name, ok, data) => { results.push(ok); console.log(ok ? "PASS" : "FAIL", name, data === undefined ? "" : JSON.stringify(data)) } +const docs = (await readdir(path.join(root, "ai"))).filter(name => name.endsWith(".md")) +for (const doc of docs) { + const text = await readFile(path.join(root, "ai", doc), "utf8") + const notPublic = new Set([...(text.split(/^## .*공개하지 않은 것.*$/m)[1]?.split(/^## /m)[0] ?? "").matchAll(/`(\w+)`/g)].map(match => match[1])) + const mentioned = [...new Set([...text.matchAll(/`((?:SW|parseSW|swHeader|useSW)\w+)/g)].map(match => match[1]))] + const unknown = mentioned.filter(name => !publicNames.has(name) && !notPublic.has(name) && !/^SWSearchControl/.test(name)) + check(`${doc}: named SW identifiers are public exports`, unknown.length === 0, { mentioned: mentioned.length, unknown }) + const leaked = [...notPublic].filter(name => publicNames.has(name)) + check(`${doc}: "not public" list contains no export`, leaked.length === 0, leaked) +} +for (const file of (await readdir(path.join(root, "ai/examples"))).filter(name => /\.tsx?$/.test(name))) { + const text = await readFile(path.join(root, "ai/examples", file), "utf8") + const imports = [...text.matchAll(/import\s*\{([^}]*)\}\s*from\s*"@hm-design\/sw"/g)].flatMap(match => match[1].split(",").map(part => part.trim().replace(/^type\s+/, "")).filter(Boolean)) + const deep = /from\s*"@hm-design\/sw\/(?!styles\.css)/.test(text) + check(`examples/${file}: imports only public exports`, !deep && imports.every(name => publicNames.has(name)), { imports, missing: imports.filter(name => !publicNames.has(name)), deep }) +} +for (const doc of [...docs.map(name => path.join("ai", name)), "README.md"]) { + const text = await readFile(path.join(root, doc), "utf8") + const links = [...text.matchAll(/\]\((\.\/[^)#\s]+)/g)].map(match => match[1]) + const broken = links.filter(link => !existsSync(path.resolve(root, path.dirname(doc), link))) + check(`${doc}: package-relative links resolve`, broken.length === 0, { links: links.length, broken }) +} +console.log(`${results.filter(Boolean).length}/${results.length} PASS`) +process.exitCode = results.every(Boolean) ? 0 : 1 diff --git a/packages/sw/tests/ai-template-consumer.browser.mjs b/packages/sw/tests/ai-template-consumer.browser.mjs new file mode 100644 index 0000000..b4b1abf --- /dev/null +++ b/packages/sw/tests/ai-template-consumer.browser.mjs @@ -0,0 +1,180 @@ +// Browser check of the independent consumer built by ai-template-consumer.mjs. +// Usage: node packages/sw/tests/ai-template-consumer.browser.mjs +// Serves /dist and checks examples A–D (rendering, assets, dropdowns, callbacks, focus, styles). +import { createRequire } from "node:module" +import { createServer } from "node:http" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import path from "node:path" + +const dist = path.resolve(process.argv[2], "dist") +const out = path.resolve(process.argv[3]) +const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? "C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright") +await mkdir(out, { recursive: true }) +const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css" } +const server = createServer(async (request, response) => { + const pathname = decodeURIComponent(new URL(request.url, "http://x").pathname) + const file = path.join(dist, pathname === "/" ? "/index.html" : pathname) + let body + try { body = await readFile(file) } catch { response.writeHead(404); response.end(); return } + response.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" }) + response.end(body) +}).listen(0, "127.0.0.1") +await new Promise(resolve => server.once("listening", resolve)) +const base = `http://127.0.0.1:${server.address().port}/` + +const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" }) +const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }) +const report = { checks: [], errors: [], warnings: [] } +page.on("pageerror", error => report.errors.push(error.message)) +page.on("console", message => { if (message.type() === "error") report.errors.push(message.text()); if (message.type() === "warning") report.warnings.push(message.text()) }) +const check = (name, ok, data) => { report.checks.push({ name, status: ok ? "PASS" : "FAIL", data }); console.log(ok ? "PASS" : "FAIL", name, JSON.stringify(data ?? "")) } +const events = async () => (await page.locator("#events").textContent()).split("\n").filter(Boolean) +const lastEvent = async () => (await events()).at(-1) +const section = id => page.locator(`[data-example="${id}"]`) +const rect = (locator) => locator.evaluate(node => { const r = node.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } }) + +await page.goto(base) +await page.locator('[data-example="d"] .app-sw-search').waitFor() +await page.evaluate(() => document.fonts.ready) + +// 1. Assets and CSS +const assets = await page.evaluate(() => [...document.images].map(image => ({ src: image.currentSrc.slice(0, 30), ok: image.complete && image.naturalWidth > 0 }))) +check("1 all SVG/PNG assets decode (data URI, no external path)", assets.length > 0 && assets.every(item => item.ok && item.src.startsWith("data:")), { count: assets.length, broken: assets.filter(item => !item.ok) }) +const tokens = await page.evaluate(() => { const s = getComputedStyle(document.querySelector('[data-example="b"] .hm-sw-theme')); return { height: s.getPropertyValue("--hm-sw-header-height").trim(), font: s.fontFamily } }) +check("1 styles.css loaded; SWTheme provides tokens", tokens.height === "49px" && tokens.font.includes("Noto Sans KR"), tokens) + +// 2. Example A: default header unchanged (Figma 1920 coordinates) +const a = section("a") +const geometry = { + header: await rect(a.locator(".hm-sw-header")), home: await rect(a.locator(".hm-sw-header__home")), + document: await rect(a.locator(".hm-sw-header__document")), object: await rect(a.locator(".hm-sw-header__object-area")), + window: await rect(a.locator(".hm-sw-header__window")), +} +check("2 A default header 1920 geometry = Figma (49 / 0·80 / 65·310 / 377·1301 / 1680·240)", + geometry.header.h === 49 && geometry.home.x === 0 && geometry.home.w === 80 && geometry.document.x === 65 && geometry.document.w === 310 + && geometry.object.x === 377 && geometry.object.w === 1301 && geometry.window.x === 1680 && geometry.window.w === 240, geometry) +await a.locator(".hm-sw-header").screenshot({ path: path.join(out, "A-default-1920.png") }) +await a.locator(".hm-sw-header__field--layer").click() +await a.getByRole("button", { name: "CENTER" }).first().click() +check("2 A layer dropdown select callback", (await lastEvent()) === "layer:center", await lastEvent()) +await a.getByRole("button", { name: "종료" }).click() +check("2 A close callback", (await lastEvent()) === "close", await lastEvent()) + +// 3. Example B: Logo → File → Layer → Color → K +const b = section("b") +const order = await b.locator(".hm-sw-theme").evaluate(bar => [...bar.querySelectorAll(".hm-sw-header__home, .hm-sw-header__document, .hm-sw-header__field, .hm-sw-header__action")].map(node => node.getAttribute("aria-label") ?? node.className.split(" ")[0])) +check("3 B order Logo → File → Layer → Color → K (DOM)", JSON.stringify(order) === JSON.stringify(["뷰어 홈", "hm-sw-header__document", "레이어", "색상", "팝업 가이드"]), order) +const bGeo = { bar: await rect(b.locator(".hm-sw-theme")), home: await rect(b.locator(".hm-sw-header__home")), document: await rect(b.locator(".hm-sw-header__document")), layer: await rect(b.locator(".hm-sw-header__field--layer")), color: await rect(b.locator(".hm-sw-header__field--color")) } +check("3 B sizes keep SW rules (bar 49, home 80 overlapping file 15px, fields 234/192 x 38)", bGeo.bar.h === 49 && bGeo.home.w === 80 && bGeo.document.x - bGeo.home.x === 65 && bGeo.layer.w === 234 && bGeo.layer.h === 38 && bGeo.color.w === 192, bGeo) +await b.locator(".hm-sw-header__document-button").click() +await b.getByRole("menuitem", { name: /New/ }).or(b.getByRole("button", { name: /New/ })).first().click() +check("3 B document dropdown New callback", (await lastEvent()) === "document:new", await lastEvent()) +const layerField = b.locator(".hm-sw-header__field--layer") +await layerField.click() +const layerPanel = b.locator(".hm-sw-header__layer [id], .hm-sw-header__layer-menu").first() +check("3 B layer dropdown opens under the field", await layerField.getAttribute("aria-expanded") === "true", await layerField.getAttribute("aria-expanded")) +await page.screenshot({ path: path.join(out, "B-layer-open.png"), clip: { x: 0, y: (await rect(b)).y, width: 1000, height: 360 } }) +await page.keyboard.press("Escape") +check("3 B Escape closes and returns focus to the field", await layerField.getAttribute("aria-expanded") === "false" && await layerField.evaluate(node => node === document.activeElement), null) +void layerPanel +await b.locator(".hm-sw-header__field--color").click() +await b.getByRole("button", { name: "빨강" }).click() +check("3 B color chip callback (dropdown stays open)", (await lastEvent()) === "color:red" && await b.locator(".hm-sw-header__field--color").getAttribute("aria-expanded") === "true", await lastEvent()) +await page.mouse.click(1800, 900) +const k = b.getByRole("button", { name: "팝업 가이드" }) +await k.hover() +await page.waitForTimeout(500) +const guide = await page.locator(".hm-sw-k-guide__panel").first().isVisible() +check("3 B K guide preview on hover (animation frame advances)", guide && Number(await page.locator(".hm-sw-k-guide__panel").first().getAttribute("data-frame")) >= 1, { guide }) +await page.waitForTimeout(1000) +await page.screenshot({ path: path.join(out, "B-k-guide.png"), clip: { x: 1300, y: (await rect(b)).y, width: 620, height: 340 } }) +await k.click() +check("3 B K click callback", (await lastEvent()) === "popupGuide", await lastEvent()) +await page.mouse.move(1, 1) +await b.locator(".hm-sw-theme").screenshot({ path: path.join(out, "B-viewer-1920.png") }) + +// 4. Example C: File → Line → Text → Dimension → Print +const c = section("c") +const cOrder = await c.locator(".hm-sw-theme").evaluate(bar => [...bar.querySelectorAll(".hm-sw-header__document, .hm-sw-header__field, .hm-sw-header__action")].map(node => node.getAttribute("aria-label") ?? "file")) +check("4 C order File → 선 → 문자 → 치수 → 인쇄", JSON.stringify(cOrder) === JSON.stringify(["file", "선스타일", "문자스타일", "치수스타일", "인쇄"]), cOrder) +const fileEdge = await c.locator(".app-sw-file-edge").evaluate(node => getComputedStyle(node).borderLeftWidth) +check("4 C first file well has a left edge (consumer wrapper)", fileEdge === "1px", fileEdge) +await c.locator(".hm-sw-header__field--line-style").click() +await c.getByRole("button", { name: "CENTER" }).first().click() +check("4 C line type callback + field value", (await lastEvent()) === "lineType:center" && (await c.locator(".hm-sw-header__field--line-style").textContent()).includes("CENTER"), await lastEvent()) +await page.keyboard.press("Escape") +await c.locator(".hm-sw-header__field--text-style").click() +await c.getByRole("button", { name: "Dotum" }).click() +check("4 C text style callback + field value", (await lastEvent()) === "textStyle:dotum" && (await c.locator(".hm-sw-header__field--text-style").textContent()).includes("Dotum"), await lastEvent()) +await c.locator(".hm-sw-header__field--dimension-style").click() +await c.getByRole("button", { name: "ISO-25" }).click() +check("4 C dimension style callback", (await lastEvent()) === "dimensionStyle:iso", await lastEvent()) +await c.getByRole("button", { name: "인쇄" }).click() +check("4 C print callback", (await lastEvent()) === "print", await lastEvent()) +await page.mouse.move(1, 1) +await c.locator(".hm-sw-theme").screenshot({ path: path.join(out, "C-feature-1920.png") }) + +// 5. Example D: existing layer / color + new SWSearchControl +const d = section("d") +const style = await d.locator(".hm-sw-theme").evaluate(bar => { + const pick = node => { const s = getComputedStyle(node), r = node.getBoundingClientRect(); return { h: Math.round(r.height), top: Math.round(r.top), border: `${s.borderTopWidth} ${s.borderTopColor}`, radius: s.borderTopLeftRadius, bg: s.backgroundImage.slice(0, 40), shadow: s.boxShadow } } + return { layer: pick(bar.querySelector(".hm-sw-header__field--layer")), search: pick(bar.querySelector(".app-sw-search")), + label: getComputedStyle(bar.querySelector(".app-sw-search__label")).fontSize, fieldLabel: getComputedStyle(bar.querySelector(".hm-sw-header__field-label")).fontSize } +}) +const same = ["h", "top", "border", "radius", "bg", "shadow"].every(key => style.layer[key] === style.search[key]) +check("5 D new search field matches the SW field surface (height/top/border/radius/background/shadow/label)", same && style.label === style.fieldLabel, style) +// Pixels of an element screenshot: the inside must not change on hover, only the 1px edge turns yellow. +const samplePixels = async (locator) => { + const buffer = await locator.screenshot() + return page.evaluate(async b64 => { + const image = new Image(); image.src = `data:image/png;base64,${b64}`; await image.decode() + const canvas = document.createElement("canvas"); canvas.width = image.width; canvas.height = image.height + const context = canvas.getContext("2d"); context.drawImage(image, 0, 0) + const at = (x, y) => [...context.getImageData(x, y, 1, 1).data].slice(0, 3) + return { inside: at(Math.round(image.width * 0.85), Math.round(image.height / 2)), edge: at(Math.round(image.width / 2), 0) } + }, buffer.toString("base64")) +} +const searchField = d.locator(".app-sw-search") +await page.mouse.move(1, 1) +const before = await samplePixels(searchField) +await searchField.hover() +const ring = await searchField.evaluate(node => getComputedStyle(node, "::after").opacity) +const after = await samplePixels(searchField) +const near = (p, q, tolerance = 12) => p.every((value, index) => Math.abs(value - q[index]) <= tolerance) +const yellow = rgb => rgb[0] > 150 && rgb[1] > 100 && rgb[2] < 90 +const layerRing = async () => { await d.locator(".hm-sw-header__field--layer").hover(); return d.locator(".hm-sw-header__field--layer").evaluate(node => getComputedStyle(node, "::after").opacity) } +check("5 D hover ring like existing fields (edge turns yellow, inside unchanged)", ring === "1" && near(before.inside, after.inside) && yellow(after.edge) && !yellow(before.edge) && (await layerRing()) === "1", { ring, before, after }) +await d.locator(".app-sw-search").hover() +await page.screenshot({ path: path.join(out, "D-search-hover.png"), clip: { x: 0, y: (await rect(d)).y, width: 760, height: 60 } }) +const input = d.getByRole("textbox", { name: "검색어" }) +await input.click() +await input.fill("WALL-01") +await page.keyboard.press("Enter") +check("5 D Enter commits through SWHeaderInput → search callback", (await lastEvent()) === "search:WALL-01", await lastEvent()) +await d.getByRole("button", { name: "검색", exact: true }).click() +check("5 D search button callback uses committed value", (await lastEvent()) === "search:WALL-01", await lastEvent()) +await input.click() +await input.fill("TMP") +await page.keyboard.press("Escape") +check("5 D Escape restores the committed value", await input.inputValue() === "WALL-01", await input.inputValue()) +await page.keyboard.press("Tab") +await d.locator(".hm-sw-header__field--color").click() +await d.getByRole("button", { name: "파랑" }).click() +check("5 D existing color dropdown in the same bar", (await lastEvent()) === "color:blue", await lastEvent()) +await page.mouse.click(1800, 900) +await page.mouse.move(1, 1) +await d.locator(".hm-sw-theme").screenshot({ path: path.join(out, "D-search-1920.png") }) + +// 6. Keyboard order follows DOM order in B +await b.locator(".hm-sw-header__home").focus() +const tabs = [] +for (let index = 0; index < 4; index++) { await page.keyboard.press("Tab"); tabs.push(await page.evaluate(() => document.activeElement?.getAttribute("aria-label") ?? document.activeElement?.textContent?.trim().slice(0, 12))) } +check("6 B Tab order = display order", tabs.includes("레이어") && tabs.indexOf("레이어") < tabs.indexOf("색상") && tabs.indexOf("색상") < tabs.indexOf("팝업 가이드"), tabs) + +check("7 no page/console errors", report.errors.length === 0, report.errors) +await writeFile(path.join(out, "report.json"), JSON.stringify(report, null, 2)) +await browser.close() +server.close() +const failed = report.checks.filter(item => item.status === "FAIL").length +console.log(`${report.checks.length - failed}/${report.checks.length} PASS`) +process.exitCode = failed ? 1 : 0 diff --git a/packages/sw/tests/ai-template-consumer.mjs b/packages/sw/tests/ai-template-consumer.mjs new file mode 100644 index 0000000..38a1652 --- /dev/null +++ b/packages/sw/tests/ai-template-consumer.mjs @@ -0,0 +1,70 @@ +// Independent consumer check for @hm-design/sw (AI template, 2026-10-19). +// Usage: node packages/sw/tests/ai-template-consumer.mjs +// Packs the built package (npm pack), creates a React 19 + TypeScript + Vite project in , installs the +// tarball, copies the shipped ai/examples from node_modules (not from the repo) and runs tsc + vite build. +// Build the package first: npm run build -w @hm-design/sw +import { execFileSync } from "node:child_process" +import { cp, mkdir, readdir, writeFile } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const packageRoot = fileURLToPath(new URL("../", import.meta.url)) +const out = path.resolve(process.argv[2] ?? "") +if (!process.argv[2] || out.startsWith(path.resolve(packageRoot, "../.."))) throw new Error("Pass an empty directory outside the repository") +await mkdir(out, { recursive: true }) +if ((await readdir(out)).length) throw new Error(`${out} is not empty`) + +const npm = (args, cwd) => execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", args, { cwd, stdio: "inherit", shell: process.platform === "win32" }) +npm(["pack", "--ignore-scripts", "--pack-destination", out], packageRoot) +const tarball = (await readdir(out)).find(name => name.endsWith(".tgz")) + +const files = { + "package.json": JSON.stringify({ + name: "sw-ai-template-consumer", private: true, type: "module", + scripts: { typecheck: "tsc -p tsconfig.json", build: "vite build" }, + }, null, 2), + "tsconfig.json": JSON.stringify({ + compilerOptions: { + target: "ES2022", lib: ["ES2022", "DOM", "DOM.Iterable"], module: "ESNext", moduleResolution: "Bundler", + jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: false, isolatedModules: true, + noUnusedLocals: true, noUnusedParameters: true, types: ["vite/client"], + }, + include: ["src"], + }, null, 2), + "vite.config.ts": `import { defineConfig } from "vite"\nimport react from "@vitejs/plugin-react"\nexport default defineConfig({ plugins: [react()] })\n`, + "index.html": `SW consumer
\n`, + "src/main.tsx": `import { StrictMode, useState } from "react" +import { createRoot } from "react-dom/client" +import "@hm-design/sw/styles.css" +import { ExampleADefaultHeader } from "./examples/ExampleA-DefaultHeader" +import { ExampleBViewerHeader } from "./examples/ExampleB-ViewerHeader" +import { ExampleCFeatureHeader } from "./examples/ExampleC-FeatureHeader" +import { ExampleDSearchExtension } from "./examples/ExampleD-SearchExtension" + +const examples = { a: ExampleADefaultHeader, b: ExampleBViewerHeader, c: ExampleCFeatureHeader, d: ExampleDSearchExtension } +function App() { + const [events, setEvents] = useState([]) + const onEvent = (name: string) => setEvents(list => [...list, name]) + return ( + <> + {Object.entries(examples).map(([id, Example]) => ( +
+ ))} +
{events.join("\\n")}
+ + ) +} +createRoot(document.getElementById("root")!).render() +`, +} +for (const [name, text] of Object.entries(files)) { + await mkdir(path.dirname(path.join(out, name)), { recursive: true }) + await writeFile(path.join(out, name), text) +} +npm(["install", "--no-audit", "--no-fund", `./${tarball}`, "react@19.3.0", "react-dom@19.3.0"], out) +npm(["install", "--no-audit", "--no-fund", "-D", "typescript@7.0.2", "vite@8.3.0", "@vitejs/plugin-react@6.1.1", "@types/react@19.3.0", "@types/react-dom@19.3.0"], out) +// The examples are taken from the installed package: what an AI tool in another project would read. +await cp(path.join(out, "node_modules/@hm-design/sw/ai/examples"), path.join(out, "src/examples"), { recursive: true }) +npm(["run", "typecheck"], out) +npm(["run", "build"], out) +console.log(`Consumer ready: ${out} (dist/ built). Next: node packages/sw/tests/ai-template-consumer.browser.mjs ${out} `)