feat(sw): prepare reusable AI-friendly component package
- Ship an AI guide in the package (ai/: usage rules, component catalog, design rules, composition patterns) via package.json files. - Add examples A-D that import only public exports: default header, viewer header, feature header, and a consumer-side search extension that reuses SWHeaderInput and the public SW tokens. - Add SW-only checks: docs/API consistency, tarball consumer project (React 19 + TS + Vite typecheck/build) and a browser check (27/27). - No runtime source or public API change; README gains a pointer section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -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와 속성창 단독 사용까지 확장.
|
||||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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만 있습니다.
|
- 속성창 공통 행은 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).
|
- 추가 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)).
|
- 투명도 게이지는 빨강 채움 + 베이지 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는 바뀌지 않았습니다.
|
||||||
@@ -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` …)를 `<SWTheme>` 안에 배치한다. 배치용 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` 밖에서 하위 컴포넌트를 쓰면 반드시 `<SWTheme>`로 감싼다. 토큰·글꼴·버튼 초기화가 이 범위에서만 적용된다.
|
||||||
|
- 소비 코드는 `hm-sw-*` class를 붙이거나 재정의하지 않는다. 배치는 자기 class(`className`)와 `style`로 한다.
|
||||||
|
- 외형 변경은 `--hm-sw-header-*` 토큰을 `SWTheme`/`SWHeader`의 `style`로 바꾸는 방법만 쓴다. 사용자 확정 면(파일명·객체 수정·속성 입력)은 기본값 유지를 권장한다.
|
||||||
|
- Light/Dark 분기는 없다. SW 디자인은 하나다.
|
||||||
|
|
||||||
|
## 4. 판단 기록 남기기
|
||||||
|
|
||||||
|
새 UI를 만들 때 결과 보고에 다음을 남긴다.
|
||||||
|
|
||||||
|
| 요청 항목 | 판단 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| 예: 레이어 | 재사용 | `SWHeaderLayerMenu` |
|
||||||
|
| 예: 검색 | 신규(소비 프로젝트) | 카탈로그 MISSING. `SWHeaderInput` 재사용 + 토큰 기반 표면 |
|
||||||
|
|
||||||
|
새 컴포넌트를 SW 패키지 공식 컴포넌트로 승격할지는 별도 검토 대상이다. 임의로 패키지에 추가하지 않는다.
|
||||||
@@ -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` 밖에서는 `<SWTheme>` 안에 둡니다(토큰·글꼴·버튼 초기화).
|
||||||
|
- **상태 소유:** 값은 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`(객체 영역 메뉴 뒤), `<header>` HTML 속성·`ref` |
|
||||||
|
|
||||||
|
- 데이터 배열(`layers`, `colors`, `lineTypes`/`lineWeights`, `textStyles`, `dimensionStyles`, `documents`)을 주지 않으면 해당 필드는 Dropdown 없는 버튼입니다.
|
||||||
|
- 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SWHeader documentTitle={name} logoSrc={logo} logoAlt="제품명" layers={layers} activeLayerId={id} onLayerSelect={l => 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`을 두지 않는다 |
|
||||||
@@ -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
|
||||||
|
<SWHeader documentTitle={name} documents={documents} layers={layers} colors={colors} onClose={close} />
|
||||||
|
```
|
||||||
|
|
||||||
|
- 부품 제거: `hiddenControls={["print", "popupGuide"]}`(로고는 항상 표시, `"home"`은 정적 로고).
|
||||||
|
- 메뉴 순서: `menuOrder={["color", "layer"]}`(5개 메뉴끼리, 누락분은 기본 순서로 뒤에).
|
||||||
|
- 객체 영역 전체를 직접 나열: `controls={[{ id: "document" }, { id: "layer" }, { id: "print" }]}`.
|
||||||
|
- 추가 도구: `tools={<MyTool />}`(객체 영역 메뉴 뒤). 새 도구는 아래 4의 규칙을 따른다.
|
||||||
|
- 이 경우에만 반응형 More가 동작합니다.
|
||||||
|
|
||||||
|
## 2. 조합 Header (간소화·기능 중심)
|
||||||
|
|
||||||
|
`<SWTheme>`을 Header 막대로 쓰고, 막대·면 배치 CSS는 소비 프로젝트가 작성합니다([example-layout.css](./examples/example-layout.css)). 이 CSS는 `--hm-sw-header-*` 토큰만 읽고 `hm-sw-*` class를 건드리지 않습니다.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SWTheme className="app-sw-bar"> {/* 49px, #060606, flex, gap 2px */}
|
||||||
|
<SWHeaderHome logoAlt="뷰어" onClick={goHome} /> {/* 80px, 다음 파일 면과 겹침 */}
|
||||||
|
<SWHeaderDocumentTitle title={name} documents={documents} onDocumentSelect={open} />
|
||||||
|
<div className="app-sw-surface"> {/* 객체 수정 면 토큰 */}
|
||||||
|
<SWHeaderLayerMenu value={layer} layers={layers} onLayerSelect={selectLayer} />
|
||||||
|
<SWHeaderColorMenu value={color} colors={colors} onColorSelect={selectColor} />
|
||||||
|
</div>
|
||||||
|
<div className="app-sw-window"> {/* 창 영역 면 토큰 */}
|
||||||
|
<SWHeaderActions showPrint={false} onPopupGuide={showGuide} />
|
||||||
|
</div>
|
||||||
|
</SWTheme>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 간소화 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}`.
|
||||||
|
- 창 제어: `<SWHeaderWindowControls onWindowMoveStart={startDrag} onMinimize={min} onClose={close} />`(창 영역 면 안).
|
||||||
|
- 조합 Header에는 반응형 More가 없습니다. 좁은 화면이 필요하면 완성형을 쓰거나 표시할 컨트롤을 줄입니다.
|
||||||
|
|
||||||
|
## 3. 기존 Dropdown을 다른 레이아웃에서
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SWTheme className="app-toolbar">
|
||||||
|
<SWHeaderLineStyleMenu value={lineStyle} lineTypes={lineTypes} lineWeights={lineWeights} onLineTypeSelect={setType} />
|
||||||
|
</SWTheme>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 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 만들기
|
||||||
@@ -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`(`<SWTheme>`)에만 정의됩니다. 새 컴포넌트는 그 안에 렌더링합니다.
|
||||||
|
- 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. `<SWTheme>`(또는 `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-`).
|
||||||
@@ -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<SWHeaderColorValue>({ value: "ByLayer", swatch: "#ffffff" })
|
||||||
|
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>("byLayer")
|
||||||
|
const log = (name: string) => () => onEvent?.(name)
|
||||||
|
return (
|
||||||
|
<SWHeader
|
||||||
|
documentTitle={documents.find(item => 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")}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <SWTheme> 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<SWHeaderColorValue>({ value: "ByLayer", swatch: "#ffffff" })
|
||||||
|
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>("byLayer")
|
||||||
|
return (
|
||||||
|
<SWTheme className="app-sw-bar" role="banner" aria-label="뷰어 헤더">
|
||||||
|
<SWHeaderHome logoAlt="뷰어" onClick={() => onEvent?.("home")} />
|
||||||
|
<SWHeaderDocumentTitle
|
||||||
|
title={documents.find(item => 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")}
|
||||||
|
/>
|
||||||
|
<div className="app-sw-surface" role="toolbar" aria-label="보기 설정">
|
||||||
|
<SWHeaderLayerMenu
|
||||||
|
value={{ name: "0" }}
|
||||||
|
layers={layers}
|
||||||
|
activeLayerId={activeLayerId}
|
||||||
|
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
|
||||||
|
onVisibilityChange={(layer, next) => onEvent?.(`layer-visible:${layer.id}:${next}`)}
|
||||||
|
/>
|
||||||
|
<SWHeaderColorMenu
|
||||||
|
value={color}
|
||||||
|
colors={colors}
|
||||||
|
mode={colorMode}
|
||||||
|
onColorSelect={option => { setColor({ value: option.label ?? option.color, swatch: option.color }); setColorMode(null); onEvent?.(`color:${option.id}`) }}
|
||||||
|
onModeChange={mode => { setColorMode(mode); onEvent?.(`colorMode:${mode}`) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="app-sw-window">
|
||||||
|
{/* K only: the existing actions pill with print removed. */}
|
||||||
|
<SWHeaderActions showPrint={false} onPopupGuide={() => onEvent?.("popupGuide")} />
|
||||||
|
</div>
|
||||||
|
</SWTheme>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<SWHeaderLineStyleValue>({ name: "ByLayer", weight: "ByLayer", scale: "1.00" })
|
||||||
|
const [textStyle, setTextStyle] = useState<SWHeaderTextStyleValue>({ font: "Arial", name: "Standard" })
|
||||||
|
const [dimensionStyle, setDimensionStyle] = useState<SWHeaderDimensionStyleValue>({ name: "Standard" })
|
||||||
|
return (
|
||||||
|
<SWTheme className="app-sw-bar" role="banner" aria-label="스타일 헤더">
|
||||||
|
{/* No logo before the file well, so the consumer layout gives it its own left edge. */}
|
||||||
|
<div className="app-sw-file-edge">
|
||||||
|
<SWHeaderDocumentTitle title="C0020101-006-평면및종단면도(06)" onClick={() => onEvent?.("document")} />
|
||||||
|
</div>
|
||||||
|
<div className="app-sw-surface" role="toolbar" aria-label="스타일">
|
||||||
|
<SWHeaderLineStyleMenu
|
||||||
|
value={lineStyle}
|
||||||
|
lineTypes={lineTypes}
|
||||||
|
lineWeights={lineWeights}
|
||||||
|
onLineTypeSelect={type => { 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}`) }}
|
||||||
|
/>
|
||||||
|
<SWHeaderTextStyleMenu
|
||||||
|
value={textStyle}
|
||||||
|
textStyles={textStyles}
|
||||||
|
onTextStyleSelect={style => { setTextStyle({ font: style.font, name: style.name }); onEvent?.(`textStyle:${style.id}`) }}
|
||||||
|
/>
|
||||||
|
<SWHeaderDimensionStyleMenu
|
||||||
|
value={dimensionStyle}
|
||||||
|
dimensionStyles={dimensionStyles}
|
||||||
|
onDimensionStyleSelect={style => { setDimensionStyle({ name: style.name }); onEvent?.(`dimensionStyle:${style.id}`) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="app-sw-window">
|
||||||
|
<SWHeaderActions showPopupGuide={false} onPrint={() => onEvent?.("print")} />
|
||||||
|
</div>
|
||||||
|
</SWTheme>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<SWHeaderColorValue>({ value: "ByLayer", swatch: "#ffffff" })
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
return (
|
||||||
|
<SWTheme className="app-sw-bar" role="banner" aria-label="검색 헤더">
|
||||||
|
<div className="app-sw-surface" role="toolbar" aria-label="객체 찾기">
|
||||||
|
<SWHeaderLayerMenu
|
||||||
|
value={{ name: "0" }}
|
||||||
|
layers={layers}
|
||||||
|
activeLayerId={activeLayerId}
|
||||||
|
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
|
||||||
|
/>
|
||||||
|
<SWHeaderColorMenu
|
||||||
|
value={color}
|
||||||
|
colors={colors}
|
||||||
|
onColorSelect={option => { setColor({ value: option.label ?? option.color, swatch: option.color }); onEvent?.(`color:${option.id}`) }}
|
||||||
|
/>
|
||||||
|
<SWSearchControl value={query} onValueChange={setQuery} onSearch={text => onEvent?.(`search:${text}`)} />
|
||||||
|
</div>
|
||||||
|
</SWTheme>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="app-sw-search" role="search" data-disabled={disabled || undefined}>
|
||||||
|
<span className="app-sw-search__text">
|
||||||
|
<span className="app-sw-search__label" aria-hidden="true">{label}</span>
|
||||||
|
<SWHeaderInput
|
||||||
|
aria-label={`${label}어`}
|
||||||
|
value={value}
|
||||||
|
placeholder="객체 이름"
|
||||||
|
disabled={disabled}
|
||||||
|
onCommit={next => { onValueChange?.(next); onSearch?.(next) }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<button type="button" className="app-sw-search__button" aria-label={label} disabled={disabled} onClick={() => onSearch?.(value)}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
|
<circle cx="7" cy="7" r="4.75" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <SWTheme>.
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
@@ -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); }
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
"./styles.css": "./dist/styles.css"
|
"./styles.css": "./dist/styles.css"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist",
|
||||||
|
"ai"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 <consumer-dir> <evidence-dir>
|
||||||
|
// Serves <consumer-dir>/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
|
||||||
@@ -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 <empty-dir-outside-the-repo>
|
||||||
|
// Packs the built package (npm pack), creates a React 19 + TypeScript + Vite project in <dir>, 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": `<!doctype html><html lang="ko"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>SW consumer</title><link rel="icon" href="data:," /><style>body { margin: 0; }</style></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>\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<string[]>([])
|
||||||
|
const onEvent = (name: string) => setEvents(list => [...list, name])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Object.entries(examples).map(([id, Example]) => (
|
||||||
|
<section key={id} data-example={id} style={{ marginBottom: 360 }}><Example onEvent={onEvent} /></section>
|
||||||
|
))}
|
||||||
|
<pre id="events">{events.join("\\n")}</pre>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>)
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
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} <evidence-dir>`)
|
||||||