feat(sw): viewer mode header, dropdown and popup panel

- SWViewerHeader: data-driven viewer menu (button / toggle / menu / tabs / switch / custom), More by
  collapseBelow, trailing slot, onCommand; SWHeader logo props with no default logo
- SWViewerMenu: dropdown, check / radio, multi-level submenu, context menu at a point
- SWViewerPanel: viewer popup frame with free children / footer, drag, corner resize, collapse, modal
- Panel controls: SWViewerSection / Field / Hint / Switch / Slider / Input / Button / Badge / Status
- SWHeader black text outline tokens on viewer text, matching icon outline (drop-shadow)
- Storybook SW/Viewer stories; remove SW/Tool story (SW/Popup kept)
- AI guide ai/VIEWER.md, example H, verification scripts and evidence
This commit is contained in:
shkim4 committed 2026-10-19 16:09:26 +09:00
1 parent e8409eff44
commit 7f42923ccf
50 files changed
+3838 -92

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
# SW 뷰어 모드 상단 메뉴·팝업 (2026-10-19)
- 작업 위치: worktree `D:\디자인시스템\HMDesign-SW-Viewer`, 브랜치 `feature/sw-viewer-header`(기준 `feature/sw-package` e8409ef). Task A+B 통합 worktree(`HMDesign-SW-Integration`)는 변경하지 않았습니다. Commit·Push 없음.
- 범위(사용자 확정): HMDesign Storybook 데모 기준. 실제 SW 프로그램 연결과 DWG/DXF 기능은 제외했습니다. 참고 프로젝트 `D:\디자인시스템\테스트\dwg-dxf-viewer-hm`은 읽기만 했고, 최신 소스는 scratchpad로 빌드해 `vite preview`로 띄웠습니다(참고 폴더에 쓰기 없음).
- 추가 요청 반영: ① Viewer Header 텍스트·아이콘에 SW Header와 같은 검정 외곽선 ② Storybook `Tool Popup` 메뉴 정리: `SW/Tool` 삭제. `SW/Popup`은 공통 `SWPopup` 예시이므로 Commit 승인 시 지시에 따라 복원·유지 ③ 로고 미제공 시 `HM` 등 아무것도 표시하지 않음.
- 증거: `docs/sw/viewer-qa/`(같은 해상도 캡처, `report.json` 74항목), `docs/sw/viewer-qa/regression/regression.json`(기존 SW Header 33장 픽셀 비교).
## A. 분석 결과
**참고 뷰어 상단 메뉴**(`Toolbar.tsx`, `sw-header.css` — `@hm-design/ui` SWHeader 조합, 1440px 실측)
- 52px Header(그라디언트 #266651 → #123328 → #0A221D, 1px #071E18 테두리), 72px 사선 로고 면(HM), 파일명 면 `clamp(220px, 25vw, 360px)`, 객체 수정 면(반경 6, 테두리 #135643).
- 그룹: 파일(파일 열기 109×34, 샘플▾ 80×34) / 도면 공간 field 면(Model·Layout 탭 97·87×40, 개수 11px) / 화면 보기 field 면(전체 보기, 2.5D·어두운 배경 스위치 칸 52·70×40) / 오른쪽 끝(레이어·설정·속성 토글 81·68·68×40, 상태 11px, 정보 32×40).
- 상태: Hover = #266651 면, On·Open·선택 탭 = #FFC600 글자, Disabled = 60%, 스위치 off #0A221D / on #FFC600.
- 반응형: 1180px 미만 = 샘플·탭·스위치가 더보기(베이지 #F5F0E5, 260px)로, 900px 미만 = 패널 토글도 더보기로, 560px 미만 = 아이콘만 표시하고 상태 글자 숨김.
**참고 팝업·설정창**
- 샘플 Dropdown(흰 면, #DEDBD2 테두리, 반경 4, 항목 36px, 강조 rgba(189,223,212,.45))과 더보기 Dropdown(라벨, 체크, 라디오 구획).
- DraggablePanel 3종(레이어 320×420, 옵션 340×520, 속성 320×420): 베이지 #E0DACE, 2px #897445, 제목 막대 #221D13·#FFC600 700, 곡선 녹색 닫기(Hover #E40000), 접기, 드래그(경계 10px), 네이티브 resize, 본문 12px 패딩, 카드 섹션 #F5F0E5.
- 라이선스 모달 Dialog.
- Context Menu와 다단계 서브메뉴는 참고 뷰어에 **없습니다**. 요청에 따라 공통 Dropdown 기능으로 추가했습니다.
**기존 SW 패키지와의 차이**
- 기존 `SWHeader`는 Figma 26:8944 기준(49px, 고정 필드 구성)입니다. 데이터 기반 메뉴, 탭, 스위치, 드래그 패널이 없습니다.
- `SWPopup`은 314px 고정 Figma 속성창 프레임으로, 이동·크기 조절·접기를 지원하지 않습니다.
- 재사용: 사용자 확정 면 토큰 3종(`--hm-sw-header-document-*`, `object-*`, `property-*`, 참고 뷰어와 같은 값), SW 외곽선 토큰(`--hm-sw-header-text-outline`, `label-outline`, `text-outline-room`), `SWTheme` 범위·버튼 초기화, `SWHeader` 로고 Props 규칙.
**분류**
| 항목 | 분류 | 근거 |
|---|---|---|
| 일반 모드 Header | 재사용(변경 없음) | `SWHeader` |
| 면 배경·외곽선·글꼴 | 재사용 | 기존 토큰 |
| 뷰어 Header | 신규 | 기존 SWHeader는 Figma 고정 구성이고, 뷰어 메뉴 구조·크기가 다름 |
| Dropdown·서브메뉴·Context | 신규 | SW 패키지에 범용 Dropdown 없음(필드 전용 Dropdown만 있음) |
| 뷰어 팝업 | 신규 | `SWPopup` 프레임(314px Figma art)과 외형·동작이 다름. Variant로 만들면 기존 속성창 DOM·CSS를 바꿔야 해서 분리 |
## B. 패키지 변경 결과
**신규 파일**
- `packages/sw/src/components/sw-viewer/`: `types.ts`, `SWViewerHeader.tsx`, `SWViewerMenu.tsx`, `SWViewerPanel.tsx`, `SWViewerControls.tsx`, `index.ts`
- `packages/sw/src/theme/sw-viewer.css`
- `packages/sw/ai/VIEWER.md`, `packages/sw/ai/examples/ExampleH-ViewerMode.tsx`
- `packages/sw/tests/viewer.browser.mjs`, `viewer-regression.browser.mjs`
**수정 파일**
- `packages/sw/src/index.ts`: 끝에 Export 1블록
- `packages/sw/ai/AI_USAGE.md`: 문서 표 1행
- `packages/sw/tests/storybook-inventory.mjs`: 삭제한 Story id 기록
기존 컴포넌트·`sw.css`·`SWHeaderPropertyPanel.tsx`는 수정하지 않았습니다. A+B 통합 작업과 충돌 가능성이 있는 곳은 `index.ts`·`AI_USAGE.md` 각 1군데뿐입니다.
**공개 API**(`@hm-design/sw`)
- `SWViewerHeader`: `groups`·`trailing`·`onCommand`·`documentTitle`·`logo`/`logoSrc`/`logoAlt`/`onHomeClick`
- `SWViewerHeaderButton`
- `SWViewerMenu`: `entries`·`anchorRef`/`anchorPoint`·`surface`·`initialFocus`
- `SWViewerPanel`: `children`·`footer`·`headerActions`·크기·위치·`strategy`·드래그·크기 조절·접기·`modal`
- 패널 콘텐츠: `SWViewerSection`, `SWViewerField`, `SWViewerHint`, `SWViewerSwitch`, `SWViewerSlider`, `SWViewerInput`, `SWViewerButton`, `SWViewerBadge`, `SWViewerStatus`
- 타입: `SWViewerHeaderItem`, `SWViewerHeaderGroup`, `SWViewerMenuEntry`, `SWViewerCommand`, `SWViewerPanelPlacement` 및 각 Props
**외곽선**(SW Header 분석 → 같은 기준 적용)
- 텍스트: SW Header는 14px 글자에 검정 1.3px(16 text-shadow), 12px 라벨에 1px을 씁니다. 뷰어의 14px 글자(파일명·버튼·탭·로고)는 `--hm-sw-header-text-outline`, 11px(스위치 캡션·개수·상태)는 `--hm-sw-header-label-outline`을 그대로 사용합니다. 말줄임 파일명에는 `overflow-clip-margin`을 적용했습니다.
- 아이콘: SW Header 아이콘 자산(print·K·레이어)은 채움 + 1px 검정 바깥 Stroke(Figma mask outside) + glow(stdDeviation 1.615, #061A14)입니다. 프로그램 아이콘은 자유 SVG라서 같은 효과를 `drop-shadow` 체인(1px × 4방향 + glow)으로 재현합니다. 토큰 `--hm-sw-viewer-icon-outline`이며 아이콘 윤곽을 따라갑니다.
- 색·크기·굵기·간격은 유지합니다. Hover·Active는 채움 색만 바뀌고, Disabled는 버튼 전체 opacity로 함께 흐려집니다. Dropdown과 패널에는 외곽선이 없습니다(SW Header More 메뉴와 같음).
**로고**
- `SWHeader`와 같은 Props(`logo`, `logoSrc`, `logoAlt`, `onHomeClick`)를 씁니다.
- 로고를 주지 않으면 사선 끝 24px만 남고 문자·아이콘은 없습니다.
- 로고를 주면 72px 면이 되고, 로고가 더 넓으면 `24 + 로고 + 25`px입니다. Header 높이 52px와 메뉴 위치는 변하지 않습니다.
**Storybook**
- `SW/Viewer` 10개: Viewer Header(로고 없음 기본), Menu Groups, Item States, Header + Dropdown(서브메뉴·체크·라디오·Context Menu), Settings Popup(footer), Popup Content Variants, Variable Size Popups, Responsive Widths, SWHeader vs Viewer Header(모드 전환), Logo
- 공개 API만 사용합니다. Story CSS는 배치와 Story 자체 목록에만 쓰고 `hm-sw-*`는 사용하지 않습니다.
- `SW/Tool`(`sw-tool.stories.tsx`)만 삭제했습니다. `SW/Popup`(`sw-popup.stories.tsx`, 공통 `SWPopup` 예시)과 `sw-integrated-stories.css`는 원본 그대로 유지합니다. `SWToolButton`/`SWToolGroup`/`SWPopup`과 그 API도 유지합니다.
- 삭제한 `sw-tool--tool-buttons`를 여는 기존 `integrated-improvement.browser.mjs`는 과거 검증 기록용으로만 남습니다. 지금 다시 실행하면 해당 단계가 실패합니다.
**AI 지침**: `ai/VIEWER.md`(역할·사용 환경·Props·이벤트·Import·Viewer 적용 조건·콘텐츠 교체·외곽선·로고·주의사항·올바른 예/잘못된 예), 예제 H, `AI_USAGE.md` 문서 표 반영.
## C. SW 프로그램 통합 결과
이번 범위에서 제외(사용자 결정)했으므로 **NOT TESTED**입니다. Storybook `SWHeader vs Viewer Header`에서 Header 교체만 UI로 시연했고, 화면 상태(파일명·패널)는 유지됩니다.
메뉴 명령은 모두 `onCommand` 로그로만 표시하며 실제 기능은 미연결입니다: 파일 열기, 전체 보기, 선택 확대, 인쇄, 내보내기, 측정.
## D. 검증 결과
| 항목 | 판정 | 근거 |
|---|---|---|
| Typecheck | PASS | `npm run typecheck`(전체 패키지 build + `tsc -b` + 5개 workspace) exit 0 |
| Package Build | PASS | `build:packages` / `build:sw` 성공 |
| Storybook Build | PASS | `npm run build-storybook` exit 0. CSS 경고 1건은 기존 docs의 `bg-[var(...)]` 문구(기존부터 있음) |
| 공개 Export | PASS | `ai-docs.check.mjs` 25/25(문서 식별자·예제 Import·링크) |
| 독립 소비 프로젝트 | PASS (일부) | `ai-template-consumer.mjs`: npm pack → 새 프로젝트 설치 → 예제 A~H `tsc` + `vite build` 통과. 예제 H의 브라우저 렌더링은 소비 프로젝트에서 미실행(Storybook에서만 확인) |
| 실제 화면 비교 | PASS (알려진 차이 있음) | 1440×900 같은 해상도 캡처와 수치 비교. 버튼·토글·탭·스위치 칸 크기가 참고와 같음(px 단위). 도면 공간 field 면은 42px로 참고(41px)와 1px 차이. 원인은 참고 Tabs 루트 39px. 파일명 면 x는 로고 규칙 때문에 −48px(의도된 차이) |
| 사용자 인터랙션 | PASS | `viewer.browser.mjs` 74/74 PASS (아래 항목 참조) |
| 외곽선 | PASS | 텍스트 1.3px/1px 검정 각 16 shadow, 아이콘 drop-shadow 5개, 색·크기·굵기 유지, Hover·Disabled에서 유지, 3배 확대 캡처(`outline-*.png`) |
| 로고 | PASS | 없음: 문자·이미지 0, 면 24px. logoSrc·logo 표시. 높이 52px, 메뉴 y 동일 |
| `SW/Tool` 삭제·`SW/Popup` 유지 | PASS | dev `index.json`: SW 제목은 `SW/Header`, `SW/Popup`, `SW/Viewer` (`viewer.browser.mjs` 재실행) |
| 기존 기능 회귀 | PASS | 기존 SW/Header Story 11개 × 3폭(1920/1180/768) 픽셀 차이 0(33/33). 비교 대상은 기준 Storybook(6024, e8409ef)과 이 브랜치. `test:components` 9/9, `test:guard` 94/94, `git diff --check` 통과 |
| Window Move Motion Story | NOT TESTED | 애니메이션이라 픽셀 비교에서 제외 |
| Firefox·Safari, 실제 창 크기 변경 | NOT TESTED | Chrome headless만 실행. 폭은 viewport와 Story 폭으로 재현 |
`viewer.browser.mjs` 74개 항목: 메뉴 열기·위치·Enter/Arrow/Escape(Focus 복귀)·바깥 클릭, 탭 화살표, 스위치, Hover 면, 토글 → 팝업, 패널 Focus·Escape·Focus 복귀, 모서리 크기 조절, 드래그·경계 10px, 접기·더블클릭, 닫기 Hover #E40000, 앞으로 가져오기, 모달 배경·Focus Trap·Escape, 서브메뉴 →/← 3단계, 체크·라디오·비활성 항목, `menuAlign`·`menuWidth`, Context Menu 포인터 위치, 화면 경계, 1440/1100/860/520 가로 넘침 0, More 구획(샘플 도면/도면 공간/화면 보기/패널), More 체크·라디오 명령, 모드 전환과 상태 유지, 로고, 외곽선, 메뉴 삭제, 페이지 오류 0.
**참고 뷰어와 의도적으로 다른 점**
- 모서리 크기 조절: 네이티브 `resize` 대신 같은 위치의 Grip을 씁니다. Firefox 등 모든 브라우저에서 동작하고 자동 검증할 수 있습니다.
- 패널 Escape 닫기를 추가했습니다(Focus가 패널 안에 있을 때).
- 로고 기본 표시를 없앴습니다(사용자 결정).
- 서브메뉴와 Context Menu를 추가했습니다(요청 범위).
## E. 최종 판정
**PARTIAL**: 패키지 컴포넌트 구현, Storybook, AI 지침, 주요 UI·인터랙션 검증은 끝났습니다. PASS가 아닌 이유는 요청 원문의 “SW 프로그램 뷰어 모드 통합”이 이번 범위에서 제외되어(사용자 결정) 진행하지 않았기 때문입니다. Commit·Push는 승인 대기입니다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,237 @@
{
"base": "http://127.0.0.1:6024",
"next": "http://127.0.0.1:6034",
"results": [
{
"id": "overview",
"width": 1920,
"size": "1920x1000 / 1920x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "overview",
"width": 1180,
"size": "1180x1000 / 1180x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "overview",
"width": 768,
"size": "768x1000 / 768x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "interactive-states",
"width": 1920,
"size": "1920x2517 / 1920x2517",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "interactive-states",
"width": 1180,
"size": "1180x2527 / 1180x2527",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "interactive-states",
"width": 768,
"size": "768x2623 / 768x2623",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "logo-slot",
"width": 1920,
"size": "1920x1000 / 1920x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "logo-slot",
"width": 1180,
"size": "1180x1000 / 1180x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "logo-slot",
"width": 768,
"size": "768x1000 / 768x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "document-dropdown",
"width": 1920,
"size": "1920x9112 / 1920x9112",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "document-dropdown",
"width": 1180,
"size": "1180x9122 / 1180x9122",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "document-dropdown",
"width": 768,
"size": "768x9298 / 768x9298",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "responsive-more",
"width": 1920,
"size": "1920x10042 / 1920x10042",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "responsive-more",
"width": 1180,
"size": "1180x10052 / 1180x10052",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "responsive-more",
"width": 768,
"size": "768x10048 / 768x10048",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "k-guide",
"width": 1920,
"size": "1920x1037 / 1920x1037",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "k-guide",
"width": 1180,
"size": "1180x1028 / 1180x1028",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "k-guide",
"width": 768,
"size": "768x1023 / 768x1023",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "property-panel-compare",
"width": 1920,
"size": "1920x1000 / 1920x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "property-panel-compare",
"width": 1180,
"size": "1180x1000 / 1180x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "property-panel-compare",
"width": 768,
"size": "768x1000 / 768x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "composed-controls",
"width": 1920,
"size": "1920x1000 / 1920x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "composed-controls",
"width": 1180,
"size": "1180x1000 / 1180x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "composed-controls",
"width": 768,
"size": "768x1000 / 768x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "document-open",
"width": 1920,
"size": "1920x10771 / 1920x10771",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "document-open",
"width": 1180,
"size": "1180x10761 / 1180x10761",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "document-open",
"width": 768,
"size": "768x10797 / 768x10797",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "hidden-sub-controls",
"width": 1920,
"size": "1920x1710 / 1920x1710",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "hidden-sub-controls",
"width": 1180,
"size": "1180x1700 / 1180x1700",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "hidden-sub-controls",
"width": 768,
"size": "768x1676 / 768x1676",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "detail-buttons",
"width": 1920,
"size": "1920x1000 / 1920x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "detail-buttons",
"width": 1180,
"size": "1180x1000 / 1180x1000",
"diffPixels": 0,
"result": "PASS"
},
{
"id": "detail-buttons",
"width": 768,
"size": "768x1000 / 768x1000",
"diffPixels": 0,
"result": "PASS"
}
]
}
+621
View File
@@ -0,0 +1,621 @@
{
"storybook": "http://127.0.0.1:6034",
"reference": "http://127.0.0.1:5182",
"comparison": {
"reference": {
"document": [
{
"x": 73,
"y": 1,
"w": 337,
"h": 50,
"text": "C0060202-001"
}
],
"object": [
{
"x": 412,
"y": 1,
"w": 1027,
"h": 50,
"text": "파일 열기샘플 Mode"
}
],
"field": [
{
"x": 622,
"y": 6,
"w": 197,
"h": 41,
"text": "Model17,377L"
},
{
"x": 823,
"y": 5,
"w": 249,
"h": 42,
"text": "전체 보기2.5D어두운"
}
],
"button": [
{
"x": 421,
"y": 9,
"w": 109,
"h": 34,
"text": "파일 열기"
},
{
"x": 534,
"y": 9,
"w": 80,
"h": 34,
"text": "샘플"
},
{
"x": 828,
"y": 9,
"w": 109,
"h": 34,
"text": "전체 보기"
},
{
"x": 1402,
"y": 6,
"w": 32,
"h": 40,
"text": ""
}
],
"toggle": [
{
"x": 1086,
"y": 6,
"w": 81,
"h": 40,
"text": "레이어"
},
{
"x": 1171,
"y": 6,
"w": 68,
"h": 40,
"text": "설정"
},
{
"x": 1243,
"y": 6,
"w": 68,
"h": 40,
"text": "속성"
}
],
"tab": [
{
"x": 627,
"y": 7,
"w": 97,
"h": 40,
"text": "Model17,377"
},
{
"x": 728,
"y": 7,
"w": 87,
"h": 40,
"text": "Layout237"
}
],
"switchField": [
{
"x": 942,
"y": 6,
"w": 52,
"h": 40,
"text": "2.5D"
},
{
"x": 998,
"y": 6,
"w": 70,
"h": 40,
"text": "어두운 배경"
}
]
},
"package": {
"document": [
{
"x": 25,
"y": 1,
"w": 360,
"h": 50,
"text": "C0060202-001"
}
],
"object": [
{
"x": 387,
"y": 1,
"w": 1053,
"h": 50,
"text": "파일 열기샘플Model"
}
],
"field": [
{
"x": 597,
"y": 5,
"w": 197,
"h": 42,
"text": "Model17,377L"
},
{
"x": 798,
"y": 5,
"w": 249,
"h": 42,
"text": "전체 보기2.5D어두운"
}
],
"button": [
{
"x": 396,
"y": 9,
"w": 109,
"h": 34,
"text": "파일 열기"
},
{
"x": 509,
"y": 9,
"w": 80,
"h": 34,
"text": "샘플"
},
{
"x": 803,
"y": 9,
"w": 109,
"h": 34,
"text": "전체 보기"
},
{
"x": 1402,
"y": 6,
"w": 32,
"h": 40,
"text": ""
}
],
"toggle": [
{
"x": 1086,
"y": 6,
"w": 81,
"h": 40,
"text": "레이어"
},
{
"x": 1171,
"y": 6,
"w": 68,
"h": 40,
"text": "설정"
},
{
"x": 1243,
"y": 6,
"w": 68,
"h": 40,
"text": "속성"
}
],
"tab": [
{
"x": 602,
"y": 6,
"w": 97,
"h": 40,
"text": "Model17,377"
},
{
"x": 702,
"y": 6,
"w": 87,
"h": 40,
"text": "Layout237"
}
],
"switchField": [
{
"x": 916,
"y": 6,
"w": 52,
"h": 40,
"text": "2.5D"
},
{
"x": 972,
"y": 6,
"w": 70,
"h": 40,
"text": "어두운 배경"
}
]
}
},
"results": [
{
"name": "geometry button sizes equal to reference",
"result": "PASS",
"detail": "reference 109x34 80x34 109x34 32x40 / package 109x34 80x34 109x34 32x40"
},
{
"name": "geometry toggle sizes equal to reference",
"result": "PASS",
"detail": "reference 81x40 68x40 68x40 / package 81x40 68x40 68x40"
},
{
"name": "geometry tab sizes equal to reference",
"result": "PASS",
"detail": "reference 97x40 87x40 / package 97x40 87x40"
},
{
"name": "geometry switchField sizes equal to reference",
"result": "PASS",
"detail": "reference 52x40 70x40 / package 52x40 70x40"
},
{
"name": "geometry field sizes equal to reference (±1px height, known)",
"result": "PASS",
"detail": "reference 197x41 249x42 / package 197x42 249x42"
},
{
"name": "geometry document well height / top (x: logo rule)",
"result": "PASS",
"detail": "reference x73 y1 h50 / package x25 y1 h50"
},
{
"name": "menu opens on click",
"result": "PASS",
"detail": ""
},
{
"name": "menu has 3 entries",
"result": "PASS",
"detail": ""
},
{
"name": "menu placed under its button",
"result": "PASS",
"detail": "menu 549,365 button 549,361"
},
{
"name": "trigger shows open state",
"result": "PASS",
"detail": ""
},
{
"name": "ArrowDown moves focus in menu",
"result": "PASS",
"detail": ""
},
{
"name": "Escape closes menu",
"result": "PASS",
"detail": ""
},
{
"name": "Escape returns focus to trigger",
"result": "PASS",
"detail": ""
},
{
"name": "Enter opens with first entry focused",
"result": "PASS",
"detail": ""
},
{
"name": "Enter selects entry (document changes)",
"result": "PASS",
"detail": ""
},
{
"name": "onCommand reports menu entry",
"result": "PASS",
"detail": "Command: samples/sample-dxf"
},
{
"name": "outside click closes menu",
"result": "PASS",
"detail": ""
},
{
"name": "tabs ArrowRight selects Layout",
"result": "PASS",
"detail": ""
},
{
"name": "header switch toggles",
"result": "PASS",
"detail": ""
},
{
"name": "hover face #266651",
"result": "PASS",
"detail": ""
},
{
"name": "toggle opens panel",
"result": "PASS",
"detail": ""
},
{
"name": "toggle pressed state + #FFC600",
"result": "PASS",
"detail": ""
},
{
"name": "panel focused on open",
"result": "PASS",
"detail": ""
},
{
"name": "Escape closes panel",
"result": "PASS",
"detail": ""
},
{
"name": "focus returns to toggle",
"result": "PASS",
"detail": ""
},
{
"name": "corner resize changes size",
"result": "PASS",
"detail": "320x420 → 263x323"
},
{
"name": "drag moves panel",
"result": "PASS",
"detail": "dx -300 dy 30"
},
{
"name": "drag stays inside bounds (10px edge)",
"result": "PASS",
"detail": "right gap 10, bottom gap 10"
},
{
"name": "collapse hides body",
"result": "PASS",
"detail": ""
},
{
"name": "title double click expands",
"result": "PASS",
"detail": ""
},
{
"name": "panel content callback",
"result": "PASS",
"detail": ""
},
{
"name": "close hover #E40000 / white X",
"result": "PASS",
"detail": ""
},
{
"name": "close button closes",
"result": "PASS",
"detail": ""
},
{
"name": "last touched panel on top",
"result": "PASS",
"detail": "layer z 37, options z 36"
},
{
"name": "modal popup opens with backdrop",
"result": "PASS",
"detail": ""
},
{
"name": "modal focus stays inside",
"result": "PASS",
"detail": ""
},
{
"name": "modal Escape closes",
"result": "PASS",
"detail": ""
},
{
"name": "ArrowRight opens submenu with focus",
"result": "PASS",
"detail": ""
},
{
"name": "submenu opens to the side",
"result": "PASS",
"detail": "parent right 596, sub left 596"
},
{
"name": "third level opens on hover",
"result": "PASS",
"detail": ""
},
{
"name": "ArrowLeft closes one level",
"result": "PASS",
"detail": ""
},
{
"name": "checkbox entry toggles via onCommand",
"result": "PASS",
"detail": "Command: file/lineweight=true"
},
{
"name": "checkbox state reflected",
"result": "PASS",
"detail": ""
},
{
"name": "disabled entry does nothing",
"result": "PASS",
"detail": ""
},
{
"name": "menuAlign end + menuWidth",
"result": "PASS",
"detail": "width 220"
},
{
"name": "context menu opens at pointer",
"result": "PASS",
"detail": "90,729"
},
{
"name": "context entry reported",
"result": "PASS",
"detail": ""
},
{
"name": "menu stays inside the viewport",
"result": "PASS",
"detail": "left 4, right 224"
},
{
"name": "no horizontal overflow at 1440 / 1100 / 860 / 520",
"result": "PASS",
"detail": "[[1440,0,0],[1100,0,0],[860,0,0],[520,0,0]]"
},
{
"name": "1440: no More",
"result": "PASS",
"detail": ""
},
{
"name": "1100: More, samples/tabs/switches moved",
"result": "PASS",
"detail": ""
},
{
"name": "860: panel toggles moved too",
"result": "PASS",
"detail": ""
},
{
"name": "520: 파일 열기 / 전체 보기 icon only",
"result": "PASS",
"detail": ""
},
{
"name": "More sections like the reference",
"result": "PASS",
"detail": "샘플 도면 | 기본 도면 · DWG | 평면 및 종단면도 · DWG | 기본 도형 · DXF | 도면 공간 | Model · 17,377개 | Layout · 237개 | 화면 보기 | 2.5D | 어두운 배경 | 패널 | 레이어 | 설정 | 속성"
},
{
"name": "More face beige 260px",
"result": "PASS",
"detail": ""
},
{
"name": "More checkbox reports command",
"result": "PASS",
"detail": "Command: layers=true"
},
{
"name": "More radio selects tab value",
"result": "PASS",
"detail": ""
},
{
"name": "mode switch shows viewer header",
"result": "PASS",
"detail": ""
},
{
"name": "brand click returns to normal SWHeader",
"result": "PASS",
"detail": ""
},
{
"name": "state kept across mode switch (layer panel still open)",
"result": "PASS",
"detail": ""
},
{
"name": "logo none: no HM / text / icon",
"result": "PASS",
"detail": "{\"text\":\"\",\"img\":false,\"width\":24,\"docX\":25,\"height\":52,\"fileOpenY\":9}"
},
{
"name": "logo none: cap shrinks to its edge (no wide gap)",
"result": "PASS",
"detail": "{\"text\":\"\",\"img\":false,\"width\":24,\"docX\":25,\"height\":52,\"fileOpenY\":9}"
},
{
"name": "logoSrc shows the image (cap = 24 + logo + 25, min 72)",
"result": "PASS",
"detail": "{\"text\":\"\",\"img\":true,\"width\":94,\"docX\":95,\"height\":52,\"fileOpenY\":9,\"logoWidth\":45}"
},
{
"name": "logo element shown",
"result": "PASS",
"detail": "{\"text\":\"LOGO\",\"img\":true,\"width\":81,\"docX\":82,\"height\":52,\"fileOpenY\":9}"
},
{
"name": "header height and menu row unchanged with / without logo",
"result": "PASS",
"detail": "52/9 52/9 52/9"
},
{
"name": "text outline 1.3px black on 14px text (label / file name / tab)",
"result": "PASS",
"detail": "rgb(0, 0, 0) 1.3px 0px 0px, rgb(0, 0, 0) 1.2px 0.5px 0px, rg"
},
{
"name": "label outline 1px black on 11px captions / counts",
"result": "PASS",
"detail": "rgb(0, 0, 0) 1px 0px 0px, rgb(0, 0, 0) 0.92px 0.38px 0px, rg"
},
{
"name": "icon outline: 4 x 1px black drop-shadows + #061A14 glow",
"result": "PASS",
"detail": "drop-shadow(rgb(0, 0, 0) 1px 0px 0px) drop-shadow(rgb(0, 0, 0) -1px 0px 0px) drop-shadow(r"
},
{
"name": "fill / size / weight / spacing kept",
"result": "PASS",
"detail": "rgb(255, 255, 255) 14px 500 normal"
},
{
"name": "hover changes fill only (outline kept)",
"result": "PASS",
"detail": ""
},
{
"name": "disabled fades text + outline together",
"result": "PASS",
"detail": "[\"0.6\",true]"
},
{
"name": "SW/Tool removed from the sidebar",
"result": "PASS",
"detail": "SW/Header, SW/Viewer, SW/Popup"
},
{
"name": "SW/Popup, SW/Header and SW/Viewer remain",
"result": "PASS",
"detail": ""
},
{
"name": "no page errors",
"result": "PASS",
"detail": ""
}
],
"errors": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+2 -1
View File
@@ -7,7 +7,8 @@
| [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트(Header·개별 컨트롤·Popup·도구 막대)·Props·Callback·크기·CSS 의존성 |
| [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형·Popup 외형·도구 버튼 |
| [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header, Popup(자유 콘텐츠), 파일 열기 + 도구 막대 |
| [examples/](./examples) | 공개 API만 쓰는 예제 A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증) |
| [examples/](./examples) | 공개 API만 쓰는 예제 A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증), H = 뷰어 모드 |
| [VIEWER.md](./VIEWER.md) | **뷰어 모드 전용**: `SWViewerHeader`(데이터 메뉴·More·외곽선·로고 규칙), `SWViewerMenu`(Dropdown·서브메뉴·우클릭), `SWViewerPanel`(팝업 프레임 + 자유 콘텐츠). 일반 SW 모드는 `SWHeader`·`SWPopup`을 그대로 씁니다 |
## 1. 작업 순서 (필수)
+104
View File
@@ -0,0 +1,104 @@
# @hm-design/sw — 뷰어 모드 (Viewer Header · Popup) 사용 지침
기준: 2026-10-19, 참고 DWG/DXF 뷰어(`dwg-dxf-viewer-hm`) 상단 메뉴·팝업을 같은 해상도(1440 × 900)에서 실측해 패키지 컴포넌트로 옮긴 결과입니다. 아래 이름은 모두 `from "@hm-design/sw"` 공개 Export이고 CSS는 `@hm-design/sw/styles.css` 한 개입니다. 예제: [examples/ExampleH-ViewerMode.tsx](./examples/ExampleH-ViewerMode.tsx).
## 1. 언제 무엇을 쓰나
| 화면 | 사용 | 쓰지 않음 |
|---|---|---|
| SW 일반 모드 Header | `SWHeader` (49px, Figma 26:8944) | `SWViewerHeader` |
| SW 뷰어 모드 Header | `SWViewerHeader` (52px, 참고 뷰어) | `SWHeader`에 뷰어 메뉴를 억지로 넣기, 직접 만든 툴바 |
| 뷰어 메뉴 Dropdown·서브메뉴·우클릭 메뉴 | `type: "menu"` 항목의 `entries`, 단독은 `SWViewerMenu` | `@hm-design/ui` DropdownMenu를 Header 안에 직접 배치 |
| 뷰어 팝업·설정창·도구 패널·입력 팝업 | `SWViewerPanel` (+ `modal`) | 새 프레임 작성, `SWPopup`(일반 SW 속성창 프레임)을 뷰어 디자인으로 재스타일 |
| 일반 SW 팝업·속성창 | `SWPopup`, `SWHeaderPropertyPanel` (변경 없음) | `SWViewerPanel` |
- 한 화면에는 Header 하나만 둡니다. 모드 전환은 프로그램이 Header 컴포넌트를 바꾸는 방식입니다(Storybook `SW/Viewer › SWHeader vs Viewer Header`). 파일·패널 상태는 프로그램 상태라 Header를 바꿔도 유지됩니다.
- 뷰어 모드 진입·종료, 파일 처리, DWG/DXF 렌더링, 메뉴 명령 실행은 **프로그램 담당**입니다. 패키지는 UI와 공통 인터랙션(열기/닫기, Focus, 키보드, 위치·크기)만 담당합니다.
- 기능이 없는 메뉴는 만들지 않습니다. 연결할 명령이 없으면 항목을 빼거나 `disabled`로 두고 “미연결”로 보고합니다.
## 2. `SWViewerHeader`
로고 면(사선) → 파일명 면 → 객체 수정 면(메뉴 그룹) → 오른쪽 끝 영역(`placement: "end"` 그룹 + `trailing`).
| Props | 설명 |
|---|---|
| `groups: SWViewerHeaderGroup[]` | 메뉴 데이터(순서대로 배치). 그룹: `id`, `label`(접근성 이름·More 구획 제목), `moreLabel?`, `surface?: "plain" \| "field"`(field = 사용자 확정 속성 입력 면), `placement?: "start" \| "end"`, `hidden?`, `items` |
| 항목 `SWViewerHeaderItem` | 공통: `id`, `label`, `icon?`(16px, 프로그램 제공), `title?`, `disabled?`, `hidden?`, `collapseBelow?`(Header 폭 미만이면 More로 이동), `iconOnlyBelow?`(미만이면 아이콘만), `iconOnly?`, `moreLabel?` |
| `type: "button"` | `onSelect` |
| `type: "toggle"` | `pressed`, `onPressedChange` (패널 열기/닫기 등) |
| `type: "menu"` | `entries: SWViewerMenuEntry[]`, `menuWidth?`, `menuAlign?: "start" \| "end"` |
| `type: "tabs"` | `value`, `options: { value, label, count?, title?, disabled? }[]`, `onValueChange` (Model / Layout) |
| `type: "switch"` | `checked`, `onCheckedChange` (2.5D 등, 위 11px 캡션) |
| `type: "custom"` | `render()`, `menuEntries?`(More에 넣을 항목) |
| `onCommand(command)` | 모든 명령을 한 곳에서 받습니다: `{ id, itemId, value? }`. 항목 자체 Callback 다음에 호출 |
| `documentTitle`, `documentPlaceholder`, `documentTooltip`, `document?` | 파일명 면 |
| `logo?`, `logoSrc?`, `logoAlt?`, `onHomeClick?` | `SWHeader`와 같은 로고 Props. **주지 않으면 아무것도 그리지 않습니다**(“HM” 문자·기본 아이콘 없음, 로고 면은 사선 끝 24px만). 주면 72px 면(로고가 더 넓으면 그만큼), 높이 최대 24px |
| `trailing?: ReactNode` | 오른쪽 끝 자유 영역: `SWViewerStatus`, `SWViewerHeaderButton` |
| `moreLabel?`, `toolbarLabel?`, `<header>` 속성, `ref` | |
- **반응형(참고 뷰어와 동일):** 항목별 `collapseBelow`로 More(⋯, 베이지 260px Dropdown)로 이동합니다. 참고 뷰어 기준값: 샘플·도면 공간·2.5D·어두운 배경 `1180`, 패널 토글 `900`, 파일 열기·전체 보기 `iconOnlyBelow: 560`. 1180px 미만은 간격 2px, 560px 미만은 상태 글자 숨김(CSS container). 가로 스크롤은 만들지 않습니다.
- More는 그룹/항목 단위 구획으로 자동 구성됩니다: menu·tabs는 자기 구획(`moreLabel`), button·toggle·switch는 그룹 구획(`group.moreLabel ?? group.label`). toggle·switch는 체크 항목, tabs는 라디오 항목이 됩니다.
- **상태 표현:** Hover = #266651 면, Active·Pressed·열림·선택 탭 = 글자·아이콘 #FFC600(선택 박스 없음), Disabled = 60% 불투명, Focus = 2px #FFC600 외곽선.
- **글자·아이콘 외곽선(SWHeader와 동일):** 14px 글자 `--hm-sw-header-text-outline`(검정 1.3px), 11px 캡션·개수·상태 `--hm-sw-header-label-outline`(검정 1px), 아이콘 `--hm-sw-viewer-icon-outline`(SWHeader 아이콘 자산의 1px 검정 바깥 Stroke + #061A14 glow를 `drop-shadow`로 재현, 아이콘 윤곽을 따름). 색·크기·굵기·간격은 그대로이고 Hover/Active는 채움 색만 바뀝니다. Dropdown·패널 안에는 외곽선이 없습니다(SWHeader More 메뉴와 같음). 프로그램 아이콘은 `currentColor`로 그리면 상태 색과 외곽선이 자동 적용됩니다.
## 3. `SWViewerMenu` / `SWViewerMenuEntry`
`entries`: `item`(`icon?`, `shortcut?`, `onSelect`), `checkbox`(`checked`, `onCheckedChange`), `radio`(`checked`, `onSelect`), `submenu`(`entries` — 다단계), `label`, `separator`. 공통 `disabled?`, `hidden?`, `keepOpen?`.
- Header의 `type: "menu"`가 이 Dropdown을 엽니다. 다른 위치(도면 우클릭 등)는 `SWViewerMenu`를 `open` / `onOpenChange` + `anchorRef`(버튼 아래 4px) 또는 `anchorPoint`(포인터 위치)로 씁니다. `surface="beige"`는 More 면, `initialFocus`는 `first`(키보드 열기) / `menu`(클릭 열기).
- 키보드: Enter·Space·ArrowDown 열기, ↑↓ Home End 이동, → 서브메뉴 열기, ← 한 단계 닫기, Escape 닫고 Trigger로 Focus 복귀, Tab·바깥 클릭 닫기. 화면 끝에서는 위/왼쪽으로 뒤집혀 화면 안에 남습니다.
## 4. `SWViewerPanel` — 팝업 프레임 + 자유 콘텐츠
외형(베이지 #E0DACE 본문, 2px #897445 테두리, #221D13 제목 막대, #FFC600 제목, 접기, 곡선 녹색 닫기 → Hover #E40000)과 공통 동작은 패키지가, 내용은 `children`·`footer`가 담당합니다.
| Props | 설명 |
|---|---|
| `title`, `icon?`, `open`, `onClose`, `closeLabel?` | 필수는 `title`·`open`·`onClose` |
| `children` | 스크롤 본문. `SWViewerSection`(카드/plain, 12px 제목), `SWViewerField`(라벨 + 컨트롤, `inline` / `stacked` + `below`), `SWViewerSwitch`, `SWViewerSlider`, `SWViewerInput`, `SWViewerButton`, `SWViewerBadge`, `SWViewerHint`, 프로그램 목록·표 등 무엇이든 |
| `footer?` | 하단 버튼 영역(본문 스크롤과 무관하게 고정). `SWViewerButton variant="outline" \| "primary"` |
| `headerActions?` | 제목 막대 추가 버튼: `SWViewerButton variant="ghost"` (기본값 복원, 선택 확대 등) |
| `width?`(320), `height?`(420, `"auto"` 가능), `minWidth?`(250), `minHeight?`(140) | 메뉴별 크기. 최대 90vw × 90vh |
| `defaultPosition?` | `{ left, top }` 또는 `"top-end"`(기본) / `"top-start"` / `"center"`(modal 기본), `inset?: { top?, side? }`(기본 56 / 24 = 52px Header 아래) |
| `strategy?` | `fixed`(기본, 화면) / `absolute`(가장 가까운 positioned 조상 안 — 미리보기·임베드) |
| `draggable?`, `resizable?`, `collapsible?`, `defaultCollapsed?`, `collapsed?`, `onCollapsedChange?`, `onPositionChange?`, `onSizeChange?` | 제목 막대 드래그, 오른쪽 아래 모서리 크기 조절, 접기(버튼·제목 더블클릭). 이동·크기는 경계 10px 안 |
| `modal?` | 10% 검정 배경, Focus Trap, 가운데 배치(입력·확인 팝업) |
| `autoFocus?` | 열 때 패널로 Focus, 닫을 때 원래 위치로 복귀(기본 true) |
- Escape는 Focus가 패널 안에 있을 때 그 패널만 닫습니다. 비모달 패널은 바깥 클릭으로 닫히지 않습니다(참고 뷰어와 동일, 도구 창). 마지막으로 누른 패널이 맨 앞으로 옵니다.
- **콘텐츠 교체:** 같은 프레임에 다른 `children`을 넣습니다(레이어 목록 / 옵션 / 속성 / 입력 폼 — Storybook `Popup Content Variants`). 프레임을 복사하거나 `hm-sw-viewer-*` class로 새 팝업을 만들지 않습니다.
## 5. 올바른 사용
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
import "@hm-design/sw/styles.css"
const groups: SWViewerHeaderGroup[] = [
{ id: "view", label: "화면 보기", surface: "field", items: [{ type: "button", id: "fit", label: "전체 보기", icon: <FitIcon />, onSelect: fit }] },
{ id: "panels", label: "패널", placement: "end", items: [{ type: "toggle", id: "options", label: "설정", icon: <OptionsIcon />, pressed: open, onPressedChange: setOpen, collapseBelow: 900 }] },
]
<SWViewerHeader documentTitle={fileName} groups={groups} onCommand={runCommand} />
<SWViewerPanel title="옵션" open={open} onClose={() => setOpen(false)}>
<SWViewerSection title="표시">
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={grid} onCheckedChange={setGrid} />}</SWViewerField>
</SWViewerSection>
</SWViewerPanel>
```
## 6. 잘못된 사용
| 하지 말 것 | 이유 / 대신 |
|---|---|
| `<SWViewerHeader logo="HM" />`처럼 로고가 없다고 임의 문자·아이콘 넣기 | 로고는 프로그램이 정합니다. 없으면 생략 |
| 뷰어 Header를 `div` + `button`으로 다시 만들기, `hm-sw-viewer-*` class를 소비 코드에 붙이기 | 데이터(`groups`)와 `custom` / `trailing`으로 구성 |
| Header 버튼 글자에 `-webkit-text-stroke`·`border`로 외곽선 만들기 | 외곽선은 패키지 토큰이 이미 적용합니다 |
| 메뉴 색·간격을 `#123456`, `margin` 등 임의 값으로 덮기 | 토큰 `--hm-sw-viewer-*` / `--hm-sw-header-*`만 `style`로 조정(기본값 유지 권장) |
| 존재하지 않는 Props 추측(`items`, `content`, `renderBody` …) | 위 표와 `dist/index.d.ts`가 전부 |
| 일반 모드 화면에서 `SWViewerHeader` 사용, 뷰어에서 `SWHeader` 재스타일 | 모드별 Header를 바꿔 끼웁니다 |
| 기능 없는 메뉴를 가짜 동작으로 연결 | 항목 제외 또는 `disabled` + 미연결 보고 |
## 7. 디자인 변경 시 주의
- 크기(52px Header, 34px 버튼, 40px 토글·탭·스위치 칸, 41~42px field 면, 72px 로고 면)와 색은 참고 뷰어 실측값입니다. 파일명·객체 수정·속성 면 배경은 SWHeader와 같은 사용자 확정 토큰(`--hm-sw-header-document-*`, `object-*`, `property-*`)을 공유하므로 바꾸면 두 Header가 함께 바뀝니다.
- Light/Dark 분기는 없습니다. 참고 뷰어와의 알려진 차이: 도면 공간(tabs) field 면 높이 42px(참고 41px — 참고 Tabs 루트가 39px인 구현 부산물), 크기 조절은 네이티브 `resize` 대신 같은 위치의 모서리 Grip(모든 브라우저 동작), 패널 Escape 닫기 추가, 로고 기본 표시 없음(사용자 결정).
@@ -0,0 +1,85 @@
// Example H — viewer mode: SWViewerHeader (data-driven menu) + SWViewerPanel popups with free content.
// Only public components. The program owns the state and the commands; the package owns the look and the
// open / close / focus / keyboard / drag behavior. Icons are the program's own (16px, currentColor).
import { useState } from "react"
import {
SWViewerButton, SWViewerField, SWViewerHeader, SWViewerHeaderButton, SWViewerHint, SWViewerInput, SWViewerPanel,
SWViewerSection, SWViewerSlider, SWViewerStatus, SWViewerSwitch,
type SWViewerHeaderGroup,
} from "@hm-design/sw"
const Icon = ({ d }: { d: string }) => (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>
)
const icons = {
open: <Icon d="M3 7h6l2 2h10v10H3z" />,
fit: <Icon d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5" />,
layers: <Icon d="M12 3l9 5-9 5-9-5 9-5zM3 13l9 5 9-5" />,
options: <Icon d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0" />,
info: <Icon d="M12 8h.01M11 12h1v5h1M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z" />,
}
export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => void }) {
const [fileName, setFileName] = useState<string | null>(null)
const [space, setSpace] = useState("model")
const [depth, setDepth] = useState(false)
const [panels, setPanels] = useState({ layers: false, options: false })
const [grid, setGrid] = useState(true)
const [zoomSpeed, setZoomSpeed] = useState(1)
const [about, setAbout] = useState(false)
const groups: SWViewerHeaderGroup[] = [
{ id: "file", label: "파일", items: [
{ type: "button", id: "open", label: "파일 열기", icon: icons.open, iconOnlyBelow: 560 },
{ type: "menu", id: "samples", label: "샘플", moreLabel: "샘플 도면", collapseBelow: 1180, entries: [
{ type: "item", id: "sample-a", label: "기본 도면 · DWG", onSelect: () => setFileName("BasicSample.dwg") },
{ type: "submenu", id: "recent", label: "최근 도면", entries: [{ type: "item", id: "recent-1", label: "simple.dxf", onSelect: () => setFileName("simple.dxf") }] },
] },
] },
{ id: "space", label: "도면 공간", surface: "field", hidden: !fileName, items: [
{ type: "tabs", id: "space", label: "모델과 배치 선택", collapseBelow: 1180, value: space, onValueChange: setSpace,
options: [{ value: "model", label: "Model", count: 1200 }, { value: "layout", label: "Layout", count: 40 }] },
] },
{ id: "view", label: "화면 보기", surface: "field", items: [
{ type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, disabled: !fileName },
{ type: "switch", id: "depth", label: "2.5D", checked: depth, collapseBelow: 1180, onCheckedChange: setDepth },
] },
{ id: "panels", label: "패널", placement: "end", items: [
{ type: "toggle", id: "layers", label: "레이어", icon: icons.layers, pressed: panels.layers, collapseBelow: 900, onPressedChange: layers => setPanels({ ...panels, layers }) },
{ type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: panels.options, collapseBelow: 900, onPressedChange: options => setPanels({ ...panels, options }) },
] },
]
return (
<>
{/* No logo prop: nothing is drawn on the cap (no "HM" text). Pass logoSrc / logo to show the program's logo. */}
<SWViewerHeader documentTitle={fileName} groups={groups}
onCommand={command => onEvent?.(`${command.itemId}/${command.id}${command.value === undefined ? "" : `=${String(command.value)}`}`)}
trailing={<>
<SWViewerStatus state={fileName ? "ready" : "idle"} />
<SWViewerHeaderButton iconOnly icon={icons.info} label="정보" onClick={() => setAbout(true)} />
</>} />
<SWViewerPanel title="레이어" icon={icons.layers} open={panels.layers} onClose={() => setPanels({ ...panels, layers: false })}>
<SWViewerSection surface="plain" aria-label="레이어 목록">
<SWViewerHint>프로그램의 레이어 목록을 여기에 둡니다.</SWViewerHint>
</SWViewerSection>
</SWViewerPanel>
<SWViewerPanel title="옵션" icon={icons.options} open={panels.options} onClose={() => setPanels({ ...panels, options: false })} width={340} height="auto" inset={{ side: 356 }}
footer={<SWViewerButton variant="primary" onClick={() => { onEvent?.("options:apply"); setPanels({ ...panels, options: false }) }}>적용</SWViewerButton>}>
<SWViewerSection title="표시">
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={grid} onCheckedChange={setGrid} />}</SWViewerField>
<SWViewerField label="줌 속도" layout="stacked" below={<SWViewerSlider aria-label="줌 속도" value={zoomSpeed} min={0.2} max={3} step={0.1} onValueChange={setZoomSpeed} />}>
{id => <SWViewerInput id={id} type="number" min={0.2} max={3} step={0.1} value={zoomSpeed} onChange={event => setZoomSpeed(Number(event.currentTarget.value))} />}
</SWViewerField>
</SWViewerSection>
</SWViewerPanel>
<SWViewerPanel modal title="정보" open={about} onClose={() => setAbout(false)} width={360} height="auto" resizable={false} collapsible={false}
footer={<SWViewerButton variant="primary" onClick={() => setAbout(false)}>확인</SWViewerButton>}>
<SWViewerHint>모듈 구성과 라이선스 고지는 프로그램이 제공합니다.</SWViewerHint>
</SWViewerPanel>
</>
)
}
@@ -0,0 +1,167 @@
import { forwardRef, useId, type ButtonHTMLAttributes, type HTMLAttributes, type InputHTMLAttributes, type ReactNode } from "react"
import "../../theme/sw-viewer.css"
const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(" ")
export interface SWViewerSwitchProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked: boolean
onCheckedChange?: (checked: boolean) => void
}
/**
* 36 x 20 switch. Its face follows where it is placed: dark header face (off #0A221D, on #FFC600) inside
* SWViewerHeader, beige panel face (off #9A9284, on #266651) inside SWViewerPanel.
*/
export const SWViewerSwitch = forwardRef<HTMLButtonElement, SWViewerSwitchProps>(function SWViewerSwitch(
{ checked, onCheckedChange, className, onClick, ...rest }, ref,
) {
return (
<button ref={ref} type="button" role="switch" aria-checked={checked} data-state={checked ? "checked" : "unchecked"}
className={cx("hm-sw-viewer-switch", className)}
onClick={event => { onClick?.(event); if (!event.defaultPrevented) onCheckedChange?.(!checked) }} {...rest}>
<span className="hm-sw-viewer-switch__thumb" aria-hidden="true" />
</button>
)
})
export type SWViewerStatusState = "idle" | "loading" | "ready" | "error"
export interface SWViewerStatusProps extends HTMLAttributes<HTMLDivElement> {
state: SWViewerStatusState
/** Short visible text. Default per state: 준비 / 불러오는 중 / 도면 열림 / 불러오기 실패. */
label?: string
/** Longer detail for screen readers and the tooltip. */
detail?: string
}
const statusLabel: Record<SWViewerStatusState, string> = { idle: "준비", loading: "불러오는 중", ready: "도면 열림", error: "불러오기 실패" }
const StatusIcon = ({ state }: { state: SWViewerStatusState }) => state === "idle" ? null : (
<svg className={state === "loading" ? "hm-sw-viewer-status__spin" : undefined} viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
{state === "loading" && <path d="M14 8a6 6 0 1 1-6-6" />}
{state === "ready" && <path d="M3.5 8.5l3 3 6-7" />}
{state === "error" && <><circle cx="8" cy="8" r="6" /><path d="M8 5v3.5M8 11h.01" /></>}
</svg>
)
/** Load state of the viewer header (reference status: muted 11px text, icon; text hides below 560px). */
export function SWViewerStatus({ state, label, detail, className, ...rest }: SWViewerStatusProps) {
return (
<div role="status" className={cx("hm-sw-viewer-status", className)} data-state={state} title={detail} {...rest}>
<StatusIcon state={state} />
<span className="hm-sw-viewer-status__label">{label ?? statusLabel[state]}</span>
{detail && <span className="hm-sw-viewer-sr-only">: {detail}</span>}
</div>
)
}
export interface SWViewerButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/**
* Panel buttons. `outline` (default): reference xs outline button (24px, white, #123328 border).
* `primary`: the same size filled with #123328 (footer confirm). `ghost`: icon button of the panel title bar.
*/
variant?: "outline" | "primary" | "ghost"
icon?: ReactNode
}
export const SWViewerButton = forwardRef<HTMLButtonElement, SWViewerButtonProps>(function SWViewerButton(
{ variant = "outline", icon, children, className, type = "button", ...rest }, ref,
) {
return (
<button ref={ref} type={type} className={cx("hm-sw-viewer-button", className)} data-variant={variant} {...rest}>
{icon && <span className="hm-sw-viewer-button__icon" aria-hidden="true">{icon}</span>}
{children}
</button>
)
})
export type SWViewerInputProps = InputHTMLAttributes<HTMLInputElement>
/** 28px panel input (#F5F0E5 face, #9A9284 border, 12px). Any native input type. */
export const SWViewerInput = forwardRef<HTMLInputElement, SWViewerInputProps>(function SWViewerInput({ className, ...rest }, ref) {
return <input ref={ref} className={cx("hm-sw-viewer-input", className)} {...rest} />
})
export interface SWViewerSliderProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
value: number
min: number
max: number
step?: number
onValueChange?: (value: number) => void
}
/** Panel slider (6px #E0DACE track, #266651 range, 18px white thumb with #266651 ring). Native range input. */
export const SWViewerSlider = forwardRef<HTMLInputElement, SWViewerSliderProps>(function SWViewerSlider(
{ value, min, max, step = 1, onValueChange, className, style, ...rest }, ref,
) {
const percent = max > min ? ((Math.min(max, Math.max(min, value)) - min) / (max - min)) * 100 : 0
return (
<input ref={ref} type="range" className={cx("hm-sw-viewer-slider", className)} value={value} min={min} max={max} step={step}
style={{ ...style, ["--hm-sw-viewer-slider-percent" as string]: `${percent}%` }}
onChange={event => onValueChange?.(Number(event.currentTarget.value))} {...rest} />
)
})
export interface SWViewerFieldProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
label: string
/** Control id for the label. Generated when omitted and `children` is a function. */
htmlFor?: string
/**
* `inline` (default): label left, control right (switch rows). `stacked`: label + control row, then `below`
* (slider rows: number input beside the label, slider underneath).
*/
layout?: "inline" | "stacked"
/** Unit after the control. */
unit?: string
/** Stacked layout: content under the label row (a slider). */
below?: ReactNode
disabled?: boolean
children: ReactNode | ((id: string) => ReactNode)
}
/** Label + control row of the viewer panel (reference option rows). */
export function SWViewerField({ label, htmlFor, layout = "inline", unit, below, disabled, children, className, ...rest }: SWViewerFieldProps) {
const generated = useId()
const id = htmlFor ?? generated
return (
<div className={cx("hm-sw-viewer-field", className)} data-layout={layout} data-disabled={disabled || undefined} {...rest}>
<div className="hm-sw-viewer-field__row">
<label className="hm-sw-viewer-field__label" htmlFor={id}>{label}</label>
<div className="hm-sw-viewer-field__control">
{typeof children === "function" ? children(id) : children}
{unit && <span className="hm-sw-viewer-field__unit">{unit}</span>}
</div>
</div>
{below}
</div>
)
}
export interface SWViewerBadgeProps extends HTMLAttributes<HTMLSpanElement> { children: ReactNode }
/** Count badge of panel lists (12px 500, #F4F1EA face). */
export function SWViewerBadge({ className, ...rest }: SWViewerBadgeProps) {
return <span className={cx("hm-sw-viewer-badge", className)} {...rest} />
}
export interface SWViewerSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
/** 12px 700 heading (reference panel h4). Also the section's accessible name. */
title?: string
/** `card` (default): the inset #F5F0E5 card. `plain`: no face (lists). */
surface?: "card" | "plain"
children?: ReactNode
}
/** A content group of SWViewerPanel. Children are free (fields, lists, any component). */
export function SWViewerSection({ title, surface = "card", children, className, ...rest }: SWViewerSectionProps) {
return (
<section className={cx("hm-sw-viewer-section", className)} data-surface={surface} aria-label={rest["aria-label"] ?? title} {...rest}>
{title && <h4 className="hm-sw-viewer-section__title">{title}</h4>}
{children}
</section>
)
}
/** Muted 12px help text of a panel section. */
export function SWViewerHint({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cx("hm-sw-viewer-hint", className)} {...rest} />
}
@@ -0,0 +1,287 @@
import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import { SWViewerMenu } from "./SWViewerMenu"
import { SWViewerSwitch } from "./SWViewerControls"
import type { SWViewerCommand, SWViewerHeaderGroup, SWViewerHeaderItem, SWViewerMenuEntry } from "./types"
import "../../theme/sw-viewer.css"
export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
/**
* Logo image URL of the program (same prop as SWHeader), max 24px high on the slanted green cap.
* The logo belongs to each program: nothing is rendered when omitted (no default text or icon) and the cap
* shrinks to its slanted edge.
*/
logoSrc?: string
/** Logo element (inline SVG, <img>, ...). Takes precedence over `logoSrc` (same prop as SWHeader). */
logo?: ReactNode
/** Accessible product name (same prop as SWHeader). With `onHomeClick` the button is "<logoAlt> 홈". */
logoAlt?: string
/** Makes the logo cap a button (e.g. back to the normal SW mode). Same prop as SWHeader. */
onHomeClick?: () => void
/** Open file name. `null` / empty shows `documentPlaceholder`. */
documentTitle?: string | null
/** Default `열린 도면 없음`. */
documentPlaceholder?: string
/** Tooltip of the file name. Default: the name, or `파일 열기 또는 샘플을 선택하세요`. */
documentTooltip?: string
/** Replaces the file-name text (the dark document well stays). */
document?: ReactNode
/** Menu data, in order. Groups with `placement="end"` form the trailing action area. */
groups: SWViewerHeaderGroup[]
/** Free content after the trailing groups (status, info button, ...). */
trailing?: ReactNode
/** Every command of the header and its dropdowns (after the item's own callback). */
onCommand?: (command: SWViewerCommand) => void
/** Accessible name of the More button. Default `추가 도구`. */
moreLabel?: string
/** Accessible name of the object surface. Default `도면 작업 도구`. */
toolbarLabel?: string
}
const MoreIcon = () => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor"><circle cx="3" cy="8" r="1.4" /><circle cx="8" cy="8" r="1.4" /><circle cx="13" cy="8" r="1.4" /></svg>
)
const ChevronDown = () => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
const isShown = (item: { hidden?: boolean; collapseBelow?: number }, width: number) => !item.hidden && !(item.collapseBelow !== undefined && width < item.collapseBelow)
/** More entries of the collapsed items, one section per group (menus and tabs get their own section). */
function moreEntries(groups: SWViewerHeaderGroup[], width: number, command: (c: SWViewerCommand) => void, owners: Map<string, string>): SWViewerMenuEntry[] {
const sections: SWViewerMenuEntry[][] = []
// Dropdown entries keep their own callbacks; the More menu reports them to onCommand with their owner item.
const own = (entries: SWViewerMenuEntry[], itemId: string) => entries.forEach(entry => { owners.set(entry.id, itemId); if (entry.type === "submenu") own(entry.entries, itemId) })
for (const group of groups) {
if (group.hidden) continue
const collapsed = group.items.filter(item => !item.hidden && item.collapseBelow !== undefined && width < item.collapseBelow)
let plain: SWViewerMenuEntry[] = []
const flush = () => { if (plain.length) sections.push([{ type: "label", id: `${group.id}:more`, label: group.moreLabel ?? group.label }, ...plain]); plain = [] }
for (const item of collapsed) {
if (item.type === "custom") { if (item.menuEntries?.length) { flush(); own(item.menuEntries, item.id); sections.push(item.menuEntries) } continue }
if (item.type === "menu") {
flush()
own(item.entries, item.id)
sections.push([{ type: "label", id: `${item.id}:more`, label: item.moreLabel ?? item.label },
...item.entries.map(entry => (entry.type === "label" || entry.type === "separator" ? entry : { ...entry, disabled: entry.disabled || item.disabled }))])
} else if (item.type === "tabs") {
flush()
sections.push([{ type: "label", id: `${item.id}:more`, label: item.moreLabel ?? item.label }, ...item.options.map(option => ({
type: "radio" as const, id: `${item.id}:${option.value}`, checked: option.value === item.value, disabled: option.disabled || item.disabled,
label: option.count !== undefined && option.count > 0 ? `${option.label} · ${option.count.toLocaleString()}개` : option.label,
onSelect: () => { item.onValueChange?.(option.value); command({ id: item.id, itemId: item.id, value: option.value }) },
}))])
} else if (item.type === "button") {
plain.push({ type: "item", id: item.id, label: item.label, icon: item.icon, disabled: item.disabled, onSelect: () => { item.onSelect?.(); command({ id: item.id, itemId: item.id }) } })
} else {
const checked = item.type === "toggle" ? item.pressed : item.checked
plain.push({ type: "checkbox", id: item.id, label: item.label, checked, disabled: item.disabled, onCheckedChange: next => {
if (item.type === "toggle") item.onPressedChange?.(next); else item.onCheckedChange?.(next)
command({ id: item.id, itemId: item.id, value: next })
} })
}
}
flush()
}
return sections.flatMap((section, index) => (index ? [{ type: "separator" as const, id: `more-sep-${index}` }, ...section] : section))
}
function MenuButton({ item, iconOnly, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "menu" }>; iconOnly: boolean | "always"; onCommand: (c: SWViewerCommand) => void }) {
const ref = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
const [focus, setFocus] = useState<"first" | "menu">("menu")
const menuId = useId()
const show = (how: "first" | "menu") => { setFocus(how); setOpen(true) }
return (
<>
<button ref={ref} type="button" className="hm-sw-viewer-header__button" data-kind="menu" data-icon-only={iconOnly === "always" ? "always" : iconOnly ? "" : undefined} data-state={open ? "open" : undefined}
aria-haspopup="menu" aria-expanded={open} aria-controls={open ? menuId : undefined} aria-label={iconOnly ? item.label : undefined} title={item.title} disabled={item.disabled}
onClick={() => (open ? setOpen(false) : show("menu"))}
onKeyDown={event => { if (event.key === "ArrowDown" || ((event.key === "Enter" || event.key === " ") && !open)) { event.preventDefault(); show("first") } }}>
{item.icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true">{item.icon}</span>}
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</span>}
<span className="hm-sw-viewer-header__chevron"><ChevronDown /></span>
</button>
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} label={item.label} initialFocus={focus}
onEntrySelect={entry => onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} />
</>
)
}
function Tabs({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "tabs" }>; onCommand: (c: SWViewerCommand) => void }) {
const select = (value: string) => { item.onValueChange?.(value); onCommand({ id: item.id, itemId: item.id, value }) }
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"]
if (!keys.includes(event.key)) return
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled)'))
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
if (index < 0) return
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length
buttons[next].focus()
buttons[next].click()
}
const current = item.options.some(option => option.value === item.value) ? item.value : item.options.find(option => !option.disabled)?.value
return (
<div role="radiogroup" aria-label={item.label} className="hm-sw-viewer-header__tabs" onKeyDown={onKeyDown}>
{item.options.map(option => {
const selected = option.value === item.value
return (
<button key={option.value} type="button" role="radio" aria-checked={selected} tabIndex={option.value === current ? 0 : -1}
className="hm-sw-viewer-header__tab" data-state={selected ? "active" : undefined} title={option.title} disabled={item.disabled || option.disabled}
onClick={() => { if (!selected) select(option.value) }}>
{option.label}
{option.count !== undefined && option.count > 0 && <span className="hm-sw-viewer-header__count">{option.count.toLocaleString()}</span>}
</button>
)
})}
</div>
)
}
function SwitchField({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "switch" }>; onCommand: (c: SWViewerCommand) => void }) {
const id = useId()
return (
<div className="hm-sw-viewer-header__switch-field" title={item.title}>
<label htmlFor={id} className="hm-sw-viewer-header__switch-label">{item.label}</label>
<SWViewerSwitch id={id} checked={item.checked} disabled={item.disabled}
onCheckedChange={next => { item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} />
</div>
)
}
function Item({ item, width, onCommand }: { item: SWViewerHeaderItem; width: number; onCommand: (c: SWViewerCommand) => void }) {
if (item.type === "custom") return <>{item.render()}</>
const iconOnly = !!item.icon && (item.iconOnly ? "always" : item.iconOnlyBelow !== undefined && width < item.iconOnlyBelow)
if (item.type === "menu") return <MenuButton item={item} iconOnly={iconOnly} onCommand={onCommand} />
if (item.type === "tabs") return <Tabs item={item} onCommand={onCommand} />
if (item.type === "switch") return <SwitchField item={item} onCommand={onCommand} />
const toggle = item.type === "toggle"
return (
<SWViewerHeaderButton label={item.label} icon={item.icon} iconOnly={iconOnly === "always" ? true : iconOnly ? "compact" : false} pressed={toggle ? item.pressed : undefined} title={item.title} disabled={item.disabled}
onClick={() => {
if (toggle) { item.onPressedChange?.(!item.pressed); onCommand({ id: item.id, itemId: item.id, value: !item.pressed }) }
else { item.onSelect?.(); onCommand({ id: item.id, itemId: item.id }) }
}} />
)
}
export interface SWViewerHeaderButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
/** Visible text, or the accessible name when `iconOnly`. */
label: string
/** 16px icon supplied by the program. */
icon?: ReactNode
/**
* `true`: always icon only, the 32 x 40 icon button (info, More). `"compact"`: the 34 x 34 command button shown
* without its label (what `iconOnlyBelow` does). Default false.
*/
iconOnly?: boolean | "compact"
/** Toggle state (aria-pressed, #FFC600 when on). Leave undefined for a command button. */
pressed?: boolean
}
/**
* The viewer header button for the `trailing` slot or a `custom` item (e.g. the info button that opens a modal
* SWViewerPanel). Same look as the data-driven items: transparent, #266651 hover, #FFC600 when pressed.
*/
export const SWViewerHeaderButton = forwardRef<HTMLButtonElement, SWViewerHeaderButtonProps>(function SWViewerHeaderButton(
{ label, icon, iconOnly = false, pressed, className, type = "button", ...rest }, ref,
) {
const kind = pressed === undefined ? "button" : "toggle"
return (
<button ref={ref} type={type} className={`hm-sw-viewer-header__button${className ? ` ${className}` : ""}`} data-kind={kind} data-icon-only={iconOnly === true ? "always" : iconOnly ? "" : undefined}
data-state={pressed === undefined ? undefined : pressed ? "on" : "off"} aria-pressed={pressed} aria-label={iconOnly ? label : undefined} {...rest}>
{icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true">{icon}</span>}
{!iconOnly && <span className="hm-sw-viewer-header__label">{label}</span>}
</button>
)
})
/**
* SW viewer-mode header (reference DWG/DXF viewer top menu): slanted brand cap → dark file-name well → one green
* object surface holding data-driven menu groups, with a trailing action area. Items move into the More dropdown
* by `collapseBelow` (measured header width, never horizontal scroll). Pure presentation: values are props, every
* action is a callback (`onCommand` plus each item's own). Use SWHeader for the normal SW mode.
*/
export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(function SWViewerHeader(
{
logo, logoSrc, logoAlt, onHomeClick, documentTitle, documentPlaceholder = "열린 도면 없음", documentTooltip, document: documentNode,
groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest
},
forwardedRef,
) {
const rootRef = useRef<HTMLElement | null>(null)
const [width, setWidth] = useState(1920)
const gradientId = `hm-sw-viewer-brand-${useId().replace(/:/g, "")}`
useLayoutEffect(() => {
const root = rootRef.current
if (!root) return
const update = () => setWidth(root.getBoundingClientRect().width)
update()
const observer = new ResizeObserver(update)
observer.observe(root)
return () => observer.disconnect()
}, [])
const command = (value: SWViewerCommand) => onCommand?.(value)
const owners = new Map<string, string>()
const more = moreEntries(groups, width, command, owners)
const moreRef = useRef<HTMLButtonElement>(null)
const [moreOpen, setMoreOpen] = useState(false)
const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu")
const renderGroup = (group: SWViewerHeaderGroup) => {
const items = group.items.filter(item => isShown(item, width))
if (group.hidden || !items.length) return null
return (
<div key={group.id} role="group" aria-label={group.label} className="hm-sw-viewer-header__group" data-surface={group.surface ?? "plain"} data-group={group.id}>
{items.map(item => <Item key={item.id} item={item} width={width} onCommand={command} />)}
</div>
)
}
const start = groups.filter(group => group.placement !== "end")
const end = groups.filter(group => group.placement === "end")
const name = documentTitle || documentPlaceholder
const mark = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
const brandContent = (
<>
<svg className="hm-sw-viewer-header__brand-shape" viewBox="0 0 88 52" preserveAspectRatio="xMaxYMid slice" aria-hidden="true" focusable="false">
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--hm-sw-viewer-brand-top)" />
<stop offset="45%" stopColor="var(--hm-sw-viewer-brand-middle)" />
<stop offset="100%" stopColor="var(--hm-sw-viewer-brand-bottom)" />
</linearGradient>
</defs>
<path d="M 0 0 H 88 L 73 44 Q 70 52 62 52 H 0 Z" fill={`url(#${gradientId})`} />
<path d="M 88 0 L 73 44 Q 70 52 62 52 H 0" fill="none" stroke="var(--hm-sw-viewer-brand-middle)" vectorEffect="non-scaling-stroke" />
</svg>
{mark && <span className="hm-sw-viewer-header__brand-mark" aria-hidden="true">{mark}</span>}
</>
)
return (
<header ref={node => { rootRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node }}
className={`hm-sw-theme hm-sw-viewer-header${className ? ` ${className}` : ""}`} {...rest}>
{onHomeClick
? <button type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} title={logoAlt} onClick={onHomeClick}>{brandContent}</button>
: <div className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} role={logoAlt ? "img" : undefined} aria-label={logoAlt}>{brandContent}</div>}
<div className="hm-sw-viewer-header__document" role="group" aria-label="현재 도면">
{documentNode ?? <span className="hm-sw-viewer-header__document-name" data-empty={!documentTitle || undefined} title={documentTooltip ?? (documentTitle || "파일 열기 또는 샘플을 선택하세요")}>{name}</span>}
</div>
<div className="hm-sw-viewer-header__object" role="toolbar" aria-label={toolbarLabel}>
{start.map(renderGroup)}
{more.length > 0 && <>
<button ref={moreRef} type="button" className="hm-sw-viewer-header__button hm-sw-viewer-header__more" data-icon-only="always" data-state={moreOpen ? "open" : undefined}
aria-label={moreLabel} title="더보기" aria-haspopup="menu" aria-expanded={moreOpen}
onClick={() => { setMoreFocus("menu"); setMoreOpen(!moreOpen) }}
onKeyDown={event => { if (event.key === "ArrowDown") { event.preventDefault(); setMoreFocus("first"); setMoreOpen(true) } }}>
<span className="hm-sw-viewer-header__icon"><MoreIcon /></span>
</button>
<SWViewerMenu entries={more} open={moreOpen} onOpenChange={setMoreOpen} anchorRef={moreRef} align="end" width={260} surface="beige" label={moreLabel} initialFocus={moreFocus}
onEntrySelect={entry => {
const owner = owners.get(entry.id)
if (owner) command({ id: entry.id, itemId: owner, value: entry.type === "checkbox" ? !entry.checked : undefined })
}} />
</>}
{(end.length > 0 || trailing) && <div className="hm-sw-viewer-header__actions">{end.map(renderGroup)}{trailing}</div>}
</div>
</header>
)
})
@@ -0,0 +1,226 @@
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"
import type { SWViewerMenuEntry } from "./types"
import "../../theme/sw-viewer.css"
export interface SWViewerMenuProps {
entries: SWViewerMenuEntry[]
open: boolean
onOpenChange: (open: boolean) => void
/** Element the menu opens below (a header button). Focus returns to it on close. */
anchorRef?: RefObject<HTMLElement | null>
/** Opens at a point instead (context menu: the pointer position). */
anchorPoint?: { x: number; y: number }
/** Edge aligned to the anchor. Default `start`. */
align?: "start" | "end"
/** Width in px. Default: its content (min 160). */
width?: number
/** `light` (default): the white dropdown face. `beige`: the More face of the viewer header. */
surface?: "light" | "beige"
/** Accessible name of the menu. */
label?: string
/** Called for every activated entry (after its own callback). */
onEntrySelect?: (entry: SWViewerMenuEntry) => void
/** `first` (default): focus the first entry, as after Enter / ArrowDown. `menu`: focus the list, as after a click. */
initialFocus?: "first" | "menu"
/** Id of the menu element (for the trigger's aria-controls). */
id?: string
className?: string
}
const ITEM_SELECTOR = ':scope > [role="none"] > [role^="menuitem"]:not([aria-disabled="true"])'
function visible(entries: SWViewerMenuEntry[]) {
const shown = entries.filter(entry => !entry.hidden)
// No leading, trailing or doubled separators once hidden entries are gone.
return shown.filter((entry, index) => entry.type !== "separator"
|| (index > 0 && index < shown.length - 1 && shown[index - 1].type !== "separator"))
}
const CheckIcon = () => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
const ChevronRight = () => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6 3.5l4.5 4.5L6 12.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
interface ListProps {
entries: SWViewerMenuEntry[]
surface: "light" | "beige"
width?: number
label?: string
/** Fixed position computer: receives the list size, returns left/top. */
place: (width: number, height: number) => { left: number; top: number }
/** Activate: run the entry and close everything (unless keepOpen). */
activate: (entry: SWViewerMenuEntry) => void
/** Escape / Tab: close everything. */
closeAll: (returnFocus: boolean) => void
/** ArrowLeft inside a submenu: close only this level. */
closeLevel?: () => void
/** `first`: focus the first entry (keyboard open). `menu`: focus the list itself (pointer open). */
autoFocus: "first" | "menu" | false
id?: string
className?: string
}
function MenuList({ entries, surface, width, label, place, activate, closeAll, closeLevel, autoFocus, id, className }: ListProps) {
const ref = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
const [openSub, setOpenSub] = useState<string | null>(null)
const [focusSub, setFocusSub] = useState(false)
const placeRef = useRef(place)
placeRef.current = place
useLayoutEffect(() => {
const update = () => {
const list = ref.current
if (!list) return
const next = placeRef.current(list.offsetWidth, list.offsetHeight)
setPosition(current => (current && current.left === next.left && current.top === next.top ? current : next))
}
update()
window.addEventListener("resize", update)
window.addEventListener("scroll", update, true)
return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true) }
}, [])
useEffect(() => {
if (!autoFocus || !position) return
const target = autoFocus === "first" ? ref.current?.querySelector<HTMLElement>(ITEM_SELECTOR) : ref.current
target?.focus({ preventScroll: true })
}, [autoFocus, position !== null])
const items = () => Array.from(ref.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// Only this level's own keys; a submenu inside handles (and stops) its own.
if (event.nativeEvent.isComposing) return
const list = items()
const index = list.indexOf(document.activeElement as HTMLElement)
const move = (next: number) => { event.preventDefault(); list[(next + list.length) % list.length]?.focus() }
switch (event.key) {
case "ArrowDown": move(index + 1); break
case "ArrowUp": move(index < 0 ? list.length - 1 : index - 1); break
case "Home": move(0); break
case "End": move(list.length - 1); break
case "ArrowLeft":
if (closeLevel) { event.preventDefault(); event.stopPropagation(); closeLevel() }
break
case "Escape": event.preventDefault(); event.stopPropagation(); closeAll(true); break
case "Tab": closeAll(false); break
default: return
}
event.stopPropagation()
}
const rows: ReactNode[] = visible(entries).map(entry => {
if (entry.type === "separator") return <div key={entry.id} role="separator" className="hm-sw-viewer-menu__separator" />
if (entry.type === "label") return <div key={entry.id} role="presentation" className="hm-sw-viewer-menu__label">{entry.label}</div>
const disabled = !!entry.disabled
const common = {
tabIndex: -1,
"aria-disabled": disabled || undefined,
"data-entry": entry.id,
onPointerMove: (event: { currentTarget: HTMLElement }) => {
if (disabled) return
if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true })
if (entry.type !== "submenu" && openSub) setOpenSub(null)
},
}
if (entry.type === "submenu") {
const expanded = openSub === entry.id
const openThis = (focus: boolean) => { if (disabled) return; setFocusSub(focus); setOpenSub(entry.id) }
return (
<div key={entry.id} role="none" className="hm-sw-viewer-menu__entry">
<button type="button" role="menuitem" className="hm-sw-viewer-menu__item" aria-haspopup="menu" aria-expanded={expanded} data-state={expanded ? "open" : undefined} {...common}
onPointerEnter={() => openThis(false)}
onClick={() => openThis(true)}
onKeyDown={event => { if (event.key === "ArrowRight" || event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); openThis(true) } }}>
<span className="hm-sw-viewer-menu__icon" aria-hidden="true">{entry.icon}</span>
<span className="hm-sw-viewer-menu__text">{entry.label}</span>
<span className="hm-sw-viewer-menu__chevron"><ChevronRight /></span>
</button>
{expanded && <MenuList entries={entry.entries} surface={surface} label={entry.label} autoFocus={focusSub ? "first" : false}activate={activate} closeAll={closeAll}
closeLevel={() => { setOpenSub(null); ref.current?.querySelector<HTMLElement>(`[data-entry="${CSS.escape(entry.id)}"]`)?.focus() }}
place={(w, h) => {
const row = ref.current?.querySelector<HTMLElement>(`[data-entry="${CSS.escape(entry.id)}"]`)?.getBoundingClientRect()
const vw = document.documentElement.clientWidth, vh = window.innerHeight
if (!row) return { left: 0, top: 0 }
// Beside the parent list (its outer edge), first entry level with the row; flips left at the screen edge.
const list = ref.current!.getBoundingClientRect()
const left = list.right + w <= vw - 4 ? list.right : Math.max(4, list.left - w)
return { left, top: Math.max(4, Math.min(row.top - 5, vh - h - 4)) }
}} />}
</div>
)
}
const role = entry.type === "checkbox" ? "menuitemcheckbox" : entry.type === "radio" ? "menuitemradio" : "menuitem"
const checked = entry.type === "item" ? undefined : entry.checked
return (
<div key={entry.id} role="none" className="hm-sw-viewer-menu__entry">
<button type="button" role={role} className="hm-sw-viewer-menu__item" aria-checked={checked} data-kind={entry.type} {...common}
onClick={() => { if (!disabled) activate(entry) }}>
<span className="hm-sw-viewer-menu__icon" aria-hidden="true">
{entry.type === "item" ? entry.icon : checked ? entry.type === "radio" ? <span className="hm-sw-viewer-menu__dot" /> : <CheckIcon /> : null}
</span>
<span className="hm-sw-viewer-menu__text">{entry.label}</span>
{entry.type === "item" && entry.shortcut && <span className="hm-sw-viewer-menu__shortcut">{entry.shortcut}</span>}
</button>
</div>
)
})
return (
<div ref={ref} id={id} role="menu" tabIndex={-1} aria-label={label} aria-orientation="vertical" className={`hm-sw-theme hm-sw-viewer-menu${className ? ` ${className}` : ""}`} data-surface={surface}
style={{ left: position?.left ?? 0, top: position?.top ?? 0, width, visibility: position ? undefined : "hidden" }}
onKeyDown={onKeyDown}>
{rows.length ? rows : <div role="presentation" className="hm-sw-viewer-menu__label">항목 없음</div>}
</div>
)
}
/**
* SW viewer dropdown (reference DWG/DXF viewer menu): fixed overlay below an anchor (or at a point), kept inside
* the viewport. Items, check / radio items, labels, separators and multi-level submenus come from `entries`.
* Keyboard: ArrowUp / ArrowDown / Home / End move, Enter / Space activate, ArrowRight / ArrowLeft open / close a
* submenu, Escape closes and returns focus to the anchor, Tab closes. A click outside closes.
*/
export function SWViewerMenu({ entries, open, onOpenChange, anchorRef, anchorPoint, align = "start", width, surface = "light", label, onEntrySelect, initialFocus = "first", id, className }: SWViewerMenuProps) {
const rootRef = useRef<HTMLDivElement>(null)
const changeRef = useRef(onOpenChange)
changeRef.current = onOpenChange
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (rootRef.current?.contains(target) || anchorRef?.current?.contains(target)) return
changeRef.current(false)
}
document.addEventListener("pointerdown", onPointerDown, true)
return () => document.removeEventListener("pointerdown", onPointerDown, true)
}, [open, anchorRef])
if (!open) return null
const closeAll = (returnFocus: boolean) => {
onOpenChange(false)
if (returnFocus) anchorRef?.current?.focus()
}
const activate = (entry: SWViewerMenuEntry) => {
if (entry.type === "item" || entry.type === "radio") entry.onSelect?.()
else if (entry.type === "checkbox") entry.onCheckedChange?.(!entry.checked)
onEntrySelect?.(entry)
if (!("keepOpen" in entry && entry.keepOpen)) closeAll(true)
}
const place = (w: number, h: number) => {
const vw = document.documentElement.clientWidth, vh = window.innerHeight
const box = anchorPoint ? { left: anchorPoint.x, right: anchorPoint.x, top: anchorPoint.y, bottom: anchorPoint.y }
: anchorRef?.current?.getBoundingClientRect() ?? { left: 0, right: 0, top: 0, bottom: 0 }
const start = align === "end" ? box.right - w : box.left
const left = Math.max(4, Math.min(start, vw - w - 4))
// 4px under a button; exactly at the pointer for a context menu.
const gap = anchorPoint ? 0 : 4
const below = box.bottom + gap
const top = below + h > vh - 4 && box.top - h - gap >= 4 ? box.top - h - gap : Math.max(4, Math.min(below, vh - h - 4))
return { left, top }
}
return (
<div ref={rootRef} className="hm-sw-viewer-menu-root">
<MenuList entries={entries} surface={surface} width={width} label={label} place={place} activate={activate} closeAll={closeAll} autoFocus={initialFocus} id={id} className={className} />
</div>
)
}
@@ -0,0 +1,236 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"
import "../../theme/sw-viewer.css"
export type SWViewerPanelPlacement = "top-end" | "top-start" | "center"
export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
/** Title (14px 700 #FFC600, ellipsis). Also the accessible name. */
title: string
/** 16px title icon supplied by the program. */
icon?: ReactNode
/** Shown when true. Unmounted otherwise (no hidden DOM). */
open: boolean
/** Close button, and Escape while focus is inside the panel. */
onClose: () => void
closeLabel?: string
/** Extra title-bar buttons before collapse / close (use `SWViewerButton variant="ghost"`). */
headerActions?: ReactNode
/** Free body content: SWViewerSection / SWViewerField / lists / any component. The body scrolls. */
children?: ReactNode
/** Bottom button area (e.g. 취소 / 적용). Stays visible while the body scrolls. */
footer?: ReactNode
/** Initial size. Default 320 x 420 (reference panel). `height: "auto"` follows the content. */
width?: number
height?: number | "auto"
minWidth?: number
minHeight?: number
/** Initial position: a point, or a placement inside the bounds. Default `top-end`. */
defaultPosition?: { left: number; top: number } | SWViewerPanelPlacement
/** Placement insets in px. Default `{ top: 56, side: 24 }` (below the 52px viewer header). */
inset?: { top?: number; side?: number }
/** `fixed` (default): the viewport. `absolute`: the nearest positioned ancestor (embedded preview). */
strategy?: "fixed" | "absolute"
/** Drag by the title bar. Default true. */
draggable?: boolean
/** Corner grip resize (min / max size, kept inside the bounds). Default true. */
resizable?: boolean
/** Collapse button and title double click. Default true. */
collapsible?: boolean
defaultCollapsed?: boolean
collapsed?: boolean
onCollapsedChange?: (collapsed: boolean) => void
/** Dim backdrop, focus trap and centered default (input / confirm popups). Default false. */
modal?: boolean
/** Focus the panel when it opens; focus returns to the previous element when it closes. Default true. */
autoFocus?: boolean
onPositionChange?: (position: { left: number; top: number }) => void
/** After a corner resize. */
onSizeChange?: (size: { width: number; height: number }) => void
}
let topLayer = 30
const EDGE = 10
const INTERACTIVE = "button, input, select, textarea, a, label, [data-panel-no-drag]"
const Chevron = ({ down }: { down: boolean }) => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d={down ? "M4 6l4 4 4-4" : "M6 4l4 4-4 4"} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
function bounds(element: HTMLElement | null, strategy: "fixed" | "absolute") {
const parent = strategy === "absolute" ? element?.offsetParent as HTMLElement | null : null
return parent ? { width: parent.clientWidth, height: parent.clientHeight } : { width: document.documentElement.clientWidth, height: window.innerHeight }
}
function clamp(left: number, top: number, element: HTMLElement, strategy: "fixed" | "absolute") {
const box = bounds(element, strategy)
return {
left: Math.round(Math.min(Math.max(left, EDGE), Math.max(EDGE, box.width - element.offsetWidth - EDGE))),
top: Math.round(Math.min(Math.max(top, EDGE), Math.max(EDGE, box.height - element.offsetHeight - EDGE))),
}
}
/**
* SW viewer popup / settings window (reference DWG/DXF viewer floating panel): #E0DACE body, 2px #897445 border,
* #221D13 title bar with #FFC600 title, collapse and the curved green close (#E40000 on hover). Non-modal by
* default: drag by the title bar, corner resize, collapse, stays inside the bounds, Escape inside closes, the
* last touched panel comes to the front. The frame is fixed; `children` / `footer` are free.
*/
export function SWViewerPanel({
title, icon, open, onClose, closeLabel = "닫기", headerActions, children, footer,
width = 320, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed",
draggable = true, resizable = true, collapsible = true, defaultCollapsed = false, collapsed: collapsedProp, onCollapsedChange,
modal = false, autoFocus = true, onPositionChange, onSizeChange, className, style, onKeyDown, onPointerDownCapture, onFocusCapture, ...rest
}: SWViewerPanelProps) {
const ref = useRef<HTMLDivElement>(null)
const titleId = useId()
const closeGradient = `hm-sw-viewer-close-${useId().replace(/:/g, "")}`
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
const [layer, setLayer] = useState(() => ++topLayer)
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed)
const collapsed = collapsible && (collapsedProp ?? internalCollapsed)
const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
// Size after a corner resize (null: the width / height props).
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
const resize = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
const positionRef = useRef(position)
positionRef.current = position
const placement = defaultPosition ?? (modal ? "center" : "top-end")
// Initial position, measured once per opening; then kept inside the bounds when they change.
useLayoutEffect(() => {
if (!open) { setPosition(null); setSize(null); return }
const panel = ref.current
if (!panel) return
const box = bounds(panel, strategy)
const top = inset?.top ?? 56, side = inset?.side ?? 24
const start = typeof placement === "object" ? placement
: placement === "center" ? { left: (box.width - panel.offsetWidth) / 2, top: (box.height - panel.offsetHeight) / 2 }
: { left: placement === "top-end" ? box.width - panel.offsetWidth - side : side, top }
setPosition(clamp(start.left, start.top, panel, strategy))
const keepInside = () => { const current = positionRef.current; if (current && ref.current) setPosition(clamp(current.left, current.top, ref.current, strategy)) }
window.addEventListener("resize", keepInside)
const parent = strategy === "absolute" ? panel.offsetParent : null
const observer = parent ? new ResizeObserver(keepInside) : null
if (parent) observer?.observe(parent)
return () => { window.removeEventListener("resize", keepInside); observer?.disconnect() }
}, [open])
// Focus in once positioned (a hidden panel cannot take focus), back to the opener on close.
const placed = position !== null
useEffect(() => {
if (!open || !autoFocus) return
const previous = document.activeElement as HTMLElement | null
return () => { if (previous?.isConnected) previous.focus({ preventScroll: true }) }
}, [open, autoFocus])
useEffect(() => {
if (open && autoFocus && placed && !ref.current?.contains(document.activeElement)) ref.current?.focus({ preventScroll: true })
}, [open, autoFocus, placed])
if (!open) return null
const raise = () => { if (layer !== topLayer) setLayer(++topLayer) }
const setCollapsed = (next: boolean) => { if (collapsedProp === undefined) setInternalCollapsed(next); onCollapsedChange?.(next) }
const onTitlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest(INTERACTIVE) || !ref.current || !position) return
drag.current = { x: event.clientX, y: event.clientY, left: position.left, top: position.top }
event.currentTarget.setPointerCapture(event.pointerId)
event.currentTarget.dataset.dragging = ""
}
const onTitlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const start = drag.current
if (!start || !ref.current) return
setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, ref.current, strategy))
}
const onTitlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!drag.current) return
drag.current = null
delete event.currentTarget.dataset.dragging
if (positionRef.current) onPositionChange?.(positionRef.current)
}
const onGripPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !ref.current) return
event.preventDefault()
resize.current = { x: event.clientX, y: event.clientY, width: ref.current.offsetWidth, height: ref.current.offsetHeight }
event.currentTarget.setPointerCapture(event.pointerId)
}
const onGripPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const start = resize.current, panel = ref.current
if (!start || !panel || !position) return
const box = bounds(panel, strategy)
// Min size, 90% of the bounds, and never past the bounds edge (10px) from the current corner.
const maxWidth = Math.min(box.width * 0.9, box.width - position.left - EDGE), maxHeight = Math.min(box.height * 0.9, box.height - position.top - EDGE)
setSize({
width: Math.round(Math.max(minWidth, Math.min(maxWidth, start.width + event.clientX - start.x))),
height: Math.round(Math.max(minHeight, Math.min(maxHeight, start.height + event.clientY - start.y))),
})
}
const onGripPointerUp = () => {
if (!resize.current) return
resize.current = null
if (ref.current) onSizeChange?.({ width: ref.current.offsetWidth, height: ref.current.offsetHeight })
}
const onPanelKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented || event.nativeEvent.isComposing) return
if (event.key === "Escape") { event.stopPropagation(); onClose(); return }
if (modal && event.key === "Tab" && ref.current) {
const focusables = Array.from(ref.current.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])'))
if (!focusables.length) { event.preventDefault(); return }
const first = focusables[0], last = focusables[focusables.length - 1]
if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
}
const frameStyle: CSSProperties = {
position: strategy,
left: position?.left ?? 0,
top: position?.top ?? 0,
zIndex: layer,
width: size?.width ?? width,
height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height,
minWidth,
minHeight: collapsed ? undefined : Math.min(minHeight, typeof height === "number" ? height : minHeight),
visibility: position ? undefined : "hidden",
...style,
}
const panel = (
<div ref={ref} role="dialog" aria-modal={modal} aria-labelledby={titleId} tabIndex={-1}
className={`hm-sw-theme hm-sw-viewer-panel${className ? ` ${className}` : ""}`} data-collapsed={collapsed || undefined} data-modal={modal || undefined}
style={frameStyle} {...rest}
onKeyDown={onPanelKeyDown}
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}>
<div className="hm-sw-viewer-panel__titlebar" data-draggable={draggable || undefined}
onPointerDown={onTitlePointerDown} onPointerMove={onTitlePointerMove} onPointerUp={onTitlePointerUp} onPointerCancel={onTitlePointerUp}
onDoubleClick={event => { if (collapsible && !(event.target as HTMLElement).closest(INTERACTIVE)) setCollapsed(!collapsed) }}>
<h2 id={titleId} className="hm-sw-viewer-panel__title" title={title}>
{icon && <span className="hm-sw-viewer-panel__title-icon" aria-hidden="true">{icon}</span>}
<span className="hm-sw-viewer-panel__title-text">{title}</span>
</h2>
<div className="hm-sw-viewer-panel__actions">
{headerActions}
{collapsible && <button type="button" className="hm-sw-viewer-panel__icon-button" aria-label={collapsed ? "펼치기" : "접기"} title={collapsed ? "펼치기" : "접기"} aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}><Chevron down={!collapsed} /></button>}
<button type="button" className="hm-sw-viewer-panel__close" aria-label={closeLabel} title={closeLabel} onClick={onClose}>
<svg className="hm-sw-viewer-panel__close-shape" viewBox="0 0 52 34" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<defs>
<radialGradient id={closeGradient} cx="55%" cy="30%" r="85%">
<stop offset="0%" stopColor="var(--hm-sw-viewer-close-glow)" />
<stop offset="100%" stopColor="var(--hm-sw-viewer-close-edge)" />
</radialGradient>
</defs>
<path d="M 0 0 C 12 0 14 34 26 34 H 52 V 0 Z" fill={`url(#${closeGradient})`} />
<path className="hm-sw-viewer-panel__close-curve" d="M 0 0 C 12 0 14 34 26 34" fill="none" vectorEffect="non-scaling-stroke" />
</svg>
<svg className="hm-sw-viewer-panel__close-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /></svg>
</button>
</div>
</div>
{!collapsed && <div className="hm-sw-viewer-panel__body">{children}</div>}
{!collapsed && footer && <div className="hm-sw-viewer-panel__footer">{footer}</div>}
{resizable && !collapsed && <div className="hm-sw-viewer-panel__resize" aria-hidden="true" data-panel-no-drag=""
onPointerDown={onGripPointerDown} onPointerMove={onGripPointerMove} onPointerUp={onGripPointerUp} onPointerCancel={onGripPointerUp} />}
</div>
)
return modal ? <><div className="hm-sw-viewer-panel-backdrop" style={{ position: strategy, zIndex: layer }} aria-hidden="true" />{panel}</> : panel
}
@@ -0,0 +1,9 @@
export { SWViewerHeader, SWViewerHeaderButton, type SWViewerHeaderProps, type SWViewerHeaderButtonProps } from "./SWViewerHeader"
export { SWViewerMenu, type SWViewerMenuProps } from "./SWViewerMenu"
export { SWViewerPanel, type SWViewerPanelProps, type SWViewerPanelPlacement } from "./SWViewerPanel"
export {
SWViewerSection, SWViewerField, SWViewerHint, SWViewerSwitch, SWViewerSlider, SWViewerInput, SWViewerButton, SWViewerBadge, SWViewerStatus,
type SWViewerSectionProps, type SWViewerFieldProps, type SWViewerSwitchProps, type SWViewerSliderProps, type SWViewerInputProps,
type SWViewerButtonProps, type SWViewerBadgeProps, type SWViewerStatusProps, type SWViewerStatusState,
} from "./SWViewerControls"
export type { SWViewerHeaderItem, SWViewerHeaderGroup, SWViewerMenuEntry, SWViewerCommand } from "./types"
@@ -0,0 +1,131 @@
import type { ReactNode } from "react"
/** One entry of an SW viewer dropdown (header menu, More, context menu). Data only: the host runs the command. */
export type SWViewerMenuEntry =
| {
type: "item"
id: string
label: string
/** 16px icon supplied by the program. */
icon?: ReactNode
/** Right-aligned hint, e.g. `F`. */
shortcut?: string
disabled?: boolean
hidden?: boolean
/** Keep the menu open after selecting. Default false. */
keepOpen?: boolean
onSelect?: () => void
}
| {
type: "checkbox"
id: string
label: string
checked: boolean
disabled?: boolean
hidden?: boolean
keepOpen?: boolean
onCheckedChange?: (checked: boolean) => void
}
| {
type: "radio"
id: string
label: string
checked: boolean
disabled?: boolean
hidden?: boolean
keepOpen?: boolean
onSelect?: () => void
}
| {
/** Multi-level menu: opens to the side (hover, Enter / Space / ArrowRight; ArrowLeft closes). */
type: "submenu"
id: string
label: string
icon?: ReactNode
disabled?: boolean
hidden?: boolean
entries: SWViewerMenuEntry[]
}
| { type: "label"; id: string; label: string; hidden?: boolean }
| { type: "separator"; id: string; hidden?: boolean }
/** Command event of SWViewerHeader / SWViewerMenu: the one place a program wires every menu action. */
export interface SWViewerCommand {
/** Header item id, or menu entry id for dropdown entries. */
id: string
/** Header item that owns the command (equals `id` for header buttons, toggles, tabs, switches). */
itemId: string
/** New state: `pressed` / `checked` (boolean) or the selected tab value (string). */
value?: boolean | string
}
interface SWViewerHeaderItemBase {
id: string
/** Visible text; also the accessible name (icon-only buttons keep it as aria-label). */
label: string
/** 16px icon supplied by the program. */
icon?: ReactNode
/** Tooltip (title attribute). */
title?: string
disabled?: boolean
/** Removed from the header and from More. */
hidden?: boolean
/** Header width (px) below which the item moves into the More dropdown. */
collapseBelow?: number
/** Header width (px) below which only the icon is shown (label kept as the accessible name). */
iconOnlyBelow?: number
/** Always icon only (e.g. the info button). Needs `icon`. */
iconOnly?: boolean
/** Section title of this item inside More (menu and tabs items only). Default `label`. */
moreLabel?: string
}
export type SWViewerHeaderItem =
| (SWViewerHeaderItemBase & { type: "button"; onSelect?: () => void })
| (SWViewerHeaderItemBase & { type: "toggle"; pressed: boolean; onPressedChange?: (pressed: boolean) => void })
| (SWViewerHeaderItemBase & {
/** Button with a dropdown (chevron). */
type: "menu"
entries: SWViewerMenuEntry[]
/** Dropdown width in px. Default: its content (min 160). */
menuWidth?: number
/** Dropdown edge aligned to the button. Default `start`. */
menuAlign?: "start" | "end"
})
| (SWViewerHeaderItemBase & {
/** Single selection such as Model / Layout (radiogroup, arrow keys move). */
type: "tabs"
value: string | null
options: { value: string; label: string; count?: number; title?: string; disabled?: boolean }[]
onValueChange?: (value: string) => void
})
| (SWViewerHeaderItemBase & {
/** Caption above a switch, e.g. 2.5D. */
type: "switch"
checked: boolean
onCheckedChange?: (checked: boolean) => void
})
| {
/** Free content in the header (program-specific control). */
type: "custom"
id: string
hidden?: boolean
collapseBelow?: number
render: () => ReactNode
/** How the item appears in More once collapsed. Default: not shown there. */
menuEntries?: SWViewerMenuEntry[]
}
export interface SWViewerHeaderGroup {
id: string
/** Accessible group name. Also the More section title of its buttons, toggles and switches. */
label: string
/** More section title when it differs from `label`. */
moreLabel?: string
/** `plain` (default): no face. `field`: the inset dark property surface (user-confirmed values). */
surface?: "plain" | "field"
/** `end`: the trailing action area at the right end of the object surface. Default `start`. */
placement?: "start" | "end"
hidden?: boolean
items: SWViewerHeaderItem[]
}
+3
View File
@@ -10,3 +10,6 @@ export {
SWPopup, SWPopupSection, SWPopupRow,
type SWPopupProps, type SWPopupSectionProps, type SWPopupRowProps,
} from "./components/sw-popup/SWPopup"
// SW viewer mode (2026-10-19): viewer header, dropdown, popup panel and panel controls.
export * from "./components/sw-viewer"
+549
View File
@@ -0,0 +1,549 @@
/* @hm-design/sw — SW viewer mode (reference: DWG/DXF viewer top menu and floating panels, measured 2026-10-19).
Viewer-only tokens. The three header surfaces reuse the user-confirmed SW tokens of sw.css
(--hm-sw-header-document-* / object-* / property-*). Never touches global selectors or the SWHeader. */
.hm-sw-header,
.hm-sw-theme {
/* Header */
--hm-sw-viewer-height: 52px;
--hm-sw-viewer-background: linear-gradient(180deg, #266651 0%, #123328 45%, #0a221d 100%);
--hm-sw-viewer-border-color: #071e18;
--hm-sw-viewer-brand-width: 72px;
/* No logo: no text or icon is drawn; the cap keeps only its slanted edge (no wide empty space). */
--hm-sw-viewer-brand-empty-width: 24px;
--hm-sw-viewer-brand-top: #123328;
--hm-sw-viewer-brand-middle: #205241;
--hm-sw-viewer-brand-bottom: #071e18;
--hm-sw-viewer-text: #fff;
--hm-sw-viewer-muted: #bddfd4;
--hm-sw-viewer-accent: #ffc600;
--hm-sw-viewer-hover: #266651;
--hm-sw-viewer-gap: 4px;
--hm-sw-viewer-radius: 4px;
--hm-sw-viewer-control-height: 40px;
--hm-sw-viewer-button-height: 34px;
--hm-sw-viewer-font-size: 14px;
--hm-sw-viewer-label-size: 11px;
--hm-sw-viewer-icon-size: 16px;
--hm-sw-viewer-disabled-opacity: 0.6;
--hm-sw-viewer-switch-off: #0a221d;
--hm-sw-viewer-switch-bevel: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 1px rgba(0, 0, 0, 0.25);
/* Icon stroke: the SWHeader icon assets (print / K guide / layer icons) are a fill + a 1px black OUTSIDE stroke
(Figma mask "outside" path) + a 0-offset glow (stdDeviation 1.615, #061A14). Program icons are free SVGs, so the
same stroke is a drop-shadow chain that follows the icon's own contour: the four 1px shadows dilate the shape
by 1px (each one also shadows the previous ones, so corners close), the last one is the glow. */
--hm-sw-viewer-icon-outline-color: var(--hm-sw-header-text-outline-color);
--hm-sw-viewer-icon-glow: #061a14;
--hm-sw-viewer-icon-outline:
drop-shadow(1px 0 0 var(--hm-sw-viewer-icon-outline-color)) drop-shadow(-1px 0 0 var(--hm-sw-viewer-icon-outline-color))
drop-shadow(0 1px 0 var(--hm-sw-viewer-icon-outline-color)) drop-shadow(0 -1px 0 var(--hm-sw-viewer-icon-outline-color))
drop-shadow(0 0 1.615px var(--hm-sw-viewer-icon-glow));
/* Dropdown (light) and More (beige) */
--hm-sw-viewer-menu-background: #fff;
--hm-sw-viewer-menu-border-color: #dedbd2;
--hm-sw-viewer-menu-text: #0a221d;
--hm-sw-viewer-menu-muted: #6e6759;
--hm-sw-viewer-menu-highlight: rgba(189, 223, 212, 0.45);
--hm-sw-viewer-menu-highlight-text: #123328;
--hm-sw-viewer-menu-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
--hm-sw-viewer-menu-item-height: 36px;
--hm-sw-viewer-more-background: #f5f0e5;
--hm-sw-viewer-more-border-color: #897445;
/* Panel */
--hm-sw-viewer-panel-background: #e0dace;
--hm-sw-viewer-panel-field-background: #f5f0e5;
--hm-sw-viewer-panel-border: #897445;
--hm-sw-viewer-panel-inner-border: #9a9284;
--hm-sw-viewer-panel-title-background: #221d13;
--hm-sw-viewer-panel-title-highlight: #a98d53;
--hm-sw-viewer-panel-title-color: #ffc600;
--hm-sw-viewer-panel-title-button-hover: #604f32;
--hm-sw-viewer-panel-text: #1d1810;
--hm-sw-viewer-panel-heading: #3e3523;
--hm-sw-viewer-panel-label: #6e6759;
--hm-sw-viewer-panel-spacing: 12px;
--hm-sw-viewer-panel-title-height: 34px;
--hm-sw-viewer-panel-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
--hm-sw-viewer-panel-primary: #123328;
--hm-sw-viewer-panel-switch-off: #9a9284;
--hm-sw-viewer-panel-switch-on: #266651;
--hm-sw-viewer-panel-badge-background: #f4f1ea;
--hm-sw-viewer-panel-overlay: rgba(0, 0, 0, 0.1);
/* Close: green curve, #E40000 on hover (user-supplied reference value). */
--hm-sw-viewer-close-glow: #123328;
--hm-sw-viewer-close-edge: #020a07;
--hm-sw-viewer-close-hover: #e40000;
--hm-sw-viewer-close-icon: #999;
}
.hm-sw-viewer-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ---------- Header ---------- */
.hm-sw-viewer-header {
container: hm-sw-viewer / inline-size;
position: relative;
box-sizing: border-box;
display: flex;
align-items: flex-start;
gap: 2px;
width: 100%;
min-width: 0;
height: var(--hm-sw-viewer-height);
border: 1px solid var(--hm-sw-viewer-border-color);
background: var(--hm-sw-viewer-background);
color: var(--hm-sw-viewer-text);
font-size: var(--hm-sw-viewer-font-size);
font-weight: 500;
line-height: 20px;
}
.hm-sw-viewer-header__brand {
position: relative;
z-index: 2;
display: flex;
align-items: center;
flex: 0 0 auto;
width: var(--hm-sw-viewer-brand-empty-width);
height: calc(var(--hm-sw-viewer-height) - 2px);
margin-right: -2px;
font-weight: 700;
}
/* With a logo: the reference 72px cap (logo at 24px), wider only for a wider logo. Without: the slanted edge only. */
.hm-sw-viewer-header__brand[data-logo] { width: auto; min-width: var(--hm-sw-viewer-brand-width); padding: 0 25px 0 24px; }
button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-header__brand-shape { position: absolute; inset: 0 auto 0 0; width: calc(100% + 16px); height: 100%; pointer-events: none; }
.hm-sw-viewer-header__brand-mark { position: relative; display: inline-flex; align-items: center; }
.hm-sw-viewer-header__brand-mark > :is(img, svg) { display: block; max-height: 24px; width: auto; }
.hm-sw-viewer-header__document {
display: flex;
align-items: center;
flex: 0 1 clamp(140px, 25cqw, 360px);
width: clamp(220px, 25cqw, 360px);
min-width: 56px;
height: calc(var(--hm-sw-viewer-height) - 2px);
padding: 0 16px 0 24px;
border: 1px solid var(--hm-sw-header-document-border-color);
border-left: 0;
background: var(--hm-sw-header-document-background);
}
.hm-sw-viewer-header__document-name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.hm-sw-viewer-header__object {
display: flex;
align-items: center;
flex: 1 0 auto;
min-width: 0;
height: calc(var(--hm-sw-viewer-height) - 2px);
gap: var(--hm-sw-viewer-gap);
padding-inline: var(--hm-sw-viewer-gap);
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);
}
.hm-sw-viewer-header__group {
display: flex;
align-items: center;
gap: var(--hm-sw-viewer-gap);
min-height: var(--hm-sw-viewer-control-height);
padding-inline: var(--hm-sw-viewer-gap);
}
.hm-sw-viewer-header__group[data-surface="field"] {
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);
}
.hm-sw-viewer-header__actions {
display: flex;
align-items: center;
align-self: stretch;
gap: var(--hm-sw-viewer-gap);
margin-left: auto;
padding-left: 6px;
}
/* Buttons, menu buttons and toggles: transparent, #266651 hover, #FFC600 pressed / open / on (no selected box). */
.hm-sw-viewer-header .hm-sw-viewer-header__button {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 8px;
height: var(--hm-sw-viewer-button-height);
padding: 6px 14px;
border: 1px solid transparent;
border-radius: var(--hm-sw-viewer-radius);
color: var(--hm-sw-viewer-text);
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.hm-sw-viewer-header .hm-sw-viewer-header__button[data-kind="toggle"] { height: var(--hm-sw-viewer-control-height); padding: 0 8px; }
.hm-sw-viewer-header .hm-sw-viewer-header__button[data-icon-only] { min-width: var(--hm-sw-viewer-button-height); padding: 0 8px; }
/* Always-icon buttons (info, More): the reference 32 x 40 icon button. */
.hm-sw-viewer-header .hm-sw-viewer-header__button[data-icon-only="always"] { min-width: 32px; width: 32px; height: var(--hm-sw-viewer-control-height); padding: 0 8px; }
.hm-sw-viewer-header .hm-sw-viewer-header__button:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); }
.hm-sw-viewer-header .hm-sw-viewer-header__button:active:not(:disabled),
.hm-sw-viewer-header .hm-sw-viewer-header__button:is([data-state="open"], [data-state="on"]) { color: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-header .hm-sw-viewer-header__button:is([data-state="open"], [data-state="on"]):not(:hover) { background: transparent; }
.hm-sw-viewer-header :is(.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab):disabled { opacity: var(--hm-sw-viewer-disabled-opacity); cursor: not-allowed; }
.hm-sw-viewer-header__icon, .hm-sw-viewer-header__chevron { display: inline-flex; width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-header__icon > svg, .hm-sw-viewer-header__chevron > svg { width: 100%; height: 100%; }
/* Tabs (Model / Layout): 40px triggers with an 11px count; the active one turns #FFC600. */
.hm-sw-viewer-header__tabs { display: flex; align-items: center; gap: var(--hm-sw-viewer-gap); }
.hm-sw-viewer-header .hm-sw-viewer-header__tab {
display: inline-flex;
align-items: center;
gap: var(--hm-sw-viewer-gap);
height: var(--hm-sw-viewer-control-height);
padding: 0 8px;
border: 1px solid transparent;
border-radius: var(--hm-sw-viewer-radius);
color: var(--hm-sw-viewer-text);
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
.hm-sw-viewer-header .hm-sw-viewer-header__tab:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); }
.hm-sw-viewer-header .hm-sw-viewer-header__tab[data-state="active"] { color: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-header__count { font-size: var(--hm-sw-viewer-label-size); font-weight: 400; font-variant-numeric: tabular-nums; }
/* Switch fields (2.5D, 어두운 배경): 11px muted caption above the switch. */
.hm-sw-viewer-header__switch-field {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--hm-sw-viewer-gap);
min-height: var(--hm-sw-viewer-control-height);
padding-inline: 8px;
}
.hm-sw-viewer-header__switch-label { color: var(--hm-sw-viewer-muted); font-size: var(--hm-sw-viewer-label-size); font-weight: 400; line-height: 1; white-space: nowrap; }
.hm-sw-viewer-header :is(.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__brand, .hm-sw-viewer-switch):focus-visible {
outline: 2px solid var(--hm-sw-viewer-accent);
outline-offset: 2px;
}
/* Text and icon stroke (2026-10-19 request): the SWHeader black outline on the viewer header, same tokens and
method — an outside outline of the glyph / icon shape, never a box border.
Text: 14px = --hm-sw-header-text-outline (1.3px black), 11px captions / counts / status = --hm-sw-header-label-outline
(1px black), as SWHeader's 12px labels. Icons: --hm-sw-viewer-icon-outline (see the token). Fill color, size,
weight and spacing are unchanged; hover / active / open change only the fill, disabled fades the whole button
(opacity) like SWHeader. Dropdown faces (light / beige) and panels get no outline, like SWHeader's More menu. */
.hm-sw-viewer-header :is(
.hm-sw-viewer-header__brand-mark, .hm-sw-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab
) {
text-shadow: var(--hm-sw-header-text-outline);
}
.hm-sw-viewer-header :is(.hm-sw-viewer-header__count, .hm-sw-viewer-header__switch-label, .hm-sw-viewer-status__label) {
text-shadow: var(--hm-sw-header-label-outline);
}
.hm-sw-viewer-header :is(.hm-sw-viewer-header__icon, .hm-sw-viewer-header__chevron, .hm-sw-viewer-status > svg) {
filter: var(--hm-sw-viewer-icon-outline);
}
/* Clipped (ellipsis) file name: the outline may paint 2px outside the box instead of being cut. */
.hm-sw-viewer-header .hm-sw-viewer-header__document-name {
overflow: clip;
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
}
/* Status */
.hm-sw-viewer-status {
display: flex;
align-items: center;
gap: var(--hm-sw-viewer-gap);
padding-inline: 8px;
color: var(--hm-sw-viewer-muted);
font-size: var(--hm-sw-viewer-label-size);
font-weight: 400;
white-space: nowrap;
}
.hm-sw-viewer-status > svg { width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-status[data-state="error"] { color: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-status__spin { animation: hm-sw-viewer-spin 1s linear infinite; }
@keyframes hm-sw-viewer-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .hm-sw-viewer-status__spin { animation: none; } }
/* Narrow header (reference breakpoints, measured on the header itself). */
@container hm-sw-viewer (max-width: 1179.98px) {
.hm-sw-viewer-header__object { gap: 2px; padding-inline: 2px; }
.hm-sw-viewer-header__group { gap: 2px; padding-inline: 2px; }
.hm-sw-viewer-header__actions { padding-left: 2px; gap: 2px; }
.hm-sw-viewer-status { padding-inline: 2px; }
}
@container hm-sw-viewer (max-width: 559.98px) {
.hm-sw-viewer-header__document { padding-right: 8px; }
.hm-sw-viewer-status__label { display: none; }
}
/* ---------- Switch (header / panel faces) ---------- */
.hm-sw-theme .hm-sw-viewer-switch {
position: relative;
display: inline-flex;
align-items: center;
flex-shrink: 0;
width: 36px;
height: 20px;
padding: 2px;
border-radius: 9999px;
background: var(--hm-sw-viewer-switch-off);
box-shadow: var(--hm-sw-viewer-switch-bevel);
transition: background-color 0.15s;
}
.hm-sw-viewer-switch__thumb { display: block; width: 16px; height: 16px; border-radius: 9999px; background: var(--hm-sw-viewer-text); transition: transform 0.15s; }
.hm-sw-viewer-switch[data-state="checked"] { background: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-switch[data-state="checked"] .hm-sw-viewer-switch__thumb { transform: translateX(16px); background: var(--hm-sw-viewer-switch-off); }
.hm-sw-theme .hm-sw-viewer-switch:disabled { opacity: var(--hm-sw-viewer-disabled-opacity); cursor: not-allowed; }
.hm-sw-viewer-panel .hm-sw-viewer-switch { background: var(--hm-sw-viewer-panel-switch-off); box-shadow: none; }
.hm-sw-viewer-panel .hm-sw-viewer-switch .hm-sw-viewer-switch__thumb { background: #fff; }
.hm-sw-viewer-panel .hm-sw-viewer-switch[data-state="checked"] { background: var(--hm-sw-viewer-panel-switch-on); }
@media (prefers-reduced-motion: reduce) { .hm-sw-viewer-switch, .hm-sw-viewer-switch__thumb { transition: none; } }
/* ---------- Dropdown ---------- */
.hm-sw-viewer-menu {
position: fixed;
z-index: 60;
display: flex;
flex-direction: column;
min-width: 160px;
max-width: calc(100vw - 8px);
max-height: min(640px, calc(100dvh - 8px));
overflow-y: auto;
padding: 4px;
border: 1px solid var(--hm-sw-viewer-menu-border-color);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-menu-background);
box-shadow: var(--hm-sw-viewer-menu-shadow);
color: var(--hm-sw-viewer-menu-text);
font-size: 14px;
font-weight: 400;
line-height: 20px;
outline: none;
}
.hm-sw-viewer-menu[data-surface="beige"] { background: var(--hm-sw-viewer-more-background); border-color: var(--hm-sw-viewer-more-border-color); }
.hm-sw-viewer-menu .hm-sw-viewer-menu__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: var(--hm-sw-viewer-menu-item-height);
padding: 0 12px;
border-radius: 2px;
color: inherit;
text-align: left;
white-space: nowrap;
outline: none;
}
.hm-sw-viewer-menu .hm-sw-viewer-menu__item:is(:focus, :hover, [data-state="open"]):not([aria-disabled]) { background: var(--hm-sw-viewer-menu-highlight); color: var(--hm-sw-viewer-menu-highlight-text); }
.hm-sw-viewer-menu .hm-sw-viewer-menu__item[aria-disabled] { opacity: 0.5; cursor: not-allowed; }
.hm-sw-viewer-menu .hm-sw-viewer-menu__item:focus-visible { outline: none; }
.hm-sw-viewer-menu__icon { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 16px; width: 16px; height: 16px; }
.hm-sw-viewer-menu__icon:empty { display: none; }
.hm-sw-viewer-menu__item[role="menuitemcheckbox"] .hm-sw-viewer-menu__icon,
.hm-sw-viewer-menu__item[role="menuitemradio"] .hm-sw-viewer-menu__icon { display: inline-flex; }
.hm-sw-viewer-menu__icon > svg { width: 16px; height: 16px; }
.hm-sw-viewer-menu__dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; }
.hm-sw-viewer-menu__text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.hm-sw-viewer-menu__shortcut { margin-left: auto; color: var(--hm-sw-viewer-menu-muted); font-size: 12px; letter-spacing: 0.05em; }
.hm-sw-viewer-menu__chevron { display: inline-flex; margin-left: auto; color: var(--hm-sw-viewer-menu-muted); }
.hm-sw-viewer-menu__label { padding: 6px 12px; color: var(--hm-sw-viewer-menu-muted); font-size: 12px; font-weight: 500; line-height: 18px; }
.hm-sw-viewer-menu__separator { flex: 0 0 1px; height: 1px; margin: 4px -4px; background: var(--hm-sw-viewer-menu-border-color); }
.hm-sw-viewer-menu[data-surface="beige"] .hm-sw-viewer-menu__separator { background: var(--hm-sw-viewer-panel-inner-border); }
/* ---------- Panel ---------- */
.hm-sw-viewer-panel {
display: flex;
flex-direction: column;
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
border: 2px solid var(--hm-sw-viewer-panel-border);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-panel-background);
box-shadow: var(--hm-sw-viewer-panel-shadow), inset 0 0 0 1px var(--hm-sw-viewer-panel-field-background);
color: var(--hm-sw-viewer-panel-text);
font-size: 14px;
font-weight: 400;
line-height: 20px;
outline: none;
}
.hm-sw-viewer-panel-backdrop { inset: 0; background: var(--hm-sw-viewer-panel-overlay); }
.hm-sw-viewer-panel__titlebar {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 4px;
min-height: var(--hm-sw-viewer-panel-title-height);
padding-left: var(--hm-sw-viewer-panel-spacing);
border-bottom: 1px solid var(--hm-sw-viewer-panel-border);
background: var(--hm-sw-viewer-panel-title-background);
box-shadow: inset 0 1px 0 var(--hm-sw-viewer-panel-title-highlight);
user-select: none;
}
.hm-sw-viewer-panel__titlebar[data-draggable] { cursor: grab; touch-action: none; }
.hm-sw-viewer-panel__titlebar[data-dragging] { cursor: grabbing; }
.hm-sw-viewer-panel__title {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
margin: 0;
color: var(--hm-sw-viewer-panel-title-color);
font-size: 14px;
font-weight: 700;
line-height: 1.4;
}
.hm-sw-viewer-panel__title-icon { display: inline-flex; flex: 0 0 16px; width: 16px; height: 16px; }
.hm-sw-viewer-panel__title-icon > svg { width: 100%; height: 100%; }
.hm-sw-viewer-panel__title-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.hm-sw-viewer-panel__actions { display: flex; flex-shrink: 0; align-items: center; }
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--hm-sw-viewer-panel-title-height);
height: var(--hm-sw-viewer-panel-title-height);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--hm-sw-viewer-muted);
}
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):hover:not(:disabled) { background: var(--hm-sw-viewer-panel-title-button-hover); color: var(--hm-sw-viewer-panel-title-color); }
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"], .hm-sw-viewer-panel__close):focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-title-color); outline-offset: -3px; }
.hm-sw-viewer-panel .hm-sw-viewer-panel__close {
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
flex-shrink: 0;
width: calc(var(--hm-sw-viewer-panel-title-height) * 1.53);
height: var(--hm-sw-viewer-panel-title-height);
padding: 0 0 0 calc(var(--hm-sw-viewer-panel-title-height) * 0.7);
overflow: hidden;
color: var(--hm-sw-viewer-close-icon);
}
.hm-sw-viewer-panel__close-shape { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.hm-sw-viewer-panel__close-curve { stroke: var(--hm-sw-viewer-panel-border); stroke-width: 1; }
.hm-sw-viewer-panel__close-icon { position: relative; width: 16px; height: 16px; pointer-events: none; }
.hm-sw-viewer-panel .hm-sw-viewer-panel__close:focus-visible { --hm-sw-viewer-close-glow: #266651; color: #fff; }
.hm-sw-viewer-panel .hm-sw-viewer-panel__close:is(:hover, :active) { --hm-sw-viewer-close-glow: var(--hm-sw-viewer-close-hover); --hm-sw-viewer-close-edge: var(--hm-sw-viewer-close-hover); color: #fff; }
.hm-sw-viewer-panel__body {
display: flex;
flex-direction: column;
gap: 20px;
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: var(--hm-sw-viewer-panel-spacing);
scrollbar-width: thin;
scrollbar-color: var(--hm-sw-viewer-panel-inner-border) var(--hm-sw-viewer-panel-background);
}
.hm-sw-viewer-panel__footer {
display: flex;
flex-shrink: 0;
justify-content: flex-end;
gap: 8px;
padding: 8px var(--hm-sw-viewer-panel-spacing);
border-top: 1px solid var(--hm-sw-viewer-panel-inner-border);
background: var(--hm-sw-viewer-panel-background);
}
/* Panel content */
.hm-sw-viewer-section { display: flex; flex-direction: column; gap: 12px; }
.hm-sw-viewer-section[data-surface="card"] {
padding: var(--hm-sw-viewer-panel-spacing);
border: 1px solid var(--hm-sw-viewer-panel-inner-border);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-panel-field-background);
box-shadow: inset 0 1px 0 #fff;
}
.hm-sw-viewer-section__title { margin: 0; color: var(--hm-sw-viewer-panel-heading); font-size: 12px; font-weight: 700; line-height: 16px; letter-spacing: 0.025em; text-transform: uppercase; }
.hm-sw-viewer-hint { margin: 0; color: var(--hm-sw-viewer-panel-heading); font-size: 12px; line-height: 16px; }
.hm-sw-viewer-field { display: flex; flex-direction: column; gap: 6px; }
.hm-sw-viewer-field[data-disabled] { opacity: 0.5; pointer-events: none; }
.hm-sw-viewer-field__row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 20px; }
.hm-sw-viewer-field__label { min-width: 0; color: var(--hm-sw-viewer-panel-label); font-size: 14px; font-weight: 500; line-height: 20px; }
.hm-sw-viewer-field__control { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.hm-sw-viewer-field__unit { color: var(--hm-sw-viewer-panel-label); font-size: 12px; }
.hm-sw-theme .hm-sw-viewer-input {
box-sizing: border-box;
width: 80px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--hm-sw-viewer-panel-inner-border);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-panel-field-background);
color: var(--hm-sw-viewer-panel-text);
font: inherit;
font-size: 12px;
line-height: 18px;
}
.hm-sw-theme .hm-sw-viewer-input:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: -1px; }
.hm-sw-theme .hm-sw-viewer-input:disabled { opacity: 0.5; }
.hm-sw-theme .hm-sw-viewer-slider {
--hm-sw-viewer-slider-percent: 0%;
width: 100%;
height: 18px;
margin: 0;
background: transparent;
appearance: none;
cursor: pointer;
}
.hm-sw-viewer-slider::-webkit-slider-runnable-track { height: 6px; border-radius: 9999px; background: linear-gradient(90deg, var(--hm-sw-viewer-panel-switch-on) var(--hm-sw-viewer-slider-percent), var(--hm-sw-viewer-panel-background) var(--hm-sw-viewer-slider-percent)); }
.hm-sw-viewer-slider::-moz-range-track { height: 6px; border-radius: 9999px; background: var(--hm-sw-viewer-panel-background); }
.hm-sw-viewer-slider::-moz-range-progress { height: 6px; border-radius: 9999px; background: var(--hm-sw-viewer-panel-switch-on); }
.hm-sw-viewer-slider::-webkit-slider-thumb { width: 18px; height: 18px; margin-top: -6px; border: 2px solid var(--hm-sw-viewer-panel-switch-on); border-radius: 9999px; background: #fff; appearance: none; }
.hm-sw-viewer-slider::-moz-range-thumb { box-sizing: border-box; width: 18px; height: 18px; border: 2px solid var(--hm-sw-viewer-panel-switch-on); border-radius: 9999px; background: #fff; }
.hm-sw-theme .hm-sw-viewer-slider:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: 2px; }
.hm-sw-theme .hm-sw-viewer-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border: 1px solid var(--hm-sw-viewer-panel-primary);
border-radius: var(--hm-sw-viewer-radius);
background: #fff;
color: var(--hm-sw-viewer-panel-primary);
font-size: 12px;
font-weight: 500;
line-height: 16px;
white-space: nowrap;
}
.hm-sw-theme .hm-sw-viewer-button[data-variant="outline"]:hover:not(:disabled) { background: var(--hm-sw-viewer-panel-field-background); }
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"] { background: var(--hm-sw-viewer-panel-primary); color: #fff; }
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"]:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); border-color: var(--hm-sw-viewer-hover); }
.hm-sw-theme .hm-sw-viewer-button:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: 1px; }
.hm-sw-theme .hm-sw-viewer-button:disabled { opacity: 0.5; cursor: not-allowed; }
.hm-sw-viewer-button__icon { display: inline-flex; width: 16px; height: 16px; }
.hm-sw-viewer-button__icon > svg { width: 100%; height: 100%; }
.hm-sw-viewer-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: var(--hm-sw-viewer-radius); background: var(--hm-sw-viewer-panel-badge-background); color: var(--hm-sw-viewer-panel-label); font-size: 12px; font-weight: 500; line-height: 18px; font-variant-numeric: tabular-nums; }
/* Corner resize grip (replaces native resize: same corner and glyph idea, works in every browser and with
pointer capture; the size stays within min / 90% of the bounds / the bounds edge). */
.hm-sw-viewer-panel__resize {
position: absolute;
right: 0;
bottom: 0;
z-index: 1;
width: 14px;
height: 14px;
cursor: nwse-resize;
touch-action: none;
background:
linear-gradient(135deg, transparent 0 55%, var(--hm-sw-viewer-panel-inner-border) 55% 62%, transparent 62% 72%, var(--hm-sw-viewer-panel-inner-border) 72% 79%, transparent 79%);
}
@@ -14,6 +14,9 @@ export const inventorySignature = entries => entries.map(entry => [entry.id, ent
// SW/Header next to its 10 retained Stories, the rest under SW/Tool and SW/Popup), and the only package files it changed.
// The two Header Stories sit directly under SW/Header (no separate Options menu), in src/sw/sw-header-open.stories.tsx.
export const integratedStoryIds = ['sw-header--document-open', 'sw-header--hidden-sub-controls', 'sw-tool--tool-buttons', 'sw-popup--custom-content', 'sw-popup--multiple-popups']
// 2026-10-19 (viewer worktree, user request): the SW/Tool Story menu was removed; SW/Popup (the common SWPopup
// examples) stays. SWToolButton / SWToolGroup stay public; integrated-improvement.browser.mjs opening this id is historical evidence only.
export const retiredToolIds = ["sw-tool--tool-buttons"]
export const integratedChangedFiles = [
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
@@ -0,0 +1,47 @@
// Regression for the SW viewer work (2026-10-19): the existing SW/Header Stories must render pixel-identical
// before (base Storybook) and after (this branch) at several widths.
// Usage: node packages/sw/tests/viewer-regression.browser.mjs <base-storybook-url> <new-storybook-url> <out-dir>
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const { chromium } = require('playwright')
const { PNG } = require('pngjs')
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
const [base, next, outArg] = process.argv.slice(2)
const out = path.resolve(outArg ?? 'docs/sw/viewer-qa/regression')
await mkdir(out, { recursive: true })
const ids = ['overview', 'interactive-states', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls', 'document-open', 'hidden-sub-controls', 'detail-buttons']
const widths = [1920, 1180, 768]
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const shot = async (url, width) => {
const page = await browser.newPage({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' })
await page.goto(url)
await page.waitForSelector('#storybook-root > *')
await page.evaluate(() => document.fonts.ready)
await page.waitForTimeout(400)
const buffer = await page.screenshot({ fullPage: true, animations: 'disabled' })
await page.close()
return PNG.sync.read(buffer)
}
const results = []
for (const id of ids) for (const width of widths) {
const url = root => `${root}/iframe.html?id=sw-header--${id}&viewMode=story`
const [a, b] = [await shot(url(base), width), await shot(url(next), width)]
const same = a.width === b.width && a.height === b.height
let diff = same ? 0 : -1
if (same) {
const image = new PNG({ width: a.width, height: a.height })
diff = pixelmatch(a.data, b.data, image.data, a.width, a.height, { threshold: 0 })
if (diff) await writeFile(path.join(out, `${id}-${width}-diff.png`), PNG.sync.write(image))
}
results.push({ id, width, size: `${a.width}x${a.height} / ${b.width}x${b.height}`, diffPixels: diff, result: diff === 0 ? 'PASS' : 'FAIL' })
console.log(`${diff === 0 ? 'PASS' : 'FAIL'} sw-header--${id} @${width} diff ${diff}`)
}
await browser.close()
await writeFile(path.join(out, 'regression.json'), JSON.stringify({ base, next, results }, null, 2) + '\n')
const failed = results.filter(item => item.result !== 'PASS').length
console.log(`${results.length - failed}/${results.length} PASS`)
process.exit(failed ? 1 : 0)
+391
View File
@@ -0,0 +1,391 @@
// SW viewer mode verification (2026-10-19).
// 1) Captures the reference DWG/DXF viewer and the SW/Viewer Stories at the same resolution (1440 x 900).
// 2) Runs interaction checks on the Stories (menus, keyboard, Escape, outside click, panels, More, modal).
// Usage: node packages/sw/tests/viewer.browser.mjs <storybook-url> <reference-url|-> <out-dir>
// e.g. node packages/sw/tests/viewer.browser.mjs http://127.0.0.1:6034 http://127.0.0.1:5182 docs/sw/viewer-qa
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const { chromium } = require('playwright')
const [storybook, reference, outArg] = process.argv.slice(2)
const out = path.resolve(outArg ?? 'docs/sw/viewer-qa')
await mkdir(out, { recursive: true })
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const results = []
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) }
const errors = []
const story = id => `${storybook}/iframe.html?id=sw-viewer--${id}&viewMode=story`
const newPage = async (width = 1440, height = 900) => {
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' })
page.on('pageerror', error => errors.push(`${page.url()}: ${error.message}`))
page.on('console', message => { if (message.type() === 'error' && !/favicon|404/.test(message.text())) errors.push(`${page.url()}: ${message.text()}`) })
return page
}
// Test-only: lift the 1280px Story page envelope so the 1440px frames are measured at full width.
const open = async (page, id) => { await page.goto(story(id)); await page.waitForSelector('.hm-sw-viewer-header'); await page.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' }); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(200) }
const log = page => page.locator('[data-callback-log]').first().innerText()
const box = locator => locator.boundingBox()
// ---------- 1. Same-resolution captures ----------
const geometry = async (page, selectors) => page.evaluate(selectors => {
const header = document.querySelector(selectors.header)
const base = header.getBoundingClientRect()
return Object.fromEntries(Object.entries(selectors.parts).map(([name, selector]) => [name, [...document.querySelectorAll(selector)].map(element => {
const rect = element.getBoundingClientRect()
return { x: Math.round(rect.x - base.x), y: Math.round(rect.y - base.y), w: Math.round(rect.width), h: Math.round(rect.height), text: element.textContent.trim().slice(0, 12) }
})]))
}, selectors)
const parts = {
reference: { header: 'header', parts: { document: '.cad-toolbar-document', object: '.cad-toolbar-object-area', field: '.cad-toolbar-property-field', button: '.cad-toolbar [data-slot=button]', toggle: '.cad-toolbar [data-slot=toggle]', tab: '.cad-toolbar [data-slot=tabs-trigger]', switchField: '.cad-toolbar-switch-field' } },
package: { header: '.hm-sw-viewer-header', parts: { document: '.hm-sw-viewer-header__document', object: '.hm-sw-viewer-header__object', field: '.hm-sw-viewer-header__group[data-surface=field]', button: '.hm-sw-viewer-header__button:not([data-kind=toggle])', toggle: '.hm-sw-viewer-header__button[data-kind=toggle]', tab: '.hm-sw-viewer-header__tab', switchField: '.hm-sw-viewer-header__switch-field' } },
}
const comparison = {}
if (reference && reference !== '-') {
const page = await newPage()
await page.goto(reference)
await page.waitForSelector('header')
await page.evaluate(() => document.fonts.ready)
await page.screenshot({ path: path.join(out, 'reference-1440-idle.png'), clip: { x: 0, y: 0, width: 1440, height: 56 } })
await page.getByRole('button', { name: '샘플' }).click()
await page.screenshot({ path: path.join(out, 'reference-1440-sample-menu.png'), clip: { x: 0, y: 0, width: 1440, height: 200 } })
await page.getByRole('menuitem', { name: /평면 및 종단면도/ }).click()
await page.waitForFunction(() => document.querySelector('.cad-toolbar-filename')?.textContent?.includes('C0060202'), null, { timeout: 30000 })
await page.waitForTimeout(1500)
comparison.reference = await geometry(page, parts.reference)
await page.screenshot({ path: path.join(out, 'reference-1440-loaded.png'), clip: { x: 0, y: 0, width: 1440, height: 56 } })
await page.getByRole('button', { name: '레이어 패널' }).click()
await page.getByRole('button', { name: '옵션 패널' }).click()
await page.waitForTimeout(300)
await page.screenshot({ path: path.join(out, 'reference-1440-panels.png') })
await page.close()
}
{
const page = await newPage(1920, 1000)
await open(page, 'viewer-header')
const frame = page.locator('[data-viewer-frame]')
const origin = await box(frame)
comparison.package = await geometry(page, parts.package)
await page.screenshot({ path: path.join(out, 'package-1440-loaded.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 56 } })
await page.getByRole('button', { name: '샘플' }).click()
await page.screenshot({ path: path.join(out, 'package-1440-sample-menu.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 200 } })
await page.keyboard.press('Escape')
await page.getByRole('button', { name: '레이어' }).click()
await page.getByRole('button', { name: '설정' }).click()
await page.waitForTimeout(200)
await page.screenshot({ path: path.join(out, 'package-1440-panels.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 620 } })
await page.close()
}
if (comparison.reference) {
// Same item = same size (positions shift with the file-name text width, which differs by sample data).
for (const key of ['button', 'toggle', 'tab', 'switchField', 'field']) {
const a = comparison.reference[key].map(item => `${item.w}x${item.h}`), b = comparison.package[key].map(item => `${item.w}x${item.h}`)
// field: the reference Tabs root is 39px inside its 40px frame, so its tab field is 41px; the package keeps both fields 42px (documented 1px difference).
const within = key === 'field' ? comparison.reference[key].every((item, index) => comparison.package[key][index] && item.w === comparison.package[key][index].w && Math.abs(item.h - comparison.package[key][index].h) <= 1) : JSON.stringify(a) === JSON.stringify(b)
check(`geometry ${key} sizes equal to reference${key === 'field' ? ' (±1px height, known)' : ''}`, within, `reference ${a.join(' ')} / package ${b.join(' ')}`)
}
const [r, p] = [comparison.reference.document[0], comparison.package.document[0]]
// x differs by design: the reference always draws "HM" (72px cap); the package draws no logo unless given (24px cap).
check('geometry document well height / top (x: logo rule)', r.h === p.h && r.y === p.y && p.x === r.x - 48, `reference x${r.x} y${r.y} h${r.h} / package x${p.x} y${p.y} h${p.h}`)
}
// ---------- 2. Interactions ----------
{
// Wide enough for the 1440px story frame plus the story page padding (panels at the frame right edge stay on screen).
const page = await newPage(1920, 1000)
await open(page, 'viewer-header')
// Dropdown: click opens, entries listed, Escape closes and returns focus.
const samples = page.getByRole('button', { name: '샘플' })
await samples.click()
const menu = page.getByRole('menu', { name: '샘플' })
check('menu opens on click', await menu.isVisible())
check('menu has 3 entries', (await menu.getByRole('menuitem').count()) === 3)
const menuBox = await box(menu), triggerBox = await box(samples)
check('menu placed under its button', Math.abs(menuBox.x - triggerBox.x) <= 1 && menuBox.y >= triggerBox.y + triggerBox.height, `menu ${Math.round(menuBox.x)},${Math.round(menuBox.y)} button ${Math.round(triggerBox.x)},${Math.round(triggerBox.y + triggerBox.height)}`)
check('trigger shows open state', (await samples.getAttribute('aria-expanded')) === 'true' && (await samples.getAttribute('data-state')) === 'open')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
check('ArrowDown moves focus in menu', (await page.evaluate(() => document.activeElement?.textContent)).includes('평면'))
await page.keyboard.press('Escape')
check('Escape closes menu', !(await menu.isVisible().catch(() => false)))
check('Escape returns focus to trigger', await samples.evaluate(element => element === document.activeElement))
// Keyboard open + Enter select → file name changes, command logged.
await samples.press('Enter')
check('Enter opens with first entry focused', (await page.evaluate(() => document.activeElement?.getAttribute('role'))) === 'menuitem')
await page.keyboard.press('End')
await page.keyboard.press('Enter')
check('Enter selects entry (document changes)', (await page.locator('.hm-sw-viewer-header__document-name').innerText()) === 'simple.dxf')
check('onCommand reports menu entry', (await log(page)).includes('samples/sample-dxf'), await log(page))
// Outside click closes.
await samples.click()
await page.mouse.click(700, 600)
check('outside click closes menu', (await page.getByRole('menu').count()) === 0)
// Tabs: arrow keys select.
const model = page.getByRole('radio', { name: /Model/ })
await model.focus()
await page.keyboard.press('ArrowRight')
check('tabs ArrowRight selects Layout', (await page.getByRole('radio', { name: /Layout/ }).getAttribute('aria-checked')) === 'true')
// Switch
const depth = page.getByRole('switch', { name: '2.5D' })
await depth.click()
check('header switch toggles', (await depth.getAttribute('aria-checked')) === 'true' && (await log(page)).includes('2.5d=true'))
// Disabled fit button is enabled once a document is open; hover face.
const fit = page.getByRole('button', { name: '전체 보기' })
await fit.hover()
check('hover face #266651', (await fit.evaluate(element => getComputedStyle(element).backgroundColor)) === 'rgb(38, 102, 81)')
// Toggle opens the panel; Escape inside closes it and focus goes back.
const layers = page.getByRole('button', { name: '레이어', exact: true })
await layers.click()
const layerPanel = page.getByRole('dialog', { name: /레이어 \(/ })
check('toggle opens panel', await layerPanel.isVisible())
check('toggle pressed state + #FFC600', (await layers.getAttribute('aria-pressed')) === 'true' && (await layers.evaluate(element => getComputedStyle(element).color)) === 'rgb(255, 198, 0)')
check('panel focused on open', await layerPanel.evaluate(element => element.contains(document.activeElement)))
await page.keyboard.press('Escape')
check('Escape closes panel', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
check('focus returns to toggle', await layers.evaluate(element => element === document.activeElement))
// Resize by the native corner (right after opening, top-end placement).
await layers.click()
const sized = await box(layerPanel)
await page.mouse.move(sized.x + sized.width - 6, sized.y + sized.height - 6)
await page.mouse.down()
await page.mouse.move(sized.x + sized.width - 63, sized.y + sized.height - 103, { steps: 6 })
await page.mouse.up()
const resized = await box(layerPanel)
check("corner resize changes size", resized.width < sized.width - 40 && resized.height < sized.height - 80, `${Math.round(sized.width)}x${Math.round(sized.height)} → ${Math.round(resized.width)}x${Math.round(resized.height)}`)
// Drag: title bar moves the panel and it stays inside the frame.
const before = await box(layerPanel)
const title = layerPanel.locator('.hm-sw-viewer-panel__titlebar')
const titleBox = await box(title)
await page.mouse.move(titleBox.x + 40, titleBox.y + 15)
await page.mouse.down()
await page.mouse.move(titleBox.x - 260, titleBox.y + 45, { steps: 8 })
await page.mouse.up()
const after = await box(layerPanel)
check('drag moves panel', Math.round(before.x - after.x) === 300 && Math.round(after.y - before.y) === 30, `dx ${Math.round(after.x - before.x)} dy ${Math.round(after.y - before.y)}`)
await page.mouse.move(after.x + 40, after.y + 15)
await page.mouse.down()
await page.mouse.move(after.x + 3000, after.y + 3000, { steps: 6 })
await page.mouse.up()
const frame = await box(page.locator('.sw-viewer-story-canvas'))
const clamped = await box(layerPanel)
check('drag stays inside bounds (10px edge)', Math.round(frame.x + frame.width - (clamped.x + clamped.width)) === 10 && Math.round(frame.y + frame.height - (clamped.y + clamped.height)) === 10,
`right gap ${Math.round(frame.x + frame.width - (clamped.x + clamped.width))}, bottom gap ${Math.round(frame.y + frame.height - (clamped.y + clamped.height))}`)
// Collapse by button and double click.
await layerPanel.getByRole('button', { name: '접기' }).click()
check('collapse hides body', (await layerPanel.locator('.hm-sw-viewer-panel__body').count()) === 0)
await title.dblclick({ position: { x: 30, y: 15 } })
check('title double click expands', (await layerPanel.locator('.hm-sw-viewer-panel__body').count()) === 1)
// Panel content works (layer eye).
await layerPanel.getByRole('button', { name: 'CR-TEXT 숨기기' }).click()
check('panel content callback', (await log(page)).includes('layer:CR-TEXT=false'))
// Close button hover face.
const close = layerPanel.getByRole('button', { name: '닫기' })
await close.hover()
check('close hover #E40000 / white X', (await close.evaluate(element => [getComputedStyle(element).getPropertyValue('--hm-sw-viewer-close-glow').trim(), getComputedStyle(element).color].join(' '))) === '#e40000 rgb(255, 255, 255)')
await close.click()
check('close button closes', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
// Two panels: the last touched comes to the front.
await layers.click()
await page.getByRole('button', { name: '설정', exact: true }).click()
const options = page.getByRole('dialog', { name: '옵션' })
const z = async () => Promise.all([layerPanel, options].map(item => item.evaluate(element => Number(getComputedStyle(element).zIndex))))
await layerPanel.locator('.hm-sw-viewer-panel__titlebar').click({ position: { x: 30, y: 15 } })
const [zl, zo] = await z()
check('last touched panel on top', zl > zo, `layer z ${zl}, options z ${zo}`)
// Modal popup: backdrop, focus trap, Escape.
await page.getByRole('button', { name: '정보 및 라이선스' }).click()
const about = page.getByRole('dialog', { name: /라이선스/ })
check('modal popup opens with backdrop', (await about.getAttribute('aria-modal')) === 'true' && (await page.locator('.hm-sw-viewer-panel-backdrop').count()) === 1)
for (let index = 0; index < 4; index++) await page.keyboard.press('Tab')
check('modal focus stays inside', await about.evaluate(element => element.contains(document.activeElement)))
await page.keyboard.press('Escape')
check('modal Escape closes', (await page.getByRole('dialog', { name: /라이선스/ }).count()) === 0)
await page.close()
}
{
// Submenus and the context menu.
const page = await newPage(1920, 1100)
await open(page, 'dropdowns')
await page.getByRole('button', { name: '도면' }).press('Enter')
const recent = page.getByRole('menuitem', { name: '최근 도면' })
await recent.focus()
await page.keyboard.press('ArrowRight')
const sub = page.getByRole('menu', { name: '최근 도면' })
check('ArrowRight opens submenu with focus', (await sub.isVisible()) && await sub.evaluate(element => element.contains(document.activeElement)))
const parentBox = await box(page.getByRole('menu', { name: '도면', exact: true })), subBox = await box(sub)
check('submenu opens to the side', subBox.x >= parentBox.x + parentBox.width, `parent right ${Math.round(parentBox.x + parentBox.width)}, sub left ${Math.round(subBox.x)}`)
await page.getByRole('menuitem', { name: '보관 도면' }).hover()
check('third level opens on hover', await page.getByRole('menu', { name: '보관 도면' }).isVisible())
await page.getByRole('menuitem', { name: '보관 도면' }).focus()
await page.keyboard.press('ArrowLeft')
check('ArrowLeft closes one level', (await page.getByRole('menu', { name: '최근 도면' }).count()) === 0 && (await page.getByRole('menu', { name: '도면', exact: true }).count()) === 1)
await page.getByRole('menuitemcheckbox', { name: '선가중치 표시' }).click()
check('checkbox entry toggles via onCommand', (await log(page)).includes('file/lineweight=true'), await log(page))
await page.getByRole('button', { name: '도면' }).click()
check('checkbox state reflected', (await page.getByRole('menuitemcheckbox', { name: '선가중치 표시' }).getAttribute('aria-checked')) === 'true')
await page.getByRole('menuitem', { name: '인쇄 (미연결)' }).click({ force: true })
check('disabled entry does nothing', (await page.getByRole('menu', { name: '도면', exact: true }).count()) === 1)
await page.keyboard.press('Escape')
const right = page.getByRole('button', { name: '오른쪽 정렬 메뉴' })
await right.click()
const rightMenu = page.getByRole('menu', { name: '오른쪽 정렬 메뉴' })
const [rb, rm] = [await box(right), await box(rightMenu)]
check('menuAlign end + menuWidth', Math.abs(rb.x + rb.width - (rm.x + rm.width)) <= 1 && Math.round(rm.width) === 220, `width ${Math.round(rm.width)}`)
await page.keyboard.press('Escape')
const area = page.locator('[data-context-area]')
const areaBox = await box(area)
await page.mouse.click(areaBox.x + 50, areaBox.y + 40, { button: 'right' })
const context = page.getByRole('menu', { name: '도면 메뉴' })
const contextBox = await box(context)
check('context menu opens at pointer', Math.abs(contextBox.x - (areaBox.x + 50)) <= 1 && Math.abs(contextBox.y - (areaBox.y + 40)) <= 1, `${Math.round(contextBox.x)},${Math.round(contextBox.y)}`)
await page.getByRole('menuitem', { name: '선택 확대' }).click()
check('context entry reported', (await log(page)).includes('context/zoom-selection'))
// Menu kept inside the viewport near the right edge.
await page.setViewportSize({ width: 900, height: 700 })
await page.mouse.click(0, 0)
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(100)
await page.locator('.sw-story-scroller').first().evaluate(element => { element.scrollLeft = element.scrollWidth })
await right.click()
const edge = await box(page.getByRole('menu', { name: '오른쪽 정렬 메뉴' }))
check('menu stays inside the viewport', edge.x >= 4 && edge.x + edge.width <= 900 - 4, `left ${Math.round(edge.x)}, right ${Math.round(edge.x + edge.width)}`)
await page.close()
}
{
// Responsive: More collects the collapsed items, no horizontal overflow, icon-only below 560.
const page = await newPage(1920, 1200)
await open(page, 'responsive')
const headers = page.locator('[data-width] .hm-sw-viewer-header')
const report = []
for (const [index, width] of [1440, 1100, 860, 520].entries()) {
const header = headers.nth(index)
const info = await header.evaluate(element => ({
overflow: element.scrollWidth - element.clientWidth,
objectOverflow: (() => { const object = element.querySelector('.hm-sw-viewer-header__object'); return object.scrollWidth - object.clientWidth })(),
more: !!element.querySelector('.hm-sw-viewer-header__more'),
labels: [...element.querySelectorAll('.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__switch-label')].map(item => item.textContent.trim() || item.getAttribute('aria-label')),
}))
report.push({ width, ...info })
const box2 = await box(header)
await page.screenshot({ path: path.join(out, `package-responsive-${width}.png`), clip: { x: box2.x, y: box2.y, width: box2.width, height: box2.height } })
}
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), JSON.stringify(report.map(item => [item.width, item.overflow, item.objectOverflow])))
check('1440: no More', !report[0].more)
check('1100: More, samples/tabs/switches moved', report[1].more && !report[1].labels.includes('샘플') && !report[1].labels.includes('2.5D') && report[1].labels.includes('레이어'))
check('860: panel toggles moved too', report[2].more && !report[2].labels.includes('레이어'))
check('520: 파일 열기 / 전체 보기 icon only', report[3].labels.includes('파일 열기') && !(await headers.nth(3).locator('.hm-sw-viewer-header__label', { hasText: '파일 열기' }).count()))
const more = headers.nth(2).getByRole('button', { name: '추가 도구' })
await more.click()
const moreMenu = page.getByRole('menu', { name: '추가 도구' })
const items = await moreMenu.locator('.hm-sw-viewer-menu__label, [role^=menuitem]').allInnerTexts()
check('More sections like the reference', ['샘플 도면', '도면 공간', '화면 보기', '패널'].every(label => items.includes(label)), items.join(' | '))
check('More face beige 260px', (await moreMenu.evaluate(element => `${getComputedStyle(element).backgroundColor} ${element.offsetWidth}`)) === 'rgb(245, 240, 229) 260')
await moreMenu.getByRole('menuitemcheckbox', { name: '레이어' }).click()
check('More checkbox reports command', (await log(page)).includes('layers=true'), await log(page))
await more.click()
await page.getByRole('menuitemradio', { name: /Layout/ }).click()
check('More radio selects tab value', (await log(page)).includes('space=layout1'))
await page.screenshot({ path: path.join(out, 'package-responsive-more-open.png') })
await page.close()
}
{
// Story captures (full page) for the report.
const page = await newPage(1920, 1000)
for (const id of ['viewer-header', 'menu-groups', 'states', 'dropdowns', 'settings-panel', 'content-variants', 'variable-size', 'responsive', 'logo', 'compare-with-sw-header']) {
await open(page, id)
await page.screenshot({ path: path.join(out, `story-${id}.png`), fullPage: true })
}
// Mode switch keeps state.
await open(page, 'compare-with-sw-header')
await page.getByLabel('뷰어 모드 (SWViewerHeader)').check()
const frame = page.locator('[data-viewer-frame]').last()
check('mode switch shows viewer header', (await frame.locator('.hm-sw-viewer-header').count()) === 1 && (await frame.locator('.hm-sw-header').count()) === 0)
await frame.getByRole('button', { name: '레이어', exact: true }).click()
await frame.getByRole('button', { name: '임시 로고 (일반 모드로) 홈' }).click()
check('brand click returns to normal SWHeader', (await frame.locator('.hm-sw-header').count()) === 1 && (await frame.locator('.hm-sw-viewer-header').count()) === 0)
await page.getByLabel('뷰어 모드 (SWViewerHeader)').check()
check('state kept across mode switch (layer panel still open)', (await frame.getByRole('dialog', { name: /레이어 \(/ }).count()) === 1)
await page.close()
}
{
// Logo: nothing drawn without one, image / element when given; layout unchanged.
const page = await newPage(1920, 1000)
await open(page, 'logo')
const cases = page.locator('[data-logo-case]')
const read = index => cases.nth(index).evaluate(element => {
const brand = element.querySelector('.hm-sw-viewer-header__brand'), doc = element.querySelector('.hm-sw-viewer-header__document')
const base = element.getBoundingClientRect()
return {
text: brand.textContent.trim(), img: !!brand.querySelector('img, .hm-sw-viewer-header__brand-mark svg'),
width: Math.round(brand.getBoundingClientRect().width), docX: Math.round(doc.getBoundingClientRect().x - base.x),
height: Math.round(element.querySelector('.hm-sw-viewer-header').getBoundingClientRect().height),
fileOpenY: Math.round(element.querySelector('.hm-sw-viewer-header__button').getBoundingClientRect().y - base.y),
}
})
const [none, src, node] = [await read(0), await read(1), await read(2)]
check('logo none: no HM / text / icon', none.text === '' && !none.img, JSON.stringify(none))
check('logo none: cap shrinks to its edge (no wide gap)', none.width === 24, JSON.stringify(none))
const logoWidth = await cases.nth(1).locator('.hm-sw-viewer-header__brand-mark').evaluate(element => Math.round(element.getBoundingClientRect().width))
check('logoSrc shows the image (cap = 24 + logo + 25, min 72)', src.img && src.width === Math.max(72, 24 + logoWidth + 25), JSON.stringify({ ...src, logoWidth }))
check('logo element shown', node.img && node.width >= 72, JSON.stringify(node))
check('header height and menu row unchanged with / without logo', [none, src, node].every(item => item.height === 52 && item.fileOpenY === none.fileOpenY), [none, src, node].map(item => item.height + '/' + item.fileOpenY).join(' '))
for (const [index, name] of [[0, 'none'], [1, 'src'], [2, 'element']]) {
const b = await box(cases.nth(index))
await page.screenshot({ path: path.join(out, 'package-logo-' + name + '.png'), clip: { x: b.x, y: b.y, width: 520, height: 52 } })
}
// Text / icon outline: same tokens as SWHeader, fill unchanged.
const outline = await page.evaluate(() => {
const header = document.querySelector('[data-logo-case] .hm-sw-viewer-header')
const style = selector => getComputedStyle(header.querySelector(selector))
return {
label: style('.hm-sw-viewer-header__label').textShadow, document: style('.hm-sw-viewer-header__document-name').textShadow,
tab: style('.hm-sw-viewer-header__tab').textShadow, caption: style('.hm-sw-viewer-header__switch-label').textShadow,
count: style('.hm-sw-viewer-header__count').textShadow, icon: style('.hm-sw-viewer-header__icon').filter, chevron: style('.hm-sw-viewer-header__chevron').filter,
labelColor: style('.hm-sw-viewer-header__label').color,
labelFont: [style('.hm-sw-viewer-header__label').fontSize, style('.hm-sw-viewer-header__label').fontWeight, style('.hm-sw-viewer-header__label').letterSpacing].join(' '),
}
})
const blacks = value => value.split('rgb(0, 0, 0)').length - 1
check('text outline 1.3px black on 14px text (label / file name / tab)', [outline.label, outline.document, outline.tab].every(value => value.includes('rgb(0, 0, 0) 1.3px 0px 0px') && blacks(value) === 16), outline.label.slice(0, 60))
check('label outline 1px black on 11px captions / counts', [outline.caption, outline.count].every(value => value.includes('rgb(0, 0, 0) 1px 0px 0px') && blacks(value) === 16), outline.caption.slice(0, 60))
check('icon outline: 4 x 1px black drop-shadows + #061A14 glow', [outline.icon, outline.chevron].every(value => (value.match(/drop-shadow/g) ?? []).length === 5 && value.includes('rgb(0, 0, 0) 1px 0px 0px') && value.includes('rgb(6, 26, 20)')), outline.icon.slice(0, 90))
check('fill / size / weight / spacing kept', outline.labelColor === 'rgb(255, 255, 255)' && outline.labelFont === '14px 500 normal', outline.labelColor + ' ' + outline.labelFont)
await page.close()
}
{
// SWHeader vs SWViewerHeader text / icon outline at 3x for the report.
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 }, deviceScaleFactor: 3 })
await open(page, 'compare-with-sw-header')
const sw = await box(page.locator('.hm-sw-header').first()), vw = await box(page.locator('.hm-sw-viewer-header').first())
await page.screenshot({ path: path.join(out, 'outline-swheader-3x.png'), clip: { x: sw.x, y: sw.y, width: 760, height: sw.height } })
await page.screenshot({ path: path.join(out, 'outline-viewer-3x.png'), clip: { x: vw.x, y: vw.y, width: 760, height: vw.height } })
await page.screenshot({ path: path.join(out, 'outline-viewer-right-3x.png'), clip: { x: vw.x + 1000, y: vw.y, width: 440, height: vw.height } })
const hovered = page.locator('.hm-sw-viewer-header').first().getByRole('button', { name: '레이어', exact: true })
await hovered.hover()
check('hover changes fill only (outline kept)', (await hovered.locator('.hm-sw-viewer-header__label').evaluate(element => getComputedStyle(element).textShadow)).includes('1.3px'))
await page.close()
const states = await browser.newPage({ viewport: { width: 1920, height: 900 }, deviceScaleFactor: 2 })
await open(states, 'states')
const header = await box(states.locator('.hm-sw-viewer-header'))
await states.getByRole('button', { name: '기본' }).hover()
await states.screenshot({ path: path.join(out, 'outline-states-2x.png'), clip: { x: header.x, y: header.y, width: 1440, height: header.height } })
const disabled = await states.getByRole('button', { name: 'Disabled', exact: true }).evaluate(element => [getComputedStyle(element).opacity, getComputedStyle(element.querySelector('.hm-sw-viewer-header__label')).textShadow.includes('1.3px')])
check('disabled fades text + outline together', disabled[0] === '0.6' && disabled[1] === true, JSON.stringify(disabled))
await states.close()
}
{
// Storybook sidebar: SW/Tool removed; SW/Popup (common SWPopup examples), SW/Header and SW/Viewer remain.
const index = await (await fetch(storybook + '/index.json')).json()
const titles = [...new Set(Object.values(index.entries).map(entry => entry.title).filter(title => title.startsWith('SW')))]
check('SW/Tool removed from the sidebar', !titles.includes('SW/Tool'), titles.join(', '))
check('SW/Popup, SW/Header and SW/Viewer remain', ['SW/Popup', 'SW/Header', 'SW/Viewer'].every(title => titles.includes(title)))
}
check('no page errors', errors.length === 0, errors.join(' | '))
await browser.close()
await writeFile(path.join(out, 'report.json'), JSON.stringify({ storybook, reference, comparison, results, errors }, null, 2) + '\n')
const failed = results.filter(item => item.result === 'FAIL').length
console.log(`\n${results.length - failed}/${results.length} PASS`)
process.exit(failed ? 1 : 0)
-91
View File
@@ -1,91 +0,0 @@
import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { SWHeader, SWHeaderHome, SWTheme, SWToolButton, SWToolGroup } from "@hm-design/sw"
import "@hm-design/sw/styles.css"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import "./sw-stories.css"
import "./sw-integrated-stories.css"
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
// SWToolButton / SWToolGroup (2026-10-19): the existing SW toggle, pill and file-name looks as public buttons
// for viewer tool bars (Model / Layout, Fit, 2.5D, ...). Public API only, like a consuming project.
const KICKER = "HM DESIGN SYSTEM / SW"
// Sample host icon (the program supplies its own): 24 x 23 box like the print icon, default #B6D0C9 / hover #FFC600.
const FitIcon = ({ color }: { color: string }) => (
<svg viewBox="0 0 24 23" width="24" height="23" aria-hidden="true">
<path d="M2 8V2h6M16 2h6v6M22 15v6h-6M8 21H2v-6" fill="none" stroke={color} strokeWidth="2" />
</svg>
)
const meta = {
title: "SW/Tool",
parameters: { layout: "fullscreen", controls: { disable: true } },
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
function Bar({ children }: { children: ReactNode }) {
return <div className="sw-story-scroller"><div className="sw-story-frame" style={{ width: 1440 }}>{children}</div></div>
}
function ToolPage() {
const [view, setView] = useState<"model" | "layout">("model")
const [depth, setDepth] = useState(false)
const [log, setLog] = useState<string[]>([])
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
const viewGroup = (
<SWToolGroup label="보기" selection="single" data-view-group="">
<SWToolButton pressed={view === "model"} onClick={() => { setView("model"); record("view(model)") }}>Model</SWToolButton>
<SWToolButton pressed={view === "layout"} onClick={() => { setView("layout"); record("view(layout)") }}>Layout</SWToolButton>
</SWToolGroup>
)
const displayGroup = (
<SWToolGroup label="표시" data-display-group="">
<SWToolButton pressed={depth} onClick={() => { setDepth(!depth); record(`2.5D(${!depth})`) }}>2.5D</SWToolButton>
</SWToolGroup>
)
const fit = (
<SWToolGroup appearance="pill" aria-label="화면" data-fit-group="">
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} hoverIcon={<FitIcon color="var(--hm-sw-header-selected-color)" />} onClick={() => record("fit")}>화면 맞춤</SWToolButton>
</SWToolGroup>
)
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Tool — SWToolButton / SWToolGroup"
description="새 디자인 값 없이 기존 SW 버튼 3종을 공개 버튼으로 씁니다. text = 속성Bar·속성창 글자 토글, pill = 인쇄·K 버튼, document = 파일명 면. SWToolGroup은 상세보기 묶음(text) 또는 인쇄·K pill(pill)이며 selection=&quot;single&quot;이면 Model / Layout 같은 단일 선택(radiogroup, 화살표 키)입니다." />
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
<StorySection title="SWHeader tools 슬롯 안" description="객체 수정 영역의 메뉴 뒤에 도구 막대를 둡니다.">
<Bar><SWHeader documentTitle="C0020101-006-평면및종단면도(06)" logoSrc={placeholderLogo} logoAlt="임시 로고"
hiddenControls={["lineStyle", "textStyle", "dimensionStyle", "details", "propertyBar", "propertyPanel"]}
tools={<>{viewGroup}{displayGroup}{fit}</>} /></Bar>
</StorySection>
<StorySection title="조합형 막대 (SWTheme)" description="Header 없이 같은 부품을 SWTheme 안에 배치합니다. 배치 CSS만 소비 프로젝트 소유입니다.">
<Bar>
<SWTheme className="sw-story-composed-bar" data-tool-bar="">
<SWHeaderHome logoSrc={placeholderLogo} logoAlt="임시 로고" />
<SWToolButton appearance="document" onClick={() => record("open")}>Open</SWToolButton>
<div className="sw-story-composed-surface">{viewGroup}{displayGroup}{fit}</div>
</SWTheme>
</Bar>
</StorySection>
<StorySection title="상태" description="text: Hover·Selected 글자 #FFC600, Disabled 40%. pill: Hover 노란 테두리, hoverIcon 교체. document: 글자만 Hover #FFC600.">
<Bar>
<SWTheme className="sw-story-composed-bar" data-tool-states="">
<SWHeaderHome logoSrc={placeholderLogo} logoAlt="임시 로고" interactive={false} />
<SWToolButton appearance="document" disabled>Disabled</SWToolButton>
<div className="sw-story-composed-surface">
<SWToolGroup label="text"><SWToolButton>Default</SWToolButton><SWToolButton pressed>Pressed</SWToolButton><SWToolButton disabled>Disabled</SWToolButton></SWToolGroup>
<SWToolGroup appearance="pill" aria-label="pill">
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} hoverIcon={<FitIcon color="var(--hm-sw-header-selected-color)" />}>Fit</SWToolButton>
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} disabled>Fit disabled</SWToolButton>
</SWToolGroup>
</div>
</SWTheme>
</Bar>
</StorySection>
</StoryPage>
)
}
export const ToolButtons: Story = { name: "Tool Buttons", render: () => <ToolPage /> }
+195
View File
@@ -0,0 +1,195 @@
// Story fixture for SW/Viewer: the reference DWG/DXF viewer menu rebuilt from the public @hm-design/sw API only.
// UI state lives here (like a consuming program); no DWG/DXF engine — commands are logged, not executed.
import { useMemo, useState } from "react"
import {
SWViewerBadge, SWViewerButton, SWViewerHeaderButton, SWViewerStatus, SWViewerField, SWViewerHint, SWViewerInput, SWViewerPanel, SWViewerSection, SWViewerSlider, SWViewerSwitch,
type SWViewerCommand, type SWViewerHeaderGroup, type SWViewerPanelProps,
} from "@hm-design/sw"
import { Eye, EyeOff, FolderOpen, Info, Layers, List, MousePointerClick, RotateCcw, Scan, SlidersHorizontal, ZoomIn } from "lucide-react"
export const SAMPLES = [
{ id: "sample-basic", label: "기본 도면 · DWG", name: "BasicSample.dwg" },
{ id: "sample-civil", label: "평면 및 종단면도 · DWG", name: "C0060202-001-평면및종단면도.dwg" },
{ id: "sample-dxf", label: "기본 도형 · DXF", name: "simple.dxf" },
] as const
export const SPACES = [
{ value: "model", label: "Model", count: 17377 },
{ value: "layout1", label: "Layout", count: 237 },
]
export const LAYERS = [
{ name: "0", color: "#ffffff", count: 1 },
{ name: "CX-BORD-LIN1", color: "#00ffff", count: 30 },
{ name: "CX-BORD-LIN2", color: "#00ff00", count: 1 },
{ name: "CX-BORD-TEX3", color: "#ff00ff", count: 17 },
{ name: "CX-BORD-TEX2", color: "#000000", count: 4 },
{ name: "CX-MISC", color: "#ffff00", count: 0 },
{ name: "CR-LEAT", color: "#00ff00", count: 4 },
{ name: "CR-GRND", color: "#00ff00", count: 6 },
{ name: "CR-TEXT", color: "#ff0000", count: 52 },
]
export type ViewerPanelId = "layers" | "options" | "properties"
export interface ViewerOptions { lineweightOn: boolean; lineweightScale: number; grid: boolean; zoomSpeed: number; zScale: number }
export const DEFAULT_OPTIONS: ViewerOptions = { lineweightOn: false, lineweightScale: 11, grid: false, zoomSpeed: 1, zScale: 10 }
/** All UI state of the demo viewer screen + the header menu data built from it. */
export function useViewerDemo({ initialDocument = null as string | null, initialPanels = [] as ViewerPanelId[] } = {}) {
const [documentName, setDocumentName] = useState<string | null>(initialDocument)
const [space, setSpace] = useState("model")
const [is25D, set25D] = useState(false)
const [dark, setDark] = useState(true)
const [panels, setPanels] = useState<Record<ViewerPanelId, boolean>>(() => ({ layers: initialPanels.includes("layers"), options: initialPanels.includes("options"), properties: initialPanels.includes("properties") }))
const [options, setOptions] = useState<ViewerOptions>(DEFAULT_OPTIONS)
const [hidden, setHidden] = useState<Set<string>>(new Set())
const [aboutOpen, setAboutOpen] = useState(false)
const [log, setLog] = useState<string[]>([])
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
const toggle = (id: ViewerPanelId, next = !panels[id]) => setPanels(current => ({ ...current, [id]: next }))
const loaded = documentName !== null
const groups = useMemo<SWViewerHeaderGroup[]>(() => [
{
id: "file", label: "파일", items: [
{ type: "button", id: "open", label: "파일 열기", icon: <FolderOpen />, title: "파일 열기", iconOnlyBelow: 560, onSelect: () => record("파일 선택창 (미연결)") },
{
type: "menu", id: "samples", label: "샘플", moreLabel: "샘플 도면", collapseBelow: 1180,
entries: SAMPLES.map(sample => ({ type: "item" as const, id: sample.id, label: sample.label, onSelect: () => setDocumentName(sample.name) })),
},
],
},
{
id: "space", label: "도면 공간", surface: "field", hidden: !loaded, items: [
{ type: "tabs", id: "space", label: "모델과 배치 선택", moreLabel: "도면 공간", collapseBelow: 1180, value: space, options: SPACES, onValueChange: setSpace },
],
},
{
id: "view", label: "화면 보기", surface: "field", items: [
{ type: "button", id: "fit", label: "전체 보기", icon: <Scan />, title: "전체 도면 맞춤 (F)", iconOnlyBelow: 560, disabled: !loaded },
{ type: "switch", id: "2.5d", label: "2.5D", checked: is25D, collapseBelow: 1180, onCheckedChange: set25D },
{ type: "switch", id: "dark", label: "어두운 배경", checked: dark, collapseBelow: 1180, onCheckedChange: setDark },
],
},
{
id: "panels", label: "패널", placement: "end", items: [
{ type: "toggle", id: "layers", label: "레이어", icon: <Layers />, title: "레이어 패널 열기 / 닫기", pressed: panels.layers, collapseBelow: 900, onPressedChange: next => toggle("layers", next) },
{ type: "toggle", id: "options", label: "설정", icon: <SlidersHorizontal />, title: "옵션 패널 열기 / 닫기", pressed: panels.options, collapseBelow: 900, onPressedChange: next => toggle("options", next) },
{ type: "toggle", id: "properties", label: "속성", icon: <List />, title: "속성 패널 열기 / 닫기", pressed: panels.properties, collapseBelow: 900, onPressedChange: next => toggle("properties", next) },
],
},
], [loaded, space, is25D, dark, panels])
/** Trailing slot: load state + the info button that opens the modal about popup (reference LicenseNotice). */
const trailing = (
<>
<SWViewerStatus state={loaded ? "ready" : "idle"} detail={loaded ? `${documentName} 열림` : "deselected"} />
<SWViewerHeaderButton iconOnly icon={<Info />} label="정보 및 라이선스" title="정보 및 라이선스" onClick={() => { setAboutOpen(true); record("about") }} />
</>
)
const onCommand = (command: SWViewerCommand) => record(`${command.itemId}${command.id !== command.itemId ? `/${command.id}` : ""}${command.value !== undefined ? `=${String(command.value)}` : ""}`)
return {
documentName, setDocumentName, space, is25D, set25D, dark, setDark, panels, toggle, options, setOptions, hidden, setHidden,
log, record, groups, trailing, onCommand, loaded, aboutOpen, setAboutOpen,
}
}
export type ViewerDemo = ReturnType<typeof useViewerDemo>
type PanelPlacement = Pick<SWViewerPanelProps, "strategy" | "defaultPosition" | "inset">
export function LayerPanel({ demo, ...placement }: { demo: ViewerDemo } & PanelPlacement) {
const [query, setQuery] = useState("")
const filtered = LAYERS.filter(layer => layer.name.toLowerCase().includes(query.trim().toLowerCase()))
const set = (names: string[]) => demo.setHidden(new Set(names))
return (
<SWViewerPanel title={`레이어 (${LAYERS.length}개)`} icon={<Layers />} open={demo.panels.layers} onClose={() => demo.toggle("layers", false)} {...placement}>
<SWViewerSection surface="plain" aria-label="레이어 목록">
<div className="sw-viewer-story-toolbar">
<SWViewerInput type="search" placeholder="레이어 검색" aria-label="레이어 검색" value={query} onChange={event => setQuery(event.currentTarget.value)} />
<SWViewerButton onClick={() => set([])}>전체 켜기</SWViewerButton>
<SWViewerButton onClick={() => set(LAYERS.map(layer => layer.name))}>전체 끄기</SWViewerButton>
</div>
{filtered.length === 0 ? <SWViewerHint>검색 결과가 없습니다 ("{query}")</SWViewerHint> : (
<ul className="sw-viewer-story-list">
{filtered.map(layer => {
const visible = !demo.hidden.has(layer.name)
return (
<li key={layer.name} data-visible={visible}>
<span className="sw-viewer-story-swatch" style={{ background: layer.color }} aria-hidden="true" />
<span className="sw-viewer-story-name" title={layer.name}>{layer.name}</span>
<SWViewerBadge>{layer.count}</SWViewerBadge>
<button type="button" className="sw-viewer-story-eye" aria-label={`${layer.name} ${visible ? "숨기기" : "표시하기"}`} title={visible ? "숨기기" : "표시하기"}
onClick={() => {
const next = new Set(demo.hidden)
if (visible) next.add(layer.name); else next.delete(layer.name)
demo.setHidden(next)
demo.record(`layer:${layer.name}=${!visible}`)
}}>{visible ? <Eye size={16} /> : <EyeOff size={16} />}</button>
</li>
)
})}
</ul>
)}
</SWViewerSection>
</SWViewerPanel>
)
}
function SliderRow({ label, value, min, max, step, disabled, onChange }: { label: string; value: number; min: number; max: number; step: number; disabled?: boolean; onChange: (value: number) => void }) {
return (
<SWViewerField label={label} layout="stacked" disabled={disabled}
below={<SWViewerSlider aria-label={label} value={value} min={min} max={max} step={step} onValueChange={onChange} />}>
{id => <SWViewerInput id={id} type="number" min={min} max={max} step={step} value={value} onChange={event => { const next = Number(event.currentTarget.value); if (Number.isFinite(next)) onChange(next) }} />}
</SWViewerField>
)
}
export function OptionsPanel({ demo, footer, ...placement }: { demo: ViewerDemo; footer?: boolean } & PanelPlacement) {
const { options, setOptions } = demo
const set = (patch: Partial<ViewerOptions>) => setOptions({ ...options, ...patch })
return (
<SWViewerPanel title="옵션" icon={<SlidersHorizontal />} open={demo.panels.options} onClose={() => demo.toggle("options", false)} width={340} height={520} {...placement}
headerActions={<SWViewerButton variant="ghost" aria-label="기본값으로" title="기본값으로" onClick={() => { setOptions(DEFAULT_OPTIONS); demo.record("options:reset") }}><RotateCcw size={16} /></SWViewerButton>}
footer={footer ? <><SWViewerButton onClick={() => demo.toggle("options", false)}>취소</SWViewerButton><SWViewerButton variant="primary" onClick={() => { demo.record("options:apply"); demo.toggle("options", false) }}>적용</SWViewerButton></> : undefined}>
<SWViewerSection title="선가중치">
<SWViewerField label="표시 사용">{id => <SWViewerSwitch id={id} checked={options.lineweightOn} onCheckedChange={lineweightOn => set({ lineweightOn })} />}</SWViewerField>
<SliderRow label="표시 배율 (px/mm)" value={options.lineweightScale} min={2} max={60} step={0.5} onChange={lineweightScale => set({ lineweightScale })} />
<SWViewerHint>이 도면에는 굵은 선이 없습니다 (0.50mm 였다면 {(0.5 * options.lineweightScale).toFixed(1)}px)</SWViewerHint>
<SWViewerHint>AutoCAD의 "선가중치 설정 → 표시 배율"과 동일합니다.</SWViewerHint>
</SWViewerSection>
<SWViewerSection title="표시">
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={options.grid} onCheckedChange={grid => set({ grid })} />}</SWViewerField>
<SliderRow label="줌 속도" value={options.zoomSpeed} min={0.2} max={3} step={0.1} onChange={zoomSpeed => set({ zoomSpeed })} />
</SWViewerSection>
<SWViewerSection title="2.5D">
<SWViewerField label="2.5D 보기">{id => <SWViewerSwitch id={id} checked={demo.is25D} onCheckedChange={demo.set25D} />}</SWViewerField>
<SliderRow label="Z 높이 배율" value={options.zScale} min={1} max={50} step={0.5} disabled={!demo.is25D} onChange={zScale => set({ zScale })} />
<SWViewerHint>좌클릭 드래그로 회전, 중간/우클릭 드래그로 이동합니다.</SWViewerHint>
</SWViewerSection>
</SWViewerPanel>
)
}
const ENTITY_ROWS: [string, string][] = [["유형", "LINE"], ["레이어", "CX-BORD-LIN1"], ["색상", "ByLayer"], ["선종류", "Continuous"]]
const GEOMETRY_ROWS: [string, string][] = [["시작 X", "18432.512"], ["시작 Y", "2210.004"], ["끝 X", "18992.040"], ["끝 Y", "2210.004"], ["길이", "559.528"]]
export function PropertiesPanel({ demo, selected = true, ...placement }: { demo: ViewerDemo; selected?: boolean } & PanelPlacement) {
const rows = (items: [string, string][]) => (
<dl className="sw-viewer-story-props">{items.map(([key, value]) => <div key={key}><dt>{key}</dt><dd title={value}>{value}</dd></div>)}</dl>
)
return (
<SWViewerPanel title="속성" icon={<Info />} open={demo.panels.properties} onClose={() => demo.toggle("properties", false)} {...placement}
headerActions={selected ? <SWViewerButton variant="ghost" aria-label="선택 확대" title="선택 확대" onClick={() => demo.record("zoomToSelection (미연결)")}><ZoomIn size={16} /></SWViewerButton> : undefined}>
{selected ? <>
<SWViewerSection surface="plain" title="일반 정보 (General)"><SWViewerBadge>LINE · h=2F1A</SWViewerBadge>{rows(ENTITY_ROWS)}</SWViewerSection>
<SWViewerSection surface="plain" title="기하 속성 (Geometry)">{rows(GEOMETRY_ROWS)}</SWViewerSection>
</> : (
<div className="sw-viewer-story-empty"><MousePointerClick size={24} aria-hidden="true" />도면에서 객체(엔티티)를 클릭하면<br />상세 속성이 표시됩니다</div>
)}
</SWViewerPanel>
)
}
+33
View File
@@ -0,0 +1,33 @@
/* Story-owned layout for SW/Viewer. Like a consuming program it arranges public components and its own list
content; it never styles hm-sw-* classes. Panel content colors read the public --hm-sw-viewer-* tokens. */
.sw-viewer-story-frame { position: relative; display: flex; flex-direction: column; overflow: hidden; background: #0a0a0b; }
.sw-viewer-story-canvas {
position: relative;
flex: 1;
min-height: 0;
background:
linear-gradient(rgba(38, 102, 81, 0.18) 1px, transparent 1px) 0 0 / 40px 40px,
linear-gradient(90deg, rgba(38, 102, 81, 0.18) 1px, transparent 1px) 0 0 / 40px 40px,
#0a0a0b;
}
.sw-viewer-story-room { padding-bottom: 320px; }
.sw-viewer-story-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 12px 4px; }
.sw-viewer-story-toolbar > input { flex: 1 0 100%; }
.sw-viewer-story-list { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
.sw-viewer-story-list li { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 3px; }
.sw-viewer-story-list li:hover { background: rgba(154, 146, 132, 0.25); }
.sw-viewer-story-list li[data-visible="false"] { opacity: 0.45; }
.sw-viewer-story-swatch { flex: 0 0 10px; width: 10px; height: 10px; border: 1px solid var(--hm-sw-viewer-panel-inner-border); border-radius: 9999px; }
.sw-viewer-story-name { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-family: ui-monospace, monospace; font-size: 12px; }
.sw-viewer-story-eye { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 4px; color: var(--hm-sw-viewer-panel-heading); }
.sw-viewer-story-eye:hover { background: rgba(154, 146, 132, 0.3); }
.sw-viewer-story-props { margin: 0; }
.sw-viewer-story-props > div { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--hm-sw-viewer-panel-inner-border); font-size: 12px; }
.sw-viewer-story-props dt { color: var(--hm-sw-viewer-panel-label); }
.sw-viewer-story-props dd { margin: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-family: ui-monospace, monospace; user-select: all; }
.sw-viewer-story-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px 0; text-align: center; font-size: 12px; color: var(--hm-sw-viewer-panel-label); }
.sw-viewer-story-steps { margin: 0; padding-left: 20px; font-size: 14px; }
.sw-viewer-story-select { height: 28px; padding: 0 8px; border: 1px solid var(--hm-sw-viewer-panel-inner-border); border-radius: 4px; background: var(--hm-sw-viewer-panel-field-background); font: inherit; font-size: 12px; }
.sw-viewer-story-reopen { position: absolute; left: 712px; top: 16px; padding: 6px 10px; border-radius: 4px; background: #f5f0e5; font-size: 12px; }
.sw-viewer-story-context { display: flex; align-items: center; justify-content: center; height: 160px; border: 1px dashed #9a9284; border-radius: 4px; background: #f5f0e5; color: #3e3523; font-size: 13px; }
.sw-viewer-story-mode { display: flex; gap: 16px; margin-bottom: 8px; font-size: 13px; }
+400
View File
@@ -0,0 +1,400 @@
import { useRef, useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
SWHeader, SWViewerButton, SWViewerField, SWViewerHeader, SWViewerHint, SWViewerInput, SWViewerMenu, SWViewerPanel, SWViewerSection, SWViewerSwitch,
type SWViewerHeaderGroup, type SWViewerMenuEntry,
} from "@hm-design/sw"
import "@hm-design/sw/styles.css"
import { Copy, Download, FileText, FolderOpen, Layers, Printer, Ruler, Scan, Search, Settings2 } from "lucide-react"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { LAYERS, LayerPanel, OptionsPanel, PropertiesPanel, SAMPLES, useViewerDemo, type ViewerDemo } from "./sw-viewer-fixture"
import "./sw-stories.css"
import "./sw-viewer-stories.css"
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
// SW viewer mode (2026-10-19): the reference DWG/DXF viewer top menu and popups as package components.
// Stories use the public @hm-design/sw API only, like a consuming program. No DWG/DXF engine: commands are logged.
const KICKER = "HM DESIGN SYSTEM / SW"
const meta = {
title: "SW/Viewer",
parameters: { layout: "fullscreen", controls: { disable: true } },
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
function Log({ lines }: { lines: string[] }) {
return <p className="sw-story-log" aria-live="polite" data-callback-log="">Command: {lines.length ? lines.join(" → ") : "없음"}</p>
}
/** A viewer screen: header on top, the drawing area below it (panels are placed inside this frame). */
function ViewerFrame({ width = 1440, height = 620, children, header }: { width?: number; height?: number; header: ReactNode; children?: ReactNode }) {
return (
<div className="sw-story-scroller">
{/* Fills the Story column up to `width`, so panels placed at the right edge stay visible. */}
<div className="sw-viewer-story-frame" style={{ width: "100%", maxWidth: width, height }} data-viewer-frame="">
{header}
<div className="sw-viewer-story-canvas" aria-label="도면 영역 (예시)">{children}</div>
</div>
</div>
)
}
function AboutPanel({ demo }: { demo: ViewerDemo }) {
return (
<SWViewerPanel modal strategy="absolute" title="모듈 구성 · 라이선스 고지" open={demo.aboutOpen} onClose={() => demo.setAboutOpen(false)} width={420} height="auto" resizable={false} collapsible={false}
footer={<SWViewerButton variant="primary" onClick={() => demo.setAboutOpen(false)}>확인</SWViewerButton>}>
<SWViewerSection title="처리 파이프라인">
<ol className="sw-viewer-story-steps">
<li>파일 로드 (.dwg → acadrust WASM, .dxf → dxf-parser)</li>
<li>CAD 파싱 결과 정규화 (레이어 / 스페이스 / 엔티티)</li>
<li>Three.js 씬 구성 및 렌더링</li>
</ol>
</SWViewerSection>
<SWViewerHint>Storybook 예시: 링크와 실제 고지문은 프로그램이 제공합니다.</SWViewerHint>
</SWViewerPanel>
)
}
function ViewerScreen({ width = 1440, initialDocument = SAMPLES[1].name as string | null, open = [] as ("layers" | "options" | "properties")[] }) {
const demo = useViewerDemo({ initialDocument, initialPanels: open })
return (
<>
<Log lines={demo.log} />
<ViewerFrame width={width} header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<LayerPanel demo={demo} strategy="absolute" />
<OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} />
<PropertiesPanel demo={demo} strategy="absolute" inset={{ side: 24, top: 488 }} />
<AboutPanel demo={demo} />
</ViewerFrame>
</>
)
}
export const ViewerHeader: Story = {
name: "Viewer Header",
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — SWViewerHeader"
description="참고 DWG/DXF 뷰어의 상단 메뉴를 패키지 컴포넌트로 옮긴 뷰어 모드 전용 Header입니다. 로고 면 → 파일명 면 → 객체 수정 면(메뉴 그룹) → 오른쪽 패널·상태·정보 영역이며, 메뉴는 groups 데이터로 구성합니다. 레이어·설정·속성 토글은 팝업을 열고, 정보 버튼은 모달 팝업을 엽니다." />
<StorySection title="기본형 (1440px, 도면 열림)" description="샘플▾ Dropdown, Model/Layout 탭, 2.5D·어두운 배경 스위치, 패널 토글을 직접 눌러 보세요. Command 줄에 onCommand 결과가 표시됩니다.">
<ViewerScreen />
</StorySection>
</StoryPage>
),
}
export const MenuGroups: Story = {
name: "Menu Groups",
render: () => {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
const pick = (ids: string[]): SWViewerHeaderGroup[] => demo.groups.map(group => ({ ...group, hidden: group.hidden || !ids.includes(group.id) }))
const rows: { title: string; ids: string[] }[] = [
{ title: "파일 그룹만 (파일 열기 · 샘플)", ids: ["file"] },
{ title: "도면 공간 (field 면 · tabs)", ids: ["space"] },
{ title: "화면 보기 (field 면 · button + switch)", ids: ["view"] },
{ title: "오른쪽 영역 (placement=end 패널 토글 + trailing 상태 · 정보)", ids: ["panels"] },
{ title: "전체", ids: demo.groups.map(group => group.id) },
]
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 메뉴 그룹" description="그룹은 groups 배열의 순서대로 놓입니다. surface=&quot;field&quot;는 사용자 확정 속성 입력 면(어두운 inset), placement=&quot;end&quot;는 오른쪽 끝 영역입니다. hidden으로 그룹·항목을 제거합니다." />
<Log lines={demo.log} />
<div className="sw-story-stack">
{rows.map(row => (
<StorySection key={row.title} title={row.title}>
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWViewerHeader documentTitle={demo.documentName} groups={pick(row.ids)} trailing={row.ids.includes("panels") ? demo.trailing : undefined} onCommand={demo.onCommand} /></div></div>
</StorySection>
))}
</div>
</StoryPage>
)
},
}
export const States: Story = {
name: "Item States",
render: () => {
const [log, setLog] = useState<string[]>([])
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
const groups: SWViewerHeaderGroup[] = [
{
id: "states", label: "상태 예시", items: [
{ type: "button", id: "default", label: "기본", icon: <FolderOpen /> },
{ type: "toggle", id: "active", label: "Active (on)", icon: <Layers />, pressed: true },
{ type: "toggle", id: "inactive", label: "Toggle (off)", icon: <Layers />, pressed: false },
{ type: "button", id: "disabled", label: "Disabled", icon: <Scan />, disabled: true },
{ type: "menu", id: "menu", label: "Dropdown", entries: [{ type: "item", id: "a", label: "항목 A" }, { type: "item", id: "b", label: "항목 B (disabled)", disabled: true }] },
{ type: "menu", id: "menu-disabled", label: "Dropdown (disabled)", disabled: true, entries: [] },
],
},
{
id: "field", label: "field 면", surface: "field", items: [
{ type: "tabs", id: "tabs", label: "탭", value: "model", options: [{ value: "model", label: "Model", count: 17377 }, { value: "layout", label: "Layout", count: 237 }, { value: "off", label: "Disabled", disabled: true }] },
{ type: "switch", id: "on", label: "Switch on", checked: true },
{ type: "switch", id: "off", label: "Switch off", checked: false },
{ type: "switch", id: "sw-disabled", label: "Disabled", checked: false, disabled: true },
],
},
]
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — Active / Hover / Disabled"
description="Hover: #266651 면, Active·열림·선택 탭: 글자와 아이콘 #FFC600(선택 박스 없음), Disabled: 60% 불투명, Focus: 2px #FFC600 외곽선(키보드). Hover와 Focus는 마우스·Tab으로 확인합니다." />
<Log lines={log} />
<div className="sw-story-scroller"><div style={{ width: 1440 }}>
<SWViewerHeader documentTitle="상태 확인용.dwg" groups={groups} onCommand={command => record(`${command.id}${command.value !== undefined ? `=${String(command.value)}` : ""}`)} />
</div></div>
</StoryPage>
)
},
}
const MENU_ENTRIES: SWViewerMenuEntry[] = [
{ type: "label", id: "l-file", label: "파일" },
{ type: "item", id: "open", label: "파일 열기", icon: <FolderOpen size={16} />, shortcut: "Ctrl+O" },
{
type: "submenu", id: "recent", label: "최근 도면", icon: <FileText size={16} />, entries: [
...SAMPLES.map(sample => ({ type: "item" as const, id: `recent:${sample.id}`, label: sample.name })),
{ type: "separator", id: "s-recent" },
{ type: "submenu", id: "recent-more", label: "보관 도면", entries: [{ type: "item", id: "archive:2025", label: "2025-평면도.dwg" }, { type: "item", id: "archive:2024", label: "2024-종단면도.dwg" }] },
],
},
{ type: "item", id: "export", label: "PNG로 내보내기", icon: <Download size={16} /> },
{ type: "item", id: "print", label: "인쇄 (미연결)", icon: <Printer size={16} />, disabled: true },
{ type: "separator", id: "s1" },
{ type: "label", id: "l-view", label: "화면 보기" },
{ type: "checkbox", id: "grid", label: "그리드", checked: true },
{ type: "checkbox", id: "lineweight", label: "선가중치 표시", checked: false },
{ type: "separator", id: "s2" },
{ type: "label", id: "l-unit", label: "단위" },
{ type: "radio", id: "unit:mm", label: "mm", checked: true },
{ type: "radio", id: "unit:m", label: "m", checked: false },
]
export const Dropdowns: Story = {
name: "Header + Dropdown",
render: () => {
const [entries, setEntries] = useState(MENU_ENTRIES)
const [log, setLog] = useState<string[]>([])
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
const apply = (entry: SWViewerMenuEntry) => setEntries(list => list.map(item => {
if (entry.type === "checkbox" && item.id === entry.id && item.type === "checkbox") return { ...item, checked: !item.checked }
if (entry.type === "radio" && item.type === "radio" && item.id.startsWith("unit:")) return { ...item, checked: item.id === entry.id }
return item
}))
const groups: SWViewerHeaderGroup[] = [
{ id: "file", label: "파일", items: [{ type: "menu", id: "file", label: "도면", icon: <FileText />, entries: entries.map(entry => ({ ...entry, ...(entry.type === "checkbox" || entry.type === "radio" ? { keepOpen: false } : {}) })) }] },
{ id: "tools", label: "도구", items: [
{ type: "menu", id: "measure", label: "측정", icon: <Ruler />, entries: [{ type: "item", id: "distance", label: "거리" }, { type: "item", id: "area", label: "면적" }, { type: "submenu", id: "angle", label: "각도", entries: [{ type: "item", id: "angle:2", label: "두 선 사이" }, { type: "item", id: "angle:3", label: "세 점" }] }] },
{ type: "menu", id: "align-end", label: "오른쪽 정렬 메뉴", menuAlign: "end", menuWidth: 220, entries: [{ type: "item", id: "copy", label: "좌표 복사", icon: <Copy size={16} /> }, { type: "item", id: "find", label: "객체 찾기", icon: <Search size={16} /> }] },
] },
]
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 상단 메뉴 + Dropdown"
description="type=&quot;menu&quot; 항목의 entries가 Dropdown이 됩니다. 일반 항목·체크·라디오·라벨·구분선·다단계 서브메뉴(submenu)를 지원합니다. 키보드: Enter/↓ 열기, ↑↓ 이동, → 서브메뉴, ← 닫기, Esc 닫고 버튼으로 복귀, 바깥 클릭 닫기." />
<Log lines={log} />
<div className="sw-story-scroller sw-viewer-story-room"><div style={{ width: 1440 }}>
<SWViewerHeader documentTitle="C0060202-001-평면및종단면도.dwg" groups={groups}
onCommand={command => { const entry = findEntry(entries, command.id); if (entry) apply(entry); record(`${command.itemId}/${command.id}${command.value !== undefined ? `=${String(command.value)}` : ""}`) }} />
</div></div>
<StorySection title="SWViewerMenu 단독 (Context Menu)" description="anchorPoint로 지정 위치에 엽니다. 아래 영역을 마우스 오른쪽 버튼으로 클릭하세요.">
<ContextMenuDemo onSelect={line => record(line)} />
</StorySection>
</StoryPage>
)
},
}
function findEntry(entries: SWViewerMenuEntry[], id: string): SWViewerMenuEntry | undefined {
for (const entry of entries) {
if (entry.id === id) return entry
if (entry.type === "submenu") { const found = findEntry(entry.entries, id); if (found) return found }
}
return undefined
}
function ContextMenuDemo({ onSelect }: { onSelect: (line: string) => void }) {
const [point, setPoint] = useState<{ x: number; y: number } | null>(null)
return (
<div className="sw-viewer-story-context" onContextMenu={event => { event.preventDefault(); setPoint({ x: event.clientX, y: event.clientY }) }} data-context-area="">
도면 영역 (오른쪽 클릭)
<SWViewerMenu open={point !== null} onOpenChange={open => { if (!open) setPoint(null) }} anchorPoint={point ?? undefined} label="도면 메뉴" initialFocus="menu"
entries={[
{ type: "item", id: "fit", label: "전체 보기", shortcut: "F" },
{ type: "item", id: "zoom-selection", label: "선택 확대" },
{ type: "separator", id: "s" },
{ type: "submenu", id: "layer", label: "레이어", entries: LAYERS.slice(0, 4).map(layer => ({ type: "item" as const, id: `hide:${layer.name}`, label: `${layer.name} 숨기기` })) },
{ type: "item", id: "props", label: "속성 보기" },
]}
onEntrySelect={entry => onSelect(`context/${entry.id}`)} />
</div>
)
}
export const SettingsPanel: Story = {
name: "Settings Popup",
render: () => {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["options"] })
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 설정창 (SWViewerPanel)"
description="설정 토글로 여는 옵션 팝업입니다. 제목 막대를 끌어 이동, 오른쪽 아래 모서리로 크기 조절, 제목 더블클릭·접기 버튼으로 접기, 닫기·Esc(팝업 안 Focus)로 닫습니다. 하단 버튼 영역(footer)을 포함한 예시입니다." />
<Log lines={demo.log} />
<ViewerFrame header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<OptionsPanel demo={demo} strategy="absolute" footer />
</ViewerFrame>
</StoryPage>
)
},
}
export const ContentVariants: Story = {
name: "Popup Content Variants",
render: () => {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["layers", "properties"] })
const [inputOpen, setInputOpen] = useState(true)
const [scale, setScale] = useState("1:500")
const [snap, setSnap] = useState(true)
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 같은 프레임, 다른 내부 콘텐츠"
description="SWViewerPanel의 외형(제목 막대·닫기·테두리·스크롤 본문)은 같고 children / footer만 다릅니다: 검색 + 목록(레이어), 읽기 전용 정의 목록(속성), 입력 폼 + 하단 버튼(입력 팝업)." />
<Log lines={demo.log} />
<ViewerFrame height={640} header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<LayerPanel demo={demo} strategy="absolute" defaultPosition={{ left: 24, top: 16 }} />
<PropertiesPanel demo={demo} strategy="absolute" defaultPosition={{ left: 368, top: 16 }} />
<SWViewerPanel strategy="absolute" defaultPosition={{ left: 712, top: 16 }} title="축척 지정" icon={<Settings2 />} open={inputOpen} onClose={() => setInputOpen(false)} width={300} height="auto" resizable={false}
footer={<><SWViewerButton onClick={() => setInputOpen(false)}>취소</SWViewerButton><SWViewerButton variant="primary" onClick={() => { demo.record(`scale=${scale}, snap=${snap}`); setInputOpen(false) }}>적용</SWViewerButton></>}>
<SWViewerSection title="입력">
<SWViewerField label="축척">{id => <SWViewerInput id={id} value={scale} onChange={event => setScale(event.currentTarget.value)} style={{ width: 120 }} />}</SWViewerField>
<SWViewerField label="스냅 사용">{id => <SWViewerSwitch id={id} checked={snap} onCheckedChange={setSnap} />}</SWViewerField>
<SWViewerField label="단위">{id => <select id={id} className="sw-viewer-story-select" defaultValue="mm"><option value="mm">mm</option><option value="m">m</option></select>}</SWViewerField>
</SWViewerSection>
</SWViewerPanel>
{!inputOpen && <button type="button" className="sw-viewer-story-reopen" onClick={() => setInputOpen(true)}>축척 지정 다시 열기</button>}
</ViewerFrame>
</StoryPage>
)
},
}
export const VariableSize: Story = {
name: "Variable Size Popups",
render: () => {
const sizes: { id: string; title: string; width: number; height: number | "auto"; left: number; note: string }[] = [
{ id: "s", title: "250 × auto (최소 폭)", width: 250, height: "auto", left: 16, note: "내용 높이" },
{ id: "m", title: "320 × 420 (기본)", width: 320, height: 420, left: 282, note: "본문 스크롤" },
{ id: "l", title: "480 × 300 (가로형)", width: 480, height: 300, left: 618, note: "resize: both" },
]
const [open, setOpen] = useState<Record<string, boolean>>({ s: true, m: true, l: true })
const lines = Array.from({ length: 24 }, (_, index) => `행 ${index + 1}`)
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 가변 크기 팝업"
description="width / height(&quot;auto&quot; 가능) / minWidth / minHeight로 메뉴별 크기를 정합니다. 최대 90vw × 90vh, 오른쪽 아래 모서리로 크기 조절, 화면(또는 strategy=&quot;absolute&quot;의 부모) 경계 안에서만 이동합니다." />
<ViewerFrame height={520} header={<SWViewerHeader documentTitle="크기 비교.dwg" groups={[]} />}>
{sizes.map(size => (
<SWViewerPanel key={size.id} strategy="absolute" defaultPosition={{ left: size.left, top: 16 }} title={size.title} open={open[size.id]} onClose={() => setOpen({ ...open, [size.id]: false })} width={size.width} height={size.height}>
<SWViewerHint>{size.note}</SWViewerHint>
<SWViewerSection surface="plain">{(size.height === "auto" ? lines.slice(0, 3) : lines).map(line => <div key={line}>{line}</div>)}</SWViewerSection>
</SWViewerPanel>
))}
</ViewerFrame>
</StoryPage>
)
},
}
export const Responsive: Story = {
name: "Responsive Widths",
render: () => {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 화면 너비"
description="참고 뷰어와 같은 기준입니다. Header 폭 1180px 미만: 샘플·도면 공간·2.5D·어두운 배경이 더보기(⋯)로 이동, 900px 미만: 패널 토글도 이동, 560px 미만: 파일 열기·전체 보기는 아이콘만, 상태 글자 숨김. 가로 스크롤은 만들지 않습니다." />
<Log lines={demo.log} />
<div className="sw-story-stack">
{[1440, 1100, 860, 520].map(width => (
<StorySection key={width} title={`${width}px`}>
<div className="sw-story-scroller"><div style={{ width }} data-width={width}><SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div></div>
</StorySection>
))}
</div>
</StoryPage>
)
},
}
function ModeSwitchDemo() {
const [mode, setMode] = useState<"normal" | "viewer">("normal")
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
const headerRef = useRef<HTMLElement>(null)
return (
<>
<div className="sw-viewer-story-mode" role="radiogroup" aria-label="모드">
{(["normal", "viewer"] as const).map(value => (
<label key={value}><input type="radio" name="sw-mode" checked={mode === value} onChange={() => setMode(value)} /> {value === "normal" ? "일반 SW 모드 (SWHeader)" : "뷰어 모드 (SWViewerHeader)"}</label>
))}
</div>
<Log lines={demo.log} />
<ViewerFrame height={480} header={mode === "normal"
? <SWHeader ref={headerRef} documentTitle={demo.documentName ?? ""} logoSrc={placeholderLogo} logoAlt="임시 로고 (뷰어 모드로)" onHomeClick={() => setMode("viewer")} />
: <SWViewerHeader ref={headerRef} documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} logoSrc={placeholderLogo} logoAlt="임시 로고 (일반 모드로)" onHomeClick={() => setMode("normal")} />}>
{mode === "viewer" && <><LayerPanel demo={demo} strategy="absolute" /><OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} /></>}
</ViewerFrame>
</>
)
}
export const CompareWithSWHeader: Story = {
name: "SWHeader vs Viewer Header",
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 일반 SW Header와 비교"
description="일반 SW 모드는 기존 SWHeader(49px, Figma 26:8944)를 그대로 쓰고, 뷰어 모드만 SWViewerHeader(52px, 참고 뷰어)를 씁니다. 두 Header는 파일명·객체 수정·속성 면 토큰을 공유합니다. 아래 전환 예시는 화면 상태(파일명·패널 상태)를 유지한 채 Header만 바꿉니다. 예시에서는 SWHeader 로고(임시)를 누르면 뷰어 모드, 뷰어 로고(임시)를 누르면 일반 모드로 돌아갑니다." />
<StorySection title="나란히 비교 (1440px)">
<div className="sw-story-stack">
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWHeader documentTitle="C0060202-001-평면및종단면도" /></div></div>
<div className="sw-story-scroller"><div style={{ width: 1440 }}><ViewerOnly /></div></div>
</div>
</StorySection>
<StorySection title="일반 ↔ 뷰어 모드 전환" description="Header 교체만 보여 주는 UI 예시입니다. 실제 모드 진입·종료와 데이터 처리는 SW 프로그램 담당입니다.">
<ModeSwitchDemo />
</StorySection>
</StoryPage>
),
}
export const Logo: Story = {
name: "Logo",
render: () => {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
const rows: { title: string; description: string; props: { logoSrc?: string; logo?: ReactNode; logoAlt?: string } }[] = [
{ title: "로고 없음 (기본)", description: "logo / logoSrc를 넘기지 않으면 아무것도 그리지 않습니다(HM 문자·기본 아이콘 없음). 로고 면은 사선 끝(24px)만 남아 넓은 빈 공간이 생기지 않습니다.", props: {} },
{ title: "logoSrc (이미지 URL)", description: "SWHeader와 같은 Prop. 로고 면 72px(로고가 더 넓으면 그만큼), 높이 최대 24px.", props: { logoSrc: placeholderLogo, logoAlt: "임시 로고" } },
{ title: "logo (요소)", description: "logo가 logoSrc보다 우선합니다.", props: { logo: <svg viewBox="0 0 32 17" width="32" height="17" aria-hidden="true"><rect x="0.5" y="0.5" width="31" height="16" rx="3" fill="none" stroke="#fff" /><text x="16" y="12.5" fill="#fff" fontSize="10" fontWeight="700" textAnchor="middle">LOGO</text></svg>, logoAlt: "예시 로고" } },
]
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 로고" description="로고는 각 프로그램이 정합니다. 제공된 경우에만 표시하고, Header 높이(52px)와 메뉴 정렬은 그대로입니다. SWHeader의 로고 동작은 바뀌지 않습니다." />
<div className="sw-story-stack">
{rows.map(row => (
<StorySection key={row.title} title={row.title} description={row.description}>
<div className="sw-story-scroller"><div style={{ width: 1440 }} data-logo-case={row.title}>
<SWViewerHeader {...row.props} documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
</div></div>
</StorySection>
))}
</div>
</StoryPage>
)
},
}
function ViewerOnly() {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
return <SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
}